ap-client 0.3.2.dev1__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.dev1
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]>=0.7.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]>=0.7.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
 
@@ -294,6 +324,25 @@ def _secret_ws_params(workspace_id: Optional[str]) -> Optional[dict]:
294
324
  return {"workspace_id": workspace_id} if workspace_id else None
295
325
 
296
326
 
327
+ def _split_paged(payload: Any) -> tuple[list[dict], dict]:
328
+ """Split a ``PagedSuccess`` envelope into ``(records, pagination)``.
329
+
330
+ ``PagedSuccess`` is ``{code,message,data:[...],pagination:{total,page,page_size}}``.
331
+ A bare list (or a null ``data``) yields an empty pagination dict so callers
332
+ can render results without probing the response shape.
333
+ """
334
+ if isinstance(payload, list):
335
+ return [item for item in payload if isinstance(item, dict)], {}
336
+ if not isinstance(payload, dict):
337
+ return [], {}
338
+ records = payload.get("data")
339
+ pagination = payload.get("pagination")
340
+ return (
341
+ [item for item in (records or []) if isinstance(item, dict)],
342
+ pagination if isinstance(pagination, dict) else {},
343
+ )
344
+
345
+
297
346
  class APIClient:
298
347
  """Agent Platform API client."""
299
348
 
@@ -458,6 +507,28 @@ class APIClient:
458
507
  """Send a GET request."""
459
508
  return self._request("GET", path, params=params, timeout=timeout)
460
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
+
461
532
  def _post(self, path: str, data: Any, timeout: TimeoutType = None) -> Any:
462
533
  """Send a POST request."""
463
534
  from .managed_ak import parameter_ak_ids, require_parameter_ak_ack
@@ -714,8 +785,35 @@ class APIClient:
714
785
  return self._get(f"/templates/{quote(name)}", params=params)
715
786
 
716
787
  def list_benchmarks(self) -> list:
717
- """List all active benchmarks from the local benchmark registry."""
718
- return self._get("/benchmarks")
788
+ """List all benchmarks from the central registry, preserving legacy IDs."""
789
+ benchmarks = []
790
+ page = 1
791
+ page_size = 200
792
+ # 与 CLI dataset list 的自动翻页同口径:100 页上限,防分页异常导致死循环。
793
+ max_pages = 100
794
+ while page <= max_pages:
795
+ payload = self._get(
796
+ "/apis/v1/benchmarks", params={"page": page, "page_size": page_size}
797
+ )
798
+ records, pagination = _split_paged(payload)
799
+ for record in records:
800
+ item = dict(record)
801
+ if "source_id" in item:
802
+ item.setdefault("id", item["source_id"])
803
+ benchmarks.append(item)
804
+ if not records or not pagination:
805
+ break
806
+ try:
807
+ total = int(pagination.get("total"))
808
+ except (TypeError, ValueError):
809
+ total = None
810
+ if total is not None:
811
+ if len(benchmarks) >= total:
812
+ break
813
+ elif len(records) < page_size:
814
+ break
815
+ page += 1
816
+ return benchmarks
719
817
 
720
818
  def get_benchmark(self, name: str) -> dict:
721
819
  """Get a single benchmark by exact name."""
@@ -850,7 +948,16 @@ class APIClient:
850
948
  "instance_ids": all_instance_ids,
851
949
  }
852
950
 
853
- # ==================== Meta operations ====================
951
+ # ==================== Dataset series (apiserver dataset domain) ====================
952
+ #
953
+ # 这一节对接 Go apiserver 的 dataset 领域(/apis/v1/datasets/...),与上面
954
+ # ossdata 目录的 /api/datasets 路由完全无关。
955
+ #
956
+ # **dataset 领域的控制面读写全部在 InstanceRepo SDK 上**(见
957
+ # docs/ap-cli-dataset-refactor-design.md §9.5):迁移期加进这里的 registry 方法、
958
+ # 以及最后三条"push 前检查 + publish-status"的旧读,都已随迁移删除。
959
+ # 本文件现在只服务旧 ossdata 路径(`dataset list --legacy`、弃用的
960
+ # `dataset versions/instances`、`job create --dataset`)。
854
961
 
855
962
  def list_meta_models(self) -> dict:
856
963
  """模型词表(apiserver 权威源 GET /apis/v1/meta-models,返回 {items:[...]})。"""
@@ -1042,6 +1149,7 @@ class APIClient:
1042
1149
  benchmark: Optional[str] = None,
1043
1150
  experiment: Optional[str] = None,
1044
1151
  ak_id: Optional[str] = None,
1152
+ instance_range: Optional[str] = None,
1045
1153
  ) -> dict:
1046
1154
  """Create a job."""
1047
1155
  body = self.build_create_job_body(
@@ -1079,6 +1187,7 @@ class APIClient:
1079
1187
  batch_size=batch_size,
1080
1188
  trials=trials,
1081
1189
  resource_profile_id=resource_profile_id,
1190
+ instance_range=instance_range,
1082
1191
  meta_model=meta_model,
1083
1192
  meta_job_type=meta_job_type,
1084
1193
  upstream_platform=upstream_platform,
@@ -1150,6 +1259,7 @@ class APIClient:
1150
1259
  benchmark: Optional[str] = None,
1151
1260
  experiment: Optional[str] = None,
1152
1261
  ak_id: Optional[str] = None,
1262
+ instance_range: Optional[str] = None,
1153
1263
  ) -> dict:
1154
1264
  """Build the /jobs request body for job creation."""
1155
1265
  _validate_credential_source_selection(
@@ -1243,6 +1353,8 @@ class APIClient:
1243
1353
  body["profile_version"] = profile_version
1244
1354
  if instances is not None:
1245
1355
  body["instances"] = instances
1356
+ if instance_range is not None:
1357
+ body["instance_range"] = instance_range
1246
1358
  if dataset is not None:
1247
1359
  body["dataset"] = dataset
1248
1360
  if batch_size is not None:
@@ -2580,6 +2692,8 @@ class APIClient:
2580
2692
  spec: dict,
2581
2693
  description: str = "",
2582
2694
  account_pool: Optional[str] = None,
2695
+ metadata: Optional[dict] = None,
2696
+ tags: Optional[list[str]] = None,
2583
2697
  ) -> dict:
2584
2698
  """Create a new submission profile."""
2585
2699
  body: dict = {"name": name, "spec": spec}
@@ -2587,6 +2701,10 @@ class APIClient:
2587
2701
  body["description"] = description
2588
2702
  if account_pool is not None:
2589
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
2590
2708
  return self._post(self._PROFILES_BASE, body)
2591
2709
 
2592
2710
  def list_profiles(
@@ -2594,6 +2712,11 @@ class APIClient:
2594
2712
  *,
2595
2713
  keyword: Optional[str] = None,
2596
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,
2597
2720
  show_all: bool = False,
2598
2721
  page: int = 1,
2599
2722
  page_size: int = 20,
@@ -2603,6 +2726,12 @@ class APIClient:
2603
2726
  Args:
2604
2727
  keyword: Filter by name keyword.
2605
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).
2606
2735
  show_all: If False (default), return only profiles owned by the
2607
2736
  current caller — the owner filter is resolved client-side
2608
2737
  via the configured API key, mirroring how the web console
@@ -2612,21 +2741,40 @@ class APIClient:
2612
2741
  page_size: Number of items per page.
2613
2742
 
2614
2743
  Raises:
2615
- ConfigurationError: When no API key is configured. Listing
2616
- profiles always requires authentication, regardless of
2617
- ``show_all``. Also raised when ``show_all`` is False but
2618
- 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.
2619
2747
  """
2620
- if not self.config.token_key:
2621
- raise ConfigurationError(
2622
- "Cannot list profiles without authentication. Set AP_API_KEY environment variable."
2623
- )
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
+
2624
2757
  params: dict = {"page": page, "page_size": page_size}
2625
2758
  if keyword:
2626
2759
  params["keyword"] = keyword
2627
2760
  if template:
2628
2761
  params["template"] = template
2629
- 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
+ )
2630
2778
  try:
2631
2779
  owner_id = self._resolve_self_user_id()
2632
2780
  except APIError as exc:
@@ -2642,6 +2790,27 @@ class APIClient:
2642
2790
  params["owner"] = owner_id
2643
2791
  return self._get(self._PROFILES_BASE, params=params)
2644
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
+
2645
2814
  def get_profile(self, id_or_name: str, *, version: Optional[int] = None) -> dict:
2646
2815
  """Get a submission profile by ID or name.
2647
2816
 
@@ -2658,6 +2827,7 @@ class APIClient:
2658
2827
  spec: dict,
2659
2828
  description: str = "",
2660
2829
  account_pool: Optional[str] = None,
2830
+ metadata: Optional[dict] = None,
2661
2831
  ) -> dict:
2662
2832
  """Update a submission profile, preserving an omitted default Pool."""
2663
2833
  body: dict = {"name": name, "spec": spec}
@@ -2665,6 +2835,8 @@ class APIClient:
2665
2835
  body["description"] = description
2666
2836
  if account_pool is not None:
2667
2837
  body["account_pool"] = account_pool
2838
+ if metadata is not None:
2839
+ body["metadata"] = metadata
2668
2840
  return self._put(f"{self._PROFILES_BASE}/{quote(profile_id)}", body)
2669
2841
 
2670
2842
  def delete_profile(self, profile_id: str) -> dict:
@@ -24,8 +24,10 @@ from ap_client.api import (
24
24
  set_verbose_override,
25
25
  )
26
26
  from ap_client.config import ENV_VAR_SPECS, ConfigurationError, _parse_bool, normalize_output_format
27
+ from ap_client.dataset_commands import register as _register_dataset_commands
27
28
  from ap_client.exporter import export_group, export_job
28
- from ap_client.irepo_commands import register as _register_dataset_repo
29
+ from ap_client.fs_commands import register as _register_fs_commands
30
+ from ap_client.instance_commands import register as _register_instance_commands
29
31
  from ap_client.managed_ak import (
30
32
  ManagedAKProtocolError,
31
33
  require_managed_ak_ack,
@@ -139,7 +141,7 @@ app.add_typer(config_app, name="config")
139
141
  app.add_typer(template_app, name="template")
140
142
  app.add_typer(dataset_app, name="dataset")
141
143
  app.add_typer(job_app, name="job")
142
- app.add_typer(job_tbb_app, name="job-tbb")
144
+ job_app.add_typer(job_tbb_app, name="tbb")
143
145
  app.add_typer(group_app, name="group")
144
146
  app.add_typer(plan_app, name="plan")
145
147
  app.add_typer(datasource_app, name="datasource")
@@ -153,8 +155,11 @@ app.add_typer(meta_job_type_app, name="meta-job-type")
153
155
  app.add_typer(benchmark_app, name="benchmark")
154
156
  app.add_typer(checkpoint_app, name="checkpoint")
155
157
 
156
- # ap dataset repo:透传 instance_repo CLI(见 ap_client/irepo_commands.py)
157
- _register_dataset_repo(dataset_app)
158
+ # Dataset 领域(见 docs/ap-cli-dataset-refactor-design.md):
159
+ # ap dataset version/split/access 嵌套在 dataset 下;instance/fs 是一等资源。
160
+ _register_dataset_commands(dataset_app)
161
+ _register_instance_commands(app)
162
+ _register_fs_commands(app)
158
163
 
159
164
  _PAI_RUNTIME_ENV_TAGS: tuple[tuple[str, str], ...] = (
160
165
  ("DLC_JOB_ID", "dlc_job_id"),
@@ -810,26 +815,43 @@ def _print_json_block(data: Any) -> None:
810
815
  typer.echo(json.dumps(data, ensure_ascii=False, indent=2))
811
816
 
812
817
 
818
+ def _format_nested_detail(value: Any) -> str:
819
+ """Nested dict/list for detail views: compact one-liner, pretty JSON when long.
820
+
821
+ A detail view must not silently drop data - the manifest in ``ap instance
822
+ get`` used to be cut mid-JSON by the one-liner preview.
823
+ """
824
+ compact = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
825
+ if len(compact) <= _PLAIN_VALUE_PREVIEW:
826
+ return compact
827
+ return json.dumps(value, ensure_ascii=False, indent=2)
828
+
829
+
813
830
  def _print_detail(data: object) -> None:
814
831
  """Render a single object as an aligned key/value detail view.
815
832
 
816
833
  Top-level scalars become key/value rows (``image`` fields shortened); nested
817
- dict/list fields are shown as compact one-liners. Falls back to JSON for
834
+ dict/list fields are shown as compact one-liners, or pretty-printed across
835
+ multiple lines when the one-liner would not fit. Falls back to JSON for
818
836
  non-dict payloads.
819
837
  """
820
838
  if not isinstance(data, dict):
821
839
  _print_json(data)
822
840
  return
823
- scalar_rows: list[tuple[str, Any]] = []
824
- nested_rows: list[tuple[str, Any]] = []
841
+ rows: list[tuple[str, str]] = []
825
842
  for key, value in data.items():
826
843
  if isinstance(value, (dict, list)):
827
- nested_rows.append((key, value))
844
+ rows.append((key, _format_nested_detail(value)))
828
845
  elif key == "image" and isinstance(value, str):
829
- scalar_rows.append((key, _shorten_image(value)))
846
+ rows.append((key, _shorten_image(value)))
830
847
  else:
831
- scalar_rows.append((key, value))
832
- _print_key_values(scalar_rows + nested_rows, skip_empty=False)
848
+ rows.append((key, _format_plain_value(value, max_len=0)))
849
+ width = max((len(label) for label, _text in rows), default=0)
850
+ indent = " " * (width + 4)
851
+ for label, text in rows:
852
+ if "\n" in text:
853
+ text = text.replace("\n", f"\n{indent}")
854
+ typer.echo(f" {label.ljust(width)}: {text}")
833
855
 
834
856
 
835
857
  def _print_template_get_plain(t: dict) -> None:
@@ -939,7 +961,7 @@ def _print_field_value(value: Any, output_format: str) -> None:
939
961
  _print_formatted(value, "json" if output_format == "plain" else output_format)
940
962
 
941
963
 
942
- def _format_plain_value(value: Any) -> str:
964
+ def _format_plain_value(value: Any, *, max_len: int = _PLAIN_VALUE_PREVIEW) -> str:
943
965
  if value is None:
944
966
  return ""
945
967
  if isinstance(value, (dict, list)):
@@ -947,9 +969,9 @@ def _format_plain_value(value: Any) -> str:
947
969
  else:
948
970
  text = str(value)
949
971
  text = text.replace("\r", "\\r").replace("\n", "\\n")
950
- if len(text) <= _PLAIN_VALUE_PREVIEW:
972
+ if not max_len or len(text) <= max_len:
951
973
  return text
952
- return f"{text[: _PLAIN_VALUE_PREVIEW - 3]}..."
974
+ return f"{text[: max_len - 3]}..."
953
975
 
954
976
 
955
977
  def _print_key_values(rows: list[tuple[str, Any]], *, skip_empty: bool = True) -> None:
@@ -2068,24 +2090,17 @@ def template_fetch(
2068
2090
 
2069
2091
  # ==================== Dataset operations ====================
2070
2092
 
2071
-
2072
- @dataset_app.command("list")
2073
- def dataset_list(
2074
- search: Optional[str] = typer.Argument(None, help="Search keyword"),
2075
- output_format: str = typer.Option(
2076
- None,
2077
- "--format",
2078
- help="Output format: plain/table/json/yaml (default: AP_FORMAT or command default)",
2079
- ),
2080
- ):
2081
- """List all datasets."""
2082
- output_format = _normalize_output_format(output_format, keep_table=True)
2083
- client = get_client()
2084
- result = client.list_all_datasets(search)
2085
- if output_format == "table":
2086
- _print_records_table(result)
2087
- else:
2088
- _print_formatted(result, output_format)
2093
+ # 设计 §6 / 上游 §13.2:`ap dataset versions` 与 `ap dataset instances` 行为保持不变
2094
+ # (仍打 ossdata 路由,`ap job create` 的配套流程依赖它们),只追加固定弃用文案。
2095
+ # 文案**只写 stderr**——JSON/YAML 的 stdout 必须仍可被 json.loads 直接消费。
2096
+ _DATASET_VERSIONS_DEPRECATION = (
2097
+ "Warning: `ap dataset versions` is deprecated.\n"
2098
+ "Use `ap dataset version list --dataset <dataset>` instead."
2099
+ )
2100
+ _DATASET_INSTANCES_DEPRECATION = (
2101
+ "Warning: `ap dataset instances` is deprecated.\n"
2102
+ "Use `ap instance list --dataset <dataset> --split <split> [--version <version>]` instead."
2103
+ )
2089
2104
 
2090
2105
 
2091
2106
  @dataset_app.command("versions")
@@ -2097,8 +2112,9 @@ def dataset_versions(
2097
2112
  help="Output format: plain/table/json/yaml (default: AP_FORMAT or command default)",
2098
2113
  ),
2099
2114
  ):
2100
- """List dataset versions."""
2115
+ """List dataset versions (deprecated: use 'ap dataset version list')."""
2101
2116
  output_format = _normalize_output_format(output_format, keep_table=True)
2117
+ _emit_progress(_DATASET_VERSIONS_DEPRECATION)
2102
2118
  client = get_client()
2103
2119
  versions = client.list_dataset_versions(dataset)
2104
2120
  if output_format == "table":
@@ -2118,8 +2134,9 @@ def dataset_instances(
2118
2134
  help="Output format: plain/table/json/yaml (default: AP_FORMAT or command default)",
2119
2135
  ),
2120
2136
  ):
2121
- """List dataset instances."""
2137
+ """List dataset instances (deprecated: use 'ap instance list')."""
2122
2138
  output_format = _normalize_output_format(output_format, keep_table=True)
2139
+ _emit_progress(_DATASET_INSTANCES_DEPRECATION)
2123
2140
  client = get_client()
2124
2141
  result = client.list_all_dataset_instances(dataset_version)
2125
2142
  if output_format == "table":
@@ -2424,7 +2441,7 @@ def job_tbb_list(
2424
2441
  help="Output format: plain/table/json/yaml (default: AP_FORMAT or command default)",
2425
2442
  ),
2426
2443
  ) -> None:
2427
- """List pipeline jobs created by ap job-tbb run."""
2444
+ """List pipeline jobs created by ap job tbb run."""
2428
2445
  output_format = _normalize_output_format(output_format, keep_table=True)
2429
2446
  selected_columns = (
2430
2447
  _parse_columns_option(columns) if output_format in {"plain", "table"} else None
@@ -2804,7 +2821,8 @@ def job_create(
2804
2821
  "--range",
2805
2822
  help=(
2806
2823
  "Select a positional subset of instance IDs. Supports END, START,END, "
2807
- "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"
2808
2826
  ),
2809
2827
  ),
2810
2828
  exclude_instance_ids: Optional[str] = typer.Option(
@@ -3261,9 +3279,12 @@ def job_create(
3261
3279
  "--profile is incompatible with --params-list (profile expands params server-side)"
3262
3280
  )
3263
3281
  raise typer.Exit(1)
3264
- if instance_range:
3265
- _emit_error("--profile is incompatible with --range")
3266
- 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)
3267
3288
  if exclude_instance_ids:
3268
3289
  _emit_error("--profile is incompatible with --exclude-instance-ids")
3269
3290
  raise typer.Exit(1)
@@ -3279,7 +3300,7 @@ def job_create(
3279
3300
 
3280
3301
  suite_name = f"{template or 'profile'}-sp-{profile}-{_dt.now().strftime('%m%d-%H%M')}"
3281
3302
 
3282
- # Parse instances from -i (simple comma split, no range/exclude expansion)
3303
+ # Parse IDs only; Profile range selection happens on the server.
3283
3304
  instances_list: Optional[list[str]] = None
3284
3305
  if instance_id:
3285
3306
  try:
@@ -3320,6 +3341,7 @@ def job_create(
3320
3341
  **({"ak_id": ak_id} if ak_id is not None else {}),
3321
3342
  profile_version=profile_version_req,
3322
3343
  instances=instances_list,
3344
+ instance_range=instance_range,
3323
3345
  dataset=dataset,
3324
3346
  batch_size=batch_size,
3325
3347
  trials=trials,
@@ -3335,6 +3357,13 @@ def job_create(
3335
3357
  _print_formatted(body, "json" if output_format == "plain" else output_format)
3336
3358
  return
3337
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
+
3338
3367
  if dry_run:
3339
3368
  _emit_dry_run_warnings(dry_run_warnings)
3340
3369
  body["dry_run"] = True
@@ -3354,7 +3383,7 @@ def job_create(
3354
3383
  agenthub_ref=agenthub_ref,
3355
3384
  instance_id=instance_id,
3356
3385
  dataset=dataset,
3357
- instance_range=None,
3386
+ instance_range=instance_range,
3358
3387
  exclude_instance_ids=None,
3359
3388
  params=params,
3360
3389
  params_list_input=None,
@@ -3429,6 +3458,7 @@ def job_create(
3429
3458
  **({"ak_id": ak_id} if ak_id is not None else {}),
3430
3459
  profile_version=profile_version_req,
3431
3460
  instances=instances_list,
3461
+ instance_range=instance_range,
3432
3462
  dataset=dataset,
3433
3463
  batch_size=batch_size,
3434
3464
  trials=trials,
@@ -4912,7 +4942,7 @@ def benchmark_list(
4912
4942
  help="Output format: plain/table/json/yaml (default: AP_FORMAT or command default)",
4913
4943
  ),
4914
4944
  ):
4915
- """List benchmarks from the local benchmark registry."""
4945
+ """List benchmarks from the central benchmark registry."""
4916
4946
  output_format = _normalize_output_format(output_format, keep_table=True)
4917
4947
  client = get_client()
4918
4948
  result = client.list_benchmarks()