diff --git a/CHANGELOG.md b/CHANGELOG.md index b65d9c17e4780e91b5983200796dbc6d9ab6ad14..9b1ab4456782b859960e7730e329a7641dede4bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,81 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Entity State support (experimental, no StateModel support yet). See the `caosdb.State` class for more information. + +### Changed ### + +### Deprecated ### + +### Removed ### + +### Fixed ### + +### Security ### + +## [0.5.1] - 2021-02-12 ## + +### Added ### + +### Changed ### + +### Deprecated ### + +### Removed ### + +### Fixed ### + +* #43 - Error with `execute_query` when server doesn't support query caching. + +### Security ### + +## [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. + +### Deprecated ### + +### Removed ### + +* Dynamic exception type `EntityMultiError`. +* `get_something` functions from all error object in `exceptions.py` +* `AmbiguityException` + +### Fixed ### + +### Security ### + +## [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. @@ -81,7 +156,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..e44702a3a22c28af986d641b5d7e454f915326c8 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,9 @@ project](https://gitlab.com/caosdb/caosdb) for more information. # License -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) 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..2db73cfaec2f6aadfc7fa3742892d970d562c946 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): diff --git a/RELEASE_GUIDELINES.md b/RELEASE_GUIDELINES.md index 02be5c1ad19f6a3a405fb08d62e23dab350ad445..dd6d5af4c43e1eea17c70f29da837c905694fed4 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 master branch. -5. Tag the latest commit of the master branch with `v<VERSION>`. +6. Tag the latest commit of the master 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 master branch back into the dev branch. + +11. After the merge of master 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..dfc0a1510823a36d963f5d868052abb17b3fe12d 100755 --- a/examples/set_permissions.py +++ b/examples/set_permissions.py @@ -51,26 +51,26 @@ out : tuple try: human_user = admin._retrieve_user("jane") _activate_user("jane") - except db.EntityDoesNotExistError: + except db.ResourceNotFoundError: 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: + except db.ResourceNotFoundError: 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.ResourceNotFoundError: human_role = admin._insert_role("human", "An Earthling.") try: alien_role = admin._retrieve_role("alien") - except db.EntityDoesNotExistError: + except db.ResourceNotFoundError: alien_role = admin._insert_role("alien", "An Extra-terrestrial.") admin._set_roles("jane", ["human"]) @@ -111,9 +111,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 Guitar", 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 +140,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 +192,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,8 +212,11 @@ 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.") 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..d491a1d340796b9b4701b307003c3e62e75d6001 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 = 2 PRE = "" # e.g. rc0, alpha.1, 0.beta-23 ISRELEASED = False diff --git a/src/caosdb/__init__.py b/src/caosdb/__init__.py index 7f656e264f9b726ed457f05c2816502a3a135634..b320b8295e2981f99788f141c1a5e8c8952aabce 100644 --- a/src/caosdb/__init__.py +++ b/src/caosdb/__init__.py @@ -48,8 +48,8 @@ from caosdb.common.models import (ACL, ALL, FIX, NONE, OBLIGATORY, RECOMMENDED, get_known_permissions, raise_errors) from caosdb.configuration import configure, get_config from caosdb.connection.connection import configure_connection, get_connection -from caosdb.exceptions import * from caosdb.version import version as __version__ +from caosdb.exceptions import * # read configuration these 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 e2846ef14d481ec938dd401289f8f0bbbfdc1d06..5b7a098a58463489eec9b827c8baa1c90a580e13 100644 --- a/src/caosdb/common/administration.py +++ b/src/caosdb/common/administration.py @@ -30,8 +30,9 @@ from lxml import etree from caosdb.common.utils import xml2str from caosdb.connection.connection import get_connection -from caosdb.exceptions import (AuthorizationException, ClientErrorException, - EntityDoesNotExistError, ServerConfigurationException) +from caosdb.exceptions import (HTTPClientError, + HTTPForbiddenError, + HTTPResourceNotFoundError) def set_server_property(key, value): @@ -112,10 +113,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 @@ -124,10 +125,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 @@ -150,13 +151,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 @@ -179,10 +180,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." @@ -195,10 +196,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 @@ -208,10 +209,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 @@ -220,10 +221,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 @@ -232,10 +233,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 @@ -249,14 +250,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 @@ -272,11 +274,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() @@ -316,10 +319,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 @@ -328,10 +331,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 c3de01fa133f25144b665f3b6b1f8d683de1e9e1..71955e300ed2751bd2838718c9d310d90027a1c7 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,25 +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 @@ -639,7 +642,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 @@ -759,15 +761,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([ @@ -808,8 +809,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) @@ -970,7 +970,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() != "": @@ -995,11 +995,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()) @@ -1038,13 +1047,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): @@ -1249,8 +1257,8 @@ class QueryTemplate(): 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, @@ -1395,8 +1403,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): @@ -1461,7 +1468,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: @@ -1477,7 +1484,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 @@ -1500,8 +1507,8 @@ 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): copy_kwargs = kwargs.copy() @@ -1510,7 +1517,7 @@ class RecordType(Entity): # 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, @@ -1538,7 +1545,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 @@ -1702,7 +1709,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) @@ -2027,7 +2034,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!" @@ -2237,15 +2245,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): @@ -2286,11 +2298,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( @@ -2408,13 +2420,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." + @@ -2430,8 +2442,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 @@ -2650,8 +2662,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 @@ -2677,10 +2688,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 = [] @@ -2702,10 +2712,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): @@ -2732,10 +2741,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): @@ -2762,7 +2770,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 = [] @@ -2781,7 +2789,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 @@ -2799,8 +2807,10 @@ class Container(list): 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() @@ -2824,15 +2834,17 @@ 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)] @@ -2892,10 +2904,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)) @@ -2936,12 +2949,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) @@ -2984,9 +2997,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") @@ -3003,8 +3017,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() @@ -3174,9 +3190,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) @@ -3637,6 +3654,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 @@ -3652,28 +3686,60 @@ class Query(): def __init__(self, q): self.flags = dict() self.messages = _Messages() + self.cached = 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" + 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 if self.q.lower().startswith('count') and len(cresp) == 0: # this was a count query @@ -3685,10 +3751,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) @@ -3698,8 +3766,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: @@ -3707,7 +3799,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): @@ -3761,7 +3853,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 @@ -3883,6 +3975,8 @@ def _parse_single_xml_element(elem): 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 @@ -3908,124 +4002,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/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..fc699ab5db1db36bc1ee63034b6828eec4d16bc1 100644 --- a/src/caosdb/connection/connection.py +++ b/src/caosdb/connection/connection.py @@ -33,11 +33,14 @@ 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.exceptions import (CaosDBException, HTTPClientError, + ConfigurationError, + CaosDBConnectionError, + HTTPForbiddenError, + LoginFailedError, + HTTPResourceNotFoundError, + HTTPServerError, + HTTPURITooLongError) from caosdb.version import version from pkg_resources import resource_filename @@ -61,6 +64,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 +95,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 +140,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 +164,7 @@ class _DefaultCaosDBServerConnection(CaosDBServerConnection): Raises ------ - ConnectionException + CaosDBConnectionError If no url has been specified, or if the CA certificate cannot be loaded. """ @@ -193,9 +201,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 +212,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 +284,7 @@ def _get_authenticator(**config): Raises ------ - ConnectionException + ConfigurationError If the password_method string cannot be resolved to a CaosAuthenticator class. """ @@ -292,10 +300,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 +406,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 +457,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 +468,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 +557,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 +579,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() diff --git a/src/caosdb/exceptions.py b/src/caosdb/exceptions.py index c406ad9624f353a400a375314f5ce9bbbb8fe804..f02a4630356726f99d8439fd821b6dd327ab22c7 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,13 +56,11 @@ 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 ServerConfigurationException(CaosDBException): @@ -67,14 +70,18 @@ class ServerConfigurationException(CaosDBException): """ -class ClientErrorException(CaosDBException): +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] @@ -85,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.""" + + +class EmptyUniqueQueryError(BadQueryError): + """A unique query or retrieve dound no result.""" + - for err in self.errors: - err.print_errs() +# ######################### Transaction errors ######################### - 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 - return newinstance +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 get_container(self): - ''' - @return: The container that raised this TransactionError during the last - transaction. - ''' + """ - return self.container + 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. @@ -196,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 @@ -232,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) @@ -244,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"): @@ -307,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) @@ -344,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): @@ -364,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 ab8cec654f2fd0eb2b77df0c969295b1f084171c..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 @@ -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/administration.rst b/src/doc/administration.rst new file mode 100644 index 0000000000000000000000000000000000000000..91b85344a018618284349b4e8dd34fe6b365b94d --- /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 ``python3 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):: + + python3 caosdb_admin.py create_user anna + python3 caosdb_admin.py set_user_password anna + python3 caosdb_admin.py add_user_roles anna administration + python3 caosdb_admin.py activate_user anna + diff --git a/src/doc/conf.py b/src/doc/conf.py index 9e2924ae726e13aacd2f955ae1904b39ad73cbc3..f276f325273b71d4b697bc57990259e842b2dbc3 100644 --- a/src/doc/conf.py +++ b/src/doc/conf.py @@ -182,10 +182,16 @@ epub_exclude_files = ['search.html'] # -- Extension configuration ------------------------------------------------- -# -- Options for intersphinx extension --------------------------------------- +# -- 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://caosdb.gitlab.io/caosdb-mysqlbackend/", + None), + "caosdb-server": ("https://caosdb.gitlab.io/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/index.rst b/src/doc/index.rst index e8cc93aa398de36c30833bc97ca79ce20564daa6..76a2f88f6d31dd9b5f17995b6d54ccc63eb33631 100644 --- a/src/doc/index.rst +++ b/src/doc/index.rst @@ -10,7 +10,8 @@ Welcome to PyCaosDB's documentation! Getting started <README_SETUP> tutorials/index Concepts <concepts> - Configuration <configuration> + Configuration <configuration> + Administration <administration> API documentation<_apidoc/modules> This is the documentation for the Python client library for CaosDB, ``PyCaosDB``. diff --git a/src/doc/tutorials/Data-Insertion.rst b/src/doc/tutorials/Data-Insertion.rst new file mode 100644 index 0000000000000000000000000000000000000000..2ead1cc259c807ee12f2459f7e452558eb1b63a2 --- /dev/null +++ b/src/doc/tutorials/Data-Insertion.rst @@ -0,0 +1,163 @@ +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 :any:`caosdb-server:Data Model` . + +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() + +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>`__. + +Inheritance of Properties +------------------------- + +Given, you want to insert a new RecordType “Fridge temperatur +experiment†as a child of the existing RecordType “Experimentâ€. The +latter may have an obligatory Property “date†(since every experiment is +conducted at some time). It is a natural way of thinking, that every sub +type of “Experiment†also has this obligatory Property—in terms of +object oriented programing the “Fridge temperatur experiment†*inherits* +that Property. + +:: + + rt = h.RecordType(name="Fridge temperatur experiment", + description="RecordType which inherits all obligatory properties from Experiment" + ).add_parent(name="Experiment", inheritance="obligatory").insert() + + print(rt.get_property(name="date").importance) ### rt now has a "date"-property -> this line prints "obligatory" + +The parameter *``inheritance=(obligatory|recommended|fix|all|none)``* of +``add_parent`` tells the server to assign obligatory:: properties of the +parent to the child automatically, recommended:: properties of the +parent to the child automatically, fix:: properties of the parent to the +child automatically, all:: properties of the parent to the child +automatically, none:: of the properties of the parent to child +automatically, + +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..ba386dc31baad1021f01d53b2b12a2623d8278ad --- /dev/null +++ b/src/doc/tutorials/errors.rst @@ -0,0 +1,53 @@ + +Error Handling +-------------- + +HeartDBException +~~~~~~~~~~~~~~~~ + +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 can +help you to find the crucial problems with your transaction by two +important methods: \* ``get_errors()`` which returns a list of instances +of ``EntityError``. \* ``get_entities()`` which returns a list of +entities in the transaction container which are erroneous. + +Additionally, ``print(transaction_error`` prints a tree-like +representation of all errors regarding the transaction in question. + +EntityError +~~~~~~~~~~~ + +An ``EntityError`` represents a single error that has been returned by +the server. You might call \* ``get_entity()`` which returns the entity +which caused the error. \* ``get_description()`` which returns a +description of the error. \* ``get_code()`` which returns the error code +(if specified) or 0 (if not). + +In fact, the ``EntityError`` class is a subclass of +``TransactionError``. So, it inherits the ``get_entities()``. Unless +overridden by subclasses of ``EntityError``, it return a list with only +one item—the entity which caused this error. Similarly, unless +overridden by subclasses, the ``get_errors()`` method returns a list +with only one item—``[self]``. + +Special Errors +~~~~~~~~~~~~~~ + +Subclasses of ``EntityError`` for special purposes: \* +``EntityDoesNotExistError`` \* ``EntityHasNoDataTypeError`` \* +``UniqueNamesError`` \* ``UnqualifiedParentsError`` - overrides +``get_entities()``: returns all parent entities with errors. - overrides +``get_errors()``: returns a list of EntityErrors which have been caused +by parent entities. \* ``UnqualifiedPropertiesError`` - overrides +``get_entities()``: returns all properties with errors. - overrides +``get_errors()``: returns a list of EntityErrors which have been caused +by properties. diff --git a/src/doc/tutorials/first_steps.rst b/src/doc/tutorials/first_steps.rst index 3da05b05c4d4025f6d81490550fd1482ae147fb4..3eb804776960ba93acb9baf78824f0cf8a06e1f7 100644 --- a/src/doc/tutorials/first_steps.rst +++ b/src/doc/tutorials/first_steps.rst @@ -113,6 +113,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 ------- 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/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_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..f4b3ee9762d9d76b65ace9a5b9b3f4039c0c6919 --- /dev/null +++ b/unittests/test_query.py @@ -0,0 +1,45 @@ +# -*- 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"/>' + q = db.Query(etree.fromstring(s)) + assert q.q == "FIND bla" + assert q.results == 0 + assert q.cached is True + + s = '<Query string="COUNT bla" results="1" cached="false"/>' + q = db.Query(etree.fromstring(s)) + assert q.q == "COUNT bla" + assert q.results == 1 + assert q.cached is False + + 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 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_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"