benchloop-client 0.2.21__tar.gz → 0.2.22__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/PKG-INFO +1 -1
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/pyproject.toml +1 -1
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/src/aliyun/bmcli/cli.py +2 -0
- benchloop_client-0.2.22/src/aliyun/bmcli/commands/metadata.py +175 -0
- benchloop_client-0.2.22/tests/test_cmd_metadata.py +231 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/uv.lock +1 -1
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/.gitignore +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/.python-version +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/README.md +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/RELEASE.md +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/benchloop-cli-design.md +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/bmcli_examples.sh +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/src/aliyun/bmcli/__init__.py +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/src/aliyun/bmcli/__main__.py +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/src/aliyun/bmcli/client.py +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/src/aliyun/bmcli/commands/__init__.py +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/src/aliyun/bmcli/commands/_common.py +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/src/aliyun/bmcli/commands/ap_cmd.py +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/src/aliyun/bmcli/commands/artifacts.py +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/src/aliyun/bmcli/commands/group_data.py +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/src/aliyun/bmcli/commands/jobs.py +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/src/aliyun/bmcli/commands/metrics.py +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/src/aliyun/bmcli/commands/overview.py +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/src/aliyun/bmcli/commands/pull.py +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/src/aliyun/bmcli/commands/report.py +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/src/aliyun/bmcli/commands/tasks.py +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/src/aliyun/bmcli/commands/trajectory.py +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/src/aliyun/bmcli/config.py +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/src/aliyun/bmcli/exit_codes.py +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/src/aliyun/bmcli/ids.py +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/src/aliyun/bmcli/output.py +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/src/aliyun/bmcli/runtime.py +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/src/aliyun/bmcli/version_check.py +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/tests/__init__.py +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/tests/conftest.py +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/tests/test_cli_options.py +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/tests/test_client.py +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/tests/test_cmd_ap_cmd.py +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/tests/test_cmd_artifacts.py +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/tests/test_cmd_metrics.py +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/tests/test_cmd_overview.py +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/tests/test_cmd_pull.py +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/tests/test_cmd_report.py +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/tests/test_cmd_tasks_jobs.py +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/tests/test_cmd_trajectory.py +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/tests/test_config.py +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/tests/test_exit_code_behavior.py +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/tests/test_exit_codes.py +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/tests/test_group_data.py +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/tests/test_ids.py +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/tests/test_runtime.py +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/tests/test_transfer_progress.py +0 -0
- {benchloop_client-0.2.21 → benchloop_client-0.2.22}/tests/test_version_check.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "benchloop_client"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.22"
|
|
8
8
|
description = "Benchloop CLI — command line tool for benchmark analysis data (metrics, trajectory, reports, artifacts)."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -11,6 +11,7 @@ from aliyun.bmcli.commands import (
|
|
|
11
11
|
ap_cmd as ap_cmd_mod,
|
|
12
12
|
artifacts as artifacts_cmd,
|
|
13
13
|
jobs as jobs_cmd,
|
|
14
|
+
metadata as metadata_cmd,
|
|
14
15
|
metrics as metrics_cmd,
|
|
15
16
|
overview as overview_cmd,
|
|
16
17
|
pull as pull_cmd,
|
|
@@ -135,6 +136,7 @@ app.add_typer(overview_cmd.app, name="overview")
|
|
|
135
136
|
app.add_typer(tasks_cmd.app, name="tasks")
|
|
136
137
|
app.add_typer(jobs_cmd.app, name="jobs")
|
|
137
138
|
app.add_typer(artifacts_cmd.app, name="artifacts")
|
|
139
|
+
app.add_typer(metadata_cmd.app, name="metadata")
|
|
138
140
|
app.command(
|
|
139
141
|
"ap-cmd",
|
|
140
142
|
context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""`bmcli metadata` — job metadata for a CLI download task (task-id only)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import tempfile
|
|
7
|
+
import zipfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from aliyun.bmcli import output
|
|
15
|
+
from aliyun.bmcli.commands._common import (
|
|
16
|
+
OutputDirOption,
|
|
17
|
+
SkipExistingOption,
|
|
18
|
+
resolve_output_dir,
|
|
19
|
+
resolve_task_id,
|
|
20
|
+
task_job_ids,
|
|
21
|
+
)
|
|
22
|
+
from aliyun.bmcli.exit_codes import OK, PARTIAL
|
|
23
|
+
from aliyun.bmcli.runtime import get_runtime, report_and_exit, safe_run_concurrent
|
|
24
|
+
|
|
25
|
+
app = typer.Typer(help="Job metadata (download task).", no_args_is_help=True)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _write_metadata_archive(
|
|
29
|
+
archive_path: Path,
|
|
30
|
+
task_payload: dict,
|
|
31
|
+
task_id: str,
|
|
32
|
+
downloaded: list[tuple[str, Path]],
|
|
33
|
+
failures: list[tuple[str, str]],
|
|
34
|
+
*,
|
|
35
|
+
pretty: bool,
|
|
36
|
+
) -> Path:
|
|
37
|
+
"""Create the metadata ZIP atomically: metadata/{job_id}.json + manifest.json."""
|
|
38
|
+
manifest = {
|
|
39
|
+
"task_id": task_id,
|
|
40
|
+
"group_id": task_payload.get("group_id"),
|
|
41
|
+
"created_at": task_payload.get("created_at"),
|
|
42
|
+
"total_jobs": len(downloaded) + len(failures),
|
|
43
|
+
"successful_jobs": len(downloaded),
|
|
44
|
+
"failed_jobs": len(failures),
|
|
45
|
+
"success_job_ids": [job_id for job_id, _path in downloaded],
|
|
46
|
+
"failures": [{"job_id": job_id, "error": error} for job_id, error in failures],
|
|
47
|
+
}
|
|
48
|
+
indent = 2 if pretty else None
|
|
49
|
+
separators = None if pretty else (",", ":")
|
|
50
|
+
manifest_text = (
|
|
51
|
+
json.dumps(
|
|
52
|
+
manifest,
|
|
53
|
+
ensure_ascii=False,
|
|
54
|
+
indent=indent,
|
|
55
|
+
separators=separators,
|
|
56
|
+
)
|
|
57
|
+
+ "\n"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# 与轨迹任务包同一套原子写法:临时 zip 落在目标目录内,成功后 replace。
|
|
61
|
+
with tempfile.NamedTemporaryFile(
|
|
62
|
+
prefix=f".{archive_path.name}.",
|
|
63
|
+
suffix=".tmp",
|
|
64
|
+
dir=archive_path.parent,
|
|
65
|
+
delete=False,
|
|
66
|
+
) as tmp_archive:
|
|
67
|
+
tmp_archive_path = Path(tmp_archive.name)
|
|
68
|
+
try:
|
|
69
|
+
with zipfile.ZipFile(
|
|
70
|
+
tmp_archive_path,
|
|
71
|
+
mode="w",
|
|
72
|
+
compression=zipfile.ZIP_DEFLATED,
|
|
73
|
+
allowZip64=True,
|
|
74
|
+
) as archive:
|
|
75
|
+
for job_id, path in downloaded:
|
|
76
|
+
file_job_id = quote(job_id, safe="-_.")
|
|
77
|
+
archive.write(path, arcname=f"metadata/{file_job_id}.json")
|
|
78
|
+
archive.writestr("manifest.json", manifest_text)
|
|
79
|
+
tmp_archive_path.replace(archive_path)
|
|
80
|
+
except Exception:
|
|
81
|
+
tmp_archive_path.unlink(missing_ok=True)
|
|
82
|
+
raise
|
|
83
|
+
return archive_path
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@app.command("download")
|
|
87
|
+
def download(
|
|
88
|
+
ctx: typer.Context,
|
|
89
|
+
task_id: str = typer.Option(
|
|
90
|
+
...,
|
|
91
|
+
"--task-id",
|
|
92
|
+
help="Download task ID created by the benchmark analysis page.",
|
|
93
|
+
),
|
|
94
|
+
output_dir: Optional[str] = OutputDirOption,
|
|
95
|
+
skip_existing: bool = SkipExistingOption,
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Download per-job metadata for a task into {task_id}_metadata.zip."""
|
|
98
|
+
rt = get_runtime(ctx)
|
|
99
|
+
# 复用统一校验:task_id 不得包含路径分隔符,保证归档留在输出目录内。
|
|
100
|
+
task_id = resolve_task_id(task_id, [], [])
|
|
101
|
+
root = resolve_output_dir(rt.output_dir, output_dir)
|
|
102
|
+
|
|
103
|
+
archive_path = root / f"{task_id}_metadata.zip"
|
|
104
|
+
if skip_existing and archive_path.exists():
|
|
105
|
+
output.log(f"skip existing {archive_path}")
|
|
106
|
+
raise typer.Exit(code=OK)
|
|
107
|
+
|
|
108
|
+
task_result = safe_run_concurrent(
|
|
109
|
+
[task_id],
|
|
110
|
+
lambda tid: rt.client.get_json(
|
|
111
|
+
f"/openapi/trajectory-download-tasks/{quote(tid, safe='')}"
|
|
112
|
+
),
|
|
113
|
+
1,
|
|
114
|
+
)
|
|
115
|
+
if task_result.failures:
|
|
116
|
+
report_and_exit(task_result, emit=False)
|
|
117
|
+
if not task_result.items:
|
|
118
|
+
output.error("download task response is empty")
|
|
119
|
+
raise typer.Exit(code=PARTIAL)
|
|
120
|
+
|
|
121
|
+
task_payload = task_result.items[0]
|
|
122
|
+
try:
|
|
123
|
+
task_jobs = task_job_ids(task_payload)
|
|
124
|
+
except ValueError as exc:
|
|
125
|
+
output.error(str(exc))
|
|
126
|
+
raise typer.Exit(code=PARTIAL) from exc
|
|
127
|
+
|
|
128
|
+
output.ensure_dir(root)
|
|
129
|
+
progress = output.ProgressCounter(
|
|
130
|
+
"task metadata downloads",
|
|
131
|
+
len(task_jobs),
|
|
132
|
+
"jobs",
|
|
133
|
+
action="processed",
|
|
134
|
+
show_eta=True,
|
|
135
|
+
)
|
|
136
|
+
progress.start()
|
|
137
|
+
|
|
138
|
+
with tempfile.TemporaryDirectory(
|
|
139
|
+
prefix=f".{task_id}-metadata-", dir=root
|
|
140
|
+
) as temp_dir:
|
|
141
|
+
temp_root = Path(temp_dir)
|
|
142
|
+
|
|
143
|
+
def task_job_worker(job_id: str) -> tuple[str, Path]:
|
|
144
|
+
try:
|
|
145
|
+
meta = rt.client.get_json(
|
|
146
|
+
f"/openapi/jobs/{quote(job_id, safe='')}/meta"
|
|
147
|
+
)
|
|
148
|
+
# job_id comes from the server-side task snapshot. Encode it
|
|
149
|
+
# before using it as a local filename so an unexpected slash
|
|
150
|
+
# or traversal segment can never escape the temporary folder.
|
|
151
|
+
file_job_id = quote(job_id, safe="-_.")
|
|
152
|
+
dest = temp_root / f"{file_job_id}.json"
|
|
153
|
+
output.write_json_file(dest, meta, pretty=rt.pretty)
|
|
154
|
+
return job_id, dest
|
|
155
|
+
finally:
|
|
156
|
+
# A failed request is still a completed attempt and must move
|
|
157
|
+
# the visible task progress forward.
|
|
158
|
+
progress.advance(job_id)
|
|
159
|
+
|
|
160
|
+
result = safe_run_concurrent(task_jobs, task_job_worker, rt.concurrency)
|
|
161
|
+
if not result.items:
|
|
162
|
+
report_and_exit(result, emit=False)
|
|
163
|
+
|
|
164
|
+
output.progress(f"creating archive {archive_path}")
|
|
165
|
+
_write_metadata_archive(
|
|
166
|
+
archive_path,
|
|
167
|
+
task_payload,
|
|
168
|
+
task_id,
|
|
169
|
+
result.items,
|
|
170
|
+
result.failures,
|
|
171
|
+
pretty=rt.pretty,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
output.log(f"wrote {archive_path}")
|
|
175
|
+
report_and_exit(result, emit=False)
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Tests for `bmcli metadata download` (task-id only)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import zipfile
|
|
7
|
+
|
|
8
|
+
from aliyun.bmcli.cli import app
|
|
9
|
+
from tests.conftest import invoke
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestMetadataDownloadTask:
|
|
13
|
+
@staticmethod
|
|
14
|
+
def task_payload(job_ids):
|
|
15
|
+
return {
|
|
16
|
+
"task_id": "T1",
|
|
17
|
+
"group_id": "G1",
|
|
18
|
+
"job_count": len(job_ids),
|
|
19
|
+
"job_ids": job_ids,
|
|
20
|
+
"created_at": "2026-07-14T10:00:00",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
def test_download_creates_zip_with_metadata_and_manifest(
|
|
24
|
+
self, runner, mock_client, tmp_path
|
|
25
|
+
):
|
|
26
|
+
def response(path):
|
|
27
|
+
if path == "/openapi/trajectory-download-tasks/T1":
|
|
28
|
+
return self.task_payload(["J1", "J2"])
|
|
29
|
+
job_id = path.split("/")[-2]
|
|
30
|
+
return {"job_id": job_id, "model": "qwen-max", "tags": ["t1"]}
|
|
31
|
+
|
|
32
|
+
mock_client.get_json.side_effect = response
|
|
33
|
+
result = invoke(
|
|
34
|
+
runner,
|
|
35
|
+
[
|
|
36
|
+
"--api-key",
|
|
37
|
+
"k",
|
|
38
|
+
"metadata",
|
|
39
|
+
"download",
|
|
40
|
+
"--task-id",
|
|
41
|
+
"T1",
|
|
42
|
+
"-o",
|
|
43
|
+
str(tmp_path),
|
|
44
|
+
],
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
archive_path = tmp_path / "T1_metadata.zip"
|
|
48
|
+
assert result.exit_code == 0
|
|
49
|
+
assert archive_path.exists()
|
|
50
|
+
assert f"wrote {archive_path}" in result.stderr
|
|
51
|
+
called_paths = [call.args[0] for call in mock_client.get_json.call_args_list]
|
|
52
|
+
assert called_paths.count("/openapi/jobs/J1/meta") == 1
|
|
53
|
+
assert called_paths.count("/openapi/jobs/J2/meta") == 1
|
|
54
|
+
with zipfile.ZipFile(archive_path) as archive:
|
|
55
|
+
assert set(archive.namelist()) == {
|
|
56
|
+
"metadata/J1.json",
|
|
57
|
+
"metadata/J2.json",
|
|
58
|
+
"manifest.json",
|
|
59
|
+
}
|
|
60
|
+
assert json.loads(archive.read("metadata/J1.json")) == {
|
|
61
|
+
"job_id": "J1",
|
|
62
|
+
"model": "qwen-max",
|
|
63
|
+
"tags": ["t1"],
|
|
64
|
+
}
|
|
65
|
+
manifest = json.loads(archive.read("manifest.json"))
|
|
66
|
+
assert manifest["task_id"] == "T1"
|
|
67
|
+
assert manifest["group_id"] == "G1"
|
|
68
|
+
assert manifest["total_jobs"] == 2
|
|
69
|
+
assert manifest["successful_jobs"] == 2
|
|
70
|
+
assert manifest["failed_jobs"] == 0
|
|
71
|
+
assert manifest["success_job_ids"] == ["J1", "J2"]
|
|
72
|
+
assert manifest["failures"] == []
|
|
73
|
+
assert not list(tmp_path.glob(".T1-metadata-*"))
|
|
74
|
+
|
|
75
|
+
def test_partial_failure_still_archives_success_and_exits_partial(
|
|
76
|
+
self, runner, mock_client, tmp_path
|
|
77
|
+
):
|
|
78
|
+
def response(path):
|
|
79
|
+
if path == "/openapi/trajectory-download-tasks/T1":
|
|
80
|
+
return self.task_payload(["J1", "J2"])
|
|
81
|
+
if path == "/openapi/jobs/J2/meta":
|
|
82
|
+
raise RuntimeError("meta unavailable")
|
|
83
|
+
return {"job_id": "J1"}
|
|
84
|
+
|
|
85
|
+
mock_client.get_json.side_effect = response
|
|
86
|
+
result = invoke(
|
|
87
|
+
runner,
|
|
88
|
+
[
|
|
89
|
+
"--api-key",
|
|
90
|
+
"k",
|
|
91
|
+
"metadata",
|
|
92
|
+
"download",
|
|
93
|
+
"--task-id",
|
|
94
|
+
"T1",
|
|
95
|
+
"-o",
|
|
96
|
+
str(tmp_path),
|
|
97
|
+
],
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
archive_path = tmp_path / "T1_metadata.zip"
|
|
101
|
+
assert result.exit_code == 1
|
|
102
|
+
assert archive_path.exists()
|
|
103
|
+
with zipfile.ZipFile(archive_path) as archive:
|
|
104
|
+
assert set(archive.namelist()) == {
|
|
105
|
+
"metadata/J1.json",
|
|
106
|
+
"manifest.json",
|
|
107
|
+
}
|
|
108
|
+
manifest = json.loads(archive.read("manifest.json"))
|
|
109
|
+
assert manifest["failed_jobs"] == 1
|
|
110
|
+
assert manifest["failures"] == [
|
|
111
|
+
{"job_id": "J2", "error": "meta unavailable"}
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
def test_all_failures_do_not_create_zip(self, runner, mock_client, tmp_path):
|
|
115
|
+
def response(path):
|
|
116
|
+
if path == "/openapi/trajectory-download-tasks/T1":
|
|
117
|
+
return self.task_payload(["J1", "J2"])
|
|
118
|
+
raise RuntimeError("meta unavailable")
|
|
119
|
+
|
|
120
|
+
mock_client.get_json.side_effect = response
|
|
121
|
+
result = invoke(
|
|
122
|
+
runner,
|
|
123
|
+
[
|
|
124
|
+
"--api-key",
|
|
125
|
+
"k",
|
|
126
|
+
"metadata",
|
|
127
|
+
"download",
|
|
128
|
+
"--task-id",
|
|
129
|
+
"T1",
|
|
130
|
+
"-o",
|
|
131
|
+
str(tmp_path),
|
|
132
|
+
],
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
assert result.exit_code == 1
|
|
136
|
+
assert not (tmp_path / "T1_metadata.zip").exists()
|
|
137
|
+
assert "2 id(s) failed" in result.stderr
|
|
138
|
+
assert not list(tmp_path.glob(".T1-metadata-*"))
|
|
139
|
+
|
|
140
|
+
def test_skip_existing_makes_no_api_calls(self, runner, mock_client, tmp_path):
|
|
141
|
+
existing = tmp_path / "T1_metadata.zip"
|
|
142
|
+
existing.write_bytes(b"zip")
|
|
143
|
+
result = invoke(
|
|
144
|
+
runner,
|
|
145
|
+
[
|
|
146
|
+
"--api-key",
|
|
147
|
+
"k",
|
|
148
|
+
"metadata",
|
|
149
|
+
"download",
|
|
150
|
+
"--task-id",
|
|
151
|
+
"T1",
|
|
152
|
+
"--skip-existing",
|
|
153
|
+
"-o",
|
|
154
|
+
str(tmp_path),
|
|
155
|
+
],
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
assert result.exit_code == 0
|
|
159
|
+
assert f"skip existing {existing}" in result.stderr
|
|
160
|
+
mock_client.get_json.assert_not_called()
|
|
161
|
+
|
|
162
|
+
def test_encodes_traversal_job_id_inside_archive(
|
|
163
|
+
self, runner, mock_client, tmp_path
|
|
164
|
+
):
|
|
165
|
+
def response(path):
|
|
166
|
+
if path == "/openapi/trajectory-download-tasks/T1":
|
|
167
|
+
return self.task_payload(["../J1"])
|
|
168
|
+
return {"job_id": "../J1"}
|
|
169
|
+
|
|
170
|
+
mock_client.get_json.side_effect = response
|
|
171
|
+
result = invoke(
|
|
172
|
+
runner,
|
|
173
|
+
[
|
|
174
|
+
"--api-key",
|
|
175
|
+
"k",
|
|
176
|
+
"metadata",
|
|
177
|
+
"download",
|
|
178
|
+
"--task-id",
|
|
179
|
+
"T1",
|
|
180
|
+
"-o",
|
|
181
|
+
str(tmp_path),
|
|
182
|
+
],
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
assert result.exit_code == 0
|
|
186
|
+
with zipfile.ZipFile(tmp_path / "T1_metadata.zip") as archive:
|
|
187
|
+
assert "metadata/..%2FJ1.json" in archive.namelist()
|
|
188
|
+
assert not (tmp_path.parent / "J1.json").exists()
|
|
189
|
+
|
|
190
|
+
def test_task_id_with_path_separator_rejected(
|
|
191
|
+
self, runner, mock_client, tmp_path
|
|
192
|
+
):
|
|
193
|
+
result = invoke(
|
|
194
|
+
runner,
|
|
195
|
+
[
|
|
196
|
+
"--api-key",
|
|
197
|
+
"k",
|
|
198
|
+
"metadata",
|
|
199
|
+
"download",
|
|
200
|
+
"--task-id",
|
|
201
|
+
"../T1",
|
|
202
|
+
"-o",
|
|
203
|
+
str(tmp_path),
|
|
204
|
+
],
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
assert result.exit_code == 2
|
|
208
|
+
assert "path separators" in result.stderr
|
|
209
|
+
mock_client.get_json.assert_not_called()
|
|
210
|
+
|
|
211
|
+
def test_empty_task_fails_without_creating_zip(
|
|
212
|
+
self, runner, mock_client, tmp_path
|
|
213
|
+
):
|
|
214
|
+
mock_client.get_json.return_value = self.task_payload([])
|
|
215
|
+
result = invoke(
|
|
216
|
+
runner,
|
|
217
|
+
[
|
|
218
|
+
"--api-key",
|
|
219
|
+
"k",
|
|
220
|
+
"metadata",
|
|
221
|
+
"download",
|
|
222
|
+
"--task-id",
|
|
223
|
+
"T1",
|
|
224
|
+
"-o",
|
|
225
|
+
str(tmp_path),
|
|
226
|
+
],
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
assert result.exit_code == 1
|
|
230
|
+
assert "download task contains no job ids" in result.stderr
|
|
231
|
+
assert not (tmp_path / "T1_metadata.zip").exists()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|