ap-client 0.3.2.dev0__tar.gz → 0.4.0__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.3.2.dev0
3
+ Version: 0.4.0
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]>=1.1.0; extra == 'all'
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]>=1.1.0; extra == 'dataset'
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 no API key is configured. Listing
2671
- profiles always requires authentication, regardless of
2672
- ``show_all``. Also raised when ``show_all`` is False but
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 not self.config.token_key:
2676
- raise ConfigurationError(
2677
- "Cannot list profiles without authentication. Set AP_API_KEY environment variable."
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 not show_all:
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
- app.add_typer(job_tbb_app, name="job-tbb")
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-tbb run."""
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
- _emit_error("--profile is incompatible with --range")
3285
- raise typer.Exit(1)
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 instances from -i (simple comma split, no range/exclude expansion)
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=None,
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
- # 服务端**显式** pre 会 400;缺省不下发(SDK 不带该参数 → 服务端按 online 处理)。
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.0 及以上后重试",
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
- written = repo.instances.pull(dataset, resolved, instance, root, split)
720
- except Exception as exc: # noqa: BLE001
721
- sdk_error_exit(exc, what=f"instance {instance}", stage="拉取")
722
-
723
- written = _place_under_target(Path(str(written)), target)
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(written),
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.0'", 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.0'", 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"Permanently delete profile '{identifier}'?", abort=True)
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-tbb")
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.3.2.dev0"
7
+ version = "0.4.0"
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]