Repository: reanahub/reana-client

Issue:
add `reana-client cat`
sometimes it's convenient to be able to just dump the contents of a file to stdout (e.g. a JSON file) for this a `reana-client cat`  would be nice to have

Discussion:

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index ba99c03..87d5e15 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -1,6 +1,11 @@
 Changes
 =======
 
+Version 0.8.2 (UNRELEASED)
+--------------------------
+
+- Changes ``download`` command to add the possibility to write files to the standard output via ``-o -`` option.
+
 Version 0.8.1 (2022-02-15)
 --------------------------
 
diff --git a/reana_client/api/client.py b/reana_client/api/client.py
index f03ddcc..f88e75d 100644
--- a/reana_client/api/client.py
+++ b/reana_client/api/client.py
@@ -402,7 +402,8 @@ def download_file(workflow, file_name, access_token):
     :param workflow: name or id which identifies the workflow.
     :param file_name: file name or path to the file requested.
     :param access_token: access token of the current user.
-    :return: Tuple containing file binary content and filename.
+    :return: Tuple containing file binary content, filename and whether
+        the returned file is a zip archive containing multiple files.
     """
     try:
         from reana_client.utils import get_api_url
@@ -421,8 +422,13 @@ def download_file(workflow, file_name, access_token):
             value, params = cgi.parse_header(content_disposition)
             file_name = params.get("filename", "downloaded_file")
 
+        # A zip archive is downloaded if multiple files are requested
+        multiple_files_zipped = (
+            http_response.headers.get("Content-Type") == "application/zip"
+        )
+
         if http_response.status_code == 200:
-            return http_response.content, file_name
+            return http_response.content, file_name, multiple_files_zipped
         else:
             raise Exception(
                 "Error {status_code} {reason} {message}".format(
diff --git a/reana_client/cli/files.py b/reana_client/cli/files.py
index 31b72f7..244bfd6 100644
--- a/reana_client/cli/files.py
+++ b/reana_client/cli/files.py
@@ -7,11 +7,13 @@
 # under the terms of the MIT License; see LICENSE file for more details.
 """REANA client output related commands."""
 
+import io
 import json
 import logging
 import os
 import sys
 import traceback
+import zipfile
 
 import click
 from reana_commons.utils import click_table_printer
@@ -28,7 +30,7 @@ from reana_client.cli.utils import (
     parse_filter_parameters,
     parse_format_parameters,
 )
-from reana_client.config import JSON, URL
+from reana_client.config import JSON, STD_OUTPUT_CHAR, URL
 from reana_client.errors import FileDeletionError, FileUploadError
 
 FILES_BLACKLIST = (".git/", "/.git/")
@@ -191,7 +193,8 @@ def get_files(
     "-o",
     "--output-directory",
     default=os.getcwd(),
-    help="Path to the directory where files will be downloaded.",
+    help="Path to the directory where files will be downloaded. "
+    "If ``-`` is specified as path, the files will be written to the standard output.",
 )
 @add_access_token_options
 @click.pass_context
@@ -207,10 +210,26 @@ def download_files(
 
     Examples: \n
     \t $ reana-client download # download all output files \n
-    \t $ reana-client download mydata.tmp outputs/myplot.png
+    \t $ reana-client download mydata.tmp outputs/myplot.png \n
+    \t $ reana-client download -o - data.txt # write data.txt to stdout
     """
     from reana_client.api.client import download_file, get_workflow_specification
 
+    def display_files_content(binary_file: bytes, multiple_files_zipped: bool) -> None:
+        """Write file(s) content to the standard output.
+
+        :param binary_file: The file(s) to print to the standard output.
+        :param multiple_files_zipped: Flag to determine if ``binary_file`` is a single
+            file or a zip archive containing multiple files.
+        """
+        if multiple_files_zipped:
+            with zipfile.ZipFile(io.BytesIO(binary_file)) as zip_file:
+                for entry in zip_file.infolist():
+                    if not entry.is_dir():
+                        click.echo(zip_file.read(entry), nl=False)
+        else:
+            click.echo(binary_file, nl=False)
+
     logging.debug("command: {}".format(ctx.command_path.replace(" ", ".")))
     for p in ctx.params:
         logging.debug("{param}: {value}".format(param=p, value=ctx.params[p]))
@@ -237,7 +256,7 @@ def download_files(
         download_failed = False
         for file_name in filenames:
             try:
-                binary_file, file_name = download_file(
+                binary_file, file_name, multiple_files_zipped = download_file(
                     workflow, file_name, access_token
                 )
 
@@ -247,16 +266,19 @@ def download_files(
                     )
                 )
 
-                outputs_file_path = os.path.join(output_directory, file_name)
-                if not os.path.exists(os.path.dirname(outputs_file_path)):
-                    os.makedirs(os.path.dirname(outputs_file_path))
+                if output_directory == STD_OUTPUT_CHAR:
+                    display_files_content(binary_file, multiple_files_zipped)
+                else:
+                    outputs_file_path = os.path.join(output_directory, file_name)
+                    if not os.path.exists(os.path.dirname(outputs_file_path)):
+                        os.makedirs(os.path.dirname(outputs_file_path))
 
-                with open(outputs_file_path, "wb") as f:
-                    f.write(binary_file)
-                display_message(
-                    "File {0} downloaded to {1}.".format(file_name, output_directory),
-                    msg_type="success",
-                )
+                    with open(outputs_file_path, "wb") as f:
+                        f.write(binary_file)
+                    display_message(
+                        f"File {file_name} downloaded to {output_directory}.",
+                        msg_type="success",
+                    )
             except OSError as e:
                 logging.debug(traceback.format_exc())
                 logging.debug(str(e))
diff --git a/reana_client/config.py b/reana_client/config.py
index a650aff..46e5eb1 100644
--- a/reana_client/config.py
+++ b/reana_client/config.py
@@ -82,3 +82,6 @@ JOB_STATUS_TO_MSG_COLOR = {
     "finished": "green",
     "running": "bright_blue",
 }
+
+STD_OUTPUT_CHAR = "-"
+"""Character used to refer to the standard output."""


Test patch:
diff --git a/tests/test_cli_files.py b/tests/test_cli_files.py
index 6e2a3b9..99093e3 100644
--- a/tests/test_cli_files.py
+++ b/tests/test_cli_files.py
@@ -9,8 +9,10 @@
 """REANA client files tests."""
 
 import hashlib
+import io
 import json
 import os
+import zipfile
 
 from click.testing import CliRunner
 from mock import Mock, patch
@@ -114,7 +116,8 @@ def test_download_file():
     mock_http_response.status_code = status_code
     mock_http_response.content = str(response).encode()
     mock_http_response.headers = {
-        "Content-Disposition": "attachment; filename={}".format(file)
+        "Content-Disposition": "attachment; filename={}".format(file),
+        "Content-Type": "multipart/form-data",
     }
     mock_requests = Mock()
     mock_requests.get = Mock(return_value=mock_http_response)
@@ -136,6 +139,86 @@ def test_download_file():
             os.remove(file)
 
 
+def test_download_file_stdout():
+    """Test writing a single file to stdout."""
+    env = {"REANA_SERVER_URL": "localhost"}
+    filename = "dummy_file.txt"
+    file_content = "Content of file to download"
+    mock_http_response = Mock()
+    mock_http_response.status_code = 200
+    mock_http_response.content = file_content.encode()
+    mock_http_response.headers = {
+        "Content-Disposition": "attachment; filename={}".format(filename),
+        "Content-Type": "multipart/form-data",
+    }
+    mock_requests = Mock()
+    mock_requests.get = Mock(return_value=mock_http_response)
+
+    reana_token = "000000"
+    runner = CliRunner(env=env)
+    with runner.isolation():
+        with patch("reana_client.api.client.requests", mock_requests):
+            result = runner.invoke(
+                cli,
+                [
+                    "download",
+                    "-t",
+                    reana_token,
+                    "--workflow",
+                    "mytest.1",
+                    filename,
+                    "-o",
+                    "-",
+                ],
+            )
+            assert result.exit_code == 0
+            assert result.output == file_content
+
+
+def test_download_multiple_files_stdout():
+    """Test writing multiple files to stdout."""
+    env = {"REANA_SERVER_URL": "localhost"}
+    files = [
+        ("dir1/dummy1.txt", "Content of dummy1.txt"),
+        ("dir1/dummy2.txt", "Content of dummy2.txt"),
+    ]
+    # Create zip file
+    return_file = io.BytesIO()
+    with zipfile.ZipFile(return_file, "w", zipfile.ZIP_DEFLATED) as zip_file:
+        for filename, content in files:
+            zip_file.writestr(filename, content)
+
+    mock_http_response = Mock()
+    mock_http_response.status_code = 200
+    mock_http_response.content = return_file.getvalue()
+    mock_http_response.headers = {
+        "Content-Disposition": "attachment; filename=files.zip",
+        "Content-Type": "application/zip",
+    }
+    mock_requests = Mock()
+    mock_requests.get = Mock(return_value=mock_http_response)
+
+    reana_token = "000000"
+    runner = CliRunner(env=env)
+    with runner.isolation():
+        with patch("reana_client.api.client.requests", mock_requests):
+            result = runner.invoke(
+                cli,
+                [
+                    "download",
+                    "-t",
+                    reana_token,
+                    "--workflow",
+                    "mytest.1",
+                    "-o",
+                    "-",
+                    "dir1",
+                ],
+            )
+            assert result.exit_code == 0
+            assert result.output == "".join([content for _, content in files])
+
+
 def test_upload_file(create_yaml_workflow_schema):
     """Test upload file."""
     reana_token = "000000"