diff --git a/api-openbis-python3-pybis/src/python/pybis/dataset.py b/api-openbis-python3-pybis/src/python/pybis/dataset.py index 8222f8de6e855d1bac388367193a1add526664e2..9ba2f319b59b4f2e3776b74d40d077571bd6157f 100644 --- a/api-openbis-python3-pybis/src/python/pybis/dataset.py +++ b/api-openbis-python3-pybis/src/python/pybis/dataset.py @@ -814,7 +814,7 @@ class DataSet( parentIds = [] dataset_type = self.type.code - properties = self.props.all_nonempty() + properties = self.formatter.format(self.props.all_nonempty()) request = { "method": "createReportFromAggregationService", @@ -882,7 +882,7 @@ class DataSet( else: request["params"][1][0]["autoGeneratedCode"] = False - props = self.p._all_props() + props = self.formatter.format(self.p._all_props()) DSpermId = data_stores["code"][0] request["params"][1][0]["properties"] = props request["params"][1][0]["dataStoreId"] = { @@ -902,7 +902,7 @@ class DataSet( # updating the DataSET else: request = self._up_attrs() - props = self.p._all_props() + props = self.formatter.format(self.p._all_props()) request["params"][1][0]["properties"] = props self.openbis._post_request(self.openbis.as_v3, request) @@ -960,6 +960,7 @@ class DataSet( wait_until_finished=True, ) + props = self.formatter.format(self.props.all_nonempty()) param = { "@type": "dss.dto.dataset.create.UploadedDataSetCreation", "@id": "1", @@ -969,7 +970,7 @@ class DataSet( "permId": self.type.code, "entityKind": "DATA_SET"}, - "properties": self.props.all_nonempty(), + "properties": props, "parentIds": [], "uploadId": upload_id } diff --git a/api-openbis-python3-pybis/src/python/pybis/material.py b/api-openbis-python3-pybis/src/python/pybis/material.py index 74b8e3b6040edba8df5cd80aecb4cf9613195506..f5ed4c6a54df290c5ba8bfcff24fd95705c5b849 100644 --- a/api-openbis-python3-pybis/src/python/pybis/material.py +++ b/api-openbis-python3-pybis/src/python/pybis/material.py @@ -15,6 +15,7 @@ from .attribute import AttrHolder from .openbis_object import OpenBisObject from .property import PropertyHolder +from .property_reformatter import PropertyReformatter from .utils import VERBOSE @@ -25,8 +26,11 @@ class Material(OpenBisObject): self.__dict__["entity"] = "material" self.__dict__["openbis"] = openbis_obj self.__dict__["type"] = type - self.__dict__["p"] = PropertyHolder(openbis_obj, type) + ph = PropertyHolder(openbis_obj, type) + self.__dict__["p"] = ph + self.__dict__["props"] = ph self.__dict__["a"] = AttrHolder(openbis_obj, "material", type) + self.__dict__["formatter"] = PropertyReformatter(openbis_obj) if data is not None: self._set_data(data) @@ -53,7 +57,7 @@ class Material(OpenBisObject): f"Property '{prop_name}' is mandatory and must not be None" ) - props = self.p._all_props() + props = self.formatter.format(self.p._all_props()) if self.is_new: request = self._new_attrs() diff --git a/api-openbis-python3-pybis/src/python/pybis/openbis_object.py b/api-openbis-python3-pybis/src/python/pybis/openbis_object.py index 812f6afe8f10745cac1ddb4d6d2458e0b84e0458..5afb398015beec585ff177b47a25ea1a3af9b000 100644 --- a/api-openbis-python3-pybis/src/python/pybis/openbis_object.py +++ b/api-openbis-python3-pybis/src/python/pybis/openbis_object.py @@ -12,15 +12,17 @@ # See the License for the specific language governing permissions and # limitations under the License. # -from .property import PropertyHolder +from collections import defaultdict + from .attribute import AttrHolder -from .utils import VERBOSE from .definitions import ( get_definition_for_entity, get_type_for_entity, get_method_for_entity, ) -from collections import defaultdict +from .property import PropertyHolder +from .property_reformatter import PropertyReformatter +from .utils import VERBOSE class OpenBisObject: @@ -39,6 +41,7 @@ class OpenBisObject: self.__dict__["type"] = type self.__dict__["p"] = PropertyHolder(openbis_obj, type) self.__dict__["a"] = AttrHolder(openbis_obj, self._entity, type) + self.__dict__["formatter"] = PropertyReformatter(openbis_obj) # existing OpenBIS object if data is not None: @@ -196,7 +199,7 @@ class OpenBisObject: f"Property '{prop_name}' is mandatory and must not be None" ) - props = self.p._all_props() + props = self.formatter.format(self.p._all_props()) # NEW if self.is_new: diff --git a/api-openbis-python3-pybis/src/python/pybis/property_reformatter.py b/api-openbis-python3-pybis/src/python/pybis/property_reformatter.py new file mode 100644 index 0000000000000000000000000000000000000000..ff9acad285bc78ec43796fd59d01a6cfc3e9b018 --- /dev/null +++ b/api-openbis-python3-pybis/src/python/pybis/property_reformatter.py @@ -0,0 +1,63 @@ +# Copyright ETH 2023 Zürich, Scientific IT Services +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from datetime import datetime + +import pandas as pd + + +def is_of_openbis_supported_date_format(value): + is_supported = False + for date_format in PropertyReformatter.SUPPORTED_DATETIME_FORMATS: + try: + datetime.strptime(value, date_format) + is_supported = True + except ValueError: + pass + return is_supported + + +class PropertyReformatter: + """Helper class for reformatting of properties, is needed""" + LONG_DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S" + + SUPPORTED_DATETIME_FORMATS = ["%Y-%m-%d", "%y-%m-%d", # ShortDateFormat + "%Y-%m-%d %H:%M", "%y-%m-%d %H:%M", # NormalDateFormat + "%Y-%m-%d %H:%M:%S", "%y-%m-%d %H:%M:%S"] # LongDateFormat + + def __init__(self, openbis_obj): + self.openbis = openbis_obj + + def format(self, properties): + if properties is None: + raise ValueError('properties can not be None!') + + for key, value in properties.items(): + property_type = self.openbis.get_property_type(key) + match property_type.dataType: + case 'TIMESTAMP': + properties[key] = self._format_timestamp(value) + case _: + pass + + return properties + + def _format_timestamp(self, value): + if is_of_openbis_supported_date_format(value): + return value + timestamp = pd.to_datetime(value) + result = timestamp.strftime(PropertyReformatter.LONG_DATETIME_FORMAT) + print( + f'WARNING: "{value}" is not of any OpenBis supported datetime formats. Reformatting to "{result}"') + return result diff --git a/api-openbis-python3-pybis/src/python/pybis/sample.py b/api-openbis-python3-pybis/src/python/pybis/sample.py index f60147c1b67bea18345cb168b925c577094788a2..5514a7f6821d71de91e62fd10c0cc3426c62a991 100644 --- a/api-openbis-python3-pybis/src/python/pybis/sample.py +++ b/api-openbis-python3-pybis/src/python/pybis/sample.py @@ -13,9 +13,9 @@ # limitations under the License. # from .attribute import AttrHolder -from .definitions import openbis_definitions from .openbis_object import OpenBisObject from .property import PropertyHolder +from .property_reformatter import PropertyReformatter from .utils import VERBOSE @@ -23,7 +23,7 @@ class Sample(OpenBisObject, entity="sample", single_item_method_name="get_sample """A Sample (new: Object) is one of the most commonly used entities in openBIS.""" def __init__( - self, openbis_obj, type, project=None, data=None, props=None, attrs=None, **kwargs + self, openbis_obj, type, project=None, data=None, props=None, attrs=None, **kwargs ): self.__dict__["openbis"] = openbis_obj self.__dict__["type"] = type @@ -31,6 +31,7 @@ class Sample(OpenBisObject, entity="sample", single_item_method_name="get_sample self.__dict__["p"] = ph self.__dict__["props"] = ph self.__dict__["a"] = AttrHolder(openbis_obj, "sample", type) + self.__dict__["formatter"] = PropertyReformatter(openbis_obj) if data is not None: self._set_data(data) @@ -227,8 +228,8 @@ class Sample(OpenBisObject, entity="sample", single_item_method_name="get_sample for prop_name, prop in self.props._property_names.items(): if prop["mandatory"]: if ( - getattr(self.props, prop_name) is None - or getattr(self.props, prop_name) == "" + getattr(self.props, prop_name) is None + or getattr(self.props, prop_name) == "" ): raise ValueError( f"Property '{prop_name}' is mandatory and must not be None" @@ -236,6 +237,7 @@ class Sample(OpenBisObject, entity="sample", single_item_method_name="get_sample sampleProject = self.project.code if self.project else None sampleExperiment = self.experiment.code if self.experiment else None + properties = PropertyReformatter(self.openbis).format(self.props()) request = { "method": "createReportFromAggregationService", @@ -250,7 +252,7 @@ class Sample(OpenBisObject, entity="sample", single_item_method_name="get_sample "sampleExperiment": sampleExperiment, "sampleCode": self.code, "sampleType": self.type.code, - "sampleProperties": self.props(), + "sampleProperties": properties, "sampleParents": self.parents, "sampleParentsNew": None, "sampleChildrenNew": self.children, diff --git a/api-openbis-python3-pybis/src/python/tests/test_dataset.py b/api-openbis-python3-pybis/src/python/tests/test_dataset.py index 616833729b97569d1e6f0d562b14761415d10e9e..9df35c4a92bd8fbe54867274b99e503a6ed8b279 100644 --- a/api-openbis-python3-pybis/src/python/tests/test_dataset.py +++ b/api-openbis-python3-pybis/src/python/tests/test_dataset.py @@ -12,9 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. # +import datetime import os import re import time +import uuid import pytest from pybis.things import Things @@ -209,3 +211,49 @@ def test_create_new_dataset_v3_directory(space): assert dataset.permId is not None assert dataset.file_list == ["testdir/testfile"] + +def test_dataset_property_in_isoformat_date(space): + o = space.openbis + + timestamp = time.strftime("%a_%y%m%d_%H%M%S").lower() + + # Create custom TIMESTAMP property type + property_type_code = "test_property_type_" + timestamp + "_" + str(uuid.uuid4()) + pt_date = o.new_property_type( + code=property_type_code, + label='custom property of data type timestamp', + description='custom property created in unit test', + dataType='TIMESTAMP', + ) + pt_date.save() + + # Create new dataset type + type_code = "test_dataset_type_" + timestamp + "_" + str(uuid.uuid4()) + dataset_type = o.new_dataset_type(code=type_code) + dataset_type.save() + + # Assign created property to new dataset type + dataset_type.assign_property(property_type_code) + + # Create new dataset with timestamp property in non-supported format + timestamp_property = datetime.datetime.now().isoformat() + testfile_path = os.path.join(os.path.dirname(__file__), "testdir/testfile") + + dataset = o.new_dataset( + type=type_code, + experiment="/DEFAULT/DEFAULT/DEFAULT", + files=[testfile_path], + props={property_type_code: timestamp_property}, + ) + dataset.save() + + # New dataset case + assert len(dataset.p()) == 1 + assert dataset.p[property_type_code] is not None + + # Update dataset case + dataset.p[property_type_code] = timestamp_property + dataset.save() + + assert len(dataset.p()) == 1 + assert dataset.p[property_type_code] is not None diff --git a/api-openbis-python3-pybis/src/python/tests/test_experiment.py b/api-openbis-python3-pybis/src/python/tests/test_experiment.py index 557b0c6eb12d4c3060e7e98b0f13ade6092bca69..68a0c6ec0ed7b9c8c741912fbd0f522a1944a93e 100644 --- a/api-openbis-python3-pybis/src/python/tests/test_experiment.py +++ b/api-openbis-python3-pybis/src/python/tests/test_experiment.py @@ -12,20 +12,18 @@ # See the License for the specific language governing permissions and # limitations under the License. # -import json -import random -import re -import pytest +import datetime import time -from pybis import DataSet -from pybis import Openbis +import uuid + +import pytest def test_create_delete_experiment(space): - o=space.openbis + o = space.openbis timestamp = time.strftime('%a_%y%m%d_%H%M%S').upper() - new_code='test_experiment_'+timestamp + new_code = 'test_experiment_' + timestamp with pytest.raises(TypeError): # experiments must be assigned to a project @@ -52,7 +50,7 @@ def test_create_delete_experiment(space): e_exists = o.get_experiment(e_new.permId) assert e_exists is not None - e_new.delete('delete test experiment '+new_code.upper()) + e_new.delete('delete test experiment ' + new_code.upper()) with pytest.raises(ValueError): e_no_longer_exists = o.get_experiment(e_exists.permId) @@ -60,9 +58,53 @@ def test_create_delete_experiment(space): def test_get_experiments(space): # test paging - o=space.openbis + o = space.openbis current_datasets = o.get_experiments(start_with=1, count=1) assert current_datasets is not None # we cannot assert == 1, because search is delayed due to lucene search... assert len(current_datasets) <= 1 + +def test_experiment_property_in_isoformat_date(space): + o = space.openbis + + timestamp = time.strftime("%a_%y%m%d_%H%M%S").lower() + + # Create custom TIMESTAMP property type + property_type_code = "test_property_type_" + timestamp + "_" + str(uuid.uuid4()) + pt_date = o.new_property_type( + code=property_type_code, + label='custom property of data type timestamp for experiment', + description='custom property created in unit test', + dataType='TIMESTAMP', + ) + pt_date.save() + + type_code = "test_experiment_type_" + timestamp + "_" + str(uuid.uuid4()) + experiment_type = o.new_experiment_type( + type_code, + description=None, + validationPlugin=None, + ) + experiment_type.save() + experiment_type.assign_property(property_type_code) + + project = o.get_projects()[0] + code = "my_experiment_{}".format(timestamp) + timestamp_property = datetime.datetime.now().isoformat() + props = {property_type_code: timestamp_property} + + exp = o.new_experiment(code=code, project=project, type=type_code, props=props) + exp.save() + + # New experiment case + assert len(exp.p()) == 1 + assert exp.p[property_type_code] is not None + + # Update experiment case + exp.p[property_type_code] = timestamp_property + exp.save() + + assert len(exp.p()) == 1 + assert exp.p[property_type_code] is not None + diff --git a/api-openbis-python3-pybis/src/python/tests/test_sample.py b/api-openbis-python3-pybis/src/python/tests/test_sample.py index b6600b2951370d1c17d672a93fa5fbf668e979d8..595e6fb097414cd162ad3253bf4658f9dab914ab 100644 --- a/api-openbis-python3-pybis/src/python/tests/test_sample.py +++ b/api-openbis-python3-pybis/src/python/tests/test_sample.py @@ -12,12 +12,14 @@ # See the License for the specific language governing permissions and # limitations under the License. # +import datetime import random import re -import uuid -import pytest import time +import uuid + import pandas as pd +import pytest def test_create_delete_sample(space): @@ -54,11 +56,11 @@ def test_create_delete_sample(space): assert sample_by_permId.registrationDate is not None # check date format: 2019-03-22 11:36:40 assert ( - re.search( - r"^\d{4}\-\d{2}\-\d{2} \d{2}\:\d{2}\:\d{2}$", - sample_by_permId.registrationDate, - ) - is not None + re.search( + r"^\d{4}\-\d{2}\-\d{2} \d{2}\:\d{2}\:\d{2}$", + sample_by_permId.registrationDate, + ) + is not None ) # get sample by identifier @@ -91,7 +93,7 @@ def test_parent_child(space): sample_type = "UNKNOWN" timestamp = time.strftime("%a_%y%m%d_%H%M%S").upper() parent_code = ( - "parent_sample_{}".format(timestamp) + "_" + str(random.randint(0, 1000)) + "parent_sample_{}".format(timestamp) + "_" + str(random.randint(0, 1000)) ) sample_parent = o.new_sample(code=parent_code, type=sample_type, space=space) sample_parent.save() @@ -106,7 +108,7 @@ def test_parent_child(space): ex_sample_parents = sample_child.get_parents() ex_sample_parent = ex_sample_parents[0] assert ( - ex_sample_parent.identifier == "/{}/{}".format(space.code, parent_code).upper() + ex_sample_parent.identifier == "/{}/{}".format(space.code, parent_code).upper() ) ex_sample_children = ex_sample_parent.get_children() @@ -137,3 +139,62 @@ def test_empty_data_frame(openbis_instance): pa = s.get_property_assignments() pd.testing.assert_frame_equal(pa.df, pd.DataFrame()) + + +def test_sample_property_in_isoformat_date(space): + o = space.openbis + + timestamp = time.strftime("%a_%y%m%d_%H%M%S").lower() + + # Create custom TIMESTAMP property type + property_type_code = "test_property_type_" + timestamp + "_" + str(uuid.uuid4()) + pt_date = o.new_property_type( + code=property_type_code, + label='custom property of data type timestamp', + description='custom property created in unit test', + dataType='TIMESTAMP', + ) + pt_date.save() + + # Create custom sample type + sample_type_code = "test_sample_type_" + timestamp + "_" + str(uuid.uuid4()) + sample_type = o.new_sample_type( + code=sample_type_code, + generatedCodePrefix="S", + autoGeneratedCode=True, + listable=True, + ) + sample_type.save() + + # Assign created property to new sample type + sample_type.assign_property( + prop=property_type_code, + section='', + ordinal=5, + mandatory=False, + showInEditView=True, + showRawValueInForms=True + ) + + sample_code = "my_sample_{}".format(timestamp) + # Create new sample with timestamp property in non-supported format + timestamp_property = datetime.datetime.now().isoformat() + sample = o.new_sample(code=sample_code, + type=sample_type_code, + space=space, + props={ + property_type_code: timestamp_property}) + sample.save() + + # New item case + assert len(sample.props()) == 1 + key, val = sample.props().popitem() + assert key == property_type_code + + # Update item case + sample.props = {property_type_code: timestamp_property} + sample.save() + + assert len(sample.props()) == 1 + key, val = sample.props().popitem() + assert key == property_type_code