benchloop-client 0.1.6__py3-none-any.whl

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.
@@ -0,0 +1,3 @@
1
+ """aliyun.bmcli — bmcli command line tool for benchmark analysis data."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,26 @@
1
+ """Executable entry point for the ``bmcli`` command."""
2
+
3
+ import sys
4
+
5
+ from aliyun.bmcli import output
6
+ from aliyun.bmcli.cli import app
7
+ from aliyun.bmcli.client import ApiError, AuthError, NetworkError
8
+ from aliyun.bmcli.exit_codes import AUTH, NETWORK, PARTIAL
9
+
10
+
11
+ def main() -> None:
12
+ try:
13
+ app()
14
+ except AuthError as exc:
15
+ output.error(str(exc))
16
+ sys.exit(AUTH)
17
+ except NetworkError as exc:
18
+ output.error(str(exc))
19
+ sys.exit(NETWORK)
20
+ except ApiError as exc:
21
+ output.error(str(exc))
22
+ sys.exit(PARTIAL)
23
+
24
+
25
+ if __name__ == "__main__":
26
+ main()
aliyun/bmcli/cli.py ADDED
@@ -0,0 +1,136 @@
1
+ """Typer application: global options, subcommand groups, and `config set`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import typer
8
+
9
+ from aliyun.bmcli import __version__, output
10
+ from aliyun.bmcli.commands import (
11
+ artifacts as artifacts_cmd,
12
+ jobs as jobs_cmd,
13
+ metrics as metrics_cmd,
14
+ overview as overview_cmd,
15
+ pull as pull_cmd,
16
+ report as report_cmd,
17
+ tasks as tasks_cmd,
18
+ trajectory as trajectory_cmd,
19
+ )
20
+ from aliyun.bmcli.config import load_config, write_config
21
+ from aliyun.bmcli.exit_codes import OK
22
+ from aliyun.bmcli.runtime import Runtime
23
+
24
+ app = typer.Typer(
25
+ name="bmcli",
26
+ help="Benchloop CLI — fetch and download benchmark analysis data.",
27
+ no_args_is_help=True,
28
+ add_completion=False,
29
+ )
30
+
31
+
32
+ def _version_callback(value: bool) -> None:
33
+ if value:
34
+ typer.echo(f"bmcli {__version__}")
35
+ raise typer.Exit(code=OK)
36
+
37
+
38
+ @app.callback()
39
+ def main(
40
+ ctx: typer.Context,
41
+ api_key: Optional[str] = typer.Option(
42
+ None, "--api-key", help="Personal api_key (highest precedence).", show_default=False
43
+ ),
44
+ host: Optional[str] = typer.Option(
45
+ None, "--host", help="Override backend base URL.", show_default=False
46
+ ),
47
+ output_dir: Optional[str] = typer.Option(
48
+ None, "-o", "--output-dir", help="Download output root directory (default '.').",
49
+ show_default=False,
50
+ ),
51
+ pretty: bool = typer.Option(
52
+ True, "--pretty/--no-pretty", help="Pretty-print JSON output."
53
+ ),
54
+ concurrency: int = typer.Option(
55
+ 5, "--concurrency", help="Concurrency for multi-id operations."
56
+ ),
57
+ timeout: float = typer.Option(
58
+ 300.0, "--timeout", help="Per-request timeout in seconds."
59
+ ),
60
+ retry: int = typer.Option(
61
+ 2, "--retry", help="Retry count on failure (exponential backoff)."
62
+ ),
63
+ cluster: Optional[str] = typer.Option(
64
+ None, "--cluster", help="Cluster name (resolved by backend if omitted).",
65
+ show_default=False,
66
+ ),
67
+ verbose: bool = typer.Option(
68
+ False, "-v", "--verbose", help="Debug logs to stderr."
69
+ ),
70
+ quiet: bool = typer.Option(
71
+ False, "-q", "--quiet", help="Only output errors."
72
+ ),
73
+ config_path: Optional[str] = typer.Option(
74
+ None, "--config", help="Path to a config file.", show_default=False
75
+ ),
76
+ version: bool = typer.Option(
77
+ False, "--version", callback=_version_callback, is_eager=True,
78
+ help="Show version and exit.",
79
+ ),
80
+ ) -> None:
81
+ """Resolve config + runtime options and stash them on the context."""
82
+ output.set_verbosity(quiet=quiet, verbose=verbose)
83
+ cfg = load_config(
84
+ api_key=api_key,
85
+ host=host,
86
+ output_dir=output_dir,
87
+ config_path=config_path,
88
+ )
89
+ ctx.obj = Runtime(
90
+ config=cfg,
91
+ concurrency=concurrency,
92
+ timeout=timeout,
93
+ retry=retry,
94
+ cluster=cluster,
95
+ pretty=pretty,
96
+ output_dir=cfg.output_dir,
97
+ verbose=verbose,
98
+ quiet=quiet,
99
+ )
100
+
101
+
102
+ # --- config command group -------------------------------------------------
103
+
104
+ config_app = typer.Typer(help="Manage local configuration (~/.benchloop/config.toml).")
105
+ app.add_typer(config_app, name="config")
106
+
107
+
108
+ @config_app.command("set")
109
+ def config_set(
110
+ api_key: Optional[str] = typer.Option(None, "--api-key", show_default=False),
111
+ host: Optional[str] = typer.Option(None, "--host", show_default=False),
112
+ output_dir: Optional[str] = typer.Option(None, "-o", "--output-dir", show_default=False),
113
+ config_path: Optional[str] = typer.Option(None, "--config", show_default=False),
114
+ ) -> None:
115
+ """Write api_key / host / output_dir to ~/.benchloop/config.toml."""
116
+ if api_key is None and host is None and output_dir is None:
117
+ raise typer.BadParameter("provide at least one of --api-key/--host/--output-dir")
118
+ path = write_config(
119
+ api_key=api_key,
120
+ host=host,
121
+ output_dir=output_dir,
122
+ config_path=config_path,
123
+ )
124
+ output.log(f"wrote config to {path}")
125
+
126
+
127
+ # --- resource sub-apps -----------------------------------------------------
128
+
129
+ app.add_typer(metrics_cmd.app, name="metrics")
130
+ app.add_typer(trajectory_cmd.app, name="trajectory")
131
+ app.add_typer(report_cmd.app, name="report")
132
+ app.add_typer(overview_cmd.app, name="overview")
133
+ app.add_typer(tasks_cmd.app, name="tasks")
134
+ app.add_typer(jobs_cmd.app, name="jobs")
135
+ app.add_typer(artifacts_cmd.app, name="artifacts")
136
+ app.command("pull")(pull_cmd.pull)
aliyun/bmcli/client.py ADDED
@@ -0,0 +1,145 @@
1
+ """HTTP client wrapping httpx with retries, typed errors and file downloads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Any, Optional
8
+
9
+ import httpx
10
+
11
+
12
+ class ClientError(Exception):
13
+ """Base class for all client-side errors."""
14
+
15
+
16
+ class AuthError(ClientError):
17
+ """Raised on HTTP 401 (invalid / missing api key)."""
18
+
19
+
20
+ class NetworkError(ClientError):
21
+ """Raised on timeout or connection failure."""
22
+
23
+
24
+ class ApiError(ClientError):
25
+ """Raised on a non-2xx HTTP response other than 401."""
26
+
27
+ def __init__(self, status: int, body: Any) -> None:
28
+ self.status = status
29
+ self.body = body
30
+ super().__init__(f"API error {status}: {body}")
31
+
32
+
33
+ class ApiClient:
34
+ """Thin wrapper over ``httpx.Client`` for the benchmark openapi."""
35
+
36
+ def __init__(
37
+ self,
38
+ host: str,
39
+ api_key: Optional[str] = None,
40
+ timeout: float = 300.0,
41
+ retry: int = 2,
42
+ cluster: Optional[str] = None,
43
+ ) -> None:
44
+ self.host = host.rstrip("/")
45
+ self.api_key = api_key
46
+ self.timeout = timeout
47
+ self.retry = max(0, retry)
48
+ self.cluster = cluster
49
+
50
+ def _headers(self) -> dict[str, str]:
51
+ headers = {"Accept": "application/json"}
52
+ if self.api_key:
53
+ headers["X-API-Key"] = self.api_key
54
+ return headers
55
+
56
+ def _url(self, path: str) -> str:
57
+ if path.startswith("http://") or path.startswith("https://"):
58
+ return path
59
+ return f"{self.host}/{path.lstrip('/')}"
60
+
61
+ def get_json(
62
+ self, path: str, params: Optional[dict[str, Any]] = None
63
+ ) -> Any:
64
+ """GET ``path`` and return parsed JSON, with retry + typed errors."""
65
+ merged: dict[str, Any] = {}
66
+ if self.cluster:
67
+ merged["cluster"] = self.cluster
68
+ if params:
69
+ merged.update({k: v for k, v in params.items() if v is not None})
70
+
71
+ url = self._url(path)
72
+ last_exc: Optional[Exception] = None
73
+ for attempt in range(self.retry + 1):
74
+ try:
75
+ with httpx.Client(timeout=self.timeout) as client:
76
+ resp = client.get(url, params=merged, headers=self._headers())
77
+ except (httpx.TimeoutException, httpx.TransportError) as exc:
78
+ last_exc = NetworkError(str(exc))
79
+ if attempt < self.retry:
80
+ time.sleep(2**attempt * 0.5)
81
+ continue
82
+ raise last_exc from exc
83
+
84
+ if resp.status_code == 401:
85
+ raise AuthError("authentication failed (HTTP 401)")
86
+ if resp.status_code >= 400:
87
+ body = self._safe_body(resp)
88
+ # Retry on 5xx; fail fast on 4xx.
89
+ if resp.status_code >= 500 and attempt < self.retry:
90
+ last_exc = ApiError(resp.status_code, body)
91
+ time.sleep(2**attempt * 0.5)
92
+ continue
93
+ raise ApiError(resp.status_code, body)
94
+
95
+ try:
96
+ return resp.json()
97
+ except ValueError as exc:
98
+ raise ApiError(resp.status_code, resp.text) from exc
99
+
100
+ if last_exc:
101
+ raise last_exc
102
+ raise NetworkError("request failed") # pragma: no cover
103
+
104
+ def download_file(self, url: str, dest_path: str | Path) -> Path:
105
+ """Stream ``url`` to ``dest_path`` on disk. Returns the path written."""
106
+ dest = Path(dest_path)
107
+ dest.parent.mkdir(parents=True, exist_ok=True)
108
+ last_exc: Optional[Exception] = None
109
+ for attempt in range(self.retry + 1):
110
+ try:
111
+ with httpx.Client(timeout=self.timeout, follow_redirects=True) as client:
112
+ with client.stream("GET", self._url(url), headers=self._headers()) as resp:
113
+ if resp.status_code == 401:
114
+ raise AuthError("authentication failed (HTTP 401)")
115
+ if resp.status_code >= 400:
116
+ body = self._safe_stream_body(resp)
117
+ raise ApiError(resp.status_code, body)
118
+ with dest.open("wb") as fh:
119
+ for chunk in resp.iter_bytes(chunk_size=65536):
120
+ fh.write(chunk)
121
+ return dest
122
+ except (httpx.TimeoutException, httpx.TransportError) as exc:
123
+ last_exc = NetworkError(str(exc))
124
+ if attempt < self.retry:
125
+ time.sleep(2**attempt * 0.5)
126
+ continue
127
+ raise last_exc from exc
128
+ if last_exc:
129
+ raise last_exc
130
+ return dest # pragma: no cover
131
+
132
+ @staticmethod
133
+ def _safe_body(resp: httpx.Response) -> Any:
134
+ try:
135
+ return resp.json()
136
+ except ValueError:
137
+ return resp.text
138
+
139
+ @staticmethod
140
+ def _safe_stream_body(resp: httpx.Response) -> str:
141
+ try:
142
+ resp.read()
143
+ return resp.text
144
+ except Exception: # pragma: no cover
145
+ return ""
@@ -0,0 +1 @@
1
+ """Resource command groups for the bmcli Typer app."""
@@ -0,0 +1,36 @@
1
+ """Shared option definitions and small helpers for command modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import typer
9
+
10
+ from aliyun.bmcli.ids import expand_ids
11
+
12
+ # Reusable Typer option objects (lists for repeatable / comma-separated flags).
13
+ GroupOption = typer.Option(
14
+ None, "-g", "--group", help="Group ID (repeatable, comma-separated, or '-' for stdin).",
15
+ show_default=False,
16
+ )
17
+ JobOption = typer.Option(
18
+ None, "-j", "--job", help="Job ID (repeatable, comma-separated, or '-' for stdin).",
19
+ show_default=False,
20
+ )
21
+ OutputDirOption = typer.Option(
22
+ None, "-o", "--output-dir", help="Override download output root directory.",
23
+ show_default=False,
24
+ )
25
+ SkipExistingOption = typer.Option(
26
+ False, "--skip-existing", help="Skip files that already exist on disk."
27
+ )
28
+
29
+
30
+ def expand(values: Optional[list[str]]) -> list[str]:
31
+ return expand_ids(values)
32
+
33
+
34
+ def resolve_output_dir(runtime_output: str, override: Optional[str]) -> Path:
35
+ """Pick the per-command override if given, else the runtime output dir."""
36
+ return Path(override) if override else Path(runtime_output)
@@ -0,0 +1,138 @@
1
+ """`bmcli artifacts` — download artifacts (Group, Job); download only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ from concurrent.futures import ThreadPoolExecutor
7
+ from pathlib import Path
8
+ from typing import Any, Optional
9
+
10
+ import typer
11
+
12
+ from aliyun.bmcli import output
13
+ from aliyun.bmcli.commands._common import (
14
+ GroupOption,
15
+ JobOption,
16
+ OutputDirOption,
17
+ SkipExistingOption,
18
+ expand,
19
+ resolve_output_dir,
20
+ )
21
+ from aliyun.bmcli.ids import enforce_mutually_exclusive, require_any
22
+ from aliyun.bmcli.runtime import get_runtime, report_and_exit, safe_run_concurrent
23
+
24
+ app = typer.Typer(help="Download artifacts (Group, Job).", no_args_is_help=True)
25
+
26
+
27
+ def _matches(name: str, include: Optional[str], exclude: Optional[str]) -> bool:
28
+ if include and not fnmatch.fnmatch(name, include):
29
+ return False
30
+ if exclude and fnmatch.fnmatch(name, exclude):
31
+ return False
32
+ return True
33
+
34
+
35
+ def _list_job(client, job_id: str) -> list[dict[str, Any]]:
36
+ data = client.get_json(f"/openapi/jobs/{job_id}/artifacts")
37
+ arts = data.get("artifacts", []) if isinstance(data, dict) else []
38
+ return [{**a, "job_id": job_id} for a in arts]
39
+
40
+
41
+ def _list_group(client, group_id: str) -> list[dict[str, Any]]:
42
+ data = client.get_json(f"/openapi/groups/{group_id}/artifacts")
43
+ arts = data.get("artifacts", []) if isinstance(data, dict) else []
44
+ # tag each entry with its group so downloads can nest under {group_id}/{job_id}/
45
+ return [{**a, "group_id": group_id} for a in arts]
46
+
47
+
48
+ @app.command("download")
49
+ def download(
50
+ ctx: typer.Context,
51
+ group: Optional[list[str]] = GroupOption,
52
+ job: Optional[list[str]] = JobOption,
53
+ include: Optional[str] = typer.Option(None, "--include", help="Glob to include by name.", show_default=False),
54
+ exclude: Optional[str] = typer.Option(None, "--exclude", help="Glob to exclude by name.", show_default=False),
55
+ list_only: bool = typer.Option(False, "--list-only", help="List artifacts without downloading."),
56
+ output_dir: Optional[str] = OutputDirOption,
57
+ skip_existing: bool = SkipExistingOption,
58
+ ) -> None:
59
+ """Fetch presigned URLs then concurrently download (or list) artifacts."""
60
+ rt = get_runtime(ctx)
61
+ groups = expand(group)
62
+ jobs = expand(job)
63
+ enforce_mutually_exclusive(groups, jobs)
64
+ require_any(groups, jobs)
65
+ root = resolve_output_dir(rt.output_dir, output_dir)
66
+
67
+ # Phase 1: list artifacts per id (concurrent, partial-failure tolerant).
68
+ if jobs:
69
+ listing = safe_run_concurrent(
70
+ jobs, lambda jid: _list_job(rt.client, jid), rt.concurrency
71
+ )
72
+ else:
73
+ listing = safe_run_concurrent(
74
+ groups, lambda gid: _list_group(rt.client, gid), rt.concurrency
75
+ )
76
+
77
+ # Flatten and filter by name glob.
78
+ flat: list[dict[str, Any]] = []
79
+ for arts in listing.items:
80
+ for a in arts:
81
+ if _matches(a.get("name") or "", include, exclude):
82
+ flat.append(a)
83
+
84
+ if list_only:
85
+ # Listing shows every entry, including jobs with no artifact (error_code).
86
+ output.emit_json(flat, pretty=rt.pretty)
87
+ report_and_exit(listing, emit=False)
88
+ return
89
+
90
+ # Only entries with a real presigned url are downloadable; the rest (e.g.
91
+ # error_code=no_artifact) are reported to stderr but are not hard failures.
92
+ downloadable = [a for a in flat if a.get("url")]
93
+ for a in flat:
94
+ if not a.get("url"):
95
+ output.log(
96
+ f"no artifact for job={a.get('job_id')} "
97
+ f"({a.get('error_code', 'no url')})"
98
+ )
99
+
100
+ # Phase 2: concurrent downloads. Job dim -> {job_id}/<name>;
101
+ # Group dim -> {group_id}/{job_id}/<name> (avoids same-name collisions).
102
+ failures = list(listing.failures)
103
+
104
+ def _dest_for(art: dict[str, Any]) -> Path:
105
+ name = art.get("name") or "artifact"
106
+ jid = str(art.get("job_id") or "")
107
+ if jobs:
108
+ sub = Path(jid) if jid else Path()
109
+ else:
110
+ gid = str(art.get("group_id") or "")
111
+ sub = Path(gid) / jid if (gid and jid) else Path(gid or jid)
112
+ return root / sub / name
113
+
114
+ def _download_one(art: dict[str, Any]) -> Optional[tuple[str, str]]:
115
+ name = art.get("name") or "artifact"
116
+ url = art.get("url")
117
+ if not url: # already reported above; defensive guard
118
+ return None
119
+ dest = _dest_for(art)
120
+ if skip_existing and dest.exists():
121
+ output.log(f"skip existing {dest}")
122
+ return None
123
+ try:
124
+ rt.client.download_file(url, dest)
125
+ output.log(f"downloaded {dest}")
126
+ return None
127
+ except Exception as exc: # noqa: BLE001 - per-artifact failure tolerated
128
+ return (f"{art.get('job_id')}:{name}", str(exc))
129
+
130
+ if downloadable:
131
+ max_workers = max(1, min(rt.concurrency, len(downloadable)))
132
+ with ThreadPoolExecutor(max_workers=max_workers) as pool:
133
+ for result in pool.map(_download_one, downloadable):
134
+ if result is not None:
135
+ failures.append(result)
136
+
137
+ listing.failures = failures
138
+ report_and_exit(listing, emit=False)
@@ -0,0 +1,131 @@
1
+ """`bmcli jobs` — Group job list (/openapi/jobs) with pagination."""
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.commands._common import (
11
+ GroupOption,
12
+ JobOption,
13
+ OutputDirOption,
14
+ SkipExistingOption,
15
+ expand,
16
+ resolve_output_dir,
17
+ )
18
+ from aliyun.bmcli.ids import (
19
+ enforce_mutually_exclusive,
20
+ reject_unsupported_dimension,
21
+ require_any,
22
+ )
23
+ from aliyun.bmcli.runtime import get_runtime, report_and_exit, safe_run_concurrent
24
+
25
+ app = typer.Typer(help="Group job list (/openapi/jobs).", no_args_is_help=True)
26
+
27
+
28
+ def _guard(groups: list[str], jobs: list[str]) -> None:
29
+ enforce_mutually_exclusive(groups, jobs)
30
+ reject_unsupported_dimension(
31
+ "jobs", groups=groups, jobs=jobs, allow_group=True, allow_job=False
32
+ )
33
+ require_any(groups, jobs)
34
+
35
+
36
+ def _params(group_id, skip, limit, template, status, instance_id) -> dict[str, Any]:
37
+ params: dict[str, Any] = {"group_id": group_id, "skip": skip, "limit": limit}
38
+ if template:
39
+ params["template"] = template
40
+ if status:
41
+ params["status"] = status
42
+ if instance_id:
43
+ params["instance_id"] = instance_id
44
+ return params
45
+
46
+
47
+ def _fetch_all(client, group_id, page_size, template, status, instance_id):
48
+ skip = 0
49
+ all_jobs: list[Any] = []
50
+ total = None
51
+ while True:
52
+ data = client.get_json(
53
+ "/openapi/jobs",
54
+ params=_params(group_id, skip, page_size, template, status, instance_id),
55
+ )
56
+ jobs = data.get("jobs", []) if isinstance(data, dict) else []
57
+ if isinstance(data, dict) and total is None:
58
+ total = data.get("total")
59
+ all_jobs.extend(jobs)
60
+ if not jobs or len(jobs) < page_size:
61
+ break
62
+ skip += page_size
63
+ if total is not None and len(all_jobs) >= total:
64
+ break
65
+ return {"group_id": group_id, "total": total if total is not None else len(all_jobs),
66
+ "jobs": all_jobs}
67
+
68
+
69
+ @app.command("get")
70
+ def get(
71
+ ctx: typer.Context,
72
+ group: Optional[list[str]] = GroupOption,
73
+ job: Optional[list[str]] = JobOption,
74
+ page: int = typer.Option(1, "--page", help="Page number (1-based)."),
75
+ page_size: int = typer.Option(50, "--page-size", help="Page size."),
76
+ all_pages: bool = typer.Option(False, "--all", help="Auto-paginate all jobs."),
77
+ template: Optional[str] = typer.Option(None, "--template", show_default=False),
78
+ status: Optional[str] = typer.Option(None, "--status", show_default=False),
79
+ instance_id: Optional[str] = typer.Option(None, "--instance-id", show_default=False),
80
+ ) -> None:
81
+ """Query the group job list; prints a JSON array."""
82
+ rt = get_runtime(ctx)
83
+ groups = expand(group)
84
+ jobs = expand(job)
85
+ _guard(groups, jobs)
86
+
87
+ def worker(group_id: str):
88
+ if all_pages:
89
+ return _fetch_all(rt.client, group_id, page_size, template, status, instance_id)
90
+ skip = max(0, (page - 1) * page_size)
91
+ data = rt.client.get_json(
92
+ "/openapi/jobs",
93
+ params=_params(group_id, skip, page_size, template, status, instance_id),
94
+ )
95
+ return {"group_id": group_id, **(data if isinstance(data, dict) else {"data": data})}
96
+
97
+ res = safe_run_concurrent(groups, worker, rt.concurrency)
98
+ report_and_exit(res, emit=True, pretty=rt.pretty)
99
+
100
+
101
+ @app.command("download")
102
+ def download(
103
+ ctx: typer.Context,
104
+ group: Optional[list[str]] = GroupOption,
105
+ job: Optional[list[str]] = JobOption,
106
+ page_size: int = typer.Option(50, "--page-size", help="Page size for pagination."),
107
+ template: Optional[str] = typer.Option(None, "--template", show_default=False),
108
+ status: Optional[str] = typer.Option(None, "--status", show_default=False),
109
+ instance_id: Optional[str] = typer.Option(None, "--instance-id", show_default=False),
110
+ output_dir: Optional[str] = OutputDirOption,
111
+ skip_existing: bool = SkipExistingOption,
112
+ ) -> None:
113
+ """Download the full job list to {group_id}/job_list.json."""
114
+ rt = get_runtime(ctx)
115
+ groups = expand(group)
116
+ jobs = expand(job)
117
+ _guard(groups, jobs)
118
+ root = resolve_output_dir(rt.output_dir, output_dir)
119
+
120
+ def worker(group_id: str):
121
+ dest = root / group_id / "job_list.json"
122
+ if skip_existing and dest.exists():
123
+ output.log(f"skip existing {dest}")
124
+ return str(dest)
125
+ data = _fetch_all(rt.client, group_id, page_size, template, status, instance_id)
126
+ output.write_json_file(dest, data, pretty=rt.pretty)
127
+ output.log(f"wrote {dest} ({len(data.get('jobs', []))} jobs)")
128
+ return str(dest)
129
+
130
+ res = safe_run_concurrent(groups, worker, rt.concurrency)
131
+ report_and_exit(res, emit=False)