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.
- aliyun/bmcli/__init__.py +3 -0
- aliyun/bmcli/__main__.py +26 -0
- aliyun/bmcli/cli.py +136 -0
- aliyun/bmcli/client.py +145 -0
- aliyun/bmcli/commands/__init__.py +1 -0
- aliyun/bmcli/commands/_common.py +36 -0
- aliyun/bmcli/commands/artifacts.py +138 -0
- aliyun/bmcli/commands/jobs.py +131 -0
- aliyun/bmcli/commands/metrics.py +136 -0
- aliyun/bmcli/commands/overview.py +70 -0
- aliyun/bmcli/commands/pull.py +168 -0
- aliyun/bmcli/commands/report.py +103 -0
- aliyun/bmcli/commands/tasks.py +121 -0
- aliyun/bmcli/commands/trajectory.py +92 -0
- aliyun/bmcli/config.py +120 -0
- aliyun/bmcli/exit_codes.py +15 -0
- aliyun/bmcli/ids.py +87 -0
- aliyun/bmcli/output.py +80 -0
- aliyun/bmcli/runtime.py +147 -0
- benchloop_client-0.1.6.dist-info/METADATA +100 -0
- benchloop_client-0.1.6.dist-info/RECORD +23 -0
- benchloop_client-0.1.6.dist-info/WHEEL +4 -0
- benchloop_client-0.1.6.dist-info/entry_points.txt +2 -0
aliyun/bmcli/__init__.py
ADDED
aliyun/bmcli/__main__.py
ADDED
|
@@ -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)
|