diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 792ab22f1524f8d4dc1db90f2b65c7f8f28f90ed..0430a4f6b5ac08d4ab38f00bff78b845e11fb97e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -54,18 +54,25 @@ pylint: allow_failure: true # run unit tests -unittest_py3.8: +unittest_py3.7: tags: [ docker ] stage: test needs: [ ] - image: python:3.8 + image: python:3.7 script: &python_test_script # Python docker has problems with tox and pip so use plain pytest here - touch ~/.pycaosdb.ini - - pip install nose pytest pytest-cov python-dateutil jsonschema==4.0.1 + - pip install nose pytest pytest-cov python-dateutil jsonschema>=4.4.0 - pip install . - python -m pytest unittests +unittest_py3.8: + tags: [ docker ] + stage: test + needs: [ ] + image: python:3.8 + script: *python_test_script + # This needs to be changed once Python 3.9 isn't the standard Python in Debian # anymore. unittest_py3.9: @@ -86,6 +93,13 @@ unittest_py3.10: image: python:3.10 script: *python_test_script +unittest_py3.11: + tags: [ docker ] + stage: test + needs: [ ] + image: python:3.11 + script: *python_test_script + # Trigger building of server image and integration tests trigger_build: stage: deploy diff --git a/.gitlab/issue_templates/Default.md b/.gitlab/issue_templates/Default.md new file mode 100644 index 0000000000000000000000000000000000000000..aa1a65aca363b87aff50280e1a86824009d2098b --- /dev/null +++ b/.gitlab/issue_templates/Default.md @@ -0,0 +1,28 @@ +## Summary + +*Please give a short summary of what the issue is.* + +## Expected Behavior + +*What did you expect how the software should behave?* + +## Actual Behavior + +*What did the software actually do?* + +## Steps to Reproduce the Problem + +*Please describe, step by step, how others can reproduce the problem. Please try these steps for yourself on a clean system.* + +1. +2. +3. + +## Specifications + +- Version: *Which version of this software?* +- Platform: *Which operating system, which other relevant software versions?* + +## Possible fixes + +*Do you have ideas how the issue can be resolved?* diff --git a/.gitlab/merge_request_templates/Default.md b/.gitlab/merge_request_templates/Default.md new file mode 100644 index 0000000000000000000000000000000000000000..35c6d01c5904289b77fc7f1de9419ef91a1510e9 --- /dev/null +++ b/.gitlab/merge_request_templates/Default.md @@ -0,0 +1,54 @@ +# Summary + +*Insert a meaningful description for this merge request here: What is the new/changed behavior? +Which bug has been fixed? Are there related issues?* + + +# Focus + +*Point the reviewer to the core of the code change. Where should they start reading? What should +they focus on (e.g. security, performance, maintainability, user-friendliness, compliance with the +specs, finding more corner cases, concrete questions)?* + + +# Test Environment + +*How to set up a test environment for manual testing?* + + +# Check List for the Author + +Please, prepare your MR for a review. Be sure to write a summary and a focus and create gitlab +comments for the reviewer. They should guide the reviewer through the changes, explain your changes +and also point out open questions. For further good practices have a look at [our review +guidelines](https://gitlab.com/caosdb/caosdb/-/blob/dev/REVIEW_GUIDELINES.md) + +- [ ] All automated tests pass +- [ ] Reference related issues +- [ ] Up-to-date CHANGELOG.md (or not necessary) +- [ ] Up-to-date JSON schema (or not necessary) +- [ ] Appropriate user and developer documentation (or not necessary) + - How do I use the software? Assume "stupid" users. + - How do I develop or debug the software? Assume novice developers. +- [ ] Annotations in code (Gitlab comments) + - Intent of new code + - Problems with old code + - Why this implementation? + + +# Check List for the Reviewer + +- [ ] I understand the intent of this MR +- [ ] All automated tests pass +- [ ] Up-to-date CHANGELOG.md (or not necessary) +- [ ] Appropriate user and developer documentation (or not necessary) +- [ ] The test environment setup works and the intended behavior is reproducible in the test + environment +- [ ] In-code documentation and comments are up-to-date. +- [ ] Check: Are there specifications? Are they satisfied? + +For further good practices have a look at [our review guidelines](https://gitlab.com/caosdb/caosdb/-/blob/dev/REVIEW_GUIDELINES.md). + + +/assign me +/target_branch dev diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d63406b65c1d466c69cd6e90593bc8ce3095734..dfa9efbddabc8958845d33ac8e74c99da342e5e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Documentation ### -* [Fixed](https://gitlab.com/caosdb/caosdb-pylib/-/issues/79) `{action}_entity_permissions` help line. +## [0.11.1] - 2023-03-07 ## +(Florian Spreckelsen) + +### Changed ### + +* Renamed `caosdb.common.models._Parents` to `caosdb.common.models._ParentList`. + +### Fixed ### + +* [caosdb-pylib#90](https://gitlab.com/caosdb/caosdb-pylib/-/issues/90): `Entity.get_parents_recursively()` did not work for unretrieved parents. + +## [0.11.0] - 2023-01-19 ## +(Florian Spreckelsen) + +### Added ### + +* `apiutils.EntityMergeConflictError` class for unresesolvable merge conflicts + when merging two entities +* Re-introduced support for Python 3.7 + +### Changed ### + +* `apiutils.merge_entities` now raises an `EntityMergeConflictError` in case of + unresolvable merge conflicts. + +### Fixed ### + +* [#82](https://gitlab.com/caosdb/caosdb-pylib/-/issues/82) Merging an entity + with properties with missing datatype leads to Exception - The correct + exception is raised in case of a missing LIST datatype. + +### Documentation ### + +* [Fixed](https://gitlab.com/caosdb/caosdb-pylib/-/issues/79) + `{action}_entity_permissions` help line. ## [0.10.0] - 2022-11-14 (Florian Spreckelsen) diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 0000000000000000000000000000000000000000..910e40a2193d527fc8e4eb68c4ca6b10a28d3630 --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,25 @@ +cff-version: 1.2.0 +message: "If you use this software, please cite it as below." +authors: + - family-names: Fitschen + given-names: Timm + orcid: https://orcid.org/0000-0002-4022-432X + - family-names: Schlemmer + given-names: Alexander + orcid: https://orcid.org/0000-0003-4124-9649 + - family-names: Hornung + given-names: Daniel + orcid: https://orcid.org/0000-0002-7846-6375 + - family-names: tom Wörden + given-names: Henrik + orcid: https://orcid.org/0000-0002-5549-578X + - family-names: Parlitz + given-names: Ulrich + orcid: https://orcid.org/0000-0003-3058-1435 + - family-names: Luther + given-names: Stefan + orcid: https://orcid.org/0000-0001-7214-8125 +title: CaosDB - Pylib +version: 0.11.1 +doi: 10.3390/data4020083 +date-released: 2022-11-14 \ No newline at end of file diff --git a/README.md b/README.md index 602df33cecfc8ec37fd791e3257221e66f120cb3..7215591a4f31f1946029442de291eb9ccf9beea1 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ By participating, you are expected to uphold our [Code of Conduct](https://gitla * 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 +* You can also contact us at **info (AT) caosdb.org** and join the CaosDB community on [#caosdb:matrix.org](https://matrix.to/#/!unwwlTfOznjEnMMXxf:matrix.org). diff --git a/README_SETUP.md b/README_SETUP.md index 48928d6c3f2c878a8d8b268b36ed2cdeba7f8014..01eea85188078ae6f2fe226e89e5c227497b4bd0 100644 --- a/README_SETUP.md +++ b/README_SETUP.md @@ -109,6 +109,8 @@ Now would be a good time to continue with the [tutorials](tutorials/index). - Run a specific test function: e.g. `tox -- unittests/test_schema.py::test_config_files` ## Documentation ## +We use sphinx to create the documentation. Docstrings in the code should comply +with the Googly style (see link below). Build documentation in `build/` with `make doc`. @@ -118,5 +120,11 @@ Build documentation in `build/` with `make doc`. - `sphinx-autoapi` - `recommonmark` +### How to contribute ### + +- [Google Style Python Docstrings](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html) +- [Google Style Python Docstrings 2nd reference](https://github.com/google/styleguide/blob/gh-pages/pyguide.md#38-comments-and-docstrings) +- [References to other documentation](https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html#role-external) + ### Troubleshooting ### If the client is to be executed directly from the `/src` folder, an initial `.\setup.py install --user` must be called. diff --git a/RELEASE_GUIDELINES.md b/RELEASE_GUIDELINES.md index 863afb8f3ac7d6770c372620523638b900785227..95ee8e314871153476c30790a456242e38dcaf9e 100644 --- a/RELEASE_GUIDELINES.md +++ b/RELEASE_GUIDELINES.md @@ -24,6 +24,7 @@ guidelines of the CaosDB Project - `version` variables in `src/doc/conf.py` - Version on [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. + - `CITATION.cff` (update version and date) 5. Merge the release branch into the main branch. diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index ca6aad829a3e0607292cf69b8b1d4b7f7758993e..0000000000000000000000000000000000000000 --- a/pytest.ini +++ /dev/null @@ -1,3 +0,0 @@ -[pytest] -testpaths=unittests -addopts=-x -vv --cov=caosdb diff --git a/setup.py b/setup.py index 35d6d69a83338a0dc543b4b6438b56c1372b7237..a6d3594c7f37820cd4f5e66cb9761651d0c47907 100755 --- a/setup.py +++ b/setup.py @@ -47,8 +47,8 @@ from setuptools import find_packages, setup ISRELEASED = False MAJOR = 0 -MINOR = 10 -MICRO = 1 +MINOR = 11 +MICRO = 2 # Do not tag as pre-release until this commit # https://github.com/pypa/packaging/pull/515 # has made it into a release. Probably we should wait for pypa/packaging>=21.4 @@ -171,10 +171,10 @@ def setup_package(): "Topic :: Scientific/Engineering :: Information Analysis", ], packages=find_packages('src'), - python_requires='>=3.8', + python_requires='>=3.7', package_dir={'': 'src'}, install_requires=['lxml>=4.6.3', - "requests[socks]>=2.28.1", + "requests[socks]>=2.26", "python-dateutil>=2.8.2", 'PyYAML>=5.4.1', 'future', diff --git a/src/caosdb/apiutils.py b/src/caosdb/apiutils.py index 0862cf9f32575b9773bc16d845bb459d67b0140c..f3195b8e152f0cb13e5dab3e3a449b7bb36623b4 100644 --- a/src/caosdb/apiutils.py +++ b/src/caosdb/apiutils.py @@ -27,12 +27,13 @@ Some simplified functions for generation of records etc. """ +import logging import sys import tempfile import warnings + from collections.abc import Iterable from subprocess import call - from typing import Optional, Any, Dict, List from caosdb.common.datatype import (BOOLEAN, DATETIME, DOUBLE, FILE, INTEGER, @@ -40,8 +41,13 @@ from caosdb.common.datatype import (BOOLEAN, DATETIME, DOUBLE, FILE, INTEGER, from caosdb.common.models import (Container, Entity, File, Property, Query, Record, RecordType, execute_query, get_config, SPECIAL_ATTRIBUTES) +from caosdb.exceptions import CaosDBException -import logging + +class EntityMergeConflictError(CaosDBException): + """An error that is raised in case of an unresolvable conflict when merging + two entities. + """ def new_record(record_type, name=None, description=None, @@ -365,14 +371,15 @@ def empty_diff(old_entity: Entity, new_entity: Entity, compare_referenced_record return True -def merge_entities(entity_a: Entity, entity_b: Entity, merge_references_with_empty_diffs=True, force=False): - """ - Merge entity_b into entity_a such that they have the same parents and properties. +def merge_entities(entity_a: Entity, entity_b: Entity, merge_references_with_empty_diffs=True, + force=False): + """Merge entity_b into entity_a such that they have the same parents and properties. - datatype, unit, value, name and description will only be changed in entity_a if they - are None for entity_a and set for entity_b. If there is a corresponding value - for entity_a different from None a RuntimeError will be raised informing of an - unresolvable merge conflict. + datatype, unit, value, name and description will only be changed in entity_a + if they are None for entity_a and set for entity_b. If there is a + corresponding value for entity_a different from None, an + EntityMergeConflictError will be raised to inform about an unresolvable merge + conflict. The merge operation is done in place. @@ -392,13 +399,18 @@ def merge_entities(entity_a: Entity, entity_b: Entity, merge_references_with_emp force : bool, optional If True, in case `entity_a` and `entity_b` have the same properties, the values of `entity_a` are replaced by those of `entity_b` in the merge. - If `False`, a RuntimeError is raised instead. Default is False. + If `False`, an EntityMergeConflictError is raised instead. Default is False. Returns ------- entity_a : Entity The initial entity_a after the in-place merge + Raises + ------ + EntityMergeConflictError + In case of an unresolvable merge conflict. + """ logging.warning( @@ -433,8 +445,8 @@ def merge_entities(entity_a: Entity, entity_b: Entity, merge_references_with_emp setattr(entity_a.get_property(key), attribute, diff_r2["properties"][key][attribute]) else: - raise RuntimeError( - f"Merge conflict:\nEntity a ({entity_a.id}, {entity_a.name}) " + raise EntityMergeConflictError( + f"Entity a ({entity_a.id}, {entity_a.name}) " f"has a Property '{key}' with {attribute}=" f"{diff_r2['properties'][key][attribute]}\n" f"Entity b ({entity_b.id}, {entity_b.name}) " @@ -463,7 +475,9 @@ def merge_entities(entity_a: Entity, entity_b: Entity, merge_references_with_emp # force overwrite setattr(entity_a, special_attribute, sa_b) else: - raise RuntimeError("Merge conflict.") + raise EntityMergeConflictError( + f"Conflict in special attribute {special_attribute}:\n" + f"A: {sa_a}\nB: {sa_b}") return entity_a diff --git a/src/caosdb/common/models.py b/src/caosdb/common/models.py index 7000ede917995c6c01b78a822c2d39ac626fcc23..08fcd0206b9df22902e80277d0e57b5f67c76db5 100644 --- a/src/caosdb/common/models.py +++ b/src/caosdb/common/models.py @@ -34,6 +34,7 @@ All additional classes are either important for the entities or the transactions. """ from __future__ import print_function, unicode_literals +from __future__ import annotations # Can be removed with 3.10. import re import sys @@ -62,7 +63,6 @@ from caosdb.exceptions import (AmbiguousEntityError, AuthorizationError, EntityDoesNotExistError, EntityError, EntityHasNoDatatypeError, HTTPURITooLongError, MismatchingEntitiesError, QueryNotUniqueError, - ServerConfigurationException, TransactionError, UniqueNamesError, UnqualifiedParentsError, UnqualifiedPropertiesError) @@ -83,7 +83,7 @@ SPECIAL_ATTRIBUTES = ["name", "role", "datatype", "description", "id", "path", "checksum", "size"] -class Entity(object): +class Entity: """Entity is a generic CaosDB object. @@ -102,6 +102,8 @@ class Entity(object): self._checksum = None self._size = None self._upload = None + # If an entity is used (e.g. as parent), it is wrapped instead of being used directly. + # see Entity._wrap() self._wrapped_entity = None self._version = None self._cuid = None @@ -112,7 +114,7 @@ class Entity(object): self.value = value self.messages = _Messages() self.properties = _Properties() - self.parents = _Parents() + self.parents = _ParentList() self.path = None self.file = None self.unit = None @@ -531,7 +533,7 @@ class Entity(object): value=value, unit=unit) if abstract_property is not None: - new_property._wrap(property) + new_property._wrap(abstract_property) # FIXME: this really necessary? @@ -622,26 +624,46 @@ class Entity(object): return self - def has_parent(self, parent, recursive=True, - check_name=True, check_id=False): - """Checks if this entity has a given parent. + def has_parent(self, parent: Entity, recursive: bool = True, retrieve: bool = True, + check_name: bool = True, check_id: bool = False): + """Check if this entity has a given parent. If 'check_name' and 'check_id' are both False, test for identity on the Python level. Otherwise use the name and/or ID for the check. Note that, if checked, name or ID should not be None, lest the check fail. - @param parent: Check for this parent. - @param recursive: Whether to check recursively. - @param check_name: Whether to use the name for ancestry check. - @param check_id: Whether to use the ID for ancestry check. - @return: True if 'parent' is a true parent, False otherwise. - """ +Parameters +---------- + +parent: Entity + Check for this parent. + +recursive: bool, optional + Whether to check recursively. + +check_name: bool, optional + Whether to use the name for ancestry check. + +check_id: bool, optional + Whether to use the ID for ancestry check. + +retrieve: bool, optional + If False, do not retrieve parents from the server. + +Returns +------- +out: bool + True if ``parent`` is a true parent, False otherwise. +""" if recursive: - parents = self.get_parents_recursively() + parents = self.get_parents_recursively(retrieve=retrieve) else: - parents = [pp._wrapped_entity for pp in self.parents] + if retrieve: + parents = [pp.retrieve()._wrapped_entity for pp in self.parents] + else: + parents = [pp._wrapped_entity for pp in self.parents] if not (check_name or check_id): return parent in parents @@ -660,39 +682,61 @@ class Entity(object): def get_parents(self): """Get all parents of this entity. - @return: _Parents(list) + @return: _ParentList(list) """ return self.parents - def get_parents_recursively(self): + def get_parents_recursively(self, retrieve: bool = True): """Get all ancestors of this entity. - @return: list of Entities - """ +Parameters +---------- - all_parents = _Parents() - self._get_parent_recursively(all_parents) +retrieve: bool, optional + If False, do not retrieve parents from the server. + +Returns +------- +out: List[Entity] + The parents of this Entity +""" + + all_parents = [] + self._get_parent_recursively(all_parents, retrieve=retrieve) return all_parents - def _get_parent_recursively(self, all_parents): + def _get_parent_recursively(self, all_parents: list, retrieve: bool = True): """Get all ancestors with a little helper. As a side effect of this method, the ancestors are added to all_parents. - @param all_parents: The added parents so far. + @param all_parents: list, The added parents so far. @return: None, but see side effects. """ for parent in self.parents: + # TODO: + # Comment on _wrap and _wrapped_entity + # Currently, I (henrik) do not why the wrapping is necessary (and it is not + # documented). However, the following illustrates, why I think, it is a bad idea. + # First you add a parent with rec.add_parent(parent), but then you cannot access + # attributes of parent when you use rec.parents[0] for example becasue you do not get + # the same object but a wrapping object and you need to know that you only get the + # original by accessing the private (!) _wrapped_entity object. w_parent = parent._wrapped_entity + if retrieve: + parent.retrieve() + for next_parent in parent.parents: + w_parent.add_parent(next_parent) - if w_parent not in all_parents: + if (w_parent.id, w_parent.name) not in [ + (all_p.id, all_p.name) for all_p in all_parents]: all_parents.append(w_parent) - w_parent._get_parent_recursively(all_parents) + w_parent._get_parent_recursively(all_parents, retrieve=retrieve) def get_parent(self, key): """Return the first parent matching the key or None if no match exists. @@ -1136,7 +1180,7 @@ class Entity(object): else: raise TypeError( 'Child was neither a Property, nor a Parent, nor a Message.\ - Was ' + str(type(child))) + Was ' + str(type(child)) + "\n" + str(child)) # add VALUE value = None @@ -1297,6 +1341,12 @@ class Entity(object): flags=flags)[0] def _wrap(self, entity): + """ + When entity shall be used as parent or property it is not added to the corresponding list + (such as the parent list) directly, but another Entity object is created and the original + Entity is wrapped using this function + TODO: document here and in dev docs why this is done. + """ self._wrapped_entity = entity return self @@ -1381,12 +1431,15 @@ def _parse_value(datatype, value): # reference via name return str(value) - except TypeError: + except TypeError as te: # deal with invalid XML: List of values without appropriate datatype if isinstance(value, list): - raise ServerConfigurationException( - "The server sent an invalid XML: List valued properties must be announced by " + raise TypeError( + "Invalid datatype: List valued properties must be announced by " "the datatype.\n" + f"Datatype: {datatype}\nvalue: {value}") + else: + # Everything else that's not related to wrong list assignments + raise te def _log_request(request, xml_body=None): @@ -2092,7 +2145,8 @@ class _Properties(list): raise KeyError(str(prop) + " not found.") -class _Parents(list): +class _ParentList(list): + # TODO unclear why this class is private. Isn't it use full for users? def _get_entity_by_cuid(self, cuid): ''' @@ -2695,9 +2749,11 @@ class Container(list): elif isinstance(entity, QueryTemplate): super().append(entity) else: - raise TypeError( - "Entity was neither an id nor a name nor an entity." + - " (was " + str(type(entity)) + ")") + warn("Entity was neither an id nor a name nor an entity." + + " (was " + str(type(entity)) + ":\n" + str(entity) + ")") + # raise TypeError( + # "Entity was neither an id nor a name nor an entity." + + # " (was " + str(type(entity)) + "\n" + str(entity) + ")") return self @@ -3645,6 +3701,7 @@ class Container(list): for p in e.get_properties(): if p.id is None: if p.name is not None: + # TODO using try except for normal execution flow is bad style try: w = self.get_entity_by_name(p.name) p._wrap(w) @@ -3656,6 +3713,7 @@ class Container(list): for p in e.get_parents(): if p.id is None: if p.name is not None: + # TODO using try except for normal execution flow is bad style try: p._wrap(self.get_entity_by_name(p.name)) except KeyError: diff --git a/src/caosdb/connection/connection.py b/src/caosdb/connection/connection.py index a2802848af7bae8fb65378532156d8469f31a9b8..46dadea9dfcfa6e614493b75d709f604aa188ef6 100644 --- a/src/caosdb/connection/connection.py +++ b/src/caosdb/connection/connection.py @@ -287,6 +287,8 @@ class _DefaultCaosDBServerConnection(CaosDBServerConnection): "Insecure SSL mode, certificate will not be checked! " "Please consider removing the `ssl_insecure` configuration option.\n" "****************") + warnings.filterwarnings(action="ignore", module="urllib3", + message="Unverified HTTPS request is being made") verify = False if verify is not None: self._session.verify = verify diff --git a/src/doc/conf.py b/src/doc/conf.py index 15fa844e546a40efa99f577944c377f0a6a0c19a..292aa7a2a427da7044c9d6acf7b000cd2fa82a32 100644 --- a/src/doc/conf.py +++ b/src/doc/conf.py @@ -29,10 +29,10 @@ copyright = '2022, IndiScale GmbH' author = 'Daniel Hornung' # The short X.Y version -version = '0.10.1' +version = '0.11.2' # The full version, including alpha/beta/rc tags # release = '0.5.2-rc2' -release = '0.10.1-dev' +release = '0.11.2-dev' # -- General configuration --------------------------------------------------- diff --git a/src/doc/tutorials/basic_analysis.rst b/src/doc/tutorials/basic_analysis.rst index cc185e0ee08f9e5ee0f890c0ab55f52972882d17..c40cad28b8c9a3be537c641b9614da2eb4df8dd9 100644 --- a/src/doc/tutorials/basic_analysis.rst +++ b/src/doc/tutorials/basic_analysis.rst @@ -34,7 +34,7 @@ Often we are interested in table like data for our processing. And the disentang >>> from caosadvancedtools.table_converter import to_table >>> # Let us retrieve the data in a table like form using `SELECT` ->>> data = db.execute_query("SELECT quality_factor FROM RECORD Analysis with quality_factor" ) +>>> data = db.execute_query("SELECT quality_factor FROM Analysis with quality_factor" ) >>> table = to_table(data) >>> print(table) quality_factor diff --git a/tox.ini b/tox.ini index 50c22d5716769ef2ec818f6c8fb94491ea372434..8212226eef2759c1864a86b8a3ad8f926480db4a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist=py38, py39, py310 +envlist=py37, py38, py39, py310, py311 skip_missing_interpreters = true [testenv] @@ -7,8 +7,13 @@ deps = . nose pytest pytest-cov - jsonschema==4.0.1 + jsonschema>=4.4.0 commands=py.test --cov=caosdb -vv {posargs} [flake8] max-line-length=100 + +[pytest] +testpaths = unittests +xfail_strict = True +addopts = -x -vv --cov=caosdb diff --git a/unittests/test_apiutils.py b/unittests/test_apiutils.py index f9de6d1a037667d0ead0f02439bde13ac4f14f60..bda381cf6427377194e272dfa14b83399b6f012f 100644 --- a/unittests/test_apiutils.py +++ b/unittests/test_apiutils.py @@ -3,6 +3,7 @@ # # Copyright (C) 2020 Timm Fitschen <t.fitschen@indiscale.com> # Copyright (C) 2022 Florian Spreckelsen <f.spreckelsen@indiscale.com> +# Copyright (C) 2022 Daniel Hornung <d.hornung@indiscale.com> # Copyright (C) 2020-2022 IndiScale GmbH <info@indiscale.com> # Copyright (C) 2018 Research Group Biomedical Physics, # Max-Planck-Institute for Dynamics and Self-Organization Göttingen @@ -29,7 +30,8 @@ import pytest import caosdb as db import caosdb.apiutils from caosdb.apiutils import (apply_to_ids, compare_entities, create_id_query, - empty_diff, resolve_reference, merge_entities) + empty_diff, EntityMergeConflictError, + resolve_reference, merge_entities) from caosdb.common.models import SPECIAL_ATTRIBUTES @@ -306,7 +308,7 @@ def test_merge_bug_conflict(): r3 = db.Record() r3.add_property(name="C", value=4, datatype="INTEGER") - with pytest.raises(RuntimeError) as excinfo: + with pytest.raises(EntityMergeConflictError): merge_entities(r3, r2) @@ -401,15 +403,13 @@ def test_wrong_merge_conflict_reference(): rec_a.add_property(name=title_prop.name, value="Some dataset title") # this does not compare referenced records, so it will fail - with pytest.raises(RuntimeError) as re: + with pytest.raises(EntityMergeConflictError): merge_entities(rec_a, rec_b, merge_references_with_empty_diffs=False) - assert "Merge conflict" in str(re.value) # ... as should this, of course rec_b.get_property(license_rt.name).value.name = "Another license" - with pytest.raises(RuntimeError) as re: + with pytest.raises(EntityMergeConflictError) as re: merge_entities(rec_a, rec_b) - assert "Merge conflict" in str(re.value) def test_empty_diff(): @@ -483,9 +483,8 @@ def test_force_merge(): recA = db.Record(name="A") recB = db.Record(name="B") - with pytest.raises(RuntimeError) as re: + with pytest.raises(EntityMergeConflictError): merge_entities(recA, recB) - assert "Merge conflict" in str(re.value) merge_entities(recA, recB, force=True) assert "B" == recA.name @@ -498,9 +497,11 @@ def test_force_merge(): recB = db.Record() recB.description = "something else" - with pytest.raises(RuntimeError) as re: + with pytest.raises(EntityMergeConflictError) as emce: merge_entities(recA, recB) - assert "Merge conflict" in str(re.value) + assert str(emce.value) == """Conflict in special attribute description: +A: something +B: something else""" merge_entities(recA, recB, force=True) assert recA.description == "something else" @@ -513,9 +514,8 @@ def test_force_merge(): recB = db.Record() recB.add_property(name="propA", value="something else") - with pytest.raises(RuntimeError) as re: + with pytest.raises(EntityMergeConflictError): merge_entities(recA, recB) - assert "Merge conflict" in str(re.value) merge_entities(recA, recB, force=True) assert recA.get_property("propA").value == "something else" @@ -539,9 +539,8 @@ def test_force_merge(): rtB = db.RecordType() rtB.add_property(name="propA", datatype=db.TEXT) - with pytest.raises(RuntimeError) as re: + with pytest.raises(EntityMergeConflictError): merge_entities(rtA, rtB) - assert "Merge conflict" in str(re.value) merge_entities(rtA, rtB, force=True) assert rtA.get_property("propA").datatype == db.TEXT @@ -554,10 +553,24 @@ def test_force_merge(): recB = db.Record() recB.add_property(name="propA", value=5, unit="cm") - with pytest.raises(RuntimeError) as re: + with pytest.raises(EntityMergeConflictError): merge_entities(recA, recB) - assert "Merge conflict" in str(re.value) merge_entities(recA, recB, force=True) assert recA.get_property("propA").unit == "cm" # unchanged assert recB.get_property("propA").unit == "cm" + + +def test_merge_missing_list_datatype_82(): + """Merging two properties, where the list-valued one has no datatype.""" + + recA = db.Record().add_property("a", 5, datatype="B") + recB_with_DT = db.Record().add_property("a", [1, 2], datatype=f"LIST<{db.DOUBLE}>") + merge_entities(recA, recB_with_DT, force=True) + assert recA.get_property("a").datatype == f"LIST<{db.DOUBLE}>" + + recA = db.Record().add_property("a", 5, datatype="B") + recB_without_DT = db.Record().add_property("a", [1, 2]) + with pytest.raises(TypeError) as te: + merge_entities(recA, recB_without_DT, force=True) + assert "Invalid datatype: List valued properties" in str(te.value) diff --git a/unittests/test_issues.py b/unittests/test_issues.py index 1e649db4f23de67e55301e0a053fba70d14680b4..2c45a6d77ba61c3f948e403f708994c0fe31481a 100644 --- a/unittests/test_issues.py +++ b/unittests/test_issues.py @@ -34,6 +34,6 @@ def test_issue_100(): # Parse from (invalid) XML file filename = os.path.join(os.path.dirname(__file__), "data", "list_in_value.xml") xml_el = lxml.etree.parse(filename).getroot() - with raises(db.ServerConfigurationException) as exc_info: + with raises(TypeError) as exc_info: db.common.models._parse_single_xml_element(xml_el) - assert "invalid XML: List valued properties" in exc_info.value.msg + assert "Invalid datatype: List valued properties" in str(exc_info.value)