ap-client 0.5.1.dev5__tar.gz → 0.5.1.dev6__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,17 +1,18 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ap-client
3
- Version: 0.5.1.dev5
3
+ Version: 0.5.1.dev6
4
4
  Summary: Agent Platform API Client & CLI
5
5
  Requires-Python: >=3.10
6
+ Requires-Dist: irepo-artifact==0.2.2
6
7
  Requires-Dist: pyyaml>=6.0
7
8
  Requires-Dist: requests>=2.28.0
8
9
  Requires-Dist: rich>=13.0.0
9
10
  Requires-Dist: typer>=0.9.0
10
11
  Requires-Dist: websockets>=13.0
11
12
  Provides-Extra: all
12
- Requires-Dist: instance-repo[oss]==1.3.2.dev5; extra == 'all'
13
+ Requires-Dist: instance-repo[oss]<2.0.0,>=1.1.4; extra == 'all'
13
14
  Provides-Extra: dataset
14
- Requires-Dist: instance-repo[oss]==1.3.2.dev5; extra == 'dataset'
15
+ Requires-Dist: instance-repo[oss]<2.0.0,>=1.1.4; extra == 'dataset'
15
16
  Description-Content-Type: text/markdown
16
17
 
17
18
  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.
@@ -63,7 +63,9 @@ def _fmt(output_format: Optional[str]) -> str:
63
63
  def _check_time_range(value: str) -> str:
64
64
  normalized = str(value or "").strip().lower()
65
65
  if not _TIME_RANGE_RE.fullmatch(normalized):
66
- raise typer.BadParameter(f"--time-range must be a duration like 30s/5m/1h, got {value!r}")
66
+ raise typer.BadParameter(
67
+ f"--time-range must be a duration like 30s/5m/1h, got {value!r}"
68
+ )
67
69
  return normalized
68
70
 
69
71
 
@@ -123,6 +123,14 @@ _UNSET: Any = object()
123
123
  # when aggregation runs later.
124
124
  EXTRA_AGG_OPS = ("avg", "sum")
125
125
 
126
+ # Others 占位:apiserver 词表不返回,CLI 展示与 get_meta_model("Others") 兜底用。
127
+ _OTHERS_MODEL = {
128
+ "name": "Others",
129
+ "series_name": "",
130
+ "source_id": "",
131
+ "series_id": "",
132
+ }
133
+
126
134
 
127
135
  def _merge_meta_tags(
128
136
  tags: Optional[list],
@@ -968,19 +976,18 @@ class APIClient:
968
976
  # `dataset versions/instances`、`job create --dataset`)。
969
977
 
970
978
  def list_meta_models(self) -> dict:
971
- """从 meta-model 标签目录获取可用模型,保留 list 的 items 响应形状。"""
972
- items = self.get_meta_tags("meta-model").get("items") or []
973
- return {
974
- "items": [
975
- {**item, "name": item["value"]}
976
- for item in items
977
- if item.get("value") and item.get("is_enabled", True)
978
- ]
979
- }
979
+ """模型词表(apiserver 权威源 GET /apis/v1/meta-models,返回 {items:[...]})。"""
980
+ return self._get_central("/meta-models")
980
981
 
981
982
  def get_meta_model(self, name: str) -> dict:
982
- """从启用的 meta-model 标签值中按 value 或 source_id 查找。"""
983
- result = self.list_meta_models()
983
+ """单个模型详情(按 name 或 source_id 从 apiserver list 过滤;Others 返回占位)。
984
+
985
+ apiserver 没有单模型详情接口,客户端从 list 结果过滤;找不到 raise
986
+ APIError 404,与原 cluster AP /meta/models/{name} 行为一致。
987
+ """
988
+ if name == "Others":
989
+ return dict(_OTHERS_MODEL)
990
+ result = self._get_central("/meta-models")
984
991
  for item in result.get("items") or []:
985
992
  if item.get("name") == name or item.get("source_id") == name:
986
993
  return item
@@ -1046,13 +1053,43 @@ class APIClient:
1046
1053
  return result
1047
1054
 
1048
1055
  def list_meta_job_types(self) -> dict:
1049
- """从 meta-job-type 标签目录获取启用的任务类型,不补本地展示值。"""
1050
- items = self.get_meta_tags("meta-job-type").get("items") or []
1056
+ """任务类型词表(目录驱动:GET /apis/v1/meta-tags?key=meta-job-type)。
1057
+
1058
+ 仅取目录中 ``is_enabled`` 的值(缺失按启用解析)+ 末尾 ``Others``
1059
+ 兜底行;``default`` 为 ``Eval-Eval``(在目录中时,否则空串)。
1060
+ 目录请求失败或为空 → 回退本地常量(Eval-Eval + Others,fail-open),
1061
+ 并在 stderr 打一行告警,词表降级可见但不阻塞命令。
1062
+ """
1063
+ error: Exception | None = None
1064
+ try:
1065
+ items = self.get_meta_tags("meta-job-type").get("items") or []
1066
+ except Exception as exc: # fail-open:目录不可用不阻塞词表命令
1067
+ error = exc
1068
+ items = []
1069
+ if items:
1070
+ options = []
1071
+ for item in items:
1072
+ value = str(item.get("value") or "").strip()
1073
+ if value and item.get("is_enabled", True):
1074
+ options.append({"value": value})
1075
+ if options:
1076
+ default = "Eval-Eval" if any(o["value"] == "Eval-Eval" for o in options) else ""
1077
+ return {
1078
+ "meta_job_type": {
1079
+ "default": default,
1080
+ "options": [*options, {"value": "Others"}],
1081
+ }
1082
+ }
1083
+ _write_stderr(
1084
+ [
1085
+ "warning: meta-job-type catalog unavailable or empty, "
1086
+ f"falling back to local constants (reason={error or 'empty catalog'})"
1087
+ ]
1088
+ )
1051
1089
  return {
1052
1090
  "meta_job_type": {
1053
- "options": [
1054
- item for item in items if item.get("value") and item.get("is_enabled", True)
1055
- ],
1091
+ "default": "Eval-Eval",
1092
+ "options": [{"value": "Eval-Eval"}, {"value": "Others"}],
1056
1093
  }
1057
1094
  }
1058
1095
 
@@ -15,6 +15,7 @@ import typer
15
15
  from ap_client import __version__, get_client, get_config, tbb
16
16
  from ap_client.akpool_commands import register as _register_akpool_commands
17
17
  from ap_client.api import (
18
+ _OTHERS_MODEL,
18
19
  APIError,
19
20
  PaginationProtocolError,
20
21
  _is_sensitive_key,
@@ -25,7 +26,7 @@ from ap_client.api import (
25
26
  )
26
27
  from ap_client.config import ENV_VAR_SPECS, ConfigurationError, _parse_bool, normalize_output_format
27
28
  from ap_client.dataset_commands import register as _register_dataset_commands
28
- from ap_client.exporter import export_group, export_job
29
+ from ap_client.exporter import export_group, export_job, export_jobs
29
30
  from ap_client.fs_commands import register as _register_fs_commands
30
31
  from ap_client.instance_commands import register as _register_instance_commands
31
32
  from ap_client.managed_ak import (
@@ -2688,22 +2689,23 @@ def meta_model_list(
2688
2689
  help="Output format: plain/table/json/yaml (default: AP_FORMAT or command default)",
2689
2690
  ),
2690
2691
  ):
2691
- """List enabled meta-model tag values."""
2692
+ """List meta model options (apiserver data + Others as the last row)."""
2692
2693
  output_format = _normalize_output_format(output_format, keep_table=True)
2693
2694
  result = get_client().list_meta_models()
2694
2695
  items = result.get("items") or []
2695
- options = items
2696
+ options = [*items, dict(_OTHERS_MODEL)] # 末尾固定追加 Others 兜底手动输入
2696
2697
  if output_format in ("plain", "table"):
2697
2698
  rows = [
2698
2699
  {
2699
2700
  "name": m.get("name") or "-",
2700
- "desc": m.get("desc_l1") or "-",
2701
+ "series": m.get("series_name") or "-",
2701
2702
  }
2702
2703
  for m in options
2703
2704
  ]
2704
- _print_plain_table(rows, [("name", "name"), ("desc", "desc")])
2705
+ _print_plain_table(rows, [("name", "name"), ("series", "series")])
2705
2706
  else:
2706
- # JSON/YAML 保持 {meta_model:{options}};每项保留目录字段及 name 别名。
2707
+ # json/yaml 包装成 {meta_model:{options}}(原 cluster AP 格式;
2708
+ # default 已随 meta-models default 逻辑移除而下线)
2707
2709
  _print_formatted({"meta_model": {"options": options}}, output_format)
2708
2710
 
2709
2711
 
@@ -2794,20 +2796,21 @@ def meta_job_type_list(
2794
2796
  help="Output format: plain/table/json/yaml (default: AP_FORMAT or command default)",
2795
2797
  ),
2796
2798
  ):
2797
- """List enabled meta-job-type tag values."""
2799
+ """List meta job type options (catalog-driven; falls back to Eval-Eval/Others)."""
2798
2800
  output_format = _normalize_output_format(output_format, keep_table=True)
2799
2801
  result = get_client().list_meta_job_types()
2800
2802
  info = result.get("meta_job_type") or {}
2801
2803
  options = info.get("options") or []
2804
+ default = info.get("default") or ""
2802
2805
  if output_format in ("plain", "table"):
2803
2806
  rows = [
2804
2807
  {
2805
2808
  "value": o.get("value") or "-",
2806
- "desc": o.get("desc_l1") or "-",
2809
+ "default": "*" if o.get("value") and o.get("value") == default else "",
2807
2810
  }
2808
2811
  for o in options
2809
2812
  ]
2810
- _print_plain_table(rows, [("value", "value"), ("desc", "desc")])
2813
+ _print_plain_table(rows, [("value", "value"), ("default", "default")])
2811
2814
  else:
2812
2815
  _print_formatted(result, output_format)
2813
2816
 
@@ -4876,10 +4879,47 @@ def job_artifacts(
4876
4879
  _print_formatted(results, output_format)
4877
4880
 
4878
4881
 
4882
+ def _validate_export_timeout(value: float) -> float:
4883
+ import math
4884
+
4885
+ if not math.isfinite(value) or value <= 0:
4886
+ raise typer.BadParameter("Timeout must be a finite number greater than zero")
4887
+ return value
4888
+
4889
+
4890
+ def _export_channel_reporter(emit):
4891
+ """Report completed Jobs, including callbacks from Group worker threads."""
4892
+ from threading import Lock
4893
+
4894
+ from rich.text import Text
4895
+
4896
+ counts = {"oss": 0, "proxy": 0}
4897
+ lock = Lock()
4898
+
4899
+ def report(job_id, status, channel):
4900
+ with lock:
4901
+ suffix = ""
4902
+ if status == "downloaded" and channel in counts:
4903
+ counts[channel] += 1
4904
+ suffix = f" channel={channel}"
4905
+ emit(Text(f"{job_id} {status}{suffix}"))
4906
+
4907
+ return report, counts
4908
+
4909
+
4879
4910
  @job_app.command("export")
4880
4911
  def job_export(
4881
- job_id: str = typer.Argument(..., help="Job ID"),
4912
+ job_id: str = typer.Argument(..., help="Job ID or comma-separated Job IDs"),
4882
4913
  output: Optional[Path] = typer.Option(None, "--output", "-o", help="Export directory"),
4914
+ workers: int = typer.Option(
4915
+ 1, "--workers", "-w", min=1, help="Number of concurrent export workers"
4916
+ ),
4917
+ timeout: float = typer.Option(
4918
+ 300,
4919
+ "--timeout",
4920
+ callback=_validate_export_timeout,
4921
+ help="SDK request timeout in seconds, not the total export duration",
4922
+ ),
4883
4923
  logs: bool = typer.Option(False, "--logs", help="Include container logs"),
4884
4924
  events: bool = typer.Option(False, "--events", help="Include events"),
4885
4925
  no_extract_artifacts: bool = typer.Option(
@@ -4889,17 +4929,50 @@ def job_export(
4889
4929
  ),
4890
4930
  ):
4891
4931
  """Export job artifacts to a local directory. Use --logs/--events to include logs and events."""
4932
+ ids = list(dict.fromkeys(value.strip() for value in job_id.split(",")))
4933
+ if not all(ids):
4934
+ raise typer.BadParameter("Job IDs must not be empty", param_hint="job_id")
4892
4935
  client = get_client()
4936
+ if len(ids) > 1:
4937
+ report, channels = _export_channel_reporter(print)
4938
+ dest, summary = export_jobs(
4939
+ client,
4940
+ ids,
4941
+ output,
4942
+ workers=workers,
4943
+ result_callback=report,
4944
+ timeout=timeout,
4945
+ include_logs=logs,
4946
+ include_events=events,
4947
+ extract_artifacts=not no_extract_artifacts,
4948
+ )
4949
+ print(
4950
+ f"Jobs exported: downloaded={summary.downloaded} "
4951
+ f"skipped={summary.skipped} failed={len(summary.failures)}"
4952
+ )
4953
+ print(f"channels: oss={channels['oss']} proxy={channels['proxy']}")
4954
+ print(f" path: {dest}")
4955
+ for failed_id, error in summary.failures.items():
4956
+ print(f"[red]Failed job {failed_id}:[/] {error}")
4957
+ if summary.failures:
4958
+ raise typer.Exit(code=1)
4959
+ return
4960
+ job_id = ids[0]
4961
+ result = {}
4893
4962
  dest = export_job(
4894
4963
  client,
4895
4964
  job_id,
4896
4965
  output,
4966
+ result_callback=lambda jid, status, channel: result.update(status=status, channel=channel),
4967
+ timeout=timeout,
4897
4968
  include_logs=logs,
4898
4969
  include_events=events,
4899
4970
  extract_artifacts=not no_extract_artifacts,
4900
4971
  )
4901
4972
  print(f"[green]Job exported:[/] {job_id}")
4902
4973
  print(f" path: {dest}")
4974
+ if result.get("channel") in {"oss", "proxy"} and result.get("status") == "downloaded":
4975
+ print(f" channel: {result['channel']}")
4903
4976
 
4904
4977
 
4905
4978
  @job_app.command("wait")
@@ -6374,6 +6447,12 @@ def group_export(
6374
6447
  workers: int = typer.Option(
6375
6448
  4, "--workers", "-w", min=1, help="Number of concurrent export workers"
6376
6449
  ),
6450
+ timeout: float = typer.Option(
6451
+ 300,
6452
+ "--timeout",
6453
+ callback=_validate_export_timeout,
6454
+ help="SDK request timeout in seconds, not the total export duration",
6455
+ ),
6377
6456
  logs: bool = typer.Option(False, "--logs", help="Include container logs"),
6378
6457
  events: bool = typer.Option(False, "--events", help="Include events"),
6379
6458
  no_extract_artifacts: bool = typer.Option(
@@ -6434,12 +6513,15 @@ def group_export(
6434
6513
  current_stage=current_stage,
6435
6514
  )
6436
6515
 
6516
+ report, channels = _export_channel_reporter(progress.console.print)
6437
6517
  dest, summary = export_group(
6438
6518
  client,
6439
6519
  group_id,
6440
6520
  output,
6441
6521
  progress_callback=_on_progress,
6442
6522
  workers=workers,
6523
+ result_callback=report,
6524
+ timeout=timeout,
6443
6525
  include_logs=logs,
6444
6526
  include_events=events,
6445
6527
  extract_artifacts=not no_extract_artifacts,
@@ -6452,6 +6534,7 @@ def group_export(
6452
6534
  f" [green]{summary.downloaded}[/] jobs downloaded, "
6453
6535
  f"[yellow]{summary.skipped}[/] jobs skipped (no artifact)"
6454
6536
  )
6537
+ print(f"channels: oss={channels['oss']} proxy={channels['proxy']}")
6455
6538
  if summary.skipped_job_names:
6456
6539
  print(f" Skipped: {', '.join(summary.skipped_job_names)}")
6457
6540
 
@@ -50,15 +50,18 @@ def register(dataset_app: typer.Typer) -> None:
50
50
 
51
51
 
52
52
  def _register_dataset_commands(dataset_app: typer.Typer) -> None:
53
- """挂载 dataset 级命令。"""
54
- from .dataset_register import dataset_register
53
+ """挂载 dataset 级命令(``list`` / ``get`` / ``create`` / ``update`` / ``claim``)。
55
54
 
55
+ 实现方式:把命令写成本模块的模块级函数,再在这里用
56
+ ``dataset_app.command("<name>")(<func>)`` 挂上去。``list`` 需要**替换**
57
+ ``cli.py`` 里现存的 ossdata 版本(设计 §4.1),挂载时用同名 ``"list"`` 覆盖,
58
+ 并同步删掉 ``cli.py`` 中的旧实现。
59
+ """
56
60
  dataset_app.command("list")(dataset_list)
57
61
  dataset_app.command("get")(dataset_get)
58
62
  dataset_app.command("create")(dataset_create)
59
63
  dataset_app.command("update")(dataset_update)
60
64
  dataset_app.command("claim")(dataset_claim)
61
- dataset_app.command("register")(dataset_register)
62
65
 
63
66
 
64
67
  # ---------------------------------------------------------------------------
@@ -83,7 +86,6 @@ _VERSION_COLUMNS: tuple[tuple[str, str], ...] = (
83
86
  ("splits", "SPLITS"),
84
87
  ("instances", "INSTANCES"),
85
88
  ("updated_at", "UPDATED_AT"),
86
- ("registered", "REGISTERED"),
87
89
  )
88
90
 
89
91
  #: 上游 §8.2 的默认列(设计 §5)。
@@ -92,7 +94,6 @@ _SPLIT_COLUMNS: tuple[tuple[str, str], ...] = (
92
94
  ("run_type", "RUN_TYPE"),
93
95
  ("instances", "INSTANCES"),
94
96
  ("updated_at", "UPDATED_AT"),
95
- ("registered", "REGISTERED"),
96
97
  )
97
98
 
98
99
  _ACCESS_COLUMNS: tuple[tuple[str, str], ...] = (
@@ -397,7 +398,6 @@ def _model_dict(record: Any) -> dict:
397
398
  ``{}`` / 缺省都不会出现在响应里),而 SDK 模型会把它们补成默认值;留着这些默认值会让
398
399
  JSON 凭空多出字段(迁移前是原样透出服务端响应,契约必须一致)。
399
400
 
400
- ``registered`` 是显式注册状态,必须保留 ``false``。
401
401
  只清洗**顶层**:嵌套结构(如 version 详情里的 ``splits[].is_default``)按模型原样输出,
402
402
  服务端在那里确实是显式返回 ``false``。
403
403
  """
@@ -412,24 +412,13 @@ def _model_dict(record: Any) -> dict:
412
412
  return {}
413
413
  if not isinstance(payload, dict):
414
414
  return {}
415
- registered = getattr(record, "registered", None)
416
- if isinstance(registered, bool):
417
- payload = {**payload, "registered": registered}
418
415
  return {
419
416
  key: value
420
417
  for key, value in payload.items()
421
- if value is not None
422
- and (key == "registered" or not (value is False or value == "" or value == [] or value == {}))
418
+ if not (value is None or value is False or value == "" or value == [] or value == {})
423
419
  }
424
420
 
425
421
 
426
- def _registered_dict(record: Any) -> dict:
427
- """Preserve SDK registration state; older metadata-only SDK reads imply true."""
428
- payload = _model_dict(record)
429
- payload.setdefault("registered", True)
430
- return payload
431
-
432
-
433
422
  def _api_biz_code(exc: BaseException) -> Optional[int]:
434
423
  """从 ``APIError`` 里取 apiserver 业务码(设计 §5.1),不 import 数值异常类。
435
424
 
@@ -825,7 +814,6 @@ def _version_row(record: dict) -> dict:
825
814
  "splits": splits,
826
815
  "instances": record.get("instance_count", ""),
827
816
  "updated_at": record.get("updated_at") or "",
828
- "registered": record.get("registered", True),
829
817
  }
830
818
 
831
819
 
@@ -845,7 +833,6 @@ def _split_row(record: dict, detail: dict) -> dict:
845
833
  "run_type": record.get("run_type") or detail.get("run_type") or "",
846
834
  "instances": "" if instances is None else instances,
847
835
  "updated_at": record.get("updated_at") or detail.get("updated_at") or "",
848
- "registered": record.get("registered", detail.get("registered", True)),
849
836
  }
850
837
 
851
838
 
@@ -1157,22 +1144,11 @@ def dataset_get(
1157
1144
  try:
1158
1145
  detail = repo.datasets.get_detail(dataset, **_env_kwargs(env))
1159
1146
  except Exception as exc: # noqa: BLE001 - 统一走设计 §5.1 的映射
1160
- if _business_code(exc) not in (92001, 94021):
1161
- raise _fail(
1162
- f"Failed to query dataset {dataset}: {_reason(exc)}",
1163
- f"Check the dataset name: ap dataset list --query {dataset}",
1164
- ) from exc
1165
- detail = None
1166
- if detail is None:
1167
- l1, _, l2 = dataset.partition("/")
1168
- rendered = {
1169
- "dataset_name": dataset,
1170
- "dataset_l1": l1,
1171
- "dataset_l2": l2,
1172
- "registered": False,
1173
- }
1174
- else:
1175
- rendered = _registered_dict(detail)
1147
+ raise _fail(
1148
+ f"Failed to query dataset {dataset}: {_reason(exc)}",
1149
+ f"Check the dataset name: ap dataset list --query {dataset}",
1150
+ ) from exc
1151
+ rendered = _model_dict(detail)
1176
1152
  _emit_detail(rendered, output_format, order=_DATASET_DETAIL_ORDER, always=("status",))
1177
1153
  if output_format not in ("json", "yaml"):
1178
1154
  _print_dataset_environments(repo, dataset, rendered)
@@ -1666,7 +1642,7 @@ def dataset_version_list(
1666
1642
  f"Check the dataset name: ap dataset list --query {dataset}",
1667
1643
  ) from exc
1668
1644
 
1669
- records = [_registered_dict(record) for record in (getattr(result, "items", None) or [])]
1645
+ records = [_model_dict(record) for record in (getattr(result, "items", None) or [])]
1670
1646
  rows = [_version_row(record) for record in records]
1671
1647
  _emit(
1672
1648
  {"dataset": dataset, "versions": records, "pagination": _pagination_envelope(result)},
@@ -1687,7 +1663,7 @@ def dataset_version_get(
1687
1663
  output_format = _fmt(output_format)
1688
1664
  resolved = _version_value(version)
1689
1665
  detail = _fetch_version(dataset, resolved, stage="query", environment=environment)
1690
- _emit_detail(_registered_dict(detail), output_format, order=_VERSION_DETAIL_ORDER)
1666
+ _emit_detail(detail, output_format, order=_VERSION_DETAIL_ORDER)
1691
1667
 
1692
1668
 
1693
1669
  @dataset_version_app.command("create")
@@ -1834,7 +1810,6 @@ def dataset_split_get(
1834
1810
  payload = dict(record)
1835
1811
  payload.setdefault("dataset", dataset)
1836
1812
  payload.setdefault("version", resolved_version)
1837
- payload.setdefault("registered", detail.get("registered", True))
1838
1813
  _emit_detail(payload, output_format)
1839
1814
  return
1840
1815
  known = ", ".join(_split_names(detail)) or "<none>"
@@ -1844,7 +1819,6 @@ def dataset_split_get(
1844
1819
  payload = dict(record)
1845
1820
  payload.setdefault("dataset", dataset)
1846
1821
  payload.setdefault("version", "")
1847
- payload.setdefault("registered", True)
1848
1822
  _emit_detail(payload, output_format)
1849
1823
  return
1850
1824
  known = (