From 030caf8ac9286b34dc8579dd302fd11ebb007d3d Mon Sep 17 00:00:00 2001
From: alaskowski <alaskowski@ethz.ch>
Date: Thu, 23 Feb 2023 16:00:14 +0100
Subject: [PATCH] SSDM-13330: Added searching samples flow for PHYSICAL data.
 Refactored code.

---
 .../src/python/obis/dm/commands/search.py     |  72 ++++++++++
 .../src/python/obis/dm/data_mgmt.py           |  46 +++++--
 .../src/python/obis/scripts/cli.py            | 126 +++++++++++++-----
 3 files changed, 200 insertions(+), 44 deletions(-)
 create mode 100644 app-openbis-command-line/src/python/obis/dm/commands/search.py

diff --git a/app-openbis-command-line/src/python/obis/dm/commands/search.py b/app-openbis-command-line/src/python/obis/dm/commands/search.py
new file mode 100644
index 00000000000..0fc3faa76e3
--- /dev/null
+++ b/app-openbis-command-line/src/python/obis/dm/commands/search.py
@@ -0,0 +1,72 @@
+#   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 .openbis_command import OpenbisCommand
+from ..command_result import CommandResult
+from ..utils import cd
+from ...scripts.click_util import click_echo
+
+
+class Search(OpenbisCommand):
+    """
+    Command to search objects in openBIS.
+    """
+
+    def __init__(self, dm, type_code, space, project, experiment, property_code, property_value,
+                 save_path):
+        """
+        :param dm: data management
+        :param type_code: Filter by type code
+        :param space: Filter by space path
+        :param project: Filter by project path
+        :param experiment: Filter by experiment
+        :param property_code: Filter by property_code, needs to be set together with property_value
+        :param property_value: Filter by property_value, needs to be set together with property_code
+        :param save_path: Path to save results. If not set, results will not be saved.
+        """
+        self.property_value = property_value
+        self.property_code = property_code
+        self.experiment = experiment
+        self.project = project
+        self.space = space
+        self.type_code = type_code
+        self.save_path = save_path
+        self.load_global_config(dm)
+        super(Search, self).__init__(dm)
+
+    def search_samples(self):
+        properties = None
+        if self.property_code is not None and self.property_value is not None:
+            properties = {
+                self.property_code: self.property_value,
+            }
+
+        search_results = self.openbis.get_samples(
+            space=self.space,
+            project=self.project,  # Not Supported with Project Samples disabled
+            experiment=self.experiment,
+            type=self.type_code,
+            where=properties,
+            props="*"  # Fetch all properties
+        )
+        click_echo("Search found {} samples".format(len(search_results)))
+        if self.save_path is not None:
+            click_echo("Saving search results in {}".format(self.save_path), with_timestamp=True)
+            with cd(self.data_mgmt.invocation_path):
+                search_results.df.to_csv(self.save_path, index=False)
+        else:
+            click_echo("Search results: %s" % search_results)
+
+        return CommandResult(returncode=0, output="Search completed.")
diff --git a/app-openbis-command-line/src/python/obis/dm/data_mgmt.py b/app-openbis-command-line/src/python/obis/dm/data_mgmt.py
index ab26c845343..a6a26f148a3 100644
--- a/app-openbis-command-line/src/python/obis/dm/data_mgmt.py
+++ b/app-openbis-command-line/src/python/obis/dm/data_mgmt.py
@@ -34,6 +34,7 @@ from .commands.download_physical import DownloadPhysical
 from .commands.move import Move
 from .commands.openbis_sync import OpenbisSync
 from .commands.removeref import Removeref
+from .commands.search import Search
 from .git import GitWrapper
 from .utils import Type
 from .utils import cd
@@ -216,6 +217,19 @@ class AbstractDataMgmt(metaclass=abc.ABCMeta):
         """
         return
 
+    @abc.abstractmethod
+    def search(self, type_code, space, project, experiment, property_code, property_value, save):
+        """Search for objects in openBIS using filtering criteria.
+        :param type_code: Type of searched object.
+        :param space: Space path to filter object.
+        :param project: Project path to filter object.
+        :param experiment: Experiment path to filter object.
+        :param property_code: Custom property code to search by, property_value must be set as well.
+        :param property_value: Custom property value to search by, property_code must be set as well.
+        :param save: File path to save results. If missing, search results will not be saved.
+        """
+        return
+
 
 class NoGitDataMgmt(AbstractDataMgmt):
     """DataMgmt operations when git is not available -- show error messages."""
@@ -256,6 +270,9 @@ class NoGitDataMgmt(AbstractDataMgmt):
     def download(self, data_set_id, content_copy_index, file, skip_integrity_check):
         self.error_raise("download", "No git command found.")
 
+    def search(self, *_):
+        self.error_raise("search", "No git command found.")
+
 
 def restore_signal_handler(data_mgmt):
     data_mgmt.restore()
@@ -332,8 +349,7 @@ class GitDataMgmt(AbstractDataMgmt):
 
     def init_data(self, desc=None):
         # check that repository does not already exist
-        # TODO remove .git check after physical flow is implemented
-        if os.path.exists('.obis') and os.path.exists('.git'):
+        if os.path.exists('.obis'):
             return CommandResult(returncode=-1, output="Folder is already an obis repository.")
         result = self.git_wrapper.git_init()
         if result.failure():
@@ -536,6 +552,9 @@ class GitDataMgmt(AbstractDataMgmt):
         else:
             return CommandResult(returncode=0, output="")
 
+    def search(self, *_):
+        self.error_raise("search", "This functionality is not implemented for data of LINK type.")
+
 
 class PhysicalDataMgmt(AbstractDataMgmt):
     """DataMgmt operations for DSS-stored data."""
@@ -544,7 +563,7 @@ class PhysicalDataMgmt(AbstractDataMgmt):
         return dm_config.SettingsResolver()
 
     def setup_local_settings(self, all_settings):
-        self.error_raise("setup local settings", "Not implemented.")
+        self.error_raise("setup local settings", "Not implemented for PHYSICAL data.")
 
     def init_data(self, desc=None):
         if os.path.exists('.obis'):
@@ -558,29 +577,34 @@ class PhysicalDataMgmt(AbstractDataMgmt):
         return CommandResult(returncode=0, output="Physical obis repository initialized.")
 
     def init_analysis(self, parent_folder, desc=None):
-        self.error_raise("init analysis", "Not implemented.")
+        self.error_raise("init analysis", "Not implemented for PHYSICAL data.")
 
     def commit(self, msg, auto_add=True, sync=True):
-        self.error_raise("commit", "Not implemented.")
+        self.error_raise("commit", "Not implemented for PHYSICAL data.")
 
     def sync(self):
-        self.error_raise("sync", "Not implemented.")
+        self.error_raise("sync", "Not implemented for PHYSICAL data.")
 
     def status(self):
-        self.error_raise("status", "Not implemented.")
+        self.error_raise("status", "Not implemented for PHYSICAL data.")
 
     def clone(self, data_set_id, ssh_user, content_copy_index, skip_integrity_check):
-        self.error_raise("clone", "Not implemented.")
+        self.error_raise("clone", "Not implemented for PHYSICAL data.")
 
     def move(self, data_set_id, ssh_user, content_copy_index, skip_integrity_check):
-        self.error_raise("move", "Not implemented.")
+        self.error_raise("move", "Not implemented for PHYSICAL data.")
 
     def addref(self):
-        self.error_raise("addref", "Not implemented.")
+        self.error_raise("addref", "Not implemented for PHYSICAL data.")
 
     def removeref(self, data_set_id=None):
-        self.error_raise("removeref", "Not implemented.")
+        self.error_raise("removeref", "Not implemented for PHYSICAL data.")
 
     def download(self, data_set_id, _content_copy_index, file, _skip_integrity_check):
         cmd = DownloadPhysical(self, data_set_id, file)
         return cmd.run()
+
+    def search(self, type_code, space, project, experiment, property_code, property_value, save):
+        cmd = Search(self, type_code, space, project, experiment, property_code, property_value,
+                     save)
+        return cmd.search_samples()
diff --git a/app-openbis-command-line/src/python/obis/scripts/cli.py b/app-openbis-command-line/src/python/obis/scripts/cli.py
index 3fc36956e21..877d7827912 100644
--- a/app-openbis-command-line/src/python/obis/scripts/cli.py
+++ b/app-openbis-command-line/src/python/obis/scripts/cli.py
@@ -50,13 +50,15 @@ def add_params(params):
         for param in reversed(params):
             func = param(func)
         return func
+
     return _add_params
 
 
 @click.group()
 @click.version_option(version=None)
 @click.option('-q', '--quiet', default=False, is_flag=True, help='Suppress status reporting.')
-@click.option('-s', '--skip_verification', default=False, is_flag=True, help='Do not verify cerficiates')
+@click.option('-s', '--skip_verification', default=False, is_flag=True,
+              help='Do not verify cerficiates')
 @click.option('-d', '--debug', default=False, is_flag=True, help="Show stack trace on error.")
 @click.pass_context
 def cli(ctx, quiet, skip_verification, debug):
@@ -88,7 +90,8 @@ def init_analysis_impl(ctx, parent, repository, description):
     analysis_dir = os.path.join(os.getcwd(), repository)
     parent = os.path.relpath(parent_dir, analysis_dir)
     parent = '..' if parent is None else parent
-    return ctx.obj['runner'].run("init_analysis", lambda dm: dm.init_analysis(parent, description), repository)
+    return ctx.obj['runner'].run("init_analysis", lambda dm: dm.init_analysis(parent, description),
+                                 repository)
 
 
 # settings commands
@@ -267,7 +270,8 @@ def repository_clear(ctx, settings):
 
 @cli.group('data_set')
 @click.option('-g', '--is_global', default=False, is_flag=True, help='Set/get global or local.')
-@click.option('-p', '--is_data_set_property', default=False, is_flag=True, help='Configure data set property.')
+@click.option('-p', '--is_data_set_property', default=False, is_flag=True,
+              help='Configure data set property.')
 @click.pass_context
 def data_set(ctx, is_global, is_data_set_property):
     """ Get/set settings related to the data set.
@@ -280,27 +284,27 @@ def data_set(ctx, is_global, is_data_set_property):
 
 
 @data_set.command('set')
-@click.argument('settings', type=SettingsSet(), nargs=-1)
+@click.argument('data_set_settings', type=SettingsSet(), nargs=-1)
 @click.pass_context
-def data_set_set(ctx, settings):
-    return ctx.obj['runner'].run("data_set_set", lambda dm: _set(ctx, settings))
+def data_set_set(ctx, data_set_settings):
+    return ctx.obj['runner'].run("data_set_set", lambda dm: _set(ctx, data_set_settings))
 
 
 @data_set.command('get')
-@click.argument('settings', type=SettingsGet(), nargs=-1)
+@click.argument('data_set_settings', type=SettingsGet(), nargs=-1)
 @click.pass_context
-def data_set_get(ctx, settings):
-    return ctx.obj['runner'].run("data_set_get", lambda dm: _get(ctx, settings))
+def data_set_get(ctx, data_set_settings):
+    return ctx.obj['runner'].run("data_set_get", lambda dm: _get(ctx, data_set_settings))
 
 
 @data_set.command('clear')
-@click.argument('settings', type=SettingsClear(), nargs=-1)
+@click.argument('data_set_settings', type=SettingsClear(), nargs=-1)
 @click.pass_context
-def data_set_clear(ctx, settings):
-    return ctx.obj['runner'].run("data_set_clear", lambda dm: _clear(ctx, settings))
+def data_set_clear(ctx, data_set_settings):
+    return ctx.obj['runner'].run("data_set_clear", lambda dm: _clear(ctx, data_set_settings))
 
 
-## object: object_id
+# # object: object_id
 
 
 @cli.group()
@@ -316,27 +320,66 @@ def object(ctx, is_global):
 
 
 @object.command('set')
-@click.argument('settings', type=SettingsSet(), nargs=-1)
+@click.argument('object_settings', type=SettingsSet(), nargs=-1)
 @click.pass_context
-def object_set(ctx, settings):
-    return ctx.obj['runner'].run("object_set", lambda dm: _set(ctx, settings))
+def object_set(ctx, object_settings):
+    return ctx.obj['runner'].run("object_set", lambda dm: _set(ctx, object_settings))
 
 
 @object.command('get')
-@click.argument('settings', type=SettingsGet(), nargs=-1)
+@click.argument('object_settings', type=SettingsGet(), nargs=-1)
 @click.pass_context
-def object_get(ctx, settings):
-    return ctx.obj['runner'].run("object_get", lambda dm: _get(ctx, settings))
+def object_get(ctx, object_settings):
+    return ctx.obj['runner'].run("object_get", lambda dm: _get(ctx, object_settings))
 
 
 @object.command('clear')
-@click.argument('settings', type=SettingsClear(), nargs=-1)
+@click.argument('object_settings', type=SettingsClear(), nargs=-1)
+@click.pass_context
+def object_clear(ctx, object_settings):
+    return ctx.obj['runner'].run("object_clear", lambda dm: _clear(ctx, object_settings))
+
+
+_search_params = [
+    click.option('-type', '--type', 'type_code', default=None, help='Type code to filter by'),
+    click.option('-space', '--space', default=None, help='Space code'),
+    click.option('-project', '--project', default=None, help='Full project identification code'),
+    click.option('-experiment', '--experiment', default=None, help='Full experiment code'),
+    click.option('-property', '--property', 'property_code', default=None, help='Property code'),
+    click.option('-property-value', '--property-value', 'property_value', default=None,
+                 help='Property value'),
+    click.option('-save', '--save', default=None, help='Filename to save results'),
+]
+
+
+@cli.command(short_help="Download files of a linked data set.")
+@add_params(_search_params)
+@click.pass_context
+def object_search(ctx, type_code, space, project, experiment, property_code, property_value, save):
+    if all(v is None for v in
+           [type_code, space, project, experiment, property_code, property_value]):
+        click_echo("You must provide at least one filtering criteria!")
+        return -1
+    if (property_code is None and property_value is not None) or (
+            property_code is not None and property_value is None):
+        click_echo("Property code and property value need to be specified!")
+        return -1
+    ctx.obj['runner'] = DataMgmtRunner(ctx.obj, halt_on_error_log=False)
+    ctx.invoke(_object_search, type_code=type_code, space=space,
+               project=project, experiment=experiment, property_code=property_code,
+               property_value=property_value, save=save)
+
+
+@object.command('search')
+@add_params(_search_params)
 @click.pass_context
-def object_clear(ctx, settings):
-    return ctx.obj['runner'].run("object_clear", lambda dm: _clear(ctx, settings))
+def _object_search(ctx, type_code, space, project, experiment, property_code, property_value, save):
+    return ctx.obj['runner'].run("object_search",
+                                 lambda dm: dm.search(type_code, space, project, experiment,
+                                                      property_code, property_value, save)),
 
 
-## collection: collection_id
+# # collection: collection_id
 
 
 @cli.group()
@@ -428,7 +471,9 @@ _commit_params = [
 @click.pass_context
 @add_params(_commit_params)
 def repository_commit(ctx, msg, auto_add, ignore_missing_parent, repository):
-    return ctx.obj['runner'].run("commit", lambda dm: dm.commit(msg, auto_add, ignore_missing_parent), repository)
+    return ctx.obj['runner'].run("commit",
+                                 lambda dm: dm.commit(msg, auto_add, ignore_missing_parent),
+                                 repository)
 
 
 @cli.command(short_help="Commit the repository to git and inform openBIS.")
@@ -439,6 +484,7 @@ def commit(ctx, msg, auto_add, ignore_missing_parent, repository):
     ctx.invoke(repository_commit, msg=msg, auto_add=auto_add,
                ignore_missing_parent=ignore_missing_parent, repository=repository)
 
+
 # init
 
 
@@ -470,6 +516,7 @@ def init(ctx, repository_path, description, is_physical):
     ctx.obj['runner'] = DataMgmtRunner(ctx.obj, halt_on_error_log=False, is_physical=is_physical)
     ctx.invoke(repository_init, repository_path=repository_path, description=description)
 
+
 # init analysis
 
 
@@ -495,6 +542,7 @@ def init_analysis(ctx, parent, repository_path, description):
     ctx.invoke(repository_init_analysis, parent=parent,
                repository_path=repository_path, description=description)
 
+
 # status
 
 
@@ -518,6 +566,7 @@ def status(ctx, repository):
     ctx.obj['runner'] = DataMgmtRunner(ctx.obj, halt_on_error_log=False)
     ctx.invoke(repository_status, repository=repository)
 
+
 # sync
 
 
@@ -538,7 +587,8 @@ def _repository_sync(dm, ignore_missing_parent):
 @click.pass_context
 @add_params(_sync_params)
 def repository_sync(ctx, ignore_missing_parent, repository):
-    return ctx.obj['runner'].run("sync", lambda dm: _repository_sync(dm, ignore_missing_parent), repository)
+    return ctx.obj['runner'].run("sync", lambda dm: _repository_sync(dm, ignore_missing_parent),
+                                 repository)
 
 
 @cli.command(short_help="Sync the repository with openBIS.")
@@ -591,13 +641,13 @@ def new_token(ctx, session_name=None, **kwargs):
     validFrom = datetime.now()
     if kwargs.get("validity_months"):
         validTo = validFrom + \
-            relativedelta(months=int(kwargs.get("validity_months")))
+                  relativedelta(months=int(kwargs.get("validity_months")))
     elif kwargs.get("validity_weeks"):
         validTo = validFrom + \
-            relativedelta(weeks=int(kwargs.get("validity_weeks")))
+                  relativedelta(weeks=int(kwargs.get("validity_weeks")))
     elif kwargs.get("validity_days"):
         validTo = validFrom + \
-            relativedelta(days=int(kwargs.get("validity_days")))
+                  relativedelta(days=int(kwargs.get("validity_days")))
     else:
         serverinfo = o.get_server_information()
         seconds = serverinfo.personal_access_tokens_max_validity_period
@@ -637,6 +687,7 @@ def addref(ctx, repository):
     ctx.obj['runner'] = DataMgmtRunner(ctx.obj, halt_on_error_log=False)
     ctx.invoke(repository_addref, repository=repository)
 
+
 # removeref
 
 
@@ -648,14 +699,16 @@ _removeref_params = [
 ]
 
 
-@repository.command("removeref", short_help="Remove the reference to the given repository from openBIS.")
+@repository.command("removeref",
+                    short_help="Remove the reference to the given repository from openBIS.")
 @click.pass_context
 @add_params(_removeref_params)
 def repository_removeref(ctx, data_set_id, repository):
     if data_set_id is not None and repository is not None:
         click_echo("Only provide the data_set id OR the repository.")
         return -1
-    return ctx.obj['runner'].run("removeref", lambda dm: dm.removeref(data_set_id=data_set_id), repository)
+    return ctx.obj['runner'].run("removeref", lambda dm: dm.removeref(data_set_id=data_set_id),
+                                 repository)
 
 
 @cli.command(short_help="Remove the reference to the given repository from openBIS.")
@@ -686,7 +739,9 @@ _download_params = [
 @add_params(_download_params)
 @click.pass_context
 def data_set_download(ctx, content_copy_index, file, data_set_id, skip_integrity_check):
-    return ctx.obj['runner'].run("download", lambda dm: dm.download(data_set_id, content_copy_index, file, skip_integrity_check))
+    return ctx.obj['runner'].run("download",
+                                 lambda dm: dm.download(data_set_id, content_copy_index, file,
+                                                        skip_integrity_check))
 
 
 @cli.command(short_help="Download files of a linked data set.")
@@ -697,6 +752,7 @@ def download(ctx, content_copy_index, file, data_set_id, skip_integrity_check):
     ctx.invoke(data_set_download, content_copy_index=content_copy_index, file=file,
                data_set_id=data_set_id, skip_integrity_check=skip_integrity_check)
 
+
 # clone
 
 
@@ -715,7 +771,9 @@ _clone_move_params = [
 @click.pass_context
 @add_params(_clone_move_params)
 def data_set_clone(ctx, ssh_user, content_copy_index, data_set_id, skip_integrity_check):
-    return ctx.obj['runner'].run("clone", lambda dm: dm.clone(data_set_id, ssh_user, content_copy_index, skip_integrity_check))
+    return ctx.obj['runner'].run("clone",
+                                 lambda dm: dm.clone(data_set_id, ssh_user, content_copy_index,
+                                                     skip_integrity_check))
 
 
 @cli.command(short_help="Clone the repository found in the given data set id.")
@@ -733,7 +791,9 @@ def clone(ctx, ssh_user, content_copy_index, data_set_id, skip_integrity_check):
 @click.pass_context
 @add_params(_clone_move_params)
 def data_set_move(ctx, ssh_user, content_copy_index, data_set_id, skip_integrity_check):
-    return ctx.obj['runner'].run("move", lambda dm: dm.move(data_set_id, ssh_user, content_copy_index, skip_integrity_check))
+    return ctx.obj['runner'].run("move",
+                                 lambda dm: dm.move(data_set_id, ssh_user, content_copy_index,
+                                                    skip_integrity_check))
 
 
 @cli.command(short_help="Move the repository found in the given data set id.")
-- 
GitLab