From 8e9c5a6028eb6d2f9dee7630b94a68317fe20063 Mon Sep 17 00:00:00 2001 From: Giuseppe Steduto Date: Thu, 2 Nov 2023 00:13:28 +0100 Subject: [PATCH] cli: add include-last-command flag to list and status commands Add `--include-last-command` flag to the `list` and `status` commands that, when set, will display info about the command currently being executed by the workflow (or the last submitted command). In case there is no info about the command, the step name will be displayed, if possible. Closes reanahub/reana-workflow-controller#486. --- CHANGES.rst | 1 + reana_client/api/client.py | 10 ++++- reana_client/cli/utils.py | 18 ++++++++ reana_client/cli/workflow.py | 81 +++++++++++++++++++++++------------- setup.py | 2 +- tests/test_cli_workflows.py | 76 +++++++++++++++++++++++++++++++++ tests/test_utils.py | 16 +++++++ 7 files changed, 172 insertions(+), 32 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 749ebc2e..14425e29 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,6 +4,7 @@ Changes Version 0.9.2 (UNRELEASED) -------------------------- +- Changes ``status``, ``list`` commands to add the ``include-last-command`` flag to display the last command executed (or in execution) by the workflow. - Fixes ``create_workflow_from_json`` API command to always send the workflow specification to the server. - Fixes ``list`` command to be case-insensitive when using the ``--sort`` flag to sort the workflow runs by a specific column name. diff --git a/reana_client/api/client.py b/reana_client/api/client.py index ba0d83bb..7c9dc628 100644 --- a/reana_client/api/client.py +++ b/reana_client/api/client.py @@ -115,6 +115,7 @@ def get_workflows( search=None, include_progress=None, include_workspace_size=None, + include_last_command=None, workflow=None, ): """List all existing workflows. @@ -130,6 +131,7 @@ def get_workflows( :param search: search workflows by name. :param include_progress: include progress information in the response. :param include_workspace_size: include workspace size information in the response. + :param include_last_command: include info about the command currently executing. :param workflow: name or id of the workflow. :return: a list of dictionaries with the information about the workflows. @@ -148,6 +150,7 @@ def get_workflows( search=search, include_progress=include_progress, include_workspace_size=include_workspace_size, + include_last_command=include_last_command, workflow_id_or_name=workflow, ).result() if http_response.status_code == 200: @@ -170,11 +173,12 @@ def get_workflows( raise e -def get_workflow_status(workflow, access_token): +def get_workflow_status(workflow, access_token, include_last_command=False): """Get status of previously created workflow. :param workflow: name or id of the workflow. :param access_token: access token of the current user. + :param include_last_command: show the last command executed/ing in the workflow. :return: a dictionary with the information about the workflow status. The dictionary has the following keys: ``id``, ``logs``, ``name``, @@ -182,7 +186,9 @@ def get_workflow_status(workflow, access_token): """ try: response, http_response = current_rs_api_client.api.get_workflow_status( - workflow_id_or_name=workflow, access_token=access_token + workflow_id_or_name=workflow, + access_token=access_token, + include_last_command=include_last_command, ).result() if http_response.status_code == 200: return response diff --git a/reana_client/cli/utils.py b/reana_client/cli/utils.py index 0acdbcaa..120d2409 100644 --- a/reana_client/cli/utils.py +++ b/reana_client/cli/utils.py @@ -10,6 +10,7 @@ import functools import json import os +import re import shlex import sys from typing import Callable, NoReturn, Optional, List, Tuple, Union @@ -277,6 +278,23 @@ def get_formatted_progress(progress): return "{0}/{1}".format(finished_jobs, total_jobs) +def get_formatted_workflow_command(progress): + """Return lastly executed command if possible, otherwise try to return the step name.""" + current_command = progress.get("current_command") + if current_command: + # Change multiline commands to a single line, replacing any sequence of consecutive newlines with a semicolon. + current_command = re.sub(r"\n+", "; ", current_command) + if current_command.startswith('bash -c "cd '): + current_command = current_command[current_command.index(";") + 2 : -2] + return current_command + else: + if "current_step_name" in progress and progress.get("current_step_name"): + current_step_name = progress.get("current_step_name") + return current_step_name + else: + return "-" + + def key_value_to_dict(ctx, param, value): """Convert tuple params to dictionary. e.g `(foo=bar)` to `{'foo': 'bar'}`. diff --git a/reana_client/cli/workflow.py b/reana_client/cli/workflow.py index d23cb108..f2e530de 100644 --- a/reana_client/cli/workflow.py +++ b/reana_client/cli/workflow.py @@ -31,6 +31,7 @@ display_formatted_output, format_session_uri, get_formatted_progress, + get_formatted_workflow_command, human_readable_or_raw_option, key_value_to_dict, parse_filter_parameters, @@ -144,6 +145,13 @@ def workflow_execution_group(ctx): default=None, help="Include size information of the workspace.", ) +@click.option( + "--include-last-command", + "include_last_command", + is_flag=True, + default=None, + help="Include the information about the last command executed (or currently in execution) by the workflow.", +) @click.option( "--show-deleted-runs", "show_deleted_runs", @@ -172,6 +180,7 @@ def workflows_list( # noqa: C901 include_duration: bool, include_progress, include_workspace_size, + include_last_command, show_deleted_runs: bool, ): # noqa: D301 """List all workflows and sessions. @@ -217,11 +226,13 @@ def workflows_list( # noqa: C901 search=search_filter, include_progress=include_progress, include_workspace_size=include_workspace_size, + include_last_command=include_last_command, workflow=workflow, ) verbose_headers = ["id", "user"] workspace_size_header = ["size"] progress_header = ["progress"] + command_header = ["last_command"] duration_header = ["duration"] headers = { "batch": ["name", "run_number", "created", "started", "ended", "status"], @@ -242,6 +253,8 @@ def workflows_list( # noqa: C901 headers[type] += progress_header if verbose or include_duration: headers[type] += duration_header + if verbose or include_last_command: + headers[type] += command_header data = [] for workflow in response: @@ -260,6 +273,8 @@ def workflows_list( # noqa: C901 value = None if header in progress_header: value = get_formatted_progress(workflow.get("progress")) + elif header in command_header: + value = get_formatted_workflow_command(workflow.get("progress")) elif header in ["started", "ended"]: _key = ( "run_started_at" if header == "started" else "run_finished_at" @@ -692,12 +707,32 @@ def workflow_restart( help="Include the duration of the workflow in seconds. In case the workflow is in " "progress, its duration as of now will be shown.", ) +@click.option( + "--include-last-command", + "include_last_command", + is_flag=True, + default=False, + help="Include the information about the command that is currently being executed by the workflow.", +) +@click.option( + "-v", + "--verbose", + is_flag=True, + default=False, + help="Set status information verbosity.", +) @add_access_token_options @check_connection -@click.option("-v", "--verbose", count=True, help="Set status information verbosity.") @click.pass_context def workflow_status( # noqa: C901 - ctx, workflow, _format, output_format, include_duration, access_token, verbose + ctx, + workflow, + _format, + output_format, + include_duration, + include_last_command, + verbose, + access_token, ): # noqa: D301 """Get status of a workflow. @@ -753,47 +788,35 @@ def add_data_from_response(row, data, headers): data.append(parsed_response) return data - def add_verbose_data_from_response(response, verbose_headers, headers, data): + def add_verbose_data_from_response(response, verbose_headers, data): for k in verbose_headers: - if k == "command": - current_command = response["progress"]["current_command"] - if current_command: - if current_command.startswith('bash -c "cd '): - current_command = current_command[ - current_command.index(";") + 2 : -2 - ] - data[-1] += [current_command] - else: - if "current_step_name" in response["progress"] and response[ - "progress" - ].get("current_step_name"): - current_step_name = response["progress"].get( - "current_step_name" - ) - data[-1] += [current_step_name] - else: - headers.remove("command") - else: - data[-1] += [response.get(k)] + data[-1] += [response.get(k)] return data logging.debug("command: {}".format(ctx.command_path.replace(" ", "."))) for p in ctx.params: logging.debug("{param}: {value}".format(param=p, value=ctx.params[p])) try: - workflow_response = get_workflow_status(workflow, access_token) + include_duration = verbose or include_duration + include_last_command = verbose or include_last_command + workflow_response = get_workflow_status( + workflow, access_token, include_last_command + ) headers = ["name", "run_number", "created", "status"] - verbose_headers = ["id", "user", "command"] + verbose_headers = ["id", "user"] data = [] add_data_from_response(workflow_response, data, headers) if verbose: headers += verbose_headers - add_verbose_data_from_response( - workflow_response, verbose_headers, headers, data - ) - if verbose or include_duration: + add_verbose_data_from_response(workflow_response, verbose_headers, data) + if include_duration: headers += ["duration"] data[-1] += [get_workflow_duration(workflow_response) or "-"] + if include_last_command: + headers += ["last_command"] + data[-1] += [ + get_formatted_workflow_command(workflow_response.get("progress")) or "-" + ] display_formatted_output(data, headers, _format, output_format) diff --git a/setup.py b/setup.py index 60a86067..db961729 100644 --- a/setup.py +++ b/setup.py @@ -45,7 +45,7 @@ "click>=7", "pathspec==0.9.0", "jsonpointer>=2.0", - "reana-commons[yadage,snakemake,cwl]>=0.9.4a1,<0.10.0", + "reana-commons[yadage,snakemake,cwl]>=0.9.4a3,<0.10.0", "tablib>=0.12.1,<0.13", "werkzeug>=0.14.1 ; python_version<'3.10'", "werkzeug>=0.15.0 ; python_version>='3.10'", diff --git a/tests/test_cli_workflows.py b/tests/test_cli_workflows.py index 4f6bc82c..8a0770ac 100644 --- a/tests/test_cli_workflows.py +++ b/tests/test_cli_workflows.py @@ -402,6 +402,81 @@ def test_workflows_without_include_workspace_size(): assert "SIZE" not in result.output +def test_workflows_include_last_command(): + """Test workflows command with --include-last-command flag.""" + response = { + "items": [ + { + "status": "running", + "created": "2018-06-13T09:47:35.66097", + "user": "00000000-0000-0000-0000-000000000000", + "name": "mytest.1", + "id": "256b25f4-4cfb-4684-b7a8-73872ef455a1", + "size": {"human_readable": "15.97 MiB", "raw": 16741346}, + "progress": { + "current_command": "some_command\n\nanother one\nlast one", + "run_started_at": "2021-05-10T12:55:04", + "run_finished_at": "2021-05-10T12:55:23", + }, + } + ] + } + status_code = 200 + mock_http_response, mock_response = Mock(), Mock() + mock_http_response.status_code = status_code + mock_response = response + env = {"REANA_SERVER_URL": "localhost"} + reana_token = "000000" + runner = CliRunner(env=env) + with runner.isolation(): + with patch( + "reana_client.api.client.current_rs_api_client", + make_mock_api_client("reana-server")(mock_response, mock_http_response), + ): + result = runner.invoke( + cli, ["list", "--include-last-command", "-t", reana_token] + ) + assert result.exit_code == 0 + assert "LAST_COMMAND" in result.output + assert "some_command; another one; last one" in result.output + + +def test_workflows_without_include_last_command(): + """Test workflows command without --include-last-command flag.""" + response = { + "items": [ + { + "status": "running", + "created": "2018-06-13T09:47:35.66097", + "user": "00000000-0000-0000-0000-000000000000", + "name": "mytest.1", + "id": "256b25f4-4cfb-4684-b7a8-73872ef455a1", + "size": {"human_readable": "", "raw": -1}, + "progress": { + "current_command": "some_command", + "run_started_at": "2021-05-10T12:55:04", + "run_finished_at": "2021-05-10T12:55:23", + }, + } + ] + } + status_code = 200 + mock_http_response, mock_response = Mock(), Mock() + mock_http_response.status_code = status_code + mock_response = response + env = {"REANA_SERVER_URL": "localhost"} + reana_token = "000000" + runner = CliRunner(env=env) + with runner.isolation(): + with patch( + "reana_client.api.client.current_rs_api_client", + make_mock_api_client("reana-server")(mock_response, mock_http_response), + ): + result = runner.invoke(cli, ["list", "-t", reana_token]) + assert result.exit_code == 0 + assert "COMMAND" not in result.output + + def test_workflows_format(): """Test workflows command with --format.""" response = { @@ -794,6 +869,7 @@ def test_get_workflow_status_ok(): assert isinstance(json_response, list) assert len(json_response) == 1 assert json_response[0]["name"] in response["name"] + assert json_response[0]["last_command"] == "-" @patch("reana_client.cli.workflow.workflow_create") diff --git a/tests/test_utils.py b/tests/test_utils.py index cbef171b..9b0cc30b 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -11,6 +11,8 @@ from unittest.mock import patch from datetime import datetime +import pytest +from reana_client.cli.utils import get_formatted_workflow_command from reana_client.utils import get_workflow_duration @@ -47,3 +49,17 @@ def test_duration_running_workflow(): *args, **kw ) assert get_workflow_duration(workflow) == 60 + 11 + + +@pytest.mark.parametrize( + "progress,expected_output", + [ + ({"current_command": 'bash -c "cd /some/path; some_command;"'}, "some_command"), + ({"current_command": "some_command"}, "some_command"), + ({"current_command": None, "current_step_name": "step_1"}, "step_1"), + ({"current_command": None, "current_step_name": None}, "-"), + ({}, "-"), + ], +) +def test_get_formatted_workflow_command(progress, expected_output): + assert get_formatted_workflow_command(progress) == expected_output