benchloop-client 0.2.20__tar.gz → 0.2.21__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 (53) hide show
  1. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/PKG-INFO +1 -1
  2. benchloop_client-0.2.21/RELEASE.md +143 -0
  3. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/pyproject.toml +1 -1
  4. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/src/aliyun/bmcli/cli.py +5 -0
  5. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/src/aliyun/bmcli/client.py +38 -2
  6. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/src/aliyun/bmcli/commands/_common.py +13 -0
  7. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/src/aliyun/bmcli/commands/artifacts.py +78 -16
  8. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/src/aliyun/bmcli/commands/pull.py +17 -3
  9. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/src/aliyun/bmcli/commands/trajectory.py +50 -5
  10. benchloop_client-0.2.21/src/aliyun/bmcli/output.py +360 -0
  11. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/tests/test_cli_options.py +32 -3
  12. benchloop_client-0.2.21/tests/test_client.py +204 -0
  13. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/tests/test_cmd_artifacts.py +100 -8
  14. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/tests/test_cmd_pull.py +6 -3
  15. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/tests/test_cmd_trajectory.py +57 -5
  16. benchloop_client-0.2.21/tests/test_transfer_progress.py +152 -0
  17. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/uv.lock +1 -1
  18. benchloop_client-0.2.20/src/aliyun/bmcli/output.py +0 -167
  19. benchloop_client-0.2.20/tests/test_client.py +0 -101
  20. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/.gitignore +0 -0
  21. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/.python-version +0 -0
  22. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/README.md +0 -0
  23. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/benchloop-cli-design.md +0 -0
  24. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/bmcli_examples.sh +0 -0
  25. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/src/aliyun/bmcli/__init__.py +0 -0
  26. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/src/aliyun/bmcli/__main__.py +0 -0
  27. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/src/aliyun/bmcli/commands/__init__.py +0 -0
  28. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/src/aliyun/bmcli/commands/ap_cmd.py +0 -0
  29. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/src/aliyun/bmcli/commands/group_data.py +0 -0
  30. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/src/aliyun/bmcli/commands/jobs.py +0 -0
  31. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/src/aliyun/bmcli/commands/metrics.py +0 -0
  32. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/src/aliyun/bmcli/commands/overview.py +0 -0
  33. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/src/aliyun/bmcli/commands/report.py +0 -0
  34. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/src/aliyun/bmcli/commands/tasks.py +0 -0
  35. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/src/aliyun/bmcli/config.py +0 -0
  36. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/src/aliyun/bmcli/exit_codes.py +0 -0
  37. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/src/aliyun/bmcli/ids.py +0 -0
  38. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/src/aliyun/bmcli/runtime.py +0 -0
  39. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/src/aliyun/bmcli/version_check.py +0 -0
  40. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/tests/__init__.py +0 -0
  41. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/tests/conftest.py +0 -0
  42. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/tests/test_cmd_ap_cmd.py +0 -0
  43. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/tests/test_cmd_metrics.py +0 -0
  44. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/tests/test_cmd_overview.py +0 -0
  45. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/tests/test_cmd_report.py +0 -0
  46. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/tests/test_cmd_tasks_jobs.py +0 -0
  47. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/tests/test_config.py +0 -0
  48. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/tests/test_exit_code_behavior.py +0 -0
  49. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/tests/test_exit_codes.py +0 -0
  50. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/tests/test_group_data.py +0 -0
  51. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/tests/test_ids.py +0 -0
  52. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/tests/test_runtime.py +0 -0
  53. {benchloop_client-0.2.20 → benchloop_client-0.2.21}/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.21
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.21"
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"
@@ -62,6 +62,10 @@ def main(
62
62
  retry: int = typer.Option(
63
63
  2, "--retry", help="Retry count on failure (exponential backoff)."
64
64
  ),
65
+ concurrency: int = typer.Option(
66
+ 5, "--concurrency", min=1,
67
+ help="Concurrent requests/downloads (use 1 for a per-file progress bar).",
68
+ ),
65
69
  verbose: bool = typer.Option(
66
70
  False, "-v", "--verbose", help="Debug logs to stderr."
67
71
  ),
@@ -87,6 +91,7 @@ def main(
87
91
  )
88
92
  ctx.obj = Runtime(
89
93
  config=cfg,
94
+ concurrency=concurrency,
90
95
  timeout=timeout,
91
96
  retry=retry,
92
97
  pretty=pretty,
@@ -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)
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from pathlib import Path
6
6
  from typing import Any, Optional
7
+ from urllib.parse import quote
7
8
 
8
9
  import typer
9
10
 
@@ -15,6 +16,7 @@ from aliyun.bmcli.commands._common import (
15
16
  SkipExistingOption,
16
17
  expand,
17
18
  resolve_output_dir,
19
+ write_empty_tgz,
18
20
  )
19
21
  from aliyun.bmcli.commands.group_data import (
20
22
  fetch_all_jobs,
@@ -28,7 +30,7 @@ from aliyun.bmcli.commands.metrics import (
28
30
  )
29
31
  from aliyun.bmcli.commands.tasks import _fetch_all as fetch_all_tasks
30
32
  from aliyun.bmcli.commands.trajectory import write_group_trajectories
31
- from aliyun.bmcli.client import AuthError
33
+ from aliyun.bmcli.client import ArtifactMissingError, AuthError
32
34
  from aliyun.bmcli.ids import enforce_mutually_exclusive, require_any
33
35
  from aliyun.bmcli.runtime import get_runtime
34
36
  from aliyun.bmcli.exit_codes import OK, PARTIAL
@@ -228,6 +230,11 @@ def _record_missing_artifacts(
228
230
  )
229
231
 
230
232
 
233
+ def _artifact_download_path(job_id: str) -> str:
234
+ """后端代理下载接口(与 --task-id 链路一致),不直连 OSS 加签 URL。"""
235
+ return f"/openapi/jobs/{quote(job_id, safe='')}/artifacts/download"
236
+
237
+
231
238
  def _pull_job_artifacts(
232
239
  rt, job_id: str, root: Path, skip_existing: bool, failures: list[tuple[str, str]]
233
240
  ) -> None:
@@ -240,8 +247,12 @@ def _pull_job_artifacts(
240
247
  output.log(f"skip existing {dest}")
241
248
  continue
242
249
  try:
243
- rt.client.download_file(a["url"], dest)
250
+ rt.client.download_file(_artifact_download_path(job_id), dest)
244
251
  output.log(f"downloaded {dest}")
252
+ except ArtifactMissingError as exc:
253
+ # 产物在 OSS 上不存在:落合法空包占位,不计入失败。
254
+ write_empty_tgz(dest)
255
+ output.warn(f"no artifact for job={job_id}, wrote empty {dest} ({exc})")
245
256
  except Exception as exc: # noqa: BLE001
246
257
  failures.append((f"{job_id}:{a.get('name')}", str(exc)))
247
258
 
@@ -325,8 +336,11 @@ def _pull_group_artifacts(
325
336
  output.log(f"skip existing {dest}")
326
337
  continue
327
338
  try:
328
- client.download_file(artifact["url"], dest)
339
+ client.download_file(_artifact_download_path(job_id), dest)
329
340
  output.log(f"downloaded {dest}")
341
+ except ArtifactMissingError as exc:
342
+ write_empty_tgz(dest)
343
+ output.warn(f"no artifact for job={job_id}, wrote empty {dest} ({exc})")
330
344
  except Exception as exc: # noqa: BLE001 - retain other jobs
331
345
  local_failures.append(
332
346
  (f"{group_id}:{job_id}:{artifact.get('name')}", str(exc))
@@ -102,9 +102,17 @@ def _write_task_archive(
102
102
  downloaded: list[tuple[str, Path]],
103
103
  failures: list[tuple[str, str]],
104
104
  *,
105
+ metadata: list[tuple[str, Path]] | None = None,
106
+ metadata_failures: list[tuple[str, str]] | None = None,
105
107
  pretty: bool,
106
108
  ) -> Path:
107
- """Create the task ZIP atomically from successfully downloaded trajectories."""
109
+ """Create the task ZIP atomically from successfully downloaded trajectories.
110
+
111
+ 归档结构:轨迹在 trajectories/ 目录,job 元数据在 metadata/ 目录(一个
112
+ job 一个 json),manifest.json 留在根目录。
113
+ """
114
+ metadata = metadata or []
115
+ metadata_failures = metadata_failures or []
108
116
  manifest = {
109
117
  "task_id": task_id,
110
118
  "group_id": task_payload.get("group_id"),
@@ -114,6 +122,9 @@ def _write_task_archive(
114
122
  "failed_jobs": len(failures),
115
123
  "success_job_ids": [job_id for job_id, _path in downloaded],
116
124
  "failures": [{"job_id": job_id, "error": error} for job_id, error in failures],
125
+ "metadata_failures": [
126
+ {"job_id": job_id, "error": error} for job_id, error in metadata_failures
127
+ ],
117
128
  }
118
129
  indent = 2 if pretty else None
119
130
  separators = None if pretty else (",", ":")
@@ -146,7 +157,10 @@ def _write_task_archive(
146
157
  ) as archive:
147
158
  for job_id, path in downloaded:
148
159
  file_job_id = quote(job_id, safe="-_.")
149
- archive.write(path, arcname=f"{file_job_id}_trajectory.json")
160
+ archive.write(path, arcname=f"trajectories/{file_job_id}_trajectory.json")
161
+ for job_id, path in metadata:
162
+ file_job_id = quote(job_id, safe="-_.")
163
+ archive.write(path, arcname=f"metadata/{file_job_id}.json")
150
164
  archive.writestr("manifest.json", manifest_text)
151
165
  tmp_archive_path.replace(archive_path)
152
166
  except Exception:
@@ -240,7 +254,9 @@ def download(
240
254
  ) as temp_dir:
241
255
  temp_root = Path(temp_dir)
242
256
 
243
- def task_job_worker(job_id: str) -> tuple[str, Path]:
257
+ def task_job_worker(
258
+ job_id: str,
259
+ ) -> tuple[str, Path, Optional[Path], Optional[str]]:
244
260
  try:
245
261
  data = rt.client.get_json(
246
262
  f"/openapi/jobs/{quote(job_id, safe='')}/trajectory"
@@ -251,7 +267,19 @@ def download(
251
267
  file_job_id = quote(job_id, safe="-_.")
252
268
  dest = temp_root / f"{file_job_id}_trajectory.json"
253
269
  output.write_json_file(dest, data, pretty=rt.pretty)
254
- return job_id, dest
270
+ # 同步拉 job 元数据(bench_job 字段);失败不影响轨迹,
271
+ # 记入 manifest.metadata_failures 并计入退出码。
272
+ meta_path: Optional[Path] = None
273
+ meta_error: Optional[str] = None
274
+ try:
275
+ meta = rt.client.get_json(
276
+ f"/openapi/jobs/{quote(job_id, safe='')}/meta"
277
+ )
278
+ meta_path = temp_root / f"{file_job_id}_meta.json"
279
+ output.write_json_file(meta_path, meta, pretty=rt.pretty)
280
+ except Exception as exc: # noqa: BLE001 - keep trajectory
281
+ meta_error = str(exc)
282
+ return job_id, dest, meta_path, meta_error
255
283
  finally:
256
284
  # A failed request is still a completed attempt and must move
257
285
  # the visible task progress forward.
@@ -261,17 +289,34 @@ def download(
261
289
  if not result.items:
262
290
  report_and_exit(result, emit=False)
263
291
 
292
+ downloaded = [(job_id, path) for job_id, path, _meta, _err in result.items]
293
+ metadata = [
294
+ (job_id, meta_path)
295
+ for job_id, _path, meta_path, _err in result.items
296
+ if meta_path is not None
297
+ ]
298
+ metadata_failures = [
299
+ (job_id, error)
300
+ for job_id, _path, _meta, error in result.items
301
+ if error is not None
302
+ ]
303
+
264
304
  output.progress(f"creating archive {archive_path}")
265
305
  _write_task_archive(
266
306
  archive_path,
267
307
  task_payload,
268
308
  task_id,
269
- result.items,
309
+ downloaded,
270
310
  result.failures,
311
+ metadata=metadata,
312
+ metadata_failures=metadata_failures,
271
313
  pretty=rt.pretty,
272
314
  )
273
315
 
274
316
  output.log(f"wrote {archive_path}")
317
+ result.failures.extend(
318
+ (job_id, f"meta: {error}") for job_id, error in metadata_failures
319
+ )
275
320
  report_and_exit(result, emit=False)
276
321
 
277
322
  def job_worker(job_id: str):