benchloop-client 0.2.24__tar.gz → 0.2.25__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.24 → benchloop_client-0.2.25}/PKG-INFO +4 -1
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/README.md +3 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/pyproject.toml +1 -1
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/cli.py +2 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/client.py +24 -0
- benchloop_client-0.2.25/src/aliyun/bmcli/commands/rsi.py +235 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/commands/trajectory.py +134 -7
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/test_client.py +63 -1
- benchloop_client-0.2.25/tests/test_cmd_rsi.py +105 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/test_cmd_trajectory.py +234 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/uv.lock +2 -2
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/.gitignore +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/.python-version +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/RELEASE.md +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/benchloop-cli-design.md +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/bmcli_examples.sh +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/__init__.py +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/__main__.py +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/commands/__init__.py +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/commands/_common.py +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/commands/ap_cmd.py +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/commands/artifacts.py +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/commands/brm.py +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/commands/group_data.py +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/commands/jobs.py +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/commands/metadata.py +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/commands/metrics.py +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/commands/overview.py +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/commands/pull.py +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/commands/report.py +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/commands/tasks.py +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/config.py +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/exit_codes.py +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/ids.py +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/output.py +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/runtime.py +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/version_check.py +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/__init__.py +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/conftest.py +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/test_cli_options.py +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/test_cmd_ap_cmd.py +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/test_cmd_artifacts.py +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/test_cmd_brm.py +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/test_cmd_metadata.py +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/test_cmd_metrics.py +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/test_cmd_overview.py +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/test_cmd_pull.py +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/test_cmd_report.py +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/test_cmd_tasks_jobs.py +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/test_config.py +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/test_exit_code_behavior.py +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/test_exit_codes.py +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/test_group_data.py +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/test_ids.py +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/test_runtime.py +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/test_transfer_progress.py +0 -0
- {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/test_version_check.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: benchloop_client
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.25
|
|
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
|
|
@@ -84,6 +84,9 @@ bl metadata download --task-id TASK_ID -o ./out
|
|
|
84
84
|
bl report download -j J123 -o ./out
|
|
85
85
|
bl report get -g G123
|
|
86
86
|
bl report download -g G123 -o ./out
|
|
87
|
+
bl rsi get -j J123
|
|
88
|
+
bl rsi trigger -j J123
|
|
89
|
+
bl rsi download -g G123 -o ./out
|
|
87
90
|
bl jobs get -g G1 --all # get supports --all (auto-paginate)
|
|
88
91
|
bl jobs download -g G1 # download is always full
|
|
89
92
|
bl artifacts download -g G1 -o ./out
|
|
@@ -72,6 +72,9 @@ bl metadata download --task-id TASK_ID -o ./out
|
|
|
72
72
|
bl report download -j J123 -o ./out
|
|
73
73
|
bl report get -g G123
|
|
74
74
|
bl report download -g G123 -o ./out
|
|
75
|
+
bl rsi get -j J123
|
|
76
|
+
bl rsi trigger -j J123
|
|
77
|
+
bl rsi download -g G123 -o ./out
|
|
75
78
|
bl jobs get -g G1 --all # get supports --all (auto-paginate)
|
|
76
79
|
bl jobs download -g G1 # download is always full
|
|
77
80
|
bl artifacts download -g G1 -o ./out
|
|
@@ -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.25"
|
|
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"
|
|
@@ -17,6 +17,7 @@ from aliyun.bmcli.commands import (
|
|
|
17
17
|
overview as overview_cmd,
|
|
18
18
|
pull as pull_cmd,
|
|
19
19
|
report as report_cmd,
|
|
20
|
+
rsi as rsi_cmd,
|
|
20
21
|
tasks as tasks_cmd,
|
|
21
22
|
trajectory as trajectory_cmd,
|
|
22
23
|
)
|
|
@@ -139,6 +140,7 @@ app.add_typer(jobs_cmd.app, name="jobs")
|
|
|
139
140
|
app.add_typer(artifacts_cmd.app, name="artifacts")
|
|
140
141
|
app.add_typer(metadata_cmd.app, name="metadata")
|
|
141
142
|
app.add_typer(brm_cmd.app, name="brm")
|
|
143
|
+
app.add_typer(rsi_cmd.app, name="rsi")
|
|
142
144
|
app.command(
|
|
143
145
|
"ap-cmd",
|
|
144
146
|
context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
|
|
@@ -38,6 +38,9 @@ class ArtifactMissingError(ClientError):
|
|
|
38
38
|
"""
|
|
39
39
|
|
|
40
40
|
|
|
41
|
+
_SSO_LOGIN_HOST = "login.alibaba-inc.com"
|
|
42
|
+
|
|
43
|
+
|
|
41
44
|
class ApiClient:
|
|
42
45
|
"""Thin wrapper over ``httpx.Client`` for the benchmark openapi."""
|
|
43
46
|
|
|
@@ -148,11 +151,16 @@ class ApiClient:
|
|
|
148
151
|
url: str,
|
|
149
152
|
dest_path: str | Path,
|
|
150
153
|
progress: Optional[Any] = None,
|
|
154
|
+
*,
|
|
155
|
+
detect_sso_redirect: bool = False,
|
|
151
156
|
) -> Path:
|
|
152
157
|
"""Stream ``url`` to ``dest_path`` on disk. Returns the path written.
|
|
153
158
|
|
|
154
159
|
``progress``(可选)需提供 ``begin(total)`` / ``advance(nbytes)`` 两个
|
|
155
160
|
方法(如 ``output.TransferStream``);每次重试会重新 ``begin`` 归零。
|
|
161
|
+
|
|
162
|
+
``detect_sso_redirect`` 开启时,被网关 302 到 SSO 登录页抛 ``AuthError``;
|
|
163
|
+
默认关闭,保持"HTML 错误页 = 产物缺失"的既有分类,不影响其他调用方。
|
|
156
164
|
"""
|
|
157
165
|
dest = Path(dest_path)
|
|
158
166
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -161,6 +169,13 @@ class ApiClient:
|
|
|
161
169
|
try:
|
|
162
170
|
with httpx.Client(timeout=self.timeout, follow_redirects=True) as client:
|
|
163
171
|
with client.stream("GET", self._url(url), headers=self._headers()) as resp:
|
|
172
|
+
if detect_sso_redirect and self._is_sso_login_redirect(resp):
|
|
173
|
+
# 网关不认 X-API-Key 时会 302 到 SSO 登录页;这是
|
|
174
|
+
# 认证问题,不能落入下面的“HTML 错误页=文件缺失”误判。
|
|
175
|
+
raise AuthError(
|
|
176
|
+
"endpoint requires SSO login (redirected to "
|
|
177
|
+
f"{_SSO_LOGIN_HOST}); X-API-Key not accepted"
|
|
178
|
+
)
|
|
164
179
|
if resp.status_code == 401:
|
|
165
180
|
raise AuthError("authentication failed (HTTP 401)")
|
|
166
181
|
if resp.status_code == 404:
|
|
@@ -199,6 +214,15 @@ class ApiClient:
|
|
|
199
214
|
raise last_exc
|
|
200
215
|
return dest # pragma: no cover
|
|
201
216
|
|
|
217
|
+
@staticmethod
|
|
218
|
+
def _is_sso_login_redirect(resp: httpx.Response) -> bool:
|
|
219
|
+
targets = [r.headers.get("location", "") for r in resp.history]
|
|
220
|
+
try:
|
|
221
|
+
targets.append(str(resp.url))
|
|
222
|
+
except RuntimeError:
|
|
223
|
+
pass # resp.request 未设置时(如测试里的裸 Response)没有 url
|
|
224
|
+
return any(_SSO_LOGIN_HOST in target for target in targets)
|
|
225
|
+
|
|
202
226
|
@staticmethod
|
|
203
227
|
def _safe_body(resp: httpx.Response) -> Any:
|
|
204
228
|
try:
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""`bmcli rsi` — RSI weakness analysis reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from aliyun.bmcli import output
|
|
10
|
+
from aliyun.bmcli.client import AuthError, NetworkError
|
|
11
|
+
from aliyun.bmcli.commands._common import (
|
|
12
|
+
GroupOption,
|
|
13
|
+
JobOption,
|
|
14
|
+
OutputDirOption,
|
|
15
|
+
SkipExistingOption,
|
|
16
|
+
expand,
|
|
17
|
+
resolve_output_dir,
|
|
18
|
+
)
|
|
19
|
+
from aliyun.bmcli.commands.group_data import (
|
|
20
|
+
fetch_all_jobs,
|
|
21
|
+
fetch_job_resources,
|
|
22
|
+
job_ids,
|
|
23
|
+
raise_for_failures,
|
|
24
|
+
)
|
|
25
|
+
from aliyun.bmcli.ids import enforce_mutually_exclusive, require_any
|
|
26
|
+
from aliyun.bmcli.runtime import get_runtime, report_and_exit, safe_run_concurrent
|
|
27
|
+
|
|
28
|
+
app = typer.Typer(help="RSI weakness analysis reports.", no_args_is_help=True)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _guard(groups: list[str], jobs: list[str]) -> None:
|
|
32
|
+
enforce_mutually_exclusive(groups, jobs)
|
|
33
|
+
require_any(groups, jobs)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _markdown(data: Any, key: str) -> str:
|
|
37
|
+
if not isinstance(data, dict):
|
|
38
|
+
return ""
|
|
39
|
+
report = data.get("report")
|
|
40
|
+
if not isinstance(report, dict):
|
|
41
|
+
return ""
|
|
42
|
+
content = report.get(key) or ""
|
|
43
|
+
return content if isinstance(content, str) else ""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _fetch_job(client, job_id: str):
|
|
47
|
+
return client.get_json(f"/openapi/jobs/{job_id}/rsi")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _fetch_group(client, group_id: str):
|
|
51
|
+
return client.get_json(f"/openapi/groups/{group_id}/rsi")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _trigger_job(client, job_id: str):
|
|
55
|
+
return client.post_json(f"/openapi/jobs/{job_id}/rsi/trigger")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _trigger_group(client, group_id: str, include_passed: bool):
|
|
59
|
+
return client.post_json(
|
|
60
|
+
f"/openapi/groups/{group_id}/rsi/trigger",
|
|
61
|
+
{"include_passed": include_passed},
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _write_markdown(content: str, dest, skip_existing: bool):
|
|
66
|
+
if not content:
|
|
67
|
+
return None
|
|
68
|
+
if skip_existing and dest.exists():
|
|
69
|
+
output.log(f"skip existing {dest}")
|
|
70
|
+
else:
|
|
71
|
+
output.write_text_file(dest, content)
|
|
72
|
+
output.log(f"wrote {dest}")
|
|
73
|
+
return str(dest)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@app.command("get")
|
|
77
|
+
def get(
|
|
78
|
+
ctx: typer.Context,
|
|
79
|
+
group: Optional[list[str]] = GroupOption,
|
|
80
|
+
job: Optional[list[str]] = JobOption,
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Query Job or Group RSI reports; prints a JSON array."""
|
|
83
|
+
rt = get_runtime(ctx)
|
|
84
|
+
groups = expand(group)
|
|
85
|
+
jobs = expand(job)
|
|
86
|
+
_guard(groups, jobs)
|
|
87
|
+
|
|
88
|
+
if jobs:
|
|
89
|
+
res = safe_run_concurrent(
|
|
90
|
+
jobs, lambda jid: _fetch_job(rt.client, jid), rt.concurrency
|
|
91
|
+
)
|
|
92
|
+
else:
|
|
93
|
+
res = safe_run_concurrent(
|
|
94
|
+
groups, lambda gid: _fetch_group(rt.client, gid), rt.concurrency
|
|
95
|
+
)
|
|
96
|
+
report_and_exit(res, emit=True, pretty=rt.pretty)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@app.command("download")
|
|
100
|
+
def download(
|
|
101
|
+
ctx: typer.Context,
|
|
102
|
+
group: Optional[list[str]] = GroupOption,
|
|
103
|
+
job: Optional[list[str]] = JobOption,
|
|
104
|
+
output_dir: Optional[str] = OutputDirOption,
|
|
105
|
+
skip_existing: bool = SkipExistingOption,
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Download RSI report markdown returned inline by the get API."""
|
|
108
|
+
rt = get_runtime(ctx)
|
|
109
|
+
groups = expand(group)
|
|
110
|
+
jobs = expand(job)
|
|
111
|
+
_guard(groups, jobs)
|
|
112
|
+
root = resolve_output_dir(rt.output_dir, output_dir)
|
|
113
|
+
|
|
114
|
+
def job_worker(job_id: str):
|
|
115
|
+
try:
|
|
116
|
+
dest = root / f"{job_id}_rsi.md"
|
|
117
|
+
if skip_existing and dest.exists():
|
|
118
|
+
output.log(f"skip existing {dest}")
|
|
119
|
+
return str(dest)
|
|
120
|
+
return _write_markdown(
|
|
121
|
+
_markdown(_fetch_job(rt.client, job_id), "report_md"),
|
|
122
|
+
dest,
|
|
123
|
+
skip_existing,
|
|
124
|
+
)
|
|
125
|
+
finally:
|
|
126
|
+
job_progress.advance(job_id)
|
|
127
|
+
|
|
128
|
+
def download_group_reports(group_id: str):
|
|
129
|
+
written = []
|
|
130
|
+
failures = []
|
|
131
|
+
try:
|
|
132
|
+
summary_dest = root / f"{group_id}_rsi.md"
|
|
133
|
+
if skip_existing and summary_dest.exists():
|
|
134
|
+
output.log(f"skip existing {summary_dest}")
|
|
135
|
+
summary = str(summary_dest)
|
|
136
|
+
else:
|
|
137
|
+
summary = _write_markdown(
|
|
138
|
+
_markdown(_fetch_group(rt.client, group_id), "summary_md"),
|
|
139
|
+
summary_dest,
|
|
140
|
+
skip_existing,
|
|
141
|
+
)
|
|
142
|
+
if summary:
|
|
143
|
+
written.append(summary)
|
|
144
|
+
except AuthError:
|
|
145
|
+
raise
|
|
146
|
+
except NetworkError as exc:
|
|
147
|
+
failures.append((f"{group_id}:summary", f"network: {exc}"))
|
|
148
|
+
except Exception as exc: # noqa: BLE001 - continue with per-Job reports
|
|
149
|
+
failures.append((f"{group_id}:summary", str(exc)))
|
|
150
|
+
|
|
151
|
+
jobs_data = fetch_all_jobs(rt.client, group_id)
|
|
152
|
+
ids = job_ids(jobs_data)
|
|
153
|
+
report_progress = output.ProgressCounter(
|
|
154
|
+
f"group rsi jobs {group_id}",
|
|
155
|
+
len(ids),
|
|
156
|
+
"jobs",
|
|
157
|
+
action="processed",
|
|
158
|
+
show_eta=True,
|
|
159
|
+
)
|
|
160
|
+
report_progress.start()
|
|
161
|
+
|
|
162
|
+
def download_job_report(api, job_id: str):
|
|
163
|
+
try:
|
|
164
|
+
dest = root / group_id / f"{job_id}_rsi.md"
|
|
165
|
+
if skip_existing and dest.exists():
|
|
166
|
+
output.log(f"skip existing {dest}")
|
|
167
|
+
return str(dest)
|
|
168
|
+
return _write_markdown(
|
|
169
|
+
_markdown(_fetch_job(api, job_id), "report_md"),
|
|
170
|
+
dest,
|
|
171
|
+
skip_existing,
|
|
172
|
+
)
|
|
173
|
+
finally:
|
|
174
|
+
report_progress.advance(job_id)
|
|
175
|
+
|
|
176
|
+
reports = fetch_job_resources(
|
|
177
|
+
rt.client,
|
|
178
|
+
ids,
|
|
179
|
+
download_job_report,
|
|
180
|
+
rt.concurrency,
|
|
181
|
+
)
|
|
182
|
+
written.extend(dest for dest in reports.items if dest)
|
|
183
|
+
failures.extend(reports.failures)
|
|
184
|
+
raise_for_failures("rsi reports", failures)
|
|
185
|
+
return written
|
|
186
|
+
|
|
187
|
+
def group_worker(group_id: str):
|
|
188
|
+
try:
|
|
189
|
+
return download_group_reports(group_id)
|
|
190
|
+
finally:
|
|
191
|
+
group_progress.advance(group_id)
|
|
192
|
+
|
|
193
|
+
if jobs:
|
|
194
|
+
job_progress = output.ProgressCounter(
|
|
195
|
+
"job rsi downloads", len(jobs), "jobs", action="processed"
|
|
196
|
+
)
|
|
197
|
+
job_progress.start()
|
|
198
|
+
res = safe_run_concurrent(jobs, job_worker, rt.concurrency)
|
|
199
|
+
else:
|
|
200
|
+
group_progress = output.ProgressCounter(
|
|
201
|
+
"group rsi downloads", len(groups), "groups", action="processed"
|
|
202
|
+
)
|
|
203
|
+
group_progress.start()
|
|
204
|
+
res = safe_run_concurrent(groups, group_worker, 1)
|
|
205
|
+
report_and_exit(res, emit=False)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@app.command("trigger")
|
|
209
|
+
def trigger(
|
|
210
|
+
ctx: typer.Context,
|
|
211
|
+
group: Optional[list[str]] = GroupOption,
|
|
212
|
+
job: Optional[list[str]] = JobOption,
|
|
213
|
+
include_passed: bool = typer.Option(
|
|
214
|
+
False,
|
|
215
|
+
"--include-passed",
|
|
216
|
+
help="Include passed jobs when triggering a Group RSI report.",
|
|
217
|
+
),
|
|
218
|
+
) -> None:
|
|
219
|
+
"""Trigger Job or Group RSI report generation."""
|
|
220
|
+
rt = get_runtime(ctx)
|
|
221
|
+
groups = expand(group)
|
|
222
|
+
jobs = expand(job)
|
|
223
|
+
_guard(groups, jobs)
|
|
224
|
+
|
|
225
|
+
if jobs:
|
|
226
|
+
res = safe_run_concurrent(
|
|
227
|
+
jobs, lambda jid: _trigger_job(rt.client, jid), rt.concurrency
|
|
228
|
+
)
|
|
229
|
+
else:
|
|
230
|
+
res = safe_run_concurrent(
|
|
231
|
+
groups,
|
|
232
|
+
lambda gid: _trigger_group(rt.client, gid, include_passed),
|
|
233
|
+
rt.concurrency,
|
|
234
|
+
)
|
|
235
|
+
report_and_exit(res, emit=True, pretty=rt.pretty)
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import gzip
|
|
6
6
|
import json
|
|
7
|
+
import mimetypes
|
|
7
8
|
import os
|
|
8
9
|
import shutil
|
|
9
10
|
import tempfile
|
|
@@ -282,6 +283,85 @@ def write_group_trajectories(
|
|
|
282
283
|
return dest
|
|
283
284
|
|
|
284
285
|
|
|
286
|
+
_TRACE_MEDIA_ENDPOINT = "/openapi/getTrajV2/mediaFile"
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _iter_trace_media(node: Any) -> Iterator[tuple[str, str]]:
|
|
290
|
+
"""Yield ``(oss_uri, mime_type)`` for every ``trace_media`` field in ``node``."""
|
|
291
|
+
if isinstance(node, dict):
|
|
292
|
+
media = node.get("trace_media")
|
|
293
|
+
if isinstance(media, dict):
|
|
294
|
+
oss_uri = media.get("oss_uri")
|
|
295
|
+
mime_type = media.get("mime_type")
|
|
296
|
+
if isinstance(oss_uri, str) and oss_uri.strip():
|
|
297
|
+
yield (
|
|
298
|
+
oss_uri.strip(),
|
|
299
|
+
mime_type.strip() if isinstance(mime_type, str) else "",
|
|
300
|
+
)
|
|
301
|
+
for value in node.values():
|
|
302
|
+
yield from _iter_trace_media(value)
|
|
303
|
+
elif isinstance(node, list):
|
|
304
|
+
for item in node:
|
|
305
|
+
yield from _iter_trace_media(item)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _trace_media_download_path(oss_uri: str, mime_type: str) -> str:
|
|
309
|
+
return (
|
|
310
|
+
f"{_TRACE_MEDIA_ENDPOINT}"
|
|
311
|
+
f"?ossUri={quote(oss_uri, safe='')}&mime={quote(mime_type, safe='')}"
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _trace_media_file_name(oss_uri: str, mime_type: str, used_names: set[str]) -> str:
|
|
316
|
+
raw_name = oss_uri.rstrip("/").rsplit("/", 1)[-1].strip()
|
|
317
|
+
name = quote(raw_name, safe="-_.")
|
|
318
|
+
if name in ("", ".", ".."):
|
|
319
|
+
name = "media"
|
|
320
|
+
if not Path(name).suffix:
|
|
321
|
+
name += mimetypes.guess_extension(mime_type) or ""
|
|
322
|
+
stem, dot, suffix = name.partition(".")
|
|
323
|
+
candidate = name
|
|
324
|
+
counter = 2
|
|
325
|
+
while candidate in used_names:
|
|
326
|
+
candidate = f"{stem}-{counter}{dot}{suffix}" if dot else f"{name}-{counter}"
|
|
327
|
+
counter += 1
|
|
328
|
+
return candidate
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _download_trace_media(client, trajectory_path: Path, source_dir: Path) -> list[str]:
|
|
332
|
+
"""Download every OSS file referenced by ``trace_media`` in one trajectory JSON.
|
|
333
|
+
|
|
334
|
+
返回错误列表;媒体文件下载失败不影响轨迹本身,由调用方记入 manifest。
|
|
335
|
+
同一 oss_uri 重复引用只下载一次。
|
|
336
|
+
"""
|
|
337
|
+
try:
|
|
338
|
+
payload = json.loads(trajectory_path.read_text(encoding="utf-8"))
|
|
339
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
340
|
+
return [f"unreadable trajectory json: {exc}"]
|
|
341
|
+
errors: list[str] = []
|
|
342
|
+
used_names: set[str] = set()
|
|
343
|
+
seen_uris: set[str] = set()
|
|
344
|
+
for oss_uri, mime_type in _iter_trace_media(payload):
|
|
345
|
+
if oss_uri in seen_uris:
|
|
346
|
+
continue
|
|
347
|
+
seen_uris.add(oss_uri)
|
|
348
|
+
name = _trace_media_file_name(oss_uri, mime_type, used_names)
|
|
349
|
+
used_names.add(name)
|
|
350
|
+
try:
|
|
351
|
+
# SSO 跳转识别只对本媒体下载链路开启,其他 download_file 调用方
|
|
352
|
+
# 维持原有错误分类不变。
|
|
353
|
+
client.download_file(
|
|
354
|
+
_trace_media_download_path(oss_uri, mime_type),
|
|
355
|
+
source_dir / name,
|
|
356
|
+
detect_sso_redirect=True,
|
|
357
|
+
)
|
|
358
|
+
except Exception as exc: # noqa: BLE001 - keep the trajectory
|
|
359
|
+
# 媒体是辅助数据:任何失败(包括跳转 SSO 登录页这类认证问题)都只
|
|
360
|
+
# 记为媒体级失败,错误信息会保留具体原因,不影响轨迹归档产出。
|
|
361
|
+
errors.append(f"{oss_uri}: {exc}")
|
|
362
|
+
return errors
|
|
363
|
+
|
|
364
|
+
|
|
285
365
|
def _write_task_archive(
|
|
286
366
|
archive_path: Path,
|
|
287
367
|
task_payload: dict,
|
|
@@ -291,15 +371,20 @@ def _write_task_archive(
|
|
|
291
371
|
*,
|
|
292
372
|
metadata: list[tuple[str, Path]] | None = None,
|
|
293
373
|
metadata_failures: list[tuple[str, str]] | None = None,
|
|
374
|
+
sources: list[tuple[str, Path]] | None = None,
|
|
375
|
+
source_failures: list[tuple[str, str]] | None = None,
|
|
294
376
|
pretty: bool,
|
|
295
377
|
) -> Path:
|
|
296
378
|
"""Create the task ZIP atomically from successfully downloaded trajectories.
|
|
297
379
|
|
|
298
|
-
归档结构:轨迹在 trajectories/
|
|
299
|
-
job
|
|
380
|
+
归档结构:轨迹在 trajectories/ 目录,轨迹引用的 trace_media OSS 文件在
|
|
381
|
+
source/ 目录(一个 job 一个以 job_id 命名的子目录),job 元数据在
|
|
382
|
+
metadata/ 目录(一个 job 一个 json),manifest.json 留在根目录。
|
|
300
383
|
"""
|
|
301
384
|
metadata = metadata or []
|
|
302
385
|
metadata_failures = metadata_failures or []
|
|
386
|
+
sources = sources or []
|
|
387
|
+
source_failures = source_failures or []
|
|
303
388
|
manifest = {
|
|
304
389
|
"task_id": task_id,
|
|
305
390
|
"group_id": task_payload.get("group_id"),
|
|
@@ -312,6 +397,9 @@ def _write_task_archive(
|
|
|
312
397
|
"metadata_failures": [
|
|
313
398
|
{"job_id": job_id, "error": error} for job_id, error in metadata_failures
|
|
314
399
|
],
|
|
400
|
+
"source_failures": [
|
|
401
|
+
{"job_id": job_id, "error": error} for job_id, error in source_failures
|
|
402
|
+
],
|
|
315
403
|
}
|
|
316
404
|
indent = 2 if pretty else None
|
|
317
405
|
separators = None if pretty else (",", ":")
|
|
@@ -345,6 +433,16 @@ def _write_task_archive(
|
|
|
345
433
|
for job_id, path in downloaded:
|
|
346
434
|
file_job_id = quote(job_id, safe="-_.")
|
|
347
435
|
archive.write(path, arcname=f"trajectories/{file_job_id}_trajectory.json")
|
|
436
|
+
for job_id, source_dir in sources:
|
|
437
|
+
file_job_id = quote(job_id, safe="-_.")
|
|
438
|
+
for media_path in sorted(source_dir.rglob("*")):
|
|
439
|
+
if not media_path.is_file():
|
|
440
|
+
continue
|
|
441
|
+
media_arcname = media_path.relative_to(source_dir).as_posix()
|
|
442
|
+
archive.write(
|
|
443
|
+
media_path,
|
|
444
|
+
arcname=f"source/{file_job_id}/{media_arcname}",
|
|
445
|
+
)
|
|
348
446
|
for job_id, path in metadata:
|
|
349
447
|
file_job_id = quote(job_id, safe="-_.")
|
|
350
448
|
archive.write(path, arcname=f"metadata/{file_job_id}.json")
|
|
@@ -447,7 +545,9 @@ def download(
|
|
|
447
545
|
|
|
448
546
|
def task_job_worker(
|
|
449
547
|
job_id: str,
|
|
450
|
-
) -> tuple[
|
|
548
|
+
) -> tuple[
|
|
549
|
+
str, Path, Optional[Path], Optional[str], Optional[Path], Optional[str]
|
|
550
|
+
]:
|
|
451
551
|
try:
|
|
452
552
|
data = fetch_job_trajectory(rt.client, quote(job_id, safe=""))
|
|
453
553
|
# job_id comes from the server-side task snapshot. Encode it
|
|
@@ -468,7 +568,16 @@ def download(
|
|
|
468
568
|
output.write_json_file(meta_path, meta, pretty=rt.pretty)
|
|
469
569
|
except Exception as exc: # noqa: BLE001 - keep trajectory
|
|
470
570
|
meta_error = str(exc)
|
|
471
|
-
|
|
571
|
+
# 同步下载轨迹里 trace_media 引用的 OSS 文件;失败不影响轨迹,
|
|
572
|
+
# 记入 manifest.source_failures 并计入退出码。
|
|
573
|
+
media_dir = temp_root / "source" / file_job_id
|
|
574
|
+
media_errors = _download_trace_media(rt.client, dest, media_dir)
|
|
575
|
+
media_error = "; ".join(media_errors) if media_errors else None
|
|
576
|
+
has_media = media_dir.is_dir() and any(
|
|
577
|
+
p.is_file() for p in media_dir.rglob("*")
|
|
578
|
+
)
|
|
579
|
+
source_dir: Optional[Path] = media_dir if has_media else None
|
|
580
|
+
return job_id, dest, meta_path, meta_error, source_dir, media_error
|
|
472
581
|
finally:
|
|
473
582
|
# A failed request is still a completed attempt and must move
|
|
474
583
|
# the visible task progress forward.
|
|
@@ -478,15 +587,28 @@ def download(
|
|
|
478
587
|
if not result.items:
|
|
479
588
|
report_and_exit(result, emit=False)
|
|
480
589
|
|
|
481
|
-
downloaded = [
|
|
590
|
+
downloaded = [
|
|
591
|
+
(job_id, path)
|
|
592
|
+
for job_id, path, _meta, _err, _src, _src_err in result.items
|
|
593
|
+
]
|
|
482
594
|
metadata = [
|
|
483
595
|
(job_id, meta_path)
|
|
484
|
-
for job_id, _path, meta_path, _err in result.items
|
|
596
|
+
for job_id, _path, meta_path, _err, _src, _src_err in result.items
|
|
485
597
|
if meta_path is not None
|
|
486
598
|
]
|
|
487
599
|
metadata_failures = [
|
|
488
600
|
(job_id, error)
|
|
489
|
-
for job_id, _path, _meta, error in result.items
|
|
601
|
+
for job_id, _path, _meta, error, _src, _src_err in result.items
|
|
602
|
+
if error is not None
|
|
603
|
+
]
|
|
604
|
+
sources = [
|
|
605
|
+
(job_id, source_dir)
|
|
606
|
+
for job_id, _path, _meta, _err, source_dir, _src_err in result.items
|
|
607
|
+
if source_dir is not None
|
|
608
|
+
]
|
|
609
|
+
source_failures = [
|
|
610
|
+
(job_id, error)
|
|
611
|
+
for job_id, _path, _meta, _err, _src, error in result.items
|
|
490
612
|
if error is not None
|
|
491
613
|
]
|
|
492
614
|
|
|
@@ -499,6 +621,8 @@ def download(
|
|
|
499
621
|
result.failures,
|
|
500
622
|
metadata=metadata,
|
|
501
623
|
metadata_failures=metadata_failures,
|
|
624
|
+
sources=sources,
|
|
625
|
+
source_failures=source_failures,
|
|
502
626
|
pretty=rt.pretty,
|
|
503
627
|
)
|
|
504
628
|
|
|
@@ -506,6 +630,9 @@ def download(
|
|
|
506
630
|
result.failures.extend(
|
|
507
631
|
(job_id, f"meta: {error}") for job_id, error in metadata_failures
|
|
508
632
|
)
|
|
633
|
+
result.failures.extend(
|
|
634
|
+
(job_id, f"source: {error}") for job_id, error in source_failures
|
|
635
|
+
)
|
|
509
636
|
report_and_exit(result, emit=False)
|
|
510
637
|
|
|
511
638
|
def job_worker(job_id: str):
|
|
@@ -8,7 +8,7 @@ from typing import Any
|
|
|
8
8
|
import httpx
|
|
9
9
|
import pytest
|
|
10
10
|
|
|
11
|
-
from aliyun.bmcli.client import ApiClient, ArtifactMissingError, NetworkError
|
|
11
|
+
from aliyun.bmcli.client import ApiClient, ArtifactMissingError, AuthError, NetworkError
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
class FakeHttpxClient:
|
|
@@ -164,6 +164,68 @@ def test_download_file_rejects_html_error_page(
|
|
|
164
164
|
assert not (tmp_path / "a.tgz").exists()
|
|
165
165
|
|
|
166
166
|
|
|
167
|
+
def test_download_file_redirect_to_sso_login_raises_auth_error(
|
|
168
|
+
fake_httpx_client: type[FakeHttpxClient], tmp_path
|
|
169
|
+
) -> None:
|
|
170
|
+
# 网关不认 X-API-Key 时会 302 到 SSO 登录页(HTTP 200 + HTML):开启
|
|
171
|
+
# detect_sso_redirect 时必须报成认证失败(AuthError),不能误判成文件缺失。
|
|
172
|
+
fake_httpx_client.responses = [
|
|
173
|
+
httpx.Response(
|
|
174
|
+
200,
|
|
175
|
+
headers={"content-type": "text/html;charset=utf-8"},
|
|
176
|
+
content=b"<html>sso login</html>",
|
|
177
|
+
history=[
|
|
178
|
+
httpx.Response(
|
|
179
|
+
302,
|
|
180
|
+
headers={
|
|
181
|
+
"location": "https://login.alibaba-inc.com/ssoLogin.htm?backURL=x"
|
|
182
|
+
},
|
|
183
|
+
)
|
|
184
|
+
],
|
|
185
|
+
)
|
|
186
|
+
]
|
|
187
|
+
client = ApiClient("https://example.com", api_key="k", retry=0)
|
|
188
|
+
|
|
189
|
+
with pytest.raises(AuthError, match="SSO login.*X-API-Key not accepted"):
|
|
190
|
+
client.download_file(
|
|
191
|
+
"/openapi/getTrajV2/mediaFile?ossUri=x&mime=y",
|
|
192
|
+
tmp_path / "a.png",
|
|
193
|
+
detect_sso_redirect=True,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
assert not (tmp_path / "a.png").exists()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_download_file_sso_redirect_default_keeps_missing_classification(
|
|
200
|
+
fake_httpx_client: type[FakeHttpxClient], tmp_path
|
|
201
|
+
) -> None:
|
|
202
|
+
# 默认不开启 SSO 识别:302 到登录页仍按“HTML 错误页 = 文件缺失”分类,
|
|
203
|
+
# 保持 artifacts / pull 等其他调用方的既有行为不变。
|
|
204
|
+
fake_httpx_client.responses = [
|
|
205
|
+
httpx.Response(
|
|
206
|
+
200,
|
|
207
|
+
headers={"content-type": "text/html;charset=utf-8"},
|
|
208
|
+
content=b"<html>sso login</html>",
|
|
209
|
+
history=[
|
|
210
|
+
httpx.Response(
|
|
211
|
+
302,
|
|
212
|
+
headers={
|
|
213
|
+
"location": "https://login.alibaba-inc.com/ssoLogin.htm?backURL=x"
|
|
214
|
+
},
|
|
215
|
+
)
|
|
216
|
+
],
|
|
217
|
+
)
|
|
218
|
+
]
|
|
219
|
+
client = ApiClient("https://example.com", api_key="k", retry=0)
|
|
220
|
+
|
|
221
|
+
with pytest.raises(ArtifactMissingError, match="html error page"):
|
|
222
|
+
client.download_file(
|
|
223
|
+
"/openapi/jobs/J1/artifacts/download", tmp_path / "a.tgz"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
assert not (tmp_path / "a.tgz").exists()
|
|
227
|
+
|
|
228
|
+
|
|
167
229
|
class RecordingProgress:
|
|
168
230
|
"""Minimal progress sink implementing begin/advance."""
|
|
169
231
|
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Tests for `bmcli rsi get`, `download`, and `trigger`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from aliyun.bmcli.cli import app
|
|
6
|
+
from tests.conftest import invoke, parse_stdout
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestRsiGet:
|
|
10
|
+
def test_get_job_calls_rsi_endpoint(self, runner, mock_client):
|
|
11
|
+
mock_client.get_json.return_value = {
|
|
12
|
+
"entity_type": "job",
|
|
13
|
+
"job_id": "J1",
|
|
14
|
+
"status": "not_created",
|
|
15
|
+
"report": None,
|
|
16
|
+
"request": None,
|
|
17
|
+
}
|
|
18
|
+
result = invoke(runner, ["--api-key", "k", "rsi", "get", "-j", "J1"])
|
|
19
|
+
assert result.exit_code == 0
|
|
20
|
+
mock_client.get_json.assert_called_once_with("/openapi/jobs/J1/rsi")
|
|
21
|
+
data = parse_stdout(result)
|
|
22
|
+
assert data[0]["status"] == "not_created"
|
|
23
|
+
|
|
24
|
+
def test_get_group_calls_group_rsi_endpoint(self, runner, mock_client):
|
|
25
|
+
mock_client.get_json.return_value = {
|
|
26
|
+
"entity_type": "group",
|
|
27
|
+
"group_id": "G1",
|
|
28
|
+
"status": "created",
|
|
29
|
+
"report": {"summary_md": "# group"},
|
|
30
|
+
"request": None,
|
|
31
|
+
}
|
|
32
|
+
result = invoke(runner, ["--api-key", "k", "rsi", "get", "-g", "G1"])
|
|
33
|
+
assert result.exit_code == 0
|
|
34
|
+
mock_client.get_json.assert_called_once_with("/openapi/groups/G1/rsi")
|
|
35
|
+
assert parse_stdout(result)[0]["report"]["summary_md"] == "# group"
|
|
36
|
+
|
|
37
|
+
def test_get_rejects_mixed_group_and_job(self, runner, mock_client):
|
|
38
|
+
result = runner.invoke(
|
|
39
|
+
app,
|
|
40
|
+
["--api-key", "k", "rsi", "get", "-g", "G1", "-j", "J1"],
|
|
41
|
+
catch_exceptions=False,
|
|
42
|
+
)
|
|
43
|
+
assert result.exit_code != 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TestRsiDownload:
|
|
47
|
+
def test_download_job_writes_inline_markdown(self, runner, mock_client, tmp_path):
|
|
48
|
+
mock_client.get_json.return_value = {
|
|
49
|
+
"status": "created",
|
|
50
|
+
"report": {"report_md": "# job rsi"},
|
|
51
|
+
}
|
|
52
|
+
result = invoke(
|
|
53
|
+
runner,
|
|
54
|
+
["--api-key", "k", "rsi", "download", "-j", "J1", "-o", str(tmp_path)],
|
|
55
|
+
)
|
|
56
|
+
assert result.exit_code == 0
|
|
57
|
+
assert (tmp_path / "J1_rsi.md").read_text() == "# job rsi"
|
|
58
|
+
|
|
59
|
+
def test_download_skips_when_report_body_missing(self, runner, mock_client, tmp_path):
|
|
60
|
+
mock_client.get_json.return_value = {"status": "creating", "report": None}
|
|
61
|
+
result = invoke(
|
|
62
|
+
runner,
|
|
63
|
+
["--api-key", "k", "rsi", "download", "-j", "J1", "-o", str(tmp_path)],
|
|
64
|
+
)
|
|
65
|
+
assert result.exit_code == 0
|
|
66
|
+
assert not (tmp_path / "J1_rsi.md").exists()
|
|
67
|
+
|
|
68
|
+
def test_download_group_writes_summary_and_job_reports(self, runner, mock_client, tmp_path):
|
|
69
|
+
def response(path, params=None):
|
|
70
|
+
if path == "/openapi/groups/G1/rsi":
|
|
71
|
+
return {"status": "created", "report": {"summary_md": "# Group"}}
|
|
72
|
+
if path == "/openapi/jobs":
|
|
73
|
+
return {"jobs": [{"job_id": "J1"}], "total": 1}
|
|
74
|
+
if path == "/openapi/jobs/J1/rsi":
|
|
75
|
+
return {"status": "created", "report": {"report_md": "# J1"}}
|
|
76
|
+
raise AssertionError(path)
|
|
77
|
+
|
|
78
|
+
mock_client.get_json.side_effect = response
|
|
79
|
+
result = invoke(
|
|
80
|
+
runner,
|
|
81
|
+
["--api-key", "k", "rsi", "download", "-g", "G1", "-o", str(tmp_path)],
|
|
82
|
+
)
|
|
83
|
+
assert result.exit_code == 0
|
|
84
|
+
assert (tmp_path / "G1_rsi.md").read_text() == "# Group"
|
|
85
|
+
assert (tmp_path / "G1" / "J1_rsi.md").read_text() == "# J1"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class TestRsiTrigger:
|
|
89
|
+
def test_trigger_job(self, runner, mock_client):
|
|
90
|
+
mock_client.post_json.return_value = {"status": "creating", "accepted": True}
|
|
91
|
+
result = invoke(runner, ["--api-key", "k", "rsi", "trigger", "-j", "J1"])
|
|
92
|
+
assert result.exit_code == 0
|
|
93
|
+
mock_client.post_json.assert_called_once_with("/openapi/jobs/J1/rsi/trigger")
|
|
94
|
+
assert parse_stdout(result)[0]["status"] == "creating"
|
|
95
|
+
|
|
96
|
+
def test_trigger_group_include_passed(self, runner, mock_client):
|
|
97
|
+
mock_client.post_json.return_value = {"status": "creating", "accepted": True}
|
|
98
|
+
result = invoke(
|
|
99
|
+
runner,
|
|
100
|
+
["--api-key", "k", "rsi", "trigger", "-g", "G1", "--include-passed"],
|
|
101
|
+
)
|
|
102
|
+
assert result.exit_code == 0
|
|
103
|
+
mock_client.post_json.assert_called_once_with(
|
|
104
|
+
"/openapi/groups/G1/rsi/trigger", {"include_passed": True}
|
|
105
|
+
)
|
|
@@ -795,3 +795,237 @@ class TestTrajectoryDownloadTask:
|
|
|
795
795
|
assert result.exit_code == 1
|
|
796
796
|
assert "download task contains no job ids" in result.stderr
|
|
797
797
|
assert not (tmp_path / "T1_trajectories.zip").exists()
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
class TestTraceMediaDownload:
|
|
801
|
+
@staticmethod
|
|
802
|
+
def trajectory_with_media():
|
|
803
|
+
return {
|
|
804
|
+
"job_id": "J1",
|
|
805
|
+
"trajectory": [
|
|
806
|
+
{
|
|
807
|
+
"messages": [
|
|
808
|
+
{
|
|
809
|
+
"role": "tool",
|
|
810
|
+
"parts": [
|
|
811
|
+
{
|
|
812
|
+
"response": [
|
|
813
|
+
{
|
|
814
|
+
"trace_media": {
|
|
815
|
+
"mime_type": "image/png",
|
|
816
|
+
"oss_uri": "oss://bucket/a/b/shot.png",
|
|
817
|
+
},
|
|
818
|
+
"type": "input_image",
|
|
819
|
+
}
|
|
820
|
+
]
|
|
821
|
+
}
|
|
822
|
+
],
|
|
823
|
+
}
|
|
824
|
+
]
|
|
825
|
+
}
|
|
826
|
+
],
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
def test_task_download_archives_trace_media_under_source_dir(
|
|
830
|
+
self, runner, mock_client, tmp_path
|
|
831
|
+
):
|
|
832
|
+
def response(path, params=None):
|
|
833
|
+
if path == "/openapi/trajectory-download-tasks/T1":
|
|
834
|
+
return TestTrajectoryDownloadTask.task_payload(["J1"])
|
|
835
|
+
if path.endswith("/meta"):
|
|
836
|
+
return {"job_id": "J1"}
|
|
837
|
+
return self.trajectory_with_media()
|
|
838
|
+
|
|
839
|
+
def download_file(url, dest_path, progress=None, detect_sso_redirect=False):
|
|
840
|
+
assert url == (
|
|
841
|
+
"/openapi/getTrajV2/mediaFile"
|
|
842
|
+
"?ossUri=oss%3A%2F%2Fbucket%2Fa%2Fb%2Fshot.png&mime=image%2Fpng"
|
|
843
|
+
)
|
|
844
|
+
assert detect_sso_redirect is True
|
|
845
|
+
dest = Path(dest_path)
|
|
846
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
847
|
+
dest.write_bytes(b"png-bytes")
|
|
848
|
+
return dest
|
|
849
|
+
|
|
850
|
+
mock_client.get_json.side_effect = response
|
|
851
|
+
mock_client.download_file.side_effect = download_file
|
|
852
|
+
result = invoke(
|
|
853
|
+
runner,
|
|
854
|
+
[
|
|
855
|
+
"--api-key",
|
|
856
|
+
"k",
|
|
857
|
+
"trajectory",
|
|
858
|
+
"download",
|
|
859
|
+
"--task-id",
|
|
860
|
+
"T1",
|
|
861
|
+
"-o",
|
|
862
|
+
str(tmp_path),
|
|
863
|
+
],
|
|
864
|
+
)
|
|
865
|
+
|
|
866
|
+
assert result.exit_code == 0
|
|
867
|
+
with zipfile.ZipFile(tmp_path / "T1_trajectories.zip") as archive:
|
|
868
|
+
assert set(archive.namelist()) == {
|
|
869
|
+
"trajectories/J1_trajectory.json",
|
|
870
|
+
"metadata/J1.json",
|
|
871
|
+
"source/J1/shot.png",
|
|
872
|
+
"manifest.json",
|
|
873
|
+
}
|
|
874
|
+
assert archive.read("source/J1/shot.png") == b"png-bytes"
|
|
875
|
+
manifest = json.loads(archive.read("manifest.json"))
|
|
876
|
+
assert manifest["source_failures"] == []
|
|
877
|
+
|
|
878
|
+
def test_trace_media_failure_keeps_trajectory_and_records_manifest(
|
|
879
|
+
self, runner, mock_client, tmp_path
|
|
880
|
+
):
|
|
881
|
+
def response(path, params=None):
|
|
882
|
+
if path == "/openapi/trajectory-download-tasks/T1":
|
|
883
|
+
return TestTrajectoryDownloadTask.task_payload(["J1"])
|
|
884
|
+
if path.endswith("/meta"):
|
|
885
|
+
return {"job_id": "J1"}
|
|
886
|
+
return self.trajectory_with_media()
|
|
887
|
+
|
|
888
|
+
mock_client.get_json.side_effect = response
|
|
889
|
+
mock_client.download_file.side_effect = RuntimeError("oss unreachable")
|
|
890
|
+
result = invoke(
|
|
891
|
+
runner,
|
|
892
|
+
[
|
|
893
|
+
"--api-key",
|
|
894
|
+
"k",
|
|
895
|
+
"trajectory",
|
|
896
|
+
"download",
|
|
897
|
+
"--task-id",
|
|
898
|
+
"T1",
|
|
899
|
+
"-o",
|
|
900
|
+
str(tmp_path),
|
|
901
|
+
],
|
|
902
|
+
)
|
|
903
|
+
|
|
904
|
+
# 媒体文件失败不丢轨迹:zip 照常产出,但退出码为部分失败并记入 manifest。
|
|
905
|
+
archive_path = tmp_path / "T1_trajectories.zip"
|
|
906
|
+
assert result.exit_code == 1
|
|
907
|
+
assert "source: " in result.stderr
|
|
908
|
+
with zipfile.ZipFile(archive_path) as archive:
|
|
909
|
+
names = set(archive.namelist())
|
|
910
|
+
assert "trajectories/J1_trajectory.json" in names
|
|
911
|
+
assert not any(name.startswith("source/") for name in names)
|
|
912
|
+
manifest = json.loads(archive.read("manifest.json"))
|
|
913
|
+
assert manifest["successful_jobs"] == 1
|
|
914
|
+
assert manifest["source_failures"] == [
|
|
915
|
+
{"job_id": "J1", "error": "oss://bucket/a/b/shot.png: oss unreachable"}
|
|
916
|
+
]
|
|
917
|
+
|
|
918
|
+
def test_download_trace_media_dedupes_uris_and_guesses_extension(self, tmp_path):
|
|
919
|
+
from aliyun.bmcli.commands.trajectory import _download_trace_media
|
|
920
|
+
|
|
921
|
+
trajectory_path = tmp_path / "J1_trajectory.json"
|
|
922
|
+
trajectory_path.write_text(
|
|
923
|
+
json.dumps(
|
|
924
|
+
{
|
|
925
|
+
"trajectory": [
|
|
926
|
+
{
|
|
927
|
+
"trace_media": {
|
|
928
|
+
"oss_uri": "oss://b/a/shot.png",
|
|
929
|
+
"mime_type": "image/png",
|
|
930
|
+
}
|
|
931
|
+
},
|
|
932
|
+
{
|
|
933
|
+
"trace_media": {
|
|
934
|
+
"oss_uri": "oss://b/a/shot.png",
|
|
935
|
+
"mime_type": "image/png",
|
|
936
|
+
}
|
|
937
|
+
},
|
|
938
|
+
{
|
|
939
|
+
"trace_media": {
|
|
940
|
+
"oss_uri": "oss://b/c/shot.png",
|
|
941
|
+
"mime_type": "image/png",
|
|
942
|
+
}
|
|
943
|
+
},
|
|
944
|
+
{
|
|
945
|
+
"trace_media": {
|
|
946
|
+
"oss_uri": "oss://b/a/video",
|
|
947
|
+
"mime_type": "video/mp4",
|
|
948
|
+
}
|
|
949
|
+
},
|
|
950
|
+
{"trace_media": {"mime_type": "image/png"}},
|
|
951
|
+
{"trace_media": "not-a-dict"},
|
|
952
|
+
]
|
|
953
|
+
}
|
|
954
|
+
),
|
|
955
|
+
encoding="utf-8",
|
|
956
|
+
)
|
|
957
|
+
calls = []
|
|
958
|
+
|
|
959
|
+
class FakeClient:
|
|
960
|
+
def download_file(self, url, dest_path, progress=None, **kwargs):
|
|
961
|
+
calls.append(url)
|
|
962
|
+
dest = Path(dest_path)
|
|
963
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
964
|
+
dest.write_bytes(b"x")
|
|
965
|
+
return dest
|
|
966
|
+
|
|
967
|
+
errors = _download_trace_media(
|
|
968
|
+
FakeClient(), trajectory_path, tmp_path / "source" / "J1"
|
|
969
|
+
)
|
|
970
|
+
|
|
971
|
+
assert errors == []
|
|
972
|
+
assert calls == [
|
|
973
|
+
"/openapi/getTrajV2/mediaFile?ossUri=oss%3A%2F%2Fb%2Fa%2Fshot.png&mime=image%2Fpng",
|
|
974
|
+
"/openapi/getTrajV2/mediaFile?ossUri=oss%3A%2F%2Fb%2Fc%2Fshot.png&mime=image%2Fpng",
|
|
975
|
+
"/openapi/getTrajV2/mediaFile?ossUri=oss%3A%2F%2Fb%2Fa%2Fvideo&mime=video%2Fmp4",
|
|
976
|
+
]
|
|
977
|
+
assert sorted(p.name for p in (tmp_path / "source" / "J1").iterdir()) == [
|
|
978
|
+
"shot-2.png",
|
|
979
|
+
"shot.png",
|
|
980
|
+
"video.mp4",
|
|
981
|
+
]
|
|
982
|
+
|
|
983
|
+
def test_trace_media_auth_error_is_recorded_and_archive_kept(
|
|
984
|
+
self, runner, mock_client, tmp_path
|
|
985
|
+
):
|
|
986
|
+
# 媒体下载跳转到 SSO 登录页 = 认证问题:错误信息要明确(不误报文件缺失),
|
|
987
|
+
# 但媒体是辅助数据——轨迹归档照常产出,记入 source_failures,退出码 1。
|
|
988
|
+
from aliyun.bmcli.client import AuthError
|
|
989
|
+
|
|
990
|
+
def response(path, params=None):
|
|
991
|
+
if path == "/openapi/trajectory-download-tasks/T1":
|
|
992
|
+
return TestTrajectoryDownloadTask.task_payload(["J1"])
|
|
993
|
+
if path.endswith("/meta"):
|
|
994
|
+
return {"job_id": "J1"}
|
|
995
|
+
return self.trajectory_with_media()
|
|
996
|
+
|
|
997
|
+
mock_client.get_json.side_effect = response
|
|
998
|
+
mock_client.download_file.side_effect = AuthError(
|
|
999
|
+
"endpoint requires SSO login (redirected to login.alibaba-inc.com); "
|
|
1000
|
+
"X-API-Key not accepted"
|
|
1001
|
+
)
|
|
1002
|
+
result = invoke(
|
|
1003
|
+
runner,
|
|
1004
|
+
[
|
|
1005
|
+
"--api-key",
|
|
1006
|
+
"k",
|
|
1007
|
+
"trajectory",
|
|
1008
|
+
"download",
|
|
1009
|
+
"--task-id",
|
|
1010
|
+
"T1",
|
|
1011
|
+
"-o",
|
|
1012
|
+
str(tmp_path),
|
|
1013
|
+
],
|
|
1014
|
+
)
|
|
1015
|
+
|
|
1016
|
+
archive_path = tmp_path / "T1_trajectories.zip"
|
|
1017
|
+
assert result.exit_code == 1
|
|
1018
|
+
assert "SSO login" in result.stderr
|
|
1019
|
+
assert archive_path.exists()
|
|
1020
|
+
with zipfile.ZipFile(archive_path) as archive:
|
|
1021
|
+
names = set(archive.namelist())
|
|
1022
|
+
assert "trajectories/J1_trajectory.json" in names
|
|
1023
|
+
assert not any(name.startswith("source/") for name in names)
|
|
1024
|
+
manifest = json.loads(archive.read("manifest.json"))
|
|
1025
|
+
assert manifest["source_failures"] == [
|
|
1026
|
+
{
|
|
1027
|
+
"job_id": "J1",
|
|
1028
|
+
"error": "oss://bucket/a/b/shot.png: endpoint requires SSO login "
|
|
1029
|
+
"(redirected to login.alibaba-inc.com); X-API-Key not accepted",
|
|
1030
|
+
}
|
|
1031
|
+
]
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
version = 1
|
|
2
|
-
revision =
|
|
2
|
+
revision = 2
|
|
3
3
|
requires-python = ">=3.10"
|
|
4
4
|
|
|
5
5
|
[[package]]
|
|
@@ -27,7 +27,7 @@ wheels = [
|
|
|
27
27
|
|
|
28
28
|
[[package]]
|
|
29
29
|
name = "benchloop-client"
|
|
30
|
-
version = "0.2.
|
|
30
|
+
version = "0.2.18"
|
|
31
31
|
source = { editable = "." }
|
|
32
32
|
dependencies = [
|
|
33
33
|
{ name = "httpx" },
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|