diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 917f0dcf..cecad877 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -189,7 +189,7 @@ jobs: strategy: matrix: testenv: [lowest, release] - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 diff --git a/reana_client/api/client.py b/reana_client/api/client.py index 8263feba..17e36af5 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 @@ -17,6 +16,7 @@ import requests from bravado.exception import HTTPError +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_regular_path, is_uuid_v4 @@ -500,9 +500,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 37d7213f..256e00c9 100644 --- a/setup.py +++ b/setup.py @@ -93,6 +93,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 7b2db7b5..5f07b341 100644 --- a/tox.ini +++ b/tox.ini @@ -11,6 +11,7 @@ envlist = py310 py311 py312 + py313 [testenv] deps =