ap-client 0.1.4.dev0__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.
- ap_client/__init__.py +13 -0
- ap_client/api.py +516 -0
- ap_client/cli.py +1016 -0
- ap_client/config.py +65 -0
- ap_client/exporter.py +368 -0
- ap_client/waiter.py +70 -0
- ap_client-0.1.4.dev0.dist-info/METADATA +9 -0
- ap_client-0.1.4.dev0.dist-info/RECORD +10 -0
- ap_client-0.1.4.dev0.dist-info/WHEEL +4 -0
- ap_client-0.1.4.dev0.dist-info/entry_points.txt +2 -0
ap_client/config.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Configuration management."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _parse_bool(value: str | None) -> bool:
|
|
8
|
+
"""Parse a boolean environment variable."""
|
|
9
|
+
if value is None:
|
|
10
|
+
return False
|
|
11
|
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _parse_positive_int(value: str | None, default: int) -> int:
|
|
15
|
+
"""Parse a positive-integer environment variable."""
|
|
16
|
+
if value is None or not value.strip():
|
|
17
|
+
return default
|
|
18
|
+
parsed = int(value)
|
|
19
|
+
if parsed <= 0:
|
|
20
|
+
raise ValueError("environment variable must be a positive integer")
|
|
21
|
+
return parsed
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Config:
|
|
26
|
+
"""CLI configuration."""
|
|
27
|
+
|
|
28
|
+
base_url: str
|
|
29
|
+
headers: dict
|
|
30
|
+
agenthub_ref: str | None
|
|
31
|
+
verbose: bool = False
|
|
32
|
+
verbose_body_limit: int = 4096
|
|
33
|
+
verbose_full_body: bool = False
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def from_env(cls) -> "Config":
|
|
37
|
+
"""Load configuration from environment variables."""
|
|
38
|
+
base_url = os.environ.get("AP_BASE_URL")
|
|
39
|
+
if not base_url:
|
|
40
|
+
raise ValueError("AP_BASE_URL environment variable must be set")
|
|
41
|
+
|
|
42
|
+
headers = {}
|
|
43
|
+
headers_str = os.environ.get("AP_HEADERS", "")
|
|
44
|
+
if headers_str:
|
|
45
|
+
for h in headers_str.split(","):
|
|
46
|
+
if ":" in h:
|
|
47
|
+
k, v = h.split(":", 1)
|
|
48
|
+
headers[k.strip()] = v.strip()
|
|
49
|
+
|
|
50
|
+
return cls(
|
|
51
|
+
base_url=base_url.rstrip("/"),
|
|
52
|
+
headers=headers,
|
|
53
|
+
agenthub_ref=os.environ.get("AP_AGENTHUB_REF"),
|
|
54
|
+
verbose=_parse_bool(os.environ.get("AP_VERBOSE")),
|
|
55
|
+
verbose_body_limit=_parse_positive_int(os.environ.get("AP_VERBOSE_BODY_LIMIT"), 4096),
|
|
56
|
+
verbose_full_body=_parse_bool(os.environ.get("AP_VERBOSE_FULL_BODY")),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_config(verbose: bool | None = None) -> Config:
|
|
61
|
+
"""Return the current configuration."""
|
|
62
|
+
config = Config.from_env()
|
|
63
|
+
if verbose is not None:
|
|
64
|
+
config.verbose = verbose
|
|
65
|
+
return config
|
ap_client/exporter.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""Export job/group data from Agent Platform to local directories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import concurrent.futures
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import socket
|
|
9
|
+
import sys
|
|
10
|
+
import tarfile
|
|
11
|
+
import threading
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Callable, Optional
|
|
14
|
+
from urllib.parse import urlparse, urlunparse
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
|
|
18
|
+
from .api import APIClient
|
|
19
|
+
|
|
20
|
+
DEFAULT_CACHE_DIR = "~/.cache/agentplatform"
|
|
21
|
+
_DOWNLOAD_TIMEOUT = (10, 300)
|
|
22
|
+
_DOWNLOAD_CHUNK_SIZE = 1024 * 1024 * 4
|
|
23
|
+
ProgressCallback = Callable[[int, int, str, str], None]
|
|
24
|
+
StageCallback = Callable[[str], None]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def default_cache_dir() -> Path:
|
|
28
|
+
"""Return the export cache root."""
|
|
29
|
+
raw = os.environ.get("AP_CACHE_DIR", DEFAULT_CACHE_DIR)
|
|
30
|
+
return Path(raw).expanduser()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def default_job_export_dir(job_id: str) -> Path:
|
|
34
|
+
"""Return the default export path for one job."""
|
|
35
|
+
return default_cache_dir() / "jobs" / job_id
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def default_group_export_dir(group_id: str) -> Path:
|
|
39
|
+
"""Return the default export path for one group."""
|
|
40
|
+
return default_cache_dir() / "groups" / group_id
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def export_job(
|
|
44
|
+
client: APIClient,
|
|
45
|
+
job_id: str,
|
|
46
|
+
output_dir: Optional[Path] = None,
|
|
47
|
+
) -> Path:
|
|
48
|
+
"""Export a single job to disk."""
|
|
49
|
+
dest = Path(output_dir) if output_dir else default_job_export_dir(job_id)
|
|
50
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
_export_job_into_dir(client, job_id, dest)
|
|
52
|
+
return dest
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def export_group(
|
|
56
|
+
client: APIClient,
|
|
57
|
+
group_id: str,
|
|
58
|
+
output_dir: Optional[Path] = None,
|
|
59
|
+
progress_callback: Optional[ProgressCallback] = None,
|
|
60
|
+
workers: int = 4,
|
|
61
|
+
) -> Path:
|
|
62
|
+
"""Export a group and all of its jobs to disk."""
|
|
63
|
+
dest = Path(output_dir) if output_dir else default_group_export_dir(group_id)
|
|
64
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
|
|
66
|
+
group = client.get_group_stats(group_id)
|
|
67
|
+
_write_json(dest / "group.json", group)
|
|
68
|
+
try:
|
|
69
|
+
group_eval = client.get_group_eval(group_id)
|
|
70
|
+
_write_json(dest / "eval.json", group_eval)
|
|
71
|
+
_write_json(dest / "eval_summary.json", group_eval.get("summary", {}))
|
|
72
|
+
_write_json(dest / "eval_tasks.json", group_eval.get("results", []))
|
|
73
|
+
except Exception:
|
|
74
|
+
# Some groups are non-eval workloads, or older servers may not expose eval endpoints.
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
jobs_dir = dest / "jobs"
|
|
78
|
+
jobs_dir.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
|
|
80
|
+
workers = max(1, int(workers))
|
|
81
|
+
jobs = _list_group_jobs(client, group_id)
|
|
82
|
+
total = len(jobs)
|
|
83
|
+
done = 0
|
|
84
|
+
done_lock = threading.Lock()
|
|
85
|
+
|
|
86
|
+
if progress_callback is not None:
|
|
87
|
+
progress_callback(done, total, "", "listing jobs")
|
|
88
|
+
|
|
89
|
+
def _notify(job_id: str, stage: str) -> None:
|
|
90
|
+
if progress_callback is None:
|
|
91
|
+
return
|
|
92
|
+
with done_lock:
|
|
93
|
+
current_done = done
|
|
94
|
+
progress_callback(current_done, total, job_id, stage)
|
|
95
|
+
|
|
96
|
+
def _mark_done(job_id: str) -> None:
|
|
97
|
+
nonlocal done
|
|
98
|
+
with done_lock:
|
|
99
|
+
done += 1
|
|
100
|
+
current_done = done
|
|
101
|
+
if progress_callback is not None:
|
|
102
|
+
progress_callback(current_done, total, job_id, "done")
|
|
103
|
+
|
|
104
|
+
if workers == 1 or total <= 1:
|
|
105
|
+
for job in jobs:
|
|
106
|
+
job_id = job["job_id"]
|
|
107
|
+
_notify(job_id, "starting")
|
|
108
|
+
_export_job_into_dir(
|
|
109
|
+
client,
|
|
110
|
+
job_id,
|
|
111
|
+
jobs_dir / job_id,
|
|
112
|
+
job=job,
|
|
113
|
+
stage_callback=lambda stage, current_job_id=job_id: _notify(current_job_id, stage),
|
|
114
|
+
)
|
|
115
|
+
_mark_done(job_id)
|
|
116
|
+
return dest
|
|
117
|
+
|
|
118
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
|
|
119
|
+
future_map = {
|
|
120
|
+
executor.submit(
|
|
121
|
+
_export_job_with_fresh_client,
|
|
122
|
+
client.config,
|
|
123
|
+
job,
|
|
124
|
+
jobs_dir / job["job_id"],
|
|
125
|
+
lambda stage, current_job_id=job["job_id"]: _notify(current_job_id, stage),
|
|
126
|
+
): job["job_id"]
|
|
127
|
+
for job in jobs
|
|
128
|
+
}
|
|
129
|
+
for future in concurrent.futures.as_completed(future_map):
|
|
130
|
+
job_id = future_map[future]
|
|
131
|
+
future.result()
|
|
132
|
+
_mark_done(job_id)
|
|
133
|
+
|
|
134
|
+
return dest
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _list_group_jobs(client: APIClient, group_id: str) -> list[dict]:
|
|
138
|
+
"""Fetch all jobs in a group."""
|
|
139
|
+
skip = 0
|
|
140
|
+
limit = 200
|
|
141
|
+
jobs: list[dict] = []
|
|
142
|
+
|
|
143
|
+
while True:
|
|
144
|
+
page = client.list_jobs(group_id=group_id, skip=skip, limit=limit)
|
|
145
|
+
page_jobs = page.get("jobs", [])
|
|
146
|
+
jobs.extend(page_jobs)
|
|
147
|
+
if len(page_jobs) < limit:
|
|
148
|
+
return jobs
|
|
149
|
+
skip += limit
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _export_job_with_fresh_client(
|
|
153
|
+
config,
|
|
154
|
+
job: dict,
|
|
155
|
+
dest: Path,
|
|
156
|
+
stage_callback: Optional[StageCallback] = None,
|
|
157
|
+
) -> None:
|
|
158
|
+
"""Export one job with a dedicated client/session for thread safety."""
|
|
159
|
+
worker_client = APIClient(config)
|
|
160
|
+
_export_job_into_dir(worker_client, job["job_id"], dest, job=job, stage_callback=stage_callback)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _export_job_into_dir(
|
|
164
|
+
client: APIClient,
|
|
165
|
+
job_id: str,
|
|
166
|
+
dest: Path,
|
|
167
|
+
job: Optional[dict] = None,
|
|
168
|
+
stage_callback: Optional[StageCallback] = None,
|
|
169
|
+
) -> None:
|
|
170
|
+
"""Export one job into an existing directory."""
|
|
171
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
172
|
+
current_stage = "job metadata"
|
|
173
|
+
|
|
174
|
+
def _set_stage(stage: str) -> None:
|
|
175
|
+
nonlocal current_stage
|
|
176
|
+
current_stage = stage
|
|
177
|
+
if stage_callback is not None:
|
|
178
|
+
stage_callback(stage)
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
_set_stage("job metadata")
|
|
182
|
+
job_data = job or client.get_job(job_id)
|
|
183
|
+
|
|
184
|
+
_set_stage("metrics")
|
|
185
|
+
metrics = client.get_job_metrics(job_id)
|
|
186
|
+
|
|
187
|
+
_set_stage("artifact metadata")
|
|
188
|
+
artifact = _get_job_artifact(client, job_id)
|
|
189
|
+
|
|
190
|
+
_set_stage("logs")
|
|
191
|
+
logs = client.get_job_logs(job_id)
|
|
192
|
+
|
|
193
|
+
_set_stage("events")
|
|
194
|
+
events = client.get_job_events(job_id)
|
|
195
|
+
|
|
196
|
+
_set_stage("writing files")
|
|
197
|
+
_write_json(dest / "job.json", job_data)
|
|
198
|
+
_write_json(dest / "metrics.json", metrics)
|
|
199
|
+
_write_json(dest / "artifacts.json", artifact)
|
|
200
|
+
_write_text(dest / "events.txt", _format_events(events))
|
|
201
|
+
_write_logs(dest / "logs", logs)
|
|
202
|
+
|
|
203
|
+
artifact_url = artifact.get("url")
|
|
204
|
+
if artifact_url:
|
|
205
|
+
archive_path = dest / "result.tgz"
|
|
206
|
+
try:
|
|
207
|
+
_set_stage("downloading artifact")
|
|
208
|
+
_download_file(artifact_url, archive_path)
|
|
209
|
+
_set_stage("extracting artifact")
|
|
210
|
+
_extract_tgz(archive_path, dest / "artifacts")
|
|
211
|
+
except Exception as exc:
|
|
212
|
+
archive_path.unlink(missing_ok=True)
|
|
213
|
+
if _is_missing_artifact_error(exc):
|
|
214
|
+
_warn_missing_artifact(job_id)
|
|
215
|
+
else:
|
|
216
|
+
raise
|
|
217
|
+
except Exception as exc:
|
|
218
|
+
raise RuntimeError(f"Failed to export job {job_id} at stage '{current_stage}': {exc}") from exc
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _get_job_artifact(client: APIClient, job_id: str) -> dict:
|
|
222
|
+
"""Return artifact metadata for one job."""
|
|
223
|
+
items = client.get_job_artifacts([job_id])
|
|
224
|
+
return items[0] if items else {"job_id": job_id, "url": None}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _write_json(path: Path, data: object) -> None:
|
|
228
|
+
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _write_text(path: Path, content: str) -> None:
|
|
232
|
+
path.write_text(content, encoding="utf-8")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _write_logs(logs_dir: Path, logs: dict) -> None:
|
|
236
|
+
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
237
|
+
|
|
238
|
+
if "containers" in logs:
|
|
239
|
+
containers = logs.get("containers") or {}
|
|
240
|
+
else:
|
|
241
|
+
container = logs.get("container") or "main"
|
|
242
|
+
containers = {container: logs.get("logs", "")}
|
|
243
|
+
|
|
244
|
+
for container, content in containers.items():
|
|
245
|
+
filename = _sanitize_name(container or "default") + ".log"
|
|
246
|
+
_write_text(logs_dir / filename, str(content or ""))
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _format_events(events: dict) -> str:
|
|
250
|
+
if "containers" in events:
|
|
251
|
+
containers = events.get("containers") or {}
|
|
252
|
+
else:
|
|
253
|
+
container = events.get("container") or "default"
|
|
254
|
+
containers = {container: events.get("events", [])}
|
|
255
|
+
|
|
256
|
+
lines = []
|
|
257
|
+
for container in sorted(containers):
|
|
258
|
+
values = containers.get(container) or []
|
|
259
|
+
if lines:
|
|
260
|
+
lines.append("")
|
|
261
|
+
lines.append("[{}]".format(container))
|
|
262
|
+
lines.extend(str(item) for item in values)
|
|
263
|
+
return "\n".join(lines) + ("\n" if lines else "")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _download_file(url: str, dest: Path) -> None:
|
|
267
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
268
|
+
temp_path = dest.with_name(dest.name + ".part")
|
|
269
|
+
public_url = url
|
|
270
|
+
download_url = _prefer_internal_oss_url(url)
|
|
271
|
+
response = None
|
|
272
|
+
try:
|
|
273
|
+
try:
|
|
274
|
+
response = requests.get(download_url, timeout=_DOWNLOAD_TIMEOUT, stream=True)
|
|
275
|
+
response.raise_for_status()
|
|
276
|
+
except (requests.ConnectionError, requests.Timeout):
|
|
277
|
+
if response is not None:
|
|
278
|
+
response.close()
|
|
279
|
+
if download_url == public_url:
|
|
280
|
+
raise
|
|
281
|
+
response = requests.get(public_url, timeout=_DOWNLOAD_TIMEOUT, stream=True)
|
|
282
|
+
response.raise_for_status()
|
|
283
|
+
with temp_path.open("wb") as f:
|
|
284
|
+
for chunk in response.iter_content(chunk_size=_DOWNLOAD_CHUNK_SIZE):
|
|
285
|
+
if chunk:
|
|
286
|
+
f.write(chunk)
|
|
287
|
+
temp_path.replace(dest)
|
|
288
|
+
except Exception:
|
|
289
|
+
if temp_path.exists():
|
|
290
|
+
temp_path.unlink()
|
|
291
|
+
raise
|
|
292
|
+
finally:
|
|
293
|
+
if response is not None:
|
|
294
|
+
response.close()
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _prefer_internal_oss_url(url: str) -> str:
|
|
298
|
+
parsed = urlparse(url)
|
|
299
|
+
hostname = parsed.hostname
|
|
300
|
+
if not hostname:
|
|
301
|
+
return url
|
|
302
|
+
internal_host = _to_internal_oss_host(hostname)
|
|
303
|
+
if internal_host == hostname:
|
|
304
|
+
return url
|
|
305
|
+
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
|
306
|
+
if not can_connect(internal_host, port=port):
|
|
307
|
+
return url
|
|
308
|
+
netloc = internal_host
|
|
309
|
+
if parsed.port is not None:
|
|
310
|
+
netloc = f"{netloc}:{parsed.port}"
|
|
311
|
+
return urlunparse(parsed._replace(netloc=netloc))
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _to_internal_oss_host(hostname: str) -> str:
|
|
315
|
+
if ".oss-" not in hostname or hostname.endswith("-internal.aliyuncs.com"):
|
|
316
|
+
return hostname
|
|
317
|
+
suffix = ".aliyuncs.com"
|
|
318
|
+
if not hostname.endswith(suffix):
|
|
319
|
+
return hostname
|
|
320
|
+
return hostname[: -len(suffix)] + "-internal.aliyuncs.com"
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@lru_cache()
|
|
324
|
+
def can_connect(endpoint: str, port: int = 80, timeout: int = 1) -> bool:
|
|
325
|
+
"""Check whether the host is reachable."""
|
|
326
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
327
|
+
sock.settimeout(timeout)
|
|
328
|
+
try:
|
|
329
|
+
ip = socket.gethostbyname(endpoint)
|
|
330
|
+
sock.connect((ip, port))
|
|
331
|
+
return True
|
|
332
|
+
except (socket.timeout, socket.gaierror, socket.error):
|
|
333
|
+
return False
|
|
334
|
+
finally:
|
|
335
|
+
sock.close()
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _is_missing_artifact_error(exc: Exception) -> bool:
|
|
339
|
+
if isinstance(exc, requests.HTTPError):
|
|
340
|
+
response = exc.response
|
|
341
|
+
if response is not None:
|
|
342
|
+
if response.status_code == 404:
|
|
343
|
+
return True
|
|
344
|
+
try:
|
|
345
|
+
body = response.text or ""
|
|
346
|
+
except Exception:
|
|
347
|
+
body = ""
|
|
348
|
+
if "NoSuchKey" in body:
|
|
349
|
+
return True
|
|
350
|
+
return "NoSuchKey" in str(exc)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _warn_missing_artifact(job_id: str) -> None:
|
|
354
|
+
print(
|
|
355
|
+
f"[ap export] warning: artifact missing for job {job_id}; "
|
|
356
|
+
"skipping result.tgz download",
|
|
357
|
+
file=sys.stderr,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _extract_tgz(archive_path: Path, dest_dir: Path) -> None:
|
|
362
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
363
|
+
with tarfile.open(archive_path, "r:gz") as tar:
|
|
364
|
+
tar.extractall(dest_dir)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _sanitize_name(name: str) -> str:
|
|
368
|
+
return "".join(ch if ch.isalnum() or ch in ("-", "_", ".") else "-" for ch in name)
|
ap_client/waiter.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Polling helpers for waiting on jobs/groups."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Callable
|
|
8
|
+
|
|
9
|
+
from .api import APIClient
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def wait_for_job(
|
|
13
|
+
client: APIClient,
|
|
14
|
+
job_id: str,
|
|
15
|
+
interval: float = 5.0,
|
|
16
|
+
printer: Callable[[str], None] = print,
|
|
17
|
+
) -> dict:
|
|
18
|
+
"""Poll until a job reaches a terminal status."""
|
|
19
|
+
_TERMINAL = {"Succeeded", "Failed", "Cancelled", "Unknown"}
|
|
20
|
+
while True:
|
|
21
|
+
job = client.get_job(job_id)
|
|
22
|
+
_print_job_status(job, printer)
|
|
23
|
+
if job.get("status") in _TERMINAL:
|
|
24
|
+
return job
|
|
25
|
+
time.sleep(interval)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def wait_for_group(
|
|
29
|
+
client: APIClient,
|
|
30
|
+
group_id: str,
|
|
31
|
+
interval: float = 5.0,
|
|
32
|
+
printer: Callable[[str], None] = print,
|
|
33
|
+
) -> dict:
|
|
34
|
+
"""Poll until all jobs in a group are finished."""
|
|
35
|
+
while True:
|
|
36
|
+
group = client.get_group_stats(group_id)
|
|
37
|
+
_print_group_status(group, printer)
|
|
38
|
+
stats = group.get("stats") or {}
|
|
39
|
+
total = int(stats.get("total", 0) or 0)
|
|
40
|
+
finished = int(stats.get("finished", 0) or 0)
|
|
41
|
+
if total == 0 or finished >= total:
|
|
42
|
+
return group
|
|
43
|
+
time.sleep(interval)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _print_job_status(job: dict, printer: Callable[[str], None]) -> None:
|
|
47
|
+
refreshed_at = _now_str()
|
|
48
|
+
status = job.get("status", "Unknown")
|
|
49
|
+
failed_reason = job.get("failed_reason") or ""
|
|
50
|
+
extra = f" failed_reason={failed_reason}" if failed_reason else ""
|
|
51
|
+
printer(
|
|
52
|
+
f"[{refreshed_at}] job={job.get('job_id')} "
|
|
53
|
+
f"status={status}{extra}"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _print_group_status(group: dict, printer: Callable[[str], None]) -> None:
|
|
58
|
+
refreshed_at = _now_str()
|
|
59
|
+
stats = group.get("stats") or {}
|
|
60
|
+
printer(
|
|
61
|
+
f"[{refreshed_at}] group={group.get('group_id')} "
|
|
62
|
+
f"total={stats.get('total', 0)} queued={stats.get('queued', 0)} "
|
|
63
|
+
f"pending={stats.get('pending', 0)} running={stats.get('running', 0)} "
|
|
64
|
+
f"finished={stats.get('finished', 0)} succeeded={stats.get('succeeded', 0)} "
|
|
65
|
+
f"failed={stats.get('failed', 0)} cancelled={stats.get('cancelled', 0)}"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _now_str() -> str:
|
|
70
|
+
return datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S")
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
ap_client/__init__.py,sha256=X-OBg7lVh4rEKgCMF29G20FvxcezmiK4rxT0tKlyOd4,351
|
|
2
|
+
ap_client/api.py,sha256=5iG3Czg4YGvUZUd9NY_cfGCrUynsg3GL68BQB2nDp_0,17071
|
|
3
|
+
ap_client/cli.py,sha256=D6UZwOr-VV7YVJegMgrV3pefu3FpsfFo9-fCw-9Xjt0,36075
|
|
4
|
+
ap_client/config.py,sha256=-1a6dAyz4IhWX-jT-tHKvhmhXdZSIRhjWmf_wTejK4I,1977
|
|
5
|
+
ap_client/exporter.py,sha256=z0NUXt5GDwsaaDr87NtlgAgld4u6cn-OkalgZbOccTA,11732
|
|
6
|
+
ap_client/waiter.py,sha256=Iho34PZc0KzSUR2sLv0FS3Xwmb7JOJzjAqbIge_Gsfs,2200
|
|
7
|
+
ap_client-0.1.4.dev0.dist-info/METADATA,sha256=aqKeqmwBNUdNE3-88_3zQKPGk5OCXVATCPcFItyzZHI,238
|
|
8
|
+
ap_client-0.1.4.dev0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
9
|
+
ap_client-0.1.4.dev0.dist-info/entry_points.txt,sha256=gsL9nrj3zDkk6baW4tzt5NSDNAdQFyNNjKZbi3-wBbI,41
|
|
10
|
+
ap_client-0.1.4.dev0.dist-info/RECORD,,
|