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.
- {ap_client-0.1.24.dev0 → ap_client-1.0.0.dev0}/PKG-INFO +1 -1
- {ap_client-0.1.24.dev0 → ap_client-1.0.0.dev0}/ap_client/api.py +33 -1
- {ap_client-0.1.24.dev0 → ap_client-1.0.0.dev0}/ap_client/cli.py +39 -3
- {ap_client-0.1.24.dev0 → ap_client-1.0.0.dev0}/ap_client/exporter.py +333 -4
- {ap_client-0.1.24.dev0 → ap_client-1.0.0.dev0}/pyproject.toml +1 -1
- {ap_client-0.1.24.dev0 → ap_client-1.0.0.dev0}/ap_client/__init__.py +0 -0
- {ap_client-0.1.24.dev0 → ap_client-1.0.0.dev0}/ap_client/config.py +0 -0
- {ap_client-0.1.24.dev0 → ap_client-1.0.0.dev0}/ap_client/irepo_commands.py +0 -0
- {ap_client-0.1.24.dev0 → ap_client-1.0.0.dev0}/ap_client/profile_commands.py +0 -0
- {ap_client-0.1.24.dev0 → ap_client-1.0.0.dev0}/ap_client/tbb.py +0 -0
- {ap_client-0.1.24.dev0 → ap_client-1.0.0.dev0}/ap_client/waiter.py +0 -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
|
-
|
|
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",
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|