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/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,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: ap-client
3
+ Version: 0.1.4.dev0
4
+ Summary: Agent Platform API Client & CLI
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: pyyaml>=6.0
7
+ Requires-Dist: requests>=2.28.0
8
+ Requires-Dist: rich>=13.0.0
9
+ Requires-Dist: typer>=0.9.0
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ap = ap_client.cli:app