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.
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/PKG-INFO +1 -1
- benchloop_client-0.2.22/RELEASE.md +143 -0
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/pyproject.toml +1 -1
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/cli.py +7 -0
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/client.py +38 -2
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/commands/_common.py +13 -0
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/commands/artifacts.py +78 -16
- benchloop_client-0.2.22/src/aliyun/bmcli/commands/metadata.py +175 -0
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/commands/pull.py +17 -3
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/commands/trajectory.py +50 -5
- benchloop_client-0.2.22/src/aliyun/bmcli/output.py +360 -0
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/tests/test_cli_options.py +32 -3
- benchloop_client-0.2.22/tests/test_client.py +204 -0
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/tests/test_cmd_artifacts.py +100 -8
- benchloop_client-0.2.22/tests/test_cmd_metadata.py +231 -0
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/tests/test_cmd_pull.py +6 -3
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/tests/test_cmd_trajectory.py +57 -5
- benchloop_client-0.2.22/tests/test_transfer_progress.py +152 -0
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/uv.lock +1 -1
- benchloop_client-0.2.20/src/aliyun/bmcli/output.py +0 -167
- benchloop_client-0.2.20/tests/test_client.py +0 -101
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/.gitignore +0 -0
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/.python-version +0 -0
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/README.md +0 -0
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/benchloop-cli-design.md +0 -0
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/bmcli_examples.sh +0 -0
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/__init__.py +0 -0
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/__main__.py +0 -0
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/commands/__init__.py +0 -0
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/commands/ap_cmd.py +0 -0
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/commands/group_data.py +0 -0
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/commands/jobs.py +0 -0
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/commands/metrics.py +0 -0
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/commands/overview.py +0 -0
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/commands/report.py +0 -0
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/commands/tasks.py +0 -0
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/config.py +0 -0
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/exit_codes.py +0 -0
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/ids.py +0 -0
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/runtime.py +0 -0
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/src/aliyun/bmcli/version_check.py +0 -0
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/tests/__init__.py +0 -0
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/tests/conftest.py +0 -0
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/tests/test_cmd_ap_cmd.py +0 -0
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/tests/test_cmd_metrics.py +0 -0
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/tests/test_cmd_overview.py +0 -0
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/tests/test_cmd_report.py +0 -0
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/tests/test_cmd_tasks_jobs.py +0 -0
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/tests/test_config.py +0 -0
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/tests/test_exit_code_behavior.py +0 -0
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/tests/test_exit_codes.py +0 -0
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/tests/test_group_data.py +0 -0
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/tests/test_ids.py +0 -0
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/tests/test_runtime.py +0 -0
- {benchloop_client-0.2.20 → benchloop_client-0.2.22}/tests/test_version_check.py +0 -0
|
@@ -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.
|
|
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(
|
|
139
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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)
|