ap-client 0.1.4.dev0__tar.gz → 0.1.5__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.
- ap_client-0.1.5/PKG-INFO +12 -0
- ap_client-0.1.5/ap_client/__init__.py +21 -0
- {ap_client-0.1.4.dev0 → ap_client-0.1.5}/ap_client/api.py +46 -11
- {ap_client-0.1.4.dev0 → ap_client-0.1.5}/ap_client/cli.py +134 -34
- {ap_client-0.1.4.dev0 → ap_client-0.1.5}/ap_client/config.py +24 -4
- {ap_client-0.1.4.dev0 → ap_client-0.1.5}/ap_client/exporter.py +26 -6
- {ap_client-0.1.4.dev0 → ap_client-0.1.5}/ap_client/waiter.py +64 -1
- {ap_client-0.1.4.dev0 → ap_client-0.1.5}/pyproject.toml +7 -1
- ap_client-0.1.4.dev0/.gitignore +0 -235
- ap_client-0.1.4.dev0/PKG-INFO +0 -9
- ap_client-0.1.4.dev0/ap_client/__init__.py +0 -13
ap_client-0.1.5/PKG-INFO
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ap-client
|
|
3
|
+
Version: 0.1.5
|
|
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
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
A lightweight Python SDK and command line interface for Agent Platform. It provides helpers for configuring API access and managing templates, datasets, jobs, and groups.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Agent Platform API Client"""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from .api import APIClient, APIError, get_client
|
|
6
|
+
from .config import Config, ConfigurationError, get_config
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
__version__ = version("ap-client")
|
|
10
|
+
except PackageNotFoundError:
|
|
11
|
+
__version__ = "0+unknown"
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"APIClient",
|
|
15
|
+
"APIError",
|
|
16
|
+
"Config",
|
|
17
|
+
"ConfigurationError",
|
|
18
|
+
"__version__",
|
|
19
|
+
"get_client",
|
|
20
|
+
"get_config",
|
|
21
|
+
]
|
|
@@ -10,7 +10,7 @@ from urllib.parse import quote
|
|
|
10
10
|
|
|
11
11
|
import requests
|
|
12
12
|
|
|
13
|
-
from .config import Config, get_config
|
|
13
|
+
from .config import Config, ConfigurationError, get_config, has_cluster_header
|
|
14
14
|
|
|
15
15
|
_GROUP_ARTIFACTS_PAGE_SIZE = 500
|
|
16
16
|
_VERBOSE_SAMPLE_KEYS = 5
|
|
@@ -18,6 +18,28 @@ _VERBOSE_SAMPLE_ITEMS = 3
|
|
|
18
18
|
_VERBOSE_VALUE_PREVIEW = 120
|
|
19
19
|
|
|
20
20
|
|
|
21
|
+
class APIError(Exception):
|
|
22
|
+
"""Raised when an API request fails."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
status_code: Optional[int],
|
|
27
|
+
detail: Any,
|
|
28
|
+
request_id: str,
|
|
29
|
+
response: Optional[requests.Response],
|
|
30
|
+
):
|
|
31
|
+
self.status_code = status_code
|
|
32
|
+
self.detail = detail
|
|
33
|
+
self.request_id = request_id
|
|
34
|
+
self.response = response
|
|
35
|
+
super().__init__(self._build_message())
|
|
36
|
+
|
|
37
|
+
def _build_message(self) -> str:
|
|
38
|
+
if self.status_code is None:
|
|
39
|
+
return f"API Network Error: {self.detail} (request_id={self.request_id})"
|
|
40
|
+
return f"API Error {self.status_code}: {self.detail} (request_id={self.request_id})"
|
|
41
|
+
|
|
42
|
+
|
|
21
43
|
class APIClient:
|
|
22
44
|
"""Agent Platform API client."""
|
|
23
45
|
|
|
@@ -55,6 +77,7 @@ class APIClient:
|
|
|
55
77
|
timeout: float = 30,
|
|
56
78
|
) -> Any:
|
|
57
79
|
"""Send an HTTP request with X-Request-ID and optional verbose logging."""
|
|
80
|
+
self._require_cluster_config()
|
|
58
81
|
url = self._url(path)
|
|
59
82
|
request_id = str(uuid.uuid4())
|
|
60
83
|
headers = {"X-Request-ID": request_id}
|
|
@@ -71,27 +94,29 @@ class APIClient:
|
|
|
71
94
|
headers=headers,
|
|
72
95
|
timeout=timeout,
|
|
73
96
|
)
|
|
74
|
-
except
|
|
97
|
+
except requests.RequestException as exc:
|
|
75
98
|
if verbose:
|
|
76
99
|
duration_ms = (time.perf_counter() - start) * 1000
|
|
77
100
|
self._log_exception(method, url, request_id, duration_ms, exc)
|
|
78
|
-
raise
|
|
101
|
+
raise APIError(None, str(exc), request_id, None) from exc
|
|
79
102
|
duration_ms = (time.perf_counter() - start) * 1000
|
|
80
103
|
if verbose:
|
|
81
104
|
self._log_response(method, url, request_id, resp, duration_ms)
|
|
82
105
|
if not resp.ok:
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
detail = err_data.get("detail", resp.text)
|
|
86
|
-
except Exception:
|
|
87
|
-
detail = resp.text
|
|
88
|
-
raise Exception(
|
|
89
|
-
f"API Error {resp.status_code}: {detail} (request_id={request_id})"
|
|
90
|
-
)
|
|
106
|
+
detail = _response_error_detail(resp)
|
|
107
|
+
raise APIError(resp.status_code, detail, request_id, resp)
|
|
91
108
|
if not resp.content:
|
|
92
109
|
return None
|
|
93
110
|
return resp.json()
|
|
94
111
|
|
|
112
|
+
def _require_cluster_config(self) -> None:
|
|
113
|
+
if has_cluster_header(self.config.headers):
|
|
114
|
+
return
|
|
115
|
+
raise ConfigurationError(
|
|
116
|
+
"Missing cluster configuration. Set AP_CLUSTER (e.g. export AP_CLUSTER=test) "
|
|
117
|
+
"or include a non-empty X-Cluster in AP_HEADERS (e.g. export AP_HEADERS='X-Cluster: test')."
|
|
118
|
+
)
|
|
119
|
+
|
|
95
120
|
def _get(self, path: str, params: Optional[dict] = None) -> Any:
|
|
96
121
|
"""Send a GET request."""
|
|
97
122
|
return self._request("GET", path, params=params)
|
|
@@ -476,6 +501,16 @@ def _safe_json(value: Any) -> str:
|
|
|
476
501
|
return str(value)
|
|
477
502
|
|
|
478
503
|
|
|
504
|
+
def _response_error_detail(resp: requests.Response) -> Any:
|
|
505
|
+
try:
|
|
506
|
+
err_data = resp.json()
|
|
507
|
+
except Exception:
|
|
508
|
+
return resp.text
|
|
509
|
+
if isinstance(err_data, dict):
|
|
510
|
+
return err_data.get("detail", resp.text)
|
|
511
|
+
return err_data
|
|
512
|
+
|
|
513
|
+
|
|
479
514
|
def _safe_decode(raw: bytes) -> str:
|
|
480
515
|
try:
|
|
481
516
|
return raw.decode("utf-8", errors="replace")
|
|
@@ -11,14 +11,21 @@ from rich.console import Console
|
|
|
11
11
|
|
|
12
12
|
from ap_client import __version__, get_client, get_config
|
|
13
13
|
from ap_client.api import set_verbose_override
|
|
14
|
+
from ap_client.config import _parse_bool
|
|
14
15
|
from ap_client.exporter import export_group, export_job
|
|
15
|
-
from ap_client.waiter import wait_for_group, wait_for_job
|
|
16
|
+
from ap_client.waiter import WaitTimeoutError, wait_for_group, wait_for_job
|
|
17
|
+
|
|
18
|
+
# Verbose mode (AP_VERBOSE / --verbose) also controls whether Typer's pretty
|
|
19
|
+
# traceback dumps local variables on unhandled exceptions. Default is off to
|
|
20
|
+
# avoid leaking large payloads (URLs, events, job dicts) to end users.
|
|
21
|
+
_INITIAL_VERBOSE = _parse_bool(os.environ.get("AP_VERBOSE"))
|
|
16
22
|
|
|
17
23
|
app = typer.Typer(
|
|
18
24
|
name="ap",
|
|
19
25
|
help="Agent Platform CLI - minimal job submission tool",
|
|
20
26
|
add_completion=False,
|
|
21
27
|
no_args_is_help=True,
|
|
28
|
+
pretty_exceptions_show_locals=_INITIAL_VERBOSE,
|
|
22
29
|
)
|
|
23
30
|
console = Console()
|
|
24
31
|
|
|
@@ -48,6 +55,8 @@ def _global_options(
|
|
|
48
55
|
del version
|
|
49
56
|
if verbose:
|
|
50
57
|
set_verbose_override(True)
|
|
58
|
+
# Keep traceback-locals behavior in sync with the HTTP verbose switch.
|
|
59
|
+
app.pretty_exceptions_show_locals = True
|
|
51
60
|
|
|
52
61
|
# Resource sub-command groups
|
|
53
62
|
template_app = typer.Typer(help="Template operations")
|
|
@@ -79,6 +88,14 @@ def _emit_info(message: str, output_format: str = "plain") -> None:
|
|
|
79
88
|
typer.echo(message, err=output_format != "plain")
|
|
80
89
|
|
|
81
90
|
|
|
91
|
+
def _format_wait_timeout(timeout: Optional[float]) -> str:
|
|
92
|
+
if timeout is None:
|
|
93
|
+
return "none"
|
|
94
|
+
if timeout == int(timeout):
|
|
95
|
+
return f"{int(timeout)}s"
|
|
96
|
+
return f"{timeout:g}s"
|
|
97
|
+
|
|
98
|
+
|
|
82
99
|
def _normalize_output_format(output_format: str) -> str:
|
|
83
100
|
value = output_format.lower()
|
|
84
101
|
if value == "text":
|
|
@@ -158,8 +175,12 @@ def _runtime_job_tags() -> list[str]:
|
|
|
158
175
|
|
|
159
176
|
|
|
160
177
|
def _print_job_list_plain(result: dict) -> None:
|
|
178
|
+
jobs = result.get("jobs", [])
|
|
179
|
+
for row in jobs:
|
|
180
|
+
if "tags" in row and isinstance(row["tags"], list):
|
|
181
|
+
row["tags"] = ",".join(row["tags"])
|
|
161
182
|
_print_plain_table(
|
|
162
|
-
|
|
183
|
+
jobs,
|
|
163
184
|
[
|
|
164
185
|
("job_id", "job_id"),
|
|
165
186
|
("template", "template"),
|
|
@@ -167,6 +188,7 @@ def _print_job_list_plain(result: dict) -> None:
|
|
|
167
188
|
("group_id", "group_id"),
|
|
168
189
|
("status", "status"),
|
|
169
190
|
("failed_reason", "failed_reason"),
|
|
191
|
+
("tags", "tags"),
|
|
170
192
|
("created_at", "created_at"),
|
|
171
193
|
],
|
|
172
194
|
)
|
|
@@ -284,11 +306,29 @@ def _mask_config_headers(headers: dict) -> dict:
|
|
|
284
306
|
return masked
|
|
285
307
|
|
|
286
308
|
|
|
309
|
+
def _extract_cluster(headers: dict) -> str | None:
|
|
310
|
+
for key, value in headers.items():
|
|
311
|
+
if key.lower() == "x-cluster":
|
|
312
|
+
return value
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
|
|
287
316
|
def _config_payload() -> dict:
|
|
288
|
-
|
|
317
|
+
from ap_client import api as _api
|
|
318
|
+
|
|
319
|
+
config = get_config(verbose=_api._verbose_override)
|
|
320
|
+
ap_cluster = (
|
|
321
|
+
os.environ.get("AP_CLUSTER", "").strip()
|
|
322
|
+
or _extract_cluster(config.headers)
|
|
323
|
+
or None
|
|
324
|
+
)
|
|
289
325
|
return {
|
|
290
326
|
"base_url": config.base_url,
|
|
327
|
+
"ap_cluster": ap_cluster,
|
|
291
328
|
"agenthub_ref": config.agenthub_ref,
|
|
329
|
+
"ap_verbose": config.verbose,
|
|
330
|
+
"ap_verbose_body_limit": config.verbose_body_limit,
|
|
331
|
+
"ap_verbose_full_body": config.verbose_full_body,
|
|
292
332
|
"headers": _mask_config_headers(config.headers),
|
|
293
333
|
}
|
|
294
334
|
|
|
@@ -303,7 +343,11 @@ def show_config(
|
|
|
303
343
|
if output_format == "plain":
|
|
304
344
|
rows = [
|
|
305
345
|
{"key": "base_url", "value": payload["base_url"]},
|
|
346
|
+
{"key": "ap_cluster", "value": payload["ap_cluster"]},
|
|
306
347
|
{"key": "agenthub_ref", "value": payload["agenthub_ref"]},
|
|
348
|
+
{"key": "ap_verbose", "value": str(payload["ap_verbose"]).lower()},
|
|
349
|
+
{"key": "ap_verbose_body_limit", "value": payload["ap_verbose_body_limit"]},
|
|
350
|
+
{"key": "ap_verbose_full_body", "value": str(payload["ap_verbose_full_body"]).lower()},
|
|
307
351
|
{"key": "headers", "value": json.dumps(payload["headers"], ensure_ascii=False)},
|
|
308
352
|
]
|
|
309
353
|
_print_plain_table(rows, [("key", "key"), ("value", "value")])
|
|
@@ -468,6 +512,12 @@ def job_create(
|
|
|
468
512
|
wait_interval: float = typer.Option(
|
|
469
513
|
5.0, "--wait-interval", min=0.1, help="Polling interval in seconds when used with --wait"
|
|
470
514
|
),
|
|
515
|
+
wait_timeout: Optional[float] = typer.Option(
|
|
516
|
+
None,
|
|
517
|
+
"--wait-timeout",
|
|
518
|
+
min=0.0,
|
|
519
|
+
help="Maximum seconds to wait when used with --wait; omit to wait indefinitely",
|
|
520
|
+
),
|
|
471
521
|
output_format: str = typer.Option("plain", "--format", help="Output format: plain/json/yaml"),
|
|
472
522
|
enable_otel_tracing: Optional[bool] = typer.Option(
|
|
473
523
|
None,
|
|
@@ -675,35 +725,63 @@ def job_create(
|
|
|
675
725
|
print(f" group_id: {result.get('group_id')}")
|
|
676
726
|
|
|
677
727
|
if wait:
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
728
|
+
try:
|
|
729
|
+
group_id = result.get("group_id")
|
|
730
|
+
if group_id:
|
|
731
|
+
_emit_info(
|
|
732
|
+
f"Waiting for group to finish: {group_id} "
|
|
733
|
+
f"(interval={wait_interval}s, timeout={_format_wait_timeout(wait_timeout)})",
|
|
734
|
+
output_format,
|
|
735
|
+
)
|
|
736
|
+
final_group = wait_for_group(
|
|
737
|
+
client,
|
|
738
|
+
group_id,
|
|
739
|
+
wait_interval,
|
|
740
|
+
timeout=wait_timeout,
|
|
741
|
+
printer=wait_printer,
|
|
742
|
+
)
|
|
743
|
+
wait_result = {
|
|
744
|
+
"type": "group",
|
|
745
|
+
"interval": wait_interval,
|
|
746
|
+
"timeout": wait_timeout,
|
|
747
|
+
"result": final_group,
|
|
748
|
+
}
|
|
749
|
+
if output_format == "plain":
|
|
750
|
+
stats = final_group.get("stats") or {}
|
|
751
|
+
print(f"[green]Group finished:[/] {group_id}")
|
|
752
|
+
print(f" total: {stats.get('total', 0)}")
|
|
753
|
+
print(f" succeeded: {stats.get('succeeded', 0)}")
|
|
754
|
+
print(f" failed: {stats.get('failed', 0)}")
|
|
755
|
+
print(f" cancelled: {stats.get('cancelled', 0)}")
|
|
756
|
+
else:
|
|
757
|
+
job = result.get("jobs", [{}])[0]
|
|
758
|
+
job_id = job.get("job_id")
|
|
759
|
+
_emit_info(
|
|
760
|
+
f"Waiting for job to finish: {job_id} "
|
|
761
|
+
f"(interval={wait_interval}s, timeout={_format_wait_timeout(wait_timeout)})",
|
|
762
|
+
output_format,
|
|
763
|
+
)
|
|
764
|
+
final_job = wait_for_job(
|
|
765
|
+
client,
|
|
766
|
+
job_id,
|
|
767
|
+
wait_interval,
|
|
768
|
+
timeout=wait_timeout,
|
|
769
|
+
printer=wait_printer,
|
|
770
|
+
)
|
|
771
|
+
wait_result = {
|
|
772
|
+
"type": "job",
|
|
773
|
+
"interval": wait_interval,
|
|
774
|
+
"timeout": wait_timeout,
|
|
775
|
+
"result": final_job,
|
|
776
|
+
}
|
|
777
|
+
if output_format == "plain":
|
|
778
|
+
print(f"[green]Job finished:[/] {job_id}")
|
|
779
|
+
print(f" status: {final_job.get('status')}")
|
|
780
|
+
if final_job.get("failed_reason"):
|
|
781
|
+
print(f" failed_reason: {final_job.get('failed_reason')}")
|
|
782
|
+
except WaitTimeoutError as exc:
|
|
783
|
+
typer.echo(f"error: {exc}", err=True)
|
|
784
|
+
raise typer.Exit(1)
|
|
707
785
|
|
|
708
786
|
if output_format != "plain":
|
|
709
787
|
payload = {"submission": result}
|
|
@@ -808,10 +886,21 @@ def job_wait(
|
|
|
808
886
|
interval: float = typer.Option(
|
|
809
887
|
5.0, "--interval", "-i", min=0.1, help="Polling interval (seconds)"
|
|
810
888
|
),
|
|
889
|
+
timeout: Optional[float] = typer.Option(
|
|
890
|
+
None,
|
|
891
|
+
"--timeout",
|
|
892
|
+
"--wait-timeout",
|
|
893
|
+
min=0.0,
|
|
894
|
+
help="Maximum seconds to wait; omit to wait indefinitely",
|
|
895
|
+
),
|
|
811
896
|
):
|
|
812
897
|
"""Poll until the job finishes."""
|
|
813
898
|
client = get_client()
|
|
814
|
-
|
|
899
|
+
try:
|
|
900
|
+
result = wait_for_job(client, job_id, interval, timeout=timeout)
|
|
901
|
+
except WaitTimeoutError as exc:
|
|
902
|
+
typer.echo(f"error: {exc}", err=True)
|
|
903
|
+
raise typer.Exit(1)
|
|
815
904
|
print(f"[green]Job finished:[/] {job_id}")
|
|
816
905
|
print(f" status: {result.get('status')}")
|
|
817
906
|
if result.get("failed_reason"):
|
|
@@ -1000,10 +1089,21 @@ def group_wait(
|
|
|
1000
1089
|
interval: float = typer.Option(
|
|
1001
1090
|
5.0, "--interval", "-i", min=0.1, help="Polling interval (seconds)"
|
|
1002
1091
|
),
|
|
1092
|
+
timeout: Optional[float] = typer.Option(
|
|
1093
|
+
None,
|
|
1094
|
+
"--timeout",
|
|
1095
|
+
"--wait-timeout",
|
|
1096
|
+
min=0.0,
|
|
1097
|
+
help="Maximum seconds to wait; omit to wait indefinitely",
|
|
1098
|
+
),
|
|
1003
1099
|
):
|
|
1004
1100
|
"""Poll until all jobs under a Group finish."""
|
|
1005
1101
|
client = get_client()
|
|
1006
|
-
|
|
1102
|
+
try:
|
|
1103
|
+
result = wait_for_group(client, group_id, interval, timeout=timeout)
|
|
1104
|
+
except WaitTimeoutError as exc:
|
|
1105
|
+
typer.echo(f"error: {exc}", err=True)
|
|
1106
|
+
raise typer.Exit(1)
|
|
1007
1107
|
stats = result.get("stats") or {}
|
|
1008
1108
|
print(f"[green]Group finished:[/] {group_id}")
|
|
1009
1109
|
print(f" total: {stats.get('total', 0)}")
|
|
@@ -3,6 +3,13 @@
|
|
|
3
3
|
import os
|
|
4
4
|
from dataclasses import dataclass
|
|
5
5
|
|
|
6
|
+
DEFAULT_BASE_URL = "http://agentplatform.aliyun-inc.com"
|
|
7
|
+
CLUSTER_HEADER = "X-Cluster"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConfigurationError(Exception):
|
|
11
|
+
"""Raised when required client configuration is missing or invalid."""
|
|
12
|
+
|
|
6
13
|
|
|
7
14
|
def _parse_bool(value: str | None) -> bool:
|
|
8
15
|
"""Parse a boolean environment variable."""
|
|
@@ -21,6 +28,17 @@ def _parse_positive_int(value: str | None, default: int) -> int:
|
|
|
21
28
|
return parsed
|
|
22
29
|
|
|
23
30
|
|
|
31
|
+
def has_cluster_header(headers: dict[str, str]) -> bool:
|
|
32
|
+
return any(key.lower() == "x-cluster" and value.strip() for key, value in headers.items())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _set_cluster_header(headers: dict[str, str], cluster: str) -> None:
|
|
36
|
+
for key in list(headers):
|
|
37
|
+
if key.lower() == "x-cluster":
|
|
38
|
+
del headers[key]
|
|
39
|
+
headers[CLUSTER_HEADER] = cluster
|
|
40
|
+
|
|
41
|
+
|
|
24
42
|
@dataclass
|
|
25
43
|
class Config:
|
|
26
44
|
"""CLI configuration."""
|
|
@@ -35,11 +53,9 @@ class Config:
|
|
|
35
53
|
@classmethod
|
|
36
54
|
def from_env(cls) -> "Config":
|
|
37
55
|
"""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")
|
|
56
|
+
base_url = os.environ.get("AP_BASE_URL") or DEFAULT_BASE_URL
|
|
41
57
|
|
|
42
|
-
headers = {}
|
|
58
|
+
headers: dict[str, str] = {}
|
|
43
59
|
headers_str = os.environ.get("AP_HEADERS", "")
|
|
44
60
|
if headers_str:
|
|
45
61
|
for h in headers_str.split(","):
|
|
@@ -47,6 +63,10 @@ class Config:
|
|
|
47
63
|
k, v = h.split(":", 1)
|
|
48
64
|
headers[k.strip()] = v.strip()
|
|
49
65
|
|
|
66
|
+
cluster = os.environ.get("AP_CLUSTER", "").strip()
|
|
67
|
+
if cluster:
|
|
68
|
+
_set_cluster_header(headers, cluster)
|
|
69
|
+
|
|
50
70
|
return cls(
|
|
51
71
|
base_url=base_url.rstrip("/"),
|
|
52
72
|
headers=headers,
|
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
"""Export job/group data from Agent Platform to local directories."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
|
+
|
|
4
5
|
import concurrent.futures
|
|
5
|
-
from functools import lru_cache
|
|
6
6
|
import json
|
|
7
7
|
import os
|
|
8
|
+
import shutil
|
|
8
9
|
import socket
|
|
9
10
|
import sys
|
|
10
11
|
import tarfile
|
|
11
12
|
import threading
|
|
13
|
+
from functools import lru_cache
|
|
12
14
|
from pathlib import Path
|
|
13
15
|
from typing import Callable, Optional
|
|
14
16
|
from urllib.parse import urlparse, urlunparse
|
|
15
17
|
|
|
16
18
|
import requests
|
|
17
19
|
|
|
18
|
-
from .api import APIClient
|
|
20
|
+
from .api import APIClient, APIError
|
|
19
21
|
|
|
20
22
|
DEFAULT_CACHE_DIR = "~/.cache/agentplatform"
|
|
21
23
|
_DOWNLOAD_TIMEOUT = (10, 300)
|
|
@@ -215,7 +217,9 @@ def _export_job_into_dir(
|
|
|
215
217
|
else:
|
|
216
218
|
raise
|
|
217
219
|
except Exception as exc:
|
|
218
|
-
raise RuntimeError(
|
|
220
|
+
raise RuntimeError(
|
|
221
|
+
f"Failed to export job {job_id} at stage '{current_stage}': {exc}"
|
|
222
|
+
) from exc
|
|
219
223
|
|
|
220
224
|
|
|
221
225
|
def _get_job_artifact(client: APIClient, job_id: str) -> dict:
|
|
@@ -336,6 +340,10 @@ def can_connect(endpoint: str, port: int = 80, timeout: int = 1) -> bool:
|
|
|
336
340
|
|
|
337
341
|
|
|
338
342
|
def _is_missing_artifact_error(exc: Exception) -> bool:
|
|
343
|
+
if isinstance(exc, APIError):
|
|
344
|
+
if exc.status_code == 404:
|
|
345
|
+
return True
|
|
346
|
+
return "NoSuchKey" in str(exc.detail)
|
|
339
347
|
if isinstance(exc, requests.HTTPError):
|
|
340
348
|
response = exc.response
|
|
341
349
|
if response is not None:
|
|
@@ -352,16 +360,28 @@ def _is_missing_artifact_error(exc: Exception) -> bool:
|
|
|
352
360
|
|
|
353
361
|
def _warn_missing_artifact(job_id: str) -> None:
|
|
354
362
|
print(
|
|
355
|
-
f"[ap export] warning: artifact missing for job {job_id}; "
|
|
356
|
-
"skipping result.tgz download",
|
|
363
|
+
f"[ap export] warning: artifact missing for job {job_id}; skipping result.tgz download",
|
|
357
364
|
file=sys.stderr,
|
|
358
365
|
)
|
|
359
366
|
|
|
360
367
|
|
|
361
368
|
def _extract_tgz(archive_path: Path, dest_dir: Path) -> None:
|
|
369
|
+
# Wipe the destination first so that a previous extraction cannot leave
|
|
370
|
+
# behind a symlink that a re-extraction would follow outside ``dest_dir``.
|
|
371
|
+
if dest_dir.exists():
|
|
372
|
+
shutil.rmtree(dest_dir, ignore_errors=True)
|
|
362
373
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
363
374
|
with tarfile.open(archive_path, "r:gz") as tar:
|
|
364
|
-
tar
|
|
375
|
+
_safe_extractall(tar, dest_dir)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _safe_extractall(tar: tarfile.TarFile, dest_dir: Path) -> None:
|
|
379
|
+
root = dest_dir.resolve()
|
|
380
|
+
for member in tar.getmembers():
|
|
381
|
+
member_path = (root / member.name).resolve()
|
|
382
|
+
if os.path.commonpath([str(root), str(member_path)]) != str(root):
|
|
383
|
+
raise ValueError(f"Unsafe artifact archive path: {member.name} {member_path}")
|
|
384
|
+
tar.extractall(dest_dir)
|
|
365
385
|
|
|
366
386
|
|
|
367
387
|
def _sanitize_name(name: str) -> str:
|
|
@@ -9,30 +9,52 @@ from typing import Callable
|
|
|
9
9
|
from .api import APIClient
|
|
10
10
|
|
|
11
11
|
|
|
12
|
+
class WaitTimeoutError(TimeoutError):
|
|
13
|
+
"""Raised when waiting for a job or group exceeds the configured timeout."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, resource_type: str, resource_id: str, timeout: float):
|
|
16
|
+
self.resource_type = resource_type
|
|
17
|
+
self.resource_id = resource_id
|
|
18
|
+
self.timeout = timeout
|
|
19
|
+
super().__init__(
|
|
20
|
+
f"timed out waiting for {resource_type} {resource_id} after {_format_seconds(timeout)}"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
12
24
|
def wait_for_job(
|
|
13
25
|
client: APIClient,
|
|
14
26
|
job_id: str,
|
|
15
27
|
interval: float = 5.0,
|
|
28
|
+
timeout: float | None = None,
|
|
16
29
|
printer: Callable[[str], None] = print,
|
|
17
30
|
) -> dict:
|
|
18
31
|
"""Poll until a job reaches a terminal status."""
|
|
19
32
|
_TERMINAL = {"Succeeded", "Failed", "Cancelled", "Unknown"}
|
|
33
|
+
deadline = _deadline(timeout)
|
|
34
|
+
first_poll = True
|
|
20
35
|
while True:
|
|
36
|
+
_raise_if_timed_out(deadline, first_poll, "job", job_id, timeout)
|
|
37
|
+
first_poll = False
|
|
21
38
|
job = client.get_job(job_id)
|
|
22
39
|
_print_job_status(job, printer)
|
|
23
40
|
if job.get("status") in _TERMINAL:
|
|
24
41
|
return job
|
|
25
|
-
|
|
42
|
+
_sleep_until_next_poll(interval, deadline, "job", job_id, timeout)
|
|
26
43
|
|
|
27
44
|
|
|
28
45
|
def wait_for_group(
|
|
29
46
|
client: APIClient,
|
|
30
47
|
group_id: str,
|
|
31
48
|
interval: float = 5.0,
|
|
49
|
+
timeout: float | None = None,
|
|
32
50
|
printer: Callable[[str], None] = print,
|
|
33
51
|
) -> dict:
|
|
34
52
|
"""Poll until all jobs in a group are finished."""
|
|
53
|
+
deadline = _deadline(timeout)
|
|
54
|
+
first_poll = True
|
|
35
55
|
while True:
|
|
56
|
+
_raise_if_timed_out(deadline, first_poll, "group", group_id, timeout)
|
|
57
|
+
first_poll = False
|
|
36
58
|
group = client.get_group_stats(group_id)
|
|
37
59
|
_print_group_status(group, printer)
|
|
38
60
|
stats = group.get("stats") or {}
|
|
@@ -40,7 +62,42 @@ def wait_for_group(
|
|
|
40
62
|
finished = int(stats.get("finished", 0) or 0)
|
|
41
63
|
if total == 0 or finished >= total:
|
|
42
64
|
return group
|
|
65
|
+
_sleep_until_next_poll(interval, deadline, "group", group_id, timeout)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _deadline(timeout: float | None) -> float | None:
|
|
69
|
+
if timeout is None:
|
|
70
|
+
return None
|
|
71
|
+
return time.monotonic() + timeout
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _raise_if_timed_out(
|
|
75
|
+
deadline: float | None,
|
|
76
|
+
first_poll: bool,
|
|
77
|
+
resource_type: str,
|
|
78
|
+
resource_id: str,
|
|
79
|
+
timeout: float | None,
|
|
80
|
+
) -> None:
|
|
81
|
+
if deadline is None or first_poll:
|
|
82
|
+
return
|
|
83
|
+
if time.monotonic() >= deadline:
|
|
84
|
+
raise WaitTimeoutError(resource_type, resource_id, timeout or 0.0)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _sleep_until_next_poll(
|
|
88
|
+
interval: float,
|
|
89
|
+
deadline: float | None,
|
|
90
|
+
resource_type: str,
|
|
91
|
+
resource_id: str,
|
|
92
|
+
timeout: float | None,
|
|
93
|
+
) -> None:
|
|
94
|
+
if deadline is None:
|
|
43
95
|
time.sleep(interval)
|
|
96
|
+
return
|
|
97
|
+
remaining = deadline - time.monotonic()
|
|
98
|
+
if remaining <= 0:
|
|
99
|
+
raise WaitTimeoutError(resource_type, resource_id, timeout or 0.0)
|
|
100
|
+
time.sleep(min(interval, remaining))
|
|
44
101
|
|
|
45
102
|
|
|
46
103
|
def _print_job_status(job: dict, printer: Callable[[str], None]) -> None:
|
|
@@ -68,3 +125,9 @@ def _print_group_status(group: dict, printer: Callable[[str], None]) -> None:
|
|
|
68
125
|
|
|
69
126
|
def _now_str() -> str:
|
|
70
127
|
return datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _format_seconds(value: float) -> str:
|
|
131
|
+
if value == int(value):
|
|
132
|
+
return f"{int(value)}s"
|
|
133
|
+
return f"{value:g}s"
|
|
@@ -4,8 +4,9 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ap-client"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.5"
|
|
8
8
|
description = "Agent Platform API Client & CLI"
|
|
9
|
+
readme = { text = "A lightweight Python SDK and command line interface for Agent Platform. It provides helpers for configuring API access and managing templates, datasets, jobs, and groups.", content-type = "text/markdown" }
|
|
9
10
|
requires-python = ">=3.10"
|
|
10
11
|
dependencies = [
|
|
11
12
|
"typer>=0.9.0",
|
|
@@ -35,3 +36,8 @@ only-include = [
|
|
|
35
36
|
|
|
36
37
|
[tool.hatch.build.targets.wheel]
|
|
37
38
|
packages = ["ap_client"]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
pythonpath = ["."]
|
|
42
|
+
testpaths = ["tests"]
|
|
43
|
+
markers = ["e2e: end-to-end tests that require external services"]
|
ap_client-0.1.4.dev0/.gitignore
DELETED
|
@@ -1,235 +0,0 @@
|
|
|
1
|
-
|
|
2
|
-
# Local config (never commit — copy from .env.example)
|
|
3
|
-
.env
|
|
4
|
-
tests/integration/.env
|
|
5
|
-
|
|
6
|
-
# Byte-compiled / optimized / DLL files
|
|
7
|
-
__pycache__/
|
|
8
|
-
*.py[codz]
|
|
9
|
-
*$py.class
|
|
10
|
-
|
|
11
|
-
# C extensions
|
|
12
|
-
*.so
|
|
13
|
-
|
|
14
|
-
# Distribution / packaging
|
|
15
|
-
.Python
|
|
16
|
-
build/
|
|
17
|
-
develop-eggs/
|
|
18
|
-
dist/
|
|
19
|
-
downloads/
|
|
20
|
-
eggs/
|
|
21
|
-
.eggs/
|
|
22
|
-
lib/
|
|
23
|
-
lib64/
|
|
24
|
-
parts/
|
|
25
|
-
sdist/
|
|
26
|
-
var/
|
|
27
|
-
wheels/
|
|
28
|
-
share/python-wheels/
|
|
29
|
-
*.egg-info/
|
|
30
|
-
.installed.cfg
|
|
31
|
-
*.egg
|
|
32
|
-
MANIFEST
|
|
33
|
-
|
|
34
|
-
# PyInstaller
|
|
35
|
-
# Usually these files are written by a python script from a template
|
|
36
|
-
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
37
|
-
*.manifest
|
|
38
|
-
*.spec
|
|
39
|
-
|
|
40
|
-
# Installer logs
|
|
41
|
-
pip-log.txt
|
|
42
|
-
pip-delete-this-directory.txt
|
|
43
|
-
|
|
44
|
-
# Unit test / coverage reports
|
|
45
|
-
htmlcov/
|
|
46
|
-
.tox/
|
|
47
|
-
.nox/
|
|
48
|
-
.coverage
|
|
49
|
-
.coverage.*
|
|
50
|
-
.cache
|
|
51
|
-
nosetests.xml
|
|
52
|
-
coverage.xml
|
|
53
|
-
*.cover
|
|
54
|
-
*.py.cover
|
|
55
|
-
.hypothesis/
|
|
56
|
-
.pytest_cache/
|
|
57
|
-
cover/
|
|
58
|
-
|
|
59
|
-
# Translations
|
|
60
|
-
*.mo
|
|
61
|
-
*.pot
|
|
62
|
-
|
|
63
|
-
# Django stuff:
|
|
64
|
-
*.log
|
|
65
|
-
local_settings.py
|
|
66
|
-
db.sqlite3
|
|
67
|
-
db.sqlite3-journal
|
|
68
|
-
|
|
69
|
-
# Flask stuff:
|
|
70
|
-
instance/
|
|
71
|
-
.webassets-cache
|
|
72
|
-
|
|
73
|
-
# Scrapy stuff:
|
|
74
|
-
.scrapy
|
|
75
|
-
|
|
76
|
-
# Sphinx documentation
|
|
77
|
-
docs/_build/
|
|
78
|
-
|
|
79
|
-
# PyBuilder
|
|
80
|
-
.pybuilder/
|
|
81
|
-
target/
|
|
82
|
-
|
|
83
|
-
# Jupyter Notebook
|
|
84
|
-
.ipynb_checkpoints
|
|
85
|
-
|
|
86
|
-
# IPython
|
|
87
|
-
profile_default/
|
|
88
|
-
ipython_config.py
|
|
89
|
-
|
|
90
|
-
# pyenv
|
|
91
|
-
# For a library or package, you might want to ignore these files since the code is
|
|
92
|
-
# intended to run in multiple environments; otherwise, check them in:
|
|
93
|
-
# .python-version
|
|
94
|
-
|
|
95
|
-
# pipenv
|
|
96
|
-
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
97
|
-
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
98
|
-
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
99
|
-
# install all needed dependencies.
|
|
100
|
-
# Pipfile.lock
|
|
101
|
-
|
|
102
|
-
# UV
|
|
103
|
-
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
104
|
-
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
105
|
-
# commonly ignored for libraries.
|
|
106
|
-
# uv.lock
|
|
107
|
-
|
|
108
|
-
# poetry
|
|
109
|
-
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
110
|
-
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
111
|
-
# commonly ignored for libraries.
|
|
112
|
-
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
113
|
-
# poetry.lock
|
|
114
|
-
# poetry.toml
|
|
115
|
-
|
|
116
|
-
# pdm
|
|
117
|
-
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
118
|
-
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
119
|
-
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
120
|
-
# pdm.lock
|
|
121
|
-
# pdm.toml
|
|
122
|
-
.pdm-python
|
|
123
|
-
.pdm-build/
|
|
124
|
-
|
|
125
|
-
# pixi
|
|
126
|
-
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
127
|
-
# pixi.lock
|
|
128
|
-
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
129
|
-
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
130
|
-
.pixi
|
|
131
|
-
|
|
132
|
-
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
133
|
-
__pypackages__/
|
|
134
|
-
|
|
135
|
-
# Celery stuff
|
|
136
|
-
celerybeat-schedule
|
|
137
|
-
celerybeat.pid
|
|
138
|
-
|
|
139
|
-
# Redis
|
|
140
|
-
*.rdb
|
|
141
|
-
*.aof
|
|
142
|
-
*.pid
|
|
143
|
-
|
|
144
|
-
# RabbitMQ
|
|
145
|
-
mnesia/
|
|
146
|
-
rabbitmq/
|
|
147
|
-
rabbitmq-data/
|
|
148
|
-
|
|
149
|
-
# ActiveMQ
|
|
150
|
-
activemq-data/
|
|
151
|
-
|
|
152
|
-
# SageMath parsed files
|
|
153
|
-
*.sage.py
|
|
154
|
-
|
|
155
|
-
# Environments
|
|
156
|
-
.envrc
|
|
157
|
-
.venv
|
|
158
|
-
env/
|
|
159
|
-
venv/
|
|
160
|
-
ENV/
|
|
161
|
-
env.bak/
|
|
162
|
-
venv.bak/
|
|
163
|
-
|
|
164
|
-
# Spyder project settings
|
|
165
|
-
.spyderproject
|
|
166
|
-
.spyproject
|
|
167
|
-
|
|
168
|
-
# Rope project settings
|
|
169
|
-
.ropeproject
|
|
170
|
-
|
|
171
|
-
# mkdocs documentation
|
|
172
|
-
/site
|
|
173
|
-
|
|
174
|
-
# mypy
|
|
175
|
-
.mypy_cache/
|
|
176
|
-
.dmypy.json
|
|
177
|
-
dmypy.json
|
|
178
|
-
|
|
179
|
-
# Pyre type checker
|
|
180
|
-
.pyre/
|
|
181
|
-
|
|
182
|
-
# pytype static type analyzer
|
|
183
|
-
.pytype/
|
|
184
|
-
|
|
185
|
-
# Cython debug symbols
|
|
186
|
-
cython_debug/
|
|
187
|
-
|
|
188
|
-
# PyCharm
|
|
189
|
-
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
190
|
-
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
191
|
-
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
192
|
-
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
193
|
-
# .idea/
|
|
194
|
-
|
|
195
|
-
# Abstra
|
|
196
|
-
# Abstra is an AI-powered process automation framework.
|
|
197
|
-
# Ignore directories containing user credentials, local state, and settings.
|
|
198
|
-
# Learn more at https://abstra.io/docs
|
|
199
|
-
.abstra/
|
|
200
|
-
|
|
201
|
-
# Visual Studio Code
|
|
202
|
-
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
203
|
-
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
204
|
-
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
205
|
-
# you could uncomment the following to ignore the entire vscode folder
|
|
206
|
-
.vscode/
|
|
207
|
-
|
|
208
|
-
# Ruff stuff:
|
|
209
|
-
.ruff_cache/
|
|
210
|
-
|
|
211
|
-
# PyPI configuration file
|
|
212
|
-
.pypirc
|
|
213
|
-
|
|
214
|
-
# Marimo
|
|
215
|
-
marimo/_static/
|
|
216
|
-
marimo/_lsp/
|
|
217
|
-
__marimo__/
|
|
218
|
-
|
|
219
|
-
# Streamlit
|
|
220
|
-
.streamlit/secrets.toml
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
results/
|
|
224
|
-
ix-run*/
|
|
225
|
-
CLAUDE.md
|
|
226
|
-
.claude/*
|
|
227
|
-
!.claude/skills/
|
|
228
|
-
DESIGN.md
|
|
229
|
-
deploy/k8s/secret.yaml
|
|
230
|
-
.idea/
|
|
231
|
-
web/
|
|
232
|
-
|
|
233
|
-
# Stress test output files
|
|
234
|
-
stress-test/results.txt
|
|
235
|
-
stress-test/stress_report*.md
|
ap_client-0.1.4.dev0/PKG-INFO
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
"""Agent Platform API Client"""
|
|
2
|
-
|
|
3
|
-
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
-
|
|
5
|
-
from .api import APIClient, get_client
|
|
6
|
-
from .config import Config, get_config
|
|
7
|
-
|
|
8
|
-
try:
|
|
9
|
-
__version__ = version("ap-client")
|
|
10
|
-
except PackageNotFoundError:
|
|
11
|
-
__version__ = "0.1.4"
|
|
12
|
-
|
|
13
|
-
__all__ = ["APIClient", "Config", "__version__", "get_client", "get_config"]
|