benchloop-client 0.2.20__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.
Files changed (55) hide show
  1. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/PKG-INFO +1 -1
  2. benchloop_client-0.2.22/RELEASE.md +143 -0
  3. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/pyproject.toml +1 -1
  4. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/cli.py +7 -0
  5. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/client.py +38 -2
  6. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/commands/_common.py +13 -0
  7. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/commands/artifacts.py +78 -16
  8. benchloop_client-0.2.22/src/aliyun/bmcli/commands/metadata.py +175 -0
  9. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/commands/pull.py +17 -3
  10. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/commands/trajectory.py +50 -5
  11. benchloop_client-0.2.22/src/aliyun/bmcli/output.py +360 -0
  12. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/tests/test_cli_options.py +32 -3
  13. benchloop_client-0.2.22/tests/test_client.py +204 -0
  14. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/tests/test_cmd_artifacts.py +100 -8
  15. benchloop_client-0.2.22/tests/test_cmd_metadata.py +231 -0
  16. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/tests/test_cmd_pull.py +6 -3
  17. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/tests/test_cmd_trajectory.py +57 -5
  18. benchloop_client-0.2.22/tests/test_transfer_progress.py +152 -0
  19. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/uv.lock +1 -1
  20. benchloop_client-0.2.20/src/aliyun/bmcli/output.py +0 -167
  21. benchloop_client-0.2.20/tests/test_client.py +0 -101
  22. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/.gitignore +0 -0
  23. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/.python-version +0 -0
  24. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/README.md +0 -0
  25. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/benchloop-cli-design.md +0 -0
  26. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/bmcli_examples.sh +0 -0
  27. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/__init__.py +0 -0
  28. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/__main__.py +0 -0
  29. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/commands/__init__.py +0 -0
  30. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/commands/ap_cmd.py +0 -0
  31. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/commands/group_data.py +0 -0
  32. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/commands/jobs.py +0 -0
  33. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/commands/metrics.py +0 -0
  34. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/commands/overview.py +0 -0
  35. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/commands/report.py +0 -0
  36. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/commands/tasks.py +0 -0
  37. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/config.py +0 -0
  38. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/exit_codes.py +0 -0
  39. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/ids.py +0 -0
  40. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/runtime.py +0 -0
  41. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/version_check.py +0 -0
  42. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/tests/__init__.py +0 -0
  43. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/tests/conftest.py +0 -0
  44. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/tests/test_cmd_ap_cmd.py +0 -0
  45. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/tests/test_cmd_metrics.py +0 -0
  46. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/tests/test_cmd_overview.py +0 -0
  47. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/tests/test_cmd_report.py +0 -0
  48. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/tests/test_cmd_tasks_jobs.py +0 -0
  49. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/tests/test_config.py +0 -0
  50. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/tests/test_exit_code_behavior.py +0 -0
  51. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/tests/test_exit_codes.py +0 -0
  52. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/tests/test_group_data.py +0 -0
  53. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/tests/test_ids.py +0 -0
  54. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/tests/test_runtime.py +0 -0
  55. {benchloop_client-0.2.20 → benchloop_client-0.2.22}/tests/test_version_check.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: benchloop_client
3
- Version: 0.2.20
3
+ Version: 0.2.22
4
4
  Summary: Benchloop CLI — command line tool for benchmark analysis data (metrics, trajectory, reports, artifacts).
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: httpx>=0.25.2
@@ -0,0 +1,143 @@
1
+ # 发布流程(Release to PyPI)
2
+
3
+ 本项目发布为**手动流程**(无 CI 发布流水线):本地构建 + `twine` 上传到 PyPI。
4
+ 包名为 `benchloop_client`,安装后提供 `bl` / `bmcli` 两个命令。
5
+
6
+ > 本文档基于 0.2.20 版本的实际发布过程整理,含踩坑记录。
7
+ > 发布流程以本文件为准;README.md 末尾的 “Release new package” 片段仅为最小示例。
8
+
9
+ ## 0. 发布前置检查
10
+
11
+ - [ ] **功能依赖的后端接口已上线生产**。例如 `bl artifacts download --task-id` 依赖
12
+ `GET /openapi/jobs/{jobId}/artifacts/download`,后端未部署时用户会收到 404。
13
+ 发布顺序永远是:**后端先上线 → CLI 再发版**;
14
+ - [ ] 功能分支已通过 CR 合入 `master`,本地已切到 `master` 并 `git pull`;
15
+ (也可以直接从 feature 分支发布,但必须保证发布内容与最终合入 master 的内容一致,
16
+ 并在发布所用 commit 上打 tag 以便回溯)
17
+ - [ ] `git status` 干净——发布内容必须精确对应某个 commit。
18
+
19
+ ## 1. Bump 版本号
20
+
21
+ PyPI 版本号**不可复用**(传错只能 yank 后发新版本),发布前必须递增:
22
+
23
+ ```bash
24
+ # 查询 PyPI 当前最新版本(注意加官方源,内部镜像有同步延迟)
25
+ pip index versions benchloop_client --index-url https://pypi.org/simple
26
+ ```
27
+
28
+ 修改 `pyproject.toml`:
29
+
30
+ ```toml
31
+ [project]
32
+ version = "0.2.XX" # 递增
33
+ ```
34
+
35
+ 提交并推送:
36
+
37
+ ```bash
38
+ git add pyproject.toml
39
+ git commit -m "Release 0.2.XX"
40
+ git push origin master
41
+ ```
42
+
43
+ ## 2. 测试、构建、校验(本地零风险,可反复执行)
44
+
45
+ ```bash
46
+ # 全量测试
47
+ uv run pytest -q
48
+
49
+ # 清理旧产物后构建(防止误传旧版本文件)
50
+ rm -rf dist/
51
+ uv build # 产出 dist/benchloop_client-0.2.XX.tar.gz + .whl
52
+
53
+ # 元数据校验,应显示 PASSED
54
+ uvx twine check dist/*
55
+ ```
56
+
57
+ 冒烟验证(隔离环境安装 wheel,确认入口和新功能参数存在):
58
+
59
+ ```bash
60
+ uv run --isolated --no-project --with ./dist/benchloop_client-0.2.XX-py3-none-any.whl \
61
+ bl --help
62
+ uv run --isolated --no-project --with ./dist/benchloop_client-0.2.XX-py3-none-any.whl \
63
+ python -c "from importlib.metadata import version; print(version('benchloop_client'))"
64
+ ```
65
+
66
+ ## 3. 配置 PyPI Token
67
+
68
+ 认证使用 PyPI API Token(需对 `benchloop_client` 项目有上传权限):
69
+
70
+ - username 固定为字面量 `__token__`(这是 PyPI 的约定哨兵值);
71
+ - password 为完整 token(含 `pypi-` 前缀)。
72
+
73
+ 二选一:
74
+
75
+ ```bash
76
+ # 方式 A:环境变量(当次终端会话有效,注意 upload 必须在同一终端执行)
77
+ export TWINE_USERNAME=__token__
78
+ export TWINE_PASSWORD=pypi-xxxx...
79
+
80
+ # 方式 B:写 ~/.pypirc(一次配置长期有效)
81
+ cat > ~/.pypirc << 'EOF'
82
+ [pypi]
83
+ username = __token__
84
+ password = pypi-你的完整token
85
+ EOF
86
+ chmod 600 ~/.pypirc
87
+ ```
88
+
89
+ ## 4. 上传(唯一不可逆步骤)
90
+
91
+ ```bash
92
+ # 确认 dist/ 中只有本次版本的两个文件,然后:
93
+ uvx --python 3.12 twine upload dist/* --verbose
94
+ ```
95
+
96
+ ⚠️ **必须带 `--python 3.12`**(或其他 uv 托管的解释器版本)。
97
+ 踩坑记录:`uvx` 默认使用系统 Python,如果系统默认 Python(如 pyenv 3.9)编译时未带
98
+ SSL 模块,会报:
99
+
100
+ ```
101
+ ImportError: Can't connect to HTTPS URL because the SSL module is not available.
102
+ ```
103
+
104
+ 上传失败不会在 PyPI 留下任何半成品,换解释器重试即可。
105
+
106
+ 成功标志:
107
+
108
+ ```
109
+ View at:
110
+ https://pypi.org/project/benchloop-client/0.2.XX/
111
+ ```
112
+
113
+ (URL 中 `benchloop-client` 是 PyPI 对 `benchloop_client` 的标准化命名,同一个包。)
114
+
115
+ ## 5. 发布后收尾
116
+
117
+ ```bash
118
+ # 验证 PyPI 生效(用版本专属 URL,包级 JSON 可能返回缓存的旧数据)
119
+ curl -s https://pypi.org/pypi/benchloop_client/0.2.XX/json | python3 -c \
120
+ "import json,sys; d=json.load(sys.stdin); print(d['info']['version'], [f['filename'] for f in d['urls']])"
121
+
122
+ # 在发布所用 commit 上打 tag 并推送
123
+ git tag v0.2.XX
124
+ git push origin v0.2.XX
125
+ ```
126
+
127
+ 端到端验收(真实数据):
128
+
129
+ ```bash
130
+ pip install --upgrade benchloop_client --index-url https://pypi.org/simple
131
+ bl artifacts download --task-id <真实taskId> -o ./out
132
+ tar tzf ./out/<taskId>/<jobId>.tgz # 能列出内容即通过
133
+ ```
134
+
135
+ ## 常见问题
136
+
137
+ | 问题 | 处理 |
138
+ | --- | --- |
139
+ | `ImportError: SSL module is not available` | `uvx --python 3.12 twine ...`,见第 4 节 |
140
+ | 上传报 401/403 | token 无效或无该项目上传权限,换 token 重试(失败无副作用) |
141
+ | `pip install` 报 `externally-managed-environment` | 命中 Homebrew Python 的 PEP 668 保护,用虚拟环境安装,或用 venv 内 pip 的**全路径**(shell alias 可能劫持 `pip3`/`python3` 导致 venv 激活了也不生效) |
142
+ | 用户升级后拿不到新版本 | 公司内部镜像(tbmirror)同步 PyPI 有延迟,升级命令需带 `--index-url https://pypi.org/simple` |
143
+ | 传错版本 | 无法覆盖,在 PyPI 上 yank 该版本后递增版本号重新发布 |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "benchloop_client"
7
- version = "0.2.20"
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,
@@ -62,6 +63,10 @@ def main(
62
63
  retry: int = typer.Option(
63
64
  2, "--retry", help="Retry count on failure (exponential backoff)."
64
65
  ),
66
+ concurrency: int = typer.Option(
67
+ 5, "--concurrency", min=1,
68
+ help="Concurrent requests/downloads (use 1 for a per-file progress bar).",
69
+ ),
65
70
  verbose: bool = typer.Option(
66
71
  False, "-v", "--verbose", help="Debug logs to stderr."
67
72
  ),
@@ -87,6 +92,7 @@ def main(
87
92
  )
88
93
  ctx.obj = Runtime(
89
94
  config=cfg,
95
+ concurrency=concurrency,
90
96
  timeout=timeout,
91
97
  retry=retry,
92
98
  pretty=pretty,
@@ -130,6 +136,7 @@ app.add_typer(overview_cmd.app, name="overview")
130
136
  app.add_typer(tasks_cmd.app, name="tasks")
131
137
  app.add_typer(jobs_cmd.app, name="jobs")
132
138
  app.add_typer(artifacts_cmd.app, name="artifacts")
139
+ app.add_typer(metadata_cmd.app, name="metadata")
133
140
  app.command(
134
141
  "ap-cmd",
135
142
  context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
@@ -30,6 +30,14 @@ class ApiError(ClientError):
30
30
  super().__init__(f"API error {status}: {body}")
31
31
 
32
32
 
33
+ class ArtifactMissingError(ClientError):
34
+ """Raised when a download endpoint reports the artifact does not exist.
35
+
36
+ 覆盖两种形态:后端直接返回 404;以及网关把缺产物的 404 重定向成
37
+ HTML 错误页(HTTP 200 + text/html)。两种情况都不应把响应体写盘。
38
+ """
39
+
40
+
33
41
  class ApiClient:
34
42
  """Thin wrapper over ``httpx.Client`` for the benchmark openapi."""
35
43
 
@@ -135,8 +143,17 @@ class ApiClient:
135
143
  raise last_exc
136
144
  raise NetworkError("request failed") # pragma: no cover
137
145
 
138
- def download_file(self, url: str, dest_path: str | Path) -> Path:
139
- """Stream ``url`` to ``dest_path`` on disk. Returns the path written."""
146
+ def download_file(
147
+ self,
148
+ url: str,
149
+ dest_path: str | Path,
150
+ progress: Optional[Any] = None,
151
+ ) -> Path:
152
+ """Stream ``url`` to ``dest_path`` on disk. Returns the path written.
153
+
154
+ ``progress``(可选)需提供 ``begin(total)`` / ``advance(nbytes)`` 两个
155
+ 方法(如 ``output.TransferStream``);每次重试会重新 ``begin`` 归零。
156
+ """
140
157
  dest = Path(dest_path)
141
158
  dest.parent.mkdir(parents=True, exist_ok=True)
142
159
  last_exc: Optional[Exception] = None
@@ -146,12 +163,31 @@ class ApiClient:
146
163
  with client.stream("GET", self._url(url), headers=self._headers()) as resp:
147
164
  if resp.status_code == 401:
148
165
  raise AuthError("authentication failed (HTTP 401)")
166
+ if resp.status_code == 404:
167
+ body = self._safe_stream_body(resp)
168
+ raise ArtifactMissingError(
169
+ f"artifact not found (HTTP 404): {body[:200]}"
170
+ )
149
171
  if resp.status_code >= 400:
150
172
  body = self._safe_stream_body(resp)
151
173
  raise ApiError(resp.status_code, body)
174
+ content_type = (resp.headers.get("content-type") or "").lower()
175
+ if "text/html" in content_type:
176
+ # 网关会把缺产物的 404 重定向成 HTML 错误页
177
+ # (HTTP 200),不能把错误页当产物写盘。
178
+ raise ArtifactMissingError(
179
+ "download returned an html error page "
180
+ "(artifact missing on OSS?)"
181
+ )
182
+ if progress is not None:
183
+ raw_length = resp.headers.get("content-length") or ""
184
+ total = int(raw_length) if raw_length.isdigit() else None
185
+ progress.begin(total)
152
186
  with dest.open("wb") as fh:
153
187
  for chunk in resp.iter_bytes(chunk_size=65536):
154
188
  fh.write(chunk)
189
+ if progress is not None:
190
+ progress.advance(len(chunk))
155
191
  return dest
156
192
  except (httpx.TimeoutException, httpx.TransportError) as exc:
157
193
  last_exc = NetworkError(str(exc))
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import tarfile
5
6
  from pathlib import Path
6
7
  from typing import Optional
7
8
 
@@ -41,6 +42,18 @@ def resolve_output_dir(runtime_output: str, override: Optional[str]) -> Path:
41
42
  return Path(override) if override else Path(runtime_output)
42
43
 
43
44
 
45
+ def write_empty_tgz(dest: Path) -> Path:
46
+ """Write a valid empty .tgz placeholder for jobs whose artifact is missing.
47
+
48
+ 产物在 OSS 上不存在时落一个合法空包,而不是把错误页 HTML 写盘,
49
+ 保证下游批量解包不会被脏文件打断。
50
+ """
51
+ dest.parent.mkdir(parents=True, exist_ok=True)
52
+ with tarfile.open(dest, "w:gz"):
53
+ pass
54
+ return dest
55
+
56
+
44
57
  def resolve_task_id(
45
58
  task_id: Optional[str], groups: list[str], jobs: list[str],
46
59
  ) -> str:
@@ -21,6 +21,7 @@ from aliyun.bmcli.commands._common import (
21
21
  resolve_output_dir,
22
22
  resolve_task_id,
23
23
  task_job_ids,
24
+ write_empty_tgz,
24
25
  )
25
26
  from aliyun.bmcli.commands.group_data import (
26
27
  fetch_all_jobs,
@@ -28,6 +29,7 @@ from aliyun.bmcli.commands.group_data import (
28
29
  job_ids,
29
30
  raise_for_failures,
30
31
  )
32
+ from aliyun.bmcli.client import ArtifactMissingError
31
33
  from aliyun.bmcli.exit_codes import PARTIAL
32
34
  from aliyun.bmcli.runtime import get_runtime, report_and_exit, safe_run_concurrent
33
35
 
@@ -161,6 +163,9 @@ def download(
161
163
 
162
164
  # OSS 公网加签已禁用:不再通过 listing 拿加签 URL 直连 OSS,而是逐 job
163
165
  # 请求后端代理接口,由后端从 OSS 内网读取并流式转发 result.tgz。
166
+ transfer_bar = output.TransferProgressBar("task artifacts", len(task_jobs))
167
+ transfer_bar.start()
168
+
164
169
  def task_job_worker(job_id: str) -> str:
165
170
  try:
166
171
  # Encode the snapshot job_id before using it as a local file
@@ -173,10 +178,20 @@ def download(
173
178
  if skip_existing and dest.exists():
174
179
  output.log(f"skip existing {dest}")
175
180
  return str(dest)
176
- rt.client.download_file(
177
- f"/openapi/jobs/{quote(job_id, safe='')}/artifacts/download",
178
- dest,
179
- )
181
+ stream = transfer_bar.stream(dest.name)
182
+ try:
183
+ rt.client.download_file(
184
+ f"/openapi/jobs/{quote(job_id, safe='')}/artifacts/download",
185
+ dest,
186
+ progress=stream,
187
+ )
188
+ except ArtifactMissingError as exc:
189
+ # 产物在 OSS 上不存在:落合法空包占位,不计入失败。
190
+ write_empty_tgz(dest)
191
+ output.warn(f"no artifact for job={job_id}, wrote empty {dest} ({exc})")
192
+ return str(dest)
193
+ finally:
194
+ stream.finish()
180
195
  output.log(f"downloaded {dest}")
181
196
  return str(dest)
182
197
  finally:
@@ -184,7 +199,10 @@ def download(
184
199
  # the visible task progress forward.
185
200
  progress.advance(job_id)
186
201
 
187
- result = safe_run_concurrent(task_jobs, task_job_worker, rt.concurrency)
202
+ try:
203
+ result = safe_run_concurrent(task_jobs, task_job_worker, rt.concurrency)
204
+ finally:
205
+ transfer_bar.close()
188
206
  report_and_exit(result, emit=False)
189
207
  return
190
208
 
@@ -199,6 +217,10 @@ def download(
199
217
  action="processed",
200
218
  )
201
219
  progress.start()
220
+ transfer_bar = output.TransferProgressBar(
221
+ f"group {group_id} artifacts", len(ids)
222
+ )
223
+ transfer_bar.start()
202
224
 
203
225
  def process_job(client, job_id: str):
204
226
  failures = []
@@ -223,18 +245,39 @@ def download(
223
245
  if skip_existing and dest.exists():
224
246
  output.log(f"skip existing {dest}")
225
247
  continue
248
+ stream = transfer_bar.stream(f"{job_id}/{dest.name}")
226
249
  try:
227
- client.download_file(artifact["url"], dest)
250
+ # 与 --task-id 链路一致:不直连 OSS 加签 URL,改走后端
251
+ # 代理接口流式下载;listing 的 url 仅作产物存在信号。
252
+ client.download_file(
253
+ f"/openapi/jobs/{quote(job_id, safe='')}"
254
+ "/artifacts/download",
255
+ dest,
256
+ progress=stream,
257
+ )
228
258
  output.log(f"downloaded {dest}")
259
+ except ArtifactMissingError as exc:
260
+ write_empty_tgz(dest)
261
+ output.warn(
262
+ f"no artifact for job={job_id}, "
263
+ f"wrote empty {dest} ({exc})"
264
+ )
229
265
  except Exception as exc: # noqa: BLE001 - retain other jobs
230
266
  failures.append(
231
267
  (f"{job_id}:{artifact.get('name')}", str(exc))
232
268
  )
269
+ finally:
270
+ stream.finish()
233
271
  return failures
234
272
  finally:
235
273
  progress.advance(job_id)
236
274
 
237
- result = fetch_job_resources(rt.client, ids, process_job, rt.concurrency)
275
+ try:
276
+ result = fetch_job_resources(
277
+ rt.client, ids, process_job, rt.concurrency
278
+ )
279
+ finally:
280
+ transfer_bar.close()
238
281
  if result.failures and not result.items:
239
282
  raise_for_failures("artifact listings", result.failures)
240
283
  nested = [
@@ -336,30 +379,49 @@ def download(
336
379
 
337
380
  def _download_one(art: dict[str, Any]) -> Optional[tuple[str, str]]:
338
381
  name = art.get("name") or "artifact"
339
- url = art.get("url")
340
- if not url: # already reported above; defensive guard
382
+ if not art.get("url"): # already reported above; defensive guard
341
383
  return None
342
384
  dest = _dest_for(art)
343
385
  if skip_existing and dest.exists():
344
386
  output.log(f"skip existing {dest}")
345
387
  download_progress.advance(str(dest))
346
388
  return None
389
+ job_id = str(art.get("job_id") or "")
390
+ stream = transfer_bar.stream(f"{job_id}/{name}" if job_id else name)
347
391
  try:
348
- rt.client.download_file(url, dest)
392
+ # 与 --task-id 链路一致:改走后端代理接口下载,listing 返回的
393
+ # 加签 url 仅作产物存在信号,不再直连 OSS。
394
+ rt.client.download_file(
395
+ f"/openapi/jobs/{quote(job_id, safe='')}/artifacts/download",
396
+ dest,
397
+ progress=stream,
398
+ )
349
399
  output.log(f"downloaded {dest}")
350
400
  download_progress.advance(str(dest))
351
401
  return None
402
+ except ArtifactMissingError as exc:
403
+ write_empty_tgz(dest)
404
+ output.warn(f"no artifact for job={art.get('job_id')}, wrote empty {dest} ({exc})")
405
+ download_progress.advance(str(dest))
406
+ return None
352
407
  except Exception as exc: # noqa: BLE001 - per-artifact failure tolerated
353
408
  return (f"{art.get('job_id')}:{name}", str(exc))
409
+ finally:
410
+ stream.finish()
354
411
 
355
412
  if downloadable:
413
+ transfer_bar = output.TransferProgressBar(progress_label, len(downloadable))
414
+ transfer_bar.start()
356
415
  max_workers = max(1, min(rt.concurrency, len(downloadable)))
357
- with ThreadPoolExecutor(max_workers=max_workers) as pool:
358
- futures = [pool.submit(_download_one, art) for art in downloadable]
359
- for future in as_completed(futures):
360
- result = future.result()
361
- if result is not None:
362
- failures.append(result)
416
+ try:
417
+ with ThreadPoolExecutor(max_workers=max_workers) as pool:
418
+ futures = [pool.submit(_download_one, art) for art in downloadable]
419
+ for future in as_completed(futures):
420
+ result = future.result()
421
+ if result is not None:
422
+ failures.append(result)
423
+ finally:
424
+ transfer_bar.close()
363
425
 
364
426
  listing.failures = failures
365
427
  report_and_exit(listing, emit=False)
@@ -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)