ap-client 0.2.1.dev0__py3-none-any.whl → 0.2.2.dev0__py3-none-any.whl

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/api.py CHANGED
@@ -268,6 +268,20 @@ class APIError(Exception):
268
268
  return f"API Error {self.status_code}: {self.detail} (request_id={self.request_id})"
269
269
 
270
270
 
271
+ class PaginationProtocolError(RuntimeError):
272
+ """The server stopped honoring a pagination protocol already in use."""
273
+
274
+
275
+ def _ensure_next_token_honored(response: dict, *, next_token: Optional[str], endpoint: str) -> None:
276
+ # An old server ignores the unknown next_token param and answers with its
277
+ # default first page, so a `response.get("next_token")` loop would silently
278
+ # repeat that page and drop the rest of the collection.
279
+ if next_token and "next_token" not in response:
280
+ raise PaginationProtocolError(
281
+ f"server stopped returning next_token while paginating {endpoint}; upgrade the server"
282
+ )
283
+
284
+
271
285
  def _secret_ws_params(workspace_id: Optional[str]) -> Optional[dict]:
272
286
  """Build the ``?workspace_id=`` query for name-addressed secret endpoints.
273
287
 
@@ -736,8 +750,35 @@ class APIClient:
736
750
  return self._get(f"/templates/{quote(name)}", params=params)
737
751
 
738
752
  def list_benchmarks(self) -> list:
739
- """List all active benchmarks from the local benchmark registry."""
740
- return self._get("/benchmarks")
753
+ """List all benchmarks from the central registry, preserving legacy IDs."""
754
+ benchmarks = []
755
+ page = 1
756
+ page_size = 200
757
+ # 与 CLI dataset list 的自动翻页同口径:100 页上限,防分页异常导致死循环。
758
+ max_pages = 100
759
+ while page <= max_pages:
760
+ payload = self._get(
761
+ "/apis/v1/benchmarks", params={"page": page, "page_size": page_size}
762
+ )
763
+ records, pagination = _split_paged(payload)
764
+ for record in records:
765
+ item = dict(record)
766
+ if "source_id" in item:
767
+ item.setdefault("id", item["source_id"])
768
+ benchmarks.append(item)
769
+ if not records or not pagination:
770
+ break
771
+ try:
772
+ total = int(pagination.get("total"))
773
+ except (TypeError, ValueError):
774
+ total = None
775
+ if total is not None:
776
+ if len(benchmarks) >= total:
777
+ break
778
+ elif len(records) < page_size:
779
+ break
780
+ page += 1
781
+ return benchmarks
741
782
 
742
783
  def get_benchmark(self, name: str) -> dict:
743
784
  """Get a single benchmark by exact name."""
@@ -929,10 +970,6 @@ class APIClient:
929
970
  params["environment"] = environment
930
971
  return _unwrap_data(self._get(f"{self._DATASET_SERIES_BASE}/detail", params=params))
931
972
 
932
- def create_dataset_series(self, body: dict) -> dict:
933
- """创建 dataset series。owner 由服务端按调用者身份填充。"""
934
- return _unwrap_data(self._post(self._DATASET_SERIES_BASE, body))
935
-
936
973
  def update_dataset_series(self, dataset_name: str, body: dict) -> dict:
937
974
  """PATCH dataset series 元数据(visibility/status 需要 dataset-admin)。"""
938
975
  return _unwrap_data(
@@ -944,12 +981,6 @@ class APIClient:
944
981
  )
945
982
  )
946
983
 
947
- def claim_dataset_series(self, dataset_id: str, body: dict) -> dict:
948
- """认领 dataset series(``workspace_id`` 服务端必填);成功后调用方成为 dataset admin。"""
949
- return _unwrap_data(
950
- self._post(f"{self._DATASET_SERIES_BASE}/{quote(dataset_id, safe='')}/claim", body)
951
- )
952
-
953
984
  def list_claim_workspaces(self) -> list[dict]:
954
985
  """列出当前用户可用于认领的 workspace。"""
955
986
  return _as_record_list(self._get("/apis/v1/datasets/claim-workspaces"))
@@ -989,31 +1020,6 @@ class APIClient:
989
1020
  params["environment"] = environment
990
1021
  return _unwrap_data(self._get(f"{self._DATASET_VERSIONS_BASE}/detail", params=params))
991
1022
 
992
- def create_dataset_series_version(self, dataset_name: str, body: dict) -> dict:
993
- """创建版本(``version``/``storage_type``/``storage_path``/``splits``/``status``)。"""
994
- return _unwrap_data(
995
- self._request(
996
- "POST",
997
- self._DATASET_VERSIONS_BASE,
998
- params={"dataset_name": dataset_name},
999
- json_body=body,
1000
- )
1001
- )
1002
-
1003
- def update_dataset_series_version(self, dataset_name: str, version: str, body: dict) -> dict:
1004
- """PATCH 版本(``status``/``splits``/``manifest``/``run_type``/``split_run_types``)。
1005
-
1006
- 服务端要求 ``splits`` 与 ``split_run_types`` 互斥,调用方需分两次 PATCH。
1007
- """
1008
- return _unwrap_data(
1009
- self._request(
1010
- "PATCH",
1011
- f"{self._DATASET_VERSIONS_BASE}/detail",
1012
- params={"dataset_name": dataset_name, "version": version},
1013
- json_body=body,
1014
- )
1015
- )
1016
-
1017
1023
  # ---- dataset instances (metadata only) ----
1018
1024
 
1019
1025
  def list_dataset_series_instances(
@@ -1062,6 +1068,19 @@ class APIClient:
1062
1068
  params["environment"] = environment
1063
1069
  return _unwrap_data(self._get(f"{self._DATASET_INSTANCES_BASE}/detail", params=params))
1064
1070
 
1071
+ def get_dataset_series_split(self, dataset_name: str, version: str, split: str) -> dict:
1072
+ """Read the selected split's lifecycle without a version-level aggregate."""
1073
+ return _unwrap_data(
1074
+ self._get(
1075
+ "/apis/v1/datasets/splits/detail",
1076
+ params={"dataset_name": dataset_name, "version": version, "split": split},
1077
+ )
1078
+ )
1079
+
1080
+ def get_dataset_split_release(self, workflow_id: str) -> dict:
1081
+ """Read a release workflow, including approval and per-step outcomes."""
1082
+ return _unwrap_data(self._get(f"/apis/v1/workflows/{quote(workflow_id, safe='')}"))
1083
+
1065
1084
  # ---- permissions ----
1066
1085
 
1067
1086
  def get_my_resource_permissions(self, resource_type: str, resource_id: str) -> list[str]:
@@ -1514,6 +1533,9 @@ class APIClient:
1514
1533
  meta_job_type: Optional[str] = None,
1515
1534
  upstream_platform: Optional[str] = None,
1516
1535
  upstream_job_id: Optional[str] = None,
1536
+ next_token: Optional[str] = None,
1537
+ pagination: Optional[str] = None,
1538
+ include_total: bool = True,
1517
1539
  ) -> dict:
1518
1540
  """List jobs."""
1519
1541
  params = {"skip": skip, "limit": limit}
@@ -1571,7 +1593,15 @@ class APIClient:
1571
1593
  params["created_at_sort"] = created_at_sort
1572
1594
  if finished_at_sort is not None:
1573
1595
  params["finished_at_sort"] = finished_at_sort
1574
- return self._get("/jobs", params=params)
1596
+ if next_token:
1597
+ params["next_token"] = next_token
1598
+ if pagination:
1599
+ params["pagination"] = pagination
1600
+ if not include_total:
1601
+ params["include_total"] = False
1602
+ response = self._get("/jobs", params=params)
1603
+ _ensure_next_token_honored(response, next_token=next_token, endpoint="/jobs")
1604
+ return response
1575
1605
 
1576
1606
  # ==================== Group operations ====================
1577
1607
 
@@ -1762,6 +1792,9 @@ class APIClient:
1762
1792
  meta_job_type: Optional[str] = None,
1763
1793
  upstream_platform: Optional[str] = None,
1764
1794
  upstream_job_id: Optional[str] = None,
1795
+ next_token: Optional[str] = None,
1796
+ pagination: Optional[str] = None,
1797
+ include_total: bool = True,
1765
1798
  ) -> dict:
1766
1799
  """List jobs in a group."""
1767
1800
  params: dict = {"skip": skip, "limit": limit}
@@ -1785,7 +1818,16 @@ class APIClient:
1785
1818
  params["upstream_job_id"] = upstream_job_id
1786
1819
  if include_post_process:
1787
1820
  params["include_post_process"] = True
1788
- return self._get(f"/groups/{quote(group_id)}/jobs", params=params)
1821
+ if next_token:
1822
+ params["next_token"] = next_token
1823
+ if pagination:
1824
+ params["pagination"] = pagination
1825
+ if not include_total:
1826
+ params["include_total"] = False
1827
+ endpoint = f"/groups/{quote(group_id)}/jobs"
1828
+ response = self._get(endpoint, params=params)
1829
+ _ensure_next_token_honored(response, next_token=next_token, endpoint=endpoint)
1830
+ return response
1789
1831
 
1790
1832
  def get_group_post_process_job_id(
1791
1833
  self, group_id: str, timeout: TimeoutType = None
@@ -2263,19 +2305,30 @@ class APIClient:
2263
2305
  skip: int = 0,
2264
2306
  limit: int = _GROUP_ARTIFACTS_PAGE_SIZE,
2265
2307
  include_post_process: bool = False,
2308
+ next_token: Optional[str] = None,
2309
+ pagination: Optional[str] = None,
2310
+ include_total: bool = True,
2266
2311
  ) -> dict:
2267
2312
  """Get one page of artifact download links for a group."""
2268
2313
  params: dict[str, object] = {"skip": skip, "limit": limit}
2269
2314
  if include_post_process:
2270
2315
  params["include_post_process"] = True
2271
- return self._get(
2272
- f"/groups/{quote(group_id)}/artifacts",
2273
- params=params,
2274
- )
2316
+ if next_token:
2317
+ params["next_token"] = next_token
2318
+ if pagination:
2319
+ params["pagination"] = pagination
2320
+ if not include_total:
2321
+ params["include_total"] = False
2322
+ endpoint = f"/groups/{quote(group_id)}/artifacts"
2323
+ response = self._get(endpoint, params=params)
2324
+ _ensure_next_token_honored(response, next_token=next_token, endpoint=endpoint)
2325
+ return response
2275
2326
 
2276
2327
  def get_group_artifacts(self, group_id: str, include_post_process: bool = False) -> dict:
2277
2328
  """Get artifact download links for a group."""
2329
+ next_token: str | None = None
2278
2330
  skip = 0
2331
+ mode: str | None = None
2279
2332
  artifacts: list[dict] = []
2280
2333
  last_page: dict | None = None
2281
2334
 
@@ -2285,25 +2338,41 @@ class APIClient:
2285
2338
  skip=skip,
2286
2339
  limit=_GROUP_ARTIFACTS_PAGE_SIZE,
2287
2340
  include_post_process=include_post_process,
2341
+ next_token=next_token,
2342
+ pagination="cursor" if mode in {None, "cursor"} else None,
2343
+ include_total=False,
2288
2344
  )
2289
2345
  last_page = page
2290
2346
  page_artifacts = page.get("artifacts") or []
2291
2347
  artifacts.extend(page_artifacts)
2348
+ has_token_field = "next_token" in page
2292
2349
 
2293
- total = page.get("total")
2294
- if total is not None and len(artifacts) >= total:
2295
- break
2296
- if len(page_artifacts) < _GROUP_ARTIFACTS_PAGE_SIZE:
2297
- break
2298
-
2299
- skip += len(page_artifacts)
2350
+ if mode is None:
2351
+ mode = "cursor" if has_token_field else "offset"
2300
2352
 
2301
- return {
2302
- **last_page,
2303
- "skip": 0,
2304
- "limit": len(artifacts),
2305
- "artifacts": artifacts,
2306
- }
2353
+ if mode == "cursor":
2354
+ next_token = page.get("next_token")
2355
+ if not next_token:
2356
+ break
2357
+ else:
2358
+ skip += len(page_artifacts)
2359
+ total = page.get("total")
2360
+ if not page_artifacts or len(page_artifacts) < _GROUP_ARTIFACTS_PAGE_SIZE:
2361
+ break
2362
+ if total is not None and skip >= total:
2363
+ break
2364
+
2365
+ result = dict(last_page or {})
2366
+ result.pop("next_token", None)
2367
+ result.update(
2368
+ {
2369
+ "total": len(artifacts),
2370
+ "skip": 0,
2371
+ "limit": len(artifacts),
2372
+ "artifacts": artifacts,
2373
+ }
2374
+ )
2375
+ return result
2307
2376
 
2308
2377
  def get_job_artifacts(self, job_ids: list) -> list:
2309
2378
  """Get artifact download links for one or more jobs."""
ap_client/cli.py CHANGED
@@ -16,6 +16,7 @@ from ap_client import __version__, get_client, get_config, tbb
16
16
  from ap_client.api import (
17
17
  _OTHERS_MODEL,
18
18
  APIError,
19
+ PaginationProtocolError,
19
20
  _merge_meta_tags,
20
21
  set_cluster_override,
21
22
  set_verbose_override,
@@ -928,10 +929,7 @@ def _print_params_plain(params: Any, masked_params: list) -> None:
928
929
  rows: list[tuple[str, Any]] = []
929
930
  for key, value in params.items():
930
931
  display_value = _format_plain_value(value)
931
- # 直接信任 masked_params 路径:服务端掩码形态已扩展为保留首尾(如
932
- # sk-***890 / ****),不能再按值严格等于 "***" 判断,否则形态变化后
933
- # (masked) 标识静默丢失
934
- if str(key) in masked_paths:
932
+ if str(key) in masked_paths and value == "***":
935
933
  display_value = f"{display_value} (masked)"
936
934
  rows.append((str(key), display_value))
937
935
  _print_key_values(rows, skip_empty=False)
@@ -2636,6 +2634,21 @@ def job_list(
2636
2634
  desc: bool = typer.Option(False, "--desc", help="Sort descending; requires --sort"),
2637
2635
  skip: int = typer.Option(0, "--skip", help="Skip the first N entries"),
2638
2636
  limit: int = typer.Option(100, "--limit", help="Maximum number of entries to return"),
2637
+ next_token: Optional[str] = typer.Option(
2638
+ None,
2639
+ "--next-token",
2640
+ help="Opaque token returned by the previous exact-group job list page; requires --group-id",
2641
+ ),
2642
+ cursor: bool = typer.Option(
2643
+ False,
2644
+ "--cursor",
2645
+ help="Opt this page into next-token pagination; requires --group-id",
2646
+ ),
2647
+ no_total: bool = typer.Option(
2648
+ False,
2649
+ "--no-total",
2650
+ help="Skip the server-side count; total becomes null in json/yaml output",
2651
+ ),
2639
2652
  columns: Optional[str] = typer.Option(
2640
2653
  None,
2641
2654
  "--columns",
@@ -2661,7 +2674,16 @@ def job_list(
2661
2674
  raise typer.BadParameter("--tag-pattern cannot be used with --tag")
2662
2675
  if normalized_tag_logic != "and" and not tag:
2663
2676
  raise typer.BadParameter("--tag-logic requires --tag")
2677
+ if next_token and not group_id:
2678
+ raise typer.BadParameter("--next-token requires --group-id")
2679
+ if cursor and not group_id:
2680
+ raise typer.BadParameter("--cursor requires --group-id")
2681
+ if next_token and skip > 0:
2682
+ raise typer.BadParameter("--next-token cannot be used with --skip greater than 0")
2683
+ if cursor and skip > 0:
2684
+ raise typer.BadParameter("--cursor cannot be used with --skip greater than 0")
2664
2685
  sort_kwargs = _job_list_sort_kwargs(sort, asc=asc, desc=desc)
2686
+ cursor_request = bool(next_token) or cursor
2665
2687
  client = get_client()
2666
2688
  result = client.list_jobs(
2667
2689
  template=template,
@@ -2685,6 +2707,9 @@ def job_list(
2685
2707
  **({"meta_model": meta_model} if meta_model is not None else {}),
2686
2708
  **({"meta_job_type": meta_job_type} if meta_job_type is not None else {}),
2687
2709
  **sort_kwargs,
2710
+ **({"next_token": next_token} if next_token else {}),
2711
+ **({"pagination": "cursor"} if cursor_request else {}),
2712
+ include_total=not no_total,
2688
2713
  skip=skip,
2689
2714
  limit=limit,
2690
2715
  )
@@ -2694,6 +2719,9 @@ def job_list(
2694
2719
  _print_job_list_plain(result, columns=selected_columns)
2695
2720
  else:
2696
2721
  _print_formatted(result, output_format)
2722
+ next_token = result.get("next_token")
2723
+ if output_format in {"table", "plain"} and next_token:
2724
+ typer.echo(f"Next token: {next_token}", err=True)
2697
2725
 
2698
2726
 
2699
2727
  @job_app.command("create")
@@ -4751,7 +4779,7 @@ def benchmark_list(
4751
4779
  help="Output format: plain/table/json/yaml (default: AP_FORMAT or command default)",
4752
4780
  ),
4753
4781
  ):
4754
- """List benchmarks from the local benchmark registry."""
4782
+ """List benchmarks from the central benchmark registry."""
4755
4783
  output_format = _normalize_output_format(output_format, keep_table=True)
4756
4784
  client = get_client()
4757
4785
  result = client.list_benchmarks()
@@ -5746,6 +5774,21 @@ def group_jobs(
5746
5774
  ),
5747
5775
  skip: int = typer.Option(0, "--skip", help="Skip the first N entries"),
5748
5776
  limit: int = typer.Option(100, "--limit", help="Maximum number of entries to return"),
5777
+ next_token: Optional[str] = typer.Option(
5778
+ None,
5779
+ "--next-token",
5780
+ help="Opaque token returned by the previous Group jobs page",
5781
+ ),
5782
+ cursor: bool = typer.Option(
5783
+ False,
5784
+ "--cursor",
5785
+ help="Opt this page into next-token pagination",
5786
+ ),
5787
+ no_total: bool = typer.Option(
5788
+ False,
5789
+ "--no-total",
5790
+ help="Skip the server-side count; total becomes null in json/yaml output",
5791
+ ),
5749
5792
  output_format: str = typer.Option(
5750
5793
  None,
5751
5794
  "--format",
@@ -5754,6 +5797,11 @@ def group_jobs(
5754
5797
  ):
5755
5798
  """List jobs in a Group."""
5756
5799
  output_format = _normalize_output_format(output_format, keep_table=True)
5800
+ if next_token and skip > 0:
5801
+ raise typer.BadParameter("--next-token cannot be used with --skip greater than 0")
5802
+ if cursor and skip > 0:
5803
+ raise typer.BadParameter("--cursor cannot be used with --skip greater than 0")
5804
+ cursor_request = bool(next_token) or cursor
5757
5805
  client = get_client()
5758
5806
  result = client.list_group_jobs(
5759
5807
  group_id,
@@ -5761,6 +5809,9 @@ def group_jobs(
5761
5809
  include_post_process=include_post_process,
5762
5810
  skip=skip,
5763
5811
  limit=limit,
5812
+ **({"next_token": next_token} if next_token else {}),
5813
+ **({"pagination": "cursor"} if cursor_request else {}),
5814
+ include_total=not no_total,
5764
5815
  )
5765
5816
  if output_format == "table":
5766
5817
  _print_job_list_plain(result, rich=True)
@@ -5768,6 +5819,9 @@ def group_jobs(
5768
5819
  _print_job_list_plain(result)
5769
5820
  else:
5770
5821
  _print_formatted(result, output_format)
5822
+ following_token = result.get("next_token")
5823
+ if output_format in {"table", "plain"} and following_token:
5824
+ typer.echo(f"Next token: {following_token}", err=True)
5771
5825
 
5772
5826
 
5773
5827
  @group_app.command("stats")
@@ -6038,7 +6092,7 @@ def main() -> None:
6038
6092
  except APIError as exc:
6039
6093
  typer.echo(_format_api_error(exc), err=True)
6040
6094
  raise SystemExit(1) from None
6041
- except ConfigurationError as exc:
6095
+ except (ConfigurationError, PaginationProtocolError) as exc:
6042
6096
  typer.echo(f"error: {exc}", err=True)
6043
6097
  raise SystemExit(1) from None
6044
6098