diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 0f9a258de99ba559d280fc5ace74a3f111a9e30e..db600343569930a436a593a8ab5d511a35bc7aca 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -57,22 +57,10 @@ mypy: tags: [ docker ] stage: linting script: - - pip install mypy types-PyYAML types-jsonschema types-requests types-setuptools types-lxml types-python-dateutil pytest + - pip install .[mypy,test] - make mypy - allow_failure: true # run unit tests -unittest_py3.8: - tags: [ docker ] - stage: test - needs: [ ] - 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 pynose pytest pytest-cov jsonschema>=4.4.0 setuptools - - pip install . - - python -m pytest unittests # This needs to be changed once Python 3.9 isn't the standard Python in Debian # anymore. @@ -92,7 +80,11 @@ unittest_py3.10: stage: test needs: [ ] image: python:3.10 - script: *python_test_script + script: &python_test_script + # Python docker has problems with tox and pip so use plain pytest here + - touch ~/.pylinkahead.ini + - pip install .[test] + - python -m pytest unittests unittest_py3.11: tags: [ docker ] @@ -113,15 +105,8 @@ unittest_py3.13: tags: [ docker ] stage: test needs: [ ] - image: python:3.13-rc - script: - # TODO: Replace by '*python_test_script' as soon as 3.13 has been officially released. - # Python docker has problems with tox and pip so use plain pytest here - - apt update && apt install -y cargo - - touch ~/.pylinkahead.ini - - pip install pynose pytest pytest-cov jsonschema>=4.4.0 setuptools - - pip install . - - python -m pytest unittests + image: python:3.13 + script: *python_test_script # Trigger building of server image and integration tests trigger_build: @@ -167,7 +152,7 @@ build-testenv: pages_prepare: &pages_prepare tags: [ cached-dind ] stage: deploy - needs: [ code_style, pylint, unittest_py3.8, unittest_py3.9, unittest_py3.10 ] + needs: [ code_style, pylint, unittest_py3.9, unittest_py3.10 ] only: refs: - /^release-.*$/i diff --git a/CHANGELOG.md b/CHANGELOG.md index 958933ba413af1b526e49b0fcacdaf2ffd7cf344..23ddf8609f2467865cdee0964ef53931d1ef115c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,114 @@ 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). +## [Unreleased] ## + +### Added ### + +### Changed ### + +### Deprecated ### + +### Removed ### + +### Fixed ### + +### Security ### + +### Documentation ### + +* [#78](https://gitlab.com/linkahead/linkahead-pylib/-/issues/78) Fix + and extend test-registration docstrings. + +## [0.17.0] - 2025-01-14 ## + +### Added ### + +* New setup extra `test` which installs the dependencies for testing. +* The Container class has a new member function `filter_by_identity` + which is based on `_filter_entity_list`. +* The `Entity` properties `_cuid` and `_flags` are now available for read-only access + as `cuid` and `flags`, respectively. + +### Changed ### + +* Renamed the `filter` function of Container, ParentList and + PropertyList to `filter_by_identity`. + +### Deprecated ### + +* `ParentList.filter` and `PropertyList.filter` functions, use + `filter_by_identity` instead. + +### Removed ### + +* Support for Python 3.8 + +### Fixed ### + +* [#73](https://gitlab.com/linkahead/linkahead-pylib/-/issues/73) + `Entity.to_xml` now detects potentially infinite recursion and prevents an error +* [#89](https://gitlab.com/linkahead/linkahead-pylib/-/issues/89) + `to_xml` does not add `noscript` or `TransactionBenchmark` tags anymore +* [#103](https://gitlab.com/linkahead/linkahead-pylib/-/issues/103) + `authentication/interface/on_response()` does not overwrite + `auth_token` if new value is `None` +* [#119](https://gitlab.com/linkahead/linkahead-pylib/-/issues/119) + The diff returned by compare_entities now uses id instead of name as + key if either property does not have a name +* [#87](https://gitlab.com/linkahead/linkahead-pylib/-/issues/87) + `XMLSyntaxError` messages when parsing (incomplete) responses in + case of certain connection timeouts. + The diff returned by compare_entities now uses id instead of name as key if either property does not have a name +* [#127](https://gitlab.com/linkahead/linkahead-pylib/-/issues/127) + pylinkahead.ini now supports None and tuples as values for the `timeout` keyword + +## [0.16.0] - 2024-11-13 ## + +### Added ### + +* `ParentList` and `PropertyList` now have a `filter` function that allows to select a subset of + the contained elements by ID and/or name. +* Official support for Python 3.13 +* Added arguments to `describe_diff` that allow customizing the labels for the 'old' and the 'new' diffs. +* Optional `realm` argument for `linkahead_admin.py set_user_password` + which defaults to `None`, i.e., the server's default realm. + +### Changed ### + +* `compare_entities` is now case insensitive with respect to property and + recordtype names +* `_ParentList` is now called `ParentList` +* `_Properties` is now called `PropertyList` +* `ParentList.remove` is now case insensitive when a name is used. + +### Deprecated ### + +* the use of the arguments `old_entity` and `new_entity` in `compare_entities` + is now deprecated. Please use `entity0` and `entity1` respectively instead. + +### Fixed ### + +* [gitlab.indiscale.com#200](https://gitlab.indiscale.com/caosdb/src/caosdb-pylib/-/issues/200) + ``linkahead_admin.py`` prints reasonable error messages when users + or roles don't exist. + +## [0.15.1] - 2024-08-21 ## + +### Deprecated ### + +* `connection.get_username`. Use `la.Info().user_info.name` instead. + +### Fixed ### + +* [#128](https://gitlab.com/linkahead/linkahead-pylib/-/issues/128) + Assign `datetime.date` or `datetime.datetime` values to `DATETIME` + properties. + +### Documentation ### + +* Added docstrings for `linkahead.models.Info` and `linkahead.models.UserInfo`. + ## [0.15.0] - 2024-07-09 ## ### Added ### diff --git a/CITATION.cff b/CITATION.cff index 148cccb1804f7ae254224074dfef408e014f5438..bcecc2fdd962f4c581a2d53d5c1a324fb643a4a3 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -20,6 +20,6 @@ authors: given-names: Stefan orcid: https://orcid.org/0000-0001-7214-8125 title: CaosDB - Pylib -version: 0.15.0 +version: 0.17.0 doi: 10.3390/data4020083 -date-released: 2024-07-09 +date-released: 2025-01-14 diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md index e2326b831a71751265c6c2d5a333ccc37145bfa5..e9bd54a1459df22afa307e256625d05e74bdc6a8 100644 --- a/DEPENDENCIES.md +++ b/DEPENDENCIES.md @@ -1,5 +1,5 @@ * caosdb-server >= 0.12.0 -* Python >= 3.8 +* Python >= 3.9 * pip >= 20.0.2 Any other dependencies are defined in the setup.py and are being installed via pip diff --git a/Makefile b/Makefile index 21ea40ac8a6eb34032aba75c089e278fa354a6f5..7490c5d586c18d9da361f8940a39550779fb5938 100644 --- a/Makefile +++ b/Makefile @@ -40,7 +40,7 @@ style: .PHONY: style lint: - pylint --unsafe-load-any-extension=y -d all -e E,F src/linkahead/common + pylint --unsafe-load-any-extension=y -d all -e E,F src/linkahead .PHONY: lint mypy: diff --git a/README.md b/README.md index 193cb8f0cff8ff5cee36a40a78e53f070527e2e0..d630e879e9ff4781bb79b193d0240ef11ae211d2 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ However, you can also create an issue for it. * Copyright (C) 2018 Research Group Biomedical Physics, Max Planck Institute for Dynamics and Self-Organization Göttingen. -* Copyright (C) 2020-2022 Indiscale GmbH <info@indiscale.com> +* Copyright (C) 2020-2025 Indiscale GmbH <info@indiscale.com> All files in this repository are licensed under a [GNU Affero General Public License](LICENCE.md) (version 3 or later). diff --git a/README_SETUP.md b/README_SETUP.md index b05eff87711b84682aa82bbd0aafd61f2e8c86eb..f4c921382edb26776391590298faed06a5391396 100644 --- a/README_SETUP.md +++ b/README_SETUP.md @@ -2,24 +2,44 @@ ## Installation ## -### Requirements ### +### How to install ### -PyCaosDB needs at least Python 3.8. Additionally, the following packages are required (they will -typically be installed automatically): +First ensure that python with at least version 3.9 is installed. Should this not be +the case, you can use the [Installing python](#installing-python-) guide for your OS. -- `lxml` -- `PyYaml` -- `PySocks` +#### Generic installation #### -Optional packages: -- `keyring` -- `jsonschema` +To install this LinkAhead python client locally, use `pip`/`pip3`: -### How to install ### +```sh +pip install linkahead +``` + +#### Additional dependencies #### + +To test using tox, you also need to install tox: +`pip install tox` + +To install dependencies used by optional functionality, the following pip extras +keywords are defined: +- `test` for testing with pytest +- `mypy` for mypy and types +- `jsonschema` +- `keyring` + +These extras can be installed using: +```sh +pip install .[KEYWORD] +``` +A current list of the dependencies installed with this program as well as those installed with +the keywords can be found in `setup.py`s `setup_package()` method, in the `metadata` dictionary +entries `install_requires` and `extras_require`. + +### Installing python ### #### Linux #### -Make sure that Python (at least version 3.8) and pip is installed, using your system tools and +Make sure that Python (at least version 3.9) and pip is installed, using your system tools and documentation. Then open a terminal and continue in the [Generic installation](#generic-installation) section. @@ -51,34 +71,7 @@ cd /Applications/Python\ 3.9/ sudo ./Install\ Certificates.command ``` -After these steps, you may continue with the [Generic -installation](#generic-installation). - -#### Generic installation #### - -To install PyCaosDB locally, use `pip3` (also called `pip` on some systems): - -```sh -pip3 install --user caosdb -``` - ---- - -Alternatively, obtain the sources from GitLab and install from there (`git` must be installed for -this option): - -```sh -git clone https://gitlab.com/caosdb/caosdb-pylib -cd caosdb-pylib -pip3 install --user . -``` - -For installation of optional packages, install with an additional option, e.g. for -validating with the caosdb json schema: - -```sh -pip3 install --user .[jsonschema] -``` +After these steps, you may continue with the [Generic installation](#generic-installation) section. ## Configuration ## @@ -87,7 +80,7 @@ is described in detail in the [configuration section of the documentation](https ## Try it out ## -Start Python and check whether the you can access the database. (You will be asked for the +Start Python and check whether you can access the database. (You will be asked for the password): ```python @@ -107,6 +100,7 @@ Now would be a good time to continue with the [tutorials](tutorials/index). - Run all tests: `tox` or `make unittest` - Run a specific test file: e.g. `tox -- unittests/test_schema.py` - Run a specific test function: e.g. `tox -- unittests/test_schema.py::test_config_files` +- To run using pytest: `pytest .` ## Documentation ## We use sphinx to create the documentation. Docstrings in the code should comply @@ -114,12 +108,6 @@ with the Googly style (see link below). Build documentation in `build/` with `make doc`. -### Requirements ### - -- `sphinx` -- `sphinx-autoapi` -- `recommonmark` - ### How to contribute ### - [Google Style Python Docstrings](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html) @@ -127,7 +115,7 @@ Build documentation in `build/` with `make doc`. - [References to other documentation](https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html#role-external) ### Troubleshooting ### -If the client is to be executed directly from the `/src` folder, an initial `.\setup.py install --user` must be called. +If the client is to be executed directly from the `/src` folder, an initial `.\setup.py install` must be called. ## Migration ## TODO diff --git a/examples/set_permissions.py b/examples/set_permissions.py index a558bde73897cb6827c93373cc8327efc10e6e15..4657f2cca182b567c761a777df838825f8e89aef 100755 --- a/examples/set_permissions.py +++ b/examples/set_permissions.py @@ -37,13 +37,13 @@ from caosdb import administration as admin def assert_user_and_role(): """Make sure that users and roles exist. -After calling this function, there will be a user "jane" with the role "human" -and the user "xaxys" with the role "alien". These users and roles are returned. + After calling this function, there will be a user "jane" with the role "human" + and the user "xaxys" with the role "alien". These users and roles are returned. -Returns -------- -out : tuple - ((human_user, human_role), (alien_user, alien_role)) + Returns + ------- + out : tuple + ((human_user, human_role), (alien_user, alien_role)) """ try: @@ -81,15 +81,15 @@ out : tuple def get_entities(count=1): """Retrieve one or more entities. -Parameters ----------- -count : int, optional - How many entities to retrieve. + Parameters + ---------- + count : int, optional + How many entities to retrieve. -Returns -------- -out : Container - A container of retrieved entities, the length is given by the parameter count. + Returns + ------- + out : Container + A container of retrieved entities, the length is given by the parameter count. """ cont = db.execute_query("FIND RECORD 'Human Food'", flags={ "P": "0L{n}".format(n=count)}) @@ -102,20 +102,20 @@ out : Container def set_permission(role_grant, role_deny, cont=None, general=False): """Set the permissions of some entities. -Parameters ----------- -role_grant : str - Role which is granted permissions. + Parameters + ---------- + role_grant : str + Role which is granted permissions. -role_deny : str - Role which is denied permissions. + role_deny : str + Role which is denied permissions. -cont : Container - Entities for which permissions are set. + cont : Container + Entities for which permissions are set. -general : bool, optional - If True, the permissions for the roles will be set. If False (the default), - permissions for the entities in the container will be set. + general : bool, optional + If True, the permissions for the roles will be set. If False (the default), + permissions for the entities in the container will be set. """ # Set general permissions @@ -143,23 +143,23 @@ general : bool, optional def test_permission(granted_user, denied_user, cont): """Tests if the permissions are set correctly for two users. -Parameters ----------- -granted_user : (str, str) - The user which should have permissions to retrieve the entities in `cont`. - Given as (user, password). + Parameters + ---------- + granted_user : (str, str) + The user which should have permissions to retrieve the entities in `cont`. + Given as (user, password). -denied_user : (str, str) - The user which should have no permission to retrieve the entities in `cont`. - Given as (user, password). + denied_user : (str, str) + The user which should have no permission to retrieve the entities in `cont`. + Given as (user, password). -cont : Container - Entities for which permissions are tested. + cont : Container + Entities for which permissions are tested. -Returns -------- -None + Returns + ------- + None """ diff --git a/setup.py b/setup.py index 1a8a754219ddf84c0b9e088a13fd0283fa63a00f..ab8555b858b0004b59e979ac2de7b2b8450073b1 100755 --- a/setup.py +++ b/setup.py @@ -46,10 +46,10 @@ from setuptools import find_packages, setup # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ######################################################################## -ISRELEASED = True +ISRELEASED = False MAJOR = 0 -MINOR = 15 -MICRO = 0 +MINOR = 17 +MICRO = 1 # Do not tag as pre-release until this commit # https://github.com/pypa/packaging/pull/515 # has made it into a release. Probably we should wait for pypa/packaging>=21.4 @@ -179,7 +179,7 @@ def setup_package(): "Topic :: Scientific/Engineering :: Information Analysis", ], packages=find_packages('src'), - python_requires='>=3.8', + python_requires='>=3.9', package_dir={'': 'src'}, install_requires=['lxml>=4.6.3', "requests[socks]>=2.26", @@ -187,11 +187,27 @@ def setup_package(): 'PyYAML>=5.4.1', 'future', ], - extras_require={'keyring': ['keyring>=13.0.0'], - 'jsonschema': ['jsonschema>=4.4.0']}, + extras_require={ + "jsonschema": ["jsonschema>=4.4.0"], + "keyring": ["keyring>=13.0.0"], + "mypy": [ + "mypy", + "types-PyYAML", + "types-jsonschema", + "types-requests", + "types-setuptools", + "types-lxml", + "types-python-dateutil", + ], + "test": [ + "pytest", + "pytest-cov", + "coverage>=4.4.2", + "jsonschema>=4.4.0", + ] + + }, setup_requires=["pytest-runner>=2.0,<3dev"], - tests_require=["pytest", "pytest-cov", "coverage>=4.4.2", - "jsonschema>=4.4.0"], package_data={ 'linkahead': ['py.typed', 'cert/indiscale.ca.crt', 'schema-pycaosdb-ini.yml'], }, diff --git a/src/doc/conf.py b/src/doc/conf.py index 4a528d9e287daeeacd50e94cfba4e479b0430212..ce1aa8ff92d18d1ccd0bd408b1ce9e3523d03940 100644 --- a/src/doc/conf.py +++ b/src/doc/conf.py @@ -25,14 +25,14 @@ import sphinx_rtd_theme # noqa: E402 # -- Project information ----------------------------------------------------- project = 'pylinkahead' -copyright = '2023, IndiScale GmbH' +copyright = '2024, IndiScale GmbH' author = 'Daniel Hornung' # The short X.Y version -version = '0.15.0' +version = '0.17.1' # The full version, including alpha/beta/rc tags # release = '0.5.2-rc2' -release = '0.15.0' +release = '0.17.1-dev' # -- General configuration --------------------------------------------------- diff --git a/src/doc/tutorials/complex_data_models.rst b/src/doc/tutorials/complex_data_models.rst index 569acdae174a9df9d0d2b5eae9a0084d793cc90c..52757c320b42f18b4b24ab9b7575e7bd0becc252 100644 --- a/src/doc/tutorials/complex_data_models.rst +++ b/src/doc/tutorials/complex_data_models.rst @@ -51,18 +51,18 @@ Examples # Very complex part of the data model: # Case 1: File added to another file f2.add_property(p1, value=f1) # this adds a file property with value first file - # to the second file + # to the second file # Case 2: Property added to a property p2.add_property(p3, value=27) # this adds an integer property with value 27 to the - # double property + # double property # Case 3: Reference property added to a property # The property p2 now has two sub properties, one is pointing to # record p2 which itself has the property p2, therefore this can be # considered a loop in the data model. p2.add_property(p4, value=r2) # this adds a reference property pointing to - # record 2 to the double property + # record 2 to the double property # Insert a container containing all the newly created entities: c = db.Container().extend([rt1, rt2, r1, r2, f1, p1, p2, p3, f2, p4]) @@ -75,3 +75,70 @@ Examples b = input("Press any key to cleanup.") # cleanup everything after the user presses any button. c.delete() + + +Finding parents and properties +------------------------------ +To find a specific parent or property of an Entity, its +ParentList or PropertyList can be filtered using names, ids, or +entities. A short example: + +.. code-block:: python3 + + import linkahead as db + + # Setup a record with six properties + r = db.Record() + p1_1 = db.Property(id=101, name="Property 1") + p1_2 = db.Property(name="Property 1") + p2_1 = db.Property(id=102, name="Property 2") + p2_2 = db.Property(id=102) + p2_3 = db.Property(id=102, name="Other Property") + p3 = db.Property(id=104, name="Other Property") + r.add_property(p1_1).add_property(p1_2).add_property(p2_1) + r.add_property(p2_2).add_property(p2_3).add_property(p3) + properties = r.properties + + # As r only has one property with id 101, this returns a list containing only p1_1 + properties.filter_by_identity(pid=101) + # Result: [p1_1] + + # Filtering with name="Property 1" returns both p1_1 and p1_2, as they share their name + properties.filter_by_identity(name="Property 1") + # Result: [p1_1, p1_2] + + # If both name and pid are given, matching is based only on pid for all entities that have an id + properties.filter_by_identity(pid="102", name="Other Property") + # Result: [p2_1, p2_2, p2_3] + + # However, filter_by_identity with name="Property 1" and id=101 returns both p1_1 and p1_2, because + # p1_2 does not have an id and matches the name + properties.filter_by_identity(pid="101", name="Property 1") + # Result: [p1_1, p1_2] + + # We can also filter using an entity, in which case the name and id of the entity are used: + properties.filter_by_identity(pid="102", name="Property 2") == properties.filter_by_identity(p2_1) + # Result: True + + # If we only need properties that match both id and name, we can set the parameter + # conjunction to True: + properties.filter_by_identity(pid="102", name="Property 2", conjunction=True) + # Result: [p2_1] + +The filter function of ParentList works analogously. + +Finding entities in a Container +------------------------------- +In the same way as described above, Container can be filtered. +A short example: + +.. code-block:: python3 + + import linkahead as db + + # Setup a record with six properties + p1 = db.Property(id=101, name="Property 1") + p2 = db.Property(name="Property 2") + c = db.Container().extend([p1,p2]) + c.filter_by_identity(name="Property 1") + # Result: [p1] diff --git a/src/linkahead/__init__.py b/src/linkahead/__init__.py index cd54f8f4e05326579521fbbf226f027d32fa616e..567748e3b3a58fb73b91f652d82ed10f818d6014 100644 --- a/src/linkahead/__init__.py +++ b/src/linkahead/__init__.py @@ -42,7 +42,7 @@ from .common.datatype import (BOOLEAN, DATETIME, DOUBLE, FILE, INTEGER, LIST, REFERENCE, TEXT) # Import of the basic API classes: from .common.models import (ACL, ALL, FIX, NONE, OBLIGATORY, RECOMMENDED, - SUGGESTED, Container, DropOffBox, Entity, File, + SUGGESTED, Container, DropOffBox, Entity, File, Parent, Info, Message, Permissions, Property, Query, QueryTemplate, Record, RecordType, delete, execute_query, get_global_acl, diff --git a/src/linkahead/apiutils.py b/src/linkahead/apiutils.py index 4ae8edd16f1fdc00eb7ba2c17661eea6e114885e..b2a612faea1616c64b7e78575156abccfdb29e61 100644 --- a/src/linkahead/apiutils.py +++ b/src/linkahead/apiutils.py @@ -28,10 +28,11 @@ """ from __future__ import annotations + import logging import warnings from collections.abc import Iterable -from typing import Any, Union, Optional +from typing import Any, Optional, Union from .common.datatype import is_reference from .common.models import (SPECIAL_ATTRIBUTES, Container, Entity, File, @@ -179,183 +180,329 @@ def getCommitIn(folder): return get_commit_in(folder) -def compare_entities(old_entity: Entity, - new_entity: Entity, - compare_referenced_records: bool = False +def compare_entities(entity0: Optional[Entity] = None, + entity1: Optional[Entity] = None, + compare_referenced_records: bool = False, + entity_name_id_equivalency: bool = False, + old_entity: Optional[Entity] = None, + new_entity: Optional[Entity] = None, ) -> tuple[dict[str, Any], dict[str, Any]]: - """Compare two entites. - - Return a tuple of dictionaries, the first index belongs to additional information for old - entity, the second index belongs to additional information for new entity. - - Additional information means in detail: - - Additional parents (a list under key "parents") - - Information about properties: - - Each property lists either an additional property or a property with a changed: - - datatype - - importance or - - value (not implemented yet) - - In case of changed information the value listed under the respective key shows the - value that is stored in the respective entity. - - If `compare_referenced_records` is `True`, also referenced entities will be - compared using this function (which is then called with - `compare_referenced_records = False` to prevent infinite recursion in case - of circular references). - - Parameters - ---------- - old_entity, new_entity : Entity - Entities to be compared - compare_referenced_records : bool, optional - Whether to compare referenced records in case of both, `old_entity` and - `new_entity`, have the same reference properties and both have a Record - object as value. If set to `False`, only the corresponding Python - objects are compared which may lead to unexpected behavior when - identical records are stored in different objects. Default is False. + """Compare two entities. + + Returns two dicts listing the differences between the two entities. The + order of the two returned dicts corresponds to the two input entities. + The dicts contain two keys, 'parents' and 'properties'. The list saved + under the 'parents' key contains those parents of the respective entity + that are missing in the other entity, and the 'properties' dict contains + properties and SPECIAL_ATTRIBUTES if they are missing or different from + their counterparts in the other entity. + + The key used to represent a parent in the parent list or a + property in the property dictionary is the entity's name if the + name is present for both compared entities, the id otherwise. + + The value of the properties dict for each listed property is again a dict + detailing the differences between this property and its counterpart. + The characteristics that are checked to determine whether two properties + match are the following: + + - datatype + - importance + - value + + If any of these characteristics differ for a property, the respective + string (datatype, importance, value) is added as a key to the dict of the + property with its value being the characteristics value, + e.g. {"prop": {"value": 6, 'importance': 'SUGGESTED'}}. Except: None as + value is not added to the dict. + If a property is of type LIST, the comparison is order-sensitive. + + Comparison of multi-properties is not yet supported, so should either + entity have several instances of one Property, the comparison is aborted + and an error is raised. + + Two parents match if their name and id are the same, any further + differences are ignored. + + Should records referenced in the value field not be checked for equality + between the entities but for equivalency, this is possible by setting the + parameter compare_referenced_records. + + Params + ------ + entity0: Entity + First entity to be compared. + entity1: Entity + Second entity to be compared. + compare_referenced_records: bool, default: False + If set to True, values with referenced records + are not checked for equality but for + equivalency using this function. + compare_referenced_records is set to False for + these recursive calls, so references of + references need to be equal. If set to `False`, + only the Python objects are compared, which may + lead to unexpected behavior. + entity_name_id_equivalency: bool, default: False + If set to True, the comparison between an + entity and an int or str also checks whether + the int/str matches the name or id of the + entity, so Entity(id=100) == 100 == "100". """ - olddiff: dict[str, Any] = {"properties": {}, "parents": []} - newdiff: dict[str, Any] = {"properties": {}, "parents": []} - - if old_entity is new_entity: - return (olddiff, newdiff) - - if type(old_entity) is not type(new_entity): + # ToDo: Discuss intended behaviour + # Questions that need clarification: + # - What is intended behaviour for multi-properties and multi-parents? + # - Do different inheritance levels for parents count as a difference? + # - Do we care about parents and properties of properties? + # - Should there be a more detailed comparison of parents without id? + # - Revisit filter - do we care about RecordType when matching? + # How to treat None? + # - Should matching of parents also take the recordtype into account + # for parents that have a name but no id? + # Suggestions for enhancements: + # - For the comparison of entities in value and properties, consider + # keeping a list of traversed entities, not only look at first layer + # - Make the empty_diff functionality faster by adding a parameter to + # this function so that it returns after the first found difference? + # - Add parameter to restrict diff to some characteristics + # - Implement comparison of date where one is a string and the other is + # datetime + if entity0 is None and old_entity is None: + raise ValueError("Please provide the first entity as first argument (`entity0`)") + if entity1 is None and new_entity is None: + raise ValueError("Please provide the second entity as second argument (`entity1`)") + if old_entity is not None: + warnings.warn("Please use 'entity0' instead of 'old_entity'.", DeprecationWarning) + if entity0 is not None: + raise ValueError("You cannot use both entity0 and old_entity") + entity0 = old_entity + if new_entity is not None: + warnings.warn("Please use 'entity1' instead of 'new_entity'.", DeprecationWarning) + if entity1 is not None: + raise ValueError("You cannot use both entity1 and new_entity") + entity1 = new_entity + assert entity0 is not None + assert entity1 is not None + + diff: tuple[dict[str, Any], dict[str, Any]] = ({"properties": {}, "parents": []}, + {"properties": {}, "parents": []}) + + if entity0 is entity1: + return diff + + if type(entity0) is not type(entity1): raise ValueError( "Comparison of different Entity types is not supported.") + # compare special attributes for attr in SPECIAL_ATTRIBUTES: - try: - oldattr = old_entity.__getattribute__(attr) - old_entity_attr_exists = True - except BaseException: - old_entity_attr_exists = False - try: - newattr = new_entity.__getattribute__(attr) - new_entity_attr_exists = True - except BaseException: - new_entity_attr_exists = False - - if old_entity_attr_exists and (oldattr == "" or oldattr is None): - old_entity_attr_exists = False - - if new_entity_attr_exists and (newattr == "" or newattr is None): - new_entity_attr_exists = False - - if not old_entity_attr_exists and not new_entity_attr_exists: + if attr == "value": continue - if ((old_entity_attr_exists ^ new_entity_attr_exists) - or (oldattr != newattr)): - - if old_entity_attr_exists: - olddiff[attr] = oldattr + attr0 = entity0.__getattribute__(attr) + # we consider "" and None to be nonexistent + attr0_unset = (attr0 == "" or attr0 is None) - if new_entity_attr_exists: - newdiff[attr] = newattr + attr1 = entity1.__getattribute__(attr) + # we consider "" and None to be nonexistent + attr1_unset = (attr1 == "" or attr1 is None) - # properties - - for prop in old_entity.properties: - matching = [p for p in new_entity.properties if p.name == prop.name] + # in both entities the current attribute is not set + if attr0_unset and attr1_unset: + continue + # treat datatype separately if one datatype is an object and the other + # a string or int, and therefore may be a name or id + if attr == "datatype": + if not attr0_unset and not attr1_unset: + if isinstance(attr0, RecordType): + if attr0.name == attr1: + continue + if str(attr0.id) == str(attr1): + continue + if isinstance(attr1, RecordType): + if attr1.name == attr0: + continue + if str(attr1.id) == str(attr0): + continue + + # add to diff if attr has different values or is not set for one entity + if (attr0_unset != attr1_unset) or (attr0 != attr1): + diff[0][attr] = attr0 + diff[1][attr] = attr1 + + # compare value + ent0_val, ent1_val = entity0.value, entity1.value + if ent0_val != ent1_val: + same_value = False + + # Surround scalar values with a list to avoid code duplication - + # this way, the scalar values can be checked against special cases + # (compare refs, entity id equivalency etc.) in the list loop + if not isinstance(ent0_val, list) and not isinstance(ent1_val, list): + ent0_val, ent1_val = [ent0_val], [ent1_val] + + if isinstance(ent0_val, list) and isinstance(ent1_val, list): + # lists can't be the same if the lengths are different + if len(ent0_val) == len(ent1_val): + lists_match = True + for val0, val1 in zip(ent0_val, ent1_val): + if val0 == val1: + continue + # Compare Entities + if (compare_referenced_records and + isinstance(val0, Entity) and isinstance(val1, Entity)): + try: + same = empty_diff(val0, val1, False, + entity_name_id_equivalency) + except (ValueError, NotImplementedError): + same = False + if same: + continue + # Compare Entity name and id + if entity_name_id_equivalency: + if (isinstance(val0, Entity) + and isinstance(val1, (int, str))): + if (str(val0.id) == str(val1) + or str(val0.name) == str(val1)): + continue + if (isinstance(val1, Entity) + and isinstance(val0, (int, str))): + if (str(val1.id) == str(val0) + or str(val1.name) == str(val0)): + continue + # val0 and val1 could not be matched + lists_match = False + break + if lists_match: + same_value = True + + if not same_value: + diff[0]["value"] = entity0.value + diff[1]["value"] = entity1.value + + # compare properties + for prop in entity0.properties: + # ToDo: Would making id default break anything? + key = prop.name if prop.name is not None else prop.id + matching = entity1.properties.filter_by_identity(prop) if len(matching) == 0: - olddiff["properties"][prop.name] = {} + # entity1 has prop, entity0 does not + diff[0]["properties"][key] = {} elif len(matching) == 1: - newdiff["properties"][prop.name] = {} - olddiff["properties"][prop.name] = {} - - if (old_entity.get_importance(prop.name) != - new_entity.get_importance(prop.name)): - olddiff["properties"][prop.name]["importance"] = \ - old_entity.get_importance(prop.name) - newdiff["properties"][prop.name]["importance"] = \ - new_entity.get_importance(prop.name) - - if (prop.datatype != matching[0].datatype): - olddiff["properties"][prop.name]["datatype"] = prop.datatype - newdiff["properties"][prop.name]["datatype"] = \ - matching[0].datatype - - if (prop.unit != matching[0].unit): - olddiff["properties"][prop.name]["unit"] = prop.unit - newdiff["properties"][prop.name]["unit"] = \ - matching[0].unit - - if (prop.value != matching[0].value): - # basic comparison of value objects says they are different - same_value = False - if compare_referenced_records: - # scalar reference - if isinstance(prop.value, Entity) and isinstance(matching[0].value, Entity): - # explicitely not recursive to prevent infinite recursion - same_value = empty_diff( - prop.value, matching[0].value, compare_referenced_records=False) - # list of references - elif isinstance(prop.value, list) and isinstance(matching[0].value, list): - # all elements in both lists actually are entity objects - # TODO: check, whether mixed cases can be allowed or should lead to an error - if (all([isinstance(x, Entity) for x in prop.value]) - and all([isinstance(x, Entity) for x in matching[0].value])): - # can't be the same if the lengths are different - if len(prop.value) == len(matching[0].value): - # do a one-by-one comparison: - # the values are the same if all diffs are empty - same_value = all( - [empty_diff(x, y, False) for x, y - in zip(prop.value, matching[0].value)]) - - if not same_value: - olddiff["properties"][prop.name]["value"] = prop.value - newdiff["properties"][prop.name]["value"] = \ - matching[0].value - - if (len(newdiff["properties"][prop.name]) == 0 - and len(olddiff["properties"][prop.name]) == 0): - newdiff["properties"].pop(prop.name) - olddiff["properties"].pop(prop.name) + # It's possible that prop has name and id, but match only has id + key = prop.name if (prop.name is not None and + matching[0].name == prop.name) else prop.id + diff[0]["properties"][key] = {} + diff[1]["properties"][key] = {} + propdiff = (diff[0]["properties"][key], + diff[1]["properties"][key]) + + # We should compare the wrapped properties instead of the + # wrapping entities if possible: + comp1, comp2 = prop, matching[0] + if (comp1._wrapped_entity is not None + and comp2._wrapped_entity is not None): + comp1, comp2 = comp1._wrapped_entity, comp2._wrapped_entity + # Recursive call to determine the differences between properties + # Note: Can lead to infinite recursion if two properties have + # themselves or each other as subproperties + od, nd = compare_entities(comp1, comp2, compare_referenced_records, + entity_name_id_equivalency) + # We do not care about parents and properties here, discard + od.pop("parents") + od.pop("properties") + nd.pop("parents") + nd.pop("properties") + # use the remaining diff + propdiff[0].update(od) + propdiff[1].update(nd) + + # As the importance of a property is an attribute of the record + # and not the property, it is not contained in the diff returned + # by compare_entities and needs to be added separately + if (entity0.get_importance(prop) != + entity1.get_importance(matching[0])): + propdiff[0]["importance"] = entity0.get_importance(prop) + propdiff[1]["importance"] = entity1.get_importance(matching[0]) + + # in case there is no difference, we remove the dict keys again + if len(propdiff[0]) == 0 and len(propdiff[1]) == 0: + diff[0]["properties"].pop(key) + diff[1]["properties"].pop(key) else: raise NotImplementedError( "Comparison not implemented for multi-properties.") - for prop in new_entity.properties: - if len([0 for p in old_entity.properties if p.name == prop.name]) == 0: - newdiff["properties"][prop.name] = {} - - # parents - - for parent in old_entity.parents: - if len([0 for p in new_entity.parents if p.name == parent.name]) == 0: - olddiff["parents"].append(parent.name) - - for parent in new_entity.parents: - if len([0 for p in old_entity.parents if p.name == parent.name]) == 0: - newdiff["parents"].append(parent.name) - - return (olddiff, newdiff) - + # we have not yet compared properties that do not exist in entity0 + for prop in entity1.properties: + key = prop.name if prop.name is not None else prop.id + # check how often the property appears in entity0 + num_prop_in_ent0 = len(entity0.properties.filter_by_identity(prop)) + if num_prop_in_ent0 == 0: + # property is only present in entity0 - add to diff + diff[1]["properties"][key] = {} + if num_prop_in_ent0 > 1: + # Check whether the property is present multiple times in entity0 + # and raise error - result would be incorrect + raise NotImplementedError( + "Comparison not implemented for multi-properties.") -def empty_diff(old_entity: Entity, new_entity: Entity, - compare_referenced_records: bool = False) -> bool: + # compare parents + for index, parents, other_entity in [(0, entity0.parents, entity1), + (1, entity1.parents, entity0)]: + for parent in parents: + key = parent.name if parent.name is not None else parent.id + matching = other_entity.parents.filter_by_identity(parent) + if len(matching) == 0: + diff[index]["parents"].append(key) + continue + + return diff + + +def empty_diff(entity0: Entity, + entity1: Entity, + compare_referenced_records: bool = False, + entity_name_id_equivalency: bool = False, + old_entity: Optional[Entity] = None, + new_entity: Optional[Entity] = None, + ) -> bool: """Check whether the `compare_entities` found any differences between - old_entity and new_entity. + entity0 and entity1. Parameters ---------- - old_entity, new_entity : Entity + entity0, entity1 : Entity Entities to be compared compare_referenced_records : bool, optional - Whether to compare referenced records in case of both, `old_entity` and - `new_entity`, have the same reference properties and both have a Record + Whether to compare referenced records in case of both, `entity0` and + `entity1`, have the same reference properties and both have a Record object as value. - + entity_name_id_equivalency : bool, optional + If set to True, the comparison between an entity and an int or str also + checks whether the int/str matches the name or id of the entity, so + Entity(id=100) == 100 == "100". """ - olddiff, newdiff = compare_entities( - old_entity, new_entity, compare_referenced_records) - for diff in [olddiff, newdiff]: + if entity0 is None and old_entity is None: + raise ValueError("Please provide the first entity as first argument (`entity0`)") + if entity1 is None and new_entity is None: + raise ValueError("Please provide the second entity as second argument (`entity1`)") + if old_entity is not None: + warnings.warn("Please use 'entity0' instead of 'old_entity'.", DeprecationWarning) + if entity0 is not None: + raise ValueError("You cannot use both entity0 and old_entity") + entity0 = old_entity + if new_entity is not None: + warnings.warn("Please use 'entity1' instead of 'new_entity'.", DeprecationWarning) + if entity1 is not None: + raise ValueError("You cannot use both entity1 and new_entity") + entity1 = new_entity + e0diff, e1diff = compare_entities(entity0, entity1, compare_referenced_records, + entity_name_id_equivalency) + for diff in [e0diff, e1diff]: for key in ["parents", "properties"]: if len(diff[key]) > 0: # There is a difference somewhere in the diff @@ -376,9 +523,9 @@ def merge_entities(entity_a: Entity, ) -> Entity: """Merge entity_b into entity_a such that they have the same parents and properties. - datatype, unit, value, name and description will only be changed in entity_a - if they are None for entity_a and set for entity_b. If there is a - corresponding value for entity_a different from None, an + The attributes datatype, unit, value, name and description will only be changed + in entity_a if they are None for entity_a and set for entity_b. If one of those attributes is + set in both entities and they differ, then an EntityMergeConflictError will be raised to inform about an unresolvable merge conflict. @@ -386,8 +533,6 @@ def merge_entities(entity_a: Entity, Returns entity_a. - WARNING: This function is currently experimental and insufficiently tested. Use with care. - Parameters ---------- entity_a, entity_b : Entity @@ -420,12 +565,11 @@ def merge_entities(entity_a: Entity, """ - logger.warning( - "This function is currently experimental and insufficiently tested. Use with care.") - # Compare both entities: diff_r1, diff_r2 = compare_entities( - entity_a, entity_b, compare_referenced_records=merge_references_with_empty_diffs) + entity_a, entity_b, + entity_name_id_equivalency=merge_id_with_resolved_entity, + compare_referenced_records=merge_references_with_empty_diffs) # Go through the comparison and try to apply changes to entity_a: for key in diff_r2["parents"]: @@ -445,7 +589,8 @@ def merge_entities(entity_a: Entity, for attribute in ("datatype", "unit", "value"): if (attribute in diff_r2["properties"][key] and diff_r2["properties"][key][attribute] is not None): - if (diff_r1["properties"][key][attribute] is None): + if (attribute not in diff_r1["properties"][key] or + diff_r1["properties"][key][attribute] is None): setattr(entity_a.get_property(key), attribute, diff_r2["properties"][key][attribute]) elif force: @@ -512,43 +657,103 @@ def merge_entities(entity_a: Entity, return entity_a -def describe_diff(olddiff, newdiff, name=None, as_update=True): +def describe_diff(entity0_diff: dict[str, Any], entity1_diff: dict[str, Any], + name: Optional[str] = None, + as_update: Optional[bool] = None, + label_e0: str = "first version", + label_e1: str = "second version", + olddiff: Any = None, + newdiff: Any = None, + ) -> str: + """ + Generate a textual description of the differences between two entities. + These can be generated using :func:`compare_entities` and used within this function like this: + + `describe_diff(*compare_entities(...))` + + Arguments: + ---------- + + entity0_diff: dict[str, Any] + First element of the tuple output of :func:`compare_entities`. + This is referred to as the "first" version. + + entity1_diff: dict[str, Any] + Second element of the tuple output of :func:`compare_entities`. + This is referred to as the "second" version. + + + name: Optional[str] + Default None. Name of the entity that will be shown in the output text. + + as_update: Optional[bool] + Default None. Not used anymore. + + label_e0: str + Can be used to set a custom label for the diff that is associated with the first entity. + + label_e1: str + Can be used to set a custom label for the diff that is associated with the second entity. + + olddiff: Any + Deprecated. Replaced by entity0_diff. + + newdiff: Any + Deprecated. Replaced by entity1_diff. + + Returns: + -------- + A text description of the differences. + + """ description = "" - for attr in list(set(list(olddiff.keys()) + list(newdiff.keys()))): + if as_update: + warnings.warn("'as_update' is deprecated. Do not use it.", DeprecationWarning) + if olddiff: + warnings.warn("'olddiff' is deprecated. Use 'entity0_diff' instead.", DeprecationWarning) + entity0_diff = olddiff + if newdiff: + warnings.warn("'newdiff' is deprecated. Use 'entity1_diff' instead.", DeprecationWarning) + entity1_diff = newdiff + + for attr in list(set(list(entity0_diff.keys()) + list(entity1_diff.keys()))): if attr == "parents" or attr == "properties": continue description += "{} differs:\n".format(attr) - description += "old version: {}\n".format( - olddiff[attr] if attr in olddiff else "not set") - description += "new version: {}\n\n".format( - newdiff[attr] if attr in newdiff else "not set") + description += label_e0 + ": {}\n".format( + entity0_diff[attr] if attr in entity0_diff else "not set") + description += label_e1 + ": {}\n\n".format( + entity1_diff[attr] if attr in entity1_diff else "not set") - if len(olddiff["parents"]) > 0: - description += ("Parents that are only in the old version:\n" - + ", ".join(olddiff["parents"]) + "\n") + if len(entity0_diff["parents"]) > 0: + description += ("Parents that are only in the " + label_e0 + ":\n" + + ", ".join(entity0_diff["parents"]) + "\n") - if len(newdiff["parents"]) > 0: - description += ("Parents that are only in the new version:\n" - + ", ".join(olddiff["parents"]) + "\n") + if len(entity1_diff["parents"]) > 0: + description += ("Parents that are only in the " + label_e1 + ":\n" + + ", ".join(entity0_diff["parents"]) + "\n") - for prop in list(set(list(olddiff["properties"].keys()) - + list(newdiff["properties"].keys()))): + for prop in list(set(list(entity0_diff["properties"].keys()) + + list(entity1_diff["properties"].keys()))): description += "property {} differs:\n".format(prop) - if prop not in olddiff["properties"]: - description += "it does not exist in the old version: \n" - elif prop not in newdiff["properties"]: - description += "it does not exist in the new version: \n" + if prop not in entity0_diff["properties"]: + description += "it does not exist in the " + label_e0 + ":\n" + elif prop not in entity1_diff["properties"]: + description += "it does not exist in the " + label_e1 + ":\n" else: - description += "old version: {}\n".format( - olddiff["properties"][prop]) - description += "new version: {}\n\n".format( - newdiff["properties"][prop]) + description += label_e0 + ": {}\n".format( + entity0_diff["properties"][prop]) + description += label_e1 + ": {}\n\n".format( + entity1_diff["properties"][prop]) if description != "": - description = ("## Difference between the old and the new " - "version of {}\n\n".format(name))+description + description = ("## Difference between the " + + label_e0 + + " and the " + + label_e1 + + " of {}\n\n".format(name)) + description return description diff --git a/src/linkahead/cached.py b/src/linkahead/cached.py index cf1d1d34362335f87c5eca094b5aa9d6b750f68d..11cb959ba10fd507c39eb4d1ddd00bf478859852 100644 --- a/src/linkahead/cached.py +++ b/src/linkahead/cached.py @@ -107,7 +107,7 @@ If a query phrase is given, the result must be unique. If this is not what you def cached_query(query_string: str) -> Container: """A cached version of :func:`linkahead.execute_query<linkahead.common.models.execute_query>`. -All additional arguments are at their default values. + All additional arguments are at their default values. """ result = _cached_access(AccessType.QUERY, query_string, unique=False) @@ -116,7 +116,7 @@ All additional arguments are at their default values. return result -@ lru_cache(maxsize=DEFAULT_SIZE) +@lru_cache(maxsize=DEFAULT_SIZE) def _cached_access(kind: AccessType, value: Union[str, int], unique: bool = True): # This is the function that is actually cached. # Due to the arguments, the cache has kind of separate sections for cached_query and @@ -161,11 +161,12 @@ def cache_clear() -> None: def cache_info(): """Return info about the cache that is used by `cached_query` and `cached_get_entity_by`. -Returns -------- + Returns + ------- -out: named tuple - See the standard library :func:`functools.lru_cache` for details.""" + out: named tuple + See the standard library :func:`functools.lru_cache` for details. + """ return _cached_access.cache_info() @@ -188,21 +189,21 @@ def cache_fill(items: dict[Union[str, int], Any], This allows to fill the cache without actually submitting queries. Note that this does not overwrite existing entries with the same keys. -Parameters ----------- + Parameters + ---------- -items: dict - A dictionary with the entries to go into the cache. The keys must be compatible with the - AccessType given in ``kind`` + items: dict + A dictionary with the entries to go into the cache. The keys must be compatible with the + AccessType given in ``kind`` -kind: AccessType, optional - The AccessType, for example ID, name, path or query. + kind: AccessType, optional + The AccessType, for example ID, name, path or query. -unique: bool, optional - If True, fills the cache for :func:`cached_get_entity_by`, presumably with - :class:`linkahead.Entity<linkahead.common.models.Entity>` objects. If False, the cache should be - filled with :class:`linkahead.Container<linkahead.common.models.Container>` objects, for use with - :func:`cached_query`. + unique: bool, optional + If True, fills the cache for :func:`cached_get_entity_by`, presumably with + :class:`linkahead.Entity<linkahead.common.models.Entity>` objects. If False, the cache should be + filled with :class:`linkahead.Container<linkahead.common.models.Container>` objects, for use with + :func:`cached_query`. """ diff --git a/src/linkahead/common/administration.py b/src/linkahead/common/administration.py index dee341fa84dd85cbd41a77c0e2d510a96f2c4824..9d9d4f013f1ad10cd0957cfb9a9e4f2f44bd6102 100644 --- a/src/linkahead/common/administration.py +++ b/src/linkahead/common/administration.py @@ -91,7 +91,7 @@ def get_server_properties() -> dict[str, Optional[str]]: props: dict[str, Optional[str]] = dict() for elem in xml.getroot(): - props[elem.tag] = elem.text + props[str(elem.tag)] = str(elem.text) return props @@ -156,7 +156,10 @@ def generate_password(length: int): 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() + return con._http_request( + method="GET", + path="User/" + (realm + "/" + name if realm is not None else name), + **kwargs).read() except HTTPForbiddenError as e: e.msg = "You are not permitted to retrieve this user." raise @@ -198,7 +201,9 @@ def _update_user(name: str, if entity is not None: params["entity"] = str(entity) try: - return con.put_form_data(entity_uri_segment="User/" + (realm + "/" + name if realm is not None else name), params=params, **kwargs).read() + return con.put_form_data(entity_uri_segment="User/" + (realm + "/" + + name if realm is not None else name), + params=params, **kwargs).read() except HTTPResourceNotFoundError as e: e.msg = "User does not exist." raise e @@ -246,7 +251,9 @@ def _insert_user(name: str, def _insert_role(name, description, **kwargs): con = get_connection() try: - return con.post_form_data(entity_uri_segment="Role", params={"role_name": name, "role_description": description}, **kwargs).read() + return con.post_form_data(entity_uri_segment="Role", + params={"role_name": name, "role_description": description}, + **kwargs).read() except HTTPForbiddenError as e: e.msg = "You are not permitted to insert a new role." raise @@ -259,7 +266,9 @@ def _insert_role(name, description, **kwargs): def _update_role(name, description, **kwargs): con = get_connection() try: - return con.put_form_data(entity_uri_segment="Role/" + name, params={"role_description": description}, **kwargs).read() + return con.put_form_data(entity_uri_segment="Role/" + name, + params={"role_description": description}, + **kwargs).read() except HTTPForbiddenError as e: e.msg = "You are not permitted to update this role." raise @@ -301,8 +310,10 @@ def _set_roles(username, roles, realm=None, **kwargs): body = xml2str(xml) con = get_connection() try: - body = con._http_request(method="PUT", path="UserRoles/" + (realm + "/" + - username if realm is not None else username), body=body, **kwargs).read() + body = con._http_request(method="PUT", + path="UserRoles/" + (realm + "/" + + username if realm is not None else username), + body=body, **kwargs).read() except HTTPForbiddenError as e: e.msg = "You are not permitted to set this user's roles." raise @@ -345,20 +356,20 @@ def _get_roles(username, realm=None, **kwargs): def _set_permissions(role, permission_rules, **kwargs): """Set permissions for a role. -Parameters ----------- + Parameters + ---------- -role : str - The role for which the permissions are set. + role : str + The role for which the permissions are set. -permission_rules : iterable<PermissionRule> - An iterable with PermissionRule objects. + permission_rules : iterable<PermissionRule> + An iterable with PermissionRule objects. -**kwargs : - Additional arguments which are passed to the HTTP request. + **kwargs : + Additional arguments which are passed to the HTTP request. -Returns -------- + Returns + ------- None """ xml = etree.Element("PermissionRules") @@ -369,7 +380,8 @@ Returns body = xml2str(xml) con = get_connection() try: - return con._http_request(method="PUT", path="PermissionRules/" + role, body=body, **kwargs).read() + return con._http_request(method="PUT", path="PermissionRules/" + role, body=body, + **kwargs).read() except HTTPForbiddenError as e: e.msg = "You are not permitted to set this role's permissions." raise @@ -381,7 +393,9 @@ Returns def _get_permissions(role, **kwargs): con = get_connection() try: - return PermissionRule._parse_body(con._http_request(method="GET", path="PermissionRules/" + role, **kwargs).read()) + return PermissionRule._parse_body(con._http_request(method="GET", + path="PermissionRules/" + role, + **kwargs).read()) except HTTPForbiddenError as e: e.msg = "You are not permitted to retrieve this role's permissions." raise @@ -393,15 +407,15 @@ def _get_permissions(role, **kwargs): class PermissionRule(): """Permission rules. -Parameters ----------- -action : str - Either "grant" or "deny" + Parameters + ---------- + action : str + Either "grant" or "deny" -permission : str - For example ``RETRIEVE:*``. + permission : str + For example ``RETRIEVE:*``. -priority : bool, optional + priority : bool, optional Whether the priority shall be set, defaults is False. """ @@ -429,7 +443,8 @@ priority : bool, optional 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) + return PermissionRule(str(elem.tag), permission, + priority if priority is not None else False) @staticmethod def _parse_body(body: str): diff --git a/src/linkahead/common/models.py b/src/linkahead/common/models.py index 39b912fb0af211730b256023e4ad0b9b7fff1759..13c5e23bbf65948d0fad94a796ee61ae6f42eafa 100644 --- a/src/linkahead/common/models.py +++ b/src/linkahead/common/models.py @@ -37,26 +37,25 @@ from __future__ import annotations # Can be removed with 3.10. import re import sys +import warnings from builtins import str from copy import deepcopy +from datetime import date, datetime from functools import cmp_to_key from hashlib import sha512 from os import listdir from os.path import isdir from random import randint from tempfile import NamedTemporaryFile - -from typing import TYPE_CHECKING -from typing import Any, Final, Literal, Optional, TextIO, Union +from typing import TYPE_CHECKING, Any, Final, Literal, Optional, TextIO, Union if TYPE_CHECKING: - from 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 tempfile import _TemporaryFileWrapper + from .datatype import DATATYPE + QueryDict = dict[str, Optional[str]] from warnings import warn @@ -65,36 +64,17 @@ from lxml import etree from ..configuration import get_config from ..connection.connection import get_connection from ..connection.encode import MultipartParam, multipart_encode -from ..exceptions import ( - AmbiguousEntityError, - AuthorizationError, - ConsistencyError, - EmptyUniqueQueryError, - EntityDoesNotExistError, - EntityError, - EntityHasNoAclError, - EntityHasNoDatatypeError, - HTTPURITooLongError, - LinkAheadConnectionError, - LinkAheadException, - MismatchingEntitiesError, - PagingConsistencyError, - QueryNotUniqueError, - TransactionError, - UniqueNamesError, - UnqualifiedParentsError, - UnqualifiedPropertiesError, -) -from .datatype import ( - BOOLEAN, - DATETIME, - DOUBLE, - INTEGER, - TEXT, - get_list_datatype, - is_list_datatype, - is_reference, -) +from ..exceptions import (AmbiguousEntityError, AuthorizationError, + ConsistencyError, EmptyUniqueQueryError, + EntityDoesNotExistError, EntityError, + EntityHasNoAclError, EntityHasNoDatatypeError, + HTTPURITooLongError, LinkAheadConnectionError, + LinkAheadException, MismatchingEntitiesError, + PagingConsistencyError, QueryNotUniqueError, + TransactionError, UniqueNamesError, + UnqualifiedParentsError, UnqualifiedPropertiesError) +from .datatype import (BOOLEAN, DATETIME, DOUBLE, INTEGER, TEXT, + get_list_datatype, is_list_datatype, is_reference) from .state import State from .timezone import TimeZone from .utils import uuid, xml2str @@ -114,8 +94,8 @@ if TYPE_CHECKING: IMPORTANCE = Literal["OBLIGATORY", "RECOMMENDED", "SUGGESTED", "FIX", "NONE"] ROLE = Literal["Entity", "Record", "RecordType", "Property", "File"] -SPECIAL_ATTRIBUTES = ["name", "role", "datatype", "description", - "id", "path", "checksum", "size", "value"] +SPECIAL_ATTRIBUTES = ["name", "role", "datatype", "description", "file", + "id", "path", "checksum", "size", "value", "unit"] _EXPERIMENTAL_STRING_IDS = None @@ -162,10 +142,10 @@ class Entity: description: Optional[str] = None, # @ReservedAssignment datatype: Optional[DATATYPE] = None, value=None, - **kwargs, + role=None, ): - self.__role: Optional[ROLE] = kwargs["role"] if "role" in kwargs else None + self.__role: Optional[ROLE] = role self._checksum: Optional[str] = None self._size = None self._upload = None @@ -180,8 +160,8 @@ class Entity: self.datatype: Optional[DATATYPE] = datatype self.value = value self.messages = Messages() - self.properties = _Properties() - self.parents = _ParentList() + self.properties = PropertyList() + self.parents = ParentList() self.path: Optional[str] = None self.file: Optional[File] = None self.unit: Optional[str] = None @@ -391,6 +371,15 @@ class Entity: def pickup(self, new_pickup): self.__pickup = new_pickup + @property # getter for _cuid + def cuid(self): + # Set if None? + return self._cuid + + @property # getter for _flags + def flags(self): + return self._flags.copy() # for dict[str, str] shallow copy is enough + def grant( self, realm: Optional[str] = None, @@ -895,29 +884,29 @@ class Entity: check. Note that, if checked, name or ID should not be None, lest the check fail. -Parameters ----------- + Parameters + ---------- -parent: Entity - Check for this parent. + parent: Entity + Check for this parent. -recursive: bool, optional - Whether to check recursively. + recursive: bool, optional + Whether to check recursively. -check_name: bool, optional - Whether to use the name for ancestry check. + check_name: bool, optional + Whether to use the name for ancestry check. -check_id: bool, optional - Whether to use the ID for ancestry check. + check_id: bool, optional + Whether to use the ID for ancestry check. -retrieve: bool, optional - If False, do not retrieve parents from the server. + retrieve: bool, optional + If False, do not retrieve parents from the server. -Returns -------- -out: bool - True if ``parent`` is a true parent, False otherwise. -""" + Returns + ------- + out: bool + True if ``parent`` is a true parent, False otherwise. + """ if recursive: parents = self.get_parents_recursively(retrieve=retrieve) @@ -944,7 +933,7 @@ out: bool def get_parents(self): """Get all parents of this entity. - @return: _ParentList(list) + @return: ParentList(list) """ return self.parents @@ -952,17 +941,17 @@ out: bool def get_parents_recursively(self, retrieve: bool = True) -> list[Entity]: """Get all ancestors of this entity. -Parameters ----------- + Parameters + ---------- -retrieve: bool, optional - If False, do not retrieve parents from the server. + retrieve: bool, optional + If False, do not retrieve parents from the server. -Returns -------- -out: list[Entity] - The parents of this Entity -""" + Returns + ------- + out: list[Entity] + The parents of this Entity + """ all_parents: list[Entity] = [] self._get_parent_recursively(all_parents, retrieve=retrieve) @@ -1048,7 +1037,7 @@ out: list[Entity] def get_properties(self): """Get all properties of this entity. - @return: _Properties(list) + @return: PropertyList(list) """ return self.properties @@ -1168,7 +1157,7 @@ out: list[Entity] else: return getattr(ref, special_selector.lower()) - def get_property_values(self, *selectors): + def get_property_values(self, *selectors) -> tuple: """ Return a tuple with the values described by the given selectors. This represents an entity's properties as if it was a row of a table @@ -1281,6 +1270,7 @@ out: list[Entity] xml: Optional[etree._Element] = None, add_properties: INHERITANCE = "ALL", local_serialization: bool = False, + visited_entities: Optional[list] = None ) -> etree._Element: """Generate an xml representation of this entity. If the parameter xml is given, all attributes, parents, properties, and messages of this @@ -1288,14 +1278,25 @@ out: list[Entity] Raise an error if xml is not a lxml.etree.Element - @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. + Parameters + ---------- + xml : etree._Element, optional + an xml element to which all attributes, parents, + properties, and messages are to be added. Default is None. + visited_entities : list, optional + list of enties that are being printed for recursion check, + should never be set manually. Default is None. + add_properties : INHERITANCE, optional + FIXME: Add documentation for the add_properties + parameter. Default is "ALL". + local_serialization : bool, optional + FIXME: Add documentation for the local_serialization + parameter. Default is False. - @return: xml representation of this entity. + Returns + ------- + xml : etree._Element + xml representation of this entity. """ if xml is None: @@ -1304,9 +1305,17 @@ out: list[Entity] xml = etree.Element(elem_tag) assert isinstance(xml, etree._Element) + if visited_entities is None: + visited_entities = [] + if self in visited_entities: + xml.text = xml2str(etree.Comment("Recursive reference")) + return xml + visited_entities.append(self) + # unwrap wrapped entity if self._wrapped_entity is not None: - xml = self._wrapped_entity.to_xml(xml, add_properties) + xml = self._wrapped_entity.to_xml(xml, add_properties, + visited_entities=visited_entities.copy()) if self.id is not None: xml.set("id", str(self.id)) @@ -1321,6 +1330,10 @@ out: list[Entity] xml.set("description", str(self.description)) if self.version is not None: + # If this ever causes problems, we might add + # visited_entities support here since it does have some + # recursion with predecessors / successors. But should be + # fine for now, since it is always set by the server. xml.append(self.version.to_xml()) if self.value is not None: @@ -1330,7 +1343,8 @@ out: list[Entity] elif self.value.name is not None: xml.text = str(self.value.name) else: - xml.text = str(self.value) + dt_str = xml2str(self.value.to_xml(visited_entities=visited_entities.copy())) + xml.text = dt_str elif isinstance(self.value, list): for v in self.value: v_elem = etree.Element("Value") @@ -1341,7 +1355,8 @@ out: list[Entity] elif v.name is not None: v_elem.text = str(v.name) else: - v_elem.text = str(v) + dt_str = xml2str(v.to_xml(visited_entities=visited_entities.copy())) + v_elem.text = dt_str elif v == "": v_elem.append(etree.Element("EmptyString")) elif v is None: @@ -1363,7 +1378,11 @@ out: list[Entity] elif self.datatype.name is not None: xml.set("datatype", str(self.datatype.name)) else: - xml.set("datatype", str(self.datatype)) + dt_str = xml2str(self.datatype.to_xml(visited_entities=visited_entities.copy())) + # Todo: Use for pretty-printing with calls from _repr_ only? + # dt_str = dt_str.replace('<', 'á¸').replace('>', 'á³').replace(' ', 'â €').replace( + # '"', '\'').replace('\n', '') + xml.set("datatype", dt_str) else: xml.set("datatype", str(self.datatype)) @@ -1386,10 +1405,11 @@ out: list[Entity] self.messages.to_xml(xml) if self.parents is not None: - self.parents.to_xml(xml) + self.parents.to_xml(xml, visited_entities=visited_entities.copy()) if self.properties is not None: - self.properties.to_xml(xml, add_properties) + self.properties.to_xml(xml, add_properties, + visited_entities=visited_entities.copy()) if len(self._flags) > 0: flagattr = "" @@ -1627,15 +1647,15 @@ out: list[Entity] unique=True, flags=None, sync=True): """Update this entity. -There are two possible work-flows to perform this update: -First: - 1) retrieve an entity - 2) do changes - 3) call update method + There are two possible work-flows to perform this update: + First: + 1) retrieve an entity + 2) do changes + 3) call update method -Second: - 1) construct entity with id - 2) call update method. + Second: + 1) construct entity with id + 2) call update method. For slight changes the second one it is more comfortable. Furthermore, it is possible to stay off-line until calling the update method. The name, description, unit, datatype, path, @@ -1716,6 +1736,9 @@ def _parse_value(datatype, value): if isinstance(value, str): return value + if datatype == DATETIME and (isinstance(value, date) or isinstance(value, datetime)): + return value + # deal with collections if isinstance(datatype, str): matcher = re.compile(r"^(?P<col>[^<]+)<(?P<dt>[^>]+)>$") @@ -1898,12 +1921,12 @@ class QueryTemplate(): @staticmethod def _from_xml(xml: etree._Element): - if xml.tag.lower() == "querytemplate": + if str(xml.tag).lower() == "querytemplate": q = QueryTemplate(name=xml.get("name"), description=xml.get("description"), query=None) for e in xml: - if e.tag.lower() == "query": + if str(e.tag).lower() == "query": q.query = e.text else: child = _parse_single_xml_element(e) @@ -1939,7 +1962,7 @@ class QueryTemplate(): ret = Messages() for m in self.messages: - if m.type.lower() == "error": + if str(m.type).lower() == "error": ret.append(m) return ret @@ -1985,11 +2008,16 @@ class Parent(Entity): xml: Optional[etree._Element] = None, add_properties: INHERITANCE = "NONE", local_serialization: bool = False, + visited_entities: Optional[Union[list, None]] = None, ): if xml is None: xml = etree.Element("Parent") - return super().to_xml(xml=xml, add_properties=add_properties) + if visited_entities is None: + visited_entities = [] + + return super().to_xml(xml=xml, add_properties=add_properties, + visited_entities=visited_entities) class _EntityWrapper(object): @@ -2060,14 +2088,19 @@ class Property(Entity): xml: Optional[etree._Element] = None, add_properties: INHERITANCE = "ALL", local_serialization: bool = False, + visited_entities: Optional[Union[list, None]] = None, ): if xml is None: xml = etree.Element("Property") + if visited_entities is None: + visited_entities = [] + return super(Property, self).to_xml( xml=xml, add_properties=add_properties, local_serialization=local_serialization, + visited_entities=visited_entities, ) def is_reference(self, server_retrieval: bool = False) -> Optional[bool]: @@ -2225,15 +2258,20 @@ class RecordType(Entity): xml: Optional[etree._Element] = None, add_properties: INHERITANCE = "ALL", local_serialization: bool = False, + visited_entities: Optional[Union[list, None]] = None, ) -> etree._Element: if xml is None: xml = etree.Element("RecordType") + if visited_entities is None: + visited_entities = [] + return Entity.to_xml( self, xml=xml, add_properties=add_properties, local_serialization=local_serialization, + visited_entities=visited_entities, ) @@ -2264,14 +2302,19 @@ class Record(Entity): xml: Optional[etree._Element] = None, add_properties: INHERITANCE = "ALL", local_serialization: bool = False, + visited_entities: Optional[Union[list, None]] = None, ): if xml is None: xml = etree.Element("Record") + if visited_entities is None: + visited_entities = [] + return super().to_xml( xml=xml, add_properties=add_properties, local_serialization=local_serialization, + visited_entities=visited_entities ) @@ -2341,6 +2384,7 @@ class File(Record): xml: Optional[etree._Element] = None, add_properties: INHERITANCE = "ALL", local_serialization: bool = False, + visited_entities: Optional[Union[list, None]] = None, ) -> etree._Element: """Convert this file to an xml element. @@ -2350,8 +2394,12 @@ class File(Record): if xml is None: xml = etree.Element("File") + if visited_entities is None: + visited_entities = [] + return Entity.to_xml(self, xml=xml, add_properties=add_properties, - local_serialization=local_serialization) + local_serialization=local_serialization, + visited_entities=visited_entities) def download(self, target: Optional[str] = None) -> str: """Download this file-entity's actual file from the file server. It @@ -2439,11 +2487,14 @@ class File(Record): value=value, unit=unit, importance=importance, inheritance=inheritance) -class _Properties(list): - """FIXME: Add docstring.""" +class PropertyList(list): + """A list class for Property objects - def __init__(self): - list.__init__(self) + This class provides addional functionality like get/set_importance or get_by_name. + """ + + def __init__(self) -> None: + super().__init__() self._importance: dict[Entity, IMPORTANCE] = dict() self._inheritance: dict[Entity, INHERITANCE] = dict() self._element_by_name: dict[str, Entity] = dict() @@ -2510,15 +2561,20 @@ class _Properties(list): return self - def to_xml(self, add_to_element: etree._Element, add_properties: INHERITANCE): + def to_xml(self, add_to_element: etree._Element, add_properties: INHERITANCE, + visited_entities: Optional[Union[list, None]] = None): + + if visited_entities is None: + visited_entities = [] + p: Property for p in self: importance = self._importance.get(p) if add_properties == FIX and not importance == FIX: continue - - pelem = p.to_xml(xml=etree.Element("Property"), add_properties=FIX) + pelem = p.to_xml(xml=etree.Element("Property"), add_properties=FIX, + visited_entities=visited_entities.copy()) if p in self._importance: pelem.set("importance", str(importance)) @@ -2536,6 +2592,42 @@ class _Properties(list): return xml2str(xml) + def filter(self, *args, **kwargs): + warnings.warn(DeprecationWarning("This function was renamed to filter_by_identity.")) + return self.filter_by_identity(*args, **kwargs) + + def filter_by_identity(self, prop: Optional[Property] = None, + pid: Union[None, str, int] = None, + name: Optional[str] = None, + conjunction: bool = False) -> list: + """ + Return all Properties from the given PropertyList that match the + selection criteria. + + Please refer to the documentation of _filter_entity_list_by_identity for a detailed + description of behaviour. + + Params + ------ + prop : Property + Property to match name and ID with. Cannot be set + simultaneously with ID or name. + pid : str, int + Property ID to match + name : str + Property name to match + conjunction : bool, defaults to False + Set to return only entities that match both id and name + if both are given. + + Returns + ------- + matches : list + List containing all matching Properties + """ + return _filter_entity_list_by_identity(self, pid=pid, name=name, entity=prop, + conjunction=conjunction) + def _get_entity_by_cuid(self, cuid: str): ''' Get the first entity which has the given cuid. @@ -2593,9 +2685,7 @@ class _Properties(list): raise KeyError(str(prop) + " not found.") -class _ParentList(list): - # TODO unclear why this class is private. Isn't it use full for users? - +class ParentList(list): def _get_entity_by_cuid(self, cuid): ''' Get the first entity which has the given cuid. @@ -2610,8 +2700,8 @@ class _ParentList(list): return e raise KeyError("No entity with that cuid in this container.") - def __init__(self): - list.__init__(self) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) self._element_by_name = dict() self._element_by_id = dict() @@ -2624,22 +2714,21 @@ class _ParentList(list): if isinstance(parent, list): for p in parent: self.append(p) - return if isinstance(parent, Entity): - if parent.id: - self._element_by_id[str(parent.id)] = parent - - if parent.name: - self._element_by_name[parent.name] = parent list.append(self, parent) else: raise TypeError("Argument was not an Entity") return self - def to_xml(self, add_to_element: etree._Element): + def to_xml(self, add_to_element: etree._Element, + visited_entities: Optional[Union[list, None]] = None): + + if visited_entities is None: + visited_entities = [] + for p in self: pelem = etree.Element("Parent") @@ -2674,7 +2763,59 @@ class _ParentList(list): return xml2str(xml) + def filter(self, *args, **kwargs): + warnings.warn(DeprecationWarning("This function was renamed to filter_by_identity.")) + return self.filter_by_identity(*args, **kwargs) + + def filter_by_identity(self, parent: Optional[Parent] = None, + pid: Union[None, str, int] = None, + name: Optional[str] = None, + conjunction: bool = False) -> list: + """ + Return all Parents from the given ParentList that match the selection + criteria. + + Please refer to the documentation of _filter_entity_list_by_identity for a detailed + description of behaviour. + + Params + ------ + listobject : Iterable(Parent) + List to be filtered + parent : Parent + Parent to match name and ID with. Cannot be set + pid : str, int + Parent ID to match + name : str + Parent name to match + simultaneously with ID or name. + conjunction : bool, defaults to False + Set to return only entities that match both id and name + if both are given. + + Returns + ------- + matches : list + List containing all matching Parents + """ + return _filter_entity_list_by_identity(self, pid=pid, name=name, entity=parent, + conjunction=conjunction) + def remove(self, parent: Union[Entity, int, str]): + """ + Remove first occurrence of parent. + + Parameters + ---------- + parent: Union[Entity, int, str], the parent to be removed identified via ID or name. If a + Parent object is provided the ID and then the name is used to identify the parent to be + removed. + + Returns + ------- + None + """ + if isinstance(parent, Entity): if parent in self: list.remove(self, parent) @@ -2692,11 +2833,11 @@ class _ParentList(list): # by name for e in self: - if e.name is not None and e.name == parent.name: + if e.name is not None and str(e.name).lower() == str(parent.name).lower(): list.remove(self, e) return - elif hasattr(parent, "encode"): + elif isinstance(parent, str): # by name for e in self: @@ -2715,6 +2856,19 @@ class _ParentList(list): raise KeyError(str(parent) + " not found.") +class _Properties(PropertyList): + def __init__(self, *args, **kwargs): + warnings.warn(DeprecationWarning("This class is deprecated. Please use PropertyList.")) + super().__init__(*args, **kwargs) + + +class _ParentList(ParentList): + def __init__(self, *args, **kwargs): + warnings.warn(DeprecationWarning("This class is deprecated. Please use ParentList " + "(without underscore).")) + super().__init__(*args, **kwargs) + + class Messages(list): """This specialization of list stores error, warning, info, and other messages. The mentioned three messages types play a special role. @@ -2975,12 +3129,12 @@ def _basic_sync(e_local, e_remote): if e_local.role is None: e_local.role = e_remote.role elif e_remote.role is not None and not e_local.role.lower() == e_remote.role.lower(): - raise ValueError("The resulting entity had a different role ({0}) " - "than the local one ({1}). This probably means, that " + raise ValueError(f"The resulting entity had a different role ({e_remote.role}) " + f"than the local one ({e_local.role}). This probably means, that " "the entity was intialized with a wrong class " "by this client or it has changed in the past and " - "this client did't know about it yet.".format( - e_remote.role, e_local.role)) + "this client did't know about it yet.\nThis is the local version of the" + f" Entity:\n{e_local}\nThis is the remote one:\n{e_remote}") e_local.id = e_remote.id e_local.name = e_remote.name @@ -3233,6 +3387,7 @@ class Container(list): if add_to_element is None: add_to_element = etree.Element("Entities") + noscript_in_supplied_xml = list(add_to_element.iter("noscript", "TransactionBenchmark")) for m in self.messages: add_to_element.append(m.to_xml()) @@ -3249,6 +3404,13 @@ class Container(list): elem = e.to_xml() add_to_element.append(elem) + # remove noscript and benchmark elements added by this function + for elem in list(add_to_element.iter("noscript", "TransactionBenchmark")): + if elem not in noscript_in_supplied_xml: + parent = elem.getparent() + if parent is not None: + parent.remove(elem) + return add_to_element def get_errors(self): @@ -3612,6 +3774,37 @@ class Container(list): return sync_dict + def filter_by_identity(self, entity: Optional[Entity] = None, + entity_id: Union[None, str, int] = None, + name: Optional[str] = None, + conjunction: bool = False) -> list: + """ + Return all Entities from this Container that match the selection criteria. + + Please refer to the documentation of _filter_entity_list_by_identity for a detailed + description of behaviour. + + Params + ------ + entity : Entity + Entity to match name and ID with + entity_id : str, int + Parent ID to match + name : str + Parent name to match + simultaneously with ID or name. + conjunction : bool, defaults to False + Set to return only entities that match both id and name + if both are given. + + Returns + ------- + matches : list + List containing all matching Entities + """ + return _filter_entity_list_by_identity(self, pid=entity_id, name=name, entity=entity, + conjunction=conjunction) + @staticmethod def _find_dependencies_in_container(container: Container): """Find elements in a container that are a dependency of another element of the same. @@ -4553,7 +4746,7 @@ class ACL(): return len(self._grants) + len(self._priority_grants) + \ len(self._priority_denials) + len(self._denials) == 0 - def clear(self): + def clear(self) -> None: self._grants: set[ACI] = set() self._denials: set[ACI] = set() self._priority_grants: set[ACI] = set() @@ -4895,7 +5088,7 @@ class Query(): self.etag = q.get("etag") for m in q: - if m.tag.lower() == 'warning' or m.tag.lower() == 'error': + if str(m.tag).lower() == 'warning' or str(m.tag).lower() == 'error': self.messages.append(_parse_single_xml_element(m)) else: self.q = q @@ -5102,13 +5295,13 @@ class DropOffBox(list): xml = etree.fromstring(body) for child in xml: - if child.tag.lower() == "stats": + if str(child.tag).lower() == "stats": infoelem = child break for child in infoelem: - if child.tag.lower() == "dropoffbox": + if str(child.tag).lower() == "dropoffbox": dropoffboxelem = child break @@ -5157,7 +5350,7 @@ class Info(): """ - def __init__(self): + def __init__(self) -> None: self.messages = Messages() self.user_info: Optional[UserInfo] = None self.time_zone: Optional[TimeZone] = None @@ -5283,36 +5476,36 @@ def _parse_single_xml_element(elem: etree._Element): "entity": Entity, } - if elem.tag.lower() in classmap: - klass = classmap.get(elem.tag.lower()) + if str(elem.tag).lower() in classmap: + klass = classmap.get(str(elem.tag).lower()) if klass is None: - raise LinkAheadException("No class for tag '{}' found.".format(elem.tag)) + raise LinkAheadException("No class for tag '{}' found.".format(str(elem.tag))) entity = klass() Entity._from_xml(entity, elem) return entity - elif elem.tag.lower() == "version": + elif str(elem.tag).lower() == "version": return Version.from_xml(elem) - elif elem.tag.lower() == "state": + elif str(elem.tag).lower() == "state": return State.from_xml(elem) - elif elem.tag.lower() == "emptystring": + elif str(elem.tag).lower() == "emptystring": return "" - elif elem.tag.lower() == "value": - if len(elem) == 1 and elem[0].tag.lower() == "emptystring": + elif str(elem.tag).lower() == "value": + if len(elem) == 1 and str(elem[0].tag).lower() == "emptystring": return "" - elif len(elem) == 1 and elem[0].tag.lower() in classmap: + elif len(elem) == 1 and str(elem[0].tag).lower() in classmap: return _parse_single_xml_element(elem[0]) elif elem.text is None or elem.text.strip() == "": return None return str(elem.text.strip()) - elif elem.tag.lower() == "querytemplate": + elif str(elem.tag).lower() == "querytemplate": return QueryTemplate._from_xml(elem) - elif elem.tag.lower() == 'query': + elif str(elem.tag).lower() == 'query': return Query(elem) - elif elem.tag.lower() == 'history': + elif str(elem.tag).lower() == 'history': return Message(type='History', description=elem.get("transaction")) - elif elem.tag.lower() == 'stats': + elif str(elem.tag).lower() == 'stats': counts = elem.find("counts") if counts is None: raise LinkAheadException("'stats' element without a 'count' found.") @@ -5332,7 +5525,7 @@ def _parse_single_xml_element(elem: etree._Element): else: code = elem.get("code") return Message( - type=elem.tag, + type=str(elem.tag), code=int(code) if code is not None else None, description=elem.get("description"), body=elem.text, @@ -5480,3 +5673,91 @@ def delete(ids: Union[list[int], range], raise_exception_on_error: bool = True): c.append(Entity(id=ids)) return c.delete(raise_exception_on_error=raise_exception_on_error) + + +def _filter_entity_list_by_identity(listobject: list[Entity], + entity: Optional[Entity] = None, + pid: Union[None, str, int] = None, + name: Optional[str] = None, + conjunction: bool = False) -> list: + """ + Returns a subset of entities from the list based on whether their id and + name matches the selection criterion. + + If both pid and name are given, entities from the list are first matched + based on id. If they do not have an id, they are matched based on name. + If only one parameter is given, only this parameter is considered. + + If an Entity is given, neither name nor ID may be set. In this case, pid + and name are determined by the attributes of given entity. + + This results in the following selection criteria: + If an entity in the list + - has both name and id, it is returned if the id matches the given not-None + value for pid. If no pid was given, it is returned if the name matches. + - has an id, but no name, it will be returned only if it matches the given + not-None value + - has no id, but a name, it will be returned if the name matches the given + not-None value + - has neither id nor name, it will never be returned + + As IDs can be strings, integer IDs are cast to string for the comparison. + + Params + ------ + listobject : Iterable(Entity) + List to be filtered + entity : Entity + Entity to match name and ID for. Cannot be set + simultaneously with ID or name. + pid : str, int + Entity ID to match + name : str + Entity name to match + conjunction : bool, defaults to False + Set to true to return only entities that match both id + and name if both are given. + + Returns + ------- + matches : list + A List containing all matching Entities + """ + # Check correct input params and setup + if entity is not None: + if pid is not None or name is not None: + raise ValueError("If an entity is given, pid and name must not be set.") + pid = entity.id + name = entity.name + if pid is None and name is None: + if entity is None: + raise ValueError("One of entity, pid or name must be set.") + else: + raise ValueError("A given entity must have at least one of name and id.") + if pid is None or name is None: + conjunction = False + + # Iterate through list and match based on given criteria + matches = [] + for candidate in listobject: + name_match, pid_match = False, False + + # Check whether name/pid match + # Comparison is only possible if both are not None + pid_none = pid is None or candidate.id is None + # Cast to string in case one is f.e. "12" and the other is 12 + if not pid_none and str(candidate.id) == str(pid): + pid_match = True + name_none = name is None or candidate.name is None + if not name_none and str(candidate.name).lower() == str(name).lower(): + name_match = True + + # If the criteria are satisfied, append the match. + if pid_match and name_match: + matches.append(candidate) + elif not conjunction: + if pid_match: + matches.append(candidate) + if pid_none and name_match: + matches.append(candidate) + return matches diff --git a/src/linkahead/common/state.py b/src/linkahead/common/state.py index e352f82d9820620d1692cb6337eb218210e799e6..b708ca13cb0a648aa2ca00507f39a531e4f55d14 100644 --- a/src/linkahead/common/state.py +++ b/src/linkahead/common/state.py @@ -20,11 +20,11 @@ # ** end header from __future__ import annotations # Can be removed with 3.10. -import copy -from lxml import etree +import copy from typing import TYPE_CHECKING -import sys + +from lxml import etree if TYPE_CHECKING: from typing import Optional @@ -87,7 +87,8 @@ class Transition: return self._to_state def __repr__(self): - return f'Transition(name="{self.name}", from_state="{self.from_state}", to_state="{self.to_state}", description="{self.description}")' + return (f'Transition(name="{self.name}", from_state="{self.from_state}", ' + f'to_state="{self.to_state}", description="{self.description}")') def __eq__(self, other): return ( @@ -103,9 +104,9 @@ class Transition: @staticmethod def from_xml(xml: etree._Element) -> "Transition": to_state = [to.get("name") - for to in xml if to.tag.lower() == "tostate"] + for to in xml if str(to.tag).lower() == "tostate"] from_state = [ - from_.get("name") for from_ in xml if from_.tag.lower() == "fromstate" + from_.get("name") for from_ in xml if str(from_.tag).lower() == "fromstate" ] return Transition( name=xml.get("name"), @@ -199,7 +200,7 @@ class State: result._id = xml.get("id") result._description = xml.get("description") transitions = [ - Transition.from_xml(t) for t in xml if t.tag.lower() == "transition" + Transition.from_xml(t) for t in xml if str(t.tag).lower() == "transition" ] if transitions: result._transitions = set(transitions) diff --git a/src/linkahead/common/versioning.py b/src/linkahead/common/versioning.py index 2e292e6bb031725fbd6da618c4b888c05072c46b..1c2999df8174e239a470cfc637533c3c8c302c33 100644 --- a/src/linkahead/common/versioning.py +++ b/src/linkahead/common/versioning.py @@ -101,11 +101,14 @@ class Version(): # pylint: disable=redefined-builtin 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, + 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.""" + """Typically the `predecessors` or `successors` should not "link back" to an existing + Version object. + + """ self.id = id self.date = date self.username = username @@ -205,8 +208,8 @@ object.""" version : Version a new version instance """ - predecessors = [Version.from_xml(p) for p in xml if p.tag.lower() == "predecessor"] - successors = [Version.from_xml(s) for s in xml if s.tag.lower() == "successor"] + predecessors = [Version.from_xml(p) for p in xml if str(p.tag).lower() == "predecessor"] + successors = [Version.from_xml(s) for s in xml if str(s.tag).lower() == "successor"] return Version(id=xml.get("id"), date=xml.get("date"), is_head=xml.get("head"), is_complete_history=xml.get("completeHistory"), diff --git a/src/linkahead/configuration.py b/src/linkahead/configuration.py index f57289d7dcb6d7ab062024dc697dbda557670d7a..5081c28af253d3da31926ab1c9449309cc171c4f 100644 --- a/src/linkahead/configuration.py +++ b/src/linkahead/configuration.py @@ -30,6 +30,15 @@ import yaml try: optional_jsonschema_validate: Optional[Callable] = None from jsonschema import validate as optional_jsonschema_validate + + # Adapted from https://github.com/python-jsonschema/jsonschema/issues/148 + # Defines Validator to allow parsing of all iterables as array in jsonschema + # CustomValidator can be removed if/once jsonschema allows tuples for arrays + from collections.abc import Iterable + from jsonschema import validators + default = validators.validator_for(True) # Returns latest supported draft + t_c = (default.TYPE_CHECKER.redefine('array', lambda x, y: isinstance(y, Iterable))) + CustomValidator = validators.extend(default, type_checker=t_c) except ImportError: pass @@ -72,14 +81,40 @@ def get_config() -> ConfigParser: return _pycaosdbconf -def config_to_yaml(config: ConfigParser) -> dict[str, dict[str, Union[int, str, bool]]]: - valobj: dict[str, dict[str, Union[int, str, bool]]] = {} +def config_to_yaml(config: ConfigParser) -> dict[str, dict[str, Union[int, str, bool, tuple, None]]]: + """ + Generates and returns a dict with all config options and their values + defined in the config. + The values of the options 'debug', 'timeout', and 'ssl_insecure' are + parsed, all other values are saved as string. + + Parameters + ---------- + config : ConfigParser + The config to be converted to a dict + + Returns + ------- + valobj : dict + A dict with config options and their values as key value pairs + """ + valobj: dict[str, dict[str, Union[int, str, bool, tuple, None]]] = {} for s in config.sections(): valobj[s] = {} for key, value in config[s].items(): # TODO: Can the type be inferred from the config object? - if key in ["timeout", "debug"]: + if key in ["debug"]: valobj[s][key] = int(value) + elif key in ["timeout"]: + value = "".join(value.split()) # Remove whitespace + if str(value).lower() in ["none", "null"]: + valobj[s][key] = None + elif value.startswith('(') and value.endswith(')'): + content = [None if str(s).lower() in ["none", "null"] else int(s) + for s in value[1:-1].split(',')] + valobj[s][key] = tuple(content) + else: + valobj[s][key] = int(value) elif key in ["ssl_insecure"]: valobj[s][key] = bool(value) else: @@ -88,11 +123,12 @@ def config_to_yaml(config: ConfigParser) -> dict[str, dict[str, Union[int, str, return valobj -def validate_yaml_schema(valobj: dict[str, dict[str, Union[int, str, bool]]]): +def validate_yaml_schema(valobj: dict[str, dict[str, Union[int, str, bool, tuple, None]]]): 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) - optional_jsonschema_validate(instance=valobj, schema=schema["schema-pycaosdb-ini"]) + optional_jsonschema_validate(instance=valobj, schema=schema["schema-pycaosdb-ini"], + cls=CustomValidator) else: warnings.warn(""" Warning: The validation could not be performed because `jsonschema` is not installed. diff --git a/src/linkahead/connection/authentication/interface.py b/src/linkahead/connection/authentication/interface.py index b48e27c08312bf1358d32a9a1203627a9d0007c2..8288880583dc58fc82ab03d371861f067406b3d3 100644 --- a/src/linkahead/connection/authentication/interface.py +++ b/src/linkahead/connection/authentication/interface.py @@ -125,8 +125,9 @@ class AbstractAuthenticator(ABC): Returns ------- """ - self.auth_token = parse_auth_token( - response.getheader("Set-Cookie")) + new_token = parse_auth_token(response.getheader("Set-Cookie")) + if new_token is not None: + self.auth_token = new_token def on_request(self, method: str, path: str, headers: QueryDict, **kwargs): # pylint: disable=unused-argument @@ -190,7 +191,7 @@ class CredentialsAuthenticator(AbstractAuthenticator): def _logout(self): self.logger.debug("[LOGOUT]") if self.auth_token is not None: - self._connection.request(method="DELETE", path="logout") + self._connection.request(method="GET", path="logout") self.auth_token = None def _login(self): diff --git a/src/linkahead/connection/connection.py b/src/linkahead/connection/connection.py index 294d9457d064f03bbe06a3347b2d2064dcf12b8c..74dd23177c548dd640c6dd1c03ce4069c366802b 100644 --- a/src/linkahead/connection/connection.py +++ b/src/linkahead/connection/connection.py @@ -39,7 +39,7 @@ from requests.adapters import HTTPAdapter from requests.exceptions import ConnectionError as HTTPConnectionError from urllib3.poolmanager import PoolManager -from ..configuration import get_config +from ..configuration import get_config, config_to_yaml from ..exceptions import (ConfigurationError, HTTPClientError, HTTPForbiddenError, HTTPResourceNotFoundError, HTTPServerError, HTTPURITooLongError, @@ -83,8 +83,10 @@ class _WrappedHTTPResponse(CaosDBHTTPResponse): return self.response.status_code def read(self, size: Optional[int] = None): + # FIXME This function behaves unexpectedly if `size` is larger than in the first run. + if self._stream_consumed is True: - raise RuntimeError("Stream is consumed") + raise BufferError("Stream is consumed") if self._buffer is None: # the buffer has been drained in the previous call. @@ -97,14 +99,14 @@ class _WrappedHTTPResponse(CaosDBHTTPResponse): 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") + raise BufferError( + "`size` parameter can not be None or zero once reading has started with a non-zero " + "value.") if len(self._buffer) >= size: # still enough bytes in the buffer - # FIXME: `chunk`` is used before definition - result = chunk[:size] - self._buffer = chunk[size:] + result = self._buffer[:size] + self._buffer = self._buffer[size:] return result if self._generator is None: @@ -116,16 +118,16 @@ class _WrappedHTTPResponse(CaosDBHTTPResponse): try: # read new data into the buffer chunk = self._buffer + next(self._generator) - result = chunk[:size] + result = chunk[:size] # FIXME what if `size` is larger than at `iter_content(size)`? if len(result) == 0: self._stream_consumed = True self._buffer = chunk[size:] return result except StopIteration: # drain buffer - result = self._buffer + last_result = self._buffer self._buffer = None - return result + return last_result def getheader(self, name: str, default=None): return self.response.headers[name] if name in self.response.headers else default @@ -218,7 +220,7 @@ class _DefaultCaosDBServerConnection(CaosDBServerConnection): "Connection failed. Network or server down? " + str(conn_err) ) - def configure(self, **config): + def configure(self, **config) -> None: """configure. Configure the http connection. @@ -420,8 +422,10 @@ def configure_connection(**kwargs): - "keyring" Uses the `keyring` library. - "auth_token" Uses only a given auth_token. - timeout : int + timeout : int, tuple, or None A connection timeout in seconds. (Default: 210) + If a tuple is given, they are used as connect and read timeouts + respectively, timeout None disables the timeout. ssl_insecure : bool Whether SSL certificate warnings should be ignored. Only use this for @@ -463,21 +467,29 @@ def configure_connection(**kwargs): global_conf = {} conf = get_config() # Convert config to dict, with preserving types - int_opts = ["timeout"] + int_opts = [] bool_opts = ["ssl_insecure"] + other_opts = ["timeout"] if conf.has_section("Connection"): global_conf = dict(conf.items("Connection")) - # Integer options + # Integer options for opt in int_opts: if opt in global_conf: global_conf[opt] = conf.getint("Connection", opt) - # Boolean options + # Boolean options for opt in bool_opts: if opt in global_conf: global_conf[opt] = conf.getboolean("Connection", opt) + + # Other options, defer parsing to configuration.config_to_yaml: + connection_config = config_to_yaml(conf)["Connection"] + for opt in other_opts: + if opt in global_conf: + global_conf[opt] = connection_config[opt] + local_conf = _make_conf(_DEFAULT_CONF, global_conf, kwargs) connection = _Connection.get_instance() @@ -551,9 +563,9 @@ class _Connection(object): # pylint: disable=useless-object-inheritance __instance = None - def __init__(self): + def __init__(self) -> None: self._delegate_connection: Optional[CaosDBServerConnection] = None - self._authenticator: Optional[CredentialsAuthenticator] = None + self._authenticator: Optional[AbstractAuthenticator] = None self.is_configured = False @classmethod @@ -563,7 +575,7 @@ class _Connection(object): # pylint: disable=useless-object-inheritance return cls.__instance - def configure(self, **config): + def configure(self, **config) -> _Connection: self.is_configured = True if "implementation" not in config: @@ -571,8 +583,7 @@ class _Connection(object): # pylint: disable=useless-object-inheritance "Missing CaosDBServerConnection implementation. You did not " "specify an `implementation` for the connection.") try: - self._delegate_connection: CaosDBServerConnection = config["implementation"]( - ) + self._delegate_connection = config["implementation"]() if not isinstance(self._delegate_connection, CaosDBServerConnection): @@ -757,9 +768,12 @@ class _Connection(object): # pylint: disable=useless-object-inheritance Shortcut for: get_connection()._authenticator._credentials_provider.username """ + warnings.warn("Deprecated. Please use ``la.Info().user_info.name`` instead.", + DeprecationWarning) if self._authenticator is None: raise ValueError( "No authenticator set. Please call configure_connection() first.") + assert isinstance(self._authenticator, CredentialsAuthenticator) if self._authenticator._credentials_provider is None: raise ValueError( "No credentials provider set. Please call configure_connection() first.") diff --git a/src/linkahead/connection/mockup.py b/src/linkahead/connection/mockup.py index 9b69971c0409708f221c402f540fac85ff9c527e..d3bc13bb474a70d48446e8532607c3e11931ff05 100644 --- a/src/linkahead/connection/mockup.py +++ b/src/linkahead/connection/mockup.py @@ -75,7 +75,7 @@ class MockUpServerConnection(CaosDBServerConnection): just returns predefined responses which mimic the LinkAhead server.""" def __init__(self): - self.resources = [self._login] + self.resources = [self._login, self._logout] def _login(self, method, path, headers, body): if method == "POST" and path == "login": @@ -84,6 +84,12 @@ class MockUpServerConnection(CaosDBServerConnection): "mockup-auth-token"}, body="") + def _logout(self, method, path, headers, body): + if method in ["DELETE", "GET"] and path == "logout": + return MockUpResponse(200, + headers={}, + body="") + def configure(self, **kwargs): """This configure method does nothing.""" diff --git a/src/linkahead/exceptions.py b/src/linkahead/exceptions.py index 609d3654ac670a993185ba1faa33db921c44409c..7d4dc0850b811c0d696cc66252aa62541c6d3029 100644 --- a/src/linkahead/exceptions.py +++ b/src/linkahead/exceptions.py @@ -94,12 +94,26 @@ class HTTPServerError(LinkAheadException): """HTTPServerError represents 5xx HTTP server errors.""" def __init__(self, body): - xml = etree.fromstring(body) - error = xml.xpath('/Response/Error')[0] - msg = error.get("description") - - if error.text is not None: - msg = msg + "\n\n" + error.text + try: + # This only works if the server sends a valid XML + # response. Then it can be parsed for more information. + xml = etree.fromstring(body) + if xml.xpath('/Response/Error'): + error = xml.xpath('/Response/Error')[0] + msg = error.get("description") if error.get("description") is not None else "" + + if error.text is not None: + if msg: + msg = msg + "\n\n" + error.text + else: + msg = error.text + else: + # Valid XML, but no error information + msg = body + except etree.XMLSyntaxError: + # Handling of incomplete responses, e.g., due to timeouts, + # c.f. https://gitlab.com/linkahead/linkahead-pylib/-/issues/87. + msg = body LinkAheadException.__init__(self, msg) diff --git a/src/linkahead/schema-pycaosdb-ini.yml b/src/linkahead/schema-pycaosdb-ini.yml index 89ce98570738fdd29dba81de25a2c022c1581467..ae46b905c62d2ab168229d92ff138937279c7aed 100644 --- a/src/linkahead/schema-pycaosdb-ini.yml +++ b/src/linkahead/schema-pycaosdb-ini.yml @@ -67,7 +67,13 @@ schema-pycaosdb-ini: description: This option is used internally and for testing. Do not override. examples: [_DefaultCaosDBServerConnection] timeout: - type: integer + oneOf: + - type: [integer, "null"] + - type: array + items: + type: [integer, "null"] + minItems: 2 + maxItems: 2 allOf: - if: properties: diff --git a/src/linkahead/utils/create_revision.py b/src/linkahead/utils/create_revision.py index 5f6ecc8148859d0ee0908412ff80d20d465cdb25..cde4bae5b0d919977d220b2c35896dcb20e933e7 100644 --- a/src/linkahead/utils/create_revision.py +++ b/src/linkahead/utils/create_revision.py @@ -34,15 +34,15 @@ def bend_references(from_id, to_id, except_for=None): and those references are changed to point to to_id. entities having an id listed in except_for are excluded. -Parameters ----------- + Parameters + ---------- -from_id : int - the old object to which references where pointing -to_id : int - the new object to which references will be pointing -except_for : list of int - entities with id of this list will not be changed + from_id : int + the old object to which references where pointing + to_id : int + the new object to which references will be pointing + except_for : list of int + entities with id of this list will not be changed """ if except_for is None: except_for = [to_id] @@ -73,16 +73,16 @@ def create_revision(old_id, prop, value): This function changes the record with id old_id. The value of the propertye prop is changed to value. -Parameters ----------- + Parameters + ---------- -old_id : int - id of the record to be changed -prop : string - name of the property to be changed -value : type of corresponding property - the new value of the corresponding property -""" + old_id : int + id of the record to be changed + prop : string + name of the property to be changed + value : type of corresponding property + the new value of the corresponding property + """ record = db.execute_query("FIND {}".format(old_id))[0] new_rec = record.copy() new_rec.get_property(prop).value = value diff --git a/src/linkahead/utils/get_entity.py b/src/linkahead/utils/get_entity.py index f84dc107e275390e53c6127834f53e5e5c6521cd..dd91cdc27b3f6adb52ddef36a59d1a0965fb662e 100644 --- a/src/linkahead/utils/get_entity.py +++ b/src/linkahead/utils/get_entity.py @@ -30,7 +30,13 @@ from .escape import escape_squoted_text def get_entity_by_name(name: str, role: Optional[str] = None) -> Entity: """Return the result of a unique query that uses the name to find the correct entity. - Submits the query "FIND ENTITY WITH name='{name}'". + Submits the query "FIND {role} WITH name='{name}'". + + Parameters + ---------- + + role: str, optional + The role for the query, defaults to ``ENTITY``. """ name = escape_squoted_text(name) if role is None: @@ -42,7 +48,13 @@ def get_entity_by_name(name: str, role: Optional[str] = None) -> 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}'". + Submits the query "FIND {role} WITH id='{eid}'". + + Parameters + ---------- + + role: str, optional + The role for the query, defaults to ``ENTITY``. """ if role is None: role = "ENTITY" @@ -53,7 +65,13 @@ def get_entity_by_id(eid: Union[str, int], role: Optional[str] = None) -> Entity def get_entity_by_path(path: str) -> Entity: """Return the result of a unique query that uses the path to find the correct file. - Submits the query "FIND FILE WHICH IS STORED AT '{path}'". + Submits the query "FIND {role} WHICH IS STORED AT '{path}'". + + Parameters + ---------- + + role: str, optional + The role for the query, defaults to ``ENTITY``. """ # type hint can be ignored, it's a unique query return execute_query(f"FIND FILE WHICH IS STORED AT '{path}'", unique=True) # type: ignore diff --git a/src/linkahead/utils/git_utils.py b/src/linkahead/utils/git_utils.py index 7a58272a3bef1930f75a1e08364349388e2bb89f..4824d619bfc77925add0c383f72360a644dd7833 100644 --- a/src/linkahead/utils/git_utils.py +++ b/src/linkahead/utils/git_utils.py @@ -36,9 +36,9 @@ logger = logging.getLogger(__name__) def get_origin_url_in(folder: str): """return the Fetch URL of the git repository in the given folder.""" - with tempfile.NamedTemporaryFile(delete=False, mode="w") as t: - call(["git", "remote", "show", "origin"], stdout=t, cwd=folder) - with open(t.name, "r") as t: + with tempfile.NamedTemporaryFile(delete=False, mode="w", encoding="utf8") as tempf: + call(["git", "remote", "show", "origin"], stdout=tempf, cwd=folder) + with open(tempf.name, "r", encoding="utf8") as t: urlString = "Fetch URL:" for line in t.readlines(): @@ -63,9 +63,9 @@ def get_branch_in(folder: str): The command "git branch" is called in the given folder and the output is returned """ - with tempfile.NamedTemporaryFile(delete=False, mode="w") as t: - call(["git", "rev-parse", "--abbrev-ref", "HEAD"], stdout=t, cwd=folder) - with open(t.name, "r") as t: + with tempfile.NamedTemporaryFile(delete=False, mode="w") as tempf: + call(["git", "rev-parse", "--abbrev-ref", "HEAD"], stdout=tempf, cwd=folder) + with open(tempf.name, "r") as t: return t.readline().strip() @@ -76,7 +76,7 @@ def get_commit_in(folder: str): and the output is returned """ - with tempfile.NamedTemporaryFile(delete=False, mode="w") as t: - call(["git", "log", "-1", "--format=%h"], stdout=t, cwd=folder) - with open(t.name, "r") as t: + with tempfile.NamedTemporaryFile(delete=False, mode="w") as tempf: + call(["git", "log", "-1", "--format=%h"], stdout=tempf, cwd=folder) + with open(tempf.name, "r") as t: return t.readline().strip() diff --git a/src/linkahead/utils/linkahead_admin.py b/src/linkahead/utils/linkahead_admin.py index f7e3b8b63f18e37e6210f2aa03f34ce5b0f688d4..ca5f3c01e0bbe95fe712761ec7f443ec88d406fd 100755 --- a/src/linkahead/utils/linkahead_admin.py +++ b/src/linkahead/utils/linkahead_admin.py @@ -33,7 +33,7 @@ from argparse import ArgumentParser, RawDescriptionHelpFormatter import linkahead as db from linkahead import administration as admin -from linkahead.exceptions import HTTPClientError +from linkahead.exceptions import HTTPClientError, HTTPResourceNotFoundError, HTTPForbiddenError __all__ = [] __version__ = 0.3 @@ -42,19 +42,42 @@ __updated__ = '2018-12-11' def do_update_role(args): - admin._update_role(name=args.role_name, description=args.role_description) + """ + Update the description of a role. + + Allowed keyword arguments: + role_name: Name of the role to update + role_description: New description of the role + """ + try: + admin._update_role(name=args.role_name, description=args.role_description) + except (HTTPResourceNotFoundError, HTTPForbiddenError) as e: + print(f"Error: Cannot update role '{args.role_name}', " + f"reason: '{e.msg}'") def do_create_role(args): - admin._insert_role(name=args.role_name, description=args.role_description) + try: + admin._insert_role(name=args.role_name, description=args.role_description) + except (HTTPClientError, HTTPForbiddenError) as e: + print(f"Error: Cannot create role '{args.role_name}', " + f"reason: '{e.msg}'") def do_retrieve_role(args): - print(admin._retrieve_role(name=args.role_name)) + try: + print(admin._retrieve_role(name=args.role_name)) + except (HTTPResourceNotFoundError, HTTPForbiddenError) as e: + print(f"Error: Cannot retrieve role '{args.role_name}', " + f"reason: '{e.msg}'") def do_delete_role(args): - admin._delete_role(name=args.role_name) + try: + admin._delete_role(name=args.role_name) + except (HTTPResourceNotFoundError, HTTPForbiddenError) as e: + print(f"Error: Cannot delete role '{args.role_name}', " + f"reason: '{e.msg}'") def do_retrieve(args): @@ -123,25 +146,27 @@ def do_create_user(args): try: admin._insert_user(name=args.user_name, email=args.user_email, password=password) - if args.activate_user: do_activate_user(args) - except HTTPClientError as e: - print(e.msg) + except (HTTPForbiddenError, HTTPClientError) as e: + print(f"Error: Cannot create user '{args.user_name}', " + f"reason: '{e.msg}'") def do_activate_user(args): try: admin._update_user(name=args.user_name, status="ACTIVE") - except HTTPClientError as e: - print(e.msg) + except (HTTPResourceNotFoundError, HTTPForbiddenError, HTTPClientError) as e: + print(f"Error: Cannot activate user '{args.user_name}', " + f"reason: '{e.msg}'") def do_deactivate_user(args): try: admin._update_user(name=args.user_name, status="INACTIVE") - except HTTPClientError as e: - print(e.msg) + except (HTTPResourceNotFoundError, HTTPForbiddenError, HTTPClientError) as e: + print(f"Error: Cannot deactivate user '{args.user_name}', " + f"reason: '{e.msg}'") def do_set_user_password(args): @@ -150,58 +175,110 @@ def do_set_user_password(args): else: password = args.user_password try: - admin._update_user(name=args.user_name, password=password) - except HTTPClientError as e: - print(e.msg) + admin._update_user(name=args.user_name, password=password, realm=args.realm) + except (HTTPResourceNotFoundError, HTTPForbiddenError, HTTPClientError) as e: + print(f"Error: Cannot set password for user '{args.user_name}', " + f"reason: '{e.msg}'") def do_add_user_roles(args): - roles = admin._get_roles(username=args.user_name, realm=None) + try: + roles = admin._get_roles(username=args.user_name, realm=None) + except (HTTPForbiddenError, HTTPResourceNotFoundError) as e: + print(f"Error: Cannot access roles for user '{args.user_name}', " + f"reason: '{e.msg}'") + return for r in args.user_roles: roles.add(r) - admin._set_roles(username=args.user_name, roles=roles) + try: + admin._set_roles(username=args.user_name, roles=roles) + except (HTTPResourceNotFoundError, HTTPForbiddenError, HTTPClientError) as e: + print(f"Error: Cannot add new roles for user '{args.user_name}', " + f"reason: '{e.msg}'") def do_remove_user_roles(args): - roles = admin._get_roles(username=args.user_name, realm=None) + try: + roles = admin._get_roles(username=args.user_name, realm=None) + except (HTTPForbiddenError, HTTPResourceNotFoundError) as e: + print(f"Error: Cannot access roles for user '{args.user_name}', " + f"reason: '{e.msg}'") + return for r in args.user_roles: if r in roles: roles.remove(r) - admin._set_roles(username=args.user_name, roles=roles) + try: + admin._set_roles(username=args.user_name, roles=roles) + except (HTTPResourceNotFoundError, HTTPForbiddenError, HTTPClientError) as e: + print(f"Error: Cannot remove roles from user '{args.user_name}', " + f"reason: '{e.msg}'") def do_set_user_entity(args): - admin._update_user(name=args.user_name, entity=args.user_entity) + try: + admin._update_user(name=args.user_name, entity=args.user_entity) + except (HTTPResourceNotFoundError, HTTPForbiddenError, HTTPClientError) as e: + print(f"Error: Cannot set entity for user '{args.user_name}', " + f"reason: '{e.msg}'") def do_reset_user_entity(args): - admin._update_user(name=args.user_name, entity="") + try: + admin._update_user(name=args.user_name, entity="") + except (HTTPResourceNotFoundError, HTTPForbiddenError, HTTPClientError) as e: + print(f"Error: Cannot remove entity for user '{args.user_name}', " + f"reason: '{e.msg}'") def do_set_user_email(args): - admin._update_user(name=args.user_name, email=args.user_email) + try: + admin._update_user(name=args.user_name, email=args.user_email) + except (HTTPResourceNotFoundError, HTTPForbiddenError, HTTPClientError) as e: + print(f"Error: Cannot set email for user '{args.user_name}', " + f"reason: '{e.msg}'") def do_retrieve_user(args): - print(admin._retrieve_user(name=args.user_name)) + try: + print(admin._retrieve_user(name=args.user_name)) + except (HTTPResourceNotFoundError, HTTPForbiddenError) as e: + print(f"Error: Cannot retrieve user '{args.user_name}', " + f"reason: '{e.msg}'") def do_delete_user(args): - admin._delete_user(name=args.user_name) + try: + admin._delete_user(name=args.user_name) + except (HTTPResourceNotFoundError, HTTPForbiddenError) as e: + print(f"Error: Cannot delete user '{args.user_name}', " + f"reason: '{e.msg}'") def do_retrieve_user_roles(args): - print(admin._get_roles(username=args.user_name)) + try: + print(admin._get_roles(username=args.user_name)) + except (HTTPResourceNotFoundError, HTTPForbiddenError) as e: + print(f"Error: Cannot retrieve roles for user '{args.user_name}', " + f"reason: '{e.msg}'") def do_retrieve_role_permissions(args): - print(admin._get_permissions(role=args.role_name)) + try: + print(admin._get_permissions(role=args.role_name)) + except (HTTPResourceNotFoundError, HTTPForbiddenError) as e: + print(f"Error: Cannot retrieve permissions for role '{args.role_name}', " + f"reason: '{e.msg}'") def do_grant_role_permissions(args): - perms = admin._get_permissions(args.role_name) + try: + perms = admin._get_permissions(role=args.role_name) + except (HTTPResourceNotFoundError, HTTPForbiddenError) as e: + print(f"Error: Cannot access permissions for role '{args.role_name}', " + f"reason: '{e.msg}'") + return for p in args.role_permissions: g = admin.PermissionRule( @@ -215,11 +292,20 @@ def do_grant_role_permissions(args): if d in perms: perms.remove(d) perms.add(g) - admin._set_permissions(role=args.role_name, permission_rules=perms) + try: + admin._set_permissions(role=args.role_name, permission_rules=perms) + except (HTTPResourceNotFoundError, HTTPForbiddenError) as e: + print(f"Error: Cannot set permissions for role '{args.role_name}', " + f"reason: '{e.msg}'") def do_revoke_role_permissions(args): - perms = admin._get_permissions(args.role_name) + try: + perms = admin._get_permissions(role=args.role_name) + except (HTTPResourceNotFoundError, HTTPForbiddenError) as e: + print(f"Error: Cannot access permissions for role '{args.role_name}', " + f"reason: '{e.msg}'") + return for p in args.role_permissions: g = admin.PermissionRule( @@ -232,11 +318,20 @@ def do_revoke_role_permissions(args): if d in perms: perms.remove(d) - admin._set_permissions(role=args.role_name, permission_rules=perms) + try: + admin._set_permissions(role=args.role_name, permission_rules=perms) + except (HTTPResourceNotFoundError, HTTPForbiddenError) as e: + print(f"Error: Cannot revoke permissions for role '{args.role_name}', " + f"reason: '{e.msg}'") def do_deny_role_permissions(args): - perms = admin._get_permissions(args.role_name) + try: + perms = admin._get_permissions(role=args.role_name) + except (HTTPResourceNotFoundError, HTTPForbiddenError) as e: + print(f"Error: Cannot access permissions for role '{args.role_name}', " + f"reason: '{e.msg}'") + return for p in args.role_permissions: g = admin.PermissionRule( @@ -250,7 +345,11 @@ def do_deny_role_permissions(args): if d in perms: perms.remove(d) perms.add(d) - admin._set_permissions(role=args.role_name, permission_rules=perms) + try: + admin._set_permissions(role=args.role_name, permission_rules=perms) + except (HTTPResourceNotFoundError, HTTPForbiddenError) as e: + print(f"Error: Cannot deny permissions for role '{args.role_name}', " + f"reason: '{e.msg}'") def do_retrieve_entity_acl(args): @@ -364,6 +463,12 @@ USAGE metavar='USERNAME', dest="user_name", help="The name of the user who's password is to be set.") + subparser.add_argument( + metavar='REALM', + dest="realm", + nargs="?", + default=None, + help="The realm of the user who's password is to be set.") subparser.add_argument( metavar='PASSWORD', nargs="?", diff --git a/src/linkahead/utils/plantuml.py b/src/linkahead/utils/plantuml.py index 19594d6e856e740fe2c58c5128eead31c37485ce..59e3c34dd04c2425aef46b6d9e2411f75b747aca 100644 --- a/src/linkahead/utils/plantuml.py +++ b/src/linkahead/utils/plantuml.py @@ -130,9 +130,9 @@ def recordtypes_to_plantuml_string(iterable, classes = [el for el in iterable if isinstance(el, db.RecordType)] - dependencies = {} - inheritances = {} - properties = [p for p in iterable if isinstance(p, db.Property)] + dependencies: dict = {} + inheritances: dict = {} + properties: list = [p for p in iterable if isinstance(p, db.Property)] grouped = [g for g in iterable if isinstance(g, Grouped)] def _add_properties(c, importance=None): @@ -272,7 +272,8 @@ package \"The property P references an instance of D\" <<Rectangle>> { return result -def retrieve_substructure(start_record_types, depth, result_id_set=None, result_container=None, cleanup=True): +def retrieve_substructure(start_record_types, depth, result_id_set=None, result_container=None, + cleanup=True): """Recursively retrieves LinkAhead record types and properties, starting from given initial types up to a specific depth. diff --git a/src/linkahead/utils/register_tests.py b/src/linkahead/utils/register_tests.py index 6909544fed5a6f80572f60ba102c72b53568d897..66fd4553346075fc77aa7b1f6003d26d9967c223 100644 --- a/src/linkahead/utils/register_tests.py +++ b/src/linkahead/utils/register_tests.py @@ -18,44 +18,62 @@ # # 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/>. - -import linkahead as db -from linkahead import administration as admin - -""" -This module implements a registration procedure for integration tests which +"""This module implements a registration procedure for integration tests which need a running LinkAhead instance. -It ensures that tests do not accidentally overwrite data in real LinkAhead -instances, as it checks whether the running LinkAhead instance is actually the -correct one, that -should be used for these tests. - -The test files have to define a global variable TEST_KEY which must be unique -for each test using +It ensures that tests do not accidentally overwrite data in real +LinkAhead instances, as it checks whether the running LinkAhead +instance is actually the correct one, that should be used for these +tests. -set_test_key("ABCDE") +The test files have to define a global variable ``TEST_KEY`` which +must be unique for each test using +:py:meth:`~linkahead.utils.register_tests.set_test_key`. The test procedure (invoked by pytest) checks whether a registration information is stored in one of the server properties or otherwise -- offers to register this test in the currently running database ONLY if this - is empty. + +- offers to register this test in the currently running database ONLY if this is + empty. - fails otherwise with a RuntimeError -NOTE: you probably need to use pytest with the -s option to be able to - register the test interactively. Otherwise, the server property has to be - set before server start-up in the server.conf of the LinkAhead server. +.. note:: + + you probably need to use pytest with the -s option to be able to + register the test interactively. Otherwise, the server property + has to be set before server start-up in the server.conf of the + LinkAhead server. This module is intended to be used with pytest. -There is a pytest fixture "clear_database" that performs the above mentioned -checks and clears the database in case of success. +There is a pytest fixture +:py:meth:`~linkahead.utils.register_tests.clear_database` that +performs the above mentioned checks and clears the database in case of +success. + """ +import linkahead as db +from linkahead import administration as admin + TEST_KEY = None -def set_test_key(KEY): +def set_test_key(KEY: str): + """Set the global ``TEST_KEY`` variable to `KEY`. Afterwards, if + `KEY` matches the ``_CAOSDB_INTEGRATION_TEST_SUITE_KEY`` server + environment variable, mehtods like :py:meth:`clear_database` can + be used. Call this function in the beginning of your test file. + + Parameters + ---------- + KEY : str + key with which the test using this function is registered and + which is checked against the + ``_CAOSDB_INTEGRATION_TEST_SUITE_KEY`` server environment + variable. + + """ global TEST_KEY TEST_KEY = KEY @@ -122,10 +140,14 @@ try: @pytest.fixture def clear_database(): - """Remove Records, RecordTypes, Properties, and Files ONLY IF the LinkAhead - server the current connection points to was registered with the appropriate key. + """Remove Records, RecordTypes, Properties, and Files ONLY IF + the LinkAhead server the current connection points to was + registered with the appropriate key using + :py:meth:`set_test_key`. + + PyTestInfo Records and the corresponding RecordType and + Property are preserved. - PyTestInfo Records and the corresponding RecordType and Property are preserved. """ _assure_test_is_registered() yield _clear_database() # called before the test function diff --git a/src/linkahead/utils/server_side_scripting.py b/src/linkahead/utils/server_side_scripting.py index 06caa3d94a629e368dc99f83dc2957c756b7b487..867155cf1f93bf1936e9b19f14926726f362edaf 100644 --- a/src/linkahead/utils/server_side_scripting.py +++ b/src/linkahead/utils/server_side_scripting.py @@ -99,6 +99,19 @@ def _make_request(call, pos_args, opts, files=None): def run_server_side_script(call, *args, files=None, **kwargs): """ + Parameters + ---------- + call : str + name of the script to be called, potentially with path prefix (e.g. ``management/update.py``) + *args : list(str) + list of positional arguments + files : dict + dictionary with where keys are the argument names with prefix (e.g. ``-p1`` or ``-Ofile``) and + the values are the paths to the files to be uploaded. Note, that only the base name will be + used when uploaded. Files will be placed in the ``.upload_files`` folder. Thus, the script + will be called with the argument ``<key>=.upload_files/<basename>``. + **kwargs : dict + kwargs will be passed to ``_make_request`` Return ------ diff --git a/tox.ini b/tox.ini index bbaaa1fc9eec2aba87c247d783818d215d8a7d5e..c63555c27b73224106109c1f675e9525d9e89b74 100644 --- a/tox.ini +++ b/tox.ini @@ -9,7 +9,7 @@ deps = . mypy jsonschema>=4.4.0 setuptools -commands=py.test --cov=caosdb -vv {posargs} +commands=py.test --cov=linkahead -vv {posargs} [flake8] max-line-length=100 @@ -17,6 +17,6 @@ max-line-length=100 [pytest] testpaths = unittests xfail_strict = True -addopts = -x -vv --cov=caosdb +addopts = -x -vv --cov=linkahead pythonpath = src diff --git a/unittests/test_apiutils.py b/unittests/test_apiutils.py index 9974643610390b486474972d8f57a9e257e879da..aee6cef3503804a6d2bdfe15e9e27f0ee66b687a 100644 --- a/unittests/test_apiutils.py +++ b/unittests/test_apiutils.py @@ -1,6 +1,7 @@ # # This file is a part of the LinkAhead Project. # +# Copyright (C) 2024 Alexander Schlemmer <a.schlemmer@indiscale.com> # Copyright (C) 2020 Timm Fitschen <t.fitschen@indiscale.com> # Copyright (C) 2022 Florian Spreckelsen <f.spreckelsen@indiscale.com> # Copyright (C) 2022 Daniel Hornung <d.hornung@indiscale.com> @@ -25,13 +26,15 @@ # Test apiutils # A. Schlemmer, 02/2018 +from io import StringIO import linkahead as db import linkahead.apiutils import pytest from linkahead.apiutils import (EntityMergeConflictError, apply_to_ids, compare_entities, create_id_query, empty_diff, - merge_entities, resolve_reference) + merge_entities, resolve_reference, + describe_diff) from linkahead.common.models import SPECIAL_ATTRIBUTES @@ -96,6 +99,7 @@ def test_resolve_reference(): def test_compare_entities(): + # test compare of parents, properties r1 = db.Record() r2 = db.Record() r1.add_parent("bla") @@ -111,13 +115,27 @@ def test_compare_entities(): r2.add_property("tester", ) r1.add_property("tests_234234", value=45) r2.add_property("tests_TT", value=45) + r1.add_property("datatype", value=45, datatype=db.INTEGER) + r2.add_property("datatype", value=45) + r1.add_property("entity_id", value=2) + r2.add_property("entity_id", value=24) + r1.add_property("entity_mix_e", value=2) + r2.add_property("entity_mix_e", value=db.Entity(id=2)) + r1.add_property("entity_mix_d", value=22) + r2.add_property("entity_mix_d", value=db.Entity(id=2)) + r1.add_property("entity_mix_w", value=22) + r2.add_property("entity_mix_w", value=db.Entity()) + r1.add_property("entity_Ent_e", value=db.Entity(id=2)) + r2.add_property("entity_Ent_e", value=db.Entity(id=2)) + r1.add_property("entity_Ent_d", value=db.Entity(id=2)) + r2.add_property("entity_Ent_d", value=db.Entity(id=22)) diff_r1, diff_r2 = compare_entities(r1, r2) assert len(diff_r1["parents"]) == 1 assert len(diff_r2["parents"]) == 0 - assert len(diff_r1["properties"]) == 4 - assert len(diff_r2["properties"]) == 4 + assert len(diff_r1["properties"]) == 11 + assert len(diff_r2["properties"]) == 11 assert "test" not in diff_r1["properties"] assert "test" not in diff_r2["properties"] @@ -134,13 +152,89 @@ def test_compare_entities(): assert "tests_234234" in diff_r1["properties"] assert "tests_TT" in diff_r2["properties"] + assert "datatype" in diff_r1["properties"] + assert "datatype" in diff_r1["properties"]["datatype"] + assert "datatype" in diff_r2["properties"] + assert "datatype" in diff_r2["properties"]["datatype"] + + assert "entity_id" in diff_r1["properties"] + assert "entity_id" in diff_r2["properties"] + + assert "entity_mix_e" in diff_r1["properties"] + assert "entity_mix_e" in diff_r2["properties"] + assert "entity_Ent_e" in diff_r1["properties"] + assert "entity_Ent_e" in diff_r2["properties"] + + assert "entity_mix_d" in diff_r1["properties"] + assert "entity_mix_d" in diff_r2["properties"] + assert "entity_mix_w" in diff_r1["properties"] + assert "entity_mix_w" in diff_r2["properties"] + assert "entity_Ent_d" in diff_r1["properties"] + assert "entity_Ent_d" in diff_r2["properties"] + + diff_r1, diff_r2 = compare_entities(r1, r2, compare_referenced_records=True) + + assert len(diff_r1["parents"]) == 1 + assert len(diff_r2["parents"]) == 0 + assert len(diff_r1["properties"]) == 10 + assert len(diff_r2["properties"]) == 10 + + assert "entity_id" in diff_r1["properties"] + assert "entity_id" in diff_r2["properties"] + + assert "entity_mix_e" in diff_r1["properties"] + assert "entity_mix_e" in diff_r2["properties"] + assert "entity_mix_w" in diff_r1["properties"] + assert "entity_mix_w" in diff_r2["properties"] + assert "entity_Ent_e" not in diff_r1["properties"] + assert "entity_Ent_e" not in diff_r2["properties"] + + assert "entity_mix_d" in diff_r1["properties"] + assert "entity_mix_d" in diff_r2["properties"] + assert "entity_Ent_d" in diff_r1["properties"] + assert "entity_Ent_d" in diff_r2["properties"] + + diff_r1, diff_r2 = compare_entities(r1, r2, + entity_name_id_equivalency=True, + compare_referenced_records=True) + + assert len(diff_r1["parents"]) == 1 + assert len(diff_r2["parents"]) == 0 + assert len(diff_r1["properties"]) == 9 + assert len(diff_r2["properties"]) == 9 + + assert "entity_id" in diff_r1["properties"] + assert "entity_id" in diff_r2["properties"] + + assert "entity_mix_e" not in diff_r1["properties"] + assert "entity_mix_e" not in diff_r2["properties"] + assert "entity_mix_w" in diff_r1["properties"] + assert "entity_mix_w" in diff_r2["properties"] + assert "entity_Ent_e" not in diff_r1["properties"] + assert "entity_Ent_e" not in diff_r2["properties"] + + assert "entity_mix_d" in diff_r1["properties"] + assert "entity_mix_d" in diff_r2["properties"] + assert "entity_Ent_d" in diff_r1["properties"] + assert "entity_Ent_d" in diff_r2["properties"] + + r1 = db.Record() + r2 = db.Record() + r1.add_property(id=20, name="entity_mix_d", value=2, datatype=db.LIST("B")) + r2.add_property("entity_mix_d", value=db.Entity()) + + diff_r1, diff_r2 = compare_entities(r1, r2, compare_referenced_records=True) + + assert len(diff_r1["properties"]) == 1 + assert len(diff_r2["properties"]) == 1 + + assert "entity_mix_d" in diff_r1["properties"] + assert "entity_mix_d" in diff_r2["properties"] + def test_compare_entities_units(): r1 = db.Record() r2 = db.Record() - r1.add_parent("bla") - r2.add_parent("bla") - r1.add_parent("lopp") r1.add_property("test", value=2, unit="cm") r2.add_property("test", value=2, unit="m") r1.add_property("tests", value=3, unit="cm") @@ -152,8 +246,6 @@ def test_compare_entities_units(): diff_r1, diff_r2 = compare_entities(r1, r2) - assert len(diff_r1["parents"]) == 1 - assert len(diff_r2["parents"]) == 0 assert len(diff_r1["properties"]) == 4 assert len(diff_r2["properties"]) == 4 @@ -170,14 +262,229 @@ def test_compare_entities_units(): assert diff_r2["properties"]["test"]["unit"] == "m" +def test_compare_entities_battery(): + par1, par3 = db.Record(name=""), db.RecordType(name="") + r1, r2, r3 = db.Record(), db.Record(), db.Record() + prop2 = db.Property(name="Property 2") + prop3 = db.Property(name="") + + # Basic tests for Properties + prop_settings = {"datatype": db.REFERENCE, "description": "desc of prop", + "value": db.Record().add_parent(par3), "unit": '°'} + t1 = db.Record().add_parent(db.RecordType(id=1)) + t2 = db.Record().add_parent(db.RecordType(id=1)) + # Change datatype + t1.add_property(db.Property(name="datatype", **prop_settings)) + prop_settings["datatype"] = par3 + t2.add_property(db.Property(name="datatype", **prop_settings)) + # Change description + t1.add_property(db.Property(name="description", **prop_settings)) + prop_settings["description"] = "diff desc" + t2.add_property(db.Property(name="description", **prop_settings)) + # Change value to copy + t1.add_property(db.Property(name="value copy", **prop_settings)) + prop_settings["value"] = db.Record().add_parent(par3) + t2.add_property(db.Property(name="value copy", **prop_settings)) + # Change value to something different + t1.add_property(db.Property(name="value", **prop_settings)) + prop_settings["value"] = db.Record(name="n").add_parent(par3) + t2.add_property(db.Property(name="value", **prop_settings)) + # Change unit + t1.add_property(db.Property(name="unit", **prop_settings)) + prop_settings["unit"] = db.Property(unit='°') + t2.add_property(db.Property(name="unit", **prop_settings)) + # Change unit again + t1.add_property(db.Property(name="unit 2", **prop_settings)) + prop_settings["unit"] = db.Property() + t2.add_property(db.Property(name="unit 2", **prop_settings)) + # Compare + diff_0 = compare_entities(t1, t2) + diff_1 = compare_entities(t1, t2, compare_referenced_records=True) + # Check correct detection of changes + assert diff_0[0]["properties"]["datatype"] == {"datatype": db.REFERENCE} + assert diff_0[1]["properties"]["datatype"] == {"datatype": par3} + assert diff_0[0]["properties"]["description"] == {"description": "desc of prop"} + assert diff_0[1]["properties"]["description"] == {"description": "diff desc"} + assert "value" in diff_0[0]["properties"]["value copy"] + assert "value" in diff_0[1]["properties"]["value copy"] + assert "value" in diff_0[0]["properties"]["value"] + assert "value" in diff_0[1]["properties"]["value"] + assert "unit" in diff_0[0]["properties"]["unit"] + assert "unit" in diff_0[1]["properties"]["unit"] + assert "unit" in diff_0[0]["properties"]["unit 2"] + assert "unit" in diff_0[1]["properties"]["unit 2"] + # Check correct result for compare_referenced_records=True + assert "value copy" not in diff_1[0]["properties"] + assert "value copy" not in diff_1[1]["properties"] + diff_0[0]["properties"].pop("value copy") + diff_0[1]["properties"].pop("value copy") + assert diff_0 == diff_1 + + # Basic tests for Parents + t3 = db.Record().add_parent(db.RecordType("A")).add_parent(db.Record("B")) + t4 = db.Record().add_parent(db.RecordType("A")) + assert compare_entities(t3, t4)[0]['parents'] == ['B'] + assert len(compare_entities(t3, t4)[1]['parents']) == 0 + t4.add_parent(db.Record("B")) + assert empty_diff(t3, t4) + # The two following assertions document current behaviour but do not make a + # lot of sense + t4.add_parent(db.Record("B")) + assert empty_diff(t3, t4) + t3.add_parent(db.RecordType("A")).add_parent(db.Record("B")) + t4.add_parent(db.RecordType("B")).add_parent(db.Record("A")) + assert empty_diff(t3, t4) + + # Basic tests for special attributes + prop_settings = {"id": 42, "name": "Property", + "datatype": db.LIST(db.REFERENCE), "value": [db.Record(name="")], + "unit": '€', "description": "desc of prop"} + alt_settings = {"id": 64, "name": "Property 2", + "datatype": db.LIST(db.TEXT), "value": [db.RecordType(name="")], + "unit": '€€', "description": " Ä™ Äš ப ཾ ཿ ∛ ∜ ã…¿ ㆀ ê°’ "} + t5 = db.Property(**prop_settings) + t6 = db.Property(**prop_settings) + assert empty_diff(t5, t6) + # ID + t5.id = alt_settings['id'] + diff = compare_entities(t5, t6) + assert diff[0] == {'properties': {}, 'parents': [], 'id': alt_settings['id']} + assert diff[1] == {'properties': {}, 'parents': [], 'id': prop_settings['id']} + t6.id = alt_settings['id'] + assert empty_diff(t5, t6) + # Name + t5.name = alt_settings['name'] + diff = compare_entities(t5, t6) + assert diff[0] == {'properties': {}, 'parents': [], 'name': alt_settings['name']} + assert diff[1] == {'properties': {}, 'parents': [], 'name': prop_settings['name']} + t6.name = alt_settings['name'] + assert empty_diff(t5, t6) + # Description + t6.description = alt_settings['description'] + diff = compare_entities(t5, t6) + assert diff[0] == {'properties': {}, 'parents': [], 'description': prop_settings['description']} + assert diff[1] == {'properties': {}, 'parents': [], 'description': alt_settings['description']} + t5.description = alt_settings['description'] + assert empty_diff(t5, t6) + # Unit + t5.unit = alt_settings['unit'] + diff = compare_entities(t5, t6) + assert diff[0] == {'properties': {}, 'parents': [], 'unit': alt_settings['unit']} + assert diff[1] == {'properties': {}, 'parents': [], 'unit': prop_settings['unit']} + t6.unit = alt_settings['unit'] + assert empty_diff(t5, t6) + # Value + t6.value = alt_settings['value'] + diff = compare_entities(t5, t6) + assert diff[0] == {'properties': {}, 'parents': [], 'value': prop_settings['value']} + assert diff[1] == {'properties': {}, 'parents': [], 'value': alt_settings['value']} + t5.value = alt_settings['value'] + assert empty_diff(t5, t6) + # Datatype + t6.datatype = alt_settings['datatype'] + diff = compare_entities(t5, t6) + assert diff[0] == {'properties': {}, 'parents': [], 'datatype': prop_settings['datatype']} + assert diff[1] == {'properties': {}, 'parents': [], 'datatype': alt_settings['datatype']} + t5.datatype = alt_settings['datatype'] + assert empty_diff(t5, t6) + # All at once + diff = compare_entities(db.Property(**prop_settings), db.Property(**alt_settings)) + assert diff[0] == {'properties': {}, 'parents': [], **prop_settings} + assert diff[1] == {'properties': {}, 'parents': [], **alt_settings} + # Entity Type + diff = compare_entities(db.Property(value=db.Property(id=101)), + db.Property(value=db.Record(id=101))) + assert "value" in diff[0] + assert "value" in diff[1] + diff = compare_entities(db.Property(value=db.Record(id=101)), + db.Property(value=db.Record(id=101))) + assert "value" in diff[0] + assert "value" in diff[1] + assert empty_diff(db.Property(value=db.Record(id=101)), + db.Property(value=db.Record(id=101)), + compare_referenced_records=True) + + # Special cases + # Files + assert not empty_diff(db.File(path='ABC', file=StringIO("ABC")), + db.File(path='ABC', file=StringIO("Other"))) + # Importance + assert empty_diff(db.Property().add_property(prop2), + db.Property().add_property(prop2)) + assert not empty_diff(db.Property().add_property(prop2, importance=db.SUGGESTED), + db.Property().add_property(prop2, importance=db.OBLIGATORY)) + # Mixed Lists + assert empty_diff(db.Property(value=[1, 2, 'a', r1]), + db.Property(value=[1, 2, 'a', r1])) + # entity_name_id_equivalency + assert not empty_diff(db.Property(value=[1, db.Record(id=2), 3, db.Record(id=4)]), + db.Property(value=[db.Record(id=1), 2, db.Record(id=3), 4])) + assert empty_diff(db.Property(value=[1, db.Record(id=2), 3, db.Record(id=4)]), + db.Property(value=[db.Record(id=1), 2, db.Record(id=3), 4]), + entity_name_id_equivalency=True) + assert empty_diff(db.Property(value=1), db.Property(value=db.Record(id=1)), + entity_name_id_equivalency=True) + # entity_name_id_equivalency + prop4 = db.Property(**prop_settings).add_parent(par1).add_property(prop2) + prop4_c = db.Property(**prop_settings).add_parent(par1).add_property(prop2) + prop4.value = db.Record(id=12) + prop4_c.value = '12' + prop4.add_property(db.Property(name="diff", datatype=db.LIST(db.REFERENCE), + value=[12, db.Record(id=13), par1, "abc%"])) + prop4_c.add_property(db.Property(name="diff", datatype=db.LIST(db.REFERENCE), + value=[db.Record(id=12), "13", par1, "abc%"])) + assert not empty_diff(prop4, prop4_c, entity_name_id_equivalency=False) + assert empty_diff(prop4, prop4_c, entity_name_id_equivalency=True) + # Order invariance + t7 = db.Property(**prop_settings).add_parent(par1).add_property(prop2) + t8 = db.Property(**alt_settings).add_parent(par3).add_property(prop3) + diffs_0 = compare_entities(t7, t8), compare_entities(t7, t8, True) + diffs_1 = compare_entities(t8, t7)[::-1], compare_entities(t8, t7, True)[::-1] + assert diffs_0 == diffs_1 + prop_settings = {"datatype": db.REFERENCE, "description": "desc of prop", + "value": db.Record().add_parent(par3), "unit": '°'} + t1.add_property(db.Property(name="description", **prop_settings)) + t2.add_property(db.Property(name="description", **prop_settings)) + # Order invariance for multi-property - either both fail or same result + try: + diffs_0 = compare_entities(t1, t2), compare_entities(t1, t2, True) + except Exception as e: + diffs_0 = type(e) + try: + diffs_1 = compare_entities(t2, t1)[::-1], compare_entities(t2, t1, True)[::-1] + except Exception as e: + diffs_1 = type(e) + assert diffs_0 == diffs_1 + # Property types + t09, t10 = db.RecordType(), db.RecordType() + for t, ex in [(db.INTEGER, [-12, 0]), (db.DATETIME, ["2030-01-01", "1012-02-29"]), + (db.DOUBLE, [13.23, 7.1]), (db.BOOLEAN, [True, False])]: + t09.add_property(db.Property(name=f"{t}:{ex[0]}", datatype=t, value=ex[0])) + t10.add_property(db.Property(name=f"{t}:{ex[0]}", datatype=t, value=ex[0])) + t09.add_property(name=f"{t}:{ex[1]}", datatype=t, value=ex[1]) + t10.add_property(name=f"{t}:{ex[1]}", datatype=t, value=ex[1]) + assert empty_diff(t09, t10) + t09.add_property(name=f"diff", value=1) + t10.add_property(name=f"diff", value=2) + assert not empty_diff(t09, t10) + # Default values + t09, t10 = db.Record(), db.Record() + t09.add_property(db.Property(name=f"A1"), value="A") + t10.add_property(name=f"A1", value="A") + t09.add_property(db.Property(id=12, name=f"A2"), value="A") + t10.add_property(id=12, name=f"A2", value="A") + t09.add_property(db.Property(id=15), value="A") + t10.add_property(id=15, value="A") + assert empty_diff(t09, t10) + # ToDo: extended tests for references + + def test_compare_special_properties(): # Test for all known special properties: - SPECIAL_PROPERTIES = ("description", "name", - "checksum", "size", "path", "id") INTS = ("size", "id") HIDDEN = ("checksum", "size") - for key in SPECIAL_PROPERTIES: + for key in SPECIAL_ATTRIBUTES: set_key = key if key in HIDDEN: set_key = "_" + key @@ -215,8 +522,7 @@ def test_compare_special_properties(): assert len(diff_r1["properties"]) == 0 assert len(diff_r2["properties"]) == 0 - -def test_compare_properties(): + # compare Property objects p1 = db.Property() p2 = db.Property() @@ -467,10 +773,10 @@ def test_empty_diff(): rec_a.remove_property("RefType") rec_b.remove_property("RefType") assert empty_diff(rec_a, rec_b) - rec_a.add_property(name="RefType", datatype=db.LIST( - "RefType"), value=[ref_rec_a, ref_rec_a]) - rec_b.add_property(name="RefType", datatype=db.LIST( - "RefType"), value=[ref_rec_b, ref_rec_b]) + rec_a.add_property(name="RefType", datatype=db.LIST("RefType"), + value=[ref_rec_a, ref_rec_a]) + rec_b.add_property(name="RefType", datatype=db.LIST("RefType"), + value=[ref_rec_b, ref_rec_b]) assert not empty_diff(rec_a, rec_b) assert empty_diff(rec_a, rec_b, compare_referenced_records=True) @@ -568,6 +874,21 @@ B: something else""" # unchanged assert recB.get_property("propA").unit == "cm" + # test whether an id is correctly overwritten by an entity without id + recA = db.Record().add_parent("A").add_property(name="B", value=112) + newRec = db.Record().add_parent("B").add_property("c") + recB = db.Record().add_parent("A").add_property(name="B", value=newRec) + + merge_entities(recA, recB, force=True) + assert recA.get_property("B").value == newRec + + recA = db.Record().add_parent("A").add_property(name="B", value=[112], + datatype=db.LIST("B")) + recB = db.Record().add_parent("A").add_property(name="B", value=[newRec], datatype=db.LIST(db.REFERENCE)) + + merge_entities(recA, recB, force=True) + assert recA.get_property("B").value == [newRec] + def test_merge_missing_list_datatype_82(): """Merging two properties, where the list-valued one has no datatype.""" @@ -601,13 +922,12 @@ def test_merge_id_with_resolved_entity(): # Overwrite from right to left in both cases merge_entities(recA, recB, merge_id_with_resolved_entity=True) - assert recA.get_property(rtname).value == ref_id - assert recA.get_property(rtname).value == recB.get_property(rtname).value + assert recA.get_property(rtname).value == ref_rec recA = db.Record().add_property(name=rtname, value=ref_rec) merge_entities(recB, recA, merge_id_with_resolved_entity=True) - assert recB.get_property(rtname).value == ref_rec - assert recA.get_property(rtname).value == recB.get_property(rtname).value + assert recB.get_property(rtname).value == ref_id + assert recA.get_property(rtname).value == ref_rec # id mismatches recB = db.Record().add_property(name=rtname, value=ref_id*2) @@ -623,7 +943,79 @@ def test_merge_id_with_resolved_entity(): # also works in lists: recA = db.Record().add_property( name=rtname, datatype=db.LIST(rtname), value=[ref_rec, ref_id*2]) - recB = db.Record().add_property(name=rtname, datatype=db.LIST(rtname), value=[ref_id, ref_id*2]) + recB = db.Record().add_property( + name=rtname, datatype=db.LIST(rtname), value=[ref_id, ref_id*2]) merge_entities(recA, recB, merge_id_with_resolved_entity=True) - assert recA.get_property(rtname).value == [ref_id, ref_id*2] - assert recA.get_property(rtname).value == recB.get_property(rtname).value + assert recA.get_property(rtname).value == [ref_rec, ref_id*2] + assert recB.get_property(rtname).value == [ref_id, ref_id*2] + + +def test_describe_diff(): + recA = db.Record() + recA.add_property(name="propA", value=2) + recA.add_property(name="propB", value=2) + recA.add_property(name="propD", value=-273, unit="K") + + recB = db.Record() + recB.add_property(name="propA", value=2) + recB.add_property(name="propB", value=12) + recB.add_property(name="propC", value="cool 17") + recB.add_property(name="propD", value=-273, unit="°C") + + diff = compare_entities(recA, recB) + diffout = describe_diff(*diff) + + assert diffout.startswith("## Difference between the first version and the second version of None") + + # The output of the describe_diff function is currently not ordered (e.g. by name of the property) + # so we cannot just compare a well-defined output string. + + assert "it does not exist in the first version:" in diffout + assert "first version: {'value': 2}" in diffout + assert "second version: {'value': 12}" in diffout + + assert "first version: {'unit': 'K'}" in diffout + assert "second version: {'unit': '°C'}" in diffout + + diffout = describe_diff(*diff, name="Entity") + assert diffout.startswith("## Difference between the first version and the second version of Entity") + + diffout = describe_diff(*diff, label_e0="recA", label_e1="recB") + assert "recA: {'value': 2}" in diffout + assert "recB: {'value': 12}" in diffout + + assert "recA: {'unit': 'K'}" in diffout + assert "recB: {'unit': '°C'}" in diffout + + assert "it does not exist in the recA:" in diffout + + assert "first" not in diffout + assert "second" not in diffout + + +def test_diff_without_names(): + """Test compare_entities in case of properties and parents with + ids and without names + (cf. https://gitlab.com/linkahead/linkahead-pylib/-/issues/119). + + """ + + r1 = db.Record(name="Test").add_parent(name="TestType") + r2 = db.Record(name="Test").add_parent(name="TestType") + r2.add_property(id=123, value="Test") + + diff1, diff2 = compare_entities(r1, r2) + assert len(diff1["properties"]) == 0 + assert len(diff2["properties"]) == 1 + assert 123 in diff2["properties"] + assert None not in diff2["properties"] + + r3 = db.Record().add_parent(id=101) + r4 = db.Record().add_parent(id=102) + diff3, diff4 = compare_entities(r3, r4) + assert len(diff3["parents"]) == 1 + assert 101 in diff3["parents"] + assert None not in diff3["parents"] + assert len(diff4["parents"]) == 1 + assert 102 in diff4["parents"] + assert None not in diff3["parents"] diff --git a/unittests/test_authentication_auth_token.py b/unittests/test_authentication_auth_token.py index 3142f1f9f54230cb19666eeb8ff5809a906f9d49..4eb17bcc3892a0d0cad0f2c86289c2e8c625d426 100644 --- a/unittests/test_authentication_auth_token.py +++ b/unittests/test_authentication_auth_token.py @@ -96,6 +96,6 @@ def test_logout_calls_delete(): auth_token="[request token]", implementation=MockUpServerConnection) - c._delegate_connection.resources.append(logout_resource) + c._delegate_connection.resources.insert(1, logout_resource) c._logout() mock.method.assert_called_once() diff --git a/unittests/test_configs/pylinkahead-timeout1.ini b/unittests/test_configs/pylinkahead-timeout1.ini new file mode 100644 index 0000000000000000000000000000000000000000..d9f894bfeba4f98ed30d96d8c29e057b5a1e643a --- /dev/null +++ b/unittests/test_configs/pylinkahead-timeout1.ini @@ -0,0 +1,4 @@ +[Connection] +url=https://localhost:10443/ +password_method = unauthenticated +timeout = None diff --git a/unittests/test_configs/pylinkahead-timeout2.ini b/unittests/test_configs/pylinkahead-timeout2.ini new file mode 100644 index 0000000000000000000000000000000000000000..b3d3796f82148459efb8e19344fe11af9e7934ec --- /dev/null +++ b/unittests/test_configs/pylinkahead-timeout2.ini @@ -0,0 +1,4 @@ +[Connection] +url=https://localhost:10443/ +password_method = unauthenticated +timeout = (1,20) diff --git a/unittests/test_configuration.py b/unittests/test_configuration.py index 95bc906c6c044c51548aa864326cc93f29a6042a..772e872c08e0a7c4aae3feffdb58244f6ad0c849 100644 --- a/unittests/test_configuration.py +++ b/unittests/test_configuration.py @@ -24,6 +24,7 @@ from os import environ, getcwd, remove from os.path import expanduser, isfile, join +from pathlib import Path import linkahead as db import pytest @@ -66,3 +67,18 @@ def test_config_ini_via_envvar(temp_ini_files): 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() + + +def test_config_timeout_option(): + expected_results = [None, (1, 20)] + # Iterate through timeout test configs + test_configs = Path(__file__).parent/'test_configs' + for test_config in test_configs.rglob('pylinkahead-timeout*.ini'): + # Test that test configs can be parsed + db.configure(str(test_config)) + dct = db.configuration.config_to_yaml(db.get_config()) + # Test that resulting dict has correct content for timeout + assert 'Connection' in dct + assert 'timeout' in dct['Connection'] + assert dct['Connection']['timeout'] in expected_results + expected_results.remove(dct['Connection']['timeout']) diff --git a/unittests/test_connection.py b/unittests/test_connection.py index a3a1eff705c64f59baec33088906bdd9a4daa14d..5d22efa46e3a6c10452085d735d1bd6f056a81fc 100644 --- a/unittests/test_connection.py +++ b/unittests/test_connection.py @@ -25,14 +25,18 @@ # pylint: disable=missing-docstring from __future__ import print_function, unicode_literals +import io import re from builtins import bytes, str # pylint: disable=redefined-builtin +import requests + from linkahead import execute_query from linkahead.configuration import _reset_config, get_config from linkahead.connection.authentication.interface import CredentialsAuthenticator from linkahead.connection.connection import (CaosDBServerConnection, _DefaultCaosDBServerConnection, + _WrappedHTTPResponse, configure_connection) from linkahead.connection.mockup import (MockUpResponse, MockUpServerConnection, _request_log_message) @@ -216,9 +220,9 @@ def test_init_connection(): def test_resources_list(): connection = test_init_connection() assert hasattr(connection, "resources") - assert len(connection.resources) == 1 - connection.resources.append(lambda **kwargs: test_init_response()) assert len(connection.resources) == 2 + connection.resources.append(lambda **kwargs: test_init_response()) + assert len(connection.resources) == 3 return connection @@ -324,3 +328,51 @@ def test_auth_token_connection(): "auth_token authenticator cannot log in " "again. You must provide a new authentication " "token.") + + +def test_buffer_read(): + """Test the buffering in _WrappedHTTPResponse.read()""" + + class MockResponse(requests.Response): + def __init__(self, content: bytes): + """A mock response + + Parameters + ---------- + content : bytes + The fake content. + """ + super().__init__() + self._content = content + bio = io.BytesIO(expected) + self.raw = bio + + expected = b"This response." + MockResponse(expected) + + ############################# + # Check for some exceptions # + ############################# + resp = _WrappedHTTPResponse(response=MockResponse(expected)) + with raises(BufferError) as rte: + resp.read(4) + resp.read() + assert "`size` parameter can not be None" in str(rte.value) + + resp = _WrappedHTTPResponse(response=MockResponse(expected)) + with raises(BufferError) as rte: + resp.read(4) + resp.read(0) + assert "`size` parameter can not be None" in str(rte.value) + + print("---") + resp = _WrappedHTTPResponse(response=MockResponse(expected)) + result = ( + resp.read(4) + + resp.read(2) + + resp.read(2) # This line failed before. + + resp.read(4) # Reading the rest in two chunks, because of current limitations in read(). + + resp.read(2) + ) + + assert result == expected diff --git a/unittests/test_container.py b/unittests/test_container.py index 95c3b683405b01d272b7935b654c2938c7ae8c52..25d5ce6c94680ec4643c897f6780148d0d76f288 100644 --- a/unittests/test_container.py +++ b/unittests/test_container.py @@ -71,7 +71,8 @@ def test_get_property_values(): ) assert len(table) == 2 house_row = table[0] - assert house_row == (house.name, 40.2, "ft", window.id, None, None, None, 20.5, 20.5, "m", owner.name) + assert house_row == (house.name, 40.2, "ft", window.id, None, None, None, 20.5, 20.5, "m", + owner.name) owner_row = table[1] assert owner_row == (owner.name, None, None, None, None, None, None, None, None, None, None) @@ -200,3 +201,15 @@ def test_container_slicing(): with pytest.raises(TypeError): cont[[0, 2, 3]] + + +def test_container_filter(): + # this is a very rudimentary test since filter_by_identity is based on + # _filter_entity_list_by_identity which is tested + # separately + cont = db.Container() + cont.extend([db.Record(name=f"TestRec{ii+1}") for ii in range(5)]) + + recs = cont.filter_by_identity(name="TestRec2") + assert len(recs) == 1 + recs[0].name == "TestRec2" diff --git a/unittests/test_entity.py b/unittests/test_entity.py index abf82f0a9b557cf9d1d2365e01fedaa4eae0c565..855e5a39d53180d32a40de46bc7bb43d0bbd58bc 100644 --- a/unittests/test_entity.py +++ b/unittests/test_entity.py @@ -22,14 +22,18 @@ # ** end header # """Tests for the Entity class.""" +import os # pylint: disable=missing-docstring import unittest -from lxml import etree -import os -from linkahead import (INTEGER, Entity, Property, Record, RecordType, +import linkahead +from linkahead import (INTEGER, Entity, Parent, Property, Record, RecordType, configure_connection) +import warnings +from linkahead.common.models import SPECIAL_ATTRIBUTES from linkahead.connection.mockup import MockUpServerConnection +from lxml import etree +from pytest import raises UNITTESTDIR = os.path.dirname(os.path.abspath(__file__)) @@ -82,7 +86,13 @@ class TestEntity(unittest.TestCase): self.assertEqual(entity.to_xml().tag, "Property") def test_instantiation(self): - self.assertRaises(Exception, Entity()) + e = Entity() + for attr in SPECIAL_ATTRIBUTES: + assert hasattr(e, attr) + + def test_instantiation_bad_argument(self): + with self.assertRaises(Exception): + Entity(rol="File") def test_parse_role(self): """During parsing, the role of an entity is set explicitely. All other @@ -97,3 +107,191 @@ class TestEntity(unittest.TestCase): # test whether the __role property of this object has explicitely been # set. self.assertEqual(getattr(entity, "_Entity__role"), "Record") + + +def test_parent_list(): + p1 = RecordType(name="A") + pl = linkahead.common.models.ParentList([p1]) + assert p1 in pl + assert pl.index(p1) == 0 + assert RecordType(name="A") not in pl + assert RecordType(id=101) not in pl + p2 = RecordType(id=101) + pl.append(p2) + assert p2 in pl + assert len(pl) == 2 + p3 = RecordType(id=103, name='B') + pl.append(p3) + assert len(pl) == 3 + + # test removal + # remove by id only, even though element in parent list has name and id + pl.remove(RecordType(id=103)) + assert len(pl) == 2 + assert p3 not in pl + assert p2 in pl + assert p1 in pl + # Same for removal by name + pl.append(p3) + assert len(pl) == 3 + pl.remove(RecordType(name='B')) + assert len(pl) == 2 + assert p3 not in pl + # And an error if no suitable element can be found + with raises(KeyError) as ve: + pl.remove(RecordType(id=105, name='B')) + assert "not found" in str(ve.value) + assert len(pl) == 2 + + # TODO also check pl1 == pl2 + + +def test_property_list(): + # TODO: Resolve parent-list TODOs, then transfer to here. + # TODO: What other considerations have to be done with properties? + p1 = Property(name="A") + pl = linkahead.common.models.PropertyList() + pl.append(p1) + assert p1 in pl + assert Property(id=101) not in pl + p2 = Property(id=101) + pl.append(p2) + assert p1 in pl + assert p2 in pl + p3 = Property(id=103, name='B') + pl.append(p3) + + +def test_filter_by_identity(): + rt1 = RecordType(id=100) + rt2 = RecordType(id=101, name="RT") + rt3 = RecordType(name="") + p1 = Property(id=100) + p2 = Property(id=100) + p3 = Property(id=101, name="RT") + p4 = Property(id=102, name="P") + p5 = Property(id=103, name="P") + p6 = Property(name="") + r1 = Record(id=100) + r2 = Record(id=100) + r3 = Record(id=101, name="RT") + r4 = Record(id=101, name="R") + r5 = Record(id=104, name="R") + r6 = Record(id=105, name="R") + test_ents = [rt1, rt2, rt3, p1, p2, p3, p4, p5, p6, r1, r2, r3, r4, r5, r6] + + # Setup + for entity in [Property(name=""), Record(name=""), RecordType(name="")]: + for coll in [entity.properties, entity.parents]: + for ent in test_ents: + assert ent not in coll + assert ent not in coll.filter_by_identity(ent) + + # Checks with each type + t, t_props, t_pars = entity, entity.properties, entity.parents + # Properties + # Basic Checks + t.add_property(p1) + tp1 = t.properties[-1] + t.add_property(p3) + tp3 = t.properties[-1] + assert len(t_props.filter_by_identity(pid=100)) == 1 + assert tp1 in t_props.filter_by_identity(pid=100) + assert len(t_props.filter_by_identity(pid="100")) == 1 + assert tp1 in t_props.filter_by_identity(pid="100") + assert len(t_props.filter_by_identity(pid=101, name="RT")) == 1 + assert tp3 in t_props.filter_by_identity(pid=101, name="RT") + for entity in [rt1, p2, r1, r2]: + assert entity not in t_props.filter_by_identity(pid=100) + assert tp1 in t_props.filter_by_identity(entity) + # Check that direct addition (not wrapped) works + t_props.append(p2) + tp2 = t_props[-1] + assert tp2 in t_props.filter_by_identity(pid=100) + assert tp2 not in t_props.filter_by_identity(pid=101, name="RT") + for entity in [rt1, r1, r2]: + assert entity not in t_props.filter_by_identity(pid=100) + assert tp2 in t_props.filter_by_identity(entity) + + # Parents + # Filtering with both name and id + t.add_parent(r3) + tr3 = t.parents[-1] + t.add_parent(r5) + tr5 = t.parents[-1] + assert tr3 in t_pars.filter_by_identity(pid=101) + assert tr5 not in t_pars.filter_by_identity(pid=101) + assert tr3 not in t_pars.filter_by_identity(name="R") + assert tr5 in t_pars.filter_by_identity(name="R") + assert tr3 in t_pars.filter_by_identity(pid=101, name="R") + assert tr5 not in t_pars.filter_by_identity(pid=101, name="R") + assert tr3 not in t_pars.filter_by_identity(pid=104, name="RT") + assert tr5 in t_pars.filter_by_identity(pid=104, name="RT") + assert tr3 not in t_pars.filter_by_identity(pid=105, name="T") + assert tr5 not in t_pars.filter_by_identity(pid=105, name="T") + # Works also without id / name and with duplicate parents + for ent in test_ents: + t.add_parent(ent) + for ent in t_pars: + assert ent in t_pars.filter_by_identity(ent) + + # Grid-Based + r7 = Record() + r7.add_property(Property()).add_property(name="A").add_property(name="B") + r7.add_property(id=27).add_property(id=27, name="A").add_property(id=27, name="B") + r7.add_property(id=43).add_property(id=43, name="A").add_property(id=43, name="B") + assert len(r7.properties.filter_by_identity(pid=27)) == 3 + assert len(r7.properties.filter_by_identity(pid=43)) == 3 + assert len(r7.properties.filter_by_identity(pid=43, conjunction=True)) == 3 + assert len(r7.properties.filter_by_identity(name="A")) == 3 + assert len(r7.properties.filter_by_identity(name="B")) == 3 + assert len(r7.properties.filter_by_identity(name="B", conjunction=True)) == 3 + assert len(r7.properties.filter_by_identity(pid=1, name="A")) == 1 + assert len(r7.properties.filter_by_identity(pid=1, name="A", conjunction=True)) == 0 + assert len(r7.properties.filter_by_identity(pid=27, name="B")) == 4 + assert len(r7.properties.filter_by_identity(pid=27, name="B", conjunction=True)) == 1 + assert len(r7.properties.filter_by_identity(pid=27, name="C")) == 3 + assert len(r7.properties.filter_by_identity(pid=27, name="C", conjunction=True)) == 0 + # Entity based filtering behaves the same + assert (r7.properties.filter_by_identity(pid=27) == + r7.properties.filter_by_identity(Property(id=27))) + assert (r7.properties.filter_by_identity(pid=43, conjunction=True) == + r7.properties.filter_by_identity(Property(id=43), conjunction=True)) + assert (r7.properties.filter_by_identity(name="A") == + r7.properties.filter_by_identity(Property(name="A"))) + assert (r7.properties.filter_by_identity(name="B") == + r7.properties.filter_by_identity(Property(name="B"))) + assert (r7.properties.filter_by_identity(name="B", conjunction=True) == + r7.properties.filter_by_identity(Property(name="B"), conjunction=True)) + assert (r7.properties.filter_by_identity(pid=1, name="A") == + r7.properties.filter_by_identity(Property(id=1, name="A"))) + assert (r7.properties.filter_by_identity(pid=1, name="A", conjunction=True) == + r7.properties.filter_by_identity(Property(id=1, name="A"), conjunction=True)) + assert (r7.properties.filter_by_identity(pid=27, name="B") == + r7.properties.filter_by_identity(Property(id=27, name="B"))) + assert (r7.properties.filter_by_identity(pid=27, name="B", conjunction=True) == + r7.properties.filter_by_identity(Property(id=27, name="B"), conjunction=True)) + assert (r7.properties.filter_by_identity(pid=27, name="C") == + r7.properties.filter_by_identity(Property(id=27, name="C"))) + assert (r7.properties.filter_by_identity(pid=27, name="C", conjunction=True) == + r7.properties.filter_by_identity(Property(id=27, name="C"), conjunction=True)) + # Name only matching and name overwrite + r8 = Record().add_property(name="A").add_property(name="B").add_property(name="B") + r8.add_property(Property(name="A"), name="B") + r8.add_property(Property(name="A", id=12), name="C") + assert len(r8.properties.filter_by_identity(name="A")) == 1 + assert len(r8.properties.filter_by_identity(name="B")) == 3 + assert len(r8.properties.filter_by_identity(name="C")) == 1 + assert len(r8.properties.filter_by_identity(pid=12)) == 1 + + with warnings.catch_warnings(record=True) as w: + # Cause all warnings to always be triggered. + warnings.simplefilter("always") + + r7.properties.filter(pid=34) + assert issubclass(w[-1].category, DeprecationWarning) + assert "This function was renamed" in str(w[-1].message) + + t.parents.filter(pid=234) + assert issubclass(w[-1].category, DeprecationWarning) + assert "This function was renamed" in str(w[-1].message) diff --git a/unittests/test_error_handling.py b/unittests/test_error_handling.py index 3f5241466e9a8f810b581cbb587e17ccf8f123ee..64f743c85e9df554e7428cf7d8477e8c823a9758 100644 --- a/unittests/test_error_handling.py +++ b/unittests/test_error_handling.py @@ -30,7 +30,7 @@ import linkahead as db from linkahead.common.models import raise_errors from linkahead.exceptions import (AuthorizationError, EntityDoesNotExistError, EntityError, - EntityHasNoDatatypeError, + EntityHasNoDatatypeError, HTTPServerError, TransactionError, UniqueNamesError, UnqualifiedParentsError, UnqualifiedPropertiesError) @@ -315,3 +315,26 @@ def test_container_with_faulty_elements(): # record raises both of them assert (isinstance(err, UnqualifiedParentsError) or isinstance(err, UnqualifiedPropertiesError)) + + +def test_incomplete_server_error_response(): + """The reason behind https://gitlab.com/linkahead/linkahead-pylib/-/issues/87.""" + # Case 1: Response is no XML at all + err = HTTPServerError("Bla") + assert str(err) == "Bla" + + # Case 2: Response is an incomplete XML, e.g. due to very unlucky timeout + err = HTTPServerError("<incomplete>XML</inc") + assert str(err) == "<incomplete>XML</inc" + + # Case 3: Response is complete XML but doesn't have response and or error information + err = HTTPServerError("<complete>XML</complete>") + assert str(err) == "<complete>XML</complete>" + + # Case 4: Response is an XML response but the error is lacking a description + err = HTTPServerError("<Response><Error>complete error</Error></Response>") + assert str(err) == "complete error" + + # Case 5: Healthy error Response + err = HTTPServerError("<Response><Error description='Error'>complete error</Error></Response>") + assert str(err) == "Error\n\ncomplete error" diff --git a/unittests/test_issues.py b/unittests/test_issues.py index 7472f710cea32c1d76f11e52fe7c3c3617804c3c..3b0117b28c1300ea1eb0919fce02e3881c2ab025 100644 --- a/unittests/test_issues.py +++ b/unittests/test_issues.py @@ -24,7 +24,9 @@ import os import lxml import linkahead as db +from datetime import date, datetime from pytest import raises +from linkahead.common.utils import xml2str def test_issue_100(): @@ -64,3 +66,65 @@ def test_issue_156(): # </ParentList> assert value is project assert parents[0].name == "RTName" + + +def test_issue_128(): + """Test assigning datetime.date(time) values to DATETIME + properties: + https://gitlab.com/linkahead/linkahead-pylib/-/issues/128. + + """ + # Test assignement correct assignment for both datatype=DATETIME + # and datatype=LIST<DATETIME>, just to be sure. + prop = db.Property(name="TestDatetime", datatype=db.DATETIME) + prop_list = db.Property(name="TestListDatetime", datatype=db.LIST(db.DATETIME)) + + today = date.today() + now = datetime.now() + + prop.value = today + assert prop.value == today + prop.value = now + assert prop.value == now + + prop_list.value = [today, today] + assert prop_list.value == [today, today] + prop_list.value = [now, now] + assert prop_list.value == [now, now] + + +def test_issue_73(): + """ + Test to_xml infinite recursion handling with cross- and self-references. + https://gitlab.com/linkahead/linkahead-pylib/-/issues/73 + """ + # Cross-reference in the property values + rt = db.RecordType(name="RT") + recA = db.Record().add_parent(rt) + recB = db.Record().add_parent(rt) + recA.add_property(name="RT", value=recB) + recB.add_property(name="RT", value=recA) + xml_str = xml2str(recB.to_xml()) + assert "<Parent name=\"RT" in xml_str + assert "<Property name=\"RT" in xml_str + assert "Recursive reference" in xml_str + assert len(xml_str) < 500 + + # Cross-reference in the properties themselves + prop1 = db.Property(name="Prop1") + prop2 = db.Property(name="Prop2") + prop1.add_property(prop2) + prop2.add_property(prop1) + xml_str = xml2str(prop2.to_xml()) + assert "<Property name=\"Prop1" in xml_str + assert "<Property name=\"Prop2" in xml_str + assert "Recursive reference" in xml_str + assert len(xml_str) < 500 + + # Self-reference in the datatype + prop = db.Property() + prop.datatype = prop + xml_str = xml2str(prop.to_xml()) + assert "datatype=" in xml_str + assert "Recursive reference" in xml_str + assert len(xml_str) < 500