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.
@@ -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 Exception as exc:
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
- try:
84
- err_data = resp.json()
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
- result.get("jobs", []),
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
- config = get_config()
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
- group_id = result.get("group_id")
679
- if group_id:
680
- _emit_info(
681
- f"Waiting for group to finish: {group_id} (interval={wait_interval}s)",
682
- output_format,
683
- )
684
- final_group = wait_for_group(client, group_id, wait_interval, printer=wait_printer)
685
- wait_result = {"type": "group", "interval": wait_interval, "result": final_group}
686
- if output_format == "plain":
687
- stats = final_group.get("stats") or {}
688
- print(f"[green]Group finished:[/] {group_id}")
689
- print(f" total: {stats.get('total', 0)}")
690
- print(f" succeeded: {stats.get('succeeded', 0)}")
691
- print(f" failed: {stats.get('failed', 0)}")
692
- print(f" cancelled: {stats.get('cancelled', 0)}")
693
- else:
694
- job = result.get("jobs", [{}])[0]
695
- job_id = job.get("job_id")
696
- _emit_info(
697
- f"Waiting for job to finish: {job_id} (interval={wait_interval}s)",
698
- output_format,
699
- )
700
- final_job = wait_for_job(client, job_id, wait_interval, printer=wait_printer)
701
- wait_result = {"type": "job", "interval": wait_interval, "result": final_job}
702
- if output_format == "plain":
703
- print(f"[green]Job finished:[/] {job_id}")
704
- print(f" status: {final_job.get('status')}")
705
- if final_job.get("failed_reason"):
706
- print(f" failed_reason: {final_job.get('failed_reason')}")
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
- result = wait_for_job(client, job_id, interval)
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
- result = wait_for_group(client, group_id, interval)
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(f"Failed to export job {job_id} at stage '{current_stage}': {exc}") from exc
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.extractall(dest_dir)
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
- time.sleep(interval)
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.4.dev0"
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"]
@@ -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
@@ -1,9 +0,0 @@
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
@@ -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"]