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.
Files changed (57) hide show
  1. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/PKG-INFO +4 -1
  2. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/README.md +3 -0
  3. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/pyproject.toml +1 -1
  4. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/cli.py +2 -0
  5. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/client.py +24 -0
  6. benchloop_client-0.2.25/src/aliyun/bmcli/commands/rsi.py +235 -0
  7. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/commands/trajectory.py +134 -7
  8. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/test_client.py +63 -1
  9. benchloop_client-0.2.25/tests/test_cmd_rsi.py +105 -0
  10. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/test_cmd_trajectory.py +234 -0
  11. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/uv.lock +2 -2
  12. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/.gitignore +0 -0
  13. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/.python-version +0 -0
  14. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/RELEASE.md +0 -0
  15. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/benchloop-cli-design.md +0 -0
  16. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/bmcli_examples.sh +0 -0
  17. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/__init__.py +0 -0
  18. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/__main__.py +0 -0
  19. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/commands/__init__.py +0 -0
  20. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/commands/_common.py +0 -0
  21. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/commands/ap_cmd.py +0 -0
  22. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/commands/artifacts.py +0 -0
  23. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/commands/brm.py +0 -0
  24. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/commands/group_data.py +0 -0
  25. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/commands/jobs.py +0 -0
  26. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/commands/metadata.py +0 -0
  27. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/commands/metrics.py +0 -0
  28. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/commands/overview.py +0 -0
  29. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/commands/pull.py +0 -0
  30. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/commands/report.py +0 -0
  31. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/commands/tasks.py +0 -0
  32. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/config.py +0 -0
  33. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/exit_codes.py +0 -0
  34. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/ids.py +0 -0
  35. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/output.py +0 -0
  36. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/runtime.py +0 -0
  37. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/src/aliyun/bmcli/version_check.py +0 -0
  38. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/__init__.py +0 -0
  39. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/conftest.py +0 -0
  40. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/test_cli_options.py +0 -0
  41. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/test_cmd_ap_cmd.py +0 -0
  42. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/test_cmd_artifacts.py +0 -0
  43. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/test_cmd_brm.py +0 -0
  44. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/test_cmd_metadata.py +0 -0
  45. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/test_cmd_metrics.py +0 -0
  46. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/test_cmd_overview.py +0 -0
  47. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/test_cmd_pull.py +0 -0
  48. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/test_cmd_report.py +0 -0
  49. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/test_cmd_tasks_jobs.py +0 -0
  50. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/test_config.py +0 -0
  51. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/test_exit_code_behavior.py +0 -0
  52. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/test_exit_codes.py +0 -0
  53. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/test_group_data.py +0 -0
  54. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/test_ids.py +0 -0
  55. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/test_runtime.py +0 -0
  56. {benchloop_client-0.2.24 → benchloop_client-0.2.25}/tests/test_transfer_progress.py +0 -0
  57. {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.24
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.24"
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/ 目录,job 元数据在 metadata/ 目录(一个
299
- job 一个 json),manifest.json 留在根目录。
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[str, Path, Optional[Path], Optional[str]]:
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
- return job_id, dest, meta_path, meta_error
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 = [(job_id, path) for job_id, path, _meta, _err in result.items]
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 = 3
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.24"
30
+ version = "0.2.18"
31
31
  source = { editable = "." }
32
32
  dependencies = [
33
33
  { name = "httpx" },