ap-client 0.1.24.dev0__tar.gz → 1.0.0.dev0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ap-client
3
- Version: 0.1.24.dev0
3
+ Version: 1.0.0.dev0
4
4
  Summary: Agent Platform API Client & CLI
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: pyyaml>=6.0
@@ -2062,6 +2062,24 @@ class APIClient:
2062
2062
  """Get artifact download links for one or more jobs."""
2063
2063
  return self._post("/jobs/artifacts", {"job_ids": job_ids})
2064
2064
 
2065
+ def get_job_artifacts_manifest(
2066
+ self,
2067
+ job_id: str,
2068
+ *,
2069
+ require_file_download: bool = False,
2070
+ ) -> dict:
2071
+ """Get the file manifest for one job's artifacts."""
2072
+ params = {"require_file_download": "true"} if require_file_download else None
2073
+ return self._get(
2074
+ f"/jobs/{quote(job_id, safe='')}/artifacts-manifest",
2075
+ params=params,
2076
+ )
2077
+
2078
+ def get_artifact_file_download_urls(self, job_id: str, paths: list[str]) -> dict:
2079
+ """Get download URLs for multiple artifact files."""
2080
+ endpoint = f"/jobs/{quote(job_id, safe='')}/artifacts/files/download"
2081
+ return self._post(endpoint, {"paths": paths})
2082
+
2065
2083
  def render_template(self, name: str, params: dict) -> dict:
2066
2084
  """Preview the rendered template output."""
2067
2085
  query_params = {}
@@ -2718,6 +2736,20 @@ _SENSITIVE_SUBSTRINGS = (
2718
2736
  "passwd",
2719
2737
  )
2720
2738
  _SENSITIVE_SEGMENT_RE = re.compile(r"(?:^|[_-])(?:ak|sk)(?:[_-]|$)", re.IGNORECASE)
2739
+ _SENSITIVE_URL_QUERY_KEYS = frozenset(
2740
+ {
2741
+ "credential",
2742
+ "ossaccesskeyid",
2743
+ "security-token",
2744
+ "signature",
2745
+ "token",
2746
+ "x-amz-credential",
2747
+ "x-amz-security-token",
2748
+ "x-amz-signature",
2749
+ "x-oss-security-token",
2750
+ "x-oss-signature",
2751
+ }
2752
+ )
2721
2753
 
2722
2754
 
2723
2755
  def _redact_headers(headers: dict, enabled: bool = True) -> dict:
@@ -2790,7 +2822,7 @@ def _redact_url_query_secrets(value: str) -> str:
2790
2822
  changed = False
2791
2823
  query = []
2792
2824
  for key, item_value in parse_qsl(parsed.query, keep_blank_values=True):
2793
- if _is_sensitive_key(key) and item_value:
2825
+ if (_is_sensitive_key(key) or key.lower() in _SENSITIVE_URL_QUERY_KEYS) and item_value:
2794
2826
  query.append((key, _mask_middle(item_value)))
2795
2827
  changed = True
2796
2828
  else:
@@ -342,6 +342,14 @@ def _parse_datasources_option(raw: Optional[str]) -> Optional[list]:
342
342
  return [item.strip() for item in value.split(",") if item.strip()] or None
343
343
 
344
344
 
345
+ def _parse_file_selectors(values: Optional[list[str]]) -> Optional[list[str]]:
346
+ """Expand repeatable, comma-separated file selector options."""
347
+ selectors = [
348
+ item.strip() for value in values or [] for item in value.split(",") if item.strip()
349
+ ]
350
+ return list(dict.fromkeys(selectors)) or None
351
+
352
+
345
353
  def _read_json_object_option(
346
354
  raw: Optional[str], file_path: Optional[str], option_name: str
347
355
  ) -> Optional[dict]:
@@ -4263,13 +4271,15 @@ _RESOURCE_ROLE_IDS = {
4263
4271
  ("job", "writer"): "built-in:job-writer",
4264
4272
  ("group", "reader"): "built-in:group-reader",
4265
4273
  ("group", "writer"): "built-in:group-writer",
4274
+ ("group", "cloner"): "built-in:group-cloner",
4266
4275
  }
4267
4276
 
4268
4277
 
4269
4278
  def _resource_role_id(resource: str, role: str) -> str:
4270
4279
  key = (resource, (role or "").strip().lower())
4271
4280
  if key not in _RESOURCE_ROLE_IDS:
4272
- raise typer.BadParameter("role must be one of: reader, writer")
4281
+ choices = "reader, writer, cloner" if resource == "group" else "reader, writer"
4282
+ raise typer.BadParameter(f"role must be one of: {choices}")
4273
4283
  return _RESOURCE_ROLE_IDS[key]
4274
4284
 
4275
4285
 
@@ -5840,7 +5850,9 @@ def group_grant(
5840
5850
  None, "--user-group", help="Grant to this user_group_id"
5841
5851
  ),
5842
5852
  role: str = typer.Option(
5843
- "reader", "--role", help="reader (view) or writer (view+cancel/update)"
5853
+ "reader",
5854
+ "--role",
5855
+ help="reader (view), cloner (view+clone), or writer (view+cancel/update)",
5844
5856
  ),
5845
5857
  ):
5846
5858
  """Grant a user/user-group access to a specific group (RBAC v2 instance delegation)."""
@@ -5854,7 +5866,7 @@ def group_revoke(
5854
5866
  user_group: Optional[str] = typer.Option(
5855
5867
  None, "--user-group", help="Revoke from this user_group_id"
5856
5868
  ),
5857
- role: str = typer.Option("reader", "--role", help="reader or writer"),
5869
+ role: str = typer.Option("reader", "--role", help="reader, cloner, or writer"),
5858
5870
  ):
5859
5871
  """Revoke a user/user-group's instance access to a specific group."""
5860
5872
  _revoke_instance_access("group", group_id, role, user, user_group)
@@ -5880,8 +5892,30 @@ def group_export(
5880
5892
  "--no-extract-artifacts",
5881
5893
  help="Download result.tgz without extracting it into artifacts/",
5882
5894
  ),
5895
+ include_files: Optional[list[str]] = typer.Option(
5896
+ None,
5897
+ "--include-files",
5898
+ help="Only download matching artifact files or folders (repeatable; comma-separated)",
5899
+ ),
5900
+ exclude_files: Optional[list[str]] = typer.Option(
5901
+ None,
5902
+ "--exclude-files",
5903
+ help="Skip matching artifact files or folders (repeatable; comma-separated)",
5904
+ ),
5883
5905
  ):
5884
5906
  """Export artifacts of all jobs under a Group to a local directory. Use --logs/--events to include logs and events."""
5907
+ include_file_selectors = _parse_file_selectors(include_files)
5908
+ exclude_file_selectors = _parse_file_selectors(exclude_files)
5909
+ if include_files and not include_file_selectors:
5910
+ _emit_error("--include-files requires at least one non-empty file or folder name")
5911
+ raise typer.Exit(1)
5912
+ if exclude_files and not exclude_file_selectors:
5913
+ _emit_error("--exclude-files requires at least one non-empty file or folder name")
5914
+ raise typer.Exit(1)
5915
+ if no_extract_artifacts and (include_file_selectors or exclude_file_selectors):
5916
+ _emit_error("--include-files/--exclude-files cannot be used with --no-extract-artifacts")
5917
+ raise typer.Exit(1)
5918
+
5885
5919
  client = get_client()
5886
5920
  from rich.progress import BarColumn, Progress, TaskProgressColumn, TextColumn
5887
5921
 
@@ -5920,6 +5954,8 @@ def group_export(
5920
5954
  include_logs=logs,
5921
5955
  include_events=events,
5922
5956
  extract_artifacts=not no_extract_artifacts,
5957
+ include_files=include_file_selectors,
5958
+ exclude_files=exclude_file_selectors,
5923
5959
  )
5924
5960
  print(f"[green]Group exported:[/] {group_id}")
5925
5961
  print(f" path: {dest}")
@@ -10,7 +10,10 @@ import shutil
10
10
  import socket
11
11
  import sys
12
12
  import tarfile
13
+ import tempfile
13
14
  import threading
15
+ import time
16
+ import uuid
14
17
  from dataclasses import dataclass, field
15
18
  from functools import lru_cache
16
19
  from pathlib import Path
@@ -26,6 +29,8 @@ logger = logging.getLogger(__name__)
26
29
  DEFAULT_CACHE_DIR = "~/.cache/agentplatform"
27
30
  _DOWNLOAD_TIMEOUT = (10, 300)
28
31
  _DOWNLOAD_CHUNK_SIZE = 1024 * 1024 * 4
32
+ _FILE_URL_BATCH_SIZE = 500
33
+ _FILE_URL_EXPIRY_MARGIN_SECONDS = 30
29
34
  ProgressCallback = Callable[[int, int, str, str], None]
30
35
  StageCallback = Callable[[str], None]
31
36
 
@@ -91,12 +96,19 @@ def export_group(
91
96
  include_logs: bool = False,
92
97
  include_events: bool = False,
93
98
  extract_artifacts: bool = True,
99
+ include_files: Optional[list[str]] = None,
100
+ exclude_files: Optional[list[str]] = None,
94
101
  ) -> tuple[Path, ExportSummary]:
95
102
  """Export a group and all of its jobs to disk.
96
103
 
97
104
  Jobs without artifact URLs are skipped: no directory is created for them.
98
105
  Returns (dest_path, ExportSummary) with download/skip statistics.
99
106
  """
107
+ include_files = _normalize_file_selectors(include_files)
108
+ exclude_files = _normalize_file_selectors(exclude_files)
109
+ if not extract_artifacts and (include_files or exclude_files):
110
+ raise ValueError("file filters cannot be used when artifact extraction is disabled")
111
+
100
112
  dest = Path(output_dir) if output_dir else default_group_export_dir(group_id)
101
113
  dest.mkdir(parents=True, exist_ok=True)
102
114
 
@@ -153,6 +165,8 @@ def export_group(
153
165
  include_events=include_events,
154
166
  extract_artifacts=extract_artifacts,
155
167
  skip_no_artifact=True,
168
+ include_files=include_files,
169
+ exclude_files=exclude_files,
156
170
  )
157
171
  if result is not None:
158
172
  skipped_jobs.append(result)
@@ -177,6 +191,8 @@ def export_group(
177
191
  include_events=include_events,
178
192
  extract_artifacts=extract_artifacts,
179
193
  skip_no_artifact=True,
194
+ include_files=include_files,
195
+ exclude_files=exclude_files,
180
196
  ): job["job_id"]
181
197
  for job in jobs
182
198
  }
@@ -250,6 +266,8 @@ def _export_job_with_fresh_client(
250
266
  include_events: bool = False,
251
267
  extract_artifacts: bool = True,
252
268
  skip_no_artifact: bool = False,
269
+ include_files: Optional[list[str]] = None,
270
+ exclude_files: Optional[list[str]] = None,
253
271
  ) -> Optional[str]:
254
272
  """Export one job with a dedicated client/session for thread safety."""
255
273
  worker_client = APIClient(config)
@@ -263,6 +281,8 @@ def _export_job_with_fresh_client(
263
281
  include_events=include_events,
264
282
  extract_artifacts=extract_artifacts,
265
283
  skip_no_artifact=skip_no_artifact,
284
+ include_files=include_files,
285
+ exclude_files=exclude_files,
266
286
  )
267
287
 
268
288
 
@@ -277,6 +297,8 @@ def _export_job_into_dir(
277
297
  include_events: bool = False,
278
298
  extract_artifacts: bool = True,
279
299
  skip_no_artifact: bool = False,
300
+ include_files: Optional[list[str]] = None,
301
+ exclude_files: Optional[list[str]] = None,
280
302
  ) -> Optional[str]:
281
303
  """Export one job into a directory.
282
304
 
@@ -288,6 +310,7 @@ def _export_job_into_dir(
288
310
  Returns job_name if skipped, None if exported successfully.
289
311
  """
290
312
  current_stage = "job metadata"
313
+ selective_artifacts = bool(include_files or exclude_files)
291
314
 
292
315
  def _set_stage(stage: str) -> None:
293
316
  nonlocal current_stage
@@ -300,10 +323,21 @@ def _export_job_into_dir(
300
323
  job_data = job or client.get_job(job_id)
301
324
 
302
325
  _set_stage("artifact metadata")
303
- artifact = _get_job_artifact(client, job_id)
326
+ manifest_payload = None
327
+ if selective_artifacts:
328
+ manifest_payload = _get_job_artifact_manifest(client, job_id)
329
+ artifact = {
330
+ "job_id": job_id,
331
+ "url": None,
332
+ "mode": "selected_files",
333
+ }
334
+ artifact_available = manifest_payload is not None
335
+ else:
336
+ artifact = _get_job_artifact(client, job_id)
337
+ artifact_available = bool(artifact.get("url"))
304
338
 
305
339
  # When skip_no_artifact is enabled, skip jobs without artifact URLs
306
- if skip_no_artifact and not artifact.get("url"):
340
+ if skip_no_artifact and not artifact_available:
307
341
  _set_stage("skipped")
308
342
  job_name = job_data.get("job_name") or job_id
309
343
  job_status = job_data.get("status", "")
@@ -322,7 +356,8 @@ def _export_job_into_dir(
322
356
 
323
357
  _write_json(dest / "job.json", job_data)
324
358
  _write_json(dest / "metrics.json", metrics)
325
- _write_json(dest / "artifacts.json", artifact)
359
+ if not selective_artifacts:
360
+ _write_json(dest / "artifacts.json", artifact)
326
361
 
327
362
  if include_events:
328
363
  _set_stage("events")
@@ -334,7 +369,19 @@ def _export_job_into_dir(
334
369
  _write_logs(dest / "logs", client, job_id, job_data)
335
370
 
336
371
  artifact_url = artifact.get("url")
337
- if artifact_url:
372
+ if selective_artifacts:
373
+ _set_stage("downloading selected artifact files")
374
+ _download_selected_artifact_files(
375
+ client,
376
+ job_id,
377
+ manifest_payload.get("manifest"),
378
+ dest / "artifacts",
379
+ include_files=include_files,
380
+ exclude_files=exclude_files,
381
+ )
382
+ _write_json(dest / "artifacts.json", artifact)
383
+ _write_json(dest / "artifacts-manifest.json", manifest_payload)
384
+ elif artifact_url:
338
385
  archive_path = dest / "result.tgz"
339
386
  try:
340
387
  _set_stage("downloading artifact")
@@ -378,6 +425,288 @@ def _get_job_artifact(client: APIClient, job_id: str) -> dict:
378
425
  return items[0] if items else {"job_id": job_id, "url": None}
379
426
 
380
427
 
428
+ def _get_job_artifact_manifest(client: APIClient, job_id: str) -> Optional[dict]:
429
+ """Return a manifest payload, or None when the job has no artifact."""
430
+ payload = client.get_job_artifacts_manifest(job_id, require_file_download=True)
431
+ if not isinstance(payload, dict):
432
+ raise ValueError("Invalid artifact manifest response")
433
+ if "manifest" not in payload:
434
+ raise ValueError("Invalid artifact manifest response")
435
+ manifest = payload["manifest"]
436
+ if manifest is None:
437
+ return None
438
+ if not isinstance(manifest, list):
439
+ raise ValueError("Invalid artifact manifest response")
440
+ if payload.get("truncated") is True:
441
+ max_entries = payload.get("max_entries", "unknown")
442
+ raise ValueError(
443
+ f"Artifact manifest is truncated (max_entries={max_entries}); "
444
+ "selective export cannot guarantee a complete result"
445
+ )
446
+ return payload
447
+
448
+
449
+ def _normalize_file_selectors(values: Optional[list[str]]) -> Optional[list[str]]:
450
+ selectors: list[str] = []
451
+ for raw in values or []:
452
+ if not isinstance(raw, str):
453
+ raise ValueError("Artifact file selectors must be strings")
454
+ selector = raw.strip()
455
+ while selector.startswith("./"):
456
+ selector = selector[2:]
457
+ selector = selector.rstrip("/")
458
+ _validate_relative_artifact_path(selector, "artifact file selector")
459
+ if selector not in selectors:
460
+ selectors.append(selector)
461
+ return selectors or None
462
+
463
+
464
+ def _validate_relative_artifact_path(path: str, label: str) -> None:
465
+ invalid = (
466
+ not path
467
+ or path.startswith("/")
468
+ or "\\" in path
469
+ or "\x00" in path
470
+ or any(part in {"", ".", ".."} for part in path.split("/"))
471
+ or (len(path) >= 2 and path[0].isalpha() and path[1] == ":")
472
+ )
473
+ if invalid:
474
+ prefix = "Unsafe" if label == "artifact manifest path" else "Invalid"
475
+ raise ValueError(f"{prefix} {label}: {path!r}")
476
+
477
+
478
+ def _parse_artifact_manifest(manifest: list[dict]) -> tuple[list[str], set[str]]:
479
+ files: list[str] = []
480
+ directories: set[str] = set()
481
+ seen_files: set[str] = set()
482
+ for item in manifest:
483
+ if not isinstance(item, dict):
484
+ raise ValueError("Invalid artifact manifest entry")
485
+ path = item.get("path")
486
+ if not isinstance(path, str):
487
+ raise ValueError("Invalid artifact manifest entry")
488
+ _validate_relative_artifact_path(path, "artifact manifest path")
489
+ item_type = item.get("type")
490
+ if item_type == "dir":
491
+ directories.add(path)
492
+ elif item_type == "file" and path not in seen_files:
493
+ seen_files.add(path)
494
+ files.append(path)
495
+ return files, directories
496
+
497
+
498
+ def _selector_matches(path: str, selector: str) -> bool:
499
+ if "/" in selector:
500
+ return path == selector or path.startswith(selector + "/")
501
+ return selector in path.split("/")
502
+
503
+
504
+ def _manifest_path_matches(path: str, selector: str, strip_root: Optional[str]) -> bool:
505
+ if _selector_matches(path, selector):
506
+ return True
507
+ if strip_root and path.startswith(strip_root + "/"):
508
+ logical_path = path[len(strip_root) + 1 :]
509
+ return _selector_matches(logical_path, selector)
510
+ return False
511
+
512
+
513
+ def _select_artifact_files(
514
+ files: list[str],
515
+ *,
516
+ include_files: Optional[list[str]],
517
+ exclude_files: Optional[list[str]],
518
+ strip_root: Optional[str] = None,
519
+ ) -> list[str]:
520
+ if include_files:
521
+ selected = [
522
+ path
523
+ for path in files
524
+ if any(_manifest_path_matches(path, selector, strip_root) for selector in include_files)
525
+ ]
526
+ else:
527
+ selected = list(files)
528
+ if exclude_files:
529
+ selected = [
530
+ path
531
+ for path in selected
532
+ if not any(
533
+ _manifest_path_matches(path, selector, strip_root) for selector in exclude_files
534
+ )
535
+ ]
536
+ return selected
537
+
538
+
539
+ def _manifest_strip_root(files: list[str], directories: set[str]) -> Optional[str]:
540
+ roots = {path.split("/", 1)[0] for path in files if "/" in path}
541
+ if len(roots) != 1:
542
+ return None
543
+ root = next(iter(roots))
544
+ if root not in directories or any("/" not in path for path in files):
545
+ return None
546
+ return root
547
+
548
+
549
+ def _download_selected_artifact_files(
550
+ client: APIClient,
551
+ job_id: str,
552
+ manifest: list[dict],
553
+ artifacts_dir: Path,
554
+ *,
555
+ include_files: Optional[list[str]],
556
+ exclude_files: Optional[list[str]],
557
+ ) -> None:
558
+ files, directories = _parse_artifact_manifest(manifest)
559
+ strip_root = _manifest_strip_root(files, directories)
560
+ selected = _select_artifact_files(
561
+ files,
562
+ include_files=include_files,
563
+ exclude_files=exclude_files,
564
+ strip_root=strip_root,
565
+ )
566
+
567
+ artifacts_dir.parent.mkdir(parents=True, exist_ok=True)
568
+ staging_dir = Path(
569
+ tempfile.mkdtemp(
570
+ prefix=f".{artifacts_dir.name}.downloading-",
571
+ dir=artifacts_dir.parent,
572
+ )
573
+ )
574
+ output_dir = staging_dir / "output"
575
+ output_dir.mkdir()
576
+
577
+ destinations = {
578
+ path: output_dir / (path[len(strip_root) + 1 :] if strip_root else path)
579
+ for path in selected
580
+ }
581
+
582
+ try:
583
+ for start in range(0, len(selected), _FILE_URL_BATCH_SIZE):
584
+ paths = selected[start : start + _FILE_URL_BATCH_SIZE]
585
+ index = 0
586
+ urls, refresh_at = _issue_artifact_file_urls(client, job_id, paths)
587
+ issued_at_index = index
588
+ refreshed_after_auth_error: set[str] = set()
589
+ while index < len(paths):
590
+ if (
591
+ index > issued_at_index
592
+ and refresh_at is not None
593
+ and time.monotonic() >= refresh_at
594
+ ):
595
+ urls, refresh_at = _issue_artifact_file_urls(client, job_id, paths[index:])
596
+ issued_at_index = index
597
+ path = paths[index]
598
+ try:
599
+ _download_file(urls[path], destinations[path])
600
+ except requests.HTTPError as exc:
601
+ response = exc.response
602
+ if (
603
+ response is not None
604
+ and response.status_code in {401, 403}
605
+ and path not in refreshed_after_auth_error
606
+ ):
607
+ refreshed_after_auth_error.add(path)
608
+ urls, refresh_at = _issue_artifact_file_urls(client, job_id, paths[index:])
609
+ issued_at_index = index
610
+ continue
611
+ _raise_sanitized_artifact_download_error(path, exc)
612
+ except Exception as exc:
613
+ _raise_sanitized_artifact_download_error(path, exc)
614
+ index += 1
615
+ _commit_staged_artifacts(staging_dir, artifacts_dir)
616
+ finally:
617
+ if _path_exists(staging_dir):
618
+ _remove_path(staging_dir)
619
+
620
+
621
+ def _raise_sanitized_artifact_download_error(path: str, exc: Exception) -> None:
622
+ """Raise a diagnostic error without retaining a capability URL in its text."""
623
+ detail = f"Artifact file download failed for {path!r}"
624
+ if isinstance(exc, requests.HTTPError) and exc.response is not None:
625
+ detail += f" (HTTP {exc.response.status_code})"
626
+ else:
627
+ detail += f" ({type(exc).__name__})"
628
+ raise RuntimeError(detail) from None
629
+
630
+
631
+ def _issue_artifact_file_urls(
632
+ client: APIClient, job_id: str, paths: list[str]
633
+ ) -> tuple[dict[str, str], Optional[float]]:
634
+ payload = client.get_artifact_file_download_urls(job_id, paths)
635
+ if not isinstance(payload, dict) or not isinstance(payload.get("files"), list):
636
+ raise ValueError("Invalid artifact file download URL response")
637
+ urls = {
638
+ item.get("path"): item.get("url")
639
+ for item in payload["files"]
640
+ if isinstance(item, dict)
641
+ and isinstance(item.get("path"), str)
642
+ and isinstance(item.get("url"), str)
643
+ and item.get("url")
644
+ }
645
+ missing = [path for path in paths if path not in urls]
646
+ if missing:
647
+ raise ValueError(
648
+ "Artifact file download URL response is missing: " + ", ".join(missing[:10])
649
+ )
650
+
651
+ expires_in = payload.get("expires_in")
652
+ refresh_at = None
653
+ if isinstance(expires_in, (int, float)) and not isinstance(expires_in, bool) and expires_in > 0:
654
+ margin = min(
655
+ _FILE_URL_EXPIRY_MARGIN_SECONDS,
656
+ max(1.0, float(expires_in) * 0.1),
657
+ )
658
+ refresh_at = time.monotonic() + max(0.0, float(expires_in) - margin)
659
+ return urls, refresh_at
660
+
661
+
662
+ def _path_exists(path: Path) -> bool:
663
+ return path.exists() or path.is_symlink()
664
+
665
+
666
+ def _remove_path(path: Path) -> None:
667
+ if path.is_symlink() or path.is_file():
668
+ path.unlink()
669
+ elif path.exists():
670
+ shutil.rmtree(path)
671
+
672
+
673
+ def _commit_staged_artifacts(staging_dir: Path, artifacts_dir: Path) -> None:
674
+ """Atomically publish a complete selective download and retire stale archives."""
675
+ suffix = uuid.uuid4().hex
676
+ artifacts_backup = artifacts_dir.with_name(f".{artifacts_dir.name}.previous-{suffix}")
677
+ archive_path = artifacts_dir.parent / "result.tgz"
678
+ archive_backup = archive_path.with_name(f".{archive_path.name}.previous-{suffix}")
679
+ moved_artifacts = False
680
+ moved_archive = False
681
+ installed = False
682
+
683
+ try:
684
+ if _path_exists(artifacts_dir):
685
+ artifacts_dir.replace(artifacts_backup)
686
+ moved_artifacts = True
687
+ if _path_exists(archive_path):
688
+ archive_path.replace(archive_backup)
689
+ moved_archive = True
690
+ staging_dir.replace(artifacts_dir)
691
+ installed = True
692
+ except Exception:
693
+ if installed and _path_exists(artifacts_dir):
694
+ _remove_path(artifacts_dir)
695
+ if moved_artifacts and _path_exists(artifacts_backup):
696
+ artifacts_backup.replace(artifacts_dir)
697
+ if moved_archive and _path_exists(archive_backup):
698
+ archive_backup.replace(archive_path)
699
+ raise
700
+
701
+ for backup in (artifacts_backup, archive_backup):
702
+ if not _path_exists(backup):
703
+ continue
704
+ try:
705
+ _remove_path(backup)
706
+ except OSError as exc:
707
+ logger.warning("Failed to remove selective export backup %s: %s", backup, exc)
708
+
709
+
381
710
  def _write_json(path: Path, data: object) -> None:
382
711
  path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
383
712
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ap-client"
7
- version = "0.1.24.dev0"
7
+ version = "1.0.0.dev0"
8
8
  description = "Agent Platform API Client & CLI"
9
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" }
10
10
  requires-python = ">=3.10"