diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b45dde5de1e4cafdbc02a15c4b775b763d5447bd..9f8c131968c050fb18001b5dc7c5468d0ed26dae 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -22,7 +22,7 @@ variables: DEPLOY_REF: dev - CI_REGISTRY_IMAGE: $CI_REGISTRY/caosdb/caosdb-pylib/testenv:latest + CI_REGISTRY_IMAGE: $CI_REGISTRY/caosdb/src/caosdb-pylib/testenv:latest # When using dind, it's wise to use the overlayfs driver for # improved performance. @@ -65,7 +65,7 @@ trigger_build: stage: deploy script: - /usr/bin/curl -X POST - -F token=$DEPLOY_TRIGGER_TOKEN + -F token=$CI_JOB_TOKEN -F "variables[F_BRANCH]=$CI_COMMIT_REF_NAME" -F "variables[PYLIB]=$CI_COMMIT_REF_NAME" -F "variables[TriggerdBy]=PYLIB" @@ -92,15 +92,13 @@ build-testenv: - docker push $CI_REGISTRY_IMAGE # Build the sphinx documentation and make it ready for deployment by Gitlab Pages -# documentation: -# stage: deploy - # Special job for serving a static website. See https://docs.gitlab.com/ee/ci/yaml/README.html#pages -pages: +pages_prepare: &pages_prepare + tags: [ cached-dind ] stage: deploy only: - # TODO this should be for master only, once releases are more regularly - - dev + refs: + - /^release-.*$/i script: - echo "Deploying" - make doc @@ -108,3 +106,8 @@ pages: artifacts: paths: - public +pages: + <<: *pages_prepare + only: + refs: + - main diff --git a/CHANGELOG.md b/CHANGELOG.md index fd2bbfd27503488cbb7b2e7aef30ff959f3f323d..38b470db9d36675ffcef0b7e1434a08c3be7f407 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added ### -* Versioning support (experimental). The version db.Version class can - represents particular entity versions and also the complete history of an - entity. -* Automated documentation builds: `make doc` - ### Changed ### ### Deprecated ### @@ -22,10 +17,91 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed ### -* deepcopy of `_Messages` objects +* #53 Documentation of inheritance + +### Security ### + +## [0.5.2] - 2021-06-03 ## + +### Added ### + +* Entity State support (experimental, no StateModel support yet). See the + `caosdb.State` class for more information. +* `etag` property for the `caosdb.Query` class. The etag allows to debug the + caching and to decide whether the server has changed between queries. +* function `_read_config_files` to read `pycaosdb.ini` files from different paths. + +### Changed ### + +* Updated error-handling tutorial in documentation to reflect the new + error classes + +### Deprecated ### + +### Removed ### + +### Fixed ### +* #45 - test_config_ini_via_envvar ### Security ### +## [0.5.1] - 2021-02-12 ## + +### Fixed ### + +* #43 - Error with `execute_query` when server doesn't support query caching. + +## [0.5.0] - 2021-02-11 ## + +### Added ### + +* New exceptions `HTTPForbiddenException` and + `HTTPResourceNotFoundException` for HTTP 403 and 404 errors, + respectively. +* `BadQueryError`, `EmptyUniqueQueryError`, and `QueryNotUniqueError` + for bad (unique) queries. +* Added `cache` paramter to `execute_query` and `Query.execute` which indicates + whether server may use the cache for the query execution. +* Added `cached` property to the `Query` class which indicates whether the + server used the cache for the execution of the last query. +* Documentation moved from wiki to this repository and enhanced. + +### Changed ### + +* Renaming of `URITooLongException` to `HTTPURITooLongError`. +* Raising of entity exceptions and transaction errors. Whenever any + transaction fails, a `TransactionError` is raised. If one ore more + entities caused that failure, corresponding entity errors are + attached as direct and indirect children of the + `TransactionError`. They can be accessed via the `get_errors` + (direct children) and `get_all_errors` (direct and indirect + children) methods; the causing entities are accessed by + `get_entities` and `get_all_entities`. The `has_error` method can be + used to check whether a `TransactionError` was caused by a specific + `EntityError`. +* Unique queries will now result in `EmptyUniqueQueryError` or + `QueryNotUniqueError` if no or more than one possible candidate is + found, respectively. + +### Removed ### + +* Dynamic exception type `EntityMultiError`. +* `get_something` functions from all error object in `exceptions.py` +* `AmbiguityException` + +## [0.4.1] - 2021-02-10 ## + +### Added ### + +* Versioning support (experimental). The version db.Version class can + represents particular entity versions and also the complete history of an + entity. +* Automated documentation builds: `make doc` + +### Fixed ### + +* deepcopy of `_Messages` objects + ## [0.4.0] - 2020-07-17## ### Added ### @@ -79,7 +155,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * import bugs in apiutils -## [0.2.4] -- 2020-04-23 +## [0.2.4] - 2020-04-23 ### Added diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md new file mode 100644 index 0000000000000000000000000000000000000000..04e783a7fa31b1d5c3a600a1009c8f040db1620d --- /dev/null +++ b/DEPENDENCIES.md @@ -0,0 +1,6 @@ +* caosdb-server == 0.3 +* Python >= 3.5 +* pip >= 20.0.2 + + +Any other dependencies are being installed via pip diff --git a/README.md b/README.md index ef26a604905d5140ae9a775065002af35ffe2121..04b34cbc07c98e73740b13200ed83fe067af99d2 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,53 @@ -# Welcome + +# README + +## Welcome This is the **CaosDB Python Client Library** repository and a part of the CaosDB project. -# Setup +## Setup Please read the [README_SETUP.md](README_SETUP.md) for instructions on how to setup this code. -# Further Reading +## Further Reading + +Please refer to the [official documentation](https://docs.indiscale.com/caosdb-pylib/) for more information. + +## Contributing + +Thank you very much to all contributers—[past, present](https://gitlab.com/caosdb/caosdb/-/blob/dev/HUMANS.md), and prospective ones. -Please refer to the [official gitlab repository of the CaosDB -project](https://gitlab.com/caosdb/caosdb) for more information. +### Code of Conduct -# License +By participating, you are expected to uphold our [Code of Conduct](https://gitlab.com/caosdb/caosdb/-/blob/dev/CODE_OF_CONDUCT.md). -Copyright (C) 2018 Research Group Biomedical Physics, Max Planck Institute for -Dynamics and Self-Organization Göttingen. -Copyright (C) 2020 Indiscale GmbH <info@indiscale.com> +### How to Contribute + +* You found a bug, have a question, or want to request a feature? Please +[create an issue](https://gitlab.com/caosdb/caosdb-pylib/-/issues). +* You want to contribute code? + * **Forking:** Please fork the repository and create a merge request in GitLab and choose this repository as + target. Make sure to select "Allow commits from members who can merge the target branch" under + Contribution when creating the merge request. This allows our team to work with you on your + request. + * **Code style:** This project adhers to the PEP8 recommendations, you can test your code style + using the `autopep8` tool (`autopep8 -i -r ./`). Please write your doc strings following the + [NumpyDoc](https://numpydoc.readthedocs.io/en/latest/format.html) conventions. +* If you have a suggestion for the [documentation](https://docs.indiscale.com/caosdb-pylib/), +the preferred way is also a merge request as describe above (the documentation resides in `src/doc`). +However, you can also create an issue for it. +* You can also contact us at **info (AT) caosdb.de** and join the + CaosDB community on + [#caosdb:matrix.org](https://matrix.to/#/!unwwlTfOznjEnMMXxf:matrix.org). + +## License + +* Copyright (C) 2018 Research Group Biomedical Physics, Max Planck Institute + for Dynamics and Self-Organization Göttingen. +* Copyright (C) 2020-2021 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 481182d02d4c25acc5c5ed9607a282b22e861448..9da548395073643c16539cef180c4d6412dd8d46 100644 --- a/README_SETUP.md +++ b/README_SETUP.md @@ -30,6 +30,23 @@ packages you will ever need out of the box. If you prefer, you may also install After installation, open an Anaconda prompt from the Windows menu and continue in the [Generic installation](#generic-installation) section. +#### iOS #### + +If there is no Python 3 installed yet, there are two main ways to obtain it: Either get the binary +package from [python.org](https://www.python.org/downloads/) or, for advanced users, install via [Homebrew](https://brew.sh/). After installation +from python.org, it is recommended to also update the TLS certificates for Python (this requires +administrator rights for your user): + +```sh +# Replace this with your Python version number: +cd /Applications/Python\ 3.9/ + +# This needs administrator rights: +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): @@ -58,7 +75,7 @@ current working directory will be read additionally, if it exists. Here, we will look at the most common configuration options. For a full and comprehensive description please check out -[pycaosdb.ini file](https://gitlab.com/caosdb/caosdb-pylib/-/blob/master/examples/pycaosdb.ini) +[pycaosdb.ini file](https://gitlab.com/caosdb/caosdb-pylib/-/blob/main/examples/pycaosdb.ini) You can download this file and use it as a starting point. @@ -67,7 +84,7 @@ Typically, you need to change at least the `url` and `username` fields as requir you do not know what to put there, but for the demo instances https://demo.indiscale.com, `username=admin` and `password=caosdb` should work). -### Authentication ## +### Authentication ### The default configuration (that your are asked for your password when ever a connection is created can be changed by setting `password_method`: @@ -92,7 +109,7 @@ The following illustrates the recommended options: #password_method=keyring ``` -### SSL Certificate ## +### SSL Certificate ### In some cases (especially if you are testing CaosDB) you might need to supply an SSL certificate to allow SSL encryption. @@ -101,9 +118,9 @@ an SSL certificate to allow SSL encryption. cacert=/path/to/caosdb.ca.pem ``` -### Further Settings ## +### Further Settings ### As mentioned above, a complete list of options can be found in the -[pycaosdb.ini file](https://gitlab.com/caosdb/caosdb-pylib/-/blob/master/examples/pycaosdb.ini) in +[pycaosdb.ini file](https://gitlab.com/caosdb/caosdb-pylib/-/blob/main/examples/pycaosdb.ini) in the examples folder of the source code. ## Try it out ## @@ -121,21 +138,20 @@ Out[2]: Connection to CaosDB with 501 Records. Note: This setup will ask you for your password whenever a new connection is created. If you do not like this, check out the "Authentication" section in the [configuration documentation](configuration.md). -Now would be a good time to continue with the [tutorials](tutorials.html). +Now would be a good time to continue with the [tutorials](tutorials/index). ## Run Unit Tests tox -## Code Formatting - -autopep8 -i -r ./ - -## Documentation # +## Documentation ## Build documentation in `build/` with `make doc`. -### Requirements ## +### Requirements ### - `sphinx` - `sphinx-autoapi` - `recommonmark` + +### Troubleshooting ### +If the client is to be executed directly from the `/src` folder, an initial `.\setup.py install --user` must be called. diff --git a/RELEASE_GUIDELINES.md b/RELEASE_GUIDELINES.md index 02be5c1ad19f6a3a405fb08d62e23dab350ad445..e015b598117abdcd575cf17e2f095fec459a4c4c 100644 --- a/RELEASE_GUIDELINES.md +++ b/RELEASE_GUIDELINES.md @@ -16,21 +16,27 @@ guidelines of the CaosDB Project 1. Create a release branch from the dev branch. This prevents further changes to the code base and a never ending release process. Naming: `release-<VERSION>` -2. Check all general prerequisites. +2. Update CHANGELOG.md -3. Prepare [setup.py](./setup.py): Update the `MAJOR`, `MINOR`, `MICRO`, `PRE` +3. Check all general prerequisites. + +4. Prepare [setup.py](./setup.py): Check the `MAJOR`, `MINOR`, `MICRO`, `PRE` variables and set `ISRELEASED` to `True`. Use the possibility to issue pre-release versions for testing. -4. Merge the release branch into the master branch. +5. Merge the release branch into the main branch. -5. Tag the latest commit of the master branch with `v<VERSION>`. +6. Tag the latest commit of the main branch with `v<VERSION>`. -6. Delete the release branch. +7. Delete the release branch. -7. Remove possibly existing `./dist` directory with old release. +8. Remove possibly existing `./dist` directory with old release. -8. Publish the release by executing `./release.sh` with uploads the caosdb +9. Publish the release by executing `./release.sh` with uploads the caosdb module to the Python Package Index [pypi.org](https://pypi.org). -9. Merge the master branch back into the dev branch. +10. Merge the main branch back into the dev branch. + +11. After the merge of main to dev, start a new development version by + setting `ISRELEASED` to `False` and by increasing at least the `MIRCO` + version in [setup.py](./setup.py) diff --git a/examples/set_permissions.py b/examples/set_permissions.py index 8b2b59f10ac033110af846eef0ed90356a09553d..8162b11bfefb41b1bcdbc74b8e314f99a61d1a4e 100755 --- a/examples/set_permissions.py +++ b/examples/set_permissions.py @@ -25,15 +25,13 @@ As a result, only a specific user or group may access it. -This script assumes that data similar to the demo server of IndiScale (at -demo.indiscale.com) exists on the server specified in the pycaosdb.ini -configuration. +This script assumes that the user specified in the pycaosdb.ini +configuration can create new entities. """ import caosdb as db from caosdb import administration as admin -import lxml def assert_user_and_role(): @@ -50,27 +48,27 @@ out : tuple """ try: human_user = admin._retrieve_user("jane") - _activate_user("jane") - except db.EntityDoesNotExistError: + admin._update_user(name="jane", status="ACTIVE") + except db.HTTPResourceNotFoundError: human_user = admin._insert_user( "jane", password="Human_Rememberable_Password_1234", status="ACTIVE") try: alien_user = admin._retrieve_user("xaxys") - _activate_user("xaxys") - except db.EntityDoesNotExistError: + admin._update_user(name="xaxys", status="ACTIVE") + except db.HTTPResourceNotFoundError: alien_user = admin._insert_user("xaxys", password="4321_Syxax", status="ACTIVE") # At the moment, the return value is only "ok" for successful insertions. try: human_role = admin._retrieve_role("human") - except db.EntityDoesNotExistError: + except db.HTTPResourceNotFoundError: human_role = admin._insert_role("human", "An Earthling.") try: alien_role = admin._retrieve_role("alien") - except db.EntityDoesNotExistError: + except db.HTTPResourceNotFoundError: alien_role = admin._insert_role("alien", "An Extra-terrestrial.") admin._set_roles("jane", ["human"]) @@ -80,24 +78,6 @@ out : tuple ("xaxys", list(admin._get_roles("xaxys")))) -def _activate_user(user): - """Set the user state to "ACTIVE" if necessary. - -Parameters ----------- -user : str - The user to activate. - -Returns -------- -None - - """ - user_xml = lxml.etree.fromstring(admin._retrieve_user(user)) - if user_xml.xpath("User")[0].attrib["status"] != "ACTIVE": - admin._update_user(user, status="ACTIVE") - - def get_entities(count=1): """Retrieve one or more entities. @@ -111,9 +91,11 @@ Returns out : Container A container of retrieved entities, the length is given by the parameter count. """ - cont = db.execute_query("FIND RECORD Guitar", flags={"P": "0L{n}".format(n=count)}) + cont = db.execute_query("FIND RECORD 'Human Food'", flags={ + "P": "0L{n}".format(n=count)}) if len(cont) != count: - raise db.CaosDBException(msg="Incorrect number of entitities returned.") + raise db.CaosDBException( + msg="Incorrect number of entitities returned.") return cont @@ -138,7 +120,8 @@ general : bool, optional # Set general permissions if general: - grant = admin.PermissionRule(action="grant", permission="RETRIEVE:OWNER") + grant = admin.PermissionRule( + action="grant", permission="RETRIEVE:OWNER") deny = admin.PermissionRule(action="deny", permission="RETRIEVE:FILE") admin._set_permissions(role=role_grant, permission_rules=[grant]) @@ -189,9 +172,12 @@ None for ent in cont: ent.retrieve() print("Successfully retrieved all entities.") - except db.AuthorizationException: - print(ent) - print("Could not retrieve this entity although it should have been possible!") + except db.TransactionError as te: + if te.has_error(db.AuthorizationError): + print(ent) + print("Could not retrieve this entity although it should have been possible!") + else: + raise te # Switch to user without permissions db.configure_connection(username=denied_user[0], password=denied_user[1], @@ -206,23 +192,45 @@ None denied_all = False print(ent) print("Could retrieve this entity although it should not have been possible!") - except db.AuthorizationException: - pass + except db.TransactionError as te: + # Only do something if an error wasn't caused by an + # AuthorizationError + if not te.has_error(db.AuthorizationError): + raise te if denied_all: print("Retrieval of all entities was successfully denied.") +def create_test_entities(): + """Create some test entities. + After calling this function, there will be a RecordType "Human Food" with the corresponding Records + "Bread", "Tomatoes", and "Twinkies" inserted in the database. + """ + rt = db.RecordType( + name="Human Food", description="Food that can be eaten only by humans").insert() + food = ("Bread", "Tomatoes", "Twinkies") + + cont = db.Container() + for i in range(len(food)): + rec = db.Record(food[i]) + rec.add_parent(name="Human Food") + cont.append(rec) + + cont.insert() + + def main(): """The main function of this script.""" - db.connection.connection.get_connection()._login() - + """Create some test entities""" + create_test_entities() + """Create new users""" human, alien = assert_user_and_role() - - # public, private, undefined entities + """Load the newly created entities.""" entities = get_entities(count=3) - + """Set permission for the entities (only humans are allowed to eat human food)""" set_permission(human[1][0], alien[1][0], entities) + """Test the permissions""" test_permission((human[0], "Human_Rememberable_Password_1234"), (alien[0], "4321_Syxax"), entities) diff --git a/release.sh b/release.sh index a5f3654291743e5ac22fd3b938719e294b3a1e90..1af097f014de6cd9eb3d3e8ba5da34aea0fe1671 100755 --- a/release.sh +++ b/release.sh @@ -1,4 +1,4 @@ #!/bin/bash -rm -r dist/ build/ .eggs/ +rm -rf dist/ build/ .eggs/ python setup.py sdist bdist_wheel python -m twine upload -s dist/* diff --git a/setup.py b/setup.py index 4ab2d7669cc4f6d3f4873c0265da6c8cf06932ba..e1d39458ea8d1b0b17ea12a82ebd7133b27b045a 100755 --- a/setup.py +++ b/setup.py @@ -46,8 +46,8 @@ from setuptools import find_packages, setup ######################################################################## MAJOR = 0 -MINOR = 4 -MICRO = 1 +MINOR = 5 +MICRO = 3 PRE = "" # e.g. rc0, alpha.1, 0.beta-23 ISRELEASED = False diff --git a/src/caosdb/__init__.py b/src/caosdb/__init__.py index b83442f1bdf0f2b111a66d63e4549f68b6bedb69..7e06885fe495c1e8c4ccc99b7d0c0f8ff8c34b5b 100644 --- a/src/caosdb/__init__.py +++ b/src/caosdb/__init__.py @@ -37,7 +37,8 @@ from os.path import expanduser, join import caosdb.apiutils from caosdb.common import administration from caosdb.common.datatype import (BOOLEAN, DATETIME, DOUBLE, FILE, INTEGER, - REFERENCE, TEXT, LIST) + LIST, REFERENCE, TEXT) +from caosdb.common.state import State, Transition # Import of the basic API classes: from caosdb.common.models import (ACL, ALL, FIX, NONE, OBLIGATORY, RECOMMENDED, SUGGESTED, Container, DropOffBox, Entity, @@ -45,15 +46,13 @@ from caosdb.common.models import (ACL, ALL, FIX, NONE, OBLIGATORY, RECOMMENDED, Query, QueryTemplate, Record, RecordType, delete, execute_query, get_global_acl, get_known_permissions, raise_errors) -from caosdb.configuration import configure, get_config +from caosdb.configuration import _read_config_files, configure, get_config from caosdb.connection.connection import configure_connection, get_connection from caosdb.exceptions import * -from caosdb.version import version as __version__ +try: + from caosdb.version import version as __version__ +except ModuleNotFoundError: + version = "uninstalled" + __version__ = version -# read configuration these files - -if "PYCAOSDBINI" in environ: - configure(expanduser(environ["PYCAOSDBINI"])) -else: - configure(expanduser('~/.pycaosdb.ini')) -configure(join(getcwd(), "pycaosdb.ini")) +_read_config_files() diff --git a/src/caosdb/apiutils.py b/src/caosdb/apiutils.py index bd279fcfe5c394b9b5cff787169cff5b9f2d3031..73074efc3057e0548c5abfd56ef3cf1ac9e9bf47 100644 --- a/src/caosdb/apiutils.py +++ b/src/caosdb/apiutils.py @@ -34,10 +34,10 @@ from collections.abc import Iterable from subprocess import call from caosdb.common.datatype import (BOOLEAN, DATETIME, DOUBLE, FILE, INTEGER, - REFERENCE, TEXT) + REFERENCE, TEXT, is_reference) from caosdb.common.models import (Container, Entity, File, Property, Query, Record, RecordType, get_config, - execute_query, is_reference) + execute_query) def new_record(record_type, name=None, description=None, @@ -244,6 +244,8 @@ class CaosDBPythonEntity(object): return (val, False) elif pr[0:4] == "LIST": return self._type_converted_list(val, pr) + elif isinstance(val, Entity): + return (convert_to_python_object(val), False) else: return (int(val), True) diff --git a/src/caosdb/common/administration.py b/src/caosdb/common/administration.py index 7997088a102e86cbf2daeb42a996a1b6ae57f446..e7ba94182d7a4d8b60c6400cd1d804f62f7bf03c 100644 --- a/src/caosdb/common/administration.py +++ b/src/caosdb/common/administration.py @@ -30,8 +30,12 @@ from lxml import etree from caosdb.common.utils import xml2str from caosdb.connection.connection import get_connection -from caosdb.exceptions import (AuthorizationException, ClientErrorException, - EntityDoesNotExistError) +from caosdb.exceptions import (HTTPClientError, + HTTPForbiddenError, + HTTPResourceNotFoundError, + EntityDoesNotExistError, + ServerConfigurationException, + ) def set_server_property(key, value): @@ -52,9 +56,11 @@ def set_server_property(key, value): None """ con = get_connection() - - con._form_data_request(method="POST", path="_server_properties", - params={key: value}).read() + try: + con._form_data_request(method="POST", path="_server_properties", + params={key: value}).read() + except EntityDoesNotExistError: + raise ServerConfigurationException("Debug mode in server is probably disabled.") from None def get_server_properties(): @@ -68,7 +74,11 @@ def get_server_properties(): The server properties. """ con = get_connection() - body = con._http_request(method="GET", path="_server_properties").response + try: + body = con._http_request(method="GET", path="_server_properties").response + except EntityDoesNotExistError: + raise ServerConfigurationException("Debug mode in server is probably disabled.") from None + xml = etree.parse(body) props = dict() @@ -106,10 +116,10 @@ def _retrieve_user(name, realm=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() - except AuthorizationException as e: + except HTTPForbiddenError as e: e.msg = "You are not permitted to retrieve this user." raise - except EntityDoesNotExistError as e: + except HTTPResourceNotFoundError as e: e.msg = "User does not exist." raise @@ -118,10 +128,10 @@ def _delete_user(name, **kwargs): con = get_connection() try: return con._http_request(method="DELETE", path="User/" + name, **kwargs).read() - except AuthorizationException as e: + except HTTPForbiddenError as e: e.msg = "You are not permitted to delete this user." raise - except EntityDoesNotExistError as e: + except HTTPResourceNotFoundError as e: e.msg = "User does not exist." raise @@ -144,13 +154,13 @@ def _update_user(name, realm=None, password=None, status=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() - except EntityDoesNotExistError as e: + except HTTPResourceNotFoundError as e: e.msg = "User does not exist." raise - except AuthorizationException as e: + except HTTPForbiddenError as e: e.msg = "You are not permitted to update this user." raise - except ClientErrorException as e: + except HTTPClientError as e: if e.status == 409: e.msg = "Entity does not exist." raise @@ -173,10 +183,10 @@ def _insert_user(name, password=None, status=None, email=None, entity=None, **kw params["entity"] = entity try: return con.post_form_data(entity_uri_segment="User", params=params, **kwargs).read() - except AuthorizationException as e: + except HTTPForbiddenError as e: e.msg = "You are not permitted to insert a new user." raise e - except ClientErrorException as e: + except HTTPClientError as e: if e.status == 409: e.msg = "User name is already in use." @@ -189,10 +199,10 @@ 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() - except AuthorizationException as e: + except HTTPForbiddenError as e: e.msg = "You are not permitted to insert a new role." raise - except ClientErrorException as e: + except HTTPClientError as e: if e.status == 409: e.msg = "Role name is already in use. Choose a different name." raise @@ -202,10 +212,10 @@ 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() - except AuthorizationException as e: + except HTTPForbiddenError as e: e.msg = "You are not permitted to update this role." raise - except EntityDoesNotExistError as e: + except HTTPResourceNotFoundError as e: e.msg = "Role does not exist." raise @@ -214,10 +224,10 @@ def _retrieve_role(name, **kwargs): con = get_connection() try: return con._http_request(method="GET", path="Role/" + name, **kwargs).read() - except AuthorizationException as e: + except HTTPForbiddenError as e: e.msg = "You are not permitted to retrieve this role." raise - except EntityDoesNotExistError as e: + except HTTPResourceNotFoundError as e: e.msg = "Role does not exist." raise @@ -226,10 +236,10 @@ def _delete_role(name, **kwargs): con = get_connection() try: return con._http_request(method="DELETE", path="Role/" + name, **kwargs).read() - except AuthorizationException as e: + except HTTPForbiddenError as e: e.msg = "You are not permitted to delete this role." raise - except EntityDoesNotExistError as e: + except HTTPResourceNotFoundError as e: e.msg = "Role does not exist." raise @@ -243,14 +253,15 @@ 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() - except AuthorizationException as e: + 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 - except EntityDoesNotExistError as e: + except HTTPResourceNotFoundError as e: e.msg = "User does not exist." raise - except ClientErrorException as e: + except HTTPClientError as e: if e.status == 409: e.msg = "Role does not exist." raise @@ -266,11 +277,12 @@ def _set_roles(username, roles, realm=None, **kwargs): def _get_roles(username, realm=None, **kwargs): con = get_connection() try: - body = con._http_request(method="GET", path="UserRoles/" + (realm + "/" + username if realm is not None else username), **kwargs).read() - except AuthorizationException as e: + body = con._http_request(method="GET", path="UserRoles/" + ( + realm + "/" + username if realm is not None else username), **kwargs).read() + except HTTPForbiddenError as e: e.msg = "You are not permitted to retrieve this user's roles." raise - except EntityDoesNotExistError as e: + except HTTPResourceNotFoundError as e: e.msg = "User does not exist." raise ret = set() @@ -310,10 +322,10 @@ Returns con = get_connection() try: return con._http_request(method="PUT", path="PermissionRules/" + role, body=body, **kwargs).read() - except AuthorizationException as e: + except HTTPForbiddenError as e: e.msg = "You are not permitted to set this role's permissions." raise - except EntityDoesNotExistError as e: + except HTTPResourceNotFoundError as e: e.msg = "Role does not exist." raise @@ -322,10 +334,10 @@ def _get_permissions(role, **kwargs): con = get_connection() try: return PermissionRule._parse_body(con._http_request(method="GET", path="PermissionRules/" + role, **kwargs).read()) - except AuthorizationException as e: + except HTTPForbiddenError as e: e.msg = "You are not permitted to retrieve this role's permissions." raise - except EntityDoesNotExistError as e: + except HTTPResourceNotFoundError as e: e.msg = "Role does not exist." raise diff --git a/src/caosdb/common/datatype.py b/src/caosdb/common/datatype.py index 246485c3957462fc98ac83f9d904413ad7518302..eb8c1e4e0088f1924940a104ec3916b9d5d40f99 100644 --- a/src/caosdb/common/datatype.py +++ b/src/caosdb/common/datatype.py @@ -25,7 +25,7 @@ import re -from ..exceptions import AmbiguityException, EntityDoesNotExistError +from ..exceptions import EmptyUniqueQueryError, QueryNotUniqueError DOUBLE = "DOUBLE" REFERENCE = "REFERENCE" @@ -91,9 +91,9 @@ def get_id_of_datatype(datatype): Raises ------ - AmbiguityException + QueryNotUniqueError If there are more than one entities with the same name as the datatype. - EntityDoesNotExistError + EmptyUniqueQueryError If there is no entity with the name of the datatype. """ from caosdb import execute_query @@ -107,11 +107,11 @@ def get_id_of_datatype(datatype): res = [el for el in res if el.name.lower() == datatype.lower()] if len(res) > 1: - raise AmbiguityException( + raise QueryNotUniqueError( "Name {} did not lead to unique result; Missing " "implementation".format(datatype)) elif len(res) != 1: - raise EntityDoesNotExistError( + raise EmptyUniqueQueryError( "No RecordType named {}".format(datatype)) return res[0].id diff --git a/src/caosdb/common/models.py b/src/caosdb/common/models.py index a5adf6af68b72663c0fe8cd6ab58c39cdcea5d27..6ec49df2722170805fb6230753f36503870a8821 100644 --- a/src/caosdb/common/models.py +++ b/src/caosdb/common/models.py @@ -5,8 +5,9 @@ # # Copyright (C) 2018 Research Group Biomedical Physics, # Max-Planck-Institute for Dynamics and Self-Organization Göttingen +# Copyright (C) 2020 Indiscale GmbH <info@indiscale.com> +# Copyright (C) 2020 Florian Spreckelsen <f.spreckelsen@indiscale.com> # Copyright (C) 2020 Timm Fitschen <t.fitschen@indiscale.com> -# Copyright (C) 2020 IndiScale GmbH <info@indiscale.com> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -29,7 +30,6 @@ from __future__ import print_function, unicode_literals import re import sys -import traceback from builtins import str from functools import cmp_to_key from hashlib import sha512 @@ -40,24 +40,28 @@ from sys import hexversion from tempfile import NamedTemporaryFile from warnings import warn -from caosdb.common.datatype import (BOOLEAN, DATETIME, DOUBLE, FILE, INTEGER, - LIST, REFERENCE, TEXT, is_reference) +from caosdb.common.datatype import (BOOLEAN, DATETIME, DOUBLE, INTEGER, TEXT) from caosdb.common.versioning import Version +from caosdb.common.state import State from caosdb.common.utils import uuid, xml2str from caosdb.configuration import get_config from caosdb.connection.connection import get_connection from caosdb.connection.encode import MultipartParam, multipart_encode -from caosdb.exceptions import (AmbiguityException, AuthorizationException, - CaosDBException, ConnectionException, - ConsistencyError, EntityDoesNotExistError, - EntityError, EntityHasNoDatatypeError, - TransactionError, UniqueNamesError, +from caosdb.exceptions import (AmbiguousEntityError, + AuthorizationError, + CaosDBException, CaosDBConnectionError, + ConsistencyError, + EmptyUniqueQueryError, + EntityDoesNotExistError, EntityError, + EntityHasNoDatatypeError, + MismatchingEntitiesError, + QueryNotUniqueError, TransactionError, + UniqueNamesError, UnqualifiedParentsError, - UnqualifiedPropertiesError, URITooLongException) + UnqualifiedPropertiesError, + HTTPURITooLongError) from lxml import etree -from .datatype import is_reference - _ENTITY_URI_SEGMENT = "Entity" # importances/inheritance @@ -109,6 +113,7 @@ class Entity(object): self.name = name self.description = description self.id = id + self.state = None @property def version(self): @@ -426,14 +431,39 @@ class Entity(object): def add_parent(self, parent=None, **kwargs): # @ReservedAssignment """Add a parent to this entity. - The first parameter is meant to identify the parent entity. So the method expects an instance of - Entity, an integer or a string here. Even though, by means of the **kwargs parameter you may pass - more parameters to this method. Accepted keywords are: id, name, inheritance. Any other keyword is - ignored right now but this may change in the future. + Parameters + ---------- + parent : Entity or int or str or None + The parent entity, either specified by the Entity object + itself, or its id or its name. Default is None. + **kwargs : dict, optional + Additional keyword arguments for specifying the parent by + name or id, and for specifying the mode of inheritance. + + id : int + Integer id of the parent entity. Ignored if `parent` + is not None. + name : str + Name of the parent entity. Ignored if `parent is not + none`. + inheritance : str + One of ``obligatory``, ``recommended``, ``suggested``, or ``fix``. Specifies the + minimum importance which parent properties need to have to be inherited by this + entity. If no `inheritance` is given, no properties will be inherited by the child. + This parameter is case-insensitive. + + Note that the behaviour is currently not yet specified when assigning parents to + Records, it only works for inheritance of RecordTypes (and Properties). + + For more information, it is recommended to look into the + :ref:`data insertion tutorial<tutorial-inheritance-properties>`. + + Raises + ------ + UserWarning + If neither a `parent` parameter, nor the `id`, nor `name` + parameter is passed to this method. - @param parent: An entity, an id or a name. - @param **kwargs: Accepted keywords: id, name, inheritance. - @raise UserWarning: If neither a 'parent' parameter, nor the 'id', nor 'name' parameter is passed to this method. """ name = (kwargs['name'] if 'name' in kwargs else None) pid = (kwargs['id'] if 'id' in kwargs else None) @@ -637,7 +667,6 @@ class Entity(object): if not isinstance(selector, (tuple, list)): selector = [selector] - val = None ref = self # there are some special selectors which can be applied to the @@ -757,15 +786,14 @@ class Entity(object): return ret - def get_errors_deep(self, roots=[]): + def get_errors_deep(self, roots=None): """Get all error messages of this entity and all sub-entities / - parents. - - / properties. + parents / properties. @return A list of tuples. Tuple index 0 contains the error message and tuple index 1 contains the tree. """ + roots = [] if roots is None else roots result_list = list() ret_self = self.get_errors() result_list.extend([ @@ -806,8 +834,7 @@ class Entity(object): xml = etree.Element("Entity") assert isinstance(xml, etree._Element) - ''' unwrap wrapped entity ''' - + # unwrap wrapped entity if self._wrapped_entity is not None: xml = self._wrapped_entity.to_xml(xml, add_properties) @@ -854,6 +881,8 @@ class Entity(object): xml.append(v_elem) elif self.value == "": xml.append(etree.Element("EmptyString")) + elif str(self.value) == "nan": + xml.text = "NaN" else: xml.text = str(self.value) @@ -907,6 +936,9 @@ class Entity(object): if self.acl is not None: xml.append(self.acl.to_xml()) + if self.state is not None: + xml.append(self.state.to_xml()) + return xml @staticmethod @@ -951,6 +983,8 @@ class Entity(object): entity.add_message(child) elif isinstance(child, Version): entity.version = child + elif isinstance(child, State): + entity.state = child elif child is None or hasattr(child, "encode"): vals.append(child) elif isinstance(child, Entity): @@ -963,7 +997,7 @@ class Entity(object): # add VALUE value = None - if len(vals): + if vals: # The value[s] have been inside a <Value> tag. value = vals elif elem.text is not None and elem.text.strip() != "": @@ -988,11 +1022,20 @@ class Entity(object): if self.id is None: c = Container().retrieve(query=self.name, sync=False) - if len(c == 1): + if len(c) == 1: e = c[0] + elif len(c) == 0: + ee = EntityDoesNotExistError( + "The entity to be updated does not exist on the server.", + entity=self + ) + raise TransactionError(ee) else: - raise AmbiguityException( - "Could not determine the desired Entity which is to be updated by its name.") + ae = AmbiguousEntityError( + "Could not determine the desired Entity which is to be updated by its name.", + entity=self + ) + raise TransactionError(ae) else: e = Container().retrieve(query=self.id, sync=False)[0] e.acl = ACL(self.acl.to_xml()) @@ -1031,13 +1074,12 @@ class Entity(object): if len(c) == 1: c[0].messages.extend(c.messages) - return c[0] - else: - raise AmbiguityException("This retrieval was not unique!!!") - else: - return Container().append(self).retrieve( - unique=unique, raise_exception_on_error=raise_exception_on_error, flags=flags) + + raise QueryNotUniqueError("This retrieval was not unique!!!") + + return Container().append(self).retrieve( + unique=unique, raise_exception_on_error=raise_exception_on_error, flags=flags) def insert(self, raise_exception_on_error=True, unique=True, sync=True, strict=False, flags=None): @@ -1070,7 +1112,7 @@ class Entity(object): flags=flags)[0] def update(self, strict=False, raise_exception_on_error=True, - unique=True, flags=None): + unique=True, flags=None, sync=True): """Update this entity. There are two possible work-flows to perform this update: @@ -1104,6 +1146,7 @@ class Entity(object): return Container().append(self).update( strict=strict, + sync=sync, raise_exception_on_error=raise_exception_on_error, unique=unique, flags=flags)[0] @@ -1239,9 +1282,10 @@ class QueryTemplate(): self.is_valid = lambda: False self.is_deleted = lambda: False self.version = None + self.state = None - def retrieve(self, strict=True, raise_exception_on_error=True, - unique=True, sync=True, flags=None): + def retrieve(self, raise_exception_on_error=True, unique=True, sync=True, + flags=None): return Container().append(self).retrieve( raise_exception_on_error=raise_exception_on_error, @@ -1386,8 +1430,7 @@ class Parent(Entity): if xml is None: xml = etree.Element("Parent") - return super(Parent, self).to_xml( - xml=xml, add_properties=add_properties) + return super().to_xml(xml=xml, add_properties=add_properties) class _EntityWrapper(object): @@ -1417,6 +1460,23 @@ class Property(Entity): property=property, value=value, **copy_kwargs) def add_parent(self, parent=None, **kwargs): + """Add a parent Entity to this Property. + + Parameters + ---------- + parent : Entity or int or str or None, optional + The parent entity + **kwargs : dict, optional + Additional keyword arguments specifying the parent Entity + by id or name, and specifying the inheritance level. See + :py:meth:`Entity.add_parent` for more information. Note + that by default, `inheritance` is set to ``fix``. + + See Also + -------- + Entity.add_parent + + """ copy_kwargs = kwargs.copy() if 'inheritance' not in copy_kwargs: @@ -1452,7 +1512,7 @@ class Message(object): if xml is None: xml = etree.Element(str(self.type)) - if self.code: + if self.code is not None: xml.set("code", str(self.code)) if self.description: @@ -1468,7 +1528,7 @@ class Message(object): def __eq__(self, obj): if isinstance(obj, Message): - return self.type == obj.type and self.code == obj.codes + return self.type == obj.type and self.code == obj.code return False @@ -1491,17 +1551,35 @@ class RecordType(Entity): # set default importance copy_kwargs['inheritance'] = FIX - return super(RecordType, self).add_property( - property=property, value=value, **copy_kwargs) + return super().add_property(property=property, value=value, + **copy_kwargs) def add_parent(self, parent=None, **kwargs): + """Add a parent to this RecordType + + Parameters + ---------- + parent : Entity or int or str or None, optional + The parent entity, either specified by the Entity object + itself, or its id or its name. Default is None. + **kwargs : dict, optional + Additional keyword arguments specifying the parent Entity by id or + name, and specifying the inheritance level. See + :py:meth:`Entity.add_parent` for more information. Note + that by default, `inheritance` is set to ``obligatory``. + + See Also + -------- + Entity.add_parent + + """ copy_kwargs = kwargs.copy() if 'inheritance' not in copy_kwargs: # set default importance copy_kwargs['inheritance'] = OBLIGATORY - return super(RecordType, self).add_parent(parent=parent, **copy_kwargs) + return super().add_parent(parent=parent, **copy_kwargs) def __init__(self, name=None, id=None, description=None, datatype=None): # @ReservedAssignment Entity.__init__(self, name=name, id=id, description=description, @@ -1529,7 +1607,7 @@ class Record(Entity): # set default importance copy_kwargs['inheritance'] = FIX - return super(Record, self).add_property( + return super().add_property( property=property, value=value, **copy_kwargs) def __init__(self, name=None, id=None, description=None): # @ReservedAssignment @@ -1693,7 +1771,7 @@ class File(Record): # set default importance copy_kwargs['inheritance'] = FIX - return super(File, self).add_property( + return super().add_property( property=property, value=value, **copy_kwargs) @@ -2018,7 +2096,8 @@ class _Messages(dict): <<< del msgs["HelloWorld",2] <<< assert msgs.get("HelloWorld",2) == None - <<< # this Message has no code and no description (make easy things easy...) + # this Message has no code and no description (make easy things easy...) + <<< <<< msgs["HelloWorld"] = "Hello!" <<< assert msgs["HelloWorld"] == "Hello!" @@ -2215,6 +2294,7 @@ def _basic_sync(e_local, e_remote): e_local.is_valid = e_remote.is_valid e_local.is_deleted = e_remote.is_deleted e_local.version = e_remote.version + e_local.state = e_remote.state if hasattr(e_remote, "query"): e_local.query = e_remote.query @@ -2227,15 +2307,19 @@ def _basic_sync(e_local, e_remote): def _deletion_sync(e_local, e_remote): if e_local is None or e_remote is None: - return None + return + try: - e_remote.get_messages()[('info', 10)] - _basic_sync(e_local, e_remote) - e_local.is_valid = lambda: False - e_local.is_deleted = lambda: True - e_local.id = None + e_remote.get_messages()["info", 10] # try and get the deletion info except KeyError: + # deletion info wasn't there e_local.messages = e_remote.messages + return + + _basic_sync(e_local, e_remote) + e_local.is_valid = lambda: False + e_local.is_deleted = lambda: True + e_local.id = None class Container(list): @@ -2276,11 +2360,11 @@ class Container(list): """ if entity in self: - super(Container, self).remove(entity) + super().remove(entity) else: for ee in self: if entity == ee.id: - super(Container, self).remove(ee) + super().remove(ee) return ee raise ValueError( @@ -2398,13 +2482,13 @@ class Container(list): """ if isinstance(entity, Entity): - super(Container, self).append(entity) + super().append(entity) elif isinstance(entity, int): - super(Container, self).append(Entity(id=entity)) + super().append(Entity(id=entity)) elif hasattr(entity, "encode"): - super(Container, self).append(Entity(name=entity)) + super().append(Entity(name=entity)) elif isinstance(entity, QueryTemplate): - super(Container, self).append(entity) + super().append(entity) else: raise TypeError( "Entity was neither an id nor a name nor an entity." + @@ -2420,8 +2504,8 @@ class Container(list): @return xml element """ tmpid = 0 - ''' users might already have specified some tmpids. -> look for smallest.''' + # users might already have specified some tmpids. -> look for smallest. for e in self: tmpid = min(tmpid, Container._get_smallest_tmpid(e)) tmpid -= 1 @@ -2640,8 +2724,7 @@ class Container(list): # list of remote entities which already have a local equivalent used_remote_entities = [] - ''' match by cuid ''' - + # match by cuid for local_entity in self: sync_dict[local_entity] = None @@ -2667,10 +2750,9 @@ class Container(list): local_entity.add_message(Message("Error", None, msg)) if raise_exception_on_error: - raise AmbiguityException(msg) - - ''' match by id ''' + raise MismatchingEntitiesError(msg) + # match by id for local_entity in self: if sync_dict[local_entity] is None and local_entity.id is not None: sync_remote_entities = [] @@ -2692,10 +2774,9 @@ class Container(list): local_entity.add_message(Message("Error", None, msg)) if raise_exception_on_error: - raise AmbiguityException(msg) - - ''' match by path ''' + raise MismatchingEntitiesError(msg) + # match by path for local_entity in self: if (sync_dict[local_entity] is None and local_entity.path is not None): @@ -2722,10 +2803,9 @@ class Container(list): local_entity.add_message(Message("Error", None, msg)) if raise_exception_on_error: - raise AmbiguityException(msg) - - ''' match by name ''' + raise MismatchingEntitiesError(msg) + # match by name for local_entity in self: if (sync_dict[local_entity] is None and local_entity.name is not None): @@ -2752,7 +2832,7 @@ class Container(list): local_entity.add_message(Message("Error", None, msg)) if raise_exception_on_error: - raise AmbiguityException(msg) + raise MismatchingEntitiesError(msg) # add remaining entities to this remote_container sync_remote_entities = [] @@ -2771,7 +2851,7 @@ class Container(list): remote_container.add_message(Message("Error", None, msg)) if raise_exception_on_error: - raise AmbiguityException(msg) + raise MismatchingEntitiesError(msg) return sync_dict @@ -2786,11 +2866,24 @@ class Container(list): this happens, none of them will be deleted. It occurs an error instead. """ + chunk_size = 100 + item_count = len(self) + # Split Container in 'chunk_size'-sized containers (if necessary) to avoid error 414 Request-URI Too Long + if item_count > chunk_size: + for i in range(0, int(item_count/chunk_size)+1): + chunk = Container() + for j in range(i*chunk_size, min(item_count, (i+1)*chunk_size)): + chunk.append(self[j]) + if len(chunk): + chunk.delete() + return self if len(self) == 0: if raise_exception_on_error: - raise TransactionError( - self, "There are no entities to be deleted. This container is empty.") + te = TransactionError( + msg="There are no entities to be deleted. This container is empty.", + container=self) + raise te return self self.clear_server_messages() @@ -2814,21 +2907,23 @@ class Container(list): description="This entity has no identifier. It cannot be deleted.")) if raise_exception_on_error: - raise EntityError( + ee = EntityError( "This entity has no identifier. It cannot be deleted.", entity) - else: - entity.is_valid = lambda: False + raise TransactionError(ee) + entity.is_valid = lambda: False if len(id_str) == 0: if raise_exception_on_error: - raise TransactionError( - self, "There are no entities to be deleted.") + te = TransactionError( + msg="There are no entities to be deleted.", + container=self) + raise te return self entity_url_segments = [_ENTITY_URI_SEGMENT, "&".join(id_str)] _log_request("DELETE: " + str(entity_url_segments) + - ("?" + flags if flags is not None else '')) + ("?" + str(flags) if flags is not None else '')) http_response = c.delete(entity_url_segments, query_dict=flags) cresp = Container._response_to_entities(http_response) @@ -2882,10 +2977,11 @@ class Container(list): description="This entity has no identifier. It cannot be retrieved.")) if raise_exception_on_error: - raise EntityError( - "This entity has no identifier. It cannot be retrieved.", entity) - else: - entity.is_valid = lambda: False + ee = EntityError( + "This entity has no identifier. It cannot be retrieved.", + entity) + raise TransactionError(ee) + entity.is_valid = lambda: False else: entities_str.append(str(query)) @@ -2926,12 +3022,12 @@ class Container(list): "&".join(entities))], query_dict=flags) return Container._response_to_entities(http_response) - except URITooLongException as uri_e: + except HTTPURITooLongError as uri_e: try: # split up uri1, uri2 = Container._split_uri_string(entities) - except ValueError: - raise uri_e + except ValueError as val_e: + raise uri_e from val_e c1 = self._retrieve(entities=uri1, flags=flags) c2 = self._retrieve(entities=uri2, flags=flags) c1.extend(c2) @@ -2974,9 +3070,10 @@ class Container(list): """Update these entites.""" if len(self) < 1: - raise TransactionError( - container=self, - msg="There are no entities to be updated. This container is empty.") + te = TransactionError( + msg="There are no entities to be updated. This container is empty.", + container=self) + raise te self.clear_server_messages() insert_xml = etree.Element("Update") @@ -2993,8 +3090,10 @@ class Container(list): for entity in self: if (entity.id is None or entity.id < 0): - raise TransactionError( - self, "You tried to update an entity without a valid id.") + ee = EntityError( + "You tried to update an entity without a valid id.", + entity) + raise TransactionError(ee) self._linearize() @@ -3164,9 +3263,10 @@ class Container(list): insert_xml.append(entity_xml) if len(self) > 0 and len(insert_xml) < 1: - raise TransactionError( - container=self, - msg="There are no entities to be inserted. This container contains existent entities only.") + te = TransactionError( + msg="There are no entities to be inserted. This container contains existent entities only.", + container=self) + raise te _log_request("POST: " + _ENTITY_URI_SEGMENT + ('' if flags is None else "?" + str(flags)), insert_xml) @@ -3402,6 +3502,22 @@ class ACL(): self.deny(username=username, realm=realm, role=role, permission=permission, priority=priority) + def combine(self, other): + """ Combine and return new instance.""" + result = ACL() + result._grants.update(other._grants) + result._grants.update(self._grants) + result._denials.update(other._denials) + result._denials.update(self._denials) + result._priority_grants.update(other._priority_grants) + result._priority_grants.update(self._priority_grants) + result._priority_denials.update(other._priority_denials) + result._priority_denials.update(self._priority_denials) + return result + + def __eq__(self, other): + return isinstance(other, ACL) and other._grants == self._grants and self._denials == other._denials and self._priority_grants == other._priority_grants and self._priority_denials == other._priority_denials + def is_empty(self): return len(self._grants) + len(self._priority_grants) + \ len(self._priority_denials) + len(self._denials) == 0 @@ -3611,6 +3727,23 @@ class ACL(): class Query(): + """Query + + Attributes + ---------- + q : str + The query string. + flags : dict of str + A dictionary of flags to be send with the query request. + messages : _Messages() + A container of messages included in the last query response. + cached : bool + indicates whether the server used the query cache for the execution of + this query. + results : int or Container + The number of results (when this was a count query) or the container + with the resulting entities. + """ def putFlag(self, key, value=None): self.flags[key] = value @@ -3626,28 +3759,63 @@ class Query(): def __init__(self, q): self.flags = dict() self.messages = _Messages() + self.cached = None + self.etag = None if isinstance(q, etree._Element): self.q = q.get("string") self.results = int(q.get("results")) + if q.get("cached") is None: + self.cached = False + else: + self.cached = q.get("cached").lower() == "true" + self.etag = q.get("etag") + for m in q: if m.tag.lower() == 'warning' or m.tag.lower() == 'error': self.messages.append(_parse_single_xml_element(m)) else: self.q = q - def execute(self, unique=False, raise_exception_on_error=True, - **kwargs): + def execute(self, unique=False, raise_exception_on_error=True, cache=True): + """Execute a query (via a server-requests) and return the results. + + Parameters + ---------- + + unique : bool + Whether the query is expected to have only one entity as result. + Defaults to False. + raise_exception_on_error : bool + Whether an exception should be raises when there are errors in the + resulting entities. Defaults to True. + cache : bool + Whether to use the query cache (equivalent to adding a "cache" + flag) to the Query object. Defaults to True. + + Returns + ------- + results : Container or integer + Returns an integer when it was a `COUNT` query. Otherwise, returns a + Container with the resulting entities. + """ connection = get_connection() - query_dict = dict(self.flags) + + flags = self.flags + if cache is False: + flags["cache"] = "false" + query_dict = dict(flags) query_dict["query"] = str(self.q) + _log_request("GET Entity?" + str(query_dict), None) http_response = connection.retrieve( entity_uri_segments=["Entity"], - query_dict=query_dict, **kwargs) + query_dict=query_dict) cresp = Container._response_to_entities(http_response) self.results = cresp.query.results + self.cached = cresp.query.cached + self.etag = cresp.query.etag if self.q.lower().startswith('count') and len(cresp) == 0: # this was a count query @@ -3659,10 +3827,12 @@ class Query(): if unique: if len(cresp) > 1 and raise_exception_on_error: - raise AmbiguityException("This query wasn't unique") - elif len(cresp) == 0 and raise_exception_on_error: - raise EntityDoesNotExistError("No such entity found.") - elif len(cresp) == 1: + raise QueryNotUniqueError( + "Query '{}' wasn't unique.".format(self.q)) + if len(cresp) == 0 and raise_exception_on_error: + raise EmptyUniqueQueryError( + "Query '{}' found no results.".format(self.q)) + if len(cresp) == 1: r = cresp[0] r.messages.extend(cresp.messages) @@ -3672,8 +3842,32 @@ class Query(): return cresp -def execute_query(q, unique=False, raise_exception_on_error=True, flags=None, - **kwargs): +def execute_query(q, unique=False, raise_exception_on_error=True, cache=True, flags=None): + """Execute a query (via a server-requests) and return the results. + + Parameters + ---------- + + q : str + The query string. + unique : bool + Whether the query is expected to have only one entity as result. + Defaults to False. + raise_exception_on_error : bool + Whether an exception should be raises when there are errors in the + resulting entities. Defaults to True. + cache : bool + Whether to use the query cache (equivalent to adding a "cache" flag). + Defaults to True. + flags : dict of str + Flags to be added to the request. + + Returns + ------- + results : Container or integer + Returns an integer when it was a `COUNT` query. Otherwise, returns a + Container with the resulting entities. + """ query = Query(q) if flags is not None: @@ -3681,7 +3875,7 @@ def execute_query(q, unique=False, raise_exception_on_error=True, flags=None, return query.execute(unique=unique, raise_exception_on_error=raise_exception_on_error, - **kwargs) + cache=cache) class DropOffBox(list): @@ -3735,7 +3929,7 @@ class Info(): c = get_connection() try: http_response = c.retrieve(["Info"]) - except ConnectionException as conn_e: + except CaosDBConnectionError as conn_e: print(conn_e) return @@ -3850,11 +4044,15 @@ def _parse_single_xml_element(elem): return entity elif elem.tag.lower() == "version": return Version.from_xml(elem) + elif elem.tag.lower() == "state": + return State.from_xml(elem) elif elem.tag.lower() == "emptystring": return "" elif elem.tag.lower() == "value": if len(elem) == 1 and elem[0].tag.lower() == "emptystring": return "" + elif len(elem) == 1 and elem[0].tag.lower() in classmap: + return _parse_single_xml_element(elem[0]) elif elem.text is None or elem.text.strip() == "": return None @@ -3880,124 +4078,119 @@ def _parse_single_xml_element(elem): "code"), description=elem.get("description"), body=elem.text) -def raise_errors(arg0): - if isinstance(arg0, (Entity, QueryTemplate)): - entity_error = EntityError( - entity=arg0, error=Message('Error', 0, 'EntityMultiError')) - found114 = False - found116 = False +def _evaluate_and_add_error(parent_error, ent): + """Evaluate the error message(s) attached to entity and add a + corresponding exception to parent_error. - for e in arg0.get_errors(): - try: - if e.code is not None: - if int(e.code) == 101: # arg0 does not exist - raise EntityDoesNotExistError(error=e, entity=arg0) - elif int(e.code) == 110: # entity has no data type - raise EntityHasNoDatatypeError(error=e, entity=arg0) - elif int(e.code) == 403: # Transaction not permitted - raise AuthorizationException(error=e, entity=arg0) - elif int(e.code) == 114: # unqualified properties - found114 = True - unqualified_properties_error = UnqualifiedPropertiesError( - error=e, entity=arg0) - - for p in arg0.get_properties(): - try: - raise_errors(p) - except EntityError as pe: - unqualified_properties_error.add_error(pe) - raise unqualified_properties_error - elif int(e.code) == 116: # unqualified parents - found116 = True - unqualified_parents_error = UnqualifiedParentsError( - error=e, entity=arg0) - - for p in arg0.get_parents(): - try: - raise_errors(p) - except EntityError as pe: - unqualified_parents_error.add_error(pe) - raise unqualified_parents_error - elif int(e.code) == 152: # name was not unique - raise UniqueNamesError(error=e, entity=arg0) - raise EntityError(error=e, entity=arg0) - except EntityError as ee: - entity_error.add_error(ee) + Parameters: + ----------- + parent_error : TrancactionError + Parent error to which the new exception will be attached. This + exception will be a direct child. + ent : Entity + Entity that caused the TransactionError. An exception is + created depending on its error message(s). - if not found114: - for p in arg0.get_properties(): - try: - raise_errors(p) - except EntityError as pe: - entity_error.add_error(pe) + Returns: + -------- + TransactionError : + Parent error with new exception(s) attached to it. + """ + if isinstance(ent, (Entity, QueryTemplate)): + # Check all error messages + found114 = False + found116 = False + for err in ent.get_errors(): + # Evaluate specific EntityErrors depending on the error + # code + if err.code is not None: + if int(err.code) == 101: # ent doesn't exist + new_exc = EntityDoesNotExistError(entity=ent, + error=err) + elif int(err.code) == 110: # ent has no data type + new_exc = EntityHasNoDatatypeError(entity=ent, + error=err) + elif int(err.code) == 403: # no permission + new_exc = AuthorizationError(entity=ent, + error=err) + elif int(err.code) == 152: # name wasn't unique + new_exc = UniqueNamesError(entity=ent, error=err) + elif int(err.code) == 114: # unqualified properties + found114 = True + new_exc = UnqualifiedPropertiesError(entity=ent, + error=err) + for prop in ent.get_properties(): + new_exc = _evaluate_and_add_error(new_exc, + prop) + elif int(err.code) == 116: # unqualified parents + found116 = True + new_exc = UnqualifiedParentsError(entity=ent, + error=err) + for par in ent.get_parents(): + new_exc = _evaluate_and_add_error(new_exc, + par) + else: # General EntityError for other codes + new_exc = EntityError(entity=ent, error=err) + else: # No error code causes a general EntityError, too + new_exc = EntityError(entity=ent, error=err) + parent_error.add_error(new_exc) + # Check for possible errors in parents and properties that + # weren't detected up to here + if not found114: + dummy_err = EntityError(entity=ent) + for prop in ent.get_properties(): + dummy_err = _evaluate_and_add_error(dummy_err, prop) + if dummy_err.errors: + parent_error.add_error(dummy_err) if not found116: - for p in arg0.get_parents(): - try: - raise_errors(p) - except EntityError as pe: - entity_error.add_error(pe) - - if len(entity_error.get_errors()) == 1: - r = entity_error.get_errors().pop() - raise r - elif len(entity_error.get_errors()) > 1: - r = entity_error._convert() - raise r - elif isinstance(arg0, Container): - transaction_error = TransactionError( - container=arg0, msg="This transaction terminated with Errors.") - doRaise = False - found12 = False - - if arg0.get_errors() is not None: - for er in arg0.get_errors(): - if er.code is not None: - if int(er.code) == 12: # atomicity violation - found12 = True - atomic_error = TransactionError( - container=arg0, error=er, msg=er.description) - - for e in arg0: - try: - raise_errors(e) - except EntityError as ee: - atomic_error.add_error(ee) - - if len(atomic_error.get_errors()) > 0: - transaction_error.add_error( - atomic_error._convert()) - doRaise = True - else: - transaction_error.add_error(atomic_error) - doRaise = True - else: - te = TransactionError( - container=arg0, error=er, msg=er.description) - transaction_error.add_error(te) - doRaise = True - - if len(transaction_error.get_errors()) == 1: - transaction_error = transaction_error.get_errors().pop() - - if not found12: - for e in arg0: - try: - raise_errors(e) - except EntityError as ee: - transaction_error.add_error(ee) - doRaise = True - - if len(transaction_error.get_errors()) == 1: - t = transaction_error.get_errors().pop() - raise t - elif len(transaction_error.get_errors()) > 1: - t = transaction_error._convert() - raise t - elif doRaise: - raise transaction_error + dummy_err = EntityError(entity=ent) + for par in ent.get_parents(): + dummy_err = _evaluate_and_add_error(dummy_err, par) + if dummy_err.errors: + parent_error.add_error(dummy_err) + + elif isinstance(ent, Container): + parent_error.container = ent + if ent.get_errors() is not None: + parent_error.code = ent.get_errors()[0].code + # In the highly unusual case of more than one error + # message, attach all of them. + parent_error.msg = '\n'.join( + [x.description for x in ent.get_errors()]) + # Go through all container elements and add them: + for elt in ent: + parent_error = _evaluate_and_add_error(parent_error, elt) + else: - raise TypeError("Parameter arg0 is to be an Entity or a Container") + raise TypeError("Parameter ent is to be an Entity or a Container") + + return parent_error + + +def raise_errors(arg0): + """Raise a TransactionError depending on the error code(s) inside + Entity, QueryTemplate or Container arg0. More detailed errors may + be attached to the TransactionError depending on the contents of + arg0. + + Parameters: + ----------- + arg0 : Entity, QueryTemplate, or Container + CaosDB object whose messages are evaluated according to their + error codes + + """ + transaction_error = _evaluate_and_add_error(TransactionError(), + arg0) + # Raise if any error was found + if len(transaction_error.all_errors) > 0: + raise transaction_error + # Cover the special case of an empty container with error + # message(s) (e.g. query syntax error) + if (transaction_error.container is not None and + transaction_error.container.has_errors()): + raise transaction_error def delete(ids, raise_exception_on_error=True): diff --git a/src/caosdb/common/state.py b/src/caosdb/common/state.py new file mode 100644 index 0000000000000000000000000000000000000000..cb74022bef57a77c8270b2033c904eecabaadf83 --- /dev/null +++ b/src/caosdb/common/state.py @@ -0,0 +1,198 @@ +# ** header v3.0 +# This file is a part of the CaosDB Project. +# +# Copyright (C) 2020 IndiScale GmbH <info@indiscale.com> +# Copyright (C) 2020 Timm Fitschen <t.fitschen@indiscale.com> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. +# +# ** end header + +import copy +from lxml import etree + + +def _translate_to_state_acis(acis): + result = set() + for aci in acis: + aci = copy.copy(aci) + if aci.role: + aci.role = "?STATE?" + aci.role + "?" + result.add(aci) + return result + + +class Transition: + """Transition + + Represents allowed transitions from one state to another. + + Properties + ---------- + name : str + The name of the transition + description: str + The description of the transition + from_state : str + A state name + to_state : str + A state name + """ + + def __init__(self, name, from_state, to_state, description=None): + self._name = name + self._from_state = from_state + self._to_state = to_state + self._description = description + + @property + def name(self): + return self._name + + @property + def description(self): + return self._description + + @property + def from_state(self): + return self._from_state + + @property + def to_state(self): + 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}")' + + def __eq__(self, other): + return (isinstance(other, Transition) + and other.name == self.name + and other.to_state == self.to_state + and other.from_state == self.from_state) + + def __hash__(self): + return 23472 + hash(self.name) + hash(self.from_state) + hash(self.to_state) + + @staticmethod + def from_xml(xml): + to_state = [to.get("name") for to in xml + if to.tag.lower() == "tostate"] + from_state = [from_.get("name") for from_ in xml + if from_.tag.lower() == "fromstate"] + result = Transition(name=xml.get("name"), + description=xml.get("description"), + from_state=from_state[0] if from_state else None, + to_state=to_state[0] if to_state else None) + return result + + +class State: + """State + + Represents the state of an entity and take care of the serialization and + deserialization of xml for the entity state. + + An entity state is always a State of a StateModel. + + Properties + ---------- + name : str + Name of the State + model : str + Name of the StateModel + description : str + Description of the State (read-only) + id : str + Id of the undelying State record (read-only) + transitions : set of Transition + All transitions which are available from this state (read-only) + """ + + def __init__(self, model, name): + self.name = name + self.model = model + self._id = None + self._description = None + self._transitions = None + + @property + def id(self): + return self._id + + @property + def description(self): + return self._description + + @property + def transitions(self): + return self._transitions + + def __eq__(self, other): + return (isinstance(other, State) + and self.name == other.name + and self.model == other.model) + + def __hash__(self): + return hash(self.name) + hash(self.model) + + def __repr__(self): + return f"State('{self.model}', '{self.name}')" + + def to_xml(self): + """Serialize this State to xml. + + Returns + ------- + xml : etree.Element + """ + xml = etree.Element("State") + if self.name is not None: + xml.set("name", self.name) + if self.model is not None: + xml.set("model", self.model) + return xml + + @staticmethod + def from_xml(xml): + """Create a new State instance from an xml Element. + + Parameters + ---------- + xml : etree.Element + + Returns + ------- + state : State + """ + name = xml.get("name") + model = xml.get("model") + result = State(name=name, model=model) + result._id = xml.get("id") + result._description = xml.get("description") + transitions = [Transition.from_xml(t) for t in xml if t.tag.lower() == + "transition"] + if transitions: + result._transitions = set(transitions) + + return result + + @staticmethod + def create_state_acl(acl): + from .models import ACL + state_acl = ACL() + state_acl._grants = _translate_to_state_acis(acl._grants) + state_acl._denials = _translate_to_state_acis(acl._denials) + state_acl._priority_grants = _translate_to_state_acis(acl._priority_grants) + state_acl._priority_denials = _translate_to_state_acis(acl._priority_denials) + return state_acl diff --git a/src/caosdb/configuration.py b/src/caosdb/configuration.py index 59f6c6a094d2df88384eb360dc4789ec99e8d787..842f1ee62a5b3178b7305e4c1e0c281a2dbd3b38 100644 --- a/src/caosdb/configuration.py +++ b/src/caosdb/configuration.py @@ -28,6 +28,9 @@ except ImportError: # python3 from configparser import ConfigParser +from os import environ, getcwd +from os.path import expanduser, join, isfile + def _reset_config(): global _pycaosdbconf @@ -50,3 +53,20 @@ def configure(inifile): def get_config(): global _pycaosdbconf return _pycaosdbconf + + +def _read_config_files(): + """Function to read config files from different paths. Checks for path in $PYCAOSDBINI or home directory (.pycaosdb.ini) and in the current working directory (pycaosdb.ini). + + Returns: + [list]: list with successfully parsed ini-files. Order: env_var or home directory, cwd. Used for testing the function. + """ + return_var = [] + if "PYCAOSDBINI" in environ: + return_var.extend(configure(expanduser(environ["PYCAOSDBINI"]))) + else: + return_var.extend(configure(expanduser('~/.pycaosdb.ini'))) + + if isfile(join(getcwd(), "pycaosdb.ini")): + return_var.extend(configure(join(getcwd(), "pycaosdb.ini"))) + return return_var diff --git a/src/caosdb/connection/authentication/auth_token.py b/src/caosdb/connection/authentication/auth_token.py index fbce78fb86d78e06d97ef00dd162c1ed57f7560d..688123867f68153d3631bb8559baa235f6f02da5 100644 --- a/src/caosdb/connection/authentication/auth_token.py +++ b/src/caosdb/connection/authentication/auth_token.py @@ -30,7 +30,7 @@ An Authentictor which only uses only a pre-supplied authentication token. from __future__ import absolute_import, unicode_literals, print_function from .interface import AbstractAuthenticator, CaosDBServerConnection from caosdb.connection.utils import auth_token_to_cookie -from caosdb.exceptions import LoginFailedException +from caosdb.exceptions import LoginFailedError def get_authentication_provider(): @@ -68,11 +68,11 @@ class AuthTokenAuthenticator(AbstractAuthenticator): self._login() def _login(self): - raise LoginFailedException("The authentication token is expired or you " - "have been logged out otherwise. The " - "auth_token authenticator cannot log in " - "again. You must provide a new " - "authentication token.") + raise LoginFailedError("The authentication token is expired or you " + "have been logged out otherwise. The " + "auth_token authenticator cannot log in " + "again. You must provide a new " + "authentication token.") def logout(self): self._logout() diff --git a/src/caosdb/connection/authentication/interface.py b/src/caosdb/connection/authentication/interface.py index d9a9b4306b1dbdedbc67dfda81a3ca3d7b4aeb41..a364aeb564ee929d995b2f8098bd21e30e9733ab 100644 --- a/src/caosdb/connection/authentication/interface.py +++ b/src/caosdb/connection/authentication/interface.py @@ -31,7 +31,7 @@ import logging from caosdb.connection.utils import urlencode from caosdb.connection.interface import CaosDBServerConnection from caosdb.connection.utils import parse_auth_token, auth_token_to_cookie -from caosdb.exceptions import LoginFailedException +from caosdb.exceptions import LoginFailedError # meta class compatible with Python 2 *and* 3: ABC = ABCMeta('ABC', (object, ), {'__slots__': ()}) @@ -197,9 +197,9 @@ class CredentialsAuthenticator(AbstractAuthenticator): # we need a username for this: if username is None: - raise LoginFailedException("No username was given.") + raise LoginFailedError("No username was given.") if password is None: - raise LoginFailedException("No password was given") + raise LoginFailedError("No password was given") headers = {} headers["Content-Type"] = "application/x-www-form-urlencoded" @@ -210,7 +210,7 @@ class CredentialsAuthenticator(AbstractAuthenticator): response.read() # clear socket if response.status != 200: - raise LoginFailedException("LOGIN WAS NOT SUCCESSFUL") + raise LoginFailedError("LOGIN WAS NOT SUCCESSFUL") self.on_response(response) return response diff --git a/src/caosdb/connection/authentication/keyring.py b/src/caosdb/connection/authentication/keyring.py index 1dc986174acbe23191305632afda91cda0c718d2..d8be7ddf030577545230c9111fdad542b6d6e7e2 100644 --- a/src/caosdb/connection/authentication/keyring.py +++ b/src/caosdb/connection/authentication/keyring.py @@ -30,7 +30,7 @@ retrieve the password. import sys import imp from getpass import getpass -from caosdb.exceptions import ConfigurationException +from caosdb.exceptions import ConfigurationError from .external_credentials_provider import ExternalCredentialsProvider from .interface import CredentialsAuthenticator @@ -67,10 +67,10 @@ def _get_external_keyring(): def _call_keyring(**config): if "username" not in config: - raise ConfigurationException("Your configuration did not provide a " - "`username` which is needed by the " - "`KeyringCaller` to retrieve the " - "password in question.") + raise ConfigurationError("Your configuration did not provide a " + "`username` which is needed by the " + "`KeyringCaller` to retrieve the " + "password in question.") url = config.get("url") username = config.get("username") app = "caosdb — {}".format(url) diff --git a/src/caosdb/connection/authentication/pass.py b/src/caosdb/connection/authentication/pass.py index 9399fc4f4a76407ad94618785adcfbb945d4c788..853cdf0ed92039e7b5fc9beda8bb76cc0f3cc030 100644 --- a/src/caosdb/connection/authentication/pass.py +++ b/src/caosdb/connection/authentication/pass.py @@ -28,7 +28,7 @@ password. """ from subprocess import check_output, CalledProcessError -from caosdb.exceptions import ConfigurationException +from caosdb.exceptions import ConfigurationError from .interface import CredentialsAuthenticator from .external_credentials_provider import ExternalCredentialsProvider @@ -50,10 +50,10 @@ def get_authentication_provider(): def _call_pass(**config): if "password_identifier" not in config: - raise ConfigurationException("Your configuration did not provide a " - "`password_identifier` which is needed " - "by the `PassCaller` to retrieve the " - "password in question.") + raise ConfigurationError("Your configuration did not provide a " + "`password_identifier` which is needed " + "by the `PassCaller` to retrieve the " + "password in question.") try: return check_output( diff --git a/src/caosdb/connection/authentication/unauthenticated.py b/src/caosdb/connection/authentication/unauthenticated.py index 53a2756eb59259a0be012e41f2ea213735568838..65febae8fd8f02f3ee0d339fafb36af512fc7be7 100644 --- a/src/caosdb/connection/authentication/unauthenticated.py +++ b/src/caosdb/connection/authentication/unauthenticated.py @@ -30,7 +30,7 @@ cookies. """ from __future__ import absolute_import, unicode_literals, print_function from .interface import AbstractAuthenticator, CaosDBServerConnection -from caosdb.exceptions import LoginFailedException +from caosdb.exceptions import LoginFailedError def get_authentication_provider(): @@ -70,11 +70,11 @@ class Unauthenticated(AbstractAuthenticator): self._login() def _login(self): - raise LoginFailedException("This caosdb client is configured to stay " - "unauthenticated. Change your " - "`password_method` and provide an " - "`auth_token` or credentials if you want " - "to authenticate this client.") + raise LoginFailedError("This caosdb client is configured to stay " + "unauthenticated. Change your " + "`password_method` and provide an " + "`auth_token` or credentials if you want " + "to authenticate this client.") def logout(self): self._logout() diff --git a/src/caosdb/connection/connection.py b/src/caosdb/connection/connection.py index 703897d62a40a820a6ff578869a862f6c7c4c019..9e273a56778737033fda9f342f967f56946b501b 100644 --- a/src/caosdb/connection/connection.py +++ b/src/caosdb/connection/connection.py @@ -33,12 +33,19 @@ from errno import EPIPE as BrokenPipe from socket import error as SocketError from caosdb.configuration import get_config -from caosdb.exceptions import (AuthorizationException, CaosDBException, - ClientErrorException, ConfigurationException, - ConnectionException, EntityDoesNotExistError, - LoginFailedException, ServerErrorException, - URITooLongException) -from caosdb.version import version +from caosdb.exceptions import (CaosDBException, HTTPClientError, + ConfigurationError, + CaosDBConnectionError, + HTTPForbiddenError, + LoginFailedError, + HTTPResourceNotFoundError, + HTTPServerError, + HTTPURITooLongError) +try: + from caosdb.version import version +except ModuleNotFoundError: + version = "uninstalled" + from pkg_resources import resource_filename from .interface import CaosDBHTTPResponse, CaosDBServerConnection @@ -61,6 +68,10 @@ class _WrappedHTTPResponse(CaosDBHTTPResponse): def __init__(self, response): self.response = response + @property + def reason(self): + return self.response.reason + @property def status(self): return self.response.status @@ -88,7 +99,8 @@ class _DefaultCaosDBServerConnection(CaosDBServerConnection): """ def __init__(self): - self._useragent = ("caosdb-pylib/{version} - {implementation}".format(version=version, implementation=type(self).__name__)) + self._useragent = ("caosdb-pylib/{version} - {implementation}".format( + version=version, implementation=type(self).__name__)) self._http_con = None self._base_path = None @@ -132,7 +144,7 @@ class _DefaultCaosDBServerConnection(CaosDBServerConnection): self._http_con.request(method=method, url=self._base_path + path, headers=headers, body=body) except SocketError as socket_err: - raise ConnectionException( + raise CaosDBConnectionError( "Connection failed. Network or server down? " + str(socket_err) ) @@ -156,7 +168,7 @@ class _DefaultCaosDBServerConnection(CaosDBServerConnection): Raises ------ - ConnectionException + CaosDBConnectionError If no url has been specified, or if the CA certificate cannot be loaded. """ @@ -193,9 +205,9 @@ class _DefaultCaosDBServerConnection(CaosDBServerConnection): try: context.load_verify_locations(config["cacert"]) except Exception as exc: - raise ConnectionException("Could not load the cacert in" - "`{}`: {}".format(config["cacert"], - exc)) + raise CaosDBConnectionError("Could not load the cacert in" + "`{}`: {}".format(config["cacert"], + exc)) context.load_default_certs() @@ -204,7 +216,7 @@ class _DefaultCaosDBServerConnection(CaosDBServerConnection): host = parsed_url.netloc self._base_path = parsed_url.path else: - raise ConnectionException( + raise CaosDBConnectionError( "No connection url specified. Please " "do so via caosdb.configure_connection(...) or in a config " "file.") @@ -276,7 +288,7 @@ def _get_authenticator(**config): Raises ------ - ConnectionException + ConfigurationError If the password_method string cannot be resolved to a CaosAuthenticator class. """ @@ -292,10 +304,10 @@ def _get_authenticator(**config): return auth_provider except ImportError: - raise ConfigurationException("Password method \"{}\" not implemented. " - "Try `plain`, `pass`, `keyring`, or " - "`auth_token`." - .format(config["password_method"])) + raise ConfigurationError("Password method \"{}\" not implemented. " + "Try `plain`, `pass`, `keyring`, or " + "`auth_token`." + .format(config["password_method"])) def configure_connection(**kwargs): @@ -398,29 +410,24 @@ def _handle_response_status(http_response): # emtpy response buffer body = http_response.read() + if status == 404: + raise HTTPResourceNotFoundError("This resource has not been found.") + elif status > 499: + raise HTTPServerError(body=body) + + reason = http_response.reason + standard_message = ("Request failed. The response returned with status " + "{} - {}.".format(status, reason)) if status == 401: - raise LoginFailedException( - "Request failed. The response returned with status " - "{}.".format(status)) + raise LoginFailedError(standard_message) elif status == 403: - raise AuthorizationException( - "Request failed. The response returned with status " - "{}.".format(status)) - elif status == 404: - raise EntityDoesNotExistError("This entity does not exist.") + raise HTTPForbiddenError(standard_message) elif status in (413, 414): - raise URITooLongException( - "Request failed. The response returned with status " - "{}.".format(status)) + raise HTTPURITooLongError(standard_message) elif 399 < status < 500: - raise ClientErrorException(msg=("Request failed. The response returned " - "with status {}.").format(status), status=status, body=body) - elif status > 499: - raise ServerErrorException(body=body) + raise HTTPClientError(msg=standard_message, status=status, body=body) else: - raise CaosDBException( - "Request failed. The response returned with status " - "{}.".format(status)) + raise CaosDBException(standard_message) class _Connection(object): # pylint: disable=useless-object-inheritance @@ -454,7 +461,7 @@ class _Connection(object): # pylint: disable=useless-object-inheritance self.is_configured = True if "implementation" not in config: - raise ConfigurationException( + raise ConfigurationError( "Missing CaosDBServerConnection implementation. You did not " "specify an `implementation` for the connection.") try: @@ -465,20 +472,20 @@ class _Connection(object): # pylint: disable=useless-object-inheritance raise TypeError("The `implementation` callable did not return " "an instance of CaosDBServerConnection.") except TypeError as type_err: - raise ConfigurationException( + raise ConfigurationError( "Bad CaosDBServerConnection implementation. The " "implementation must be a callable object which returns an " "instance of `CaosDBServerConnection` (e.g. a constructor " - "or a factory).", type_err) + "or a factory).\n{}".format(type_err.args[0])) self._delegate_connection.configure(**config) if "auth_token" in config: # deprecated, needed for older scripts config["password_method"] = "auth_token" if "password_method" not in config: - raise ConfigurationException("Missing password_method. You did " - "not specify a `password_method` for" - "the connection.") + raise ConfigurationError("Missing password_method. You did " + "not specify a `password_method` for" + "the connection.") self._authenticator = _get_authenticator( connection=self._delegate_connection, **config) @@ -554,8 +561,8 @@ class _Connection(object): # pylint: disable=useless-object-inheritance uri_segments.extend(path.split("/")) return self.retrieve(entity_uri_segments=uri_segments) - except EntityDoesNotExistError: - raise EntityDoesNotExistError("This file does not exist.") + except HTTPResourceNotFoundError: + raise HTTPResourceNotFoundError("This file does not exist.") def _login(self): self._authenticator.login() @@ -576,7 +583,7 @@ class _Connection(object): # pylint: disable=useless-object-inheritance headers=headers, body=body, reconnect=False, **kwargs) - except LoginFailedException: + except LoginFailedError: if kwargs.get("reconnect", True) is True: self._login() @@ -608,3 +615,11 @@ class _Connection(object): # pylint: disable=useless-object-inheritance _handle_response_status(http_response) return http_response + + def get_username(self): + """ + Return the username of the current connection. + + Shortcut for: get_connection()._authenticator._credentials_provider.username + """ + return self._authenticator._credentials_provider.username diff --git a/src/caosdb/exceptions.py b/src/caosdb/exceptions.py index 5763bef4ac805a7dd1dfde8da2f6f3b7e5fd4bd8..fdd2e11f1dfb8857f86942df2534d732bad9a793 100644 --- a/src/caosdb/exceptions.py +++ b/src/caosdb/exceptions.py @@ -5,6 +5,8 @@ # # Copyright (C) 2018 Research Group Biomedical Physics, # Max-Planck-Institute for Dynamics and Self-Organization Göttingen +# Copyright (C) 2020 Indiscale GmbH <info@indiscale.com> +# Copyright (C) 2020 Florian Spreckelsen <f.spreckelsen@indiscale.com> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -21,6 +23,10 @@ # # ** end header # +"""The exceptions module defines exceptions for HTTP Errors (4xx and 5xx and +HTTP response codes) and for transaction errors (i.e. missing permissions, +dependencies, non-passing consistency checks etc.). +""" from lxml import etree @@ -28,13 +34,13 @@ from lxml import etree class CaosDBException(Exception): """Base class of all CaosDB exceptions.""" - def __init__(self, msg=None, *args): - Exception.__init__(self, msg, *args) + def __init__(self, msg): + Exception.__init__(self, msg) self.msg = msg -class ConfigurationException(CaosDBException): - """ConfigurationException. +class ConfigurationError(CaosDBException): + """ConfigurationError. Indicates a misconfiguration. @@ -43,7 +49,6 @@ class ConfigurationException(CaosDBException): msg : str A descriptin of the misconfiguration. The constructor adds a few lines with explainingg where to find the configuration. - *args Attributes ---------- @@ -51,23 +56,32 @@ class ConfigurationException(CaosDBException): A description of the misconfiguration. """ - def __init__(self, msg, *args): - super(ConfigurationException, self).__init__(msg + - ConfigurationException._INFO, - *args) + def __init__(self, msg): + super().__init__(msg + ConfigurationError._INFO) _INFO = ("\n\nPlease check your ~/.pycaosdb.ini and your $PWD/" - ".pycaosdb.ini. Do at least one of them exist and are they correct?") + ".pycaosdb.ini. Does at least one of them exist and are they correct?") -class ClientErrorException(CaosDBException): +class ServerConfigurationException(CaosDBException): + """The server is configured in a different way than expected. + + This can be for example unexpected flags or settings or missing extensions. + """ + + +class HTTPClientError(CaosDBException): + """HTTPClientError represents 4xx HTTP client errors.""" + def __init__(self, msg, status, body): self.status = status self.body = body CaosDBException.__init__(self, msg) -class ServerErrorException(CaosDBException): +class HTTPServerError(CaosDBException): + """HTTPServerError represents 5xx HTTP server errors.""" + def __init__(self, body): xml = etree.fromstring(body) error = xml.xpath('/Response/Error')[0] @@ -78,107 +92,126 @@ class ServerErrorException(CaosDBException): CaosDBException.__init__(self, msg) -class ConnectionException(CaosDBException): +class CaosDBConnectionError(CaosDBException): """Connection is not configured or the network is down.""" def __init__(self, msg=None): CaosDBException.__init__(self, msg) -class URITooLongException(CaosDBException): +class HTTPURITooLongError(HTTPClientError): """The URI of the last request was too long.""" def __init__(self, msg=None): - CaosDBException.__init__(self, msg) + HTTPClientError.__init__(self, msg=msg, status=414, body=None) + +class LoginFailedError(CaosDBException): + """Login failed. -class AmbiguityException(CaosDBException): - """A retrieval of an entity that was supposed to be uniquely identifiable - returned two or more entities.""" + Probably, your username/password pair is wrong. + """ def __init__(self, msg=None): - CaosDBException.__init__(self, msg) + CaosDBException.__init__(self, msg=msg) -class LoginFailedException(CaosDBException): - """Login failed. +class HTTPForbiddenError(HTTPClientError): + """You're lacking the required permissions. Corresponds to HTTP status + 403. - Probably, your username/password pair is wrong. """ def __init__(self, msg=None): - CaosDBException.__init__(self, msg=msg) + HTTPClientError.__init__(self, msg=msg, status=403, body=None) -class TransactionError(CaosDBException): +class HTTPResourceNotFoundError(HTTPClientError): + """The requested resource doesn't exist; corresponds to HTTP status + 404. - def _calc_bases(self): - types = dict() - # collect each class once + """ - for err in self.errors: - types[id(type(err))] = type(err) - # delete redundant super classes + def __init__(self, msg=None): + HTTPClientError.__init__(self, msg=msg, status=404, body=None) - if len(types.values()) > 1: - # remove TransactionError - try: - del types[id(TransactionError)] - except KeyError: - pass - if len(types.values()) > 1: - # remove EntityError - try: - del types[id(EntityError)] - except KeyError: - pass +class MismatchingEntitiesError(CaosDBException): + """Mismatching entities were found during container sync.""" - ret = () - for t in types.values(): - ret += (t,) +# ######################### Bad query errors ########################### - if ret == (): - ret = (type(self),) - return ret +class BadQueryError(CaosDBException): + """Base class for query errors that are not transaction errors.""" - def __init__(self, container=None, error=None, msg=None): - self.container = container - self.errors = [] - self.msg = msg if msg is not None else str(error) - self.error = error - def print_errs(self): - print(self) +class QueryNotUniqueError(BadQueryError): + """A unique query or retrieve found more than one entity.""" - for err in self.errors: - err.print_errs() - def _convert(self): - t = self._calc_bases() - try: - newtype = type('TransactionError', t, {}) - except BaseException: - self.print_errs() - raise - newinstance = newtype(container=self.container, error=self.msg) - newinstance.errors = self.errors - newinstance.get_entities = self.get_entities +class EmptyUniqueQueryError(BadQueryError): + """A unique query or retrieve dound no result.""" - return newinstance - def get_container(self): - ''' - @return: The container that raised this TransactionError during the last - transaction. - ''' +# ######################### Transaction errors ######################### - return self.container + +class TransactionError(CaosDBException): + """An error of this type is raised whenever any transaction fails with + one or more entities between client and CaosDB server. More + detailed errors are collected as direct and indirect children in + the 'errors' list (direct children) and the 'all_errors' set (set + of all direct and indirect children). + + """ + + def __init__(self, error=None, + msg="An error occured during the transaction.", + container=None): + CaosDBException.__init__(self, msg=msg) + self.errors = [] + self.all_errors = set() + self.entities = [] + self.all_entities = set() + self.container = container + # special case of faulty container + if container is not None and container.get_errors() is not None: + self.code = container.get_errors()[0].code + else: + self.code = None + if error is not None: + self.add_error(error) + + def has_error(self, error_t, direct_children_only=False): + """Check whether this transaction error contains an error of type + error_t. If direct_children_only is True, only direct children + are checked. + + Parameters + ---------- + error_t : EntityError + error type to be checked + direct_children_only: bool, optional + If True, only direct children, i.e., all errors in + self.errors are checked. Else all direct and indirect + children, i.e., all errors in self.all_errors are + used. Default is false. + + Returns + ------- + has_error : bool + True if at least one of the children is of type error_t, + False otherwise. + + """ + + test_set = self.errors if direct_children_only else self.all_errors + return any([isinstance(err, error_t) for err in test_set]) def add_error(self, error): - """Add an error to this TransactionError. + """Add an error as a direct child to this TransactionError. @param error: An EntityError or a list of EntityErrors. @@ -189,35 +222,31 @@ class TransactionError(CaosDBException): """ if hasattr(error, "__iter__"): - for e in error: - self.add_error(e) + for err in error: + self.add_error(err) return self - elif isinstance(error, TransactionError): + elif isinstance(error, EntityError): self.errors.append(error) + self.entities.append(error.entity) + + self.all_errors.add(error) + self.all_errors.update(error.all_errors) + self.all_entities.add(error.entity) + self.all_entities.update(error.all_entities) return self else: raise TypeError( - "Argument is to be an TransactionError or a list of TransactionErrors.") - - def get_errors(self): - ''' - @return: A list of all EntityError objects. - ''' - - if hasattr(self, 'errors'): - return self.errors - - return None + "Argument is to be an EntityError or a list of EntityErrors.") def _repr_reasons(self, indent): - if self.get_errors() is not None and len(self.get_errors()) > 0: + if self.errors is not None and len(self.errors) > 0: ret = "\n" + indent + " +--| REASONS |--" - for c in self.get_errors(): + for err in self.errors: ret += '\n' + indent + ' | -> ' + \ - c.__str__(indent=indent + ' |') + err.__str__(indent=indent + ' |') ret += "\n" + indent + " +----------------" return ret @@ -225,8 +254,11 @@ class TransactionError(CaosDBException): return '' def _repr_head(self, indent): - return str(type(self).__name__) + ((': ' + self.msg) - if hasattr(self, 'msg') and self.msg is not None else '') + return indent + str(type(self).__name__) + ( + (': ' + self.msg) + if hasattr(self, 'msg') and self.msg is not None + else '' + ) def __str__(self, indent=''): ret = self._repr_head(indent=indent) @@ -237,58 +269,21 @@ class TransactionError(CaosDBException): def __repr__(self): return self.__str__() - def get_entities(self): - ''' - @return: A list of all Entity objects with errors. - ''' - ret = [] - - if hasattr(self, 'get_entity') and self.get_entity() is not None: - ret.append(self.get_entity()) - - for error in self.errors: - if hasattr(error, 'get_entity'): - if error.get_entity() not in ret: - ret.append(error.get_entity()) -# if hasattr(error, 'get_entities'): -# for e in error.get_entities(): -# if e not in ret: -# ret.append(e) - return ret - - def get_error(self): - return self.error - class EntityError(TransactionError): + """This is the most basic entity error. It is constructed using an + entity that caused the error and the error message attached by the + server. - @staticmethod - def _sort_t(t): - if len(t) > 1: - ret = () - '''remove EntityError''' - - for i in range(len(t)): - if t[i] != EntityError: - ret += (t[i],) - t = ret - - return t - - def _convert(self): - t = self._calc_bases() - # TODO is it really a good idea to create dynamically types here? - newtype = type('EntityMultiError', t+(Exception,), {}) - newinstance = newtype(error=self.error, entity=self.entity) - setattr(newinstance, 'msg', self.msg) - setattr(newinstance, 'errors', self.errors) - setattr(newinstance, 'container', self.container) - - return newinstance + """ - def __init__(self, error=None, container=None, entity=None): - TransactionError.__init__(self, container=container) + def __init__(self, error=None, entity=None): + TransactionError.__init__(self) self.error = error + if hasattr(error, "code"): + self.code = error.code + else: + self.code = None self.entity = entity if error is not None and hasattr(error, "encode"): @@ -300,34 +295,17 @@ class EntityError(TransactionError): else: self.msg = str(error) - def get_entity(self): - ''' - @return: The entity that caused this error. - ''' - - if hasattr(self, 'entity'): - return self.entity - - return None - @property def description(self): + """The description of the error.""" return self.error.description if self.error is not None else None - def get_code(self): - return self.error.code if self.error is not None else None - - def get_error(self): - ''' - @return: Error Message object of this Error. - ''' - - return self.error - def _repr_head(self, indent): if hasattr(self, 'entity') and self.entity is not None: - return str(type(self.entity).__name__).upper() + " (" + str(self.entity.id) + (("," + "'" + str(self.entity.name) + "'") - if self.entity.name is not None else '') + ") CAUSED " + TransactionError._repr_head(self, indent) + return (str(type(self.entity).__name__).upper() + " (id: " + + str(self.entity.id) + ((", name: " + "'" + str(self.entity.name) + "'") if + self.entity.name is not None else '') + ") CAUSED " + + TransactionError._repr_head(self, indent)) else: return TransactionError._repr_head(self, indent) @@ -337,15 +315,19 @@ class UniqueNamesError(EntityError): class UnqualifiedParentsError(EntityError): - """This entity has unqualified parents (call 'get_errors()' for a list of - errors of the parent entities or 'get_entities()' for a list of parent - entities with errors).""" + """This entity has unqualified parents (see 'errors' attribute for a + list of errors of the parent entities or 'entities' attribute for + a list of parent entities with errors). + + """ class UnqualifiedPropertiesError(EntityError): - """This entity has unqualified properties (call 'get_errors()' for a list - of errors of the properties or 'get_entities()' for a list of properties - with errors).""" + """This entity has unqualified properties (see 'errors' attribute for + a list of errors of the properties or 'entities' attribute for a + list of properties with errors). + + """ class EntityDoesNotExistError(EntityError): @@ -357,11 +339,17 @@ class EntityHasNoDatatypeError(EntityError): class ConsistencyError(EntityError): - pass + """The transaction violates database consistency.""" -class AuthorizationException(EntityError): +class AuthorizationError(EntityError): """You are not allowed to do what ever you tried to do. - Maybe you need more privileges or a user account at all. + Maybe you need more privileges or a user account. + """ + + +class AmbiguousEntityError(EntityError): + """A retrieval of the entity was not possible because there is more + than one possible candidate. """ diff --git a/src/caosdb/utils/caosdb_admin.py b/src/caosdb/utils/caosdb_admin.py index 250c2878d5d615b0815bdd7b0bb287d1567fe085..392d8bea2ce3d9a868c32854800ca6cb78f021ba 100755 --- a/src/caosdb/utils/caosdb_admin.py +++ b/src/caosdb/utils/caosdb_admin.py @@ -33,7 +33,7 @@ from argparse import ArgumentParser, RawDescriptionHelpFormatter import caosdb as db from caosdb import administration as admin -from caosdb.exceptions import ClientErrorException +from caosdb.exceptions import HTTPClientError __all__ = [] __version__ = 0.3 @@ -73,7 +73,7 @@ def do_retrieve(args): c.append(db.Entity(id=eid)) except ValueError: c.append(db.Entity(name=i)) - c.retrieve() + c.retrieve(flags=eval(args.flags)) print(c) @@ -123,7 +123,7 @@ def do_create_user(args): try: admin._insert_user(name=args.user_name, email=args.user_email, password=password) - except ClientErrorException as e: + except HTTPClientError as e: print(e.msg) @@ -141,20 +141,20 @@ def do_set_user_password(args): def do_add_user_roles(args): - roles = admin._get_roles(user=args.user_name, realm=None) + roles = admin._get_roles(username=args.user_name, realm=None) for r in args.user_roles: roles.add(r) - admin._set_roles(user=args.user_name, roles=roles) + admin._set_roles(username=args.user_name, roles=roles) def do_remove_user_roles(args): - roles = admin._get_roles(user=args.user_name, realm=None) + roles = admin._get_roles(username=args.user_name, realm=None) for r in args.user_roles: if r in roles: roles.remove(r) - admin._set_roles(user=args.user_name, roles=roles) + admin._set_roles(username=args.user_name, roles=roles) def do_set_user_entity(args): @@ -178,7 +178,7 @@ def do_delete_user(args): def do_retrieve_user_roles(args): - print(admin._get_roles(user=args.user_name)) + print(admin._get_roles(username=args.user_name)) def do_retrieve_role_permissions(args): diff --git a/src/doc/Makefile b/src/doc/Makefile index 5458c5300efc82e55686bc1cd6934182c5c8e39a..64219c5957ee963e84f9305685f2ec4e8ed3d761 100644 --- a/src/doc/Makefile +++ b/src/doc/Makefile @@ -32,6 +32,7 @@ PY_BASEDIR = ../caosdb SOURCEDIR = . BUILDDIR = ../../build/doc + .PHONY: doc-help Makefile # Put it first so that "make" without argument is like "make help". @@ -44,4 +45,4 @@ doc-help: @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) apidoc: - @$(SPHINXAPIDOC) -o _apidoc $(PY_BASEDIR) + @$(SPHINXAPIDOC) -o _apidoc --separate $(PY_BASEDIR) diff --git a/src/doc/administration.rst b/src/doc/administration.rst new file mode 100644 index 0000000000000000000000000000000000000000..061acc8364d2ef62f743a20d7b9e6562baac0fc5 --- /dev/null +++ b/src/doc/administration.rst @@ -0,0 +1,14 @@ +Administration +============== + +The Python script ``caosdb_admin.py`` should be used for administrative tasks. +Call ``caosdb_admin.py --help`` to see how to use it. + +The most common task is to create a new user (in the CaosDB realm) and set a +password for the user (note that a user typically needs to be activated):: + + caosdb_admin.py create_user anna + caosdb_admin.py set_user_password anna + caosdb_admin.py add_user_roles anna administration + caosdb_admin.py activate_user anna + diff --git a/src/doc/conf.py b/src/doc/conf.py index 9e2924ae726e13aacd2f955ae1904b39ad73cbc3..b05fa1c71c1dcd0b59916594818449d2ebc574bd 100644 --- a/src/doc/conf.py +++ b/src/doc/conf.py @@ -8,16 +8,18 @@ # -- Path setup -------------------------------------------------------------- -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. +# If extensions (or modules to document with autodoc) are in another directory, add these +# directories to sys.path here. This is particularly necessary if this package is installed at a +# different version, for example via `pip install`. # -# import os -# import sys -# sys.path.insert(0, os.path.abspath('../caosdb')) - +# If the directory is relative to the documentation root, use os.path.abspath to make it absolute, +# like shown here. +# +import os +import sys +sys.path.insert(0, os.path.abspath('..')) -import sphinx_rtd_theme +import sphinx_rtd_theme # noqa: E402 # -- Project information ----------------------------------------------------- @@ -27,9 +29,10 @@ copyright = '2020, IndiScale GmbH' author = 'Daniel Hornung' # The short X.Y version -version = '0.4.0' +version = '0.5.2' # The full version, including alpha/beta/rc tags -release = '0.4.0-rc' +# release = '0.5.2-rc2' +release = '0.5.2' # -- General configuration --------------------------------------------------- @@ -43,6 +46,7 @@ release = '0.4.0-rc' # ones. extensions = [ 'sphinx.ext.autodoc', + 'sphinx.ext.autosectionlabel', 'sphinx.ext.intersphinx', 'sphinx.ext.napoleon', # For Google style docstrings "recommonmark", # For markdown files. @@ -54,7 +58,6 @@ templates_path = ['_templates'] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: -# source_suffix = ['.rst', '.md'] # The master toctree document. @@ -81,6 +84,7 @@ pygments_style = None # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # + html_theme = "sphinx_rtd_theme" # Theme options are theme-specific and customize the look and feel of a theme @@ -182,10 +186,24 @@ epub_exclude_files = ['search.html'] # -- Extension configuration ------------------------------------------------- -# -- Options for intersphinx extension --------------------------------------- +# True to prefix each section label with the name of the document it is in, followed by a colon. For +# example, index:Introduction for a section called Introduction that appears in document +# index.rst. Useful for avoiding ambiguity when the same section heading appears in different +# documents. +# +# Note: This stops "normal" links from working, so it should be kept at False. +# autosectionlabel_prefix_document = True + +# -- Options for intersphinx ------------------------------------------------- + +# https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html#confval-intersphinx_mapping +intersphinx_mapping = { + "python": ("https://docs.python.org/", None), + "caosdb-mysqlbackend": ("https://docs.indiscale.com/caosdb-mysqlbackend/", + None), + "caosdb-server": ("https://docs.indiscale.com/caosdb-server/", None), +} -# Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {'https://docs.python.org/': None} # TODO Which options do we want? autodoc_default_options = { diff --git a/src/doc/configuration.md b/src/doc/configuration.md index b2de2781d5adff4d59cb3648cd912e142327f676..6e53542f661dcae94622fef24a67cecf7491df9c 100644 --- a/src/doc/configuration.md +++ b/src/doc/configuration.md @@ -50,5 +50,5 @@ debugging (which I hope will not be necessary for this tutorial) or if you want the internals of the protocol. A complete list of options can be found in the -[pycaosdb.ini file](https://gitlab.com/caosdb/caosdb-pylib/-/blob/master/examples/pycaosdb.ini) in +[pycaosdb.ini file](https://gitlab.com/caosdb/caosdb-pylib/-/blob/main/examples/pycaosdb.ini) in the examples folder of the source code. diff --git a/src/doc/index.rst b/src/doc/index.rst index e8cc93aa398de36c30833bc97ca79ce20564daa6..bd29c6c56acf5c173e94ae6471a6aeba56ea4b93 100644 --- a/src/doc/index.rst +++ b/src/doc/index.rst @@ -10,13 +10,14 @@ Welcome to PyCaosDB's documentation! Getting started <README_SETUP> tutorials/index Concepts <concepts> - Configuration <configuration> - API documentation<_apidoc/modules> + Configuration <configuration> + Administration <administration> + API documentation<_apidoc/caosdb> This is the documentation for the Python client library for CaosDB, ``PyCaosDB``. -This documentation helps you to :doc:`get started<getting_started>`, explains the most important -:doc:`concepts<concepts>` and offers a range of :doc:`tutorials<tutorials>`. +This documentation helps you to :doc:`get started<README_SETUP>`, explains the most important +:doc:`concepts<concepts>` and offers a range of :doc:`tutorials<tutorials/index>`. Indices and tables diff --git a/src/doc/tutorials/Data-Insertion.rst b/src/doc/tutorials/Data-Insertion.rst new file mode 100644 index 0000000000000000000000000000000000000000..22fb9461d6916003b2dad496ff3487df335c8dcc --- /dev/null +++ b/src/doc/tutorials/Data-Insertion.rst @@ -0,0 +1,173 @@ +Data Insertion +============== + +Data Models +~~~~~~~~~~~ + +Data is stored and structured in CaosDB using a concept of RecordTypes, Properties, Records etc. If +you do not know what these are, please look at the chapter :doc:`Data +Model<caosdb-server:Data-Model>` in the CaosDB server documentation. + +In order to insert some actual data, we need to create a data model +using RecordTypes and Properties (You may skip this if you use a CaosDB +instance that already has the required types). So, let’s create a simple +Property called “a†of datatype double. This is very easy in pylib: + +.. code:: python + + a = db.Property(name="a", datatype=db.DOUBLE) + +There are a few basic datatypes: db.INTEGER, db.TEXT. See `data +type <Specification/Datatype>`__ for a full list. + +We can create our own small data model for e.g. a simulation by adding +two more Properties and a RecordType: + +.. code:: python + + b = db.Property(name="b", datatype=db.DOUBLE) + epsilon = db.Property(name="epsilon", datatype=db.DOUBLE) + recordtype = db.RecordType(name="BarkleySimulation") + recordtype.add_property(a) + recordtype.add_property(b) + recordtype.add_property(epsilon) + container = db.Container() + container.extend([a, b, epsilon, recordtype]) + container.insert() + +.. _tutorial-inheritance-properties: + +Inheritance of Properties +------------------------- + +Suppose you want to create a new RecordType “2D_BarkleySimulation†+that denotes spatially extended Barkley simulations. This is a subtype +of the “BarkleySimulation†RecordType above and should have all its +parameters, i.e., properties. It may be assigned more, e.g., spatial +resolution, but we'll omit this for the sake of brevity for now. + +.. code:: python + + rt = db.RecordType(name="2D_BarkleySimulation", + description="Spatially extended Barkley simulation") + # inherit all properties from the BarkleySimulation RecordType + rt.add_parent(name="BarkleySimulation", inheritance="all") + rt.insert() + + print(rt.get_property(name="epsilon").importance) ### rt has a "epsilon" property with the same importance as "BarkleySimulation" + +The parameter ``inheritance=(obligatory|recommended|fix|all|none)`` of +:py:meth:`Entity.add_parent()<caosdb.common.models.Entity.add_parent>` tells the server to assign +all properties of the parent RecordType with the chosen importance (and properties with a higher +importance) to the child RecordType +automatically upon insertion. See the chapter on `importance +<https://docs.indiscale.com/caosdb-server/specification/RecordType.html#importance>`_ in the +documentation of the CaosDB server for more information on the importance and inheritance of +properties. + +.. note:: + + The inherited properties will only be visible after the insertion since they are set by the + CaosDB server, not by the Python client. + + +Insert Actual Data +~~~~~~~~~~~~~~~~~~ + +Suppose the RecordType “Experiment†and the Property “date†exist in the +database. You can then create single data Records by using the +corresponding python class: + +.. code:: python + + rec = db.Record() + rec.add_parent(name="Experiment") + rec.add_property(name="date", value="2020-01-07") + rec.insert() + +Here, the record has a parent: The RecordType “Experimentâ€. And a +Property: date. + +Note, that if you want to use a property that is not a primitive +datatype like db.INTEGER and so on, you need to use the ID of the Entity +that you are referencing. + +.. code:: python + + rec = db.Record() + rec.add_parent(name="Experiment") + rec.add_property(name="report", value=235507) + rec.add_property(name="Analysis", value=230007) + rec.insert() + +Of course, the IDs 235507 and 230007 need to exist in CaosDB. The first +example shows how to use a db.REFERENCE Property (report) and the second +shows that you can use any RecordType as Property to reference a Record +that has such a parent. + +Most Records do not have name however it can absolutely make sense. In +that case use the name argument when creating it. Another useful feature +is the fact that properties can have units: + +.. code:: python + + rec = db.Record("DeviceNo-AB110") + rec.add_parent(name="SlicingMachine") + rec.add_property(name="weight", value="1749", unit="kg") + rec.insert() + +If you are in some kind of analysis you can do this in batch mode with a +container. E.g. if you have a python list ``analysis_results``: + +.. code:: python + + cont = db.Container() + for date, result in analysis_results: + rec = db.Record() + rec.add_parent(name="Experiment") + rec.add_property(name="date", value=date) + rec.add_property(name="result", value=result) + cont.append(rec) + + cont.insert() + +Useful is also, that you can insert directly tabular data. + +.. code:: python + + from caosadvancedtools.table_converter import from_tsv + + recs = from_tsv("test.csv", "Experiment") + print(recs) + recs.insert() + +With this example file +`test.csv <uploads/4f2c8756a26a3984c0af09d206d583e5/test.csv>`__. + + +File Update +----------- + +Updating an existing file by uploading a new version. + +1. Retrieve the file record of interest, e.g. by ID: + +.. code:: python + + import caosdb as db + + file_upd = db.File(id=174).retrieve() + +2. Set the new local file path. The remote file path is stored in the + file object as ``file_upd.path`` while the local path can be found in + ``file_upd.file``. + +.. code:: python + + file_upd.file = "./supplements.pdf" + +3. Update the file: + +.. code:: python + + file_upd.update() diff --git a/src/doc/tutorials/data-model-interface.md b/src/doc/tutorials/data-model-interface.md new file mode 100644 index 0000000000000000000000000000000000000000..f6967c57a0a3de6e7c6fd3d2b64d3f59620526de --- /dev/null +++ b/src/doc/tutorials/data-model-interface.md @@ -0,0 +1,36 @@ +# Data Models + + + +You also want to change the datamodel? Also call +```bash +pip3 install --user --no-deps . +``` +in +```bash +CaosDB/data_models +``` + +Change to the appropriate directory +```bash +cd CaosDB/data_models +``` +There are "data models" defined in +```bash +caosdb_models +``` +having an ending like "_model.py" +A set of data models is also considered to be a model +You can create an UML representation of a model or a set of models by calling +```bash +./model_interface.py -u model_name [model_name2] +``` +If you have troubles look at +```bash +./model_interface.py -h +``` +You can change existing models (but be careful! I hope you know what you are doing) or add new ones by changing the appropriate files or adding a new XXXX_model.py +Once you are done, you can sync your changes with the server +```bash +./model_interface.py -s model_name [model_name2] +``` diff --git a/src/doc/tutorials/errors.rst b/src/doc/tutorials/errors.rst new file mode 100644 index 0000000000000000000000000000000000000000..37c53c9b527a0435f9f24ae6c6e71687e73eb963 --- /dev/null +++ b/src/doc/tutorials/errors.rst @@ -0,0 +1,176 @@ + +Error Handling +============== + +In case of erroneous transactions, connection problems and a lot of +other cases, PyCaosDB may raise specific errors in order to pinpoint +the problem as precisely as possible. Some of these errors a +representations of errors in the CaosDB server, others stem from +problems that occurred on the client side. + +The errors and exceptions are ordered hierarchically form the most +general exceptions to specific transaction or connection problems. The +most important error types and the hierarchy will be explained in the +following. For more information on specific error types, see also the +:doc:`source code<../_apidoc/caosdb.exceptions>`. + +.. note:: + + Starting from PyCaosDB 0.5, the error handling has changed + significantly. New error classes have been introduced and the + behavior of ``TransactionError`` and ``EntityError`` has been + re-worked. In the following, only the "new" errors are + discussed. Please refer to the documentation of PyCaosDB 0.4.1 and + earlier for the old error handling. + +CaosDBException +---------------- + +``CaosDBException`` is the most generic exception and all other error classes inherit +from this one. Because of its generality, it doesn't tell you much +except that some component of PyCaosDB raised an exception. If you +want to catch all possible CaosDB errors, this is the class to use. + +TransactionError +---------------- + +Every transaction (calling ``insert``, ``update``, ``retrieve``, or +``delete`` on a container or an entity) may finish with errors. They +indicate, for instance, that an entity does not exist or that you need +to specify a data type for your property and much more. If and only if +one or more errors occur during a transaction a ``TransactionError`` +will be raised by the transaction method. The ``TransactionError`` +class is a container for all errors which occur during a +transaction. It usually contains one or more :ref:`entity +errors<EntityError>` which you can inspect in order to learn why the +transaction failed. For this inspection, there are some helpful +attributes and methods provided by the ``TransactionError``: + +* ``entities``: a list of all entities that directly caused at least one error + in this transaction. + +* ``errors``: a list of all ``EntityError`` objects that directly caused the + transaction to fail. + +* ``all_entities``, ``all_errors``: sets of all entities and errors + that, directly or indirectly, caused either this ``TransactionError`` or any of the + ``EntityError`` objects it contains. + +* ``has_error(error_t)``: Check whether an error of type ``error_t`` + occurred during the transaction. + +Additionally, ``print(transaction_error)`` prints a tree-like +representation of all errors regarding the transaction in question. + +EntityError +----------- + +An ``EntityError`` specifies the entity and the error proper that +caused a transaction to fail. It is never raised on its own but is +contained in a ``TransactionError`` (which may or may not contain +other ``EntityError`` objects) which is then raised. ``EntityError`` +has several :ref:`subclasses<Special Errors>` that further specify the +error that occurred. + +The ``EntityError`` class is in fact a subclass of +``TransactionError``. Thus, it has the same methods and attributes as +the ``TransactionError`` explained +:ref:`above<TransactionError>`. This is important in case of an +``EntityError`` that was caused by other faulty entities (e.g., broken +parents or properties). In that case these problematic entities and +errors can again be inspected by visiting the ``entities`` and +``errors`` lists as above. + +Special Errors +~~~~~~~~~~~~~~ + +Subclasses of ``EntityError`` for special purposes: + +* ``EntityDoesNotExistError`` + +* ``EntityHasNoDataTypeError`` + +* ``UniqueNamesError`` + +* ``UnqualifiedParentsError`` + +* ``UnqualifiedPropertiesError`` + +* ``ConsistencyError`` + +* ``AuthorizationError`` + +* ``AmbiguousEntityError`` + +BadQueryError +------------- + +A ``BadQueryError`` is raised when a query could not be processed by +the server. In contrast to a ``TransactionError`` it is not +necessarily caused by problematic entities or +containers. ``BadQueryError`` has the two important subclasses +``EmptyUniqueQueryError`` and ``QueryNotUniqueError`` for queries with +``unique=True`` which found no or ambiguous entities, respectively. + +HTTP Errors +----------- + +An ``HTTPClientError`` or an ``HTTPServerError`` is raised in case of +http(s) connection problems caused by the Python client or the CaosDB +server, respectively. There are the following subclasses of +``HTTPClientError`` that are used to specify the connection problem: + +* ``HTTPURITooLongError``: The URI of the request was too long to be + processed by the server. + +* ``HTTPForbiddenError``: You're not allowed to access this resource. + +* ``HTTPResourceNotFoundError``: The requested resource doesn't exist. + +Other Errors +------------ + +There are further subclasses of ``CaosDBException`` that are raised in +case of faulty configurations or other problems. They should be rather +self-explanatory from their names; see the :doc:`source code<../_apidoc/caosdb.exceptions>` +for further information. + +* ``ConfigurationError`` + +* ``LoginFailedError`` + +* ``MismatchingEntitiesError`` + +* ``ServerConfigurationException`` + +Examples +-------- + +.. code-block:: python3 + + import caosdb as db + + def link_and_insert(entity, linked, link=True): + """Link the ENTITY to LINKED and insert it.""" + if link: + entity.add_property(db.Property(name="link", value=linked)) + try: + entity.insert() + except db.TransactionError as tre: + # Unique names problem may be worked around by using another name + if tre.has_error(db.UniqueNamesError): + for ent_error in tre.errors: + if (isinstance(ent_error, db.UniqueNamesError) + and entity in ent_error.entities): + entity.name = entity.name + "_new" # Try again with new name. + link_and_insert(entity, linked, link=False) + break + # Unqualified properties will be handled by the caller + elif tre.has_error(db.UnqualifiedPropertiesError): + for ent_error in tre.errors: + if (isinstance(ent_error, db.UnqualifiedPropertiesError_ + and entity in ent_error.entities): + raise RuntimeError("One of the properties was unqualified: " + str(ent_error)) + # Other problems are not covered by this tutorial + else: + raise NotImplementedError("Unhandled TransactionError: " + str(tre)) diff --git a/src/doc/tutorials/first_steps.rst b/src/doc/tutorials/first_steps.rst index 3da05b05c4d4025f6d81490550fd1482ae147fb4..34b96bbeca416107fb34feb4707b9ef46fc49fe7 100644 --- a/src/doc/tutorials/first_steps.rst +++ b/src/doc/tutorials/first_steps.rst @@ -2,14 +2,15 @@ First Steps =========== You should have a working connection to a CaosDB instance now. If not, please check out the -:doc:`Getting Started secton</getting_started>`. +:doc:`Getting Started secton</README_SETUP>`. If you are not yet familiar with Records, RecordTypes and Properties used in CaosDB, -please check out the respective part in the `Web Interface Tutorial`_. -You should also know the basics of the CaosDB Query Language (a tutorial is here_). +please check out the respective part in the `Web Interface tutorial`_. +You should also know the basics of the CaosDB Query Language (a tutorial is +`here <https://docs.indiscale.com/caosdb-webui/tutorials/query.html>`_). -We recommend that you connect to the demo instance in order to try out the following -examples. You can do this with +We recommend that you connect to the `demo instance`_ (hosted by `Indiscale`_) in order to try out +the following examples. You can do this with >>> import caosdb as db >>> _ = db.configure_connection( @@ -19,7 +20,7 @@ examples. You can do this with ... password="caosdb") or by using corresponding settings in the configuration file -(see :doc:`Getting Started secton</getting_started>`.). +(see :doc:`Getting Started secton</README_SETUP>`.). However, you can also translate the examples to the data model that you have at hand. Let's start with a simple query. @@ -113,6 +114,8 @@ You can download files (if the LinkAhead server has access to them) The file will be saved under target_path. If the files are large data files, it is often a better idea to only retrieve the path of the file and access them via a local mount. + + Summary ------- @@ -122,9 +125,6 @@ the result Records and their properties. The next tutorial shows how to make some meaningful use of this. - -.. _here: https://gitlabio.something .. _`demo instance`: https://demo.indiscale.com .. _`IndiScale`: https://indiscale.com -.. _`Web Interface Tutorial`: https://caosdb.gitlab.io/caosdb-webui/tutorials/model.html -.. _here: https://caosdb.gitlab.io/caosdb-webui/tutorials/cql.html +.. _`Web Interface tutorial`: https://docs.indiscale.com/caosdb-webui/tutorials/first_steps.html diff --git a/src/doc/tutorials/index.rst b/src/doc/tutorials/index.rst index 311f7080045e8925dd26eb03c2da3b12b1dba6e4..3889edb8f47e973cc7ae25c9134d75cfeab95f65 100644 --- a/src/doc/tutorials/index.rst +++ b/src/doc/tutorials/index.rst @@ -12,4 +12,7 @@ advanced usage of the Python client. first_steps basic_analysis + Data-Insertion + errors + data-model-interface diff --git a/unittests/docker/Dockerfile b/unittests/docker/Dockerfile index e41fb91b3e9ae54cff277519c03f481c21264e9e..7fa3f75bd198724628dee48ab328829fa071a639 100644 --- a/unittests/docker/Dockerfile +++ b/unittests/docker/Dockerfile @@ -5,6 +5,6 @@ RUN apt-get update && \ curl pycodestyle \ python3-sphinx ARG COMMIT="dev" -RUN git clone -b dev https://gitlab.com/caosdb/caosdb-pylib.git && \ +RUN git clone -b dev https://gitlab.indiscale.com/caosdb/src/caosdb-pylib.git && \ cd caosdb-pylib && git checkout $COMMIT && pip3 install . RUN pip3 install recommonmark sphinx-rtd-theme diff --git a/unittests/test_authentication_auth_token.py b/unittests/test_authentication_auth_token.py index 1eaf091863e23205c9ffca5373e51b654a5a42e4..15e54121fc0d7b5c2be645cdb88bc20804a10980 100644 --- a/unittests/test_authentication_auth_token.py +++ b/unittests/test_authentication_auth_token.py @@ -32,7 +32,7 @@ from unittest.mock import Mock from caosdb.connection.authentication import auth_token as at from caosdb.connection.mockup import MockUpServerConnection, MockUpResponse from caosdb.connection.utils import parse_auth_token -from caosdb.exceptions import LoginFailedException +from caosdb.exceptions import LoginFailedError from caosdb import configure_connection @@ -73,7 +73,7 @@ def test_login_raises(): c = configure_connection(url="https://example.com", password_method="auth_token", auth_token="[auth_token]") - with raises(LoginFailedException): + with raises(LoginFailedError): c._login() diff --git a/unittests/test_authentication_unauthenticated.py b/unittests/test_authentication_unauthenticated.py index 9ea864a9999c5e3a74fa22fd3f6942c4e5806256..52146b08ed4e1026660eebacedf348aeb2ff2721 100644 --- a/unittests/test_authentication_unauthenticated.py +++ b/unittests/test_authentication_unauthenticated.py @@ -32,7 +32,7 @@ from unittest.mock import Mock from caosdb.connection.authentication import unauthenticated from caosdb.connection.mockup import MockUpServerConnection, MockUpResponse from caosdb.connection.utils import parse_auth_token -from caosdb.exceptions import LoginFailedException +from caosdb.exceptions import LoginFailedError from caosdb import configure_connection from .test_authentication_auth_token import response_with_auth_token @@ -67,5 +67,5 @@ def test_configure_connection(): def test_login_raises(): c = configure_connection(url="https://example.com", password_method="unauthenticated") - with raises(LoginFailedException): + with raises(LoginFailedError): c._login() diff --git a/unittests/test_configuration.py b/unittests/test_configuration.py index 76445b6f262120d6a29c73527a9bf042f85f8a05..b135e7cd65b11be7cb6c4ef2237a41a6639ccbb7 100644 --- a/unittests/test_configuration.py +++ b/unittests/test_configuration.py @@ -22,19 +22,45 @@ # ** end header # +import pytest import caosdb as db +from os import environ, getcwd, remove +from os.path import expanduser, isfile, join from pytest import raises -def test_config_ini_via_envvar(): - from os import environ - from os.path import expanduser +@pytest.fixture +def temp_ini_files(): + created_temp_ini_cwd = False + created_temp_ini_home = False + if not isfile(join(getcwd(), "pycaosdb.ini")): + open("pycaosdb.ini", 'a').close() # create temporary ini file + created_temp_ini_cwd = True + if not isfile(expanduser("~/.pycaosdb.ini")): + open(expanduser("~/.pycaosdb.ini"), 'a').close() # create temporary ini file in home directory + created_temp_ini_home = True + yield 0 + if created_temp_ini_cwd: + remove("pycaosdb.ini") + if created_temp_ini_home: + remove(expanduser("~/.pycaosdb.ini")) + environ["PYCAOSDBINI"] = "~/.pycaosdb.ini" + + +def test_config_ini_via_envvar(temp_ini_files): with raises(KeyError): environ["PYCAOSDBINI"] environ["PYCAOSDBINI"] = "bla bla" assert environ["PYCAOSDBINI"] == "bla bla" - assert db.configuration.configure(environ["PYCAOSDBINI"]) == [] + # test wrong configuration file in envvar + assert not expanduser(environ["PYCAOSDBINI"]) in db.configuration._read_config_files() + # test good configuration file in envvar environ["PYCAOSDBINI"] = "~/.pycaosdb.ini" - assert db.configuration.configure(expanduser(environ["PYCAOSDBINI"])) == [expanduser("~/.pycaosdb.ini")] + assert expanduser("~/.pycaosdb.ini") in db.configuration._read_config_files() + # test without envvar + environ.pop("PYCAOSDBINI") + assert expanduser("~/.pycaosdb.ini") in db.configuration._read_config_files() + # test configuration file in cwd + assert join(getcwd(), "pycaosdb.ini") in db.configuration._read_config_files() diff --git a/unittests/test_connection.py b/unittests/test_connection.py index 1dfd1fce730d4d4ba627eb4c27830b0c5156fb29..16370f00b7d5e3389582befaac1762b1d2992fcf 100644 --- a/unittests/test_connection.py +++ b/unittests/test_connection.py @@ -37,7 +37,7 @@ from caosdb.connection.connection import (CaosDBServerConnection, from caosdb.connection.mockup import (MockUpResponse, MockUpServerConnection, _request_log_message) from caosdb.connection.utils import make_uri_path, quote, urlencode -from caosdb.exceptions import ConfigurationException, LoginFailedException +from caosdb.exceptions import ConfigurationError, LoginFailedError from nose.tools import assert_equal as eq from nose.tools import assert_false as falz from nose.tools import assert_is_not_none as there @@ -234,7 +234,7 @@ def test_test_request_with_two_responses(): def test_missing_implementation(): connection = configure_connection() - with raises(ConfigurationException) as exc_info: + with raises(ConfigurationError) as exc_info: connection.configure() assert exc_info.value.args[0].startswith( "Missing CaosDBServerConnection implementation.") @@ -242,27 +242,26 @@ def test_missing_implementation(): def test_bad_implementation_not_callable(): connection = configure_connection() - with raises(ConfigurationException) as exc_info: + with raises(ConfigurationError) as exc_info: connection.configure(implementation=None) assert exc_info.value.args[0].startswith( "Bad CaosDBServerConnection implementation.") - assert exc_info.value.args[1].args[0] == "'NoneType' object is not callable" + assert "'NoneType' object is not callable" in exc_info.value.args[0] def test_bad_implementation_wrong_class(): connection = configure_connection() - with raises(ConfigurationException) as exc_info: + with raises(ConfigurationError) as exc_info: connection.configure(implementation=dict) assert exc_info.value.args[0].startswith( "Bad CaosDBServerConnection implementation.") - assert exc_info.value.args[1].args[0] == ( - "The `implementation` callable did not return an instance of " - "CaosDBServerConnection.") + assert ("The `implementation` callable did not return an instance of " + "CaosDBServerConnection.") in exc_info.value.args[0] def test_missing_auth_method(): connection = configure_connection() - with raises(ConfigurationException) as exc_info: + with raises(ConfigurationError) as exc_info: connection.configure(implementation=MockUpServerConnection) assert exc_info.value.args[0].startswith("Missing password_method.") @@ -272,11 +271,12 @@ def test_missing_password(): connection.configure(implementation=setup_two_resources, password_method="plain") connection._authenticator.auth_token = "[test-auth-token]" - assert connection.retrieve(["some"]).headers["Cookie"] == "SessionToken=%5Btest-auth-token%5D;" + assert connection.retrieve( + ["some"]).headers["Cookie"] == "SessionToken=%5Btest-auth-token%5D;" connection.configure(implementation=setup_two_resources, password_method="plain") - with raises(LoginFailedException): + with raises(LoginFailedError): connection.delete(["401"]) @@ -284,11 +284,13 @@ def test_auth_token_connection(): connection = configure_connection(auth_token="blablabla", password_method="auth_token", implementation=setup_two_resources) - connection.retrieve(["some"]).headers["Cookie"] == "SessionToken=blablabla;" + connection.retrieve( + ["some"]).headers["Cookie"] == "SessionToken=blablabla;" connection._logout() - with raises(LoginFailedException) as cm: - connection.retrieve(["some"]).headers["Cookie"] == "SessionToken=blablabla;" + with raises(LoginFailedError) as cm: + connection.retrieve( + ["some"]).headers["Cookie"] == "SessionToken=blablabla;" assert cm.value.args[0] == ("The authentication token is expired or you " "have been logged out otherwise. The " "auth_token authenticator cannot log in " diff --git a/unittests/test_connection_utils.py b/unittests/test_connection_utils.py index c21b453e3a7588f30d86be8d2ed39bb4d7a1d31e..3890ae05cfe38b78a5ba0829753420246bdb560d 100644 --- a/unittests/test_connection_utils.py +++ b/unittests/test_connection_utils.py @@ -28,7 +28,7 @@ from pytest import raises from nose.tools import (assert_equal as eq, assert_raises as raiz, assert_true as tru, assert_is_not_none as there, assert_false as falz) -from caosdb.exceptions import ConfigurationException, LoginFailedException +from caosdb.exceptions import ConfigurationError, LoginFailedError from caosdb.connection.utils import parse_auth_token, auth_token_to_cookie from caosdb.connection.connection import ( configure_connection, CaosDBServerConnection, @@ -45,8 +45,10 @@ def setup_module(): def test_parse_auth_token(): - assert parse_auth_token("SessionToken=%5Bblablabla%5D; expires=bla; ...") == "[blablabla]" + assert parse_auth_token( + "SessionToken=%5Bblablabla%5D; expires=bla; ...") == "[blablabla]" def test_auth_token_to_cookie(): - assert auth_token_to_cookie("[blablabla]") == "SessionToken=%5Bblablabla%5D;" + assert auth_token_to_cookie( + "[blablabla]") == "SessionToken=%5Bblablabla%5D;" diff --git a/unittests/test_error_handling.py b/unittests/test_error_handling.py new file mode 100644 index 0000000000000000000000000000000000000000..7f974e7db826d093e335b250953658b08db062cd --- /dev/null +++ b/unittests/test_error_handling.py @@ -0,0 +1,317 @@ +# -*- encoding: utf-8 -*- +# +# ** header v3.0 +# This file is a part of the CaosDB Project. +# +# Copyright (C) 2020 Indiscale GmbH <info@indiscale.com> +# Copyright (C) 2020 Florian Spreckelsen <f.spreckelsen@indiscale.com> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. +# +# ** end header +# +"""Test the new (as of June 2020) error handling. All errors should +be TransactionErrors at first which may have one or more level of +children. + +""" +import caosdb as db +from caosdb.common.models import raise_errors +from caosdb.exceptions import (AuthorizationError, + EntityDoesNotExistError, EntityError, + EntityHasNoDatatypeError, + TransactionError, UniqueNamesError, + UnqualifiedParentsError, + UnqualifiedPropertiesError) + +from pytest import raises + + +# #################### Single Error Tests #################### + + +def _add_error_message_to_entity(entity, code, description="Error message"): + """Attach error message with code and description to entity""" + message = db.Message(type="Error", code=code, + description=description) + entity.add_message(message) + + return entity + + +def test_has_no_datatype_error(): + """Code 110; property without datatype""" + code = 110 + prop = _add_error_message_to_entity(db.Property(name="TestProp"), + code) + with raises(TransactionError) as e: + raise_errors(prop) + # There should be exactly one child + assert len(e.value.errors) == 1 + err = e.value.errors[0] + # check type and entity of only child + assert isinstance(err, EntityHasNoDatatypeError) + assert err.entity.name == prop.name + + +def test_entity_does_not_exist_error(): + """Code 101; entity does not exist""" + code = 101 + ent = _add_error_message_to_entity(db.Entity(name="TestEnt"), + code) + with raises(TransactionError) as e: + raise_errors(ent) + # There should be exactly one child + assert len(e.value.errors) == 1 + err = e.value.errors[0] + # check type and entity of only child + assert isinstance(err, EntityDoesNotExistError) + assert err.entity.name == ent.name + + +def test_entity_error(): + """Code 0; most basic.""" + code = 0 + ent = _add_error_message_to_entity(db.Entity(name="TestEnt"), + code) + with raises(TransactionError) as e: + raise_errors(ent) + assert len(e.value.errors) == 1 + err = e.value.errors[0] + assert isinstance(err, EntityError) + assert err.entity.name == ent.name + + +def test_unique_names_error(): + """Code 152; name is not unique""" + code = 152 + ent = _add_error_message_to_entity(db.Entity(name="TestEnt"), + code) + with raises(TransactionError) as e: + raise_errors(ent) + assert len(e.value.errors) == 1 + err = e.value.errors[0] + assert isinstance(err, UniqueNamesError) + assert err.entity.name == ent.name + + +def test_authorization_exception(): + """Code 403; transaction not allowed""" + code = 403 + ent = _add_error_message_to_entity(db.Entity(name="TestEnt"), + code) + with raises(TransactionError) as e: + raise_errors(ent) + assert len(e.value.errors) == 1 + err = e.value.errors[0] + assert isinstance(err, AuthorizationError) + assert err.entity.name == ent.name + + +def test_empty_container_with_error(): + """Has to raise an error, even though container is empty.""" + code = 0 + cont = _add_error_message_to_entity(db.Container(), code) + with raises(TransactionError) as e: + raise_errors(cont) + # No entity errors + assert len(e.value.errors) == 0 + assert e.value.container == cont + assert int(e.value.code) == code + + +def test_faulty_container_with_healthy_entities(): + """Raises a TransactionError without any EntityErrors since only the + container, but none of its entities has an error. + + """ + code = 0 + cont = _add_error_message_to_entity(db.Container(), code) + cont.append(db.Entity("TestHealthyEnt1")) + cont.append(db.Entity("TestHealthyEnt2")) + with raises(TransactionError) as e: + raise_errors(cont) + # No entity errors + assert len(e.value.errors) == 0 + assert len(e.value.entities) == 0 + assert e.value.container == cont + assert int(e.value.code) == code + + +# #################### Children with children #################### + + +def test_unqualified_parents_error(): + """Code 116; parent does not exist""" + code = 116 + entity_does_not_exist_code = 101 + parent = _add_error_message_to_entity( + db.RecordType(name="TestParent"), + entity_does_not_exist_code) + rec = _add_error_message_to_entity(db.Record(name="TestRecord"), + code) + rec.add_parent(parent) + with raises(TransactionError) as e: + raise_errors(rec) + te = e.value + # One direct child, two errors in total + assert len(te.errors) == 1 + assert len(te.all_errors) == 2 + # UnqualifiedParentsError in Record ... + assert isinstance(te.errors[0], UnqualifiedParentsError) + assert te.errors[0].entity.name == rec.name + # ... caused by non-existing parent + assert isinstance(te.errors[0].errors[0], EntityDoesNotExistError) + assert te.errors[0].errors[0].entity.name == parent.name + + +def test_unqualified_properties_error(): + """Code 114; properties do not exist or have wrong data types or + values. + + """ + code = 114 + entity_code = 0 + no_entity_code = 101 + prop1 = _add_error_message_to_entity(db.Property( + name="TestProp1"), entity_code) + prop2 = _add_error_message_to_entity(db.Property( + name="TestProp2"), no_entity_code) + rec = _add_error_message_to_entity(db.Record(name="TestRecord"), + code) + rec.add_property(prop1).add_property(prop2) + with raises(TransactionError) as e: + raise_errors(rec) + te = e.value + assert len(te.errors) == 1 + upe = te.errors[0] + assert upe.entity.name == rec.name + assert len(upe.errors) == 2 + for error_t in [UnqualifiedPropertiesError, EntityError, + EntityDoesNotExistError]: + assert any([isinstance(x, error_t) for x in te.all_errors]) + assert upe.code == code + + +# #################### Multiple errors #################### + +def test_parent_and_properties_errors(): + """Record with UnqualifiedParentsError and UnqualifiedPropertiesError, + and corresponding parent and properties with their errors as + above. Test whether all levels are in order. + + """ + prop_code = 114 + parent_code = 116 + entity_code = 0 + no_entity_code = 101 + parent = _add_error_message_to_entity( + db.RecordType(name="TestParent"), no_entity_code) + prop1 = _add_error_message_to_entity(db.Property( + name="TestProp1"), entity_code) + prop2 = _add_error_message_to_entity(db.Property( + name="TestProp2"), no_entity_code) + rec = _add_error_message_to_entity(db.Record(name="TestRecord"), + prop_code) + rec = _add_error_message_to_entity(rec, parent_code) + rec.add_parent(parent) + rec.add_property(prop1).add_property(prop2) + with raises(TransactionError) as e: + raise_errors(rec) + # Now there should be two direct children; both have to be + # displayed correctly. + te = e.value + # exactly two children: + assert len(te.errors) == 2 + # both have to have the right codes and entities + found_parent = False + found_prop = False + for err in te.errors: + if err.code == parent_code: + found_parent = True + assert err.errors[0].entity.name == parent.name + assert prop1.name not in [x.name for x in + err.all_entities] + assert prop2.name not in [x.name for x in + err.all_entities] + elif err.code == prop_code: + found_prop = True + assert parent.name not in [x.name for x in + err.all_entities] + for sub_err in err.errors: + if sub_err.code == entity_code: + assert sub_err.entity.name == prop1.name + elif sub_err.code == no_entity_code: + assert sub_err.entity.name == prop2.name + assert found_parent + assert found_prop + + +def test_container_with_faulty_elements(): + """Code 12; container with valid and invalid entities. All faulty + entities have to be reflected correctly in the errors list of the + TransactionError raised by the container. + + """ + container_code = 12 + prop_code = 114 + parent_code = 116 + name_code = 152 + auth_code = 403 + entity_code = 0 + no_entity_code = 101 + # Broken parents and properties + parent = _add_error_message_to_entity( + db.RecordType(name="TestParent"), no_entity_code) + prop1 = _add_error_message_to_entity(db.Property( + name="TestProp1"), entity_code) + prop2 = _add_error_message_to_entity(db.Property( + name="TestProp2"), no_entity_code) + cont = _add_error_message_to_entity(db.Container(), + container_code) + # healthy record and property + good_rec = db.Record(name="TestRecord1") + good_prop = db.Property(name="TestProp3") + cont.extend([good_rec, good_prop]) + # broken records with single and multiole errors + rec_name = _add_error_message_to_entity(db.Record(name="TestRecord2"), + code=name_code) + rec_auth = _add_error_message_to_entity(db.Record(name="TestRecord3"), + code=auth_code) + rec_par_prop = _add_error_message_to_entity( + db.Record(name="TestRecord"), prop_code) + rec_par_prop = _add_error_message_to_entity(rec_par_prop, parent_code) + rec_par_prop.add_parent(parent) + rec_par_prop.add_property(prop1).add_property(prop2) + cont.extend([rec_name, rec_auth, rec_par_prop]) + with raises(TransactionError) as e: + raise_errors(cont) + te = e.value + assert te.container == cont + assert te.code == container_code + # no healthy entity caused an error + for good in [good_rec, good_prop]: + assert good not in te.all_entities + # all records that caused problems + assert {rec_name, rec_auth, rec_par_prop}.issubset(te.all_entities) + # the container error contains the errors caused by the records + for err in te.errors: + if err.entity.name == rec_name.name: + assert isinstance(err, UniqueNamesError) + elif err.entity.name == rec_auth.name: + assert isinstance(err, AuthorizationError) + elif err.entity.name == rec_par_prop.name: + # record raises both of them + assert (isinstance(err, UnqualifiedParentsError) or + isinstance(err, UnqualifiedPropertiesError)) diff --git a/unittests/test_property.py b/unittests/test_property.py index 8b2deeb4b06cfbf3d42881201307a1e2122124e3..752ee01f0eafef14dbffd1e62c99d1c816c45d05 100644 --- a/unittests/test_property.py +++ b/unittests/test_property.py @@ -84,3 +84,8 @@ def test_get_property_with_entity(): p = Property(id=1234) r.add_property(id=1234, value="bla") assert r.get_property(p).value == "bla" + + +def test_selected_reference_list(): + assert len(testrecord.get_property("Conductor").value) == 1 + assert isinstance(testrecord.get_property("Conductor").value[0], Entity) diff --git a/unittests/test_query.py b/unittests/test_query.py new file mode 100644 index 0000000000000000000000000000000000000000..12622ea486dda717ca1fbc1255510575c5e0c8e6 --- /dev/null +++ b/unittests/test_query.py @@ -0,0 +1,48 @@ +# -*- encoding: utf-8 -*- +# +# ** header v3.0 +# This file is a part of the CaosDB Project. +# +# Copyright (C) 2021 Indiscale GmbH <info@indiscale.com> +# Copyright (C) 2021 Timm Fitschen <f.fitschen@indiscale.com> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. +# +# ** end header +# +from lxml import etree +import caosdb as db + + +def test_query_parsing(): + s = '<Query string="FIND bla" results="0" cached="true" etag="asdf"/>' + q = db.Query(etree.fromstring(s)) + assert q.q == "FIND bla" + assert q.results == 0 + assert q.cached is True + assert q.etag == "asdf" + + s = '<Query string="COUNT bla" results="1" cached="false" etag="asdf"/>' + q = db.Query(etree.fromstring(s)) + assert q.q == "COUNT bla" + assert q.results == 1 + assert q.cached is False + assert q.etag == "asdf" + + s = '<Query string="COUNT blub" results="4"/>' + q = db.Query(etree.fromstring(s)) + assert q.q == "COUNT blub" + assert q.results == 4 + assert q.cached is False + assert q.etag is None diff --git a/unittests/test_record.xml b/unittests/test_record.xml index e961bdc6b88eb8e62b92696b52d7ad6a2dbf8089..018c747c11027a2c3996c65d1deab1d18514e17b 100644 --- a/unittests/test_record.xml +++ b/unittests/test_record.xml @@ -328,4 +328,10 @@ <Value>45531</Value> <Value>45532</Value> </Property> + <Property datatype="LIST<Person>" description="DESCRIBE ME!" id="1634561234" importance="FIX" name="Conductor"> + <Value> + <Record id="23456543"> + </Record> + </Value> + </Property> </Record> diff --git a/unittests/test_state.py b/unittests/test_state.py new file mode 100644 index 0000000000000000000000000000000000000000..202c7a02af3db28434406626e5164def46febed7 --- /dev/null +++ b/unittests/test_state.py @@ -0,0 +1,77 @@ +import pytest +import caosdb as db +from caosdb import State, Transition +from caosdb.common.models import parse_xml, ACL +from lxml import etree + + +def test_state_xml(): + state = State(model="model1", name="state1") + xml = etree.tostring(state.to_xml()) + + assert xml == b'<State name="state1" model="model1"/>' + state = State.from_xml(etree.fromstring(xml)) + assert state.name == "state1" + assert state.model == "model1" + + assert xml == etree.tostring(state.to_xml()) + + +def test_entity_xml(): + r = db.Record() + assert r.state is None + r.state = State(model="model1", name="state1") + + xml = etree.tostring(r.to_xml()) + assert xml == b'<Record><State name="state1" model="model1"/></Record>' + + r = parse_xml(xml) + assert r.state == State(model="model1", name="state1") + + +def test_description(): + xml = b'<State name="state1" model="model1"/>' + state = State.from_xml(etree.fromstring(xml)) + assert state.description is None + + with pytest.raises(AttributeError): + state.description = "test" + + xml = b'<State name="state1" model="model1" description="test2"/>' + state = State.from_xml(etree.fromstring(xml)) + assert state.description == "test2" + + +def test_id(): + xml = b'<State name="state1" model="model1"/>' + state = State.from_xml(etree.fromstring(xml)) + assert state.id is None + + with pytest.raises(AttributeError): + state.id = "2345" + + xml = b'<State name="state1" model="model1" id="1234"/>' + state = State.from_xml(etree.fromstring(xml)) + assert state.id == "1234" + + +def test_create_state_acl(): + acl = ACL() + acl.grant(role="role1", permission="DO:IT") + acl.grant(role="?OWNER?", permission="DO:THAT") + state_acl = State.create_state_acl(acl) + assert state_acl.get_permissions_for_role("?STATE?role1?") == {"DO:IT"} + assert state_acl.get_permissions_for_role("?STATE??OWNER??") == {"DO:THAT"} + + +def test_transitions(): + xml = b'<State name="state1" model="model1"/>' + state = State.from_xml(etree.fromstring(xml)) + assert state.transitions is None + + with pytest.raises(AttributeError): + state.transitions = [] + + xml = b'<State name="state1" model="model1" id="1234"><Transition name="t1"><FromState name="state1"/><ToState name="state2"/></Transition></State>' + state = State.from_xml(etree.fromstring(xml)) + assert state.transitions == set([Transition(name="t1", from_state="state1", to_state="state2")]) diff --git a/unittests/test_utils.py b/unittests/test_utils.py index f308445ed1a06331a54ebf67411dc154836557ca..42d18ba06eb7516bb318de54cb537f548cfe9081 100644 --- a/unittests/test_utils.py +++ b/unittests/test_utils.py @@ -24,7 +24,6 @@ """Tests for caosdb.common.utils.""" from __future__ import unicode_literals from lxml.etree import Element -from nose.tools import assert_equals as eq from caosdb.common.utils import xml2str @@ -32,4 +31,4 @@ def test_xml2str(): name = 'Björn' element = Element(name) serialized = xml2str(element) - eq(serialized, "<Björn/>\n") + assert serialized == "<Björn/>\n"