diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 1ce007dc228105849d89d4fc720b9a8bf729ee1b..0f9a258de99ba559d280fc5ace74a3f111a9e30e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -29,9 +29,9 @@ variables: image: $CI_REGISTRY_IMAGE stages: + - setup - code_style - linting - - setup - test - deploy @@ -53,26 +53,27 @@ pylint: - make lint allow_failure: true +mypy: + tags: [ docker ] + stage: linting + script: + - pip install mypy types-PyYAML types-jsonschema types-requests types-setuptools types-lxml types-python-dateutil pytest + - make 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 - - pip install nose pytest pytest-cov python-dateutil jsonschema>=4.4.0 + - pip install pynose pytest pytest-cov jsonschema>=4.4.0 setuptools - 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: @@ -100,6 +101,28 @@ unittest_py3.11: image: python:3.11 script: *python_test_script +unittest_py3.12: + tags: [ docker ] + stage: test + needs: [ ] + image: python:3.12 + script: *python_test_script + +unittest_py3.13: + allow_failure: true + tags: [ docker ] + stage: test + needs: [ ] + image: python:3.13-rc + script: + # TODO: Replace by '*python_test_script' as soon as 3.13 has been officially released. + # Python docker has problems with tox and pip so use plain pytest here + - apt update && apt install -y cargo + - touch ~/.pylinkahead.ini + - pip install pynose pytest pytest-cov jsonschema>=4.4.0 setuptools + - pip install . + - python -m pytest unittests + # Trigger building of server image and integration tests trigger_build: stage: deploy @@ -126,6 +149,7 @@ build-testenv: stage: setup only: - schedules + - web script: - cd unittests/docker - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY diff --git a/CHANGELOG.md b/CHANGELOG.md index f331067707f361da9430a24cce58d18808e4ac02..958933ba413af1b526e49b0fcacdaf2ffd7cf344 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,41 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.15.0] - 2024-07-09 ## + +### Added ### + +* Support for Python 3.12 +* The `linkahead` module now opts into type checking and supports mypy. +* [#112](https://gitlab.com/linkahead/linkahead-pylib/-/issues/112) + `Entity.update_acl` now supports optional `**kwargs` that are passed to the + `Entity.update` method that is called internally, thus allowing, e.g., + updating the ACL despite possible naming collisions with `unique=False`. +* a `role` argument for `get_entity_by_name` and `get_entity_by_id` + +### Changed ### + +* Using environment variable PYLINKAHEADINI instead of PYCAOSDBINI. + +### Removed ### + +* Support for Python 3.7 + +### Fixed ### + +* [#104](https://gitlab.com/linkahead/linkahead-pylib/-/issues/104) Selecting + parts of a `Container` with a `slice` used to return a `list` object instead + of a `Container`, removing all useful methods of the `Container` class. This + has been fixed and using a `slice` such as `[:2]` now returns a new + `Container`. +* [#120](https://gitlab.com/linkahead/linkahead-pylib/-/issues/120) Unwanted + subproperties in reference properties. + +### Documentation ### + +* Added documentation and a tutorial example for the usage of the `page_length` + argument of `execute_query`. + ## [0.14.0] - 2024-02-20 ### Added ### diff --git a/CITATION.cff b/CITATION.cff index cbcb570b27b7cd71f50645614222302bccc34805..148cccb1804f7ae254224074dfef408e014f5438 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -20,6 +20,6 @@ authors: given-names: Stefan orcid: https://orcid.org/0000-0001-7214-8125 title: CaosDB - Pylib -version: 0.14.0 +version: 0.15.0 doi: 10.3390/data4020083 -date-released: 2024-02-20 +date-released: 2024-07-09 diff --git a/Makefile b/Makefile index d15c830d8e4cf6e4bc0b519b9fa5b8cb5f224043..21ea40ac8a6eb34032aba75c089e278fa354a6f5 100644 --- a/Makefile +++ b/Makefile @@ -32,7 +32,7 @@ doc: install: @echo "Not implemented yet, use pip for installation." -check: style lint +check: style lint mypy .PHONY: check style: @@ -43,6 +43,10 @@ lint: pylint --unsafe-load-any-extension=y -d all -e E,F src/linkahead/common .PHONY: lint +mypy: + mypy src/linkahead/common unittests +.PHONY: mypy + unittest: tox -r .PHONY: unittest diff --git a/examples/pycaosdb_example.py b/examples/pycaosdb_example.py deleted file mode 100755 index 9a3d766791ca7a6fd111d734d08ac4cf3b85b75a..0000000000000000000000000000000000000000 --- a/examples/pycaosdb_example.py +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env python3 -"""A small example to get started with caosdb-pylib. - -Make sure that a `pylinkahead.ini` is readable at one of the expected locations. -""" - -import random - -import caosdb as db - - -def reconfigure_connection(): - """Change the current connection configuration.""" - conf = db.configuration.get_config() - conf.set("Connection", "url", "https://demo.indiscale.com") - db.configure_connection() - - -def main(): - """Shows a few examples how to use the CaosDB library.""" - conf = dict(db.configuration.get_config().items("Connection")) - print("##### Config:\n{}\n".format(conf)) - - if conf["cacert"] == "/path/to/caosdb.ca.pem": - print("Very likely, the path the the TLS certificate is not correct, " - "please fix it.") - - # Query the server, the result is a Container - result = db.Query("FIND Record").execute() - print("##### First query result:\n{}\n".format(result[0])) - - # Retrieve a random Record - rec_id = random.choice([rec.id for rec in result]) - rec = db.Record(id=rec_id).retrieve() - print("##### Randomly retrieved Record:\n{}\n".format(rec)) - - -if __name__ == "__main__": - main() diff --git a/examples/pylinkahead.ini b/examples/pylinkahead.ini index f37e24e0e5b754ec58a07b034ba2755096f0b441..84d1eb8526201c817d6614e7eb74f35a932c5d78 100644 --- a/examples/pylinkahead.ini +++ b/examples/pylinkahead.ini @@ -1,7 +1,7 @@ # To be found be the caosdb package, the INI file must be located either in # - $CWD/pylinkahead.ini # - $HOME/.pylinkahead.ini -# - the location given in the env variable PYCAOSDBINI +# - the location given in the env variable PYLINKAHEADINI [Connection] # URL of the CaosDB server diff --git a/examples/pylinkahead_example.py b/examples/pylinkahead_example.py new file mode 100755 index 0000000000000000000000000000000000000000..6effd57c73669c0aaa0284cb28105ae349dac608 --- /dev/null +++ b/examples/pylinkahead_example.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +# This file is a part of the LinkAhead Project. +# +# Copyright (C) 2024 IndiScale GmbH <info@indiscale.com> +# Copyright (C) 2024 Daniel Hornung <d.hornung@indiscale.com> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. + +"""A small example to get started with linkahead-pylib. + +Make sure that a `pylinkahead.ini` is readable at one of the expected locations. +""" + +import random + +import caosdb as db + + +def reconfigure_connection(): + """Change the current connection configuration.""" + conf = db.configuration.get_config() + conf.set("Connection", "url", "https://demo.indiscale.com") + db.configure_connection() + + +def main(): + """Shows a few examples how to use the LinkAhead library.""" + conf = dict(db.configuration.get_config().items("Connection")) + print("##### Config:\n{}\n".format(conf)) + + if conf["cacert"] == "/path/to/caosdb.ca.pem": + print("Very likely, the path to the TLS certificate is not correct, " + "please fix it.") + + # Query the server, the result is a Container + result = db.Query("FIND Record").execute() + print("##### First query result:\n{}\n".format(result[0])) + + # Retrieve a random Record + rec_id = random.choice([rec.id for rec in result]) + rec = db.Record(id=rec_id).retrieve() + print("##### Randomly retrieved Record:\n{}\n".format(rec)) + + +if __name__ == "__main__": + main() diff --git a/setup.cfg b/setup.cfg index c46089e4d24843d7d4cc4f83dad6ec1351e4cc3f..b7f1a7395a53c32c2e43c72db0b359e4a7aaadb6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,4 +1,11 @@ [aliases] test=pytest + [pycodestyle] ignore=E501,E121,E123,E126,E226,E24,E704,W503,W504 + +[mypy] +ignore_missing_imports = True + +# [mypy-linkahead.*] +# check_untyped_defs = True \ No newline at end of file diff --git a/setup.py b/setup.py index a77b1095e2c7e3f678a85f7aff49d0a213bb9381..1a8a754219ddf84c0b9e088a13fd0283fa63a00f 100755 --- a/setup.py +++ b/setup.py @@ -48,7 +48,7 @@ from setuptools import find_packages, setup ISRELEASED = True MAJOR = 0 -MINOR = 14 +MINOR = 15 MICRO = 0 # Do not tag as pre-release until this commit # https://github.com/pypa/packaging/pull/515 @@ -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", @@ -193,7 +193,7 @@ def setup_package(): tests_require=["pytest", "pytest-cov", "coverage>=4.4.2", "jsonschema>=4.4.0"], package_data={ - 'linkahead': ['cert/indiscale.ca.crt', 'schema-pycaosdb-ini.yml'], + 'linkahead': ['py.typed', 'cert/indiscale.ca.crt', 'schema-pycaosdb-ini.yml'], }, scripts=[ "src/linkahead/utils/caosdb_admin.py", diff --git a/src/doc/conf.py b/src/doc/conf.py index 771a1f048f79b779526c56b2fd56712761021757..4a528d9e287daeeacd50e94cfba4e479b0430212 100644 --- a/src/doc/conf.py +++ b/src/doc/conf.py @@ -29,10 +29,10 @@ copyright = '2023, IndiScale GmbH' author = 'Daniel Hornung' # The short X.Y version -version = '0.14.0' +version = '0.15.0' # The full version, including alpha/beta/rc tags # release = '0.5.2-rc2' -release = '0.14.0' +release = '0.15.0' # -- General configuration --------------------------------------------------- diff --git a/src/doc/configuration.md b/src/doc/configuration.md index 54ae251b9db9ef000545e701406b979aa58043f8..427551db4e1e97d7ca5f9820df6d5916e3496020 100644 --- a/src/doc/configuration.md +++ b/src/doc/configuration.md @@ -1,6 +1,6 @@ # Configuration of PyLinkAhead # The behavior of PyLinkAhead is defined via a configuration that is provided using configuration files. -PyLinkAhead tries to read from the inifile specified in the environment variable `PYCAOSDBINI` or +PyLinkAhead tries to read from the inifile specified in the environment variable `PYLINKAHEADINI` or alternatively in `~/.pylinkahead.ini` upon import. After that, the ini file `pylinkahead.ini` in the current working directory will be read additionally, if it exists. 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/__init__.py b/src/linkahead/__init__.py index 3a8c5ba39c88deaa5dc945135e3828945fd39d58..cd54f8f4e05326579521fbbf226f027d32fa616e 100644 --- a/src/linkahead/__init__.py +++ b/src/linkahead/__init__.py @@ -24,7 +24,7 @@ """LinkAhead Python bindings. -Tries to read from the inifile specified in the environment variable `PYCAOSDBINI` or +Tries to read from the inifile specified in the environment variable `PYLINKAHEADINI` or alternatively in `~/.pylinkahead.ini` upon import. After that, the ini file `pylinkahead.ini` in the current working directory will be read additionally, if it exists. diff --git a/src/linkahead/apiutils.py b/src/linkahead/apiutils.py index e2ed0facea84e6056b1ac877b4417ce6ad8ef504..4ae8edd16f1fdc00eb7ba2c17661eea6e114885e 100644 --- a/src/linkahead/apiutils.py +++ b/src/linkahead/apiutils.py @@ -6,6 +6,8 @@ # Max-Planck-Institute for Dynamics and Self-Organization Göttingen # Copyright (C) 2020 Timm Fitschen <t.fitschen@indiscale.com> # Copyright (C) 2020-2022 IndiScale GmbH <info@indiscale.com> +# Copyright (C) 2024 Indiscale GmbH <info@indiscale.com> +# Copyright (C) 2024 Joscha Schmiedt <joscha@schmiedt.dev> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -25,11 +27,11 @@ """API-Utils: Some simplified functions for generation of records etc. """ - +from __future__ import annotations import logging import warnings from collections.abc import Iterable -from typing import Any, Dict, List +from typing import Any, Union, Optional from .common.datatype import is_reference from .common.models import (SPECIAL_ATTRIBUTES, Container, Entity, File, @@ -47,12 +49,14 @@ class EntityMergeConflictError(LinkAheadException): """ -def new_record(record_type, name=None, description=None, - tempid=None, insert=False, **kwargs): +def new_record(record_type: Union[str], + name: Optional[str] = None, + description: Optional[str] = None, + tempid: Optional[int] = None, + insert: bool = False, **kwargs) -> Record: """Function to simplify the creation of Records. record_type: The name of the RecordType to use for this record. - (ids should also work.) name: Name of the new Record. kwargs: Key-value-pairs for the properties of this Record. @@ -92,19 +96,19 @@ def new_record(record_type, name=None, description=None, return r -def id_query(ids): +def id_query(ids: list[int]) -> Container: warnings.warn("Please use 'create_id_query', which only creates" "the string.", DeprecationWarning) - return execute_query(create_id_query(ids)) + return execute_query(create_id_query(ids)) # type: ignore -def create_id_query(ids): +def create_id_query(ids: list[int]) -> str: return "FIND ENTITY WITH " + " OR ".join( ["ID={}".format(id) for id in ids]) -def get_type_of_entity_with(id_): +def get_type_of_entity_with(id_: int): objs = retrieve_entities_with_ids([id_]) if len(objs) == 0: @@ -127,11 +131,11 @@ def get_type_of_entity_with(id_): return Entity -def retrieve_entity_with_id(eid): +def retrieve_entity_with_id(eid: int): return execute_query("FIND ENTITY WITH ID={}".format(eid), unique=True) -def retrieve_entities_with_ids(entities): +def retrieve_entities_with_ids(entities: list) -> Container: collection = Container() step = 20 @@ -175,7 +179,10 @@ 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(old_entity: Entity, + new_entity: Entity, + compare_referenced_records: bool = False + ) -> tuple[dict[str, Any], dict[str, Any]]: """Compare two entites. Return a tuple of dictionaries, the first index belongs to additional information for old @@ -209,8 +216,8 @@ def compare_entities(old_entity: Entity, new_entity: Entity, compare_referenced_ identical records are stored in different objects. Default is False. """ - olddiff: Dict[str, Any] = {"properties": {}, "parents": []} - newdiff: Dict[str, Any] = {"properties": {}, "parents": []} + olddiff: dict[str, Any] = {"properties": {}, "parents": []} + newdiff: dict[str, Any] = {"properties": {}, "parents": []} if old_entity is new_entity: return (olddiff, newdiff) @@ -290,12 +297,15 @@ def compare_entities(old_entity: Entity, new_entity: Entity, compare_referenced_ 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]): + 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 + # 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)]) + [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 @@ -328,7 +338,8 @@ def compare_entities(old_entity: Entity, new_entity: Entity, compare_referenced_ return (olddiff, newdiff) -def empty_diff(old_entity: Entity, new_entity: Entity, compare_referenced_records: bool = False): +def empty_diff(old_entity: Entity, new_entity: Entity, + compare_referenced_records: bool = False) -> bool: """Check whether the `compare_entities` found any differences between old_entity and new_entity. @@ -357,8 +368,12 @@ def empty_diff(old_entity: Entity, new_entity: Entity, compare_referenced_record return True -def merge_entities(entity_a: Entity, entity_b: Entity, merge_references_with_empty_diffs=True, - force=False, merge_id_with_resolved_entity: bool = False): +def merge_entities(entity_a: Entity, + entity_b: Entity, + merge_references_with_empty_diffs=True, + force=False, + merge_id_with_resolved_entity: bool = False + ) -> 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 @@ -441,8 +456,12 @@ def merge_entities(entity_a: Entity, entity_b: Entity, merge_references_with_emp if merge_id_with_resolved_entity is True and attribute == "value": # Do a special check for the case of an id value on the # one hand, and a resolved entity on the other side. - this = entity_a.get_property(key).value - that = entity_b.get_property(key).value + prop_a = entity_a.get_property(key) + assert prop_a is not None, f"Property {key} not found in entity_a" + prop_b = entity_b.get_property(key) + assert prop_b is not None, f"Property {key} not found in entity_b" + this = prop_a.value + that = prop_b.value same = False if isinstance(this, list) and isinstance(that, list): if len(this) == len(that): @@ -465,11 +484,13 @@ def merge_entities(entity_a: Entity, entity_b: Entity, merge_references_with_emp else: # TODO: This is a temporary FIX for # https://gitlab.indiscale.com/caosdb/src/caosdb-pylib/-/issues/105 - entity_a.add_property(id=entity_b.get_property(key).id, - name=entity_b.get_property(key).name, - datatype=entity_b.get_property(key).datatype, - value=entity_b.get_property(key).value, - unit=entity_b.get_property(key).unit, + prop_b = entity_b.get_property(key) + assert prop_b is not None, f"Property {key} not found in entity_b" + entity_a.add_property(id=prop_b.id, + name=prop_b.name, + datatype=prop_b.datatype, + value=prop_b.value, + unit=prop_b.unit, importance=entity_b.get_importance(key)) # entity_a.add_property( # entity_b.get_property(key), @@ -591,7 +612,7 @@ def resolve_reference(prop: Property): prop.value = retrieve_entity_with_id(prop.value) -def create_flat_list(ent_list: List[Entity], flat: List[Entity]): +def create_flat_list(ent_list: list[Entity], flat: list[Entity]): """ Recursively adds all properties contained in entities from ent_list to the output list flat. Each element will only be added once to the list. diff --git a/src/linkahead/cached.py b/src/linkahead/cached.py index b27afe0469bcaac733ece4c0be3d8d124f6305c0..cf1d1d34362335f87c5eca094b5aa9d6b750f68d 100644 --- a/src/linkahead/cached.py +++ b/src/linkahead/cached.py @@ -5,6 +5,8 @@ # Copyright (C) 2023 IndiScale GmbH <info@indiscale.com> # Copyright (C) 2023 Henrik tom Wörden <h.tomwoerden@indiscale.com> # Copyright (C) 2023 Daniel Hornung <d.hornung@indiscale.com> +# Copyright (C) 2024 Indiscale GmbH <info@indiscale.com> +# Copyright (C) 2024 Joscha Schmiedt <joscha@schmiedt.dev> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -32,9 +34,10 @@ See also - ``cached_get_entity_by(...)`` : Get an Entity by name, id, ... """ +from __future__ import annotations from enum import Enum from functools import lru_cache -from typing import Union +from typing import Any, Optional, Union from .exceptions import EmptyUniqueQueryError, QueryNotUniqueError from .utils import get_entity @@ -45,7 +48,7 @@ from .common.models import execute_query, Entity, Container DEFAULT_SIZE = 33333 # This dict cache is solely for filling the real cache manually (e.g. to reuse older query results) -_DUMMY_CACHE = {} +_DUMMY_CACHE: dict[Union[str, int], Any] = {} class AccessType(Enum): @@ -59,8 +62,10 @@ class AccessType(Enum): NAME = 4 -def cached_get_entity_by(eid: Union[str, int] = None, name: str = None, path: str = None, query: - str = None) -> Entity: +def cached_get_entity_by(eid: Union[str, int, None] = None, + name: Optional[str] = None, + path: Optional[str] = None, + query: Optional[str] = None) -> Union[Entity, tuple[None]]: """Return a single entity that is identified uniquely by one argument. You must supply exactly one argument. @@ -99,7 +104,7 @@ If a query phrase is given, the result must be unique. If this is not what you raise RuntimeError("This line should never be reached.") -def cached_query(query_string) -> Container: +def cached_query(query_string: str) -> Container: """A cached version of :func:`linkahead.execute_query<linkahead.common.models.execute_query>`. All additional arguments are at their default values. @@ -111,8 +116,8 @@ All additional arguments are at their default values. return result -@lru_cache(maxsize=DEFAULT_SIZE) -def _cached_access(kind: AccessType, value: Union[str, int], unique=True): +@ 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 # cached_get_entity_by with the different AccessTypes. However, there is only one cache size. @@ -123,12 +128,24 @@ def _cached_access(kind: AccessType, value: Union[str, int], unique=True): try: if kind == AccessType.QUERY: + if not isinstance(value, str): + raise TypeError( + f"If AccessType is QUERY, value must be a string, not {type(value)}.") return execute_query(value, unique=unique) if kind == AccessType.NAME: + if not isinstance(value, str): + raise TypeError( + f"If AccessType is NAME, value must be a string, not {type(value)}.") return get_entity.get_entity_by_name(value) if kind == AccessType.EID: + if not isinstance(value, (str, int)): + raise TypeError( + f"If AccessType is EID, value must be a string or int, not {type(value)}.") return get_entity.get_entity_by_id(value) if kind == AccessType.PATH: + if not isinstance(value, str): + raise TypeError( + f"If AccessType is PATH, value must be a string, not {type(value)}.") return get_entity.get_entity_by_path(value) except (QueryNotUniqueError, EmptyUniqueQueryError) as exc: return exc @@ -152,7 +169,7 @@ out: named tuple return _cached_access.cache_info() -def cache_initialize(maxsize=DEFAULT_SIZE) -> None: +def cache_initialize(maxsize: int = DEFAULT_SIZE) -> None: """Create a new cache with the given size for `cached_query` and `cached_get_entity_by`. This implies a call of :func:`cache_clear`, the old cache is emptied. @@ -163,7 +180,9 @@ def cache_initialize(maxsize=DEFAULT_SIZE) -> None: _cached_access = lru_cache(maxsize=maxsize)(_cached_access.__wrapped__) -def cache_fill(items: dict, kind: AccessType = AccessType.EID, unique: bool = True) -> None: +def cache_fill(items: dict[Union[str, int], Any], + kind: AccessType = AccessType.EID, + unique: bool = True) -> None: """Add entries to the cache manually. This allows to fill the cache without actually submitting queries. Note that this does not @@ -186,6 +205,19 @@ unique: bool, optional :func:`cached_query`. """ + + if kind == AccessType.QUERY: + assert all(isinstance(key, str) for key in items.keys()), "Keys must be strings." + elif kind == AccessType.NAME: + assert all(isinstance(key, str) for key in items.keys()), "Keys must be strings." + elif kind == AccessType.EID: + assert all(isinstance(key, (str, int)) + for key in items.keys()), "Keys must be strings or integers." + elif kind == AccessType.PATH: + assert all(isinstance(key, str) for key in items.keys()), "Keys must be strings." + else: + raise ValueError(f"Unknown AccessType: {kind}") + # 1. add the given items to the corresponding dummy dict cache _DUMMY_CACHE.update(items) diff --git a/src/linkahead/common/administration.py b/src/linkahead/common/administration.py index 417081b0dad19ce15049b8ce05aeef8cc86607f7..dee341fa84dd85cbd41a77c0e2d510a96f2c4824 100644 --- a/src/linkahead/common/administration.py +++ b/src/linkahead/common/administration.py @@ -23,8 +23,8 @@ # # ** end header # - -"""missing docstring.""" +from __future__ import annotations +"""Utility functions for server and user administration.""" import random import re @@ -38,8 +38,12 @@ from ..exceptions import (EntityDoesNotExistError, HTTPClientError, ServerConfigurationException) from .utils import xml2str +from typing import Optional, TYPE_CHECKING, Union +if TYPE_CHECKING: + from ..common.models import Entity + -def set_server_property(key, value): +def set_server_property(key: str, value: str): """set_server_property. Set a server property. @@ -65,7 +69,7 @@ def set_server_property(key, value): "Debug mode in server is probably disabled.") from None -def get_server_properties(): +def get_server_properties() -> dict[str, Optional[str]]: """get_server_properties. Get all server properties as a dict. @@ -84,7 +88,7 @@ def get_server_properties(): "Debug mode in server is probably disabled.") from None xml = etree.parse(body) - props = dict() + props: dict[str, Optional[str]] = dict() for elem in xml.getroot(): props[elem.tag] = elem.text @@ -92,7 +96,7 @@ def get_server_properties(): return props -def get_server_property(key): +def get_server_property(key: str) -> Optional[str]: """get_server_property. Get a server property. @@ -149,7 +153,7 @@ def generate_password(length: int): return password -def _retrieve_user(name, realm=None, **kwargs): +def _retrieve_user(name: str, realm: Optional[str] = None, **kwargs): con = get_connection() try: return con._http_request(method="GET", path="User/" + (realm + "/" + name if realm is not None else name), **kwargs).read() @@ -161,7 +165,7 @@ def _retrieve_user(name, realm=None, **kwargs): raise -def _delete_user(name, **kwargs): +def _delete_user(name: str, **kwargs): con = get_connection() try: return con._http_request(method="DELETE", path="User/" + name, **kwargs).read() @@ -173,10 +177,14 @@ def _delete_user(name, **kwargs): raise -def _update_user(name, realm=None, password=None, status=None, - email=None, entity=None, **kwargs): +def _update_user(name: str, + realm: Optional[str] = None, + password: Optional[str] = None, + status: Optional[str] = None, + email: Optional[str] = None, + entity: Optional[Entity] = None, **kwargs): con = get_connection() - params = {} + params: dict[str, Optional[str]] = {} if password is not None: params["password"] = password @@ -204,9 +212,13 @@ def _update_user(name, realm=None, password=None, status=None, raise -def _insert_user(name, password=None, status=None, email=None, entity=None, **kwargs): +def _insert_user(name: str, + password: Optional[str] = None, + status: Optional[str] = None, + email: Optional[str] = None, + entity: Optional[Entity] = None, **kwargs): con = get_connection() - params = {"username": name} + params: dict[str, Union[str, Entity]] = {"username": name} if password is not None: params["password"] = password @@ -394,15 +406,15 @@ priority : bool, optional """ @staticmethod - def _parse_boolean(bstr): + def _parse_boolean(bstr) -> bool: return str(bstr) in ["True", "true", "TRUE", "yes"] - def __init__(self, action, permission, priority=False): + def __init__(self, action: str, permission: str, priority: bool = False): self._action = action self._permission = permission self._priority = PermissionRule._parse_boolean(priority) - def _to_xml(self): + def _to_xml(self) -> etree._Element: xml = etree.Element(self._action) xml.set("permission", self._permission) @@ -412,12 +424,15 @@ priority : bool, optional return xml @staticmethod - def _parse_element(elem): - return PermissionRule(elem.tag, elem.get( - "permission"), elem.get("priority")) + def _parse_element(elem: etree._Element): + permission = elem.get("permission") + if permission is None: + raise ValueError(f"Permission is missing in PermissionRule xml: {elem}") + priority = PermissionRule._parse_boolean(elem.get("priority")) + return PermissionRule(elem.tag, permission, priority if priority is not None else False) @staticmethod - def _parse_body(body): + def _parse_body(body: str): xml = etree.fromstring(body) ret = set() diff --git a/src/linkahead/common/datatype.py b/src/linkahead/common/datatype.py index c0c15feca240112f1f8e33a0cd37932151fcd9f0..7afcb7a5beee26a99934640ac41ccf403f9325fe 100644 --- a/src/linkahead/common/datatype.py +++ b/src/linkahead/common/datatype.py @@ -22,9 +22,16 @@ # # ** end header # - +from __future__ import annotations import re +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from typing import Literal, Union + from linkahead.common.models import Entity, Container + DATATYPE = Literal["DOUBLE", "REFERENCE", "TEXT", "DATETIME", "INTEGER", "FILE", "BOOLEAN"] + + from ..exceptions import EmptyUniqueQueryError, QueryNotUniqueError DOUBLE = "DOUBLE" @@ -36,15 +43,14 @@ FILE = "FILE" BOOLEAN = "BOOLEAN" -def LIST(datatype): +def LIST(datatype: Union[str, Entity, DATATYPE]) -> str: # FIXME May be ambiguous (if name duplicate) or insufficient (if only ID exists). - if hasattr(datatype, "name"): - datatype = datatype.name + datatype = getattr(datatype, "name", datatype) return "LIST<" + str(datatype) + ">" -def get_list_datatype(datatype: str, strict: bool = False): +def get_list_datatype(datatype: str, strict: bool = False) -> Union[str, None]: """Returns the datatype of the elements in the list. If it not a list, return None.""" # TODO Union[str, Entity] if not isinstance(datatype, str) or not datatype.lower().startswith("list"): @@ -68,13 +74,13 @@ def get_list_datatype(datatype: str, strict: bool = False): return None -def is_list_datatype(datatype): +def is_list_datatype(datatype: str) -> bool: """ returns whether the datatype is a list """ return get_list_datatype(datatype) is not None -def is_reference(datatype): +def is_reference(datatype: str) -> bool: """Returns whether the value is a reference FILE and REFERENCE properties are examples, but also datatypes that are @@ -99,12 +105,12 @@ def is_reference(datatype): if datatype in [DOUBLE, BOOLEAN, INTEGER, TEXT, DATETIME]: return False elif is_list_datatype(datatype): - return is_reference(get_list_datatype(datatype)) + return is_reference(get_list_datatype(datatype)) # type: ignore else: return True -def get_referenced_recordtype(datatype): +def get_referenced_recordtype(datatype: str) -> str: """Return the record type of the referenced datatype. Raises @@ -128,7 +134,7 @@ def get_referenced_recordtype(datatype): raise ValueError("datatype must be a reference") if is_list_datatype(datatype): - datatype = get_list_datatype(datatype) + datatype = get_list_datatype(datatype) # type: ignore if datatype is None: raise ValueError("list does not have a list datatype") @@ -139,7 +145,7 @@ def get_referenced_recordtype(datatype): return datatype -def get_id_of_datatype(datatype): +def get_id_of_datatype(datatype: str) -> int: """ returns the id of a Record Type This is not trivial, as queries may also return children. A check comparing @@ -164,12 +170,14 @@ def get_id_of_datatype(datatype): from .models import execute_query if is_list_datatype(datatype): - datatype = get_list_datatype(datatype) + datatype = get_list_datatype(datatype) # type: ignore q = "FIND RECORDTYPE {}".format(datatype) # we cannot use unique=True here, because there might be subtypes - res = execute_query(q) - res = [el for el in res if el.name.lower() == datatype.lower()] + res: Container = execute_query(q) # type: ignore + if isinstance(res, int): + raise ValueError("FIND RECORDTYPE query returned an `int`") + res: list[Entity] = [el for el in res if el.name.lower() == datatype.lower()] # type: ignore if len(res) > 1: raise QueryNotUniqueError( diff --git a/src/linkahead/common/models.py b/src/linkahead/common/models.py index 8565c2e7061aeb1dd6e02a34ad4ca867327a6e95..98fc28a47a3c7b034b4b6f1dc12481f62fc69fd4 100644 --- a/src/linkahead/common/models.py +++ b/src/linkahead/common/models.py @@ -4,9 +4,10 @@ # # Copyright (C) 2018 Research Group Biomedical Physics, # Max-Planck-Institute for Dynamics and Self-Organization Göttingen -# Copyright (C) 2020-2023 Indiscale GmbH <info@indiscale.com> +# Copyright (C) 2020-2024 IndiScale GmbH <info@indiscale.com> # Copyright (C) 2020-2023 Florian Spreckelsen <f.spreckelsen@indiscale.com> # Copyright (C) 2020-2022 Timm Fitschen <t.fitschen@indiscale.com> +# Copyright (C) 2024 Joscha Schmiedt <joscha@schmiedt.dev> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -33,7 +34,6 @@ transactions. """ from __future__ import annotations # Can be removed with 3.10. -from __future__ import print_function, unicode_literals import re import sys @@ -45,7 +45,19 @@ from os import listdir from os.path import isdir from random import randint from tempfile import NamedTemporaryFile -from typing import Any, Optional + +from typing import TYPE_CHECKING +from typing import Any, Final, Literal, Optional, TextIO, Union + +if TYPE_CHECKING: + from datetime import datetime + from .datatype import DATATYPE + from tempfile import _TemporaryFileWrapper + from io import BufferedWriter + from os import PathLike + QueryDict = dict[str, Optional[str]] + + from warnings import warn from lxml import etree @@ -53,17 +65,36 @@ 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 .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 @@ -71,14 +102,17 @@ from .versioning import Version _ENTITY_URI_SEGMENT = "Entity" -# importances/inheritance -OBLIGATORY = "OBLIGATORY" -SUGGESTED = "SUGGESTED" -RECOMMENDED = "RECOMMENDED" -FIX = "FIX" -ALL = "ALL" -NONE = "NONE" +OBLIGATORY: Final = "OBLIGATORY" +SUGGESTED: Final = "SUGGESTED" +RECOMMENDED: Final = "RECOMMENDED" +FIX: Final = "FIX" +ALL: Final = "ALL" +NONE: Final = "NONE" +if TYPE_CHECKING: + INHERITANCE = Literal["OBLIGATORY", "SUGGESTED", "RECOMMENDED", "ALL", "NONE", "FIX"] + 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"] @@ -97,47 +131,57 @@ class Entity: by the user to control several server-side plug-ins. """ - def __init__(self, name=None, id=None, description=None, # @ReservedAssignment - datatype=None, value=None, **kwargs): - self.__role = kwargs["role"] if "role" in kwargs else None - self._checksum = None + def __init__( + self, + name: Optional[str] = None, + id: Optional[int] = None, + description: Optional[str] = None, # @ReservedAssignment + datatype: Optional[DATATYPE] = None, + value=None, + **kwargs, + ): + + self.__role: Optional[ROLE] = kwargs["role"] if "role" in kwargs else None + self._checksum: Optional[str] = None self._size = None self._upload = None # If an entity is used (e.g. as parent), it is wrapped instead of being used directly. # see Entity._wrap() - self._wrapped_entity = None - self._version = None - self._cuid = None - self._flags = dict() + self._wrapped_entity: Optional[Entity] = None + self._version: Optional[Version] = None + self._cuid: Optional[str] = None + self._flags: dict[str, str] = dict() self.__value = None - self.__datatype = None - self.datatype = datatype + self.__datatype: Optional[DATATYPE] = None + self.datatype: Optional[DATATYPE] = datatype self.value = value self.messages = Messages() self.properties = _Properties() self.parents = _ParentList() - self.path = None - self.file = None - self.unit = None - self.acl = None - self.permissions = None + self.path: Optional[str] = None + self.file: Optional[File] = None + self.unit: Optional[str] = None + self.acl: Optional[ACL] = None + self.permissions: Optional[Permissions] = None self.is_valid = lambda: False self.is_deleted = lambda: False self.name = name self.description = description - self.id = id - self.state = None + self.id: Optional[int] = id + self.state: Optional[State] = None - def copy(self): + def copy(self) -> Entity: """ Return a copy of entity. + FIXME: This method doesn't have a deep keyword argument. If deep == True return a deep copy, recursively copying all sub entities. Standard properties are copied using add_property. Special attributes, as defined by the global variable SPECIAL_ATTRIBUTES and additionaly the "value" are copied using setattr. """ + new: Union[File, Property, RecordType, Record, Entity] if self.role == "File": new = File() elif self.role == "Property": @@ -177,7 +221,7 @@ class Entity: return self._wrapped_entity.version @version.setter - def version(self, version): + def version(self, version: Optional[Version]): self._version = version @property @@ -202,7 +246,7 @@ class Entity: return self._wrapped_entity.size @property - def id(self): + def id(self) -> Any: if self.__id is not None: return self.__id @@ -212,9 +256,11 @@ class Entity: return self._wrapped_entity.id @id.setter - def id(self, new_id): + def id(self, new_id) -> None: if new_id is not None: - self.__id = int(new_id) + if not isinstance(new_id, int): + new_id = int(new_id) + self.__id: Optional[int] = new_id else: self.__id = None @@ -249,14 +295,14 @@ class Entity: return self._wrapped_entity.description - @property - def checksum(self): - return self._checksum - @description.setter def description(self, new_description): self.__description = new_description + @property + def checksum(self): + return self._checksum + @property def unit(self): if self.__unit is not None or self._wrapped_entity is None: @@ -323,8 +369,15 @@ class Entity: def pickup(self, new_pickup): self.__pickup = new_pickup - def grant(self, realm=None, username=None, role=None, - permission=None, priority=False, revoke_denial=True): + def grant( + self, + realm: Optional[str] = None, + username: Optional[str] = None, + role: Optional[str] = None, + permission: Optional[str] = None, + priority: bool = False, + revoke_denial: bool = True, + ): """Grant a permission to a user or role for this entity. You must specify either only the username and the realm, or only the @@ -354,12 +407,23 @@ 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) - def deny(self, realm=None, username=None, role=None, - permission=None, priority=False, revoke_grant=True): + def deny( + self, + realm: Optional[str] = None, + username: Optional[str] = None, + role: Optional[str] = None, + permission: Optional[str] = None, + priority: bool = False, + revoke_grant: bool = True, + ): """Deny a permission to a user or role for this entity. You must specify either only the username and the realm, or only the @@ -389,12 +453,18 @@ 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) def revoke_denial(self, realm=None, username=None, role=None, permission=None, priority=False): + if self.acl is None: + raise EntityHasNoAclError("This entity does not have an ACL (yet).") self.acl.revoke_denial( realm=realm, username=username, @@ -404,6 +474,8 @@ class Entity: def revoke_grant(self, realm=None, username=None, role=None, permission=None, priority=False): + if self.acl is None: + raise EntityHasNoAclError("This entity does not have an ACL (yet).") self.acl.revoke_grant( realm=realm, username=username, @@ -411,15 +483,17 @@ class Entity: permission=permission, priority=priority) - def is_permitted(self, permission, role=None): - if role is None: + def is_permitted(self, permission: Permission, role: Optional[str] = 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) - def get_all_messages(self): + 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() ret.append(self.messages) @@ -494,20 +568,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) @@ -518,8 +596,29 @@ class Entity: return self - def add_property(self, property=None, value=None, id=None, name=None, description=None, datatype=None, - unit=None, importance=None, inheritance=None): # @ReservedAssignment + def add_property( + self, + property: Union[int, str, Entity, None] = None, + value: Union[ + int, + str, + bool, + datetime, + Entity, + list[int], + list[str], + list[bool], + list[Entity], + None, + ] = None, + id: Optional[int] = None, + name: Optional[str] = None, + description: Optional[str] = None, + datatype: Optional[DATATYPE] = None, + unit: Optional[str] = None, + importance: Optional[IMPORTANCE] = None, + inheritance: Optional[INHERITANCE] = None, + ) -> Entity: # @ReservedAssignment """Add a property to this entity. The first parameter is meant to identify the property entity either via @@ -574,8 +673,8 @@ class Entity: If the first parameter is an integer then it is interpreted as the id and id must be undefined or None. UserWarning - If the first parameter is not None and neither an instance of Entity nor an integer it is - interpreted as the name and name must be undefined or None. + If the first parameter is not None and neither an instance of Entity nor an integer it + is interpreted as the name and name must be undefined or None. Raises ------ @@ -591,7 +690,8 @@ class Entity: >>> import linkahead as db >>> rec = db.Record(name="TestRec").add_parent(name="TestType") - >>> rec.add_property("TestProp", value=27) # specified by name, you could equally use the property's id if it is known + >>> rec.add_property("TestProp", value=27) # specified by name, you could equally use the + >>> # property's id if it is known You can also use the Python object: @@ -614,10 +714,12 @@ class Entity: Note that since `TestProp` is a scalar integer Property, the datatype `LIST<INTEGER>` has to be specified explicitly. - Finally, we can also add reference properties, specified by the RecordType of the referenced entity. + Finally, we can also add reference properties, specified by the RecordType of the referenced + entity. >>> ref_rec = db.Record(name="ReferencedRecord").add_parent(name="OtherRT") - >>> rec.add_property(name="OtherRT", value=ref_rec) # or value=ref_rec.id if ref_rec has one set by the server + >>> rec.add_property(name="OtherRT", value=ref_rec) # or value=ref_rec.id if ref_rec has + >>> # one set by the server See more on adding properties and inserting data in https://docs.indiscale.com/caosdb-pylib/tutorials/Data-Insertion.html. @@ -637,12 +739,21 @@ class Entity: abstract_property = property elif isinstance(property, int): if pid is not None: - raise UserWarning("The first parameter was an integer which would normally be interpreted as the id of the property which is to be added. But you have also specified a parameter 'id' in the method call. This is ambiguous and cannot be processed.") + raise UserWarning( + "The first parameter was an integer which would normally be interpreted as the" + " id of the property which is to be added. But you have also specified a" + " parameter 'id' in the method call. This is ambiguous and cannot be processed." + ) pid = property id = pid elif property is not None: if name is not None: - raise UserWarning("The first parameter was neither an instance of Entity nor an integer. Therefore the string representation of your first parameter would normally be interpreted name of the property which is to be added. But you have also specified a parameter 'name' in the method call. This is ambiguous and cannot be processed.") + raise UserWarning( + "The first parameter was neither an instance of Entity nor an integer." + " Therefore the string representation of your first parameter would normally be" + " interpreted name of the property which is to be added. But you have also" + " specified a parameter 'name' in the method call. This is ambiguous and cannot" + " be processed.") name = str(property) if property is None and name is None and pid is None: @@ -689,7 +800,13 @@ class Entity: return self - def add_parent(self, parent=None, id=None, name=None, inheritance=None): # @ReservedAssignment + def add_parent( + self, + parent: Union[Entity, int, str, None] = None, + id: Optional[int] = None, + name: Optional[str] = None, + inheritance: INHERITANCE = "NONE", + ): # @ReservedAssignment """Add a parent to this entity. Parameters @@ -703,8 +820,8 @@ class Entity: name : str Name of the parent entity. Ignored if `parent is not none`. - inheritance : str - One of ``obligatory``, ``recommended``, ``suggested``, or ``fix``. Specifies the + inheritance : str, INHERITANCE + 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. @@ -810,7 +927,7 @@ out: bool return self.parents - def get_parents_recursively(self, retrieve: bool = True): + def get_parents_recursively(self, retrieve: bool = True) -> list[Entity]: """Get all ancestors of this entity. Parameters @@ -821,16 +938,16 @@ retrieve: bool, optional Returns ------- -out: List[Entity] +out: list[Entity] The parents of this Entity """ - all_parents = [] + all_parents: list[Entity] = [] self._get_parent_recursively(all_parents, retrieve=retrieve) return all_parents - def _get_parent_recursively(self, all_parents: list, retrieve: bool = True): + def _get_parent_recursively(self, all_parents: list[Entity], retrieve: bool = True): """Get all ancestors with a little helper. As a side effect of this method, the ancestors are added to @@ -861,7 +978,7 @@ out: List[Entity] all_parents.append(w_parent) w_parent._get_parent_recursively(all_parents, retrieve=retrieve) - def get_parent(self, key): + def get_parent(self, key: Union[int, Entity, str]) -> Union[Entity, None]: """Return the first parent matching the key or None if no match exists. Parameters @@ -910,7 +1027,7 @@ out: List[Entity] return self.properties - def get_property(self, pattern): + def get_property(self, pattern: Union[int, str, Entity]) -> Union[Property, None]: """ Return the first matching property or None. Parameters @@ -951,11 +1068,14 @@ 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 - def _get_value_for_selector(self, selector): + def _get_value_for_selector( + self, selector: Union[str, list[str], tuple[str]] + ) -> Any: """return the value described by the selector A selector is a list or a tuple of strings describing a path in an @@ -966,8 +1086,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 @@ -982,7 +1104,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 @@ -1009,9 +1131,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()) @@ -1044,7 +1168,7 @@ out: List[Entity] row : tuple A row-like representation of the entity's properties. """ - row = tuple() + row: tuple = tuple() for selector in selectors: val = self._get_value_for_selector(selector) @@ -1092,7 +1216,7 @@ out: List[Entity] return ret - def get_errors_deep(self, roots=None): + def get_errors_deep(self, roots=None) -> list[tuple[str, list[Entity]]]: """Get all error messages of this entity and all sub-entities / parents / properties. @@ -1123,7 +1247,12 @@ out: List[Entity] return False - def to_xml(self, xml=None, add_properties=ALL, local_serialization=False): + def to_xml( + self, + xml: Optional[etree._Element] = None, + add_properties: INHERITANCE = "ALL", + local_serialization: bool = False, + ) -> etree._Element: """Generate an xml representation of this entity. If the parameter xml is given, all attributes, parents, properties, and messages of this entity will be added to it instead of creating a new element. @@ -1133,6 +1262,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. """ @@ -1143,7 +1276,6 @@ out: List[Entity] assert isinstance(xml, etree._Element) # unwrap wrapped entity - if self._wrapped_entity is not None: xml = self._wrapped_entity.to_xml(xml, add_properties) @@ -1329,7 +1461,30 @@ out: List[Entity] self.acl = Entity(name=self.name, id=self.id).retrieve( flags={"ACL": None}).acl - def update_acl(self): + def update_acl(self, **kwargs): + """Update this entity's ACL on the server. + + A typical workflow is to first edit ``self.acl`` and then call this + method. + + Note + ---- + This overwrites any existing ACL, so you may want to run + ``retrieve_acl`` before updating the ACL in this entity. + + Parameters + ---------- + **kwargs : dict + Keyword arguments that are passed through to the + ``Entity.update`` method. Useful for e.g. ``unique=False`` in the + case of naming collisions. + + Returns + ------- + e : Entity + This entity after the update of the ACL. + + """ if self.id is None: c = Container().retrieve(query=self.name, sync=False) @@ -1349,8 +1504,10 @@ out: List[Entity] raise TransactionError(ae) else: e = Container().retrieve(query=self.id, sync=False)[0] + if self.acl is None: + raise EntityHasNoAclError("This entity does not have an ACL yet. Please set one first.") e.acl = ACL(self.acl.to_xml()) - e.update() + e.update(**kwargs) return e @@ -1393,20 +1550,27 @@ out: List[Entity] return Container().append(self).retrieve( unique=unique, raise_exception_on_error=raise_exception_on_error, flags=flags) - def insert(self, raise_exception_on_error=True, unique=True, - sync=True, strict=False, flags=None): + def insert( + self, + raise_exception_on_error=True, + unique=True, + sync=True, + strict=False, + flags: Optional[dict] = None, + ): """Insert this entity into a LinkAhead server. 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. Parameters ---------- @@ -1444,21 +1608,22 @@ Second: 1) construct entity with id 2) call update method. - For slight changes the second one it is more comfortable. Furthermore, it is possible to stay - off-line until calling the update method. The name, description, unit, datatype, path, - and value of an entity may be changed. Additionally, properties, parents and messages may be added. + For slight changes the second one it is more comfortable. Furthermore, it is possible to + stay off-line until calling the update method. The name, description, unit, datatype, path, + and value of an entity may be changed. Additionally, properties, parents and messages may be + added. - However, the first one is more powerful: It is possible to delete and change properties, parents - and attributes, which is not possible via the second one for internal reasons (which are reasons - of definiteness). + However, the first one is more powerful: It is possible to delete and change properties, + parents and attributes, which is not possible via the second one for internal reasons (which + are reasons of definiteness). If the update 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 updates might cause warning messages on the server-side, but the updates are performed - anyway. Set flag 'strict' to True in order to force the server to take all warnings as errors. - This prevents the server from updating this entity if any warnings occur. + anyway. Set flag 'strict' to True in order to force the server to take all warnings as + errors. This prevents the server from updating this entity if any warnings occur. @param strict=False: Flag for strict mode. """ @@ -1596,7 +1761,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" @@ -1615,15 +1786,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[QueryDict] = None, + ) -> Container: return Container().append(self).retrieve( raise_exception_on_error=raise_exception_on_error, @@ -1631,8 +1807,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[QueryDict] = None, + ) -> Container: return Container().append(self).insert( strict=strict, @@ -1641,8 +1823,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[QueryDict] = None, + ) -> Container: return Container().append(self).update( strict=strict, @@ -1658,7 +1846,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) -> etree._Element: if xml is None: xml = etree.Element("QueryTemplate") @@ -1688,7 +1876,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) @@ -1698,16 +1886,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: @@ -1757,14 +1947,25 @@ class Parent(Entity): def affiliation(self, affiliation): self.__affiliation = affiliation - def __init__(self, id=None, name=None, description=None, inheritance=None): # @ReservedAssignment + def __init__( + self, + id: Optional[int] = None, + name: Optional[str] = None, + description: Optional[str] = None, + inheritance: Optional[INHERITANCE] = None, + ): # @ReservedAssignment Entity.__init__(self, id=id, name=name, description=description) if inheritance is not None: self.set_flag("inheritance", inheritance) self.__affiliation = None - def to_xml(self, xml=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") @@ -1783,7 +1984,8 @@ class Property(Entity): """LinkAhead's Property object.""" - def add_property(self, property=None, value=None, id=None, name=None, description=None, datatype=None, + def add_property(self, property=None, value=None, id=None, name=None, description=None, + datatype=None, unit=None, importance=FIX, inheritance=FIX): # @ReservedAssignment """See ``Entity.add_property``.""" @@ -1819,27 +2021,45 @@ class Property(Entity): """ - return super(Property, self).add_parent(parent=parent, id=id, name=name, inheritance=inheritance) - - def __init__(self, name=None, id=None, description=None, datatype=None, - value=None, unit=None): + return super(Property, self).add_parent(parent=parent, id=id, name=name, + inheritance=inheritance) + + def __init__( + self, + name: Optional[str] = None, + id: Optional[int] = None, + description: Optional[str] = None, + datatype: Union[DATATYPE, None] = None, + value=None, + unit: Optional[str] = None, + ): Entity.__init__(self, id=id, name=name, description=description, datatype=datatype, value=value, role="Property") self.unit = unit - 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("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): + def is_reference(self, server_retrieval: bool = False) -> Optional[bool]: """Returns whether this Property is a reference Parameters ---------- server_retrieval : bool, optional - If True and the datatype is not set, the Property is retrieved from the server, by default False + If True and the datatype is not set, the Property is retrieved from the server, by + default False Returns ------- @@ -1877,13 +2097,19 @@ class Property(Entity): class Message(object): - def __init__(self, type=None, code=None, description=None, body=None): # @ReservedAssignment + def __init__( + self, + type: Optional[str] = None, + code: Optional[int] = None, + description: Optional[str] = None, + body: Union[str, etree._Attrib, None] = None, + ): # @ReservedAssignment self.description = description self.type = type if type is not None else "Info" self.code = int(code) if code is not None else None self.body = body - def to_xml(self, xml=None): + def to_xml(self, xml: Optional[etree._Element] = None) -> etree._Element: if xml is None: xml = etree.Element(str(self.type)) @@ -1903,21 +2129,23 @@ class Message(object): def __eq__(self, obj): if isinstance(obj, Message): - return self.type == obj.type and self.code == obj.code and self.description == obj.description + return (self.type == obj.type and self.code == obj.code + and self.description == obj.description) return False - def get_code(self): + def get_code(self) -> Optional[int]: warn(("get_code is deprecated and will be removed in future. " "Use self.code instead."), DeprecationWarning) - return int(self.code) + return int(self.code) if self.code is not None else None class RecordType(Entity): """This class represents LinkAhead's RecordType entities.""" - def add_property(self, property=None, value=None, id=None, name=None, description=None, datatype=None, + def add_property(self, property=None, value=None, id=None, name=None, description=None, + datatype=None, unit=None, importance=RECOMMENDED, inheritance=FIX): # @ReservedAssignment """See ``Entity.add_property``.""" @@ -1925,7 +2153,13 @@ class RecordType(Entity): property=property, id=id, name=name, description=description, datatype=datatype, value=value, unit=unit, importance=importance, inheritance=inheritance) - def add_parent(self, parent=None, id=None, name=None, inheritance=OBLIGATORY): + def add_parent( + self, + parent: Union[Entity, int, str, None] = None, + id: Optional[int] = None, + name: Optional[str] = None, + inheritance: INHERITANCE = "OBLIGATORY", + ): """Add a parent to this RecordType Parameters @@ -1944,8 +2178,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. @@ -1958,22 +2192,39 @@ class RecordType(Entity): return super().add_parent(parent=parent, id=id, name=name, inheritance=inheritance) - def __init__(self, name=None, id=None, description=None, datatype=None): # @ReservedAssignment + def __init__( + self, + name: Optional[str] = None, + id: Optional[int] = None, + description: Optional[str] = None, + datatype: Optional[DATATYPE] = None, + ): # @ReservedAssignment Entity.__init__(self, name=name, id=id, description=description, datatype=datatype, role="RecordType") - 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, + ) -> 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): """This class represents LinkAhead's Record entities.""" - def add_property(self, property=None, value=None, id=None, name=None, description=None, datatype=None, + def add_property(self, property=None, value=None, id=None, name=None, description=None, + datatype=None, unit=None, importance=FIX, inheritance=FIX): # @ReservedAssignment """See ``Entity.add_property``.""" @@ -1981,15 +2232,29 @@ class Record(Entity): property=property, id=id, name=name, description=description, datatype=datatype, value=value, unit=unit, importance=importance, inheritance=inheritance) - def __init__(self, name=None, id=None, description=None): # @ReservedAssignment + def __init__( + self, + name: Optional[str] = None, + id: Optional[int] = None, + description: Optional[str] = None, + ): # @ReservedAssignment 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): @@ -2022,9 +2287,17 @@ class File(Record): """ - def __init__(self, name=None, id=None, description=None, # @ReservedAssignment - path=None, file=None, pickup=None, # @ReservedAssignment - thumbnail=None, from_location=None): + def __init__( + self, + name: Optional[str] = None, + id: Optional[int] = None, + description: Optional[str] = None, # @ReservedAssignment + path: Optional[str] = None, + file: Union[str, TextIO, None] = None, + pickup: Optional[str] = None, # @ReservedAssignment + thumbnail: Optional[str] = None, + from_location=None, + ): Record.__init__(self, id=id, name=name, description=description) self.role = "File" self.datatype = None @@ -2045,7 +2318,12 @@ class File(Record): if self.pickup is None: self.pickup = from_location - def to_xml(self, xml=None, add_properties=ALL, local_serialization=False): + def to_xml( + self, + xml: Optional[etree._Element] = None, + add_properties: INHERITANCE = "ALL", + local_serialization: bool = False, + ) -> etree._Element: """Convert this file to an xml element. @return: xml element @@ -2057,7 +2335,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) -> str: """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. @@ -2067,7 +2345,8 @@ 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) @@ -2079,7 +2358,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) @@ -2119,7 +2400,7 @@ class File(Record): return File._get_checksum_single_file(files) @staticmethod - def _get_checksum_single_file(single_file): + def _get_checksum_single_file(single_file: Union[str, bytes, PathLike[str], PathLike[bytes]]): _file = open(single_file, 'rb') data = _file.read(1000) checksum = sha512() @@ -2141,26 +2422,30 @@ class File(Record): class _Properties(list): + """FIXME: Add docstring.""" def __init__(self): list.__init__(self) - self._importance = dict() - self._inheritance = dict() - self._element_by_name = dict() - self._element_by_id = dict() - - def get_importance(self, property): # @ReservedAssignment + 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: 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 + # @ReservedAssignment + def set_importance(self, property: Optional[Property], importance: IMPORTANCE): if property is not None: self._importance[property] = importance - def get_by_name(self, name): + 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. @@ -2175,7 +2460,12 @@ class _Properties(list): return self - def append(self, property, importance=None, inheritance=None): # @ReservedAssignment + def append( + self, + property: Union[list[Entity], Entity, Property], + importance: Optional[IMPORTANCE] = None, + inheritance: Optional[INHERITANCE] = None, + ): # @ReservedAssignment if isinstance(property, list): for p in property: self.append(p, importance, inheritance) @@ -2189,7 +2479,7 @@ class _Properties(list): if inheritance is not None: self._inheritance[property] = inheritance else: - self._inheritance[property] = FIX + self._inheritance[property] = "FIX" if property.id is not None: self._element_by_id[str(property.id)] = property @@ -2202,7 +2492,8 @@ class _Properties(list): return self - def to_xml(self, add_to_element, add_properties): + def to_xml(self, add_to_element: etree._Element, add_properties: INHERITANCE): + p: Property for p in self: importance = self._importance.get(p) @@ -2212,7 +2503,7 @@ class _Properties(list): pelem = p.to_xml(xml=etree.Element("Property"), add_properties=FIX) if p in self._importance: - pelem.set("importance", importance) + pelem.set("importance", str(importance)) if p in self._inheritance: pelem.set("flag", "inheritance:" + @@ -2227,7 +2518,7 @@ class _Properties(list): return xml2str(xml) - def _get_entity_by_cuid(self, cuid): + def _get_entity_by_cuid(self, cuid: str): ''' Get the first entity which has the given cuid. Note: this method is intended for internal use. @@ -2241,7 +2532,7 @@ class _Properties(list): return e raise KeyError("No entity with that cuid in this container.") - def remove(self, prop): + def remove(self, prop: Union[Entity, int]): if isinstance(prop, Entity): if prop in self: list.remove(self, prop) @@ -2330,7 +2621,7 @@ class _ParentList(list): return self - def to_xml(self, add_to_element): + def to_xml(self, add_to_element: etree._Element): for p in self: pelem = etree.Element("Parent") @@ -2365,7 +2656,7 @@ class _ParentList(list): return xml2str(xml) - def remove(self, parent): + def remove(self, parent: Union[Entity, int, str]): if isinstance(parent, Entity): if parent in self: list.remove(self, parent) @@ -2425,7 +2716,8 @@ class Messages(list): <<< msgs = Messages() <<< # create Message - <<< msg = Message(type="HelloWorld", code=1, description="Greeting the world", body="Hello, world!") + <<< msg = Message(type="HelloWorld", code=1, description="Greeting the world", + ... body="Hello, world!") <<< # append it to the Messages <<< msgs.append(msg) @@ -2499,11 +2791,12 @@ class Messages(list): if isinstance(value, Message): body = value.body description = value.description - m = Message + m = Message() else: body = value description = None - m = Message(type=type, code=code, description=description, body=body) + m = Message(type=type, code=code, + description=description, body=body) if isinstance(key, int): super().__setitem__(key, m) else: @@ -2511,7 +2804,8 @@ class Messages(list): def __getitem__(self, key): if not isinstance(key, int): - warn("__getitem__ only supports integer keys in future.", DeprecationWarning) + warn("__getitem__ only supports integer keys in future.", + DeprecationWarning) if isinstance(key, tuple): if len(key) == 2: type = key[0] # @ReservedAssignment @@ -2537,7 +2831,8 @@ class Messages(list): def __delitem__(self, key): if isinstance(key, tuple): - warn("__delitem__ only supports integer keys in future.", DeprecationWarning) + warn("__delitem__ only supports integer keys in future.", + DeprecationWarning) if self.get(key[0], key[1]) is not None: self.remove(self.get(key[0], key[1])) else: @@ -2590,7 +2885,7 @@ class Messages(list): return default - def to_xml(self, add_to_element): + def to_xml(self, add_to_element: etree._Element): for m in self: melem = m.to_xml() add_to_element.append(melem) @@ -2742,7 +3037,7 @@ class Container(list): def __hash__(self): return object.__hash__(self) - def remove(self, entity): + def remove(self, entity: Entity): """Remove the first entity from this container which is equal to the given entity. Raise a ValueError if there is no such entity. @@ -2779,7 +3074,8 @@ class Container(list): return e raise KeyError("No entity with such cuid (" + str(cuid) + ")!") - def get_entity_by_id(self, id): # @ReservedAssignment + # @ReservedAssignment + def get_entity_by_id(self, id: Union[int, str]) -> Entity: """Get the first entity which has the given id. Note: If several entities are in this list which have the same id, this method will only return the first and ignore the others. @@ -2812,7 +3108,7 @@ class Container(list): return error_list - def get_entity_by_name(self, name, case_sensitive=True): + def get_entity_by_name(self, name: str, case_sensitive: bool = True) -> Entity: """Get the first entity which has the given name. Note: If several entities are in this list which have the same name, this method will only return the first and ignore the others. @@ -2891,11 +3187,13 @@ class Container(list): return self - def to_xml(self, add_to_element=None, local_serialization=False): + def to_xml(self, add_to_element: Optional[etree._Element] = None, + local_serialization: bool = False) -> etree._Element: """Get an xml tree representing this Container or append all entities to the given xml element. - @param add_to_element=None: optional element to which all entities of this container is to be appended. + @param add_to_element=None: optional element to which all entities of this container is to + be appended. @return xml element """ tmpid = 0 @@ -3001,6 +3299,14 @@ class Container(list): def __repr__(self): return xml2str(self.to_xml()) + def __getitem__(self, key): + self_as_list_slice = super().__getitem__(key) + if isinstance(self_as_list_slice, list): + # Construct new Container from list slice + return Container().extend(self_as_list_slice) + else: + return self_as_list_slice + @staticmethod def from_xml(xml_str): """Creates a Container from the given xml string. @@ -3037,7 +3343,7 @@ class Container(list): if isinstance(e, Message): c.messages.append(e) elif isinstance(e, Query): - c.query = e + c.query = e # type: ignore if e.messages is not None: c.messages.extend(e.messages) @@ -3060,10 +3366,17 @@ class Container(list): return c else: raise LinkAheadException( - "The server's response didn't contain the expected elements. The configuration of this client might be invalid (especially the url).") - - def _sync(self, container, unique, raise_exception_on_error, - name_case_sensitive=False, strategy=_basic_sync): + "The server's response didn't contain the expected elements. The configuration of" + " this client might be invalid (especially the url).") + + def _sync( + self, + container: Container, + unique: bool, + raise_exception_on_error: bool, + name_case_sensitive: bool = False, + strategy=_basic_sync, + ): """Synchronize this container (C1) with another container (C2). That is: 1) Synchronize any entity e1 in C1 with the @@ -3109,13 +3422,19 @@ class Container(list): self._timestamp = container._timestamp self._srid = container._srid - def _calc_sync_dict(self, remote_container, unique, - raise_exception_on_error, name_case_sensitive): + def _calc_sync_dict( + self, + remote_container: Container, + unique: bool, + raise_exception_on_error: bool, + name_case_sensitive: bool, + ): # self is local, remote_container is remote. # which is to be synced with which: # sync_dict[local_entity]=sync_remote_enities - sync_dict = dict() + sync_dict: dict[Union[Container, Entity], + Optional[list[Entity]]] = dict() # list of remote entities which already have a local equivalent used_remote_entities = [] @@ -3144,7 +3463,8 @@ class Container(list): msg = "Request was not unique. CUID " + \ str(local_entity._cuid) + " was found " + \ str(len(sync_remote_entities)) + " times." - local_entity.add_message(Message(description=msg, type="Error")) + local_entity.add_message( + Message(description=msg, type="Error")) if raise_exception_on_error: raise MismatchingEntitiesError(msg) @@ -3169,7 +3489,8 @@ class Container(list): msg = "Request was not unique. ID " + \ str(local_entity.id) + " was found " + \ str(len(sync_remote_entities)) + " times." - local_entity.add_message(Message(description=msg, type="Error")) + local_entity.add_message( + Message(description=msg, type="Error")) if raise_exception_on_error: raise MismatchingEntitiesError(msg) @@ -3199,7 +3520,8 @@ class Container(list): msg = "Request was not unique. Path " + \ str(local_entity.path) + " was found " + \ str(len(sync_remote_entities)) + " times." - local_entity.add_message(Message(description=msg, type="Error")) + local_entity.add_message( + Message(description=msg, type="Error")) if raise_exception_on_error: raise MismatchingEntitiesError(msg) @@ -3229,7 +3551,8 @@ class Container(list): msg = "Request was not unique. Name " + \ str(local_entity.name) + " was found " + \ str(len(sync_remote_entities)) + " times." - local_entity.add_message(Message(description=msg, type="Error")) + local_entity.add_message( + Message(description=msg, type="Error")) if raise_exception_on_error: raise MismatchingEntitiesError(msg) @@ -3242,13 +3565,15 @@ class Container(list): sync_remote_entities.append(remote_entity) if len(sync_remote_entities) > 0: + # FIXME: How is this supposed to work? sync_dict[self] = sync_remote_entities if unique and len(sync_remote_entities) != 0: msg = "Request was not unique. There are " + \ str(len(sync_remote_entities)) + \ " entities which could not be matched to one of the requested ones." - remote_container.add_message(Message(description=msg, type="Error")) + remote_container.add_message( + Message(description=msg, type="Error")) if raise_exception_on_error: raise MismatchingEntitiesError(msg) @@ -3256,7 +3581,7 @@ class Container(list): return sync_dict @staticmethod - def _find_dependencies_in_container(container): + def _find_dependencies_in_container(container: Container): """Find elements in a container that are a dependency of another element of the same. Parameters @@ -3278,15 +3603,17 @@ class Container(list): dependent_references = set() dependencies = set() + container_item: Entity for container_item in container: item_id.add(container_item.id) for parents in container_item.get_parents(): is_parent.add(parents.id) + prop: Property for prop in container_item.get_properties(): - prop_dt = prop.datatype - if is_reference(prop_dt): + prop_dt: Union[DATATYPE, str, None] = prop.datatype + if prop_dt is not None and is_reference(prop_dt): # add only if it is a reference, not a simple property # Step 1: look for prop.value if prop.value is not None: @@ -3308,7 +3635,8 @@ class Container(list): if is_list_datatype(prop_dt): ref_name = get_list_datatype(prop_dt) try: - is_being_referenced.add(container.get_entity_by_name(ref_name).id) + is_being_referenced.add( + container.get_entity_by_name(ref_name).id) # type: ignore except KeyError: pass elif isinstance(prop_dt, str): @@ -3341,7 +3669,8 @@ class Container(list): return dependencies - def delete(self, raise_exception_on_error=True, flags=None, chunk_size=100): + def delete(self, raise_exception_on_error: bool = True, + flags: Optional[QueryDict] = None, chunk_size: int = 100): """Delete all entities in this container. Entities are identified via their id if present and via their @@ -3353,7 +3682,8 @@ class Container(list): """ item_count = len(self) - # Split Container in 'chunk_size'-sized containers (if necessary) to avoid error 414 Request-URI Too Long + # Split Container in 'chunk_size'-sized containers (if necessary) to avoid error 414 + # Request-URI Too Long if item_count > chunk_size: dependencies = Container._find_dependencies_in_container(self) @@ -3364,7 +3694,8 @@ class Container(list): if len(dependencies) == item_count: if raise_exception_on_error: te = TransactionError( - msg="The container is too large and with too many dependencies within to be deleted.", + msg=("The container is too large and with too many dependencies within to" + " be deleted."), container=self) raise te @@ -3449,8 +3780,14 @@ class Container(list): return self - def retrieve(self, query=None, unique=True, - raise_exception_on_error=True, sync=True, flags=None): + def retrieve( + self, + query: Union[str, list, None] = None, + unique: bool = True, + raise_exception_on_error: bool = True, + sync: bool = True, + flags: Optional[QueryDict] = None, + ): """Retrieve all entities in this container identified via their id if present and via their name otherwise. Any locally already existing attributes (name, description, ...) will be preserved. Any such @@ -3459,9 +3796,9 @@ class Container(list): If any entity has no id and no name a LinkAheadException will be raised. - Note: If only a name is given this could lead to ambiguities. All entities with the name in question - will be returned. Therefore, the container could contain more elements after the retrieval than - before. + Note: If only a name is given this could lead to ambiguities. All entities with the name in + question will be returned. Therefore, the container could contain more elements after the + retrieval than before. """ if isinstance(query, list): @@ -3525,7 +3862,7 @@ class Container(list): return (entities[0:hl], entities[hl:len(entities)]) - def _retrieve(self, entities, flags): + def _retrieve(self, entities, flags: Optional[QueryDict]): c = get_connection() try: _log_request("GET: " + _ENTITY_URI_SEGMENT + str(entities) + @@ -3558,7 +3895,8 @@ class Container(list): return self @staticmethod - def _dir_to_http_parts(root, d, upload): # @ReservedAssignment + # @ReservedAssignment + def _dir_to_http_parts(root: str, d: Optional[str], upload: str): ret = [] x = (root + '/' + d if d is not None else root) @@ -3579,8 +3917,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: @@ -3591,7 +3935,7 @@ class Container(list): self.clear_server_messages() insert_xml = etree.Element("Update") - http_parts = [] + http_parts: list[MultipartParam] = [] if flags is None: flags = {} @@ -3661,7 +4005,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) @@ -3702,29 +4048,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[QueryDict] = 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 = {} @@ -3738,25 +4098,25 @@ class Container(list): self._linearize() # TODO: This is a possible solution for ticket#137 -# retrieved = Container() -# for entity in self: -# if entity.is_valid(): -# retrieved.append(entity) -# if len(retrieved)>0: -# retrieved = retrieved.retrieve(raise_exception_on_error=False, sync=False) -# for e_remote in retrieved: -# if e_remote.id is not None: -# try: -# self.get_entity_by_id(e_remote.id).is_valid=e_remote.is_valid -# continue -# except KeyError: -# pass -# if e_remote.name is not None: -# try: -# self.get_entity_by_name(e_remote.name).is_valid=e_remote.is_valid -# continue -# except KeyError: -# pass + # retrieved = Container() + # for entity in self: + # if entity.is_valid(): + # retrieved.append(entity) + # if len(retrieved)>0: + # retrieved = retrieved.retrieve(raise_exception_on_error=False, sync=False) + # for e_remote in retrieved: + # if e_remote.id is not None: + # try: + # self.get_entity_by_id(e_remote.id).is_valid=e_remote.is_valid + # continue + # except KeyError: + # pass + # if e_remote.name is not None: + # try: + # self.get_entity_by_name(e_remote.name).is_valid=e_remote.is_valid + # continue + # except KeyError: + # pass for entity in self: if entity.is_valid(): continue @@ -3778,7 +4138,8 @@ class Container(list): if len(self) > 0 and len(insert_xml) < 1: te = TransactionError( - msg="There are no entities to be inserted. This container contains existent entities only.", + msg=("There are no entities to be inserted. This container contains existent" + " entities only."), container=self) raise te _log_request("POST: " + _ENTITY_URI_SEGMENT + @@ -3805,7 +4166,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) @@ -3820,7 +4180,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: @@ -3885,7 +4245,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. @@ -3941,7 +4303,8 @@ def sync_global_acl(): ACL.global_acl = ACL(xml=pelem) else: raise LinkAheadException( - "The server's response didn't contain the expected elements. The configuration of this client might be invalid (especially the url).") + "The server's response didn't contain the expected elements. The configuration of this" + " client might be invalid (especially the url).") def get_known_permissions(): @@ -3959,7 +4322,15 @@ def get_global_acl(): class ACI(): - def __init__(self, realm, username, role, permission): + """FIXME: Add docstring""" + + def __init__( + self, + realm: Optional[str], + username: Optional[str], + role: Optional[str], + permission: Optional[str], + ): self.role = role self.username = username self.realm = realm @@ -3969,36 +4340,43 @@ class ACI(): return hash(self.__repr__()) def __eq__(self, other): - return isinstance(other, ACI) and (self.role is None and self.username == other.username and self.realm == - other.realm) or self.role == other.role and self.permission == other.permission + return (isinstance(other, ACI) and + (self.role is None and self.username == other.username + and self.realm == other.realm) + or self.role == other.role and self.permission == other.permission) def __repr__(self): - return str(self.realm) + ":" + str(self.username) + ":" + str(self.role) + ":" + str(self.permission) + return ":".join([str(self.realm), str(self.username), str(self.role), str(self.permission)]) - def add_to_element(self, e): + def add_to_element(self, e: etree._Element): 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) class ACL(): + """FIXME: Add docstring""" - global_acl = None + global_acl: Optional[ACL] = None - def __init__(self, xml=None): + def __init__(self, xml: Optional[etree._Element] = None): if xml is not None: self.parse_xml(xml) else: self.clear() - def parse_xml(self, xml): + def parse_xml(self, xml: etree._Element): """Clear this ACL and parse the xml. Iterate over the rules in the xml and add each rule to this ACL. @@ -4007,14 +4385,14 @@ class ACL(): Parameters ---------- - xml : lxml.etree.Element + xml : lxml.etree._Element The xml element containing the ACL rules, i.e. <Grant> and <Deny> rules. """ self.clear() self._parse_xml(xml) - def _parse_xml(self, xml): + def _parse_xml(self, xml: etree._Element): """Parse the xml. Iterate over the rules in the xml and add each rule to this ACL. @@ -4023,7 +4401,7 @@ class ACL(): Parameters ---------- - xml : lxml.etree.Element + xml : lxml.etree._Element The xml element containing the ACL rules, i.e. <Grant> and <Deny> rules. """ @@ -4032,7 +4410,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": @@ -4047,7 +4425,7 @@ class ACL(): permission=permission, priority=priority, revoke_grant=False) - def combine(self, other): + def combine(self, other: ACL) -> ACL: """ Combine and return new instance.""" result = ACL() result._grants.update(other._grants) @@ -4062,22 +4440,26 @@ class ACL(): return result def __eq__(self, other): - return isinstance(other, ACL) and other._grants == self._grants and self._denials == other._denials and self._priority_grants == other._priority_grants and self._priority_denials == other._priority_denials + return (isinstance(other, ACL) + and other._grants == self._grants + and self._denials == other._denials + and self._priority_grants == other._priority_grants + and self._priority_denials == other._priority_denials) def is_empty(self): return len(self._grants) + len(self._priority_grants) + \ len(self._priority_denials) + len(self._denials) == 0 def clear(self): - self._grants = set() - self._denials = set() - self._priority_grants = set() - self._priority_denials = set() + self._grants: set[ACI] = set() + self._denials: set[ACI] = set() + self._priority_grants: set[ACI] = set() + self._priority_denials: set[ACI] = set() - def _get_boolean_priority(self, priority): + def _get_boolean_priority(self, priority: Any): return str(priority).lower() in ["true", "1", "yes", "y"] - def _remove_item(self, item, priority): + def _remove_item(self, item, priority: bool): try: self._denials.remove(item) except KeyError: @@ -4097,8 +4479,14 @@ class ACL(): except KeyError: pass - def revoke_grant(self, username=None, realm=None, - role=None, permission=None, priority=False): + def revoke_grant( + self, + username: Optional[str] = None, + realm: Optional[str] = None, + role: Optional[str] = None, + permission: Optional[str] = None, + priority: Union[bool, str] = False, + ): priority = self._get_boolean_priority(priority) item = ACI(role=role, username=username, realm=realm, permission=permission) @@ -4110,8 +4498,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) @@ -4123,8 +4517,15 @@ class ACL(): if item in self._denials: self._denials.remove(item) - def grant(self, permission, username=None, realm=None, role=None, - priority=False, revoke_denial=True): + def grant( + self, + permission: Optional[str], + username: Optional[str] = None, + realm: Optional[str] = None, + role: Optional[str] = None, + priority: bool = False, + revoke_denial: bool = True, + ): """Grant a permission to a user or role. You must specify either only the username and the realm, or only the @@ -4165,8 +4566,15 @@ class ACL(): else: self._grants.add(item) - def deny(self, username=None, realm=None, role=None, - permission=None, priority=False, revoke_grant=True): + def deny( + self, + username: Optional[str] = None, + realm: Optional[str] = None, + role: Optional[str] = None, + permission: Optional[str] = None, + priority: bool = False, + revoke_grant: bool = True, + ): """Deny a permission to a user or role for this entity. You must specify either only the username and the realm, or only the @@ -4207,7 +4615,7 @@ class ACL(): else: self._denials.add(item) - def to_xml(self, xml=None): + def to_xml(self, xml: Optional[etree._Element] = None): if xml is None: xml = etree.Element("EntityACL") @@ -4237,7 +4645,7 @@ class ACL(): return xml - def get_acl_for_role(self, role): + def get_acl_for_role(self, role: str) -> ACL: ret = ACL() for aci in self._grants: @@ -4258,7 +4666,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: @@ -4283,7 +4691,7 @@ class ACL(): return ret - def get_permissions_for_user(self, username, realm=None): + def get_permissions_for_user(self, username: str, realm: Optional[str] = None): acl = self.get_acl_for_user(username, realm) _grants = set() @@ -4304,7 +4712,7 @@ class ACL(): return ((_grants - _denials) | _priority_grants) - _priority_denials - def get_permissions_for_role(self, role): + def get_permissions_for_role(self, role: str): acl = self.get_acl_for_role(role) _grants = set() @@ -4337,8 +4745,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() @@ -4351,7 +4759,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 @@ -4362,20 +4770,25 @@ class Query(): def getFlag(self, key): return self.flags.get(key) - def __init__(self, q): - self.flags = dict() + def __init__(self, q: Union[str, etree._Element]): + self.flags: QueryDict = dict() self.messages = Messages() - self.cached = None + 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: @@ -4384,7 +4797,7 @@ class Query(): else: self.q = q - def _query_request(self, query_dict): + def _query_request(self, query_dict: QueryDict): """Used internally to execute the query request...""" _log_request("GET Entity?" + str(query_dict), None) connection = get_connection() @@ -4394,7 +4807,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: QueryDict, + 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: @@ -4406,12 +4824,18 @@ class Query(): next_page = self._query_request(query_dict) etag = next_page.query.etag if etag is not None and etag != self.etag: - raise PagingConsistencyError("The database state changed while retrieving the pages") + raise PagingConsistencyError( + "The database state changed while retrieving the pages") yield next_page index += page_length - def execute(self, unique=False, raise_exception_on_error=True, cache=True, - page_length=None): + def execute( + self, + unique: bool = False, + raise_exception_on_error: bool = True, + cache: bool = True, + page_length: Optional[int] = None, + ) -> Union[Container, int]: """Execute a query (via a server-requests) and return the results. Parameters @@ -4492,14 +4916,20 @@ 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 -def execute_query(q, unique=False, raise_exception_on_error=True, cache=True, - flags=None, page_length=None): +def execute_query( + q: str, + unique: bool = False, + raise_exception_on_error: bool = True, + cache: bool = True, + flags: Optional[QueryDict] = None, + page_length: Optional[int] = None, +) -> Union[Container, Entity, int]: """Execute a query (via a server-requests) and return the results. Parameters @@ -4538,7 +4968,7 @@ def execute_query(q, unique=False, raise_exception_on_error=True, cache=True, Returns ------- - results : Container or integer + results : Container or Entity or integer Returns an integer when it was a `COUNT` query. Otherwise, returns a Container with the resulting entities. """ @@ -4554,8 +4984,7 @@ def execute_query(q, unique=False, raise_exception_on_error=True, cache=True, class DropOffBox(list): def __init__(self, *args, **kwargs): - warn(DeprecationWarning( - "The DropOffBox is deprecated and will be removed in future.")) + warn(DeprecationWarning("The DropOffBox is deprecated and will be removed in future.")) super().__init__(*args, **kwargs) path = None @@ -4591,7 +5020,7 @@ class DropOffBox(list): class UserInfo(): - def __init__(self, xml): + def __init__(self, xml: etree._Element): self.roles = [role.text for role in xml.findall("Roles/Role")] self.name = xml.get("username") self.realm = xml.get("realm") @@ -4601,6 +5030,8 @@ class Info(): def __init__(self): self.messages = Messages() + self.user_info: Optional[UserInfo] = None + self.time_zone: Optional[TimeZone] = None self.sync() def sync(self): @@ -4641,7 +5072,7 @@ class Info(): class Permission(): - def __init__(self, name, description=None): + def __init__(self, name: str, description: Optional[str] = None): self.name = name self.description = description @@ -4663,21 +5094,25 @@ class Permission(): class Permissions(): - known_permissions = None + known_permissions: Optional[Permissions] = None - def __init__(self, xml): + def __init__(self, xml: etree._Element): self.parse_xml(xml) def clear(self): self._perms = set() - def parse_xml(self, xml): + def parse_xml(self, xml: etree._Element): self.clear() 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): @@ -4692,7 +5127,7 @@ class Permissions(): return str(self._perms) -def parse_xml(xml): +def parse_xml(xml: Union[str, etree._Element]): """parse a string or tree representation of an xml document to a set of entities (records, recordtypes, properties, or files). @@ -4701,24 +5136,27 @@ def parse_xml(xml): """ if isinstance(xml, etree._Element): - elem = xml + elem: etree._Element = xml else: elem = etree.fromstring(xml) return _parse_single_xml_element(elem) -def _parse_single_xml_element(elem): +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) @@ -4746,7 +5184,8 @@ def _parse_single_xml_element(elem): 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) @@ -4755,14 +5194,23 @@ def _parse_single_xml_element(elem): 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, ent): +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. @@ -4771,7 +5219,7 @@ def _evaluate_and_add_error(parent_error, ent): parent_error : TransactionError Parent error to which the new exception will be attached. This exception will be a direct child. - ent : Entity + ent : Entity or Container or QueryTemplate Entity that caused the TransactionError. An exception is created depending on its error message(s). @@ -4793,8 +5241,8 @@ def _evaluate_and_add_error(parent_error, ent): 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) @@ -4865,7 +5313,7 @@ def _evaluate_and_add_error(parent_error, ent): return parent_error -def raise_errors(arg0): +def raise_errors(arg0: Union[Entity, QueryTemplate, Container]): """Raise a TransactionError depending on the error code(s) inside Entity, QueryTemplate or Container arg0. More detailed errors may be attached to the TransactionError depending on the contents of @@ -4892,7 +5340,7 @@ def raise_errors(arg0): raise transaction_error -def delete(ids, 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/common/state.py b/src/linkahead/common/state.py index 82f314e80191163f14a5c4babdd749f977f2901b..e352f82d9820620d1692cb6337eb218210e799e6 100644 --- a/src/linkahead/common/state.py +++ b/src/linkahead/common/state.py @@ -19,11 +19,19 @@ # # ** end header +from __future__ import annotations # Can be removed with 3.10. import copy from lxml import etree +from typing import TYPE_CHECKING +import sys -def _translate_to_state_acis(acis): +if TYPE_CHECKING: + from typing import Optional + from linkahead.common.models import ACL, ACI + + +def _translate_to_state_acis(acis: set[ACI]) -> set[ACI]: result = set() for aci in acis: aci = copy.copy(aci) @@ -50,7 +58,13 @@ class Transition: A state name """ - def __init__(self, name, from_state, to_state, description=None): + def __init__( + self, + name: Optional[str], + from_state: Optional[str], + to_state: Optional[str], + description: Optional[str] = None, + ): self._name = name self._from_state = from_state self._to_state = to_state @@ -76,25 +90,29 @@ class Transition: return f'Transition(name="{self.name}", from_state="{self.from_state}", to_state="{self.to_state}", description="{self.description}")' def __eq__(self, other): - return (isinstance(other, Transition) - and other.name == self.name - and other.to_state == self.to_state - and other.from_state == self.from_state) + return ( + isinstance(other, Transition) + and other.name == self.name + and other.to_state == self.to_state + and other.from_state == self.from_state + ) def __hash__(self): return 23472 + hash(self.name) + hash(self.from_state) + hash(self.to_state) @staticmethod - def from_xml(xml): - to_state = [to.get("name") for to in xml - if to.tag.lower() == "tostate"] - from_state = [from_.get("name") for from_ in xml - if from_.tag.lower() == "fromstate"] - result = Transition(name=xml.get("name"), - description=xml.get("description"), - from_state=from_state[0] if from_state else None, - to_state=to_state[0] if to_state else None) - return result + def from_xml(xml: etree._Element) -> "Transition": + to_state = [to.get("name") + for to in xml if to.tag.lower() == "tostate"] + from_state = [ + from_.get("name") for from_ in xml if from_.tag.lower() == "fromstate" + ] + return Transition( + name=xml.get("name"), + description=xml.get("description"), + from_state=from_state[0] if from_state else None, + to_state=to_state[0] if to_state else None, + ) class State: @@ -119,12 +137,12 @@ class State: All transitions which are available from this state (read-only) """ - def __init__(self, model, name): + def __init__(self, model: Optional[str], name: Optional[str]): self.name = name self.model = model - self._id = None - self._description = None - self._transitions = None + self._id: Optional[str] = None + self._description: Optional[str] = None + self._transitions: Optional[set[Transition]] = None @property def id(self): @@ -139,9 +157,11 @@ class State: return self._transitions def __eq__(self, other): - return (isinstance(other, State) - and self.name == other.name - and self.model == other.model) + return ( + isinstance(other, State) + and self.name == other.name + and self.model == other.model + ) def __hash__(self): return hash(self.name) + hash(self.model) @@ -164,7 +184,7 @@ class State: return xml @staticmethod - def from_xml(xml): + def from_xml(xml: etree._Element): """Create a new State instance from an xml Element. Parameters @@ -175,24 +195,26 @@ class State: ------- state : State """ - name = xml.get("name") - model = xml.get("model") - result = State(name=name, model=model) + result = State(name=xml.get("name"), model=xml.get("model")) result._id = xml.get("id") result._description = xml.get("description") - transitions = [Transition.from_xml(t) for t in xml if t.tag.lower() == - "transition"] + transitions = [ + Transition.from_xml(t) for t in xml if t.tag.lower() == "transition" + ] if transitions: result._transitions = set(transitions) return result @staticmethod - def create_state_acl(acl): + def create_state_acl(acl: ACL): from .models import ACL + state_acl = ACL() state_acl._grants = _translate_to_state_acis(acl._grants) state_acl._denials = _translate_to_state_acis(acl._denials) - state_acl._priority_grants = _translate_to_state_acis(acl._priority_grants) - state_acl._priority_denials = _translate_to_state_acis(acl._priority_denials) + state_acl._priority_grants = _translate_to_state_acis( + acl._priority_grants) + state_acl._priority_denials = _translate_to_state_acis( + acl._priority_denials) return state_acl diff --git a/src/linkahead/common/timezone.py b/src/linkahead/common/timezone.py index 8fc5e710d3cbf6f20cf81397573f972db3b22f12..9ccb433c49b07e73a90bdad3ae6caaf2017a9c1d 100644 --- a/src/linkahead/common/timezone.py +++ b/src/linkahead/common/timezone.py @@ -1,3 +1,7 @@ +from __future__ import annotations +from typing import Optional + + class TimeZone(): """ TimeZone, e.g. CEST, Europe/Berlin, UTC+4. @@ -7,13 +11,13 @@ class TimeZone(): ---------- zone_id : string ID of the time zone. - offset : int - Offset to UTC in seconds. + offset : str + Offset to UTC, e.g. "+1400" display_name : string A human-friendly name of the time zone: """ - def __init__(self, zone_id, offset, display_name): + def __init__(self, zone_id: Optional[str], offset: Optional[str], display_name: str): self.zone_id = zone_id self.offset = offset self.display_name = display_name diff --git a/src/linkahead/common/versioning.py b/src/linkahead/common/versioning.py index facfbc488e413e090ea1a856501ccd96334f8354..2e292e6bb031725fbd6da618c4b888c05072c46b 100644 --- a/src/linkahead/common/versioning.py +++ b/src/linkahead/common/versioning.py @@ -26,10 +26,14 @@ Currently this module defines nothing but a single class, `Version`. """ -from __future__ import absolute_import +from __future__ import annotations from .utils import xml2str from lxml import etree +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from typing import Optional, List, Union + class Version(): """The version of an entity. @@ -95,9 +99,11 @@ class Version(): """ # pylint: disable=redefined-builtin - def __init__(self, id=None, date=None, username=None, realm=None, - predecessors=None, successors=None, is_head=False, - is_complete_history=False): + def __init__(self, id: Optional[str] = None, date: Optional[str] = None, + username: Optional[str] = None, realm: Optional[str] = None, + predecessors: Optional[List[Version]] = None, successors: Optional[List[Version]] = None, + is_head: Union[bool, str, None] = False, + is_complete_history: Union[bool, str, None] = False): """Typically the `predecessors` or `successors` should not "link back" to an existing Version object.""" self.id = id @@ -109,7 +115,7 @@ object.""" self.is_head = str(is_head).lower() == "true" self.is_complete_history = str(is_complete_history).lower() == "true" - def get_history(self): + def get_history(self) -> List[Version]: """ Returns a flat list of Version instances representing the history of the entity. @@ -126,7 +132,7 @@ object.""" ------- list of Version """ - versions = [] + versions: List[Version] = [] for p in self.predecessors: # assuming that predecessors don't have any successors versions = p.get_history() @@ -137,7 +143,7 @@ object.""" versions.extend(s.get_history()) return versions - def to_xml(self, tag="Version"): + def to_xml(self, tag: str = "Version") -> etree._Element: """Serialize this version to xml. The tag name is 'Version' per default. But since this method is called @@ -184,7 +190,7 @@ object.""" return xml2str(self.to_xml()) @staticmethod - def from_xml(xml): + def from_xml(xml: etree._Element) -> Version: """Parse a version object from a 'Version' xml element. Parameters diff --git a/src/linkahead/configuration.py b/src/linkahead/configuration.py index 55a400459b1f3c9ea93d10ddf8c78765aa323eaa..f57289d7dcb6d7ab062024dc697dbda557670d7a 100644 --- a/src/linkahead/configuration.py +++ b/src/linkahead/configuration.py @@ -21,14 +21,14 @@ # # ** end header # - +from __future__ import annotations import os import warnings import yaml try: - optional_jsonschema_validate = None + optional_jsonschema_validate: Optional[Callable] = None from jsonschema import validate as optional_jsonschema_validate except ImportError: pass @@ -37,13 +37,17 @@ from configparser import ConfigParser from os import environ, getcwd from os.path import expanduser, isfile, join +from typing import Union, Callable, Optional + +_pycaosdbconf = ConfigParser(allow_no_value=False) + def _reset_config(): global _pycaosdbconf _pycaosdbconf = ConfigParser(allow_no_value=False) -def configure(inifile): +def configure(inifile: str) -> list[str]: """read config from file. Return a list of files which have successfully been parsed. @@ -61,15 +65,15 @@ def configure(inifile): return read_config -def get_config(): +def get_config() -> ConfigParser: global _pycaosdbconf if ("_pycaosdbconf" not in globals() or _pycaosdbconf is None): _reset_config() return _pycaosdbconf -def config_to_yaml(config): - valobj = {} +def config_to_yaml(config: ConfigParser) -> dict[str, dict[str, Union[int, str, bool]]]: + valobj: dict[str, dict[str, Union[int, str, bool]]] = {} for s in config.sections(): valobj[s] = {} for key, value in config[s].items(): @@ -84,7 +88,7 @@ def config_to_yaml(config): return valobj -def validate_yaml_schema(valobj): +def validate_yaml_schema(valobj: dict[str, dict[str, Union[int, str, bool]]]): if optional_jsonschema_validate: with open(os.path.join(os.path.dirname(__file__), "schema-pycaosdb-ini.yml")) as f: schema = yaml.load(f, Loader=yaml.SafeLoader) @@ -95,10 +99,10 @@ def validate_yaml_schema(valobj): """) -def _read_config_files(): +def _read_config_files() -> list[str]: """Read config files from different paths. - Read the config from either ``$PYCAOSDBINI`` or home directory (``~/.pylinkahead.ini``), and + Read the config from either ``$PYLINKAHEADINI`` or home directory (``~/.pylinkahead.ini``), and additionally adds config from a config file in the current working directory (``pylinkahead.ini``). If deprecated names are used (starting with 'pycaosdb'), those used in addition but the files @@ -127,15 +131,18 @@ def _read_config_files(): warnings.warn("\n\nYou have a config file with the old naming scheme (pycaosdb.ini). " f"Please use the new version and rename\n" f" {ini_cwd_caosdb}\nto\n {ini_cwd}", DeprecationWarning) + if "PYCAOSDBINI" in environ: + warnings.warn("\n\nYou have an environment variable PYCAOSDBINI. " + "Please rename it to PYLINKAHEADINI.") # End: LinkAhead rename block ################################################## - if "PYCAOSDBINI" in environ: - if not isfile(expanduser(environ["PYCAOSDBINI"])): + if "PYLINKAHEADINI" in environ: + if not isfile(expanduser(environ["PYLINKAHEADINI"])): raise RuntimeError( - f"No configuration file found at\n{expanduser(environ['PYCAOSDBINI'])}" - "\nwhich was given via the environment variable PYCAOSDBINI" + f"No configuration file found at\n{expanduser(environ['PYLINKAHEADINI'])}" + "\nwhich was given via the environment variable PYLINKAHEADINI" ) - return_var.extend(configure(expanduser(environ["PYCAOSDBINI"]))) + return_var.extend(configure(expanduser(environ["PYLINKAHEADINI"]))) else: if isfile(ini_user_caosdb): return_var.extend(configure(ini_user_caosdb)) diff --git a/src/linkahead/connection/authentication/external_credentials_provider.py b/src/linkahead/connection/authentication/external_credentials_provider.py index 3d1b8afa17f58a87f09afba90c4bc7ae6dcba693..8e22c5c5796603dcc6acfbe3eb9345b8fb2e2f4e 100644 --- a/src/linkahead/connection/authentication/external_credentials_provider.py +++ b/src/linkahead/connection/authentication/external_credentials_provider.py @@ -23,13 +23,10 @@ # """external_credentials_provider.""" from __future__ import absolute_import, unicode_literals -from abc import ABCMeta +from abc import ABC import logging from .plain import PlainTextCredentialsProvider -# meta class compatible with Python 2 *and* 3: -ABC = ABCMeta(str('ABC'), (object, ), {str('__slots__'): ()}) - class ExternalCredentialsProvider(PlainTextCredentialsProvider, ABC): """ExternalCredentialsProvider. diff --git a/src/linkahead/connection/authentication/input.py b/src/linkahead/connection/authentication/input.py index 2799207354b3949063461229d7d465e8a83c83ae..14e8196e72a9e6266631511cf36d5e8c0c5c68c4 100644 --- a/src/linkahead/connection/authentication/input.py +++ b/src/linkahead/connection/authentication/input.py @@ -25,13 +25,13 @@ A CredentialsProvider which reads the password from the input line. """ -from __future__ import absolute_import, unicode_literals, print_function +from __future__ import annotations from .interface import CredentialsProvider, CredentialsAuthenticator - +from typing import Optional import getpass -def get_authentication_provider(): +def get_authentication_provider() -> CredentialsAuthenticator: """get_authentication_provider. Return an authenticator which uses the input for username/password credentials. @@ -61,8 +61,8 @@ class InputCredentialsProvider(CredentialsProvider): def __init__(self): super(InputCredentialsProvider, self).__init__() - self._password = None - self._username = None + self._password: Optional[str] = None + self._username: Optional[str] = None def configure(self, **config): """configure. diff --git a/src/linkahead/connection/authentication/interface.py b/src/linkahead/connection/authentication/interface.py index 6de43b81f441ab60401c1c01885eaa514790d3de..b48e27c08312bf1358d32a9a1203627a9d0007c2 100644 --- a/src/linkahead/connection/authentication/interface.py +++ b/src/linkahead/connection/authentication/interface.py @@ -1,10 +1,11 @@ # -*- coding: utf-8 -*- # -# ** header v3.0 # This file is a part of the LinkAhead Project. # # Copyright (C) 2018 Research Group Biomedical Physics, # Max-Planck-Institute for Dynamics and Self-Organization Göttingen +# Copyright (C) 2024 Indiscale GmbH <info@indiscale.com> +# Copyright (C) 2024 Joscha Schmiedt <joscha@schmiedt.dev> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -18,23 +19,25 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <https://www.gnu.org/licenses/>. -# -# ** end header -# + """This module provides the interfaces for authenticating requests to the LinkAhead server. -Implementing modules muts provide a `get_authentication_provider()` method. +Implementing modules must provide a `get_authentication_provider()` method. """ -from abc import ABCMeta, abstractmethod, abstractproperty + +from __future__ import annotations +from abc import ABC, abstractmethod import logging from ..utils import urlencode from ..interface import CaosDBServerConnection from ..utils import parse_auth_token, auth_token_to_cookie from ...exceptions import LoginFailedError +from typing import TYPE_CHECKING, Optional +if TYPE_CHECKING: + from ..interface import CaosDBHTTPResponse + QueryDict = dict[str, Optional[str]] -# meta class compatible with Python 2 *and* 3: -ABC = ABCMeta('ABC', (object, ), {'__slots__': ()}) _LOGGER = logging.getLogger(__name__) @@ -108,7 +111,7 @@ class AbstractAuthenticator(ABC): """ pass - def on_response(self, response): + def on_response(self, response: CaosDBHTTPResponse): """on_response. A call-back with is to be called by the connection after each @@ -125,7 +128,7 @@ class AbstractAuthenticator(ABC): self.auth_token = parse_auth_token( response.getheader("Set-Cookie")) - def on_request(self, method, path, headers, **kwargs): + def on_request(self, method: str, path: str, headers: QueryDict, **kwargs): # pylint: disable=unused-argument """on_request. @@ -262,10 +265,12 @@ class CredentialsProvider(ABC): None """ - @abstractproperty + @property + @abstractmethod def password(self): """password.""" - @abstractproperty + @property + @abstractmethod def username(self): """username.""" diff --git a/src/linkahead/connection/authentication/keyring.py b/src/linkahead/connection/authentication/keyring.py index 202520bbab7e940ccce6517e640eff5904039553..dad6ed9b4ad77175db6b75e30b70152878da487d 100644 --- a/src/linkahead/connection/authentication/keyring.py +++ b/src/linkahead/connection/authentication/keyring.py @@ -35,7 +35,7 @@ from .external_credentials_provider import ExternalCredentialsProvider from .interface import CredentialsAuthenticator -def get_authentication_provider(): +def get_authentication_provider() -> CredentialsAuthenticator: """get_authentication_provider. Return an authenticator which uses plain text username/password credentials. diff --git a/src/linkahead/connection/authentication/pass.py b/src/linkahead/connection/authentication/pass.py index bec307401f945a6cd2e223195e0cce2396602061..81a901523fcad65680d34947cd9cc741e06a0352 100644 --- a/src/linkahead/connection/authentication/pass.py +++ b/src/linkahead/connection/authentication/pass.py @@ -33,7 +33,7 @@ from .interface import CredentialsAuthenticator from .external_credentials_provider import ExternalCredentialsProvider -def get_authentication_provider(): +def get_authentication_provider() -> CredentialsAuthenticator: """get_authentication_provider. Return an authenticator which uses plain text username/password credentials. diff --git a/src/linkahead/connection/connection.py b/src/linkahead/connection/connection.py index 91b4a01da455d0f365e39b0b0f7359e07096e707..294d9457d064f03bbe06a3347b2d2064dcf12b8c 100644 --- a/src/linkahead/connection/connection.py +++ b/src/linkahead/connection/connection.py @@ -1,11 +1,11 @@ # -*- coding: utf-8 -*- -# -# ** header v3.0 # This file is a part of the LinkAhead Project. # # Copyright (C) 2018 Research Group Biomedical Physics, # Max-Planck-Institute for Dynamics and Self-Organization Göttingen # Copyright (c) 2019 Daniel Hornung +# Copyright (C) 2024 Indiscale GmbH <info@indiscale.com> +# Copyright (C) 2024 Joscha Schmiedt <joscha@schmiedt.dev> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -23,7 +23,7 @@ # ** end header # """Connection to a LinkAhead server.""" -from __future__ import absolute_import, print_function, unicode_literals +from __future__ import annotations import logging import ssl @@ -32,8 +32,7 @@ import warnings from builtins import str # pylint: disable=redefined-builtin from errno import EPIPE as BrokenPipe from socket import error as SocketError -from urllib.parse import quote, urlparse -from warnings import warn +from urllib.parse import ParseResult, quote, urlparse from requests import Session as HTTPSession from requests.adapters import HTTPAdapter @@ -52,22 +51,28 @@ try: except ModuleNotFoundError: version = "uninstalled" -from pkg_resources import resource_filename - from .encode import MultipartYielder, ReadableMultiparts from .interface import CaosDBHTTPResponse, CaosDBServerConnection -from .utils import make_uri_path, parse_url, urlencode +from .utils import make_uri_path, urlencode + +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from typing import Optional, Any, Iterator, Union + from requests.models import Response + from ssl import _SSLMethod + from .authentication.interface import AbstractAuthenticator, CredentialsAuthenticator + _LOGGER = logging.getLogger(__name__) class _WrappedHTTPResponse(CaosDBHTTPResponse): - def __init__(self, response): - self.response = response - self._generator = None - self._buffer = b'' - self._stream_consumed = False + def __init__(self, response: Response): + self.response: Response = response + self._generator: Optional[Iterator[Any]] = None + self._buffer: Optional[bytes] = b'' + self._stream_consumed: bool = False @property def reason(self): @@ -77,7 +82,7 @@ class _WrappedHTTPResponse(CaosDBHTTPResponse): def status(self): return self.response.status_code - def read(self, size=None): + def read(self, size: Optional[int] = None): if self._stream_consumed is True: raise RuntimeError("Stream is consumed") @@ -91,8 +96,13 @@ class _WrappedHTTPResponse(CaosDBHTTPResponse): self._stream_consumed = True return self.response.content + if size is None or size == 0: + raise RuntimeError( + "size parameter should not be None if the stream is not consumed yet") + if len(self._buffer) >= size: # still enough bytes in the buffer + # FIXME: `chunk`` is used before definition result = chunk[:size] self._buffer = chunk[size:] return result @@ -117,7 +127,7 @@ class _WrappedHTTPResponse(CaosDBHTTPResponse): self._buffer = None return result - def getheader(self, name, default=None): + def getheader(self, name: str, default=None): return self.response.headers[name] if name in self.response.headers else default def getheaders(self): @@ -130,7 +140,7 @@ class _WrappedHTTPResponse(CaosDBHTTPResponse): class _SSLAdapter(HTTPAdapter): """Transport adapter that allows us to use different SSL versions.""" - def __init__(self, ssl_version): + def __init__(self, ssl_version: _SSLMethod): self.ssl_version = ssl_version super().__init__() @@ -156,7 +166,11 @@ class _DefaultCaosDBServerConnection(CaosDBServerConnection): self._session = None self._timeout = None - def request(self, method, path, headers=None, body=None): + def request(self, + method: str, path: str, + headers: Optional[dict[str, str]] = None, + body: Union[str, bytes, None] = None, + **kwargs) -> _WrappedHTTPResponse: """request. Send a HTTP request to the server. @@ -169,14 +183,14 @@ class _DefaultCaosDBServerConnection(CaosDBServerConnection): An URI path segment (without the 'scheme://host:port/' parts), including query and frament segments. headers : dict of str -> str, optional - HTTP request headers. (Defautl: None) + HTTP request headers. (Default: None) body : str or bytes or readable, optional The body of the HTTP request. Bytes should be a utf-8 encoded string. Returns ------- - response : CaosDBHTTPResponse + response : _WrappedHTTPResponse """ if headers is None: @@ -232,14 +246,16 @@ class _DefaultCaosDBServerConnection(CaosDBServerConnection): "No connection url specified. Please " "do so via linkahead.configure_connection(...) or in a config " "file.") - if (not config["url"].lower().startswith("https://") and not config["url"].lower().startswith("http://")): + url_string: str = config["url"] + if (not url_string.lower().startswith("https://") + and not url_string.lower().startswith("http://")): raise LinkAheadConnectionError("The connection url is expected " "to be a http or https url and " "must include the url scheme " "(i.e. start with https:// or " "http://).") - url = urlparse(config["url"]) + url: ParseResult = urlparse(url=url_string) path = url.path.strip("/") if len(path) > 0: path = path + "/" @@ -271,7 +287,7 @@ class _DefaultCaosDBServerConnection(CaosDBServerConnection): if "timeout" in config: self._timeout = config["timeout"] - def _setup_ssl(self, config): + def _setup_ssl(self, config: dict[str, Any]): if "ssl_version" in config and config["cacert"] is not None: ssl_version = getattr(ssl, config["ssl_version"]) else: @@ -325,7 +341,7 @@ _DEFAULT_CONF = { } -def _get_authenticator(**config): +def _get_authenticator(**config) -> AbstractAuthenticator: """_get_authenticator. Import and configure the password_method. @@ -337,7 +353,7 @@ def _get_authenticator(**config): Currently, there are four valid values for this parameter: 'plain', 'pass', 'keyring' and 'auth_token'. **config : - Any other keyword arguments are passed the configre method of the + Any other keyword arguments are passed the configure method of the password_method. Returns @@ -413,7 +429,9 @@ def configure_connection(**kwargs): auth_token : str (optional) An authentication token which has been issued by the LinkAhead Server. - Implies `password_method="auth_token"` if set. An example token string would be `["O","OneTimeAuthenticationToken","anonymous",["administration"],[],1592995200000,604800000,"3ZZ4WKRB-5I7DG2Q6-ZZE6T64P-VQ","197d0d081615c52dc18fb323c300d7be077beaad4020773bb58920b55023fa6ee49355e35754a4277b9ac525c882bcd3a22e7227ba36dfcbbdbf8f15f19d1ee9",1,30000]`. + Implies `password_method="auth_token"` if set. An example token string would be + ``["O","OneTimeAuthenticationToken","anonymous",["administration"],[],1592995200000, + 604800000,"3ZZ4WKRB-5I7DG2Q6-ZZE6T64P-VQ","197d0d081615...1ee9",1,30000]``. https_proxy : str, optional Define a proxy for the https connections, e.g. `http://localhost:8888`, @@ -534,8 +552,8 @@ class _Connection(object): # pylint: disable=useless-object-inheritance __instance = None def __init__(self): - self._delegate_connection = None - self._authenticator = None + self._delegate_connection: Optional[CaosDBServerConnection] = None + self._authenticator: Optional[CredentialsAuthenticator] = None self.is_configured = False @classmethod @@ -553,7 +571,8 @@ class _Connection(object): # pylint: disable=useless-object-inheritance "Missing CaosDBServerConnection implementation. You did not " "specify an `implementation` for the connection.") try: - self._delegate_connection = config["implementation"]() + self._delegate_connection: CaosDBServerConnection = config["implementation"]( + ) if not isinstance(self._delegate_connection, CaosDBServerConnection): @@ -579,14 +598,19 @@ class _Connection(object): # pylint: disable=useless-object-inheritance return self - def retrieve(self, entity_uri_segments=None, query_dict=None, **kwargs): + def retrieve(self, + entity_uri_segments: Optional[list[str]] = None, + query_dict: Optional[dict[str, Optional[str]]] = None, + **kwargs) -> CaosDBHTTPResponse: path = make_uri_path(entity_uri_segments, query_dict) http_response = self._http_request(method="GET", path=path, **kwargs) return http_response - def delete(self, entity_uri_segments=None, query_dict=None, **kwargs): + def delete(self, entity_uri_segments: Optional[list[str]] = None, + query_dict: Optional[dict[str, Optional[str]]] = None, **kwargs) -> ( + CaosDBHTTPResponse): path = make_uri_path(entity_uri_segments, query_dict) http_response = self._http_request( @@ -594,15 +618,18 @@ class _Connection(object): # pylint: disable=useless-object-inheritance return http_response - def update(self, entity_uri_segment, query_dict=None, **kwargs): + def update(self, entity_uri_segment: Optional[list[str]], + query_dict: Optional[dict[str, Optional[str]]] = None, **kwargs) -> ( + CaosDBHTTPResponse): path = make_uri_path(entity_uri_segment, query_dict) http_response = self._http_request(method="PUT", path=path, **kwargs) return http_response - def activate_user(self, link): - self._authenticator.logout() + def activate_user(self, link: str) -> CaosDBHTTPResponse: + if self._authenticator is not None: + self._authenticator.logout() fullurl = urlparse(link) path = fullurl.path query = fullurl.query @@ -611,17 +638,19 @@ class _Connection(object): # pylint: disable=useless-object-inheritance return http_response - def put_form_data(self, entity_uri_segment, params): + def put_form_data(self, entity_uri_segment: str, params) -> CaosDBHTTPResponse: return self._form_data_request( method="PUT", path=entity_uri_segment, params=params) - def post_form_data(self, entity_uri_segment, params): + def post_form_data(self, entity_uri_segment: str, params: dict[str, Optional[str]]) -> ( + CaosDBHTTPResponse): return self._form_data_request( method="POST", path=entity_uri_segment, params=params) - def _form_data_request(self, method, path, params): + def _form_data_request(self, method: str, path: str, params: dict[str, Optional[str]]) -> ( + CaosDBHTTPResponse): body = urlencode(params) headers = {} headers["Content-Type"] = "application/x-www-form-urlencoded" @@ -633,7 +662,9 @@ class _Connection(object): # pylint: disable=useless-object-inheritance return response - def insert(self, entity_uri_segment, query_dict=None, body=None, **kwargs): + def insert(self, entity_uri_segment: Optional[list[str]], + query_dict: Optional[dict[str, Optional[str]]] = None, + body: Union[str, bytes, None] = None, **kwargs) -> CaosDBHTTPResponse: path = make_uri_path(entity_uri_segment, query_dict) http_response = self._http_request( @@ -641,7 +672,7 @@ class _Connection(object): # pylint: disable=useless-object-inheritance return http_response - def download_file(self, path): + def download_file(self, path: str): """This function downloads a file via HTTP from the LinkAhead file system.""" try: @@ -658,7 +689,9 @@ class _Connection(object): # pylint: disable=useless-object-inheritance def _logout(self): self._authenticator.logout() - def _http_request(self, method, path, headers=None, body=None, **kwargs): + def _http_request(self, method: str, path: str, + headers: Optional[dict["str", Any]] = None, + body: Union[str, bytes, None] = None, **kwargs): try: return self._retry_http_request(method=method, path=path, headers=headers, body=body, @@ -681,16 +714,30 @@ class _Connection(object): # pylint: disable=useless-object-inheritance **kwargs) raise - def _retry_http_request(self, method, path, headers, body, **kwargs): + def _retry_http_request(self, + method: str, + path: str, + headers: Optional[dict["str", Any]], + body: Union[str, bytes, None], **kwargs) -> CaosDBHTTPResponse: - if hasattr(body, "encode"): + if hasattr(body, "encode") and body is not None: # python3 body = body.encode("utf-8") if headers is None: headers = {} + + if self._authenticator is None: + raise ValueError( + "No authenticator set. Please call configure_connection() first.") + self._authenticator.on_request(method=method, path=path, headers=headers) + + if self._delegate_connection is None: + raise ValueError( + "No connection set. Please call configure_connection() first.") + _LOGGER.debug("request: %s %s %s", method, path, str(headers)) http_response = self._delegate_connection.request( method=method, @@ -704,10 +751,16 @@ class _Connection(object): # pylint: disable=useless-object-inheritance return http_response - def get_username(self): + def get_username(self) -> str: """ Return the username of the current connection. Shortcut for: get_connection()._authenticator._credentials_provider.username """ + if self._authenticator is None: + raise ValueError( + "No authenticator set. Please call configure_connection() first.") + if self._authenticator._credentials_provider is None: + raise ValueError( + "No credentials provider set. Please call configure_connection() first.") return self._authenticator._credentials_provider.username diff --git a/src/linkahead/connection/encode.py b/src/linkahead/connection/encode.py index 6b328285e97e4dce2483ddd955134ee64cd3ce84..a76197803c9652e2d0c4e32819ee3e3f97758bfc 100644 --- a/src/linkahead/connection/encode.py +++ b/src/linkahead/connection/encode.py @@ -5,6 +5,8 @@ # # Copyright (C) 2018 Research Group Biomedical Physics, # Max-Planck-Institute for Dynamics and Self-Organization Göttingen +# Copyright (C) 2024 Indiscale GmbH <info@indiscale.com> +# Copyright (C) 2024 Joscha Schmiedt <joscha@schmiedt.dev> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -48,6 +50,7 @@ as multipart/form-data suitable for a HTTP POST or PUT request. multipart/form-data is the standard way to upload files over HTTP """ +from __future__ import annotations __all__ = [ 'gen_boundary', 'encode_and_quote', 'MultipartParam', 'encode_string', @@ -61,6 +64,10 @@ import re import os import mimetypes from email.header import Header +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Optional def gen_boundary(): @@ -68,7 +75,7 @@ def gen_boundary(): return uuid.uuid4().hex -def encode_and_quote(data): +def encode_and_quote(data: Optional[str]) -> Optional[str]: """If ``data`` is unicode, return urllib.quote_plus(data.encode("utf-8")) otherwise return urllib.quote_plus(data)""" if data is None: @@ -111,7 +118,7 @@ class MultipartParam(object): """ def __init__(self, - name, + name: str, value=None, filename=None, filetype=None, @@ -147,14 +154,6 @@ class MultipartParam(object): except BaseException: raise ValueError("Could not determine filesize") - def __cmp__(self, other): - attrs = [ - 'name', 'value', 'filename', 'filetype', 'filesize', 'fileobj' - ] - myattrs = [getattr(self, a) for a in attrs] - oattrs = [getattr(other, a) for a in attrs] - return cmp(myattrs, oattrs) - def reset(self): """Reset the file object's read pointer.""" if self.fileobj is not None: diff --git a/src/linkahead/connection/interface.py b/src/linkahead/connection/interface.py index d63dbeb8cc4cd59e056823440948aa54906dd47c..fc22577dffb4f2e0d30924324cd7a4901d2c8b1a 100644 --- a/src/linkahead/connection/interface.py +++ b/src/linkahead/connection/interface.py @@ -22,11 +22,14 @@ # ** end header # """This module defines the CaosDBServerConnection interface.""" -from abc import ABCMeta, abstractmethod, abstractproperty +from __future__ import annotations +from abc import ABCMeta, abstractmethod, ABC from warnings import warn -# meta class compatible with Python 2 *and* 3: -ABC = ABCMeta('ABC', (object, ), {'__slots__': ()}) + +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from typing import Optional, Union class CaosDBHTTPResponse(ABC): @@ -34,14 +37,14 @@ class CaosDBHTTPResponse(ABC): LinkAheadServer.""" @abstractmethod - def read(self, size=-1): + def read(self, size: Optional[int] = -1): """Read up to *size* bytes from the response body. If size is unspecified or -1, all bytes until EOF are returned. """ @abstractmethod - def getheader(self, name, default=None): + def getheader(self, name: str, default=None): """Return the value of the header *name* or the value of *default* if there is no such header. @@ -50,12 +53,13 @@ class CaosDBHTTPResponse(ABC): are returned likewise. """ - @abstractproperty - def status(self): + @property + @abstractmethod + def status(self) -> int: """Status code of the response.""" @abstractmethod - def getheaders(self): + def getheaders(self) -> dict[str, str]: """Return all headers.""" def __enter__(self): @@ -78,7 +82,12 @@ class CaosDBServerConnection(ABC): LinkAhead server.""" @abstractmethod - def request(self, method, path, headers=None, body=None, **kwargs): + def request(self, + method: str, + path: str, + headers: Optional[dict[str, str]] = None, + body: Union[str, bytes, None] = None, + **kwargs) -> CaosDBHTTPResponse: """Abstract method. Implement this method for HTTP requests to the LinkAhead server. diff --git a/src/linkahead/connection/utils.py b/src/linkahead/connection/utils.py index 90ec6b5ba6789747f5d4452a1260306b716b1f7e..deb97f808ea7bb8e6e35a206d1da66e18a39b7eb 100644 --- a/src/linkahead/connection/utils.py +++ b/src/linkahead/connection/utils.py @@ -5,6 +5,8 @@ # # Copyright (C) 2018 Research Group Biomedical Physics, # Max-Planck-Institute for Dynamics and Self-Organization Göttingen +# Copyright (C) 2024 Indiscale GmbH <info@indiscale.com> +# Copyright (C) 2024 Joscha Schmiedt <joscha@schmiedt.dev> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -22,14 +24,18 @@ # ** end header # """Utility functions for the connection module.""" -from __future__ import unicode_literals, print_function -from builtins import str as unicode -from urllib.parse import (urlencode as _urlencode, quote as _quote, - urlparse, urlunparse, unquote as _unquote) +from __future__ import annotations + import re +from urllib.parse import (urlencode as _urlencode, quote as _quote, + urlparse, urlunparse, unquote) +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from typing import Optional -def urlencode(query): + +def urlencode(query: dict[str, Optional[str]]) -> str: """Convert a dict of into a url-encoded (unicode) string. This is basically a python2/python3 compatibility wrapper for the respective @@ -79,7 +85,8 @@ modules when they are called with only the query parameter. })) -def make_uri_path(segments=None, query=None): +def make_uri_path(segments: Optional[list[str]] = None, + query: Optional[dict[str, Optional[str]]] = None) -> str: """Url-encode all segments, concat them with slashes and append the query. Examples @@ -105,22 +112,25 @@ def make_uri_path(segments=None, query=None): """ path_no_query = ("/".join([quote(segment) for segment in segments]) if segments else "") - return str(path_no_query if query is None else "?".join([ + if query is None: + return str(path_no_query) + + return str("?".join([ path_no_query, "&".join([ quote(key) + "=" + - (quote(query[key]) if query[key] is not None else "") + (quote(query[key]) if query[key] is not None else "") # type: ignore for key in query ]) ])) -def quote(string): +def quote(string: str) -> str: enc = string.encode('utf-8') return _quote(enc).replace('/', '%2F') -def parse_url(url): - fullurl = urlparse(url) +def parse_url(url: str): + fullurl = urlparse(url=url) # make sure the path ends with a slash if not fullurl.path.endswith("/"): parse_result = list(fullurl) @@ -132,19 +142,7 @@ def parse_url(url): _PATTERN = re.compile(r"^SessionToken=([^;]*);.*$") -def unquote(string): - """unquote. - - Decode an urlencoded string into a plain text string. - """ - bts = _unquote(string) - if hasattr(bts, "decode"): - # python 2 - return bts.decode("utf-8") - return bts - - -def parse_auth_token(cookie): +def parse_auth_token(cookie: Optional[str]) -> Optional[str]: """parse_auth_token. Parse an auth token from a cookie. @@ -165,7 +163,7 @@ def parse_auth_token(cookie): return auth_token -def auth_token_to_cookie(auth_token): +def auth_token_to_cookie(auth_token: str) -> str: """auth_token_to_cookie. Urlencode an auth token string and format it as a cookie. 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/src/linkahead/high_level_api.py b/src/linkahead/high_level_api.py index 70f1be36283b706f8d38d450d937ab13a9b9e699..18d219c732672d16d0ab43e562cfe73d682614fe 100644 --- a/src/linkahead/high_level_api.py +++ b/src/linkahead/high_level_api.py @@ -23,7 +23,7 @@ # # ** end header # - +# type: ignore """ A high level API for accessing LinkAhead entities from within python. diff --git a/src/linkahead/py.typed b/src/linkahead/py.typed new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/linkahead/utils/get_entity.py b/src/linkahead/utils/get_entity.py index 282f7c86e10571d0e0d62b93da7f61bba5205cba..f84dc107e275390e53c6127834f53e5e5c6521cd 100644 --- a/src/linkahead/utils/get_entity.py +++ b/src/linkahead/utils/get_entity.py @@ -21,27 +21,33 @@ """Convenience functions to retrieve a specific entity.""" -from typing import Union +from typing import Union, Optional from ..common.models import Entity, execute_query from .escape import escape_squoted_text -def get_entity_by_name(name: str) -> Entity: +def get_entity_by_name(name: str, role: Optional[str] = None) -> Entity: """Return the result of a unique query that uses the name to find the correct entity. Submits the query "FIND ENTITY WITH name='{name}'". """ name = escape_squoted_text(name) - return execute_query(f"FIND ENTITY WITH name='{name}'", unique=True) + if role is None: + role = "ENTITY" + # type hint can be ignored, it's a unique query, so never Container or int + return execute_query(f"FIND {role} WITH name='{name}'", unique=True) # type: ignore -def get_entity_by_id(eid: Union[str, int]) -> Entity: +def get_entity_by_id(eid: Union[str, int], role: Optional[str] = None) -> Entity: """Return the result of a unique query that uses the id to find the correct entity. Submits the query "FIND ENTITY WITH id='{eid}'". """ - return execute_query(f"FIND ENTITY WITH id='{eid}'", unique=True) + if role is None: + role = "ENTITY" + # type hint can be ignored, it's a unique query + return execute_query(f"FIND {role} WITH id='{eid}'", unique=True) # type: ignore def get_entity_by_path(path: str) -> Entity: @@ -49,4 +55,5 @@ def get_entity_by_path(path: str) -> Entity: Submits the query "FIND FILE WHICH IS STORED AT '{path}'". """ - return execute_query(f"FIND FILE WHICH IS STORED AT '{path}'", unique=True) + # type hint can be ignored, it's a unique query + return execute_query(f"FIND FILE WHICH IS STORED AT '{path}'", unique=True) # type: ignore diff --git a/src/linkahead/utils/plantuml.py b/src/linkahead/utils/plantuml.py index e5432dcebff7bd7aef83d2ad0355b34d82fbf331..19594d6e856e740fe2c58c5128eead31c37485ce 100644 --- a/src/linkahead/utils/plantuml.py +++ b/src/linkahead/utils/plantuml.py @@ -409,7 +409,7 @@ def to_graphics(recordtypes: List[db.Entity], filename: str, raise Exception("An error occured during the execution of " "plantuml when using the format {}. " "Is plantuml installed? " - "You might want to dry a different format.".format(format)) + "You might want to try a different format.".format(format)) # copy only the final product into the target directory shutil.copy(os.path.join(td, filename + "." + extension), output_dirname) diff --git a/tox.ini b/tox.ini index 8212226eef2759c1864a86b8a3ad8f926480db4a..bbaaa1fc9eec2aba87c247d783818d215d8a7d5e 100644 --- a/tox.ini +++ b/tox.ini @@ -1,13 +1,14 @@ [tox] -envlist=py37, py38, py39, py310, py311 +envlist=py38, py39, py310, py311, py312, py313 skip_missing_interpreters = true [testenv] deps = . - nose pytest pytest-cov + mypy jsonschema>=4.4.0 + setuptools commands=py.test --cov=caosdb -vv {posargs} [flake8] @@ -17,3 +18,5 @@ max-line-length=100 testpaths = unittests xfail_strict = True addopts = -x -vv --cov=caosdb +pythonpath = src + diff --git a/unittests/docker/Dockerfile b/unittests/docker/Dockerfile index 9b848cf69c829408f3f3edd599323b6b0321e041..51a9006ff59aad81e3f2fc09b5d783518a07f06e 100644 --- a/unittests/docker/Dockerfile +++ b/unittests/docker/Dockerfile @@ -12,4 +12,4 @@ ARG COMMIT="dev" # TODO Rename to linkahead RUN git clone -b dev https://gitlab.indiscale.com/caosdb/src/caosdb-pylib.git linkahead-pylib && \ cd linkahead-pylib && git checkout $COMMIT && pip3 install . -RUN pip3 install recommonmark sphinx-rtd-theme +RUN pip3 install recommonmark sphinx-rtd-theme mypy diff --git a/unittests/test_concrete_property.py b/unittests/test_concrete_property.py index e70668f02aab12762a342f035a974f708652ae69..32d38bbed553b6b1d162f7e76fb271de38e08b95 100644 --- a/unittests/test_concrete_property.py +++ b/unittests/test_concrete_property.py @@ -27,10 +27,20 @@ from linkahead import configure_connection from linkahead.common.models import _ConcreteProperty from linkahead.connection.mockup import MockUpServerConnection +from pytest import raises # pylint: disable=missing-docstring -from nose.tools import assert_equal as eq -from nose.tools import assert_is_not_none as there -from nose.tools import assert_true as tru + + +def eq(a, b): + assert a == b + + +def there(a): + assert a is not None + + +def tru(a): + assert a def setup_module(): diff --git a/unittests/test_configuration.py b/unittests/test_configuration.py index 40506e878b18473587da8b694d9381c15bdbd860..95bc906c6c044c51548aa864326cc93f29a6042a 100644 --- a/unittests/test_configuration.py +++ b/unittests/test_configuration.py @@ -45,24 +45,24 @@ def temp_ini_files(): remove("pylinkahead.ini") if created_temp_ini_home: remove(expanduser("~/.pylinkahead.ini")) - environ["PYCAOSDBINI"] = "~/.pylinkahead.ini" + environ["PYLINKAHEADINI"] = "~/.pylinkahead.ini" def test_config_ini_via_envvar(temp_ini_files): with raises(KeyError): - environ["PYCAOSDBINI"] + environ["PYLINKAHEADINI"] - environ["PYCAOSDBINI"] = "bla bla" - assert environ["PYCAOSDBINI"] == "bla bla" + environ["PYLINKAHEADINI"] = "bla bla" + assert environ["PYLINKAHEADINI"] == "bla bla" # test wrong configuration file in envvar with pytest.raises(RuntimeError): db.configuration._read_config_files() # test good configuration file in envvar - environ["PYCAOSDBINI"] = "~/.pylinkahead.ini" + environ["PYLINKAHEADINI"] = "~/.pylinkahead.ini" assert expanduser("~/.pylinkahead.ini") in db.configuration._read_config_files() # test without envvar - environ.pop("PYCAOSDBINI") + environ.pop("PYLINKAHEADINI") assert expanduser("~/.pylinkahead.ini") in db.configuration._read_config_files() # test configuration file in cwd assert join(getcwd(), "pylinkahead.ini") in db.configuration._read_config_files() diff --git a/unittests/test_connection.py b/unittests/test_connection.py index ca36a71680f8e13ac9114b9ab0bff0b6a96ea4c3..a3a1eff705c64f59baec33088906bdd9a4daa14d 100644 --- a/unittests/test_connection.py +++ b/unittests/test_connection.py @@ -39,14 +39,21 @@ from linkahead.connection.mockup import (MockUpResponse, MockUpServerConnection, from linkahead.connection.utils import make_uri_path, quote, urlencode from linkahead.exceptions import (ConfigurationError, LoginFailedError, LinkAheadConnectionError) -from nose.tools import assert_equal as eq -from nose.tools import assert_false as falz -from nose.tools import assert_is_not_none as there -from nose.tools import assert_raises as raiz -from nose.tools import assert_true as tru from pytest import raises +def eq(a, b): + assert a == b + + +def there(a): + assert a is not None + + +def tru(a): + assert a + + def setup_function(function): configure_connection(url="http://localhost:8888/some/path", password_method="plain", username="test", @@ -74,11 +81,11 @@ def test_urlencode(): eq(urlencode({'key1': 'val1'}), 'key1=val1') eq(urlencode({'keynoval': None}), 'keynoval=') eq(urlencode({'kèy': 'välüe'}), 'k%C3%A8y=v%C3%A4l%C3%BCe') - with raiz(AttributeError): + with raises(AttributeError) as exc_info: urlencode({bytes('asdf', 'utf-8'): 'asdf'}) - with raiz(AttributeError): + with raises(AttributeError) as exc_info: urlencode({'asdf': bytes('asdf', 'utf-8')}) - with raiz(AttributeError): + with raises(AttributeError) as exc_info: urlencode({None: 'asdf'}) @@ -138,10 +145,10 @@ def test_configure_connection_bad_url(): def test_connection_interface(): - with raiz(TypeError) as cm: + with raises(TypeError) as cm: CaosDBServerConnection() - tru(cm.exception.args[0].startswith( - "Can't instantiate abstract class CaosDBServerConnection")) + + assert "Can't instantiate abstract class CaosDBServerConnection" in str(cm.value) tru(hasattr(CaosDBServerConnection, "request")) tru(hasattr(CaosDBServerConnection.request, "__call__")) @@ -151,10 +158,10 @@ def test_connection_interface(): def test_use_mockup_implementation(): - with raiz(RuntimeError) as rerr: + with raises(RuntimeError) as rerr: execute_query("FIND Something") - print(rerr.exception.args[0]) - eq(rerr.exception.args[0], + print(str(rerr.value)) + eq(str(rerr.value), "No response for this request - GET: Entity?query=FIND%20Something") @@ -219,9 +226,9 @@ def test_resources_list(): def test_request_basics(): connection = test_init_connection() tru(hasattr(connection, "request")) - with raiz(RuntimeError) as cm: + with raises(RuntimeError) as cm: connection.request(method="GET", path="asdf") - eq(cm.exception.args[0], "No response for this request - GET: asdf") + eq(str(cm.value), "No response for this request - GET: asdf") connection = test_resources_list() there(connection.request(method="GET", path="asdf")) diff --git a/unittests/test_connection_utils.py b/unittests/test_connection_utils.py index 6a95fffa2f5f3dbfb302e035deee2f24fab9acf5..d82cfde07150fa3dd480cfcdd0ae63fd25fdad24 100644 --- a/unittests/test_connection_utils.py +++ b/unittests/test_connection_utils.py @@ -25,9 +25,7 @@ # pylint: disable=missing-docstring from __future__ import unicode_literals, print_function from pytest import raises -from nose.tools import (assert_equal as eq, assert_raises as raiz, assert_true - as tru, assert_is_not_none as there, assert_false as - falz) + from linkahead.exceptions import ConfigurationError, LoginFailedError from linkahead.connection.utils import parse_auth_token, auth_token_to_cookie from linkahead.connection.connection import ( @@ -40,6 +38,18 @@ from linkahead.connection.authentication.interface import CredentialsAuthenticat from linkahead import execute_query +def eq(a, b): + assert a == b + + +def there(a): + assert a is not None + + +def tru(a): + assert a + + def setup_module(): _reset_config() diff --git a/unittests/test_container.py b/unittests/test_container.py index 4cd8fefcaefee9fe6fdc5857805353227b493dfb..c3a60140d43383c81f03c38c9dd5cc7779bc77ba 100644 --- a/unittests/test_container.py +++ b/unittests/test_container.py @@ -5,7 +5,8 @@ # This file is a part of the LinkAhead Project. # # Copyright (C) 2020 Timm Fitschen <t.fitschen@indiscale.com> -# Copyright (C) 2020 IndiScale GmbH <info@indiscale.com> +# Copyright (C) 2024 Joscha Schmiedt <joscha@schmiedt.dev> +# Copyright (C) 2020-2024 IndiScale GmbH <info@indiscale.com> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -178,3 +179,23 @@ def test_container_deletion_with_references(): assert len(deps14a) == 1 and deps14a.pop() == -1 assert len(deps14b) == 1 and deps14b.pop() == -1 assert len(deps15) == 1 and deps15.pop() == -1 + + +def test_container_slicing(): + cont = db.Container() + cont.extend([db.Record(name=f"TestRec{ii+1}") for ii in range(5)]) + assert isinstance(cont, db.common.models.Container) + container_slice = cont[:2] + assert isinstance(container_slice, db.common.models.Container), \ + f"Container slice should be Container, was {type(container_slice)}" + for element in container_slice: + assert isinstance(element, db.Record), \ + f"element in slice was not Record, but {type(element)}" + assert len(container_slice) == 2 + assert cont[-1].name == "TestRec5" + + with pytest.raises(TypeError): + cont["stringkey"] + + with pytest.raises(TypeError): + cont[[0, 2, 3]] diff --git a/unittests/test_exception.py b/unittests/test_exception.py index 23607f46e1794ff336aa6687403c69f99b851988..1e54edbeec4551712a90115e18b5437398657861 100644 --- a/unittests/test_exception.py +++ b/unittests/test_exception.py @@ -21,8 +21,8 @@ import warnings -from caosdb.exceptions import (CaosDBConnectionError, CaosDBException, - LinkAheadConnectionError, LinkAheadException) +from linkahead.exceptions import (CaosDBConnectionError, CaosDBException, + LinkAheadConnectionError, LinkAheadException) # make sure the deprecation is raised with warnings.catch_warnings(record=True) as w: diff --git a/unittests/test_file.py b/unittests/test_file.py index dd974cb176ca69e2ffb065b5de185611e528e815..c1093cdd26b71bd8f1d48e98dd224df956f629f8 100644 --- a/unittests/test_file.py +++ b/unittests/test_file.py @@ -25,13 +25,10 @@ from linkahead import File, Record, configure_connection from linkahead.connection.mockup import MockUpServerConnection # pylint: disable=missing-docstring -from nose.tools import assert_equal as eq -from nose.tools import assert_is_not_none as there -from nose.tools import assert_true as tru def setup_module(): - there(File) + assert File is not None configure_connection(url="unittests", username="testuser", password_method="plain", password="testpassword", timeout=200, @@ -39,12 +36,12 @@ def setup_module(): def hat(obj, attr): - tru(hasattr(obj, attr)) + assert hasattr(obj, attr) def test_is_record(): file_ = File() - tru(isinstance(file_, Record)) + assert isinstance(file_, Record) def test_instance_variable(): @@ -57,4 +54,4 @@ def test_instance_variable(): def test_role(): file_ = File() - eq(file_.role, "File") + assert file_.role == "File" diff --git a/unittests/test_high_level_api.py b/unittests/test_high_level_api.py index ea5e635eadaa849480de5f3ece10b813a538a1b0..82c1a5caf0f0719b5946ecd6749b4079bb6794bc 100644 --- a/unittests/test_high_level_api.py +++ b/unittests/test_high_level_api.py @@ -21,23 +21,23 @@ # # Test high level api module # A. Schlemmer, 02/2022 - - -import caosdb as db -from caosdb.high_level_api import (convert_to_entity, convert_to_python_object, - new_high_level_entity) -from caosdb.high_level_api import (CaosDBPythonUnresolvedParent, - CaosDBPythonUnresolvedReference, - CaosDBPythonRecord, CaosDBPythonFile, - high_level_type_for_standard_type, - standard_type_for_high_level_type, - high_level_type_for_role, - CaosDBPythonEntity) -from caosdb.apiutils import compare_entities - -from caosdb.common.datatype import (is_list_datatype, - get_list_datatype, - is_reference) +# type: ignore + +import linkahead as db +from linkahead.high_level_api import (convert_to_entity, convert_to_python_object, + new_high_level_entity) +from linkahead.high_level_api import (CaosDBPythonUnresolvedParent, + CaosDBPythonUnresolvedReference, + CaosDBPythonRecord, CaosDBPythonFile, + high_level_type_for_standard_type, + standard_type_for_high_level_type, + high_level_type_for_role, + CaosDBPythonEntity) +from linkahead.apiutils import compare_entities + +from linkahead.common.datatype import (is_list_datatype, + get_list_datatype, + is_reference) import pytest from lxml import etree diff --git a/unittests/test_record_type.py b/unittests/test_record_type.py index 594f9c647997d68cccdcccc56eaab482cd694c74..8741950a31c89088f5b96003d363d5e3db030852 100644 --- a/unittests/test_record_type.py +++ b/unittests/test_record_type.py @@ -25,13 +25,10 @@ from linkahead import Entity, RecordType, configure_connection from linkahead.connection.mockup import MockUpServerConnection # pylint: disable=missing-docstring -from nose.tools import assert_equal as eq -from nose.tools import assert_is_not_none as there -from nose.tools import assert_true as tru def setup_module(): - there(RecordType) + assert RecordType is not None configure_connection(url="unittests", username="testuser", password_method="plain", password="testpassword", timeout=200, @@ -39,14 +36,14 @@ def setup_module(): def hat(obj, attr): - tru(hasattr(obj, attr)) + assert hasattr(obj, attr) def test_is_entity(): recty = RecordType() - tru(isinstance(recty, Entity)) + assert isinstance(recty, Entity) def test_role(): recty = RecordType() - eq(recty.role, "RecordType") + assert recty.role == "RecordType" diff --git a/unittests/test_server_side_scripting.py b/unittests/test_server_side_scripting.py index 7749af982113c71be1717646e83813ee34c7cff0..a27aefd6eb6ad3ab37a183b6e520935f2b8e8cb3 100644 --- a/unittests/test_server_side_scripting.py +++ b/unittests/test_server_side_scripting.py @@ -28,8 +28,9 @@ from unittest.mock import Mock from linkahead.utils import server_side_scripting as sss from linkahead.connection.mockup import MockUpServerConnection, MockUpResponse from linkahead import configure_connection +from typing import List -_REMOVE_FILES_AFTERWARDS = [] +_REMOVE_FILES_AFTERWARDS: List[str] = [] def setup_module():