diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd8e2f05..e9972b77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -189,7 +189,7 @@ jobs: strategy: matrix: testenv: [lowest, release] - python: ['3.6', '3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] + python: ['3.6', '3.7', '3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] steps: - uses: actions/checkout@v3 diff --git a/reana_client/api/client.py b/reana_client/api/client.py index ecdfd5e4..0c58bc96 100644 --- a/reana_client/api/client.py +++ b/reana_client/api/client.py @@ -7,7 +7,6 @@ # under the terms of the MIT License; see LICENSE file for more details. """REANA REST API client.""" -import cgi import json import logging import os @@ -27,6 +26,7 @@ from reana_commons.errors import REANASecretAlreadyExists, REANASecretDoesNotExist from werkzeug.local import LocalProxy +from reana_client.api.utils import get_content_disposition_filename from reana_client.config import ERROR_MESSAGES from reana_client.errors import FileDeletionError, FileUploadError from reana_client.utils import is_uuid_v4, is_regular_path @@ -492,9 +492,9 @@ def download_file(workflow, file_name, access_token): verify=False, ) if "Content-Disposition" in http_response.headers: - content_disposition = http_response.headers.get("Content-Disposition") - value, params = cgi.parse_header(content_disposition) - file_name = params.get("filename", "downloaded_file") + file_name = get_content_disposition_filename( + http_response.headers.get("Content-Disposition") + ) # A zip archive is downloaded if multiple files are requested multiple_files_zipped = ( diff --git a/reana_client/api/utils.py b/reana_client/api/utils.py index e781ddcd..dc226058 100644 --- a/reana_client/api/utils.py +++ b/reana_client/api/utils.py @@ -1,12 +1,14 @@ # -*- coding: utf-8 -*- # # This file is part of REANA. -# Copyright (C) 2019, 2020, 2021 CERN. +# Copyright (C) 2019, 2020, 2021, 2024 CERN. # # REANA is free software; you can redistribute it and/or modify it # under the terms of the MIT License; see LICENSE file for more details. """REANA client API utils.""" +from email.message import Message + def get_path_from_operation_id(paths_dict, operation_id): """Find API path based on operation id.""" @@ -17,3 +19,17 @@ def get_path_from_operation_id(paths_dict, operation_id): if paths_dict[path][method]["operationId"] == operation_id: return path return None + + +def get_content_disposition_filename(content_disposition_header): + """Retrieve filename from a Content-Disposition like header. + + Using email module instead of cgi.parse header due to https://peps.python.org/pep-0594/#cgi + + Return a filename if found, otherwise a default string. + """ + msg = Message() + msg["content-disposition"] = content_disposition_header + filename = msg.get_filename() + + return filename if filename else "downloaded_file" diff --git a/setup.py b/setup.py index 382e5c46..8009023c 100644 --- a/setup.py +++ b/setup.py @@ -101,6 +101,7 @@ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", diff --git a/tests/test_api_utils.py b/tests/test_api_utils.py new file mode 100644 index 00000000..04f6a43f --- /dev/null +++ b/tests/test_api_utils.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# +# This file is part of REANA. +# Copyright (C) 2024 CERN. +# +# REANA is free software; you can redistribute it and/or modify it +# under the terms of the MIT License; see LICENSE file for more details. + +"""REANA API utils tests.""" + +import pytest + +from reana_client.api.utils import get_content_disposition_filename + + +@pytest.mark.parametrize( + "content_disposition_header, expected_filename", + [ + ("inline", "downloaded_file"), + ("attachment", "downloaded_file"), + ('attachment; filename="example.txt"', "example.txt"), + ("attachment; filename*=UTF-8''example.txt", "example.txt"), + ("attachment; filename=folder", "folder"), + ('attachment; filename="folder/*/example.txt"', "folder/*/example.txt"), + ], +) +def test_get_content_disposition_filename( + content_disposition_header, expected_filename +): + assert ( + get_content_disposition_filename(content_disposition_header) + == expected_filename + ) diff --git a/tox.ini b/tox.ini index 9904a694..81399019 100644 --- a/tox.ini +++ b/tox.ini @@ -5,7 +5,15 @@ # under the terms of the MIT License; see LICENSE file for more details. [tox] -envlist = py36, py37, py38, py39, py310, py311, py312 +envlist = + py36 + py37 + py38 + py39 + py310 + py311 + py312 + py313 [testenv] deps = pytest