diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b45dde5de1e4cafdbc02a15c4b775b763d5447bd..9f8c131968c050fb18001b5dc7c5468d0ed26dae 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -22,7 +22,7 @@ variables: DEPLOY_REF: dev - CI_REGISTRY_IMAGE: $CI_REGISTRY/caosdb/caosdb-pylib/testenv:latest + CI_REGISTRY_IMAGE: $CI_REGISTRY/caosdb/src/caosdb-pylib/testenv:latest # When using dind, it's wise to use the overlayfs driver for # improved performance. @@ -65,7 +65,7 @@ trigger_build: stage: deploy script: - /usr/bin/curl -X POST - -F token=$DEPLOY_TRIGGER_TOKEN + -F token=$CI_JOB_TOKEN -F "variables[F_BRANCH]=$CI_COMMIT_REF_NAME" -F "variables[PYLIB]=$CI_COMMIT_REF_NAME" -F "variables[TriggerdBy]=PYLIB" @@ -92,15 +92,13 @@ build-testenv: - docker push $CI_REGISTRY_IMAGE # Build the sphinx documentation and make it ready for deployment by Gitlab Pages -# documentation: -# stage: deploy - # Special job for serving a static website. See https://docs.gitlab.com/ee/ci/yaml/README.html#pages -pages: +pages_prepare: &pages_prepare + tags: [ cached-dind ] stage: deploy only: - # TODO this should be for master only, once releases are more regularly - - dev + refs: + - /^release-.*$/i script: - echo "Deploying" - make doc @@ -108,3 +106,8 @@ pages: artifacts: paths: - public +pages: + <<: *pages_prepare + only: + refs: + - main diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e190313f02f94d31bb3a0e9f0310eed7b729ea4..38b470db9d36675ffcef0b7e1434a08c3be7f407 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,24 +17,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed ### +* #53 Documentation of inheritance + ### Security ### -## [0.5.1] - 2021-02-12 ## +## [0.5.2] - 2021-06-03 ## ### Added ### +* Entity State support (experimental, no StateModel support yet). See the + `caosdb.State` class for more information. +* `etag` property for the `caosdb.Query` class. The etag allows to debug the + caching and to decide whether the server has changed between queries. +* function `_read_config_files` to read `pycaosdb.ini` files from different paths. + ### Changed ### +* Updated error-handling tutorial in documentation to reflect the new + error classes + ### Deprecated ### ### Removed ### ### Fixed ### - -* #43 - Error with `execute_query` when server doesn't support query caching. +* #45 - test_config_ini_via_envvar ### Security ### +## [0.5.1] - 2021-02-12 ## + +### Fixed ### + +* #43 - Error with `execute_query` when server doesn't support query caching. + ## [0.5.0] - 2021-02-11 ## ### Added ### @@ -67,19 +83,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `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 ### @@ -89,18 +98,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 entity. * Automated documentation builds: `make doc` -### Changed ### - -### Deprecated ### - -### Removed ### - ### Fixed ### * deepcopy of `_Messages` objects -### Security ### - ## [0.4.0] - 2020-07-17## ### Added ### diff --git a/README.md b/README.md index e44702a3a22c28af986d641b5d7e454f915326c8..04b34cbc07c98e73740b13200ed83fe067af99d2 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,49 @@ -# Welcome + +# README + +## Welcome This is the **CaosDB Python Client Library** repository and a part of the CaosDB project. -# Setup +## Setup Please read the [README_SETUP.md](README_SETUP.md) for instructions on how to setup this code. -# Further Reading +## Further Reading + +Please refer to the [official documentation](https://docs.indiscale.com/caosdb-pylib/) for more information. + +## Contributing + +Thank you very much to all contributers—[past, present](https://gitlab.com/caosdb/caosdb/-/blob/dev/HUMANS.md), and prospective ones. -Please refer to the [official gitlab repository of the CaosDB -project](https://gitlab.com/caosdb/caosdb) for more information. +### Code of Conduct -# License +By participating, you are expected to uphold our [Code of Conduct](https://gitlab.com/caosdb/caosdb/-/blob/dev/CODE_OF_CONDUCT.md). + +### How to Contribute + +* You found a bug, have a question, or want to request a feature? Please +[create an issue](https://gitlab.com/caosdb/caosdb-pylib/-/issues). +* You want to contribute code? + * **Forking:** Please fork the repository and create a merge request in GitLab and choose this repository as + target. Make sure to select "Allow commits from members who can merge the target branch" under + Contribution when creating the merge request. This allows our team to work with you on your + request. + * **Code style:** This project adhers to the PEP8 recommendations, you can test your code style + using the `autopep8` tool (`autopep8 -i -r ./`). Please write your doc strings following the + [NumpyDoc](https://numpydoc.readthedocs.io/en/latest/format.html) conventions. +* If you have a suggestion for the [documentation](https://docs.indiscale.com/caosdb-pylib/), +the preferred way is also a merge request as describe above (the documentation resides in `src/doc`). +However, you can also create an issue for it. +* You can also contact us at **info (AT) caosdb.de** and join the + CaosDB community on + [#caosdb:matrix.org](https://matrix.to/#/!unwwlTfOznjEnMMXxf:matrix.org). + +## License * Copyright (C) 2018 Research Group Biomedical Physics, Max Planck Institute for Dynamics and Self-Organization Göttingen. @@ -22,4 +51,3 @@ project](https://gitlab.com/caosdb/caosdb) for more information. 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 2db73cfaec2f6aadfc7fa3742892d970d562c946..9da548395073643c16539cef180c4d6412dd8d46 100644 --- a/README_SETUP.md +++ b/README_SETUP.md @@ -75,7 +75,7 @@ current working directory will be read additionally, if it exists. Here, we will look at the most common configuration options. For a full and comprehensive description please check out -[pycaosdb.ini file](https://gitlab.com/caosdb/caosdb-pylib/-/blob/master/examples/pycaosdb.ini) +[pycaosdb.ini file](https://gitlab.com/caosdb/caosdb-pylib/-/blob/main/examples/pycaosdb.ini) You can download this file and use it as a starting point. @@ -84,7 +84,7 @@ Typically, you need to change at least the `url` and `username` fields as requir you do not know what to put there, but for the demo instances https://demo.indiscale.com, `username=admin` and `password=caosdb` should work). -### Authentication ## +### Authentication ### The default configuration (that your are asked for your password when ever a connection is created can be changed by setting `password_method`: @@ -109,7 +109,7 @@ The following illustrates the recommended options: #password_method=keyring ``` -### SSL Certificate ## +### SSL Certificate ### In some cases (especially if you are testing CaosDB) you might need to supply an SSL certificate to allow SSL encryption. @@ -118,9 +118,9 @@ an SSL certificate to allow SSL encryption. cacert=/path/to/caosdb.ca.pem ``` -### Further Settings ## +### Further Settings ### As mentioned above, a complete list of options can be found in the -[pycaosdb.ini file](https://gitlab.com/caosdb/caosdb-pylib/-/blob/master/examples/pycaosdb.ini) in +[pycaosdb.ini file](https://gitlab.com/caosdb/caosdb-pylib/-/blob/main/examples/pycaosdb.ini) in the examples folder of the source code. ## Try it out ## @@ -138,21 +138,20 @@ Out[2]: Connection to CaosDB with 501 Records. Note: This setup will ask you for your password whenever a new connection is created. If you do not like this, check out the "Authentication" section in the [configuration documentation](configuration.md). -Now would be a good time to continue with the [tutorials](tutorials.html). +Now would be a good time to continue with the [tutorials](tutorials/index). ## Run Unit Tests tox -## Code Formatting - -autopep8 -i -r ./ - -## Documentation # +## Documentation ## Build documentation in `build/` with `make doc`. -### Requirements ## +### Requirements ### - `sphinx` - `sphinx-autoapi` - `recommonmark` + +### Troubleshooting ### +If the client is to be executed directly from the `/src` folder, an initial `.\setup.py install --user` must be called. diff --git a/RELEASE_GUIDELINES.md b/RELEASE_GUIDELINES.md index dd6d5af4c43e1eea17c70f29da837c905694fed4..e015b598117abdcd575cf17e2f095fec459a4c4c 100644 --- a/RELEASE_GUIDELINES.md +++ b/RELEASE_GUIDELINES.md @@ -24,9 +24,9 @@ guidelines of the CaosDB Project variables and set `ISRELEASED` to `True`. Use the possibility to issue pre-release versions for testing. -5. Merge the release branch into the master branch. +5. Merge the release branch into the main branch. -6. Tag the latest commit of the master branch with `v<VERSION>`. +6. Tag the latest commit of the main branch with `v<VERSION>`. 7. Delete the release branch. @@ -35,8 +35,8 @@ guidelines of the CaosDB Project 9. Publish the release by executing `./release.sh` with uploads the caosdb module to the Python Package Index [pypi.org](https://pypi.org). -10. Merge the master branch back into the dev branch. +10. Merge the main branch back into the dev branch. -11. After the merge of master to dev, start a new development version by +11. After the merge of main to dev, start a new development version by setting `ISRELEASED` to `False` and by increasing at least the `MIRCO` version in [setup.py](./setup.py) diff --git a/examples/set_permissions.py b/examples/set_permissions.py index dfc0a1510823a36d963f5d868052abb17b3fe12d..8162b11bfefb41b1bcdbc74b8e314f99a61d1a4e 100755 --- a/examples/set_permissions.py +++ b/examples/set_permissions.py @@ -25,15 +25,13 @@ As a result, only a specific user or group may access it. -This script assumes that data similar to the demo server of IndiScale (at -demo.indiscale.com) exists on the server specified in the pycaosdb.ini -configuration. +This script assumes that the user specified in the pycaosdb.ini +configuration can create new entities. """ import caosdb as db from caosdb import administration as admin -import lxml def assert_user_and_role(): @@ -50,27 +48,27 @@ out : tuple """ try: human_user = admin._retrieve_user("jane") - _activate_user("jane") - except db.ResourceNotFoundError: + admin._update_user(name="jane", status="ACTIVE") + except db.HTTPResourceNotFoundError: human_user = admin._insert_user( "jane", password="Human_Rememberable_Password_1234", status="ACTIVE") try: alien_user = admin._retrieve_user("xaxys") - _activate_user("xaxys") - except db.ResourceNotFoundError: + admin._update_user(name="xaxys", status="ACTIVE") + except db.HTTPResourceNotFoundError: alien_user = admin._insert_user("xaxys", password="4321_Syxax", status="ACTIVE") # At the moment, the return value is only "ok" for successful insertions. try: human_role = admin._retrieve_role("human") - except db.ResourceNotFoundError: + except db.HTTPResourceNotFoundError: human_role = admin._insert_role("human", "An Earthling.") try: alien_role = admin._retrieve_role("alien") - except db.ResourceNotFoundError: + except db.HTTPResourceNotFoundError: alien_role = admin._insert_role("alien", "An Extra-terrestrial.") admin._set_roles("jane", ["human"]) @@ -80,24 +78,6 @@ out : tuple ("xaxys", list(admin._get_roles("xaxys")))) -def _activate_user(user): - """Set the user state to "ACTIVE" if necessary. - -Parameters ----------- -user : str - The user to activate. - -Returns -------- -None - - """ - user_xml = lxml.etree.fromstring(admin._retrieve_user(user)) - if user_xml.xpath("User")[0].attrib["status"] != "ACTIVE": - admin._update_user(user, status="ACTIVE") - - def get_entities(count=1): """Retrieve one or more entities. @@ -111,7 +91,7 @@ Returns out : Container A container of retrieved entities, the length is given by the parameter count. """ - cont = db.execute_query("FIND RECORD Guitar", flags={ + cont = db.execute_query("FIND RECORD 'Human Food'", flags={ "P": "0L{n}".format(n=count)}) if len(cont) != count: raise db.CaosDBException( @@ -221,17 +201,36 @@ None print("Retrieval of all entities was successfully denied.") +def create_test_entities(): + """Create some test entities. + After calling this function, there will be a RecordType "Human Food" with the corresponding Records + "Bread", "Tomatoes", and "Twinkies" inserted in the database. + """ + rt = db.RecordType( + name="Human Food", description="Food that can be eaten only by humans").insert() + food = ("Bread", "Tomatoes", "Twinkies") + + cont = db.Container() + for i in range(len(food)): + rec = db.Record(food[i]) + rec.add_parent(name="Human Food") + cont.append(rec) + + cont.insert() + + def main(): """The main function of this script.""" - db.connection.connection.get_connection()._login() - + """Create some test entities""" + create_test_entities() + """Create new users""" human, alien = assert_user_and_role() - - # public, private, undefined entities + """Load the newly created entities.""" entities = get_entities(count=3) - + """Set permission for the entities (only humans are allowed to eat human food)""" set_permission(human[1][0], alien[1][0], entities) + """Test the permissions""" test_permission((human[0], "Human_Rememberable_Password_1234"), (alien[0], "4321_Syxax"), entities) diff --git a/setup.py b/setup.py index d491a1d340796b9b4701b307003c3e62e75d6001..e1d39458ea8d1b0b17ea12a82ebd7133b27b045a 100755 --- a/setup.py +++ b/setup.py @@ -47,7 +47,7 @@ from setuptools import find_packages, setup MAJOR = 0 MINOR = 5 -MICRO = 2 +MICRO = 3 PRE = "" # e.g. rc0, alpha.1, 0.beta-23 ISRELEASED = False diff --git a/src/caosdb/__init__.py b/src/caosdb/__init__.py index b86fcd638c7321c2e0464b603fb736e3adfbafe3..7e06885fe495c1e8c4ccc99b7d0c0f8ff8c34b5b 100644 --- a/src/caosdb/__init__.py +++ b/src/caosdb/__init__.py @@ -37,7 +37,8 @@ from os.path import expanduser, join import caosdb.apiutils from caosdb.common import administration from caosdb.common.datatype import (BOOLEAN, DATETIME, DOUBLE, FILE, INTEGER, - REFERENCE, TEXT, LIST) + LIST, REFERENCE, TEXT) +from caosdb.common.state import State, Transition # Import of the basic API classes: from caosdb.common.models import (ACL, ALL, FIX, NONE, OBLIGATORY, RECOMMENDED, SUGGESTED, Container, DropOffBox, Entity, @@ -45,15 +46,13 @@ from caosdb.common.models import (ACL, ALL, FIX, NONE, OBLIGATORY, RECOMMENDED, Query, QueryTemplate, Record, RecordType, delete, execute_query, get_global_acl, get_known_permissions, raise_errors) -from caosdb.configuration import configure, get_config +from caosdb.configuration import _read_config_files, configure, get_config from caosdb.connection.connection import configure_connection, get_connection -from caosdb.version import version as __version__ from caosdb.exceptions import * +try: + from caosdb.version import version as __version__ +except ModuleNotFoundError: + version = "uninstalled" + __version__ = version -# read configuration these files - -if "PYCAOSDBINI" in environ: - configure(expanduser(environ["PYCAOSDBINI"])) -else: - configure(expanduser('~/.pycaosdb.ini')) -configure(join(getcwd(), "pycaosdb.ini")) +_read_config_files() diff --git a/src/caosdb/common/administration.py b/src/caosdb/common/administration.py index 7f9336c7473ac759aab4461c84b708a3b9049d8b..e7ba94182d7a4d8b60c6400cd1d804f62f7bf03c 100644 --- a/src/caosdb/common/administration.py +++ b/src/caosdb/common/administration.py @@ -32,7 +32,10 @@ from caosdb.common.utils import xml2str from caosdb.connection.connection import get_connection from caosdb.exceptions import (HTTPClientError, HTTPForbiddenError, - HTTPResourceNotFoundError) + HTTPResourceNotFoundError, + EntityDoesNotExistError, + ServerConfigurationException, + ) def set_server_property(key, value): @@ -53,9 +56,11 @@ def set_server_property(key, value): None """ con = get_connection() - - con._form_data_request(method="POST", path="_server_properties", - params={key: value}).read() + try: + con._form_data_request(method="POST", path="_server_properties", + params={key: value}).read() + except EntityDoesNotExistError: + raise ServerConfigurationException("Debug mode in server is probably disabled.") from None def get_server_properties(): @@ -69,7 +74,11 @@ def get_server_properties(): The server properties. """ con = get_connection() - body = con._http_request(method="GET", path="_server_properties").response + try: + body = con._http_request(method="GET", path="_server_properties").response + except EntityDoesNotExistError: + raise ServerConfigurationException("Debug mode in server is probably disabled.") from None + xml = etree.parse(body) props = dict() diff --git a/src/caosdb/common/models.py b/src/caosdb/common/models.py index 9592e484654d3a610d5516cb7ca52c3df71682ba..6ec49df2722170805fb6230753f36503870a8821 100644 --- a/src/caosdb/common/models.py +++ b/src/caosdb/common/models.py @@ -42,6 +42,7 @@ from warnings import warn 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 @@ -112,6 +113,7 @@ class Entity(object): self.name = name self.description = description self.id = id + self.state = None @property def version(self): @@ -429,14 +431,39 @@ class Entity(object): def add_parent(self, parent=None, **kwargs): # @ReservedAssignment """Add a parent to this entity. - The first parameter is meant to identify the parent entity. So the method expects an instance of - Entity, an integer or a string here. Even though, by means of the **kwargs parameter you may pass - more parameters to this method. Accepted keywords are: id, name, inheritance. Any other keyword is - ignored right now but this may change in the future. + Parameters + ---------- + parent : Entity or int or str or None + The parent entity, either specified by the Entity object + itself, or its id or its name. Default is None. + **kwargs : dict, optional + Additional keyword arguments for specifying the parent by + name or id, and for specifying the mode of inheritance. + + id : int + Integer id of the parent entity. Ignored if `parent` + is not None. + name : str + Name of the parent entity. Ignored if `parent is not + none`. + inheritance : str + One of ``obligatory``, ``recommended``, ``suggested``, or ``fix``. Specifies the + minimum importance which parent properties need to have to be inherited by this + entity. If no `inheritance` is given, no properties will be inherited by the child. + This parameter is case-insensitive. + + Note that the behaviour is currently not yet specified when assigning parents to + Records, it only works for inheritance of RecordTypes (and Properties). + + For more information, it is recommended to look into the + :ref:`data insertion tutorial<tutorial-inheritance-properties>`. + + Raises + ------ + UserWarning + If neither a `parent` parameter, nor the `id`, nor `name` + parameter is passed to this method. - @param parent: An entity, an id or a name. - @param **kwargs: Accepted keywords: id, name, inheritance. - @raise UserWarning: If neither a 'parent' parameter, nor the 'id', nor 'name' parameter is passed to this method. """ name = (kwargs['name'] if 'name' in kwargs else None) pid = (kwargs['id'] if 'id' in kwargs else None) @@ -854,6 +881,8 @@ class Entity(object): xml.append(v_elem) elif self.value == "": xml.append(etree.Element("EmptyString")) + elif str(self.value) == "nan": + xml.text = "NaN" else: xml.text = str(self.value) @@ -907,6 +936,9 @@ class Entity(object): if self.acl is not None: xml.append(self.acl.to_xml()) + if self.state is not None: + xml.append(self.state.to_xml()) + return xml @staticmethod @@ -951,6 +983,8 @@ class Entity(object): entity.add_message(child) elif isinstance(child, Version): entity.version = child + elif isinstance(child, State): + entity.state = child elif child is None or hasattr(child, "encode"): vals.append(child) elif isinstance(child, Entity): @@ -1078,7 +1112,7 @@ class Entity(object): flags=flags)[0] def update(self, strict=False, raise_exception_on_error=True, - unique=True, flags=None): + unique=True, flags=None, sync=True): """Update this entity. There are two possible work-flows to perform this update: @@ -1112,6 +1146,7 @@ class Entity(object): return Container().append(self).update( strict=strict, + sync=sync, raise_exception_on_error=raise_exception_on_error, unique=unique, flags=flags)[0] @@ -1247,6 +1282,7 @@ class QueryTemplate(): self.is_valid = lambda: False self.is_deleted = lambda: False self.version = None + self.state = None def retrieve(self, raise_exception_on_error=True, unique=True, sync=True, flags=None): @@ -1424,6 +1460,23 @@ class Property(Entity): property=property, value=value, **copy_kwargs) def add_parent(self, parent=None, **kwargs): + """Add a parent Entity to this Property. + + Parameters + ---------- + parent : Entity or int or str or None, optional + The parent entity + **kwargs : dict, optional + Additional keyword arguments specifying the parent Entity + by id or name, and specifying the inheritance level. See + :py:meth:`Entity.add_parent` for more information. Note + that by default, `inheritance` is set to ``fix``. + + See Also + -------- + Entity.add_parent + + """ copy_kwargs = kwargs.copy() if 'inheritance' not in copy_kwargs: @@ -1502,6 +1555,24 @@ class RecordType(Entity): **copy_kwargs) def add_parent(self, parent=None, **kwargs): + """Add a parent to this RecordType + + Parameters + ---------- + parent : Entity or int or str or None, optional + The parent entity, either specified by the Entity object + itself, or its id or its name. Default is None. + **kwargs : dict, optional + Additional keyword arguments specifying the parent Entity by id or + name, and specifying the inheritance level. See + :py:meth:`Entity.add_parent` for more information. Note + that by default, `inheritance` is set to ``obligatory``. + + See Also + -------- + Entity.add_parent + + """ copy_kwargs = kwargs.copy() if 'inheritance' not in copy_kwargs: @@ -2223,6 +2294,7 @@ def _basic_sync(e_local, e_remote): e_local.is_valid = e_remote.is_valid e_local.is_deleted = e_remote.is_deleted e_local.version = e_remote.version + e_local.state = e_remote.state if hasattr(e_remote, "query"): e_local.query = e_remote.query @@ -2794,6 +2866,17 @@ class Container(list): this happens, none of them will be deleted. It occurs an error instead. """ + chunk_size = 100 + item_count = len(self) + # Split Container in 'chunk_size'-sized containers (if necessary) to avoid error 414 Request-URI Too Long + if item_count > chunk_size: + for i in range(0, int(item_count/chunk_size)+1): + chunk = Container() + for j in range(i*chunk_size, min(item_count, (i+1)*chunk_size)): + chunk.append(self[j]) + if len(chunk): + chunk.delete() + return self if len(self) == 0: if raise_exception_on_error: @@ -2840,7 +2923,7 @@ class Container(list): entity_url_segments = [_ENTITY_URI_SEGMENT, "&".join(id_str)] _log_request("DELETE: " + str(entity_url_segments) + - ("?" + flags if flags is not None else '')) + ("?" + str(flags) if flags is not None else '')) http_response = c.delete(entity_url_segments, query_dict=flags) cresp = Container._response_to_entities(http_response) @@ -3419,6 +3502,22 @@ class ACL(): self.deny(username=username, realm=realm, role=role, permission=permission, priority=priority) + def combine(self, other): + """ Combine and return new instance.""" + result = ACL() + result._grants.update(other._grants) + result._grants.update(self._grants) + result._denials.update(other._denials) + result._denials.update(self._denials) + result._priority_grants.update(other._priority_grants) + result._priority_grants.update(self._priority_grants) + result._priority_denials.update(other._priority_denials) + result._priority_denials.update(self._priority_denials) + return result + + def __eq__(self, other): + return isinstance(other, ACL) and other._grants == self._grants and self._denials == other._denials and self._priority_grants == other._priority_grants and self._priority_denials == other._priority_denials + def is_empty(self): return len(self._grants) + len(self._priority_grants) + \ len(self._priority_denials) + len(self._denials) == 0 @@ -3661,6 +3760,7 @@ class Query(): self.flags = dict() self.messages = _Messages() self.cached = None + self.etag = None if isinstance(q, etree._Element): self.q = q.get("string") @@ -3670,6 +3770,7 @@ class Query(): self.cached = False else: self.cached = q.get("cached").lower() == "true" + self.etag = q.get("etag") for m in q: if m.tag.lower() == 'warning' or m.tag.lower() == 'error': @@ -3714,6 +3815,7 @@ class Query(): cresp = Container._response_to_entities(http_response) self.results = cresp.query.results self.cached = cresp.query.cached + self.etag = cresp.query.etag if self.q.lower().startswith('count') and len(cresp) == 0: # this was a count query @@ -3942,6 +4044,8 @@ def _parse_single_xml_element(elem): return entity elif elem.tag.lower() == "version": return Version.from_xml(elem) + elif elem.tag.lower() == "state": + return State.from_xml(elem) elif elem.tag.lower() == "emptystring": return "" elif elem.tag.lower() == "value": diff --git a/src/caosdb/common/state.py b/src/caosdb/common/state.py new file mode 100644 index 0000000000000000000000000000000000000000..cb74022bef57a77c8270b2033c904eecabaadf83 --- /dev/null +++ b/src/caosdb/common/state.py @@ -0,0 +1,198 @@ +# ** header v3.0 +# This file is a part of the CaosDB Project. +# +# Copyright (C) 2020 IndiScale GmbH <info@indiscale.com> +# Copyright (C) 2020 Timm Fitschen <t.fitschen@indiscale.com> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. +# +# ** end header + +import copy +from lxml import etree + + +def _translate_to_state_acis(acis): + result = set() + for aci in acis: + aci = copy.copy(aci) + if aci.role: + aci.role = "?STATE?" + aci.role + "?" + result.add(aci) + return result + + +class Transition: + """Transition + + Represents allowed transitions from one state to another. + + Properties + ---------- + name : str + The name of the transition + description: str + The description of the transition + from_state : str + A state name + to_state : str + A state name + """ + + def __init__(self, name, from_state, to_state, description=None): + self._name = name + self._from_state = from_state + self._to_state = to_state + self._description = description + + @property + def name(self): + return self._name + + @property + def description(self): + return self._description + + @property + def from_state(self): + return self._from_state + + @property + def to_state(self): + return self._to_state + + def __repr__(self): + return f'Transition(name="{self.name}", from_state="{self.from_state}", to_state="{self.to_state}", description="{self.description}")' + + def __eq__(self, other): + return (isinstance(other, Transition) + and other.name == self.name + and other.to_state == self.to_state + and other.from_state == self.from_state) + + def __hash__(self): + return 23472 + hash(self.name) + hash(self.from_state) + hash(self.to_state) + + @staticmethod + def from_xml(xml): + to_state = [to.get("name") for to in xml + if to.tag.lower() == "tostate"] + from_state = [from_.get("name") for from_ in xml + if from_.tag.lower() == "fromstate"] + result = Transition(name=xml.get("name"), + description=xml.get("description"), + from_state=from_state[0] if from_state else None, + to_state=to_state[0] if to_state else None) + return result + + +class State: + """State + + Represents the state of an entity and take care of the serialization and + deserialization of xml for the entity state. + + An entity state is always a State of a StateModel. + + Properties + ---------- + name : str + Name of the State + model : str + Name of the StateModel + description : str + Description of the State (read-only) + id : str + Id of the undelying State record (read-only) + transitions : set of Transition + All transitions which are available from this state (read-only) + """ + + def __init__(self, model, name): + self.name = name + self.model = model + self._id = None + self._description = None + self._transitions = None + + @property + def id(self): + return self._id + + @property + def description(self): + return self._description + + @property + def transitions(self): + return self._transitions + + def __eq__(self, other): + return (isinstance(other, State) + and self.name == other.name + and self.model == other.model) + + def __hash__(self): + return hash(self.name) + hash(self.model) + + def __repr__(self): + return f"State('{self.model}', '{self.name}')" + + def to_xml(self): + """Serialize this State to xml. + + Returns + ------- + xml : etree.Element + """ + xml = etree.Element("State") + if self.name is not None: + xml.set("name", self.name) + if self.model is not None: + xml.set("model", self.model) + return xml + + @staticmethod + def from_xml(xml): + """Create a new State instance from an xml Element. + + Parameters + ---------- + xml : etree.Element + + Returns + ------- + state : State + """ + name = xml.get("name") + model = xml.get("model") + result = State(name=name, model=model) + result._id = xml.get("id") + result._description = xml.get("description") + transitions = [Transition.from_xml(t) for t in xml if t.tag.lower() == + "transition"] + if transitions: + result._transitions = set(transitions) + + return result + + @staticmethod + def create_state_acl(acl): + from .models import ACL + state_acl = ACL() + state_acl._grants = _translate_to_state_acis(acl._grants) + state_acl._denials = _translate_to_state_acis(acl._denials) + state_acl._priority_grants = _translate_to_state_acis(acl._priority_grants) + state_acl._priority_denials = _translate_to_state_acis(acl._priority_denials) + return state_acl diff --git a/src/caosdb/configuration.py b/src/caosdb/configuration.py index 4d0797844182b8465ef5f97a869e31ee4fcaf47d..6e8a9c6ff2083b0c30324722003fb3c08a592191 100644 --- a/src/caosdb/configuration.py +++ b/src/caosdb/configuration.py @@ -28,6 +28,9 @@ except ImportError: # python3 from configparser import ConfigParser +from os import environ, getcwd +from os.path import expanduser, join, isfile + def _reset_config(): global _pycaosdbconf @@ -49,3 +52,20 @@ def configure(inifile): def get_config(): return _pycaosdbconf + + +def _read_config_files(): + """Function to read config files from different paths. Checks for path in $PYCAOSDBINI or home directory (.pycaosdb.ini) and in the current working directory (pycaosdb.ini). + + Returns: + [list]: list with successfully parsed ini-files. Order: env_var or home directory, cwd. Used for testing the function. + """ + return_var = [] + if "PYCAOSDBINI" in environ: + return_var.extend(configure(expanduser(environ["PYCAOSDBINI"]))) + else: + return_var.extend(configure(expanduser('~/.pycaosdb.ini'))) + + if isfile(join(getcwd(), "pycaosdb.ini")): + return_var.extend(configure(join(getcwd(), "pycaosdb.ini"))) + return return_var diff --git a/src/caosdb/connection/connection.py b/src/caosdb/connection/connection.py index fc699ab5db1db36bc1ee63034b6828eec4d16bc1..9e273a56778737033fda9f342f967f56946b501b 100644 --- a/src/caosdb/connection/connection.py +++ b/src/caosdb/connection/connection.py @@ -41,7 +41,11 @@ from caosdb.exceptions import (CaosDBException, HTTPClientError, HTTPResourceNotFoundError, HTTPServerError, HTTPURITooLongError) -from caosdb.version import version +try: + from caosdb.version import version +except ModuleNotFoundError: + version = "uninstalled" + from pkg_resources import resource_filename from .interface import CaosDBHTTPResponse, CaosDBServerConnection @@ -611,3 +615,11 @@ class _Connection(object): # pylint: disable=useless-object-inheritance _handle_response_status(http_response) return http_response + + def get_username(self): + """ + Return the username of the current connection. + + Shortcut for: get_connection()._authenticator._credentials_provider.username + """ + return self._authenticator._credentials_provider.username diff --git a/src/caosdb/exceptions.py b/src/caosdb/exceptions.py index c9da22ce59b4eee37ea35e58d24ef436a6e88255..fdd2e11f1dfb8857f86942df2534d732bad9a793 100644 --- a/src/caosdb/exceptions.py +++ b/src/caosdb/exceptions.py @@ -63,6 +63,13 @@ class ConfigurationError(CaosDBException): ".pycaosdb.ini. Does at least one of them exist and are they correct?") +class ServerConfigurationException(CaosDBException): + """The server is configured in a different way than expected. + + This can be for example unexpected flags or settings or missing extensions. + """ + + class HTTPClientError(CaosDBException): """HTTPClientError represents 4xx HTTP client errors.""" @@ -182,8 +189,8 @@ class TransactionError(CaosDBException): error_t. If direct_children_only is True, only direct children are checked. - Parameters: - ----------- + Parameters + ---------- error_t : EntityError error type to be checked direct_children_only: bool, optional @@ -192,8 +199,8 @@ class TransactionError(CaosDBException): children, i.e., all errors in self.all_errors are used. Default is false. - Returns: - -------- + Returns + ------- has_error : bool True if at least one of the children is of type error_t, False otherwise. diff --git a/src/caosdb/utils/caosdb_admin.py b/src/caosdb/utils/caosdb_admin.py index c2e751d95a3f56549215465eba73e4f3129aa5c8..d926419d984f1d83e5a111ac9d72c7c8c1c74571 100755 --- a/src/caosdb/utils/caosdb_admin.py +++ b/src/caosdb/utils/caosdb_admin.py @@ -73,7 +73,7 @@ def do_retrieve(args): c.append(db.Entity(id=eid)) except ValueError: c.append(db.Entity(name=i)) - c.retrieve() + c.retrieve(flags=eval(args.flags)) print(c) diff --git a/src/doc/Makefile b/src/doc/Makefile index 5458c5300efc82e55686bc1cd6934182c5c8e39a..64219c5957ee963e84f9305685f2ec4e8ed3d761 100644 --- a/src/doc/Makefile +++ b/src/doc/Makefile @@ -32,6 +32,7 @@ PY_BASEDIR = ../caosdb SOURCEDIR = . BUILDDIR = ../../build/doc + .PHONY: doc-help Makefile # Put it first so that "make" without argument is like "make help". @@ -44,4 +45,4 @@ doc-help: @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) apidoc: - @$(SPHINXAPIDOC) -o _apidoc $(PY_BASEDIR) + @$(SPHINXAPIDOC) -o _apidoc --separate $(PY_BASEDIR) diff --git a/src/doc/administration.rst b/src/doc/administration.rst index 91b85344a018618284349b4e8dd34fe6b365b94d..061acc8364d2ef62f743a20d7b9e6562baac0fc5 100644 --- a/src/doc/administration.rst +++ b/src/doc/administration.rst @@ -2,13 +2,13 @@ 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. +Call ``caosdb_admin.py --help`` to see how to use it. The most common task is to create a new user (in the CaosDB realm) and set a password for the user (note that a user typically needs to be activated):: - 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 + caosdb_admin.py create_user anna + caosdb_admin.py set_user_password anna + caosdb_admin.py add_user_roles anna administration + caosdb_admin.py activate_user anna diff --git a/src/doc/conf.py b/src/doc/conf.py index f276f325273b71d4b697bc57990259e842b2dbc3..b05fa1c71c1dcd0b59916594818449d2ebc574bd 100644 --- a/src/doc/conf.py +++ b/src/doc/conf.py @@ -8,16 +8,18 @@ # -- Path setup -------------------------------------------------------------- -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. +# If extensions (or modules to document with autodoc) are in another directory, add these +# directories to sys.path here. This is particularly necessary if this package is installed at a +# different version, for example via `pip install`. # -# import os -# import sys -# sys.path.insert(0, os.path.abspath('../caosdb')) - +# If the directory is relative to the documentation root, use os.path.abspath to make it absolute, +# like shown here. +# +import os +import sys +sys.path.insert(0, os.path.abspath('..')) -import sphinx_rtd_theme +import sphinx_rtd_theme # noqa: E402 # -- Project information ----------------------------------------------------- @@ -27,9 +29,10 @@ copyright = '2020, IndiScale GmbH' author = 'Daniel Hornung' # The short X.Y version -version = '0.4.0' +version = '0.5.2' # The full version, including alpha/beta/rc tags -release = '0.4.0-rc' +# release = '0.5.2-rc2' +release = '0.5.2' # -- General configuration --------------------------------------------------- @@ -43,6 +46,7 @@ release = '0.4.0-rc' # ones. extensions = [ 'sphinx.ext.autodoc', + 'sphinx.ext.autosectionlabel', 'sphinx.ext.intersphinx', 'sphinx.ext.napoleon', # For Google style docstrings "recommonmark", # For markdown files. @@ -54,7 +58,6 @@ templates_path = ['_templates'] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: -# source_suffix = ['.rst', '.md'] # The master toctree document. @@ -81,6 +84,7 @@ pygments_style = None # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # + html_theme = "sphinx_rtd_theme" # Theme options are theme-specific and customize the look and feel of a theme @@ -182,14 +186,22 @@ epub_exclude_files = ['search.html'] # -- Extension configuration ------------------------------------------------- +# True to prefix each section label with the name of the document it is in, followed by a colon. For +# example, index:Introduction for a section called Introduction that appears in document +# index.rst. Useful for avoiding ambiguity when the same section heading appears in different +# documents. +# +# Note: This stops "normal" links from working, so it should be kept at False. +# autosectionlabel_prefix_document = True + # -- Options for intersphinx ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html#confval-intersphinx_mapping intersphinx_mapping = { "python": ("https://docs.python.org/", None), - "caosdb-mysqlbackend": ("https://caosdb.gitlab.io/caosdb-mysqlbackend/", + "caosdb-mysqlbackend": ("https://docs.indiscale.com/caosdb-mysqlbackend/", None), - "caosdb-server": ("https://caosdb.gitlab.io/caosdb-server/", None), + "caosdb-server": ("https://docs.indiscale.com/caosdb-server/", None), } diff --git a/src/doc/configuration.md b/src/doc/configuration.md index b2de2781d5adff4d59cb3648cd912e142327f676..6e53542f661dcae94622fef24a67cecf7491df9c 100644 --- a/src/doc/configuration.md +++ b/src/doc/configuration.md @@ -50,5 +50,5 @@ debugging (which I hope will not be necessary for this tutorial) or if you want the internals of the protocol. A complete list of options can be found in the -[pycaosdb.ini file](https://gitlab.com/caosdb/caosdb-pylib/-/blob/master/examples/pycaosdb.ini) in +[pycaosdb.ini file](https://gitlab.com/caosdb/caosdb-pylib/-/blob/main/examples/pycaosdb.ini) in the examples folder of the source code. diff --git a/src/doc/index.rst b/src/doc/index.rst index 76a2f88f6d31dd9b5f17995b6d54ccc63eb33631..bd29c6c56acf5c173e94ae6471a6aeba56ea4b93 100644 --- a/src/doc/index.rst +++ b/src/doc/index.rst @@ -12,12 +12,12 @@ Welcome to PyCaosDB's documentation! Concepts <concepts> Configuration <configuration> Administration <administration> - API documentation<_apidoc/modules> + API documentation<_apidoc/caosdb> This is the documentation for the Python client library for CaosDB, ``PyCaosDB``. -This documentation helps you to :doc:`get started<getting_started>`, explains the most important -:doc:`concepts<concepts>` and offers a range of :doc:`tutorials<tutorials>`. +This documentation helps you to :doc:`get started<README_SETUP>`, explains the most important +:doc:`concepts<concepts>` and offers a range of :doc:`tutorials<tutorials/index>`. Indices and tables diff --git a/src/doc/tutorials/Data-Insertion.rst b/src/doc/tutorials/Data-Insertion.rst index 2ead1cc259c807ee12f2459f7e452558eb1b63a2..22fb9461d6916003b2dad496ff3487df335c8dcc 100644 --- a/src/doc/tutorials/Data-Insertion.rst +++ b/src/doc/tutorials/Data-Insertion.rst @@ -4,9 +4,9 @@ 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` . +Data is stored and structured in CaosDB using a concept of RecordTypes, Properties, Records etc. If +you do not know what these are, please look at the chapter :doc:`Data +Model<caosdb-server:Data-Model>` in the CaosDB server documentation. In order to insert some actual data, we need to create a data model using RecordTypes and Properties (You may skip this if you use a CaosDB @@ -35,6 +35,42 @@ two more Properties and a RecordType: container.extend([a, b, epsilon, recordtype]) container.insert() +.. _tutorial-inheritance-properties: + +Inheritance of Properties +------------------------- + +Suppose you want to create a new RecordType “2D_BarkleySimulation†+that denotes spatially extended Barkley simulations. This is a subtype +of the “BarkleySimulation†RecordType above and should have all its +parameters, i.e., properties. It may be assigned more, e.g., spatial +resolution, but we'll omit this for the sake of brevity for now. + +.. code:: python + + rt = db.RecordType(name="2D_BarkleySimulation", + description="Spatially extended Barkley simulation") + # inherit all properties from the BarkleySimulation RecordType + rt.add_parent(name="BarkleySimulation", inheritance="all") + rt.insert() + + print(rt.get_property(name="epsilon").importance) ### rt has a "epsilon" property with the same importance as "BarkleySimulation" + +The parameter ``inheritance=(obligatory|recommended|fix|all|none)`` of +:py:meth:`Entity.add_parent()<caosdb.common.models.Entity.add_parent>` tells the server to assign +all properties of the parent RecordType with the chosen importance (and properties with a higher +importance) to the child RecordType +automatically upon insertion. See the chapter on `importance +<https://docs.indiscale.com/caosdb-server/specification/RecordType.html#importance>`_ in the +documentation of the CaosDB server for more information on the importance and inheritance of +properties. + +.. note:: + + The inherited properties will only be visible after the insertion since they are set by the + CaosDB server, not by the Python client. + + Insert Actual Data ~~~~~~~~~~~~~~~~~~ @@ -108,32 +144,6 @@ Useful is also, that you can insert directly tabular data. 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 ----------- diff --git a/src/doc/tutorials/errors.rst b/src/doc/tutorials/errors.rst index ba386dc31baad1021f01d53b2b12a2623d8278ad..37c53c9b527a0435f9f24ae6c6e71687e73eb963 100644 --- a/src/doc/tutorials/errors.rst +++ b/src/doc/tutorials/errors.rst @@ -1,53 +1,176 @@ Error Handling --------------- +============== -HeartDBException -~~~~~~~~~~~~~~~~ +In case of erroneous transactions, connection problems and a lot of +other cases, PyCaosDB may raise specific errors in order to pinpoint +the problem as precisely as possible. Some of these errors a +representations of errors in the CaosDB server, others stem from +problems that occurred on the client side. + +The errors and exceptions are ordered hierarchically form the most +general exceptions to specific transaction or connection problems. The +most important error types and the hierarchy will be explained in the +following. For more information on specific error types, see also the +:doc:`source code<../_apidoc/caosdb.exceptions>`. + +.. note:: + + Starting from PyCaosDB 0.5, the error handling has changed + significantly. New error classes have been introduced and the + behavior of ``TransactionError`` and ``EntityError`` has been + re-worked. In the following, only the "new" errors are + discussed. Please refer to the documentation of PyCaosDB 0.4.1 and + earlier for the old error handling. + +CaosDBException +---------------- + +``CaosDBException`` is the most generic exception and all other error classes inherit +from this one. Because of its generality, it doesn't tell you much +except that some component of PyCaosDB raised an exception. If you +want to catch all possible CaosDB errors, this is the class to use. TransactionError -~~~~~~~~~~~~~~~~ +---------------- Every transaction (calling ``insert``, ``update``, ``retrieve``, or ``delete`` on a container or an entity) may finish with errors. They indicate, for instance, that an entity does not exist or that you need to specify a data type for your property and much more. If and only if one or more errors occur during a transaction a ``TransactionError`` -will be raised by the transaction method. The ``TransactionError`` class -is a container for all errors which occur during a transaction. It 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 +will be raised by the transaction method. The ``TransactionError`` +class is a container for all errors which occur during a +transaction. It usually contains one or more :ref:`entity +errors<EntityError>` which you can inspect in order to learn why the +transaction failed. For this inspection, there are some helpful +attributes and methods provided by the ``TransactionError``: + +* ``entities``: a list of all entities that directly caused at least one error + in this transaction. + +* ``errors``: a list of all ``EntityError`` objects that directly caused the + transaction to fail. + +* ``all_entities``, ``all_errors``: sets of all entities and errors + that, directly or indirectly, caused either this ``TransactionError`` or any of the + ``EntityError`` objects it contains. + +* ``has_error(error_t)``: Check whether an error of type ``error_t`` + occurred during the transaction. + +Additionally, ``print(transaction_error)`` prints a tree-like representation of all errors regarding the transaction in question. EntityError -~~~~~~~~~~~ +----------- -An ``EntityError`` 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). +An ``EntityError`` specifies the entity and the error proper that +caused a transaction to fail. It is never raised on its own but is +contained in a ``TransactionError`` (which may or may not contain +other ``EntityError`` objects) which is then raised. ``EntityError`` +has several :ref:`subclasses<Special Errors>` that further specify the +error that occurred. -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]``. +The ``EntityError`` class is in fact a subclass of +``TransactionError``. Thus, it has the same methods and attributes as +the ``TransactionError`` explained +:ref:`above<TransactionError>`. This is important in case of an +``EntityError`` that was caused by other faulty entities (e.g., broken +parents or properties). In that case these problematic entities and +errors can again be inspected by visiting the ``entities`` and +``errors`` lists as above. Special Errors ~~~~~~~~~~~~~~ -Subclasses of ``EntityError`` for special purposes: \* -``EntityDoesNotExistError`` \* ``EntityHasNoDataTypeError`` \* -``UniqueNamesError`` \* ``UnqualifiedParentsError`` - 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. +Subclasses of ``EntityError`` for special purposes: + +* ``EntityDoesNotExistError`` + +* ``EntityHasNoDataTypeError`` + +* ``UniqueNamesError`` + +* ``UnqualifiedParentsError`` + +* ``UnqualifiedPropertiesError`` + +* ``ConsistencyError`` + +* ``AuthorizationError`` + +* ``AmbiguousEntityError`` + +BadQueryError +------------- + +A ``BadQueryError`` is raised when a query could not be processed by +the server. In contrast to a ``TransactionError`` it is not +necessarily caused by problematic entities or +containers. ``BadQueryError`` has the two important subclasses +``EmptyUniqueQueryError`` and ``QueryNotUniqueError`` for queries with +``unique=True`` which found no or ambiguous entities, respectively. + +HTTP Errors +----------- + +An ``HTTPClientError`` or an ``HTTPServerError`` is raised in case of +http(s) connection problems caused by the Python client or the CaosDB +server, respectively. There are the following subclasses of +``HTTPClientError`` that are used to specify the connection problem: + +* ``HTTPURITooLongError``: The URI of the request was too long to be + processed by the server. + +* ``HTTPForbiddenError``: You're not allowed to access this resource. + +* ``HTTPResourceNotFoundError``: The requested resource doesn't exist. + +Other Errors +------------ + +There are further subclasses of ``CaosDBException`` that are raised in +case of faulty configurations or other problems. They should be rather +self-explanatory from their names; see the :doc:`source code<../_apidoc/caosdb.exceptions>` +for further information. + +* ``ConfigurationError`` + +* ``LoginFailedError`` + +* ``MismatchingEntitiesError`` + +* ``ServerConfigurationException`` + +Examples +-------- + +.. code-block:: python3 + + import caosdb as db + + def link_and_insert(entity, linked, link=True): + """Link the ENTITY to LINKED and insert it.""" + if link: + entity.add_property(db.Property(name="link", value=linked)) + try: + entity.insert() + except db.TransactionError as tre: + # Unique names problem may be worked around by using another name + if tre.has_error(db.UniqueNamesError): + for ent_error in tre.errors: + if (isinstance(ent_error, db.UniqueNamesError) + and entity in ent_error.entities): + entity.name = entity.name + "_new" # Try again with new name. + link_and_insert(entity, linked, link=False) + break + # Unqualified properties will be handled by the caller + elif tre.has_error(db.UnqualifiedPropertiesError): + for ent_error in tre.errors: + if (isinstance(ent_error, db.UnqualifiedPropertiesError_ + and entity in ent_error.entities): + raise RuntimeError("One of the properties was unqualified: " + str(ent_error)) + # Other problems are not covered by this tutorial + else: + raise NotImplementedError("Unhandled TransactionError: " + str(tre)) diff --git a/src/doc/tutorials/first_steps.rst b/src/doc/tutorials/first_steps.rst index 3eb804776960ba93acb9baf78824f0cf8a06e1f7..34b96bbeca416107fb34feb4707b9ef46fc49fe7 100644 --- a/src/doc/tutorials/first_steps.rst +++ b/src/doc/tutorials/first_steps.rst @@ -2,14 +2,15 @@ First Steps =========== You should have a working connection to a CaosDB instance now. If not, please check out the -:doc:`Getting Started secton</getting_started>`. +:doc:`Getting Started secton</README_SETUP>`. If you are not yet familiar with Records, RecordTypes and Properties used in CaosDB, -please check out the respective part in the `Web Interface Tutorial`_. -You should also know the basics of the CaosDB Query Language (a tutorial is here_). +please check out the respective part in the `Web Interface tutorial`_. +You should also know the basics of the CaosDB Query Language (a tutorial is +`here <https://docs.indiscale.com/caosdb-webui/tutorials/query.html>`_). -We recommend that you connect to the demo instance in order to try out the following -examples. You can do this with +We recommend that you connect to the `demo instance`_ (hosted by `Indiscale`_) in order to try out +the following examples. You can do this with >>> import caosdb as db >>> _ = db.configure_connection( @@ -19,7 +20,7 @@ examples. You can do this with ... password="caosdb") or by using corresponding settings in the configuration file -(see :doc:`Getting Started secton</getting_started>`.). +(see :doc:`Getting Started secton</README_SETUP>`.). However, you can also translate the examples to the data model that you have at hand. Let's start with a simple query. @@ -124,9 +125,6 @@ the result Records and their properties. The next tutorial shows how to make some meaningful use of this. - -.. _here: https://gitlabio.something .. _`demo instance`: https://demo.indiscale.com .. _`IndiScale`: https://indiscale.com -.. _`Web Interface Tutorial`: https://caosdb.gitlab.io/caosdb-webui/tutorials/model.html -.. _here: https://caosdb.gitlab.io/caosdb-webui/tutorials/cql.html +.. _`Web Interface tutorial`: https://docs.indiscale.com/caosdb-webui/tutorials/first_steps.html diff --git a/unittests/docker/Dockerfile b/unittests/docker/Dockerfile index e41fb91b3e9ae54cff277519c03f481c21264e9e..7fa3f75bd198724628dee48ab328829fa071a639 100644 --- a/unittests/docker/Dockerfile +++ b/unittests/docker/Dockerfile @@ -5,6 +5,6 @@ RUN apt-get update && \ curl pycodestyle \ python3-sphinx ARG COMMIT="dev" -RUN git clone -b dev https://gitlab.com/caosdb/caosdb-pylib.git && \ +RUN git clone -b dev https://gitlab.indiscale.com/caosdb/src/caosdb-pylib.git && \ cd caosdb-pylib && git checkout $COMMIT && pip3 install . RUN pip3 install recommonmark sphinx-rtd-theme diff --git a/unittests/test_configuration.py b/unittests/test_configuration.py index 76445b6f262120d6a29c73527a9bf042f85f8a05..b135e7cd65b11be7cb6c4ef2237a41a6639ccbb7 100644 --- a/unittests/test_configuration.py +++ b/unittests/test_configuration.py @@ -22,19 +22,45 @@ # ** end header # +import pytest import caosdb as db +from os import environ, getcwd, remove +from os.path import expanduser, isfile, join from pytest import raises -def test_config_ini_via_envvar(): - from os import environ - from os.path import expanduser +@pytest.fixture +def temp_ini_files(): + created_temp_ini_cwd = False + created_temp_ini_home = False + if not isfile(join(getcwd(), "pycaosdb.ini")): + open("pycaosdb.ini", 'a').close() # create temporary ini file + created_temp_ini_cwd = True + if not isfile(expanduser("~/.pycaosdb.ini")): + open(expanduser("~/.pycaosdb.ini"), 'a').close() # create temporary ini file in home directory + created_temp_ini_home = True + yield 0 + if created_temp_ini_cwd: + remove("pycaosdb.ini") + if created_temp_ini_home: + remove(expanduser("~/.pycaosdb.ini")) + environ["PYCAOSDBINI"] = "~/.pycaosdb.ini" + + +def test_config_ini_via_envvar(temp_ini_files): with raises(KeyError): environ["PYCAOSDBINI"] environ["PYCAOSDBINI"] = "bla bla" assert environ["PYCAOSDBINI"] == "bla bla" - assert db.configuration.configure(environ["PYCAOSDBINI"]) == [] + # test wrong configuration file in envvar + assert not expanduser(environ["PYCAOSDBINI"]) in db.configuration._read_config_files() + # test good configuration file in envvar environ["PYCAOSDBINI"] = "~/.pycaosdb.ini" - assert db.configuration.configure(expanduser(environ["PYCAOSDBINI"])) == [expanduser("~/.pycaosdb.ini")] + assert expanduser("~/.pycaosdb.ini") in db.configuration._read_config_files() + # test without envvar + environ.pop("PYCAOSDBINI") + assert expanduser("~/.pycaosdb.ini") in db.configuration._read_config_files() + # test configuration file in cwd + assert join(getcwd(), "pycaosdb.ini") in db.configuration._read_config_files() diff --git a/unittests/test_query.py b/unittests/test_query.py index f4b3ee9762d9d76b65ace9a5b9b3f4039c0c6919..12622ea486dda717ca1fbc1255510575c5e0c8e6 100644 --- a/unittests/test_query.py +++ b/unittests/test_query.py @@ -26,20 +26,23 @@ import caosdb as db def test_query_parsing(): - s = '<Query string="FIND bla" results="0" cached="true"/>' + s = '<Query string="FIND bla" results="0" cached="true" etag="asdf"/>' q = db.Query(etree.fromstring(s)) assert q.q == "FIND bla" assert q.results == 0 assert q.cached is True + assert q.etag == "asdf" - s = '<Query string="COUNT bla" results="1" cached="false"/>' + s = '<Query string="COUNT bla" results="1" cached="false" etag="asdf"/>' q = db.Query(etree.fromstring(s)) assert q.q == "COUNT bla" assert q.results == 1 assert q.cached is False + assert q.etag == "asdf" s = '<Query string="COUNT blub" results="4"/>' q = db.Query(etree.fromstring(s)) assert q.q == "COUNT blub" assert q.results == 4 assert q.cached is False + assert q.etag is None diff --git a/unittests/test_state.py b/unittests/test_state.py new file mode 100644 index 0000000000000000000000000000000000000000..202c7a02af3db28434406626e5164def46febed7 --- /dev/null +++ b/unittests/test_state.py @@ -0,0 +1,77 @@ +import pytest +import caosdb as db +from caosdb import State, Transition +from caosdb.common.models import parse_xml, ACL +from lxml import etree + + +def test_state_xml(): + state = State(model="model1", name="state1") + xml = etree.tostring(state.to_xml()) + + assert xml == b'<State name="state1" model="model1"/>' + state = State.from_xml(etree.fromstring(xml)) + assert state.name == "state1" + assert state.model == "model1" + + assert xml == etree.tostring(state.to_xml()) + + +def test_entity_xml(): + r = db.Record() + assert r.state is None + r.state = State(model="model1", name="state1") + + xml = etree.tostring(r.to_xml()) + assert xml == b'<Record><State name="state1" model="model1"/></Record>' + + r = parse_xml(xml) + assert r.state == State(model="model1", name="state1") + + +def test_description(): + xml = b'<State name="state1" model="model1"/>' + state = State.from_xml(etree.fromstring(xml)) + assert state.description is None + + with pytest.raises(AttributeError): + state.description = "test" + + xml = b'<State name="state1" model="model1" description="test2"/>' + state = State.from_xml(etree.fromstring(xml)) + assert state.description == "test2" + + +def test_id(): + xml = b'<State name="state1" model="model1"/>' + state = State.from_xml(etree.fromstring(xml)) + assert state.id is None + + with pytest.raises(AttributeError): + state.id = "2345" + + xml = b'<State name="state1" model="model1" id="1234"/>' + state = State.from_xml(etree.fromstring(xml)) + assert state.id == "1234" + + +def test_create_state_acl(): + acl = ACL() + acl.grant(role="role1", permission="DO:IT") + acl.grant(role="?OWNER?", permission="DO:THAT") + state_acl = State.create_state_acl(acl) + assert state_acl.get_permissions_for_role("?STATE?role1?") == {"DO:IT"} + assert state_acl.get_permissions_for_role("?STATE??OWNER??") == {"DO:THAT"} + + +def test_transitions(): + xml = b'<State name="state1" model="model1"/>' + state = State.from_xml(etree.fromstring(xml)) + assert state.transitions is None + + with pytest.raises(AttributeError): + state.transitions = [] + + xml = b'<State name="state1" model="model1" id="1234"><Transition name="t1"><FromState name="state1"/><ToState name="state2"/></Transition></State>' + state = State.from_xml(etree.fromstring(xml)) + assert state.transitions == set([Transition(name="t1", from_state="state1", to_state="state2")])