From fd16997f9fe861c6c70eec7ca12166f3c39fe137 Mon Sep 17 00:00:00 2001 From: Qianye Su <976113615@qq.com> Date: Mon, 5 Oct 2026 22:30:55 +0800 Subject: [PATCH] Handle scalar download targets as single paths --- cdsapi/api.py | 12 ++++- tests/test_download.py | 115 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 tests/test_download.py diff --git a/cdsapi/api.py b/cdsapi/api.py index 376e739..ceb884d 100644 --- a/cdsapi/api.py +++ b/cdsapi/api.py @@ -598,9 +598,17 @@ def _download(self, results, targets=None): return results def download(self, results, targets=None): + """Download results to a single path or an iterable of target paths. + + Targets are consumed in result traversal order. If no target is + provided, each result uses its default filename. + """ if targets: - # Make a copy - targets = [t for t in targets] + if isinstance(targets, (str, bytes, os.PathLike)): + targets = [targets] + else: + # Make a copy so the caller's targets are not consumed. + targets = list(targets) return self._download(results, targets) def remote(self, url): diff --git a/tests/test_download.py b/tests/test_download.py new file mode 100644 index 0000000..bd59e3b --- /dev/null +++ b/tests/test_download.py @@ -0,0 +1,115 @@ +from pathlib import Path +from unittest.mock import Mock + +import pytest +import requests + +from cdsapi.api import Client, Result + + +@pytest.fixture +def client(monkeypatch): + monkeypatch.setattr( + requests.Session, + "request", + Mock(side_effect=AssertionError("Unexpected HTTP request")), + ) + with requests.Session() as session: + yield Client( + url="https://example.invalid/api/", + key="test:token", + session=session, + quiet=True, + delete=False, + ) + + +@pytest.fixture +def download(monkeypatch): + download = Mock( + side_effect=lambda url, size, target: ( + url.rsplit("/", 1)[-1] if target is None else target + ) + ) + monkeypatch.setattr(Result, "_download", download) + return download + + +def make_result(client, name, as_dict): + if as_dict: + return {"location": name, "contentLength": 1, "contentType": "test"} + return Result( + client, + {"location": name, "content_length": 1, "content_type": "test"}, + ) + + +@pytest.mark.parametrize("as_dict", [False, True]) +@pytest.mark.parametrize( + "targets,expected", + [ + ("download.nc", "download.nc"), + (b"download.nc", b"download.nc"), + (Path("download.nc"), Path("download.nc")), + (["download.nc"], "download.nc"), + (("download.nc",), "download.nc"), + (None, "source.nc"), + ([], "source.nc"), + ], +) +def test_download_single_result(client, download, as_dict, targets, expected): + result = make_result(client, "source.nc", as_dict) + original = targets.copy() if isinstance(targets, list) else targets + + assert client.download(result, targets) == expected + + assert targets == original + target = None if not targets else expected + download.assert_called_once_with("https://example.invalid/api/source.nc", 1, target) + + +@pytest.mark.parametrize("as_dict", [False, True]) +@pytest.mark.parametrize("result_type", [list, tuple]) +@pytest.mark.parametrize("target_type", [list, tuple]) +def test_download_multiple_results(client, download, as_dict, result_type, target_type): + results = result_type( + make_result(client, name, as_dict) for name in ("a.nc", "b.nc") + ) + targets = target_type(("first.nc", "second.nc")) + + assert client.download(results, targets) == ["first.nc", "second.nc"] + + assert list(targets) == ["first.nc", "second.nc"] + assert download.call_count == 2 + + +@pytest.mark.parametrize("target_type", [str, bytes, Path]) +def test_download_writes_target(client, tmp_path, target_type): + path = tmp_path / "download.nc" + target = bytes(path) if target_type is bytes else target_type(path) + payload = b"offline download payload" + response = Mock(spec=requests.Response) + response.status_code = 200 + response.reason = "OK" + response.iter_content.return_value = [payload] + client.session.get = Mock(return_value=response) + result = Result( + client, + { + "location": "source.nc", + "content_length": len(payload), + "content_type": "test", + }, + ) + + assert client.download(result, target) == target + + assert path.read_bytes() == payload + response.close.assert_called_once_with() + client.session.get.assert_called_once_with( + "https://example.invalid/api/source.nc", + stream=True, + verify=client.verify, + headers=None, + timeout=client.timeout, + )