ap-client 0.3.2.dev0__tar.gz → 0.4.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.3.2.dev0 → ap_client-0.4.0.dev0}/PKG-INFO +3 -3
- {ap_client-0.3.2.dev0 → ap_client-0.4.0.dev0}/ap_client/api.py +126 -9
- {ap_client-0.3.2.dev0 → ap_client-0.4.0.dev0}/ap_client/cli.py +21 -10
- {ap_client-0.3.2.dev0 → ap_client-0.4.0.dev0}/ap_client/dataset_commands.py +3 -2
- {ap_client-0.3.2.dev0 → ap_client-0.4.0.dev0}/ap_client/instance_commands.py +25 -7
- {ap_client-0.3.2.dev0 → ap_client-0.4.0.dev0}/ap_client/irepo_sdk.py +2 -2
- {ap_client-0.3.2.dev0 → ap_client-0.4.0.dev0}/ap_client/profile_commands.py +96 -1
- {ap_client-0.3.2.dev0 → ap_client-0.4.0.dev0}/ap_client/tbb.py +1 -1
- {ap_client-0.3.2.dev0 → ap_client-0.4.0.dev0}/pyproject.toml +3 -3
- {ap_client-0.3.2.dev0 → ap_client-0.4.0.dev0}/ap_client/__init__.py +0 -0
- {ap_client-0.3.2.dev0 → ap_client-0.4.0.dev0}/ap_client/config.py +0 -0
- {ap_client-0.3.2.dev0 → ap_client-0.4.0.dev0}/ap_client/exporter.py +0 -0
- {ap_client-0.3.2.dev0 → ap_client-0.4.0.dev0}/ap_client/fs_commands.py +0 -0
- {ap_client-0.3.2.dev0 → ap_client-0.4.0.dev0}/ap_client/managed_ak.py +0 -0
- {ap_client-0.3.2.dev0 → ap_client-0.4.0.dev0}/ap_client/split_publish.py +0 -0
- {ap_client-0.3.2.dev0 → ap_client-0.4.0.dev0}/ap_client/waiter.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ap-client
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0.dev0
|
|
4
4
|
Summary: Agent Platform API Client & CLI
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Requires-Dist: pyyaml>=6.0
|
|
@@ -9,9 +9,9 @@ Requires-Dist: rich>=13.0.0
|
|
|
9
9
|
Requires-Dist: typer>=0.9.0
|
|
10
10
|
Requires-Dist: websockets>=13.0
|
|
11
11
|
Provides-Extra: all
|
|
12
|
-
Requires-Dist: instance-repo[oss]
|
|
12
|
+
Requires-Dist: instance-repo[oss]<2.0.0,>=1.1.2; extra == 'all'
|
|
13
13
|
Provides-Extra: dataset
|
|
14
|
-
Requires-Dist: instance-repo[oss]
|
|
14
|
+
Requires-Dist: instance-repo[oss]<2.0.0,>=1.1.2; extra == 'dataset'
|
|
15
15
|
Description-Content-Type: text/markdown
|
|
16
16
|
|
|
17
17
|
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.
|
|
@@ -283,6 +283,36 @@ def _ensure_next_token_honored(response: dict, *, next_token: Optional[str], end
|
|
|
283
283
|
)
|
|
284
284
|
|
|
285
285
|
|
|
286
|
+
def _openapi_supports_instance_range(document: Any) -> bool:
|
|
287
|
+
"""Check the actual POST /jobs JSON request schema, failing closed on unknown shapes."""
|
|
288
|
+
|
|
289
|
+
def resolve(node: Any) -> dict:
|
|
290
|
+
seen = set()
|
|
291
|
+
while isinstance(node, dict) and "$ref" in node:
|
|
292
|
+
ref = node["$ref"]
|
|
293
|
+
if not isinstance(ref, str) or not ref.startswith("#/") or ref in seen:
|
|
294
|
+
return {}
|
|
295
|
+
seen.add(ref)
|
|
296
|
+
node = document
|
|
297
|
+
for part in ref[2:].split("/"):
|
|
298
|
+
if not isinstance(node, dict):
|
|
299
|
+
return {}
|
|
300
|
+
node = node.get(part.replace("~1", "/").replace("~0", "~"))
|
|
301
|
+
return node if isinstance(node, dict) else {}
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
path = resolve(document["paths"]["/jobs"])
|
|
305
|
+
body = resolve(path["post"]["requestBody"])
|
|
306
|
+
schema = resolve(body["content"]["application/json"]["schema"])
|
|
307
|
+
properties = schema["properties"]
|
|
308
|
+
except (KeyError, TypeError):
|
|
309
|
+
return False
|
|
310
|
+
if not isinstance(properties, dict) or "instance_range" not in properties:
|
|
311
|
+
return False
|
|
312
|
+
field = resolve(properties["instance_range"])
|
|
313
|
+
return bool(field) and field.get("readOnly") is not True
|
|
314
|
+
|
|
315
|
+
|
|
286
316
|
def _secret_ws_params(workspace_id: Optional[str]) -> Optional[dict]:
|
|
287
317
|
"""Build the ``?workspace_id=`` query for name-addressed secret endpoints.
|
|
288
318
|
|
|
@@ -477,6 +507,28 @@ class APIClient:
|
|
|
477
507
|
"""Send a GET request."""
|
|
478
508
|
return self._request("GET", path, params=params, timeout=timeout)
|
|
479
509
|
|
|
510
|
+
def ensure_profile_range_supported(self, timeout: TimeoutType = None) -> None:
|
|
511
|
+
"""Check the target cluster before sending a profile range request.
|
|
512
|
+
|
|
513
|
+
Do not cache this across submissions: the server may have been rolled back.
|
|
514
|
+
Use the normal authenticated, cluster-scoped request path.
|
|
515
|
+
"""
|
|
516
|
+
try:
|
|
517
|
+
document = self._get(
|
|
518
|
+
"/openapi.json", timeout=timeout if timeout is not None else (10, 30)
|
|
519
|
+
)
|
|
520
|
+
except APIError as exc:
|
|
521
|
+
raise ValueError(
|
|
522
|
+
"Cannot verify server support for --profile --range via /openapi.json; "
|
|
523
|
+
f"no jobs were submitted. Check AP_BASE_URL, AP_CLUSTER and server access. {exc}"
|
|
524
|
+
) from exc
|
|
525
|
+
if not _openapi_supports_instance_range(document):
|
|
526
|
+
raise ValueError(
|
|
527
|
+
"Cannot confirm server support for --profile --range: /openapi.json must "
|
|
528
|
+
"declare instance_range in the POST /jobs JSON request schema. "
|
|
529
|
+
"No jobs were submitted. Upgrade the target server before using --range."
|
|
530
|
+
)
|
|
531
|
+
|
|
480
532
|
def _post(self, path: str, data: Any, timeout: TimeoutType = None) -> Any:
|
|
481
533
|
"""Send a POST request."""
|
|
482
534
|
from .managed_ak import parameter_ak_ids, require_parameter_ak_ack
|
|
@@ -1097,6 +1149,7 @@ class APIClient:
|
|
|
1097
1149
|
benchmark: Optional[str] = None,
|
|
1098
1150
|
experiment: Optional[str] = None,
|
|
1099
1151
|
ak_id: Optional[str] = None,
|
|
1152
|
+
instance_range: Optional[str] = None,
|
|
1100
1153
|
) -> dict:
|
|
1101
1154
|
"""Create a job."""
|
|
1102
1155
|
body = self.build_create_job_body(
|
|
@@ -1134,6 +1187,7 @@ class APIClient:
|
|
|
1134
1187
|
batch_size=batch_size,
|
|
1135
1188
|
trials=trials,
|
|
1136
1189
|
resource_profile_id=resource_profile_id,
|
|
1190
|
+
instance_range=instance_range,
|
|
1137
1191
|
meta_model=meta_model,
|
|
1138
1192
|
meta_job_type=meta_job_type,
|
|
1139
1193
|
upstream_platform=upstream_platform,
|
|
@@ -1205,6 +1259,7 @@ class APIClient:
|
|
|
1205
1259
|
benchmark: Optional[str] = None,
|
|
1206
1260
|
experiment: Optional[str] = None,
|
|
1207
1261
|
ak_id: Optional[str] = None,
|
|
1262
|
+
instance_range: Optional[str] = None,
|
|
1208
1263
|
) -> dict:
|
|
1209
1264
|
"""Build the /jobs request body for job creation."""
|
|
1210
1265
|
_validate_credential_source_selection(
|
|
@@ -1298,6 +1353,8 @@ class APIClient:
|
|
|
1298
1353
|
body["profile_version"] = profile_version
|
|
1299
1354
|
if instances is not None:
|
|
1300
1355
|
body["instances"] = instances
|
|
1356
|
+
if instance_range is not None:
|
|
1357
|
+
body["instance_range"] = instance_range
|
|
1301
1358
|
if dataset is not None:
|
|
1302
1359
|
body["dataset"] = dataset
|
|
1303
1360
|
if batch_size is not None:
|
|
@@ -2635,6 +2692,8 @@ class APIClient:
|
|
|
2635
2692
|
spec: dict,
|
|
2636
2693
|
description: str = "",
|
|
2637
2694
|
account_pool: Optional[str] = None,
|
|
2695
|
+
metadata: Optional[dict] = None,
|
|
2696
|
+
tags: Optional[list[str]] = None,
|
|
2638
2697
|
) -> dict:
|
|
2639
2698
|
"""Create a new submission profile."""
|
|
2640
2699
|
body: dict = {"name": name, "spec": spec}
|
|
@@ -2642,6 +2701,10 @@ class APIClient:
|
|
|
2642
2701
|
body["description"] = description
|
|
2643
2702
|
if account_pool is not None:
|
|
2644
2703
|
body["account_pool"] = account_pool
|
|
2704
|
+
if metadata is not None:
|
|
2705
|
+
body["metadata"] = metadata
|
|
2706
|
+
if tags is not None:
|
|
2707
|
+
body["tags"] = tags
|
|
2645
2708
|
return self._post(self._PROFILES_BASE, body)
|
|
2646
2709
|
|
|
2647
2710
|
def list_profiles(
|
|
@@ -2649,6 +2712,11 @@ class APIClient:
|
|
|
2649
2712
|
*,
|
|
2650
2713
|
keyword: Optional[str] = None,
|
|
2651
2714
|
template: Optional[str] = None,
|
|
2715
|
+
dataset: Optional[str] = None,
|
|
2716
|
+
scaffold: Optional[str] = None,
|
|
2717
|
+
owner: Optional[str] = None,
|
|
2718
|
+
profile_ids: Optional[list[str]] = None,
|
|
2719
|
+
tags: Optional[list[str]] = None,
|
|
2652
2720
|
show_all: bool = False,
|
|
2653
2721
|
page: int = 1,
|
|
2654
2722
|
page_size: int = 20,
|
|
@@ -2658,6 +2726,12 @@ class APIClient:
|
|
|
2658
2726
|
Args:
|
|
2659
2727
|
keyword: Filter by name keyword.
|
|
2660
2728
|
template: Filter by template name.
|
|
2729
|
+
dataset: Filter by metadata dataset using a case-insensitive substring match.
|
|
2730
|
+
scaffold: Filter by metadata scaffold using a case-insensitive substring match.
|
|
2731
|
+
owner: Filter by an explicit owner ID instead of the current caller.
|
|
2732
|
+
profile_ids: Filter by profile IDs (maximum 100; mutually exclusive
|
|
2733
|
+
with ``keyword``).
|
|
2734
|
+
tags: Filter by profile tags using AND semantics (maximum 10).
|
|
2661
2735
|
show_all: If False (default), return only profiles owned by the
|
|
2662
2736
|
current caller — the owner filter is resolved client-side
|
|
2663
2737
|
via the configured API key, mirroring how the web console
|
|
@@ -2667,21 +2741,40 @@ class APIClient:
|
|
|
2667
2741
|
page_size: Number of items per page.
|
|
2668
2742
|
|
|
2669
2743
|
Raises:
|
|
2670
|
-
ConfigurationError: When
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
the API key cannot be resolved to a user_id.
|
|
2744
|
+
ConfigurationError: When the default Mine view needs an API key
|
|
2745
|
+
but none is configured, or the key cannot be resolved.
|
|
2746
|
+
ValueError: When mutually exclusive filters are combined.
|
|
2674
2747
|
"""
|
|
2675
|
-
if
|
|
2676
|
-
raise
|
|
2677
|
-
|
|
2678
|
-
)
|
|
2748
|
+
if keyword and profile_ids:
|
|
2749
|
+
raise ValueError("profile_ids and keyword are mutually exclusive")
|
|
2750
|
+
if owner and show_all:
|
|
2751
|
+
raise ValueError("owner and show_all are mutually exclusive")
|
|
2752
|
+
if profile_ids and len(profile_ids) > 100:
|
|
2753
|
+
raise ValueError("profile_ids accepts at most 100 values")
|
|
2754
|
+
if tags and len(tags) > 10:
|
|
2755
|
+
raise ValueError("tags accepts at most 10 values")
|
|
2756
|
+
|
|
2679
2757
|
params: dict = {"page": page, "page_size": page_size}
|
|
2680
2758
|
if keyword:
|
|
2681
2759
|
params["keyword"] = keyword
|
|
2682
2760
|
if template:
|
|
2683
2761
|
params["template"] = template
|
|
2684
|
-
if
|
|
2762
|
+
if dataset:
|
|
2763
|
+
params["dataset"] = dataset
|
|
2764
|
+
if scaffold:
|
|
2765
|
+
params["scaffold"] = scaffold
|
|
2766
|
+
if profile_ids:
|
|
2767
|
+
params["profile_ids"] = ",".join(profile_ids)
|
|
2768
|
+
if tags:
|
|
2769
|
+
params["tags"] = ",".join(tags)
|
|
2770
|
+
if owner:
|
|
2771
|
+
params["owner"] = owner
|
|
2772
|
+
elif not show_all:
|
|
2773
|
+
if not self.config.token_key:
|
|
2774
|
+
raise ConfigurationError(
|
|
2775
|
+
"Cannot list own profiles without authentication. "
|
|
2776
|
+
"Set AP_API_KEY environment variable or use --all."
|
|
2777
|
+
)
|
|
2685
2778
|
try:
|
|
2686
2779
|
owner_id = self._resolve_self_user_id()
|
|
2687
2780
|
except APIError as exc:
|
|
@@ -2697,6 +2790,27 @@ class APIClient:
|
|
|
2697
2790
|
params["owner"] = owner_id
|
|
2698
2791
|
return self._get(self._PROFILES_BASE, params=params)
|
|
2699
2792
|
|
|
2793
|
+
def list_profile_tags(
|
|
2794
|
+
self,
|
|
2795
|
+
*,
|
|
2796
|
+
query: Optional[str] = None,
|
|
2797
|
+
limit: Optional[int] = None,
|
|
2798
|
+
) -> dict:
|
|
2799
|
+
"""List distinct tags for SDK consumers building tag selectors or completion UIs."""
|
|
2800
|
+
params: dict = {}
|
|
2801
|
+
if query:
|
|
2802
|
+
params["q"] = query
|
|
2803
|
+
if limit is not None:
|
|
2804
|
+
params["limit"] = limit
|
|
2805
|
+
return self._get(f"{self._PROFILES_BASE}/tags", params=params)
|
|
2806
|
+
|
|
2807
|
+
def replace_profile_tags(self, profile_id: str, tags: list[str]) -> dict:
|
|
2808
|
+
"""Replace all tags on a submission profile; an empty list clears them."""
|
|
2809
|
+
return self._put(
|
|
2810
|
+
f"{self._PROFILES_BASE}/{quote(profile_id)}/tags",
|
|
2811
|
+
{"tags": tags},
|
|
2812
|
+
)
|
|
2813
|
+
|
|
2700
2814
|
def get_profile(self, id_or_name: str, *, version: Optional[int] = None) -> dict:
|
|
2701
2815
|
"""Get a submission profile by ID or name.
|
|
2702
2816
|
|
|
@@ -2713,6 +2827,7 @@ class APIClient:
|
|
|
2713
2827
|
spec: dict,
|
|
2714
2828
|
description: str = "",
|
|
2715
2829
|
account_pool: Optional[str] = None,
|
|
2830
|
+
metadata: Optional[dict] = None,
|
|
2716
2831
|
) -> dict:
|
|
2717
2832
|
"""Update a submission profile, preserving an omitted default Pool."""
|
|
2718
2833
|
body: dict = {"name": name, "spec": spec}
|
|
@@ -2720,6 +2835,8 @@ class APIClient:
|
|
|
2720
2835
|
body["description"] = description
|
|
2721
2836
|
if account_pool is not None:
|
|
2722
2837
|
body["account_pool"] = account_pool
|
|
2838
|
+
if metadata is not None:
|
|
2839
|
+
body["metadata"] = metadata
|
|
2723
2840
|
return self._put(f"{self._PROFILES_BASE}/{quote(profile_id)}", body)
|
|
2724
2841
|
|
|
2725
2842
|
def delete_profile(self, profile_id: str) -> dict:
|
|
@@ -141,7 +141,7 @@ app.add_typer(config_app, name="config")
|
|
|
141
141
|
app.add_typer(template_app, name="template")
|
|
142
142
|
app.add_typer(dataset_app, name="dataset")
|
|
143
143
|
app.add_typer(job_app, name="job")
|
|
144
|
-
|
|
144
|
+
job_app.add_typer(job_tbb_app, name="tbb")
|
|
145
145
|
app.add_typer(group_app, name="group")
|
|
146
146
|
app.add_typer(plan_app, name="plan")
|
|
147
147
|
app.add_typer(datasource_app, name="datasource")
|
|
@@ -844,8 +844,6 @@ def _print_detail(data: object) -> None:
|
|
|
844
844
|
rows.append((key, _format_nested_detail(value)))
|
|
845
845
|
elif key == "image" and isinstance(value, str):
|
|
846
846
|
rows.append((key, _shorten_image(value)))
|
|
847
|
-
elif value is None:
|
|
848
|
-
continue
|
|
849
847
|
else:
|
|
850
848
|
rows.append((key, _format_plain_value(value, max_len=0)))
|
|
851
849
|
width = max((len(label) for label, _text in rows), default=0)
|
|
@@ -2443,7 +2441,7 @@ def job_tbb_list(
|
|
|
2443
2441
|
help="Output format: plain/table/json/yaml (default: AP_FORMAT or command default)",
|
|
2444
2442
|
),
|
|
2445
2443
|
) -> None:
|
|
2446
|
-
"""List pipeline jobs created by ap job
|
|
2444
|
+
"""List pipeline jobs created by ap job tbb run."""
|
|
2447
2445
|
output_format = _normalize_output_format(output_format, keep_table=True)
|
|
2448
2446
|
selected_columns = (
|
|
2449
2447
|
_parse_columns_option(columns) if output_format in {"plain", "table"} else None
|
|
@@ -2823,7 +2821,8 @@ def job_create(
|
|
|
2823
2821
|
"--range",
|
|
2824
2822
|
help=(
|
|
2825
2823
|
"Select a positional subset of instance IDs. Supports END, START,END, "
|
|
2826
|
-
"START,END,STEP. Also accepts START:END[:STEP] and range(START, END, STEP)"
|
|
2824
|
+
"START,END,STEP. Also accepts START:END[:STEP] and range(START, END, STEP). "
|
|
2825
|
+
"With --profile, the server slices instances before trials expansion"
|
|
2827
2826
|
),
|
|
2828
2827
|
),
|
|
2829
2828
|
exclude_instance_ids: Optional[str] = typer.Option(
|
|
@@ -3280,9 +3279,12 @@ def job_create(
|
|
|
3280
3279
|
"--profile is incompatible with --params-list (profile expands params server-side)"
|
|
3281
3280
|
)
|
|
3282
3281
|
raise typer.Exit(1)
|
|
3283
|
-
if instance_range:
|
|
3284
|
-
|
|
3285
|
-
|
|
3282
|
+
if instance_range is not None:
|
|
3283
|
+
try:
|
|
3284
|
+
_parse_instance_range(instance_range)
|
|
3285
|
+
except ValueError as exc:
|
|
3286
|
+
_emit_error(str(exc))
|
|
3287
|
+
raise typer.Exit(1)
|
|
3286
3288
|
if exclude_instance_ids:
|
|
3287
3289
|
_emit_error("--profile is incompatible with --exclude-instance-ids")
|
|
3288
3290
|
raise typer.Exit(1)
|
|
@@ -3298,7 +3300,7 @@ def job_create(
|
|
|
3298
3300
|
|
|
3299
3301
|
suite_name = f"{template or 'profile'}-sp-{profile}-{_dt.now().strftime('%m%d-%H%M')}"
|
|
3300
3302
|
|
|
3301
|
-
# Parse
|
|
3303
|
+
# Parse IDs only; Profile range selection happens on the server.
|
|
3302
3304
|
instances_list: Optional[list[str]] = None
|
|
3303
3305
|
if instance_id:
|
|
3304
3306
|
try:
|
|
@@ -3339,6 +3341,7 @@ def job_create(
|
|
|
3339
3341
|
**({"ak_id": ak_id} if ak_id is not None else {}),
|
|
3340
3342
|
profile_version=profile_version_req,
|
|
3341
3343
|
instances=instances_list,
|
|
3344
|
+
instance_range=instance_range,
|
|
3342
3345
|
dataset=dataset,
|
|
3343
3346
|
batch_size=batch_size,
|
|
3344
3347
|
trials=trials,
|
|
@@ -3354,6 +3357,13 @@ def job_create(
|
|
|
3354
3357
|
_print_formatted(body, "json" if output_format == "plain" else output_format)
|
|
3355
3358
|
return
|
|
3356
3359
|
|
|
3360
|
+
if instance_range is not None:
|
|
3361
|
+
try:
|
|
3362
|
+
client.ensure_profile_range_supported(timeout=timeout)
|
|
3363
|
+
except (ValueError, ConfigurationError) as exc:
|
|
3364
|
+
_emit_error(str(exc))
|
|
3365
|
+
raise typer.Exit(1) from None
|
|
3366
|
+
|
|
3357
3367
|
if dry_run:
|
|
3358
3368
|
_emit_dry_run_warnings(dry_run_warnings)
|
|
3359
3369
|
body["dry_run"] = True
|
|
@@ -3373,7 +3383,7 @@ def job_create(
|
|
|
3373
3383
|
agenthub_ref=agenthub_ref,
|
|
3374
3384
|
instance_id=instance_id,
|
|
3375
3385
|
dataset=dataset,
|
|
3376
|
-
instance_range=
|
|
3386
|
+
instance_range=instance_range,
|
|
3377
3387
|
exclude_instance_ids=None,
|
|
3378
3388
|
params=params,
|
|
3379
3389
|
params_list_input=None,
|
|
@@ -3448,6 +3458,7 @@ def job_create(
|
|
|
3448
3458
|
**({"ak_id": ak_id} if ak_id is not None else {}),
|
|
3449
3459
|
profile_version=profile_version_req,
|
|
3450
3460
|
instances=instances_list,
|
|
3461
|
+
instance_range=instance_range,
|
|
3451
3462
|
dataset=dataset,
|
|
3452
3463
|
batch_size=batch_size,
|
|
3453
3464
|
trials=trials,
|
|
@@ -1909,8 +1909,9 @@ def dataset_split_update(
|
|
|
1909
1909
|
resolved_run_type = _check_choice(run_type, _RUN_TYPES, "--run-type")
|
|
1910
1910
|
|
|
1911
1911
|
if not resolved_version:
|
|
1912
|
-
# 无版本空间改 run_type 走 recordsvc 的 split 记录(PATCH /datasets/splits/detail)
|
|
1913
|
-
#
|
|
1912
|
+
# 无版本空间改 run_type 走 recordsvc 的 split 记录(PATCH /datasets/splits/detail):
|
|
1913
|
+
# 写端点仅限 online——SDK 1.1.2 起派生/显式 pre 都会前置 SchemaError 拒绝,
|
|
1914
|
+
# 不再出现"存在性检查读 pre、写入默默落 online"的读写环境撕裂。
|
|
1914
1915
|
env = _environment(environment)
|
|
1915
1916
|
if env == "pre":
|
|
1916
1917
|
raise _fail(
|
|
@@ -24,6 +24,7 @@ from __future__ import annotations
|
|
|
24
24
|
|
|
25
25
|
import inspect
|
|
26
26
|
import shutil
|
|
27
|
+
import tempfile
|
|
27
28
|
from pathlib import Path
|
|
28
29
|
from typing import Any, List, Optional
|
|
29
30
|
|
|
@@ -256,7 +257,7 @@ def _sdk_read_kwargs(sdk_obj: Any, method: str, *, environment: str, metadata_mo
|
|
|
256
257
|
if dropped:
|
|
257
258
|
raise _fail(
|
|
258
259
|
f"已安装的 instance-repo 不支持按调用指定 {'/'.join(dropped)}",
|
|
259
|
-
"升级到 instance-repo 1.1.
|
|
260
|
+
"升级到 instance-repo 1.1.2 及以上后重试",
|
|
260
261
|
exit_code=2,
|
|
261
262
|
)
|
|
262
263
|
return kwargs
|
|
@@ -714,20 +715,37 @@ def instance_pull(
|
|
|
714
715
|
"instance pull 不支持公网下载;请在 AP dev cluster / ROCK VPC 内执行",
|
|
715
716
|
)
|
|
716
717
|
|
|
718
|
+
# SDK 的 swe/custom 下载会先往输出目录写 <id>.json 与 .probe 中间文件,再由
|
|
719
|
+
# _place_under_target 搬进 <id>/——把用户目录直接交给它,未加 --overwrite 也会
|
|
720
|
+
# 覆盖目标同级的同名文件。先落到独立临时目录,归一后再整体搬入最终目标。
|
|
721
|
+
staging = Path(tempfile.mkdtemp(prefix=f"ap-pull-{instance}-"))
|
|
717
722
|
try:
|
|
723
|
+
try:
|
|
724
|
+
written = repo.instances.pull(dataset, resolved, instance, staging, split)
|
|
725
|
+
except Exception as exc: # noqa: BLE001
|
|
726
|
+
sdk_error_exit(exc, what=f"instance {instance}", stage="拉取")
|
|
727
|
+
staged = _place_under_target(Path(str(written)), staging / instance)
|
|
718
728
|
root.mkdir(parents=True, exist_ok=True)
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
729
|
+
if target.exists():
|
|
730
|
+
if not overwrite:
|
|
731
|
+
raise _fail(
|
|
732
|
+
f"instance {instance} 拉取目标 {target} 已存在",
|
|
733
|
+
"换一个 --output/-o 目录,或加 --overwrite 允许覆盖",
|
|
734
|
+
)
|
|
735
|
+
if target.is_dir():
|
|
736
|
+
shutil.rmtree(target)
|
|
737
|
+
else:
|
|
738
|
+
target.unlink()
|
|
739
|
+
shutil.move(str(staging / instance), str(target))
|
|
740
|
+
finally:
|
|
741
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
724
742
|
payload = {
|
|
725
743
|
"instance": instance,
|
|
726
744
|
"dataset": dataset,
|
|
727
745
|
"version": resolved,
|
|
728
746
|
"split": split,
|
|
729
747
|
"output": str(target),
|
|
730
|
-
"content": str(
|
|
748
|
+
"content": str(target / staged.name),
|
|
731
749
|
}
|
|
732
750
|
_emit_detail(payload, output_format)
|
|
733
751
|
|
|
@@ -308,7 +308,7 @@ _BIZ_CODE_RULES: dict[int, tuple[str, int]] = {
|
|
|
308
308
|
),
|
|
309
309
|
94018: ("当前角色只读,写操作需要 writer 角色;让 admin 执行 ap dataset access grant", 1),
|
|
310
310
|
94019: ("dataset 标识冲突:检查 --dataset 的取值是否与已有 dataset 重名", 1),
|
|
311
|
-
94020: ("请求未带 dataset 标识(SDK 过旧),升级:pip install -U 'instance-repo[oss]>=1.1.
|
|
311
|
+
94020: ("请求未带 dataset 标识(SDK 过旧),升级:pip install -U 'instance-repo[oss]>=1.1.2'", 1),
|
|
312
312
|
94021: ("该 dataset 不存在,先执行:ap dataset create <dataset> --benchmark <b>", 1),
|
|
313
313
|
94022: (
|
|
314
314
|
"无该用户存储空间的授权,让空间主人执行:ap fs access grant --user <你的工号> --role reader",
|
|
@@ -330,7 +330,7 @@ _CODE_RULES: dict[str, tuple[str, int]] = {
|
|
|
330
330
|
"E_DIGEST_MISMATCH": ("内容校验失败,重新拉取或重新打包该 instance 后再试", 1),
|
|
331
331
|
"E_IMMUTABLE": ("已发布的版本不可修改,创建新版本再操作", 1),
|
|
332
332
|
"E_FORBIDDEN": ("当前身份无权执行该操作,确认 AP_API_KEY 与 dataset 角色", 1),
|
|
333
|
-
"E_OWNER_REF_MISSING": ("缺少上游来源引用,升级:pip install -U 'instance-repo[oss]>=1.1.
|
|
333
|
+
"E_OWNER_REF_MISSING": ("缺少上游来源引用,升级:pip install -U 'instance-repo[oss]>=1.1.2'", 1),
|
|
334
334
|
"E_LAYOUT": ("instance 目录结构不合规,先执行 ap instance validate <path> 定位问题", 1),
|
|
335
335
|
"E_SCHEMA": ("instance 元数据不符合 schema,先执行 ap instance validate <path> 定位问题", 1),
|
|
336
336
|
"E_CRED_EXPIRED": ("临时凭据已过期,重试该命令即可重新换取", 1),
|
|
@@ -105,6 +105,25 @@ def _load_spec(
|
|
|
105
105
|
return data
|
|
106
106
|
|
|
107
107
|
|
|
108
|
+
def _load_metadata(metadata_inline: Optional[str]) -> Optional[dict]:
|
|
109
|
+
"""Parse the currently supported explicit profile metadata override."""
|
|
110
|
+
if metadata_inline is None:
|
|
111
|
+
return None
|
|
112
|
+
try:
|
|
113
|
+
data = json.loads(metadata_inline)
|
|
114
|
+
except json.JSONDecodeError as exc:
|
|
115
|
+
raise typer.BadParameter(f"Invalid JSON in --metadata: {exc}") from exc
|
|
116
|
+
if not isinstance(data, dict):
|
|
117
|
+
raise typer.BadParameter("Metadata must be a JSON object")
|
|
118
|
+
unsupported_keys = set(data) - {"scaffold"}
|
|
119
|
+
if unsupported_keys:
|
|
120
|
+
raise typer.BadParameter(
|
|
121
|
+
"--metadata only supports the scaffold key; "
|
|
122
|
+
"dataset and split are derived from the profile spec"
|
|
123
|
+
)
|
|
124
|
+
return data
|
|
125
|
+
|
|
126
|
+
|
|
108
127
|
# Name constraint — single source of truth shared by help text, CLI
|
|
109
128
|
# validation, and error messages. Server-side ValidateProfileName
|
|
110
129
|
# enforces the same rule; keep in sync when changing.
|
|
@@ -284,6 +303,14 @@ def profile_create(
|
|
|
284
303
|
"--account-pool",
|
|
285
304
|
help="Default Account Pool ID used when a submission does not override it",
|
|
286
305
|
),
|
|
306
|
+
metadata_inline: Optional[str] = typer.Option(
|
|
307
|
+
None,
|
|
308
|
+
"--metadata",
|
|
309
|
+
help="Profile metadata override as JSON (currently only scaffold; dataset/split are derived)",
|
|
310
|
+
),
|
|
311
|
+
tags: Optional[list[str]] = typer.Option(
|
|
312
|
+
None, "--tag", help="Initial profile tag (repeatable)"
|
|
313
|
+
),
|
|
287
314
|
output_format: str = typer.Option(
|
|
288
315
|
None,
|
|
289
316
|
"--format",
|
|
@@ -299,12 +326,15 @@ def profile_create(
|
|
|
299
326
|
raise typer.Exit(1)
|
|
300
327
|
output_format = _normalize_output_format(output_format)
|
|
301
328
|
spec = _load_spec(file, spec_inline)
|
|
329
|
+
metadata = _load_metadata(metadata_inline)
|
|
302
330
|
client = get_client()
|
|
303
331
|
result = client.create_profile(
|
|
304
332
|
name=name,
|
|
305
333
|
spec=spec,
|
|
306
334
|
description=description,
|
|
307
335
|
account_pool=account_pool,
|
|
336
|
+
metadata=metadata,
|
|
337
|
+
tags=tags,
|
|
308
338
|
)
|
|
309
339
|
if output_format == "plain":
|
|
310
340
|
print("[green]Profile created:[/]")
|
|
@@ -313,10 +343,54 @@ def profile_create(
|
|
|
313
343
|
_print_formatted(result, output_format)
|
|
314
344
|
|
|
315
345
|
|
|
346
|
+
@profile_app.command("set-tags")
|
|
347
|
+
def profile_set_tags(
|
|
348
|
+
identifier: str = typer.Argument(..., help="Profile ID or name"),
|
|
349
|
+
tags: Optional[list[str]] = typer.Option(
|
|
350
|
+
None, "--tag", help="Replacement profile tag (repeatable)"
|
|
351
|
+
),
|
|
352
|
+
clear: bool = typer.Option(False, "--clear", help="Clear all profile tags"),
|
|
353
|
+
output_format: str = typer.Option(
|
|
354
|
+
None,
|
|
355
|
+
"--format",
|
|
356
|
+
help="Output format: plain/table/json/yaml (default: AP_FORMAT or command default)",
|
|
357
|
+
),
|
|
358
|
+
):
|
|
359
|
+
"""Replace all tags on a submission profile."""
|
|
360
|
+
if bool(tags) == clear:
|
|
361
|
+
raise typer.BadParameter("exactly one of --tag or --clear is required")
|
|
362
|
+
output_format = _normalize_output_format(output_format)
|
|
363
|
+
client = get_client()
|
|
364
|
+
replacement = [] if clear else tags or []
|
|
365
|
+
result = client.replace_profile_tags(identifier, replacement)
|
|
366
|
+
if output_format == "plain":
|
|
367
|
+
payload = result.get("data", result) if isinstance(result, dict) else {}
|
|
368
|
+
effective_tags = (
|
|
369
|
+
payload.get("tags", replacement) if isinstance(payload, dict) else replacement
|
|
370
|
+
)
|
|
371
|
+
shown = ", ".join(effective_tags) if effective_tags else "(cleared)"
|
|
372
|
+
print(f"[green]Profile tags updated:[/] {identifier} -> {shown}")
|
|
373
|
+
else:
|
|
374
|
+
_print_formatted(result, output_format)
|
|
375
|
+
|
|
376
|
+
|
|
316
377
|
@profile_app.command("list")
|
|
317
378
|
def profile_list(
|
|
318
379
|
keyword: Optional[str] = typer.Option(None, "--keyword", "-k", help="Filter by name keyword"),
|
|
319
380
|
template: Optional[str] = typer.Option(None, "--template", "-t", help="Filter by template"),
|
|
381
|
+
dataset: Optional[str] = typer.Option(
|
|
382
|
+
None, "--dataset", help="Filter by metadata dataset (case-insensitive substring)"
|
|
383
|
+
),
|
|
384
|
+
scaffold: Optional[str] = typer.Option(
|
|
385
|
+
None, "--scaffold", help="Filter by metadata scaffold (case-insensitive substring)"
|
|
386
|
+
),
|
|
387
|
+
owner: Optional[str] = typer.Option(None, "--owner", help="Filter by owner ID"),
|
|
388
|
+
profile_ids: Optional[list[str]] = typer.Option(
|
|
389
|
+
None, "--profile-id", help="Filter by profile ID (repeatable; max 100)"
|
|
390
|
+
),
|
|
391
|
+
tags: Optional[list[str]] = typer.Option(
|
|
392
|
+
None, "--tag", help="Filter by tag (repeatable; max 10; combined with AND)"
|
|
393
|
+
),
|
|
320
394
|
all_profiles: bool = typer.Option(
|
|
321
395
|
False, "--all", "-a", help="Show all profiles (not just own)"
|
|
322
396
|
),
|
|
@@ -329,11 +403,24 @@ def profile_list(
|
|
|
329
403
|
),
|
|
330
404
|
):
|
|
331
405
|
"""List submission profiles (default: own profiles only)."""
|
|
406
|
+
if keyword and profile_ids:
|
|
407
|
+
raise typer.BadParameter("--profile-id and --keyword are mutually exclusive")
|
|
408
|
+
if owner and all_profiles:
|
|
409
|
+
raise typer.BadParameter("--owner and --all are mutually exclusive")
|
|
410
|
+
if profile_ids and len(profile_ids) > 100:
|
|
411
|
+
raise typer.BadParameter("--profile-id accepts at most 100 values")
|
|
412
|
+
if tags and len(tags) > 10:
|
|
413
|
+
raise typer.BadParameter("--tag accepts at most 10 values")
|
|
332
414
|
output_format = _normalize_output_format(output_format)
|
|
333
415
|
client = get_client()
|
|
334
416
|
result = client.list_profiles(
|
|
335
417
|
keyword=keyword,
|
|
336
418
|
template=template,
|
|
419
|
+
dataset=dataset,
|
|
420
|
+
scaffold=scaffold,
|
|
421
|
+
owner=owner,
|
|
422
|
+
profile_ids=profile_ids,
|
|
423
|
+
tags=tags,
|
|
337
424
|
show_all=all_profiles,
|
|
338
425
|
page=page,
|
|
339
426
|
page_size=page_size,
|
|
@@ -377,6 +464,11 @@ def profile_update(
|
|
|
377
464
|
"--account-pool",
|
|
378
465
|
help="Set the default Account Pool ID; pass an empty value to clear it",
|
|
379
466
|
),
|
|
467
|
+
metadata_inline: Optional[str] = typer.Option(
|
|
468
|
+
None,
|
|
469
|
+
"--metadata",
|
|
470
|
+
help="Profile metadata override as JSON (currently only scaffold; dataset/split are derived)",
|
|
471
|
+
),
|
|
380
472
|
output_format: str = typer.Option(
|
|
381
473
|
None,
|
|
382
474
|
"--format",
|
|
@@ -391,6 +483,7 @@ def profile_update(
|
|
|
391
483
|
"""
|
|
392
484
|
output_format = _normalize_output_format(output_format)
|
|
393
485
|
spec = _load_spec(file, spec_inline)
|
|
486
|
+
metadata = _load_metadata(metadata_inline)
|
|
394
487
|
|
|
395
488
|
# Get current profile to fill in unchanged fields (name is preserved
|
|
396
489
|
# verbatim so that the server's `name` required-field validation
|
|
@@ -400,6 +493,7 @@ def profile_update(
|
|
|
400
493
|
|
|
401
494
|
final_description = description if description is not None else current.get("description", "")
|
|
402
495
|
final_account_pool = account_pool if account_pool is not None else current.get("account_pool")
|
|
496
|
+
final_metadata = metadata if metadata is not None else current.get("metadata")
|
|
403
497
|
|
|
404
498
|
result = client.update_profile(
|
|
405
499
|
profile_id=current.get("profile_id") or current.get("id") or identifier,
|
|
@@ -407,6 +501,7 @@ def profile_update(
|
|
|
407
501
|
spec=spec,
|
|
408
502
|
description=final_description,
|
|
409
503
|
account_pool=final_account_pool,
|
|
504
|
+
metadata=final_metadata,
|
|
410
505
|
)
|
|
411
506
|
if output_format == "plain":
|
|
412
507
|
print("[green]Profile updated:[/]")
|
|
@@ -422,7 +517,7 @@ def profile_delete(
|
|
|
422
517
|
):
|
|
423
518
|
"""Delete a submission profile."""
|
|
424
519
|
if not yes:
|
|
425
|
-
typer.confirm(f"
|
|
520
|
+
typer.confirm(f"Delete profile '{identifier}'?", abort=True)
|
|
426
521
|
client = get_client()
|
|
427
522
|
try:
|
|
428
523
|
client.delete_profile(identifier)
|
|
@@ -71,7 +71,7 @@ def merge_tbb_tags(command: str, user_tags: Sequence[str] | None) -> list[str]:
|
|
|
71
71
|
if not value:
|
|
72
72
|
continue
|
|
73
73
|
if value.startswith("tbb-pipeline:"):
|
|
74
|
-
raise TBBProtocolError("tbb-pipeline:* is reserved by ap job
|
|
74
|
+
raise TBBProtocolError("tbb-pipeline:* is reserved by ap job tbb")
|
|
75
75
|
if value in seen:
|
|
76
76
|
continue
|
|
77
77
|
merged.append(value)
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ap-client"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.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"
|
|
@@ -18,10 +18,10 @@ dependencies = [
|
|
|
18
18
|
|
|
19
19
|
[project.optional-dependencies]
|
|
20
20
|
dataset = [
|
|
21
|
-
"instance-repo[oss]>=1.1.0",
|
|
21
|
+
"instance-repo[oss]>=1.1.2,<2.0.0",
|
|
22
22
|
]
|
|
23
23
|
all = [
|
|
24
|
-
"instance-repo[oss]>=1.1.0",
|
|
24
|
+
"instance-repo[oss]>=1.1.2,<2.0.0",
|
|
25
25
|
]
|
|
26
26
|
|
|
27
27
|
[project.scripts]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|