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/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..fca246ed7004a25e7eedc594a4e7eceb68ee295a 100755 --- a/setup.py +++ b/setup.py @@ -174,7 +174,7 @@ def setup_package(): python_requires='>=3.8', 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/__init__.py b/src/caosdb/__init__.py index 7e06885fe495c1e8c4ccc99b7d0c0f8ff8c34b5b..45303cc275042b4eb83ab37f2fd67ce077fa0561 100644 --- a/src/caosdb/__init__.py +++ b/src/caosdb/__init__.py @@ -41,11 +41,12 @@ from caosdb.common.datatype import (BOOLEAN, DATETIME, DOUBLE, FILE, INTEGER, 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, + SUGGESTED, Container, Entity, File, Info, Message, Permissions, Property, Query, QueryTemplate, Record, RecordType, delete, execute_query, get_global_acl, - get_known_permissions, raise_errors) + get_known_permissions, raise_errors, + Directory, Link) from caosdb.configuration import _read_config_files, configure, get_config from caosdb.connection.connection import configure_connection, get_connection from caosdb.exceptions import * diff --git a/src/caosdb/common/models.py b/src/caosdb/common/models.py index 05f661c9b6ef2c89027e2a56fe22e6a9d1e59dff..40e49f5a6c6646d6218874a60e29666c4054d3af 100644 --- a/src/caosdb/common/models.py +++ b/src/caosdb/common/models.py @@ -51,7 +51,7 @@ from lxml import etree from caosdb.common.datatype import (BOOLEAN, DATETIME, DOUBLE, INTEGER, TEXT, is_list_datatype, is_reference) from caosdb.common.state import State -from caosdb.common.utils import uuid, xml2str, experimental +from caosdb.common.utils import uuid, xml2str from caosdb.common.timezone import TimeZone from caosdb.common.versioning import Version from caosdb.configuration import get_config @@ -115,6 +115,7 @@ class Entity(object): self.parents = _Parents() self.path = None self.file = None + self.link_target = None self.unit = None self.acl = None self.permissions = None @@ -301,6 +302,30 @@ class Entity(object): def path(self, new_path): self.__path = new_path + @property + def link_target(self): + if self.__link_target is not None or self._wrapped_entity is None: + return self.__link_target + + return self._wrapped_entity.link_target + + @link_target.setter + def link_target(self, new_link_target): + self.__link_target = new_link_target + + @property + def thumbnail(self): + warn(DeprecationWarning( + "The thumbnail feature has been removed from the CaosDB server " + "API")) + return None + + @thumbnail.setter + def thumbnail(self, new_thumbnail): + warn(DeprecationWarning( + "The thumbnail feature has been removed from the CaosDB server " + "API")) + @property def file(self): if self.__file is not None or self._wrapped_entity is None: @@ -314,14 +339,16 @@ class Entity(object): @property def pickup(self): - if self.__pickup is not None or self._wrapped_entity is None: - return self.__pickup - - return self._wrapped_entity.pickup + warn(DeprecationWarning( + "The drop-off/pickup feature has been removed from the CaosDB " + "server API")) + return None @pickup.setter def pickup(self, new_pickup): - self.__pickup = new_pickup + warn(DeprecationWarning( + "The drop-off/pickup feature has been removed from the CaosDB " + "server API")) def grant(self, realm=None, username=None, role=None, permission=None, priority=False, revoke_denial=True): @@ -1048,6 +1075,9 @@ class Entity(object): if self.file is not None and local_serialization: xml.set("file", self.file) + if self.link_target is not None: + xml.set("linktarget", str(self.link_target)) + if self.checksum is not None: xml.set("checksum", self.checksum) @@ -1102,6 +1132,7 @@ class Entity(object): entity.description = elem.get("description") entity.path = elem.get("path") entity._checksum = elem.get("checksum") + entity.link_target = elem.get("linktarget") entity._size = elem.get("size") entity.datatype = elem.get("datatype") # @ReservedAssignment entity.unit = elem.get("unit") @@ -1365,7 +1396,7 @@ def _parse_value(datatype, value): # This is for a special case, where the xml parser could not differentiate # between single values and lists with one element. As - if hasattr(value, "__len__") and len(value) == 1: + if hasattr(value, "__len__") and len(value) == 1 and not isinstance(value, str): return _parse_value(datatype, value[0]) # deal with references @@ -1419,23 +1450,12 @@ class QueryTemplate(): self.description = description self.query = query self._cuid = None - self.value = None - self.datatype = None self.messages = _Messages() - self.properties = None - self.parents = None - self.path = None - self.file = None - self._checksum = None - self._size = None - self._upload = None - self.unit = None self.acl = None self.permissions = None 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): @@ -1691,9 +1711,7 @@ class Property(Entity): class Message(object): - # @ReservedAssignment - - def __init__(self, type, code=None, description=None, body=None): # @ReservedAssignment + def __init__(self, type, code=None, description=None, body=None): self.type = type self.code = code self.description = description @@ -1804,8 +1822,39 @@ class Record(Entity): return Entity.to_xml(self, xml, add_properties=ALL) -@experimental("The Directory class is experimental. It should not be used \ - until the server's file storage API has been refactored.") +class Link(Entity): + """This class represents CaosDB's link entities.""" + + def __init__(self, name=None, id=None, description=None, path=None, + target=None): + Record.__init__(self, id=id, name=name, description=description) + self.role = "Link" + self.datatype = None + self.link_target = target + + # location in the fileserver + self.path = path + + def to_xml(self, xml=None, add_properties=ALL, local_serialization=False): + """Convert this Link to an xml element. + + @return: xml element + """ + + if xml is None: + xml = etree.Element("Link") + + return Entity.to_xml(self, xml=xml, add_properties=add_properties, + local_serialization=local_serialization) + + def add_property(self, property=None, id=None, name=None, description=None, datatype=None, + value=None, unit=None, importance=FIX, inheritance=FIX): + + return super().add_property( + property=property, id=id, name=name, description=description, datatype=datatype, + value=value, unit=unit, importance=importance, inheritance=inheritance) + + class Directory(Record): """This class represents CaosDB's directory entities.""" @@ -1822,7 +1871,7 @@ class Directory(Record): self.import_file = import_file or recursive_import def to_xml(self, xml=None, add_properties=ALL, local_serialization=False): - """Convert this file to an xml element. + """Convert this Directory to an xml element. @return: xml element """ @@ -1845,14 +1894,9 @@ class File(Record): """This class represents CaosDB's file entities. - For inserting a new file to the server, `path` gives the new location, and - (exactly?) one of `file` and `pickup` should (must?) be given to specify the - source of the file. - - Symlinking from the "extroot" file system is not supported by this API yet, - it can be done manually using the `InsertFilesInDir` flag. For sample code, - look at `test_files.py` in the Python integration tests of the - `load_files.py` script in the advanced user tools. + For inserting a new file to the server or updating an existent one. + `path` gives the new location. `file` specifies the (local) file which is + to be uploaded. It is mandatory for insertions, but optionally for updates. Parameters ---------- @@ -1870,14 +1914,16 @@ class File(Record): A local path or python file object. The file designated by this argument will be uploaded to the server via HTTP. pickup : str - A file/folder in the DropOffBox (the server will move that file into - its "caosroot" file system). (DEPRECATED, use import feature) + Deprecated. + thumbnail : str + Deprecated. import_file : bool Import the file (don't upload it, its already there). Default: `False` """ def __init__(self, name=None, id=None, description=None, - path=None, file=None, pickup=None, import_file=False): + path=None, file=None, pickup=None, + thumbnail=None, import_file=False): Record.__init__(self, id=id, name=name, description=description) self.role = "File" self.datatype = None @@ -1888,9 +1934,18 @@ class File(Record): # local file path or pointer to local file self.file = file - self.pickup = pickup self.import_file = import_file + if thumbnail is not None: + warn(DeprecationWarning( + "The thumbnail feature has been removed from the CaosDB " + "server API")) + + if pickup is not None: + warn(DeprecationWarning( + "The drop-off/pickup feature has been removed from the CaosDB " + "server API")) + def to_xml(self, xml=None, add_properties=ALL, local_serialization=False): """Convert this file to an xml element. @@ -1942,21 +1997,21 @@ class File(Record): return checksum.hexdigest().lower() @staticmethod - def sha512(file): - return File._get_checksum_single_file(file) + def _get_checksum(file): + if hasattr(file, "name"): + file_name = file.name + else: + file_name = file - @staticmethod - def _get_checksum_single_file(single_file): - _file = open(single_file, 'rb') - data = _file.read(1000) checksum = sha512() - - while data: - checksum.update(data) + with open(file_name, 'rb') as _file: data = _file.read(1000) - _file.close() - return checksum.hexdigest() + while data: + checksum.update(data) + data = _file.read(1000) + + return checksum.hexdigest().lower() def add_property(self, property=None, id=None, name=None, description=None, datatype=None, value=None, unit=None, importance=FIX, inheritance=FIX): @@ -2499,30 +2554,13 @@ def _basic_sync(e_local, e_remote): "this client did't know about it yet.".format( e_remote.role, e_local.role)) - e_local.id = e_remote.id - e_local.name = e_remote.name - e_local.description = e_remote.description - e_local.path = e_remote.path - e_local._checksum = e_remote._checksum - e_local._size = e_remote._size - e_local.datatype = e_remote.datatype - e_local.unit = e_remote.unit - e_local.value = e_remote.value - e_local.properties = e_remote.properties - e_local.parents = e_remote.parents - e_local.messages = e_remote.messages - e_local.acl = e_remote.acl - e_local.permissions = e_remote.permissions - 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 - - if hasattr(e_remote, "affiliation"): - e_local.affiliation = e_remote.affiliation + for attr in ["path", "link_target", "_checksum", "_size", "datatype", + "unit", "value", "properties", "parents", "messages", "acl", + "permissions", "is_valid", "is_deleted", "version", "state", + "query", "affiliation", "id", "name", "description"]: + + if hasattr(e_remote, attr): + setattr(e_local, attr, getattr(e_remote, attr)) return e_local @@ -3006,6 +3044,7 @@ class Container(list): for local_entity in self: if (sync_dict[local_entity] is None + and hasattr(local_entity, "path") and local_entity.path is not None): sync_remote_entities = [] @@ -3425,8 +3464,6 @@ class Container(list): if hasattr(entity, '_upload') and entity._upload is not None: entity_xml.set("upload", entity._upload) - elif hasattr(entity, 'pickup') and entity.pickup is not None: - entity_xml.set("pickup", entity.pickup) elif hasattr(entity, 'import_file') and entity.import_file is True: entity_xml.set("import", "true") @@ -3575,8 +3612,6 @@ class Container(list): if hasattr(entity, '_upload') and entity._upload is not None: entity_xml.set("upload", entity._upload) - elif hasattr(entity, 'pickup') and entity.pickup is not None: - entity_xml.set("pickup", entity.pickup) elif hasattr(entity, 'recursive_import') and entity.recursive_import is True: entity_xml.set("recursive_import", "true") elif hasattr(entity, 'import_file') and entity.import_file is True: @@ -4292,39 +4327,6 @@ def execute_query(q, unique=False, raise_exception_on_error=True, cache=True, fl cache=cache) -class DropOffBox(list): - - path = None - - def sync(self): - c = get_connection() - _log_request("GET: Info") - http_response = c.retrieve(["Info"]) - body = http_response.read() - _log_response(body) - - xml = etree.fromstring(body) - - for child in xml: - if child.tag.lower() == "stats": - infoelem = child - - break - - for child in infoelem: - if child.tag.lower() == "dropoffbox": - dropoffboxelem = child - - break - del self[:] - self.path = dropoffboxelem.get('path') - - for f in dropoffboxelem: - self.append(f.get('path')) - - return self - - class UserInfo(): def __init__(self, xml): @@ -4451,6 +4453,7 @@ def _parse_single_xml_element(elem): 'property': Property, 'file': File, 'directory': Directory, + 'link': Link, 'parent': Parent, 'entity': Entity} diff --git a/tox.ini b/tox.ini index 50c22d5716769ef2ec818f6c8fb94491ea372434..3b3371a9424cf6692a7c3c05f9c911ffdd34b957 100644 --- a/tox.ini +++ b/tox.ini @@ -12,3 +12,8 @@ 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_datatype.py b/unittests/test_datatype.py index 9b3c6267fb018e2cd3085dea568d7396c4549ac8..5e42117581f6e1d21cac9e9a30ed95a0b658d52a 100644 --- a/unittests/test_datatype.py +++ b/unittests/test_datatype.py @@ -87,3 +87,9 @@ def test_parsing_of_references(): entity = db.Record(name="bla") assert id(_parse_value(dtype, entity)) == id(entity) + + +def test_parsing_of_single_list_item_str_len_one(): + db.Record().add_property("blub", datatype=db.LIST(db.TEXT), value=["a"]) + db.Record().add_property("blub", datatype=db.LIST(db.INTEGER), value=[1]) + db.Record().add_property("blub", datatype=db.LIST("bla"), value=["a"]) diff --git a/unittests/test_file.py b/unittests/test_file.py index 3c80af7f362a7cdabe0a9ebc89cd2986d04fe242..9abe3dcc41663b38083a284fd0e1a84be00b3c6a 100644 --- a/unittests/test_file.py +++ b/unittests/test_file.py @@ -24,14 +24,10 @@ """Tests for the File class.""" from caosdb import File, Record, configure_connection from caosdb.connection.mockup import MockUpServerConnection -# pylint: disable=missing-docstring -from nose.tools import assert_equal as eq -from nose.tools import assert_is_not_none as there -from nose.tools import assert_true as tru def setup_module(): - there(File) + assert File is not None configure_connection(url="unittests", username="testuser", password_method="plain", password="testpassword", timeout=200, @@ -39,12 +35,12 @@ def setup_module(): def hat(obj, attr): - tru(hasattr(obj, attr)) + assert hasattr(obj, attr) def test_is_record(): file_ = File() - tru(isinstance(file_, Record)) + assert isinstance(file_, Record) def test_instance_variable(): @@ -57,4 +53,39 @@ def test_instance_variable(): def test_role(): file_ = File() - eq(file_.role, "File") + assert file_.role == "File" + + +def test_ticket_237(): + """This is an ancient test which has been moved here from the integration + test suite. It checks the basic functionality of the _wrap(function)""" + + f1 = File( + name="name1", + path="path1", + file="file1") + assert f1.name == "name1" + assert f1.path == "path1" + assert f1.file == "file1" + + f2 = File(name="name2") + assert f2.name == "name2" + assert f2.path is None + assert f2.file is None + + f2._wrap(f1) + + assert f2.name == "name2" + assert f2.path == "path1" + assert f2.file == "file1" + + f2.path = "path2" + f2.file = "file2" + + assert f2.name == "name2" + assert f2.path == "path2" + assert f2.file == "file2" + + assert f1.name == "name1" + assert f1.path == "path1" + assert f1.file == "file1"