benchloop-client 0.2.23__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 (58) hide show
  1. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/PKG-INFO +13 -1
  2. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/README.md +12 -0
  3. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/pyproject.toml +1 -1
  4. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/src/aliyun/bmcli/cli.py +2 -0
  5. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/src/aliyun/bmcli/client.py +24 -0
  6. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/src/aliyun/bmcli/commands/_common.py +6 -0
  7. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/src/aliyun/bmcli/commands/metrics.py +14 -3
  8. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/src/aliyun/bmcli/commands/pull.py +8 -3
  9. benchloop_client-0.2.25/src/aliyun/bmcli/commands/rsi.py +235 -0
  10. benchloop_client-0.2.25/src/aliyun/bmcli/commands/trajectory.py +680 -0
  11. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/tests/test_client.py +63 -1
  12. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/tests/test_cmd_metrics.py +32 -2
  13. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/tests/test_cmd_pull.py +80 -0
  14. benchloop_client-0.2.25/tests/test_cmd_rsi.py +105 -0
  15. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/tests/test_cmd_trajectory.py +487 -42
  16. benchloop_client-0.2.23/src/aliyun/bmcli/commands/trajectory.py +0 -363
  17. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/.gitignore +0 -0
  18. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/.python-version +0 -0
  19. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/RELEASE.md +0 -0
  20. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/benchloop-cli-design.md +0 -0
  21. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/bmcli_examples.sh +0 -0
  22. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/src/aliyun/bmcli/__init__.py +0 -0
  23. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/src/aliyun/bmcli/__main__.py +0 -0
  24. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/src/aliyun/bmcli/commands/__init__.py +0 -0
  25. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/src/aliyun/bmcli/commands/ap_cmd.py +0 -0
  26. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/src/aliyun/bmcli/commands/artifacts.py +0 -0
  27. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/src/aliyun/bmcli/commands/brm.py +0 -0
  28. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/src/aliyun/bmcli/commands/group_data.py +0 -0
  29. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/src/aliyun/bmcli/commands/jobs.py +0 -0
  30. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/src/aliyun/bmcli/commands/metadata.py +0 -0
  31. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/src/aliyun/bmcli/commands/overview.py +0 -0
  32. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/src/aliyun/bmcli/commands/report.py +0 -0
  33. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/src/aliyun/bmcli/commands/tasks.py +0 -0
  34. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/src/aliyun/bmcli/config.py +0 -0
  35. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/src/aliyun/bmcli/exit_codes.py +0 -0
  36. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/src/aliyun/bmcli/ids.py +0 -0
  37. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/src/aliyun/bmcli/output.py +0 -0
  38. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/src/aliyun/bmcli/runtime.py +0 -0
  39. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/src/aliyun/bmcli/version_check.py +0 -0
  40. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/tests/__init__.py +0 -0
  41. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/tests/conftest.py +0 -0
  42. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/tests/test_cli_options.py +0 -0
  43. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/tests/test_cmd_ap_cmd.py +0 -0
  44. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/tests/test_cmd_artifacts.py +0 -0
  45. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/tests/test_cmd_brm.py +0 -0
  46. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/tests/test_cmd_metadata.py +0 -0
  47. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/tests/test_cmd_overview.py +0 -0
  48. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/tests/test_cmd_report.py +0 -0
  49. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/tests/test_cmd_tasks_jobs.py +0 -0
  50. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/tests/test_config.py +0 -0
  51. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/tests/test_exit_code_behavior.py +0 -0
  52. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/tests/test_exit_codes.py +0 -0
  53. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/tests/test_group_data.py +0 -0
  54. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/tests/test_ids.py +0 -0
  55. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/tests/test_runtime.py +0 -0
  56. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/tests/test_transfer_progress.py +0 -0
  57. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/tests/test_version_check.py +0 -0
  58. {benchloop_client-0.2.23 → benchloop_client-0.2.25}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: benchloop_client
3
- Version: 0.2.23
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
@@ -99,6 +102,15 @@ bl brm status --task-id UID
99
102
  bl brm list -g G1
100
103
  ```
101
104
 
105
+ Metrics and Job trajectory requests always explicitly use the Trace source. The
106
+ CLI does not expose a `trace`/`artifacts` source selector.
107
+
108
+ Group trajectory retrieval and downloads use the backend Trace export API. The
109
+ CLI waits for the export URL, downloads the gzip JSON-lines payload, and keeps
110
+ the existing `group_id`/`trajectories` JSON layout. The export wait timeout is
111
+ 30 minutes by default; override it in seconds with
112
+ `--download-timeout SECONDS` on trajectory commands or `pull`.
113
+
102
114
  Group downloads always auto-paginate and include every Job in the Group. In
103
115
  particular, `trajectory`, detailed `metrics`, `report`, and `artifacts` enumerate
104
116
  the complete Job list instead of relying on the backend Group endpoints that
@@ -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
@@ -87,6 +90,15 @@ bl brm status --task-id UID
87
90
  bl brm list -g G1
88
91
  ```
89
92
 
93
+ Metrics and Job trajectory requests always explicitly use the Trace source. The
94
+ CLI does not expose a `trace`/`artifacts` source selector.
95
+
96
+ Group trajectory retrieval and downloads use the backend Trace export API. The
97
+ CLI waits for the export URL, downloads the gzip JSON-lines payload, and keeps
98
+ the existing `group_id`/`trajectories` JSON layout. The export wait timeout is
99
+ 30 minutes by default; override it in seconds with
100
+ `--download-timeout SECONDS` on trajectory commands or `pull`.
101
+
90
102
  Group downloads always auto-paginate and include every Job in the Group. In
91
103
  particular, `trajectory`, detailed `metrics`, `report`, and `artifacts` enumerate
92
104
  the complete Job list instead of relying on the backend Group endpoints that
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "benchloop_client"
7
- version = "0.2.23"
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:
@@ -31,6 +31,12 @@ OutputDirOption = typer.Option(
31
31
  SkipExistingOption = typer.Option(
32
32
  False, "--skip-existing", help="Skip files that already exist on disk."
33
33
  )
34
+ DownloadTimeoutOption = typer.Option(
35
+ 1800,
36
+ "--download-timeout",
37
+ min=1,
38
+ help="Maximum seconds to wait for a Group Trace trajectory export.",
39
+ )
34
40
 
35
41
 
36
42
  def expand(values: Optional[list[str]]) -> list[str]:
@@ -25,6 +25,7 @@ from aliyun.bmcli.runtime import get_runtime, report_and_exit, safe_run_concurre
25
25
  app = typer.Typer(help="Run metrics (Group overall/detail, Job).", no_args_is_help=True)
26
26
 
27
27
  _SCOPES = ("overall", "detail", "all")
28
+ _TRACE_SOURCE = "trace"
28
29
 
29
30
  _GROUP_RUN_METRIC_FIELDS = (
30
31
  "job_count",
@@ -112,7 +113,9 @@ def _validate_scope(scope: str) -> str:
112
113
 
113
114
 
114
115
  def _fetch_job(client, job_id: str):
115
- data = client.get_json(f"/openapi/metrics/{job_id}")
116
+ data = client.get_json(
117
+ f"/openapi/metrics/{job_id}", params={"source": _TRACE_SOURCE}
118
+ )
116
119
  return _format_job_payload(data, job_id)
117
120
 
118
121
 
@@ -125,7 +128,12 @@ def fetch_group_detail(
125
128
  nonlocal tokenized_metrics
126
129
  data = client.get_json(
127
130
  "/openapi/metrics",
128
- params={"group_id": group_id, "skip": skip, "limit": limit},
131
+ params={
132
+ "group_id": group_id,
133
+ "skip": skip,
134
+ "limit": limit,
135
+ "source": _TRACE_SOURCE,
136
+ },
129
137
  )
130
138
  if isinstance(data, dict) and tokenized_metrics is None:
131
139
  tokenized_metrics = data.get("tokenized_metrics")
@@ -154,7 +162,10 @@ def fetch_group_detail(
154
162
 
155
163
 
156
164
  def _fetch_group_overall(client, group_id: str):
157
- data = client.get_json(f"/openapi/groups/{group_id}/metrics/overall")
165
+ data = client.get_json(
166
+ f"/openapi/groups/{group_id}/metrics/overall",
167
+ params={"source": _TRACE_SOURCE},
168
+ )
158
169
  return _format_group_overall(data, group_id)
159
170
 
160
171
 
@@ -14,6 +14,7 @@ from aliyun.bmcli.commands._common import (
14
14
  JobOption,
15
15
  OutputDirOption,
16
16
  SkipExistingOption,
17
+ DownloadTimeoutOption,
17
18
  expand,
18
19
  resolve_output_dir,
19
20
  write_empty_tgz,
@@ -29,7 +30,10 @@ from aliyun.bmcli.commands.metrics import (
29
30
  fetch_group_detail,
30
31
  )
31
32
  from aliyun.bmcli.commands.tasks import _fetch_all as fetch_all_tasks
32
- from aliyun.bmcli.commands.trajectory import write_group_trajectories
33
+ from aliyun.bmcli.commands.trajectory import (
34
+ fetch_job_trajectory,
35
+ write_group_trajectories,
36
+ )
33
37
  from aliyun.bmcli.client import ArtifactMissingError, AuthError
34
38
  from aliyun.bmcli.ids import enforce_mutually_exclusive, require_any
35
39
  from aliyun.bmcli.runtime import get_runtime
@@ -61,6 +65,7 @@ def pull(
61
65
  ),
62
66
  output_dir: Optional[str] = OutputDirOption,
63
67
  skip_existing: bool = SkipExistingOption,
68
+ download_timeout: int = DownloadTimeoutOption,
64
69
  ) -> None:
65
70
  """Aggregate downloads for the given groups/jobs into the layout."""
66
71
  rt = get_runtime(ctx)
@@ -107,7 +112,7 @@ def pull(
107
112
  f"{jid}:trajectory",
108
113
  lambda jid=jid: _fetch_and_write_json(
109
114
  root / f"{jid}_trajectory.json",
110
- lambda: client.get_json(f"/openapi/jobs/{jid}/trajectory"),
115
+ lambda: fetch_job_trajectory(client, jid),
111
116
  ),
112
117
  )
113
118
  if "report" in selected:
@@ -160,7 +165,7 @@ def pull(
160
165
  rt.concurrency,
161
166
  trajectory_dest,
162
167
  pretty=rt.pretty,
163
- ids=job_ids(_group_jobs()),
168
+ download_timeout=download_timeout,
164
169
  ),
165
170
  )
166
171
  if "report" in selected:
@@ -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)