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