ap-client 0.3.1.dev1__tar.gz → 0.3.2.dev0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ap-client
3
- Version: 0.3.1.dev1
3
+ Version: 0.3.2.dev0
4
4
  Summary: Agent Platform API Client & CLI
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: pyyaml>=6.0
@@ -294,24 +294,6 @@ def _secret_ws_params(workspace_id: Optional[str]) -> Optional[dict]:
294
294
  return {"workspace_id": workspace_id} if workspace_id else None
295
295
 
296
296
 
297
- def _unwrap_data(payload: Any) -> Any:
298
- """Unwrap the apiserver ``{code,message,data}`` envelope when present.
299
-
300
- The dataset-domain detail/create/patch endpoints return a bare JSON body,
301
- but the same handlers are occasionally wrapped by the generic success
302
- envelope. Tolerate both so command code never has to branch: a mapping that
303
- carries ``data`` alongside ``code``/``message`` is treated as an envelope,
304
- anything else is returned verbatim.
305
- """
306
- if (
307
- isinstance(payload, dict)
308
- and "data" in payload
309
- and ("code" in payload or "message" in payload)
310
- ):
311
- return payload["data"]
312
- return payload
313
-
314
-
315
297
  def _split_paged(payload: Any) -> tuple[list[dict], dict]:
316
298
  """Split a ``PagedSuccess`` envelope into ``(records, pagination)``.
317
299
 
@@ -815,26 +815,45 @@ def _print_json_block(data: Any) -> None:
815
815
  typer.echo(json.dumps(data, ensure_ascii=False, indent=2))
816
816
 
817
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
+
818
830
  def _print_detail(data: object) -> None:
819
831
  """Render a single object as an aligned key/value detail view.
820
832
 
821
833
  Top-level scalars become key/value rows (``image`` fields shortened); nested
822
- 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
823
836
  non-dict payloads.
824
837
  """
825
838
  if not isinstance(data, dict):
826
839
  _print_json(data)
827
840
  return
828
- scalar_rows: list[tuple[str, Any]] = []
829
- nested_rows: list[tuple[str, Any]] = []
841
+ rows: list[tuple[str, str]] = []
830
842
  for key, value in data.items():
831
843
  if isinstance(value, (dict, list)):
832
- nested_rows.append((key, value))
844
+ rows.append((key, _format_nested_detail(value)))
833
845
  elif key == "image" and isinstance(value, str):
834
- scalar_rows.append((key, _shorten_image(value)))
846
+ rows.append((key, _shorten_image(value)))
847
+ elif value is None:
848
+ continue
835
849
  else:
836
- scalar_rows.append((key, value))
837
- _print_key_values(scalar_rows + nested_rows, skip_empty=False)
850
+ rows.append((key, _format_plain_value(value, max_len=0)))
851
+ width = max((len(label) for label, _text in rows), default=0)
852
+ indent = " " * (width + 4)
853
+ for label, text in rows:
854
+ if "\n" in text:
855
+ text = text.replace("\n", f"\n{indent}")
856
+ typer.echo(f" {label.ljust(width)}: {text}")
838
857
 
839
858
 
840
859
  def _print_template_get_plain(t: dict) -> None:
@@ -944,7 +963,7 @@ def _print_field_value(value: Any, output_format: str) -> None:
944
963
  _print_formatted(value, "json" if output_format == "plain" else output_format)
945
964
 
946
965
 
947
- def _format_plain_value(value: Any) -> str:
966
+ def _format_plain_value(value: Any, *, max_len: int = _PLAIN_VALUE_PREVIEW) -> str:
948
967
  if value is None:
949
968
  return ""
950
969
  if isinstance(value, (dict, list)):
@@ -952,9 +971,9 @@ def _format_plain_value(value: Any) -> str:
952
971
  else:
953
972
  text = str(value)
954
973
  text = text.replace("\r", "\\r").replace("\n", "\\n")
955
- if len(text) <= _PLAIN_VALUE_PREVIEW:
974
+ if not max_len or len(text) <= max_len:
956
975
  return text
957
- return f"{text[: _PLAIN_VALUE_PREVIEW - 3]}..."
976
+ return f"{text[: max_len - 3]}..."
958
977
 
959
978
 
960
979
  def _print_key_values(rows: list[tuple[str, Any]], *, skip_empty: bool = True) -> None:
@@ -17,6 +17,8 @@ import typer
17
17
  if TYPE_CHECKING: # pragma: no cover - 仅用于类型标注,运行时不 import
18
18
  from datetime import datetime
19
19
 
20
+ from instance_repo import Repo
21
+
20
22
  __all__ = [
21
23
  "dataset_version_app",
22
24
  "dataset_split_app",
@@ -118,6 +120,7 @@ _NO_BENCHMARK = "none"
118
120
  _FORMAT_HELP = "Output format: plain/table/json/yaml (default: AP_FORMAT or command default)"
119
121
 
120
122
  _METADATA_MODELS = ("version_first", "split_first")
123
+ _VERSION_SPLIT_METADATA_MODEL = "version_first"
121
124
 
122
125
  #: CLI **不替 SDK 决定元数据模型**:不下发 ``metadata_model``,由 SDK 按 profile 归一
123
126
  #: (当前为 ``split_first``)。
@@ -153,10 +156,10 @@ _NO_VERSION_NOTE = "未指定 --version 表示无版本空间(split_first),不
153
156
  # ---------------------------------------------------------------------------
154
157
 
155
158
 
156
- def _fmt(output_format: Optional[str]) -> str:
159
+ def _fmt(output_format: Optional[str], *, default: str = "plain") -> str:
157
160
  from .cli import _normalize_output_format
158
161
 
159
- return _normalize_output_format(output_format, keep_table=True)
162
+ return _normalize_output_format(output_format, default=default, keep_table=True)
160
163
 
161
164
 
162
165
  def _fail(message: str, hint: str, exit_code: int = 1) -> "typer.Exit":
@@ -166,11 +169,6 @@ def _fail(message: str, hint: str, exit_code: int = 1) -> "typer.Exit":
166
169
  return typer.Exit(exit_code)
167
170
 
168
171
 
169
- def _note(message: str) -> None:
170
- """退出码 0 的路径只用 ``note:``(设计 §5.1);一律 stderr。"""
171
- typer.echo(f"note: {message}", err=True)
172
-
173
-
174
172
  def _describe(exc: BaseException) -> str:
175
173
  message = getattr(exc, "message", None)
176
174
  text = str(message or "").strip() or str(exc).strip()
@@ -410,6 +408,21 @@ def _api_biz_code(exc: BaseException) -> Optional[int]:
410
408
  return None
411
409
 
412
410
 
411
+ def _business_code(exc: BaseException) -> Optional[int]:
412
+ code = getattr(exc, "biz_code", None)
413
+ if isinstance(code, int) and not isinstance(code, bool):
414
+ return code
415
+ return _api_biz_code(exc)
416
+
417
+
418
+ def _last_admin_hint(dataset: str) -> str:
419
+ return (
420
+ "不能撤销或降级 dataset 的最后一个 admin;先授予另一个用户 admin:"
421
+ f"ap dataset access grant {dataset} --user <另一工号> --role admin,"
422
+ "确认成功后再重试当前命令"
423
+ )
424
+
425
+
413
426
  #: apiserver dataset 控制面业务码 → 固定 hint(设计 §5.1)。
414
427
  _DATASET_CODE_HINTS: dict[int, str] = {
415
428
  92008: "该环境未开启 dataset 能力,请联系管理员",
@@ -420,7 +433,7 @@ _DATASET_CODE_HINTS: dict[int, str] = {
420
433
  }
421
434
 
422
435
 
423
- def _resolve_benchmark_id(repo: Any, value: str) -> str:
436
+ def _resolve_benchmark_id(repo: "Repo", value: str) -> str:
424
437
  """``--benchmark`` 接受名称或 ``source_id``,统一解析成 ``source_id``(设计 §9)。
425
438
 
426
439
  走 SDK ``benchmarks``:先按 source_id 点查,再按名称模糊搜一遍精确比对
@@ -522,7 +535,12 @@ def _updated_range(from_value: Optional[str], to_value: Optional[str]) -> tuple[
522
535
 
523
536
 
524
537
  def _fetch_version(
525
- dataset: str, version: str, *, stage: str, environment: Optional[str] = None
538
+ dataset: str,
539
+ version: str,
540
+ *,
541
+ stage: str,
542
+ environment: Optional[str] = None,
543
+ metadata_model: str = "",
526
544
  ) -> dict:
527
545
  """读 version 详情(含内嵌 ``splits[]``);空 version 的报错必须点明"无版本空间"语义。
528
546
 
@@ -536,10 +554,18 @@ def _fetch_version(
536
554
  env = _environment(environment)
537
555
  repo = _dataset_repo()
538
556
  try:
539
- detail = repo.versions.get(dataset, version, **_env_kwargs(env))
557
+ detail = repo.versions.get(
558
+ dataset,
559
+ version,
560
+ **_env_kwargs(env),
561
+ **({"metadata_model": metadata_model} if metadata_model else {}),
562
+ )
540
563
  except Exception as exc: # noqa: BLE001 - 统一走设计 §5.1 的映射
541
564
  if version:
542
- hint = f"确认版本名:ap dataset version list --dataset {dataset}"
565
+ hint = (
566
+ f"确认版本名:ap dataset version list --dataset {dataset}"
567
+ ";版本也可能在另一个元数据 environment,加 --environment online/pre 重试"
568
+ )
543
569
  else:
544
570
  hint = f"{_NO_VERSION_NOTE};要操作具体版本请加 --version <version>"
545
571
  raise _fail(
@@ -634,7 +660,7 @@ def _split_names(detail: dict) -> list[str]:
634
660
  return names
635
661
 
636
662
 
637
- def _dataset_repo() -> Any:
663
+ def _dataset_repo() -> "Repo":
638
664
  """dataset 写路径统一走 InstanceRepo SDK;未装 ``dataset`` extra 时退出 2。"""
639
665
  from .irepo_sdk import SdkMissing, load_repo
640
666
 
@@ -653,6 +679,7 @@ def _patch_version(
653
679
  stage: str,
654
680
  fallback_hint: str = "",
655
681
  environment: Optional[str] = None,
682
+ metadata_model: str = "",
656
683
  ) -> None:
657
684
  """split 写操作统一走 SDK;body 为 splits/split_run_types 单键。
658
685
 
@@ -664,7 +691,14 @@ def _patch_version(
664
691
 
665
692
  repo = _dataset_repo()
666
693
  try:
667
- update_version_detail(repo, dataset, version, body, environment=_environment(environment))
694
+ update_version_detail(
695
+ repo,
696
+ dataset,
697
+ version,
698
+ body,
699
+ environment=_environment(environment),
700
+ metadata_model=metadata_model,
701
+ )
668
702
  except Exception as exc: # noqa: BLE001 - 统一走设计 §5.1 的映射
669
703
  code = getattr(exc, "biz_code", None)
670
704
  if code == 94018:
@@ -800,14 +834,16 @@ def _bindings(repo, dataset: str) -> list[dict]:
800
834
 
801
835
 
802
836
  def _bindings_of(repo, dataset: str, principal_id: str, principal_type: str) -> list[dict]:
803
- """某 principal 在此 dataset 上的现存绑定;读失败按"读不到"处理,不阻断主动作。"""
837
+ """某 principal 在此 dataset 上的现存绑定;读失败时停止变更。"""
804
838
  from .irepo_sdk import _reason
805
839
 
806
840
  try:
807
841
  rows = _bindings(repo, dataset)
808
- except Exception as exc: # noqa: BLE001 - 读不到绑定不该拦住授权本身
809
- _note(f"读取现有绑定失败({_reason(exc)}),跳过角色去重")
810
- return []
842
+ except Exception as exc: # noqa: BLE001
843
+ raise _fail(
844
+ f"dataset {dataset} 现有授权查询失败:{_reason(exc)}",
845
+ "确认 dataset-admin 权限和当前集群后重试",
846
+ ) from exc
811
847
  return [
812
848
  row
813
849
  for row in rows
@@ -888,7 +924,7 @@ def dataset_list(
888
924
  """List datasets from the registry. Defaults to JSON; use --format table for a table."""
889
925
  from .cli import _print_formatted, get_client
890
926
 
891
- output_format = _fmt(output_format)
927
+ output_format = _fmt(output_format, default="json")
892
928
  resolved_query = str(query or "").strip()
893
929
  legacy_search = str(search or "").strip()
894
930
  if legacy_search:
@@ -901,10 +937,6 @@ def dataset_list(
901
937
  f"或 ap dataset list --query {query}",
902
938
  exit_code=2,
903
939
  )
904
- if not resolved_query:
905
- _note(
906
- f"位置参数搜索已兼容为 --query 的等价形式:ap dataset list --query {legacy_search}"
907
- )
908
940
  resolved_query = legacy_search
909
941
 
910
942
  if legacy:
@@ -935,7 +967,6 @@ def dataset_list(
935
967
  exit_code=2,
936
968
  )
937
969
  result = get_client().list_all_datasets(resolved_query or None)
938
- _note("--legacy 使用旧数据源(与 ap job create --dataset 读取的一致),已自动翻页拉全量")
939
970
  if output_format == "table":
940
971
  from .cli import _print_records_table
941
972
 
@@ -1001,14 +1032,6 @@ def dataset_list(
1001
1032
  records, result = fetch(resolved_page)
1002
1033
  pagination = _pagination_envelope(result)
1003
1034
 
1004
- total = _pagination_total(pagination)
1005
- if total is not None and total > len(records):
1006
- _note(
1007
- f"共 {total} 个 dataset,本次返回第 {resolved_page} 页的 {len(records)} 条"
1008
- f"(每页 {resolved_page_size});继续翻页用 --page/--page-size,"
1009
- f"拉全量用 --legacy"
1010
- )
1011
-
1012
1035
  payload: dict[str, Any] = {}
1013
1036
  if mine:
1014
1037
  payload["owned_by_me"] = True
@@ -1017,11 +1040,44 @@ def dataset_list(
1017
1040
  payload["datasets"] = [_legacy_dataset_record(record) for record in records]
1018
1041
  payload["total"] = _pagination_total(pagination, fallback=len(records))
1019
1042
  payload["pagination"] = pagination
1020
- if output_format == "table":
1021
- _print_table(rows, _DATASET_COLUMNS, output_format)
1022
- else:
1023
- # Legacy plain output is JSON, as for dataset versions / instances.
1024
- _print_formatted(payload, output_format)
1043
+ _emit(payload, rows, _DATASET_COLUMNS, output_format)
1044
+
1045
+
1046
+ #: ``dataset get`` 尾部 environment 摘要表的列。
1047
+ _ENVIRONMENT_SUMMARY_COLUMNS: tuple[tuple[str, str], ...] = (
1048
+ ("environment", "ENVIRONMENT"),
1049
+ ("versions", "VERSIONS"),
1050
+ ("splits", "SPLITS"),
1051
+ ("latest_version", "LATEST_VERSION"),
1052
+ )
1053
+
1054
+
1055
+ def _print_dataset_environments(repo: "Repo", dataset: str, detail: dict) -> None:
1056
+ """``dataset get`` 尾部按 environment 汇总 version/split,告诉用户该去哪个 env 指定。
1057
+
1058
+ 仅人读视图(plain/table)追加;``json``/``yaml`` 恒为服务端原样。单个 env 读失败
1059
+ (旧 SDK 不接受 environment、权限差异)显示 ``?``,不影响主输出。
1060
+ """
1061
+ envs = [str(env).strip() for env in detail.get("available_envs") or [] if str(env).strip()]
1062
+ if not envs:
1063
+ return
1064
+ rows: list[dict] = []
1065
+ for env in envs:
1066
+ try:
1067
+ env_detail = _model_dict(repo.datasets.get_detail(dataset, environment=env))
1068
+ except Exception: # noqa: BLE001 - 摘要尽力而为
1069
+ rows.append({"environment": env, "versions": "?", "splits": "?", "latest_version": "?"})
1070
+ continue
1071
+ rows.append(
1072
+ {
1073
+ "environment": env,
1074
+ "versions": env_detail.get("version_count", ""),
1075
+ "splits": env_detail.get("split_count", ""),
1076
+ "latest_version": env_detail.get("latest_version") or "",
1077
+ }
1078
+ )
1079
+ typer.echo("\nEnvironments (add --environment <env> to target one)")
1080
+ _print_table(rows, _ENVIRONMENT_SUMMARY_COLUMNS, "plain")
1025
1081
 
1026
1082
 
1027
1083
  def dataset_get(
@@ -1032,6 +1088,13 @@ def dataset_get(
1032
1088
  """Show dataset detail, including version / split summary stats."""
1033
1089
  from .irepo_sdk import _reason
1034
1090
 
1091
+ dataset = dataset.strip()
1092
+ if not dataset:
1093
+ raise _fail(
1094
+ "dataset 查询失败:名称不能为空",
1095
+ "传入非空 dataset 名称,例如 ap dataset get alibaba/a",
1096
+ exit_code=2,
1097
+ )
1035
1098
  output_format = _fmt(output_format)
1036
1099
  env = _environment(environment)
1037
1100
  repo = _dataset_repo()
@@ -1042,12 +1105,10 @@ def dataset_get(
1042
1105
  f"dataset {dataset} 查询失败:{_reason(exc)}",
1043
1106
  f"确认 dataset 名称:ap dataset list --query {dataset}",
1044
1107
  ) from exc
1045
- _emit_detail(
1046
- _model_dict(detail),
1047
- output_format,
1048
- order=_DATASET_DETAIL_ORDER,
1049
- always=("status",),
1050
- )
1108
+ rendered = _model_dict(detail)
1109
+ _emit_detail(rendered, output_format, order=_DATASET_DETAIL_ORDER, always=("status",))
1110
+ if output_format not in ("json", "yaml"):
1111
+ _print_dataset_environments(repo, dataset, rendered)
1051
1112
 
1052
1113
 
1053
1114
  def dataset_create(
@@ -1182,7 +1243,6 @@ def dataset_claim(
1182
1243
  if not detail.get("claimable", False):
1183
1244
  # 幂等:已认领就不是错误(设计 §4.1),stdout 仍按 --format 输出现状。
1184
1245
  owner = str(detail.get("owner") or "").strip() or "<unknown>"
1185
- _note(f"dataset {dataset} 已被认领(owner={owner}),无需重复认领")
1186
1246
  _emit_detail(
1187
1247
  {
1188
1248
  "dataset": dataset,
@@ -1198,21 +1258,30 @@ def dataset_claim(
1198
1258
 
1199
1259
  if not (workspace or "").strip():
1200
1260
  candidates: list[Any] = []
1261
+ candidate_error = ""
1201
1262
  try:
1202
1263
  candidates = repo.datasets.list_claim_workspaces() or []
1203
1264
  except Exception as exc: # noqa: BLE001 - 列不出来不影响报用法错
1204
- _note(f"可选 workspace 列举失败:{_reason(exc)}")
1265
+ candidate_error = _reason(exc)
1266
+ choices: list[str] = []
1205
1267
  for item in candidates:
1206
1268
  queue_id = str(getattr(item, "queue_id", "") or getattr(item, "id", "") or "").strip()
1207
1269
  name = str(getattr(item, "name", "") or "").strip()
1208
1270
  if not queue_id and isinstance(item, dict):
1209
1271
  queue_id = str(item.get("queue_id") or item.get("id") or "")
1210
1272
  name = str(item.get("name") or "")
1211
- typer.echo(f"note: 可选 workspace: {queue_id} {name}".rstrip(), err=True)
1273
+ if queue_id:
1274
+ choices.append(f"{queue_id} ({name})" if name else queue_id)
1275
+ if choices:
1276
+ hint = f"可选 workspace:{', '.join(choices)};"
1277
+ elif candidate_error:
1278
+ hint = f"可选 workspace 列举失败:{candidate_error};"
1279
+ else:
1280
+ hint = ""
1281
+ hint += f"执行 ap dataset claim {dataset} --workspace <queue-id> --benchmark <benchmark>"
1212
1282
  raise _fail(
1213
1283
  f"dataset {dataset} 认领失败:服务端要求 workspace_id,但没给 --workspace",
1214
- "从上面的候选里挑一个:ap dataset claim "
1215
- f"{dataset} --workspace <queue-id> --benchmark <benchmark>",
1284
+ hint,
1216
1285
  exit_code=2,
1217
1286
  )
1218
1287
 
@@ -1230,9 +1299,7 @@ def dataset_claim(
1230
1299
  require_benchmark=bool(benchmark_id),
1231
1300
  )
1232
1301
  except Exception as exc: # noqa: BLE001 - 统一走设计 §5.1 的映射
1233
- code = getattr(exc, "biz_code", None)
1234
- if code is None:
1235
- code = _api_biz_code(exc)
1302
+ code = _business_code(exc)
1236
1303
  if code == 92010:
1237
1304
  # 已被认领:以服务端 92010 为准(不本地推断),幂等 exit 0。
1238
1305
  owner = str(detail.get("owner") or "").strip()
@@ -1243,7 +1310,6 @@ def dataset_claim(
1243
1310
  except Exception: # noqa: BLE001 - 取不到 owner 不影响幂等退出
1244
1311
  owner = ""
1245
1312
  owner = owner or "<unknown>"
1246
- _note(f"dataset {dataset} 已被认领(owner={owner}),无需重复认领")
1247
1313
  _emit_detail(
1248
1314
  {
1249
1315
  "dataset": dataset,
@@ -1341,7 +1407,13 @@ def dataset_access_grant(
1341
1407
  try:
1342
1408
  repo.datasets.revoke(dataset, principal_id, stale, principal_type=resolved_type)
1343
1409
  except Exception as exc: # noqa: BLE001
1344
- if getattr(exc, "biz_code", None) != 24401:
1410
+ code = _business_code(exc)
1411
+ if code == 92011:
1412
+ raise _fail(
1413
+ f"dataset {dataset} 旧角色 {stale} 撤销失败:{_reason(exc)}",
1414
+ _last_admin_hint(dataset),
1415
+ ) from exc
1416
+ if code != 24401:
1345
1417
  raise _fail(
1346
1418
  f"dataset {dataset} 旧角色 {stale} 撤销失败:{_reason(exc)}",
1347
1419
  f"先手动撤销:ap dataset access revoke {dataset} "
@@ -1349,19 +1421,14 @@ def dataset_access_grant(
1349
1421
  ) from exc
1350
1422
  else:
1351
1423
  replaced.append(stale)
1352
- _note(f"已撤销 {principal_id} 在 {dataset} 上的旧角色 {stale}")
1353
1424
 
1354
1425
  already = False
1355
1426
  try:
1356
1427
  repo.datasets.grant(dataset, principal_id, resolved_role, principal_type=resolved_type)
1357
1428
  except Exception as exc: # noqa: BLE001 - 统一走设计 §5.1 的映射
1358
- code = getattr(exc, "biz_code", None)
1429
+ code = _business_code(exc)
1359
1430
  if code == 24404:
1360
1431
  already = True
1361
- _note(
1362
- f"dataset {dataset} 上 {principal_id} 的 {resolved_role} 绑定已存在"
1363
- f"({_reason(exc)})"
1364
- )
1365
1432
  elif code == 24402:
1366
1433
  raise _fail(
1367
1434
  f"dataset {dataset} 授权失败:入参非法({_reason(exc)})",
@@ -1435,7 +1502,6 @@ def dataset_access_revoke(
1435
1502
  if row["role"] in _ROLE_IDS
1436
1503
  ]
1437
1504
  if not targets:
1438
- _note(f"dataset {dataset} 上 {principal_id} 已无绑定,无需撤销")
1439
1505
  _emit_detail(
1440
1506
  {
1441
1507
  "dataset": dataset,
@@ -1455,14 +1521,9 @@ def dataset_access_revoke(
1455
1521
  try:
1456
1522
  repo.datasets.revoke(dataset, principal_id, target, principal_type=resolved_type)
1457
1523
  except Exception as exc: # noqa: BLE001 - 统一走设计 §5.1 的映射
1458
- code = getattr(exc, "biz_code", None)
1524
+ code = _business_code(exc)
1459
1525
  if code == 24401:
1460
- # 幂等:目标态已达成(设计 §5.1),不用 error: 前缀。
1461
1526
  missing.append(target)
1462
- _note(
1463
- f"dataset {dataset} 上 {principal_id} 的 {target} 绑定不存在,"
1464
- f"已处于目标状态({_reason(exc)})"
1465
- )
1466
1527
  continue
1467
1528
  if code == 24402:
1468
1529
  raise _fail(
@@ -1470,6 +1531,11 @@ def dataset_access_revoke(
1470
1531
  "检查工号/用户组 id 与 --principal-type 是否匹配",
1471
1532
  exit_code=2,
1472
1533
  ) from exc
1534
+ if code == 92011:
1535
+ raise _fail(
1536
+ f"dataset {dataset} 撤销 {target} 失败:{_reason(exc)}",
1537
+ _last_admin_hint(dataset),
1538
+ ) from exc
1473
1539
  raise _fail(
1474
1540
  f"dataset {dataset} 撤销 {target} 失败:{_reason(exc)}",
1475
1541
  "撤销需要 dataset-admin;确认该环境已开启 dataset RBAC",
@@ -1534,14 +1600,6 @@ def dataset_version_list(
1534
1600
  ) from exc
1535
1601
 
1536
1602
  records = [_model_dict(record) for record in (getattr(result, "items", None) or [])]
1537
- if records and all(
1538
- "split_count" not in record and "instance_count" not in record for record in records
1539
- ):
1540
- # 旧 SDK(1.0.9.dev0 及以前)的版本列表投影丢服务端统计字段:如实说明,不静默空列。
1541
- _note(
1542
- "已安装的 instance-repo 未返回版本统计(split_count/instance_count),"
1543
- "SPLITS/INSTANCES 两列为空;升级到 1.1.0 及以上可恢复"
1544
- )
1545
1603
  rows = [_version_row(record) for record in records]
1546
1604
  _emit(
1547
1605
  {"dataset": dataset, "versions": records, "pagination": _pagination_envelope(result)},
@@ -1569,10 +1627,15 @@ def dataset_version_get(
1569
1627
  def dataset_version_create(
1570
1628
  version: str = typer.Argument(..., help="Dataset version to create"),
1571
1629
  dataset: str = typer.Option(..., "--dataset", help="Dataset name"),
1630
+ storage_path: Optional[str] = typer.Option(
1631
+ None,
1632
+ "--storage-path",
1633
+ help="Explicit oss:// storage path (otherwise auto-computed from data-plane addressing)",
1634
+ ),
1572
1635
  output_format: str = typer.Option(None, "--format", help=_FORMAT_HELP),
1573
1636
  ):
1574
1637
  """Create a draft version. storage_path is auto-computed from data-plane addressing."""
1575
- from .irepo_sdk import SdkMissing, load_repo, sdk_error_exit
1638
+ from .irepo_sdk import SdkMissing, _is_addressing_failure, _reason, load_repo, sdk_error_exit
1576
1639
 
1577
1640
  output_format = _fmt(output_format)
1578
1641
  resolved = _version_value(version)
@@ -1590,9 +1653,19 @@ def dataset_version_create(
1590
1653
  except SdkMissing as exc:
1591
1654
  typer.echo(str(exc), err=True)
1592
1655
  raise typer.Exit(2) from exc
1656
+ create_kwargs = {}
1657
+ explicit_storage_path = str(storage_path or "").strip()
1658
+ if explicit_storage_path:
1659
+ create_kwargs["storage_path"] = explicit_storage_path
1593
1660
  try:
1594
- record = repo.versions.create(dataset, resolved)
1661
+ record = repo.versions.create(dataset, resolved, **create_kwargs)
1595
1662
  except Exception as exc: # noqa: BLE001 - 统一走设计 §5.1 的映射
1663
+ if _is_addressing_failure(exc):
1664
+ raise _fail(
1665
+ f"dataset {dataset} 版本 {resolved} 创建失败:{_reason(exc)}",
1666
+ "设置 AP_CLUSTER,或在命令前使用 ap --cluster <cluster>;"
1667
+ "也可传 --storage-path oss://<bucket>/<prefix>/",
1668
+ ) from exc
1596
1669
  sdk_error_exit(exc, what=f"dataset {dataset} 版本 {resolved}", stage="创建")
1597
1670
  # 与 ``version get`` 同一个视图:同一个对象在两个命令里字段集、字段顺序、空值处理
1598
1671
  # 必须一致(``to_dict()`` 原样输出会把 SDK 补的 False/[]/{} 也打出来)。
@@ -1642,7 +1715,11 @@ def dataset_split_list(
1642
1715
 
1643
1716
  if resolved_version:
1644
1717
  detail = _fetch_version(
1645
- dataset, resolved_version, stage="split 查询", environment=environment
1718
+ dataset,
1719
+ resolved_version,
1720
+ stage="split 查询",
1721
+ environment=environment,
1722
+ metadata_model=_VERSION_SPLIT_METADATA_MODEL,
1646
1723
  )
1647
1724
  records = _split_records(detail)
1648
1725
  rows = [_split_row(record, detail) for record in records]
@@ -1673,7 +1750,11 @@ def dataset_split_get(
1673
1750
 
1674
1751
  if resolved_version:
1675
1752
  detail = _fetch_version(
1676
- dataset, resolved_version, stage="split 查询", environment=environment
1753
+ dataset,
1754
+ resolved_version,
1755
+ stage="split 查询",
1756
+ environment=environment,
1757
+ metadata_model=_VERSION_SPLIT_METADATA_MODEL,
1677
1758
  )
1678
1759
  for record in _split_records(detail):
1679
1760
  if str(record.get("name") or "") == split:
@@ -1738,20 +1819,22 @@ def dataset_split_create(
1738
1819
  )
1739
1820
 
1740
1821
  detail = _fetch_version(
1741
- dataset, resolved_version, stage="split 创建前查询", environment=environment
1822
+ dataset,
1823
+ resolved_version,
1824
+ stage="split 创建前查询",
1825
+ environment=environment,
1826
+ metadata_model=_VERSION_SPLIT_METADATA_MODEL,
1742
1827
  )
1743
1828
 
1744
1829
  existing = _split_names(detail)
1745
1830
  if split in existing:
1746
1831
  # 幂等:已存在同名 split(设计 §4.3)。
1747
- _note(f"dataset {dataset} 版本 {_version_label(resolved_version)} 已有 split {split}")
1748
- if resolved_run_type:
1749
- _note(f"要改 run_type 请用:ap dataset split update {split} --dataset {dataset}")
1750
1832
  for record in _split_records(detail):
1751
1833
  if str(record.get("name") or "") == split:
1752
1834
  payload = dict(record)
1753
1835
  payload.setdefault("dataset", dataset)
1754
1836
  payload.setdefault("version", resolved_version)
1837
+ payload["created"] = False
1755
1838
  _emit_detail(payload, output_format)
1756
1839
  return
1757
1840
  _emit_detail(
@@ -1768,6 +1851,7 @@ def dataset_split_create(
1768
1851
  {"splits": names},
1769
1852
  stage="split 创建",
1770
1853
  environment=environment,
1854
+ metadata_model=_VERSION_SPLIT_METADATA_MODEL,
1771
1855
  )
1772
1856
 
1773
1857
  # ② run_type 只能第二次 PATCH:服务端 splits 与 split_run_types 互斥。
@@ -1785,6 +1869,7 @@ def dataset_split_create(
1785
1869
  # 第一段 PATCH 已建出 split,失败后的恢复动作是补打 run_type,不是重跑 create。
1786
1870
  fallback_hint=f"split {split} 已创建但 run_type 未设置,补打:{retry}",
1787
1871
  environment=environment,
1872
+ metadata_model=_VERSION_SPLIT_METADATA_MODEL,
1788
1873
  )
1789
1874
 
1790
1875
  _emit_detail(
@@ -1872,14 +1957,16 @@ def dataset_split_update(
1872
1957
  return
1873
1958
 
1874
1959
  detail = _fetch_version(
1875
- dataset, resolved_version, stage="split 更新前查询", environment=environment
1960
+ dataset,
1961
+ resolved_version,
1962
+ stage="split 更新前查询",
1963
+ environment=environment,
1964
+ metadata_model=_VERSION_SPLIT_METADATA_MODEL,
1876
1965
  )
1877
1966
 
1878
1967
  if split not in _split_names(detail):
1879
1968
  known = ", ".join(_split_names(detail)) or "<无>"
1880
1969
  hint = f"先创建:ap dataset split create {split} --dataset {dataset}(现有:{known})"
1881
- if not resolved_version:
1882
- hint = f"{hint};{_NO_VERSION_NOTE}"
1883
1970
  raise _fail(
1884
1971
  f"dataset {dataset} 版本 {_version_label(resolved_version)} split {split} "
1885
1972
  f"更新失败:不存在",
@@ -1892,6 +1979,7 @@ def dataset_split_update(
1892
1979
  {"split_run_types": [{"name": split, "run_type": resolved_run_type}]},
1893
1980
  stage="split 更新",
1894
1981
  environment=environment,
1982
+ metadata_model=_VERSION_SPLIT_METADATA_MODEL,
1895
1983
  )
1896
1984
  _emit_detail(
1897
1985
  {
@@ -13,7 +13,7 @@
13
13
 
14
14
  1. ``--user`` 是**空间归主的工号**,不是被授权人;``fs access grant/revoke`` 的
15
15
  被授权主体用 ``--principal``。``--principal`` 优先;只给了 ``--user`` 时把它
16
- 当被授权主体、空间归主取当前用户,并在 stderr 说明这次解释。
16
+ 当被授权主体、空间归主取当前用户,结果中的 owner/principal 字段反映最终解释。
17
17
  2. 数据面寻址(含 ACL 资源 id 里的 storage_env)对客屏蔽:用户只需配 AP 基础环境
18
18
  变量(``AP_CLUSTER`` / ``AP_API_KEY``),storage_env 由 SDK 依据 cluster 自动发现
19
19
  (``repo.profile.storage_env``)。CLI 只把它用于**回显** ``{storage_env}/{uid}``,
@@ -99,10 +99,6 @@ def _fail(message: str, hint: str, exit_code: int = 1) -> "typer.Exit":
99
99
  return typer.Exit(exit_code)
100
100
 
101
101
 
102
- def _note(message: str) -> None:
103
- typer.echo(f"note: {message}", err=True)
104
-
105
-
106
102
  def _describe(exc: BaseException) -> str:
107
103
  message = getattr(exc, "message", None)
108
104
  text = str(message or "").strip() or str(exc).strip()
@@ -462,10 +458,6 @@ def _resolve_principal(repo, *, user: Optional[str], principal: Optional[str]) -
462
458
  exit_code=2,
463
459
  )
464
460
  owner = _resolve_uid(repo, None)
465
- _note(
466
- f"--principal 未给:把 --user={fallback} 当作被授权主体,"
467
- f"空间归主取当前工号 {owner};要授权别人的空间请显式写 --user <归主> --principal <主体>"
468
- )
469
461
  return owner, fallback
470
462
 
471
463
 
@@ -508,7 +500,6 @@ def fs_access_grant(
508
500
  if not is_already_satisfied(exc):
509
501
  sdk_error_exit(exc, what=f"用户存储空间 {owner} 的授权", stage="授予")
510
502
  already = True
511
- _note(f"用户存储空间 {owner} 上 {grantee} 的 {resolved_role} 绑定已存在:{_describe(exc)}")
512
503
 
513
504
  _emit_detail(
514
505
  {
@@ -583,7 +574,6 @@ def fs_access_revoke(
583
574
  if per_role and not targets:
584
575
  targets = _roles_of(repo, owner, grantee, resolved_type, subpath)
585
576
  if not targets:
586
- _note(f"用户存储空间 {owner} 上 {grantee} 没有任何绑定,无需撤销")
587
577
  _emit_detail(
588
578
  _revoke_payload(owner, storage_env, resolved_type, grantee, subpath, [], []),
589
579
  output_format,
@@ -610,7 +600,6 @@ def fs_access_revoke(
610
600
  if not is_already_satisfied(exc):
611
601
  sdk_error_exit(exc, what=f"用户存储空间 {owner} 的授权", stage="撤销")
612
602
  missing.append(target or "*")
613
- _note(f"用户存储空间 {owner} 上 {grantee} 的绑定本就不存在:{_describe(exc)}")
614
603
  else:
615
604
  revoked.append(target or "*")
616
605
 
@@ -120,11 +120,6 @@ def _fail(message: str, hint: str, exit_code: int = 1) -> "typer.Exit":
120
120
  return typer.Exit(exit_code)
121
121
 
122
122
 
123
- def _note(message: str) -> None:
124
- """退出码 0 的路径只用 ``note:``(设计 §5.1);一律 stderr。"""
125
- typer.echo(f"note: {message}", err=True)
126
-
127
-
128
123
  def _load_repo_or_exit():
129
124
  """懒加载 SDK;缺 extra → 安装提示 + 退出码 2(设计 §2)。"""
130
125
  from .irepo_sdk import SdkMissing, load_repo
@@ -136,6 +131,25 @@ def _load_repo_or_exit():
136
131
  raise typer.Exit(2) from exc
137
132
 
138
133
 
134
+ def _probe_pull_private_endpoint(repo: Any, dataset: str) -> bool | None:
135
+ content = getattr(repo, "_content", None)
136
+ instances = getattr(repo, "instances", None)
137
+ credentials = getattr(content, "_credentials", None)
138
+ probe = getattr(content, "_probe_private_endpoint", None)
139
+ layout_factory = getattr(instances, "_layout", None)
140
+ if not all(callable(value) for value in (credentials, probe, layout_factory)):
141
+ return None
142
+
143
+ from instance_repo._routing import _plan_oss_route
144
+
145
+ layout = layout_factory()
146
+ sts = credentials(layout.sts_prefix(dataset), dataset)
147
+ route = _plan_oss_route(sts.get("endpoint", ""))
148
+ if not route.standard:
149
+ return None
150
+ return bool(probe(route.private_endpoint))
151
+
152
+
139
153
  #: SDK ``instance_repo.validate.detect_format`` 的取值 → 对应校验函数名。分派必须与 SDK
140
154
  #: ``InstancesClient.validate`` 一致;认不出的格式回退 ``check_layout``(与 SDK 同样的兜底)。
141
155
  _VALIDATOR_BY_FORMAT: dict[str, str] = {
@@ -217,12 +231,7 @@ def _describe(exc: BaseException) -> str:
217
231
 
218
232
 
219
233
  def _sdk_read_kwargs(sdk_obj: Any, method: str, *, environment: str, metadata_model: str) -> dict:
220
- """按 SDK 能力决定是否下发 environment / metadata_model。
221
-
222
- ``environment`` / ``metadata_model`` 的按调用覆盖是 ``feat/apcli-dataset`` 起才有的
223
- (1.1.0 前只有分支构建带);用已发布的旧 SDK 时这里不下发,并在 stderr 如实说明读的是
224
- profile 派生的集合——而不是让它抛 TypeError,也不是静默读错集合。
225
- """
234
+ """按 SDK 能力下发 environment / metadata_model,不允许静默忽略显式值。"""
226
235
  fn = getattr(sdk_obj, method, None)
227
236
  accepted: set[str] = set()
228
237
  if fn is not None:
@@ -237,14 +246,18 @@ def _sdk_read_kwargs(sdk_obj: Any, method: str, *, environment: str, metadata_mo
237
246
  accepted.add(name)
238
247
 
239
248
  kwargs: dict[str, str] = {}
249
+ dropped: list[str] = []
240
250
  for key, value in (("environment", environment), ("metadata_model", metadata_model)):
241
251
  # 空值不下发:CLI 不替 SDK 决定 metadata_model(见 dataset_commands._metadata_model)。
242
252
  if key in accepted and value:
243
253
  kwargs[key] = value
244
- if "environment" not in accepted:
245
- _note(
246
- "已安装的 instance-repo 不支持按调用指定 environment/metadata_model,"
247
- "本次按 profile 派生值读取;需要精确指定请升级到 1.1.0 及以上"
254
+ elif value:
255
+ dropped.append(key)
256
+ if dropped:
257
+ raise _fail(
258
+ f"已安装的 instance-repo 不支持按调用指定 {'/'.join(dropped)}",
259
+ "升级到 instance-repo 1.1.0 及以上后重试",
260
+ exit_code=2,
248
261
  )
249
262
  return kwargs
250
263
 
@@ -390,6 +403,12 @@ def instance_list(
390
403
  output_format = _fmt(output_format)
391
404
  resolved_version = _version_value(version)
392
405
  repo = _load_repo_or_exit()
406
+ read_kwargs = _sdk_read_kwargs(
407
+ repo.instances,
408
+ "list_paged",
409
+ environment=_environment(environment),
410
+ metadata_model=_metadata_model_for_split(metadata_model, split=split),
411
+ )
393
412
  try:
394
413
  result = repo.instances.list_paged(
395
414
  dataset,
@@ -397,12 +416,7 @@ def instance_list(
397
416
  split=split,
398
417
  page=page,
399
418
  page_size=page_size,
400
- **_sdk_read_kwargs(
401
- repo.instances,
402
- "list_paged",
403
- environment=_environment(environment),
404
- metadata_model=_metadata_model_for_split(metadata_model, split=split),
405
- ),
419
+ **read_kwargs,
406
420
  )
407
421
  except Exception as exc: # noqa: BLE001 - 统一走设计 §5.1 的映射
408
422
  raise _fail(
@@ -441,18 +455,19 @@ def instance_get(
441
455
  output_format = _fmt(output_format)
442
456
  resolved_version = _version_value(version)
443
457
  repo = _load_repo_or_exit()
458
+ read_kwargs = _sdk_read_kwargs(
459
+ repo.instances,
460
+ "get",
461
+ environment=_environment(environment),
462
+ metadata_model=_metadata_model_for_split(metadata_model, split=split),
463
+ )
444
464
  try:
445
465
  record = repo.instances.get(
446
466
  dataset,
447
467
  resolved_version,
448
468
  instance,
449
469
  split=split,
450
- **_sdk_read_kwargs(
451
- repo.instances,
452
- "get",
453
- environment=_environment(environment),
454
- metadata_model=_metadata_model_for_split(metadata_model, split=split),
455
- ),
470
+ **read_kwargs,
456
471
  )
457
472
  except Exception as exc: # noqa: BLE001 - 统一走设计 §5.1 的映射
458
473
  raise _fail(
@@ -467,9 +482,12 @@ def instance_get(
467
482
  _emit_detail(_instance_record(record), output_format)
468
483
 
469
484
 
485
+ def _confirm_version_autocreate(repo, dataset: str, version: str, split: str, *, yes: bool) -> None:
486
+ """version 不存在时 push 会触发服务端自动创建;要求二次确认(可 ``-y/--yes`` 跳过)。
470
487
 
471
- def _confirm_version_autocreate(dataset: str, version: str, split: str, *, yes: bool) -> None:
472
- """version 不存在时 push 会触发服务端自动创建;要求二次确认(可 ``-y/--yes`` 跳过)。"""
488
+ ``repo`` 由调用方构造并复用:``Repo.__init__`` 会做一次 best-effort 的
489
+ repo-config 自动发现(HTTP),确认与推送共用一个实例,不重复触网。
490
+ """
473
491
  import sys
474
492
 
475
493
  from .dataset_commands import _env_kwargs, _environment, _version_label
@@ -478,7 +496,6 @@ def _confirm_version_autocreate(dataset: str, version: str, split: str, *, yes:
478
496
  if not str(version or "").strip():
479
497
  # 无版本空间:没有"版本会被自动创建"这回事(数据面由 ingest 处理)。
480
498
  return
481
- repo = _load_repo_or_exit()
482
499
  try:
483
500
  repo.versions.get(dataset, version, **_env_kwargs(_environment(None)))
484
501
  except Exception as exc: # noqa: BLE001 - 统一走设计 §5.1 的映射
@@ -493,7 +510,6 @@ def _confirm_version_autocreate(dataset: str, version: str, split: str, *, yes:
493
510
 
494
511
  label = _version_label(version)
495
512
  if yes:
496
- _note(f"版本 {label} 不存在,上传时将由服务端自动创建(已用 --yes 跳过确认)")
497
513
  return
498
514
  if not sys.stdin.isatty():
499
515
  raise _fail(
@@ -547,13 +563,13 @@ def instance_push(
547
563
  # ② 版本不存在时 push 会在服务端自动创建;先确认,避免误建。
548
564
  # (split 已发布不可写由**服务端**裁决——pre 环境里 ingest 产出的 split 记录状态
549
565
  # 就是 published,客户端按它预判会把正常的二次上传全拦掉。)
550
- _confirm_version_autocreate(dataset, resolved, split, yes=yes)
551
-
552
- # ③ 数据面推送。
566
+ # ③ 数据面推送(repo 只构造一次,确认与推送复用,见 _confirm_version_autocreate)。
553
567
  repo = _load_repo_or_exit()
568
+ _confirm_version_autocreate(repo, dataset, resolved, split, yes=yes)
554
569
  pushed: list[dict] = []
555
570
  failed: list[dict] = []
556
571
  unconfirmed: list[str] = []
572
+ result_count_mismatch = False
557
573
  if len(targets) == 1:
558
574
  try:
559
575
  result = repo.instances.push(
@@ -603,10 +619,19 @@ def instance_push(
603
619
  exc, what=f"dataset {dataset} 的 {len(targets)} 个 instance", stage="上传"
604
620
  )
605
621
  else:
606
- for path, result in zip(targets, list(results_many or [])):
622
+ results_list = list(results_many or [])
623
+ result_count_mismatch = len(results_list) != len(targets)
624
+ if len(results_list) < len(targets):
625
+ unconfirmed.extend(targets[len(results_list) :])
626
+ for path, result in zip(targets, results_list):
607
627
  pushed.append(_push_row(path, result))
608
628
 
609
629
  _print_push_summary(dataset, resolved, split, pushed, failed, unconfirmed, output_format)
630
+ if result_count_mismatch:
631
+ raise _fail(
632
+ f"SDK 返回了 {len(results_list)}/{len(targets)} 个上传结果,无法确认完整写入状态",
633
+ f"用 ap instance list --dataset {dataset} --split {split} 核对实际内容",
634
+ )
610
635
 
611
636
 
612
637
  def _push_row(path: str, result: Any) -> dict:
@@ -679,6 +704,16 @@ def instance_pull(
679
704
  )
680
705
 
681
706
  repo = _load_repo_or_exit()
707
+ try:
708
+ private_endpoint_reachable = _probe_pull_private_endpoint(repo, dataset)
709
+ except Exception as exc: # noqa: BLE001
710
+ sdk_error_exit(exc, what=f"instance {instance}", stage="拉取前私网探测")
711
+ if private_endpoint_reachable is False:
712
+ raise _fail(
713
+ f"instance {instance} 拉取失败:当前网络无法访问私网 OSS endpoint",
714
+ "instance pull 不支持公网下载;请在 AP dev cluster / ROCK VPC 内执行",
715
+ )
716
+
682
717
  try:
683
718
  root.mkdir(parents=True, exist_ok=True)
684
719
  written = repo.instances.pull(dataset, resolved, instance, root, split)
@@ -24,24 +24,31 @@
24
24
  cluster 概念。
25
25
 
26
26
  因此 :func:`load_repo` 用 ``inspect.signature`` 做一次能力探测:签名支持的参数
27
- 显式传入,不支持的回退为进程内环境变量注入(不覆盖用户已设的值),并向 stderr
28
- 提示升级。
27
+ 显式传入,不支持的回退为进程内环境变量注入(不覆盖用户已设的值)。
29
28
  """
30
29
 
31
30
  from __future__ import annotations
32
31
 
33
32
  import inspect
33
+ import json
34
34
  import os
35
+ import time
35
36
  from typing import TYPE_CHECKING, Any, NoReturn
36
37
 
37
38
  import typer
38
39
 
39
- from .config import Config, _parse_bool
40
+ from .config import _parse_bool, get_config
40
41
 
41
42
  if TYPE_CHECKING: # pragma: no cover - 仅供类型检查,运行时不 import SDK
42
43
  from instance_repo import Repo
43
44
 
44
- __all__ = ["SdkMissing", "is_already_satisfied", "load_repo", "sdk_error_exit"]
45
+ __all__ = [
46
+ "SdkMissing",
47
+ "is_already_satisfied",
48
+ "load_repo",
49
+ "sdk_error_exit",
50
+ "update_version_detail",
51
+ ]
45
52
 
46
53
  #: 数据集元数据的环境枚举(服务端 ``dataset environment``)。
47
54
  DATASET_ENVIRONMENTS = ("online", "pre")
@@ -53,14 +60,6 @@ _INSTALL_HINT = (
53
60
  "Install it with: pip install 'ap-client[dataset]'"
54
61
  )
55
62
 
56
- #: 旧 SDK(无 ``api_base``/``cluster`` 构造参数)时的升级提示,一行 stderr。
57
- _LEGACY_SDK_NOTE = (
58
- "note: installed instance-repo does not accept Repo(api_base=/cluster=); "
59
- "falling back to INSTANCEREPO_* environment injection. "
60
- "Upgrade with: pip install -U 'instance-repo[oss]>=1.0.9' "
61
- "for cluster<->storage_env validation and symmetric read/write paths."
62
- )
63
-
64
63
  # storage_env 解析顺序:显式参数 > 规范环境变量(1.0.0) > 旧别名(0.8.x)。
65
64
  # **没有默认值**——猜一个 storage_env 会把数据写到错误的存储环境。
66
65
  _STORAGE_ENV_VARS = ("INSTANCE_REPO_STORAGE_ENV", "IR_STORAGE_ENV")
@@ -122,6 +121,97 @@ def _setdefault_env(name: str, value: str) -> None:
122
121
  os.environ[name] = value
123
122
 
124
123
 
124
+ def _sdk_log_value(raw: bytes | str | None, cfg: Any) -> str:
125
+ if raw is None:
126
+ return ""
127
+ text = raw.decode("utf-8", errors="replace") if isinstance(raw, bytes) else raw
128
+ try:
129
+ value = json.loads(text)
130
+ except (TypeError, ValueError):
131
+ if cfg.verbose_redaction:
132
+ return "<non-JSON body omitted>"
133
+ rendered = text
134
+ else:
135
+ from .api import _redact_sensitive_data, _safe_json
136
+
137
+ value = _redact_sensitive_data(value, cfg.verbose_redaction)
138
+ if cfg.verbose_redaction:
139
+ value = _redact_sdk_tokens(value)
140
+ rendered = _safe_json(value)
141
+ if cfg.verbose_full_body or len(rendered) <= cfg.verbose_body_limit:
142
+ return rendered
143
+ return rendered[: cfg.verbose_body_limit] + "..."
144
+
145
+
146
+ def _redact_sdk_tokens(value: Any) -> Any:
147
+ if isinstance(value, dict):
148
+ redacted = {}
149
+ for key, item in value.items():
150
+ normalized = str(key).lower().replace("_", "").replace("-", "")
151
+ redacted[key] = (
152
+ "<redacted>" if normalized.endswith("token") else _redact_sdk_tokens(item)
153
+ )
154
+ return redacted
155
+ if isinstance(value, list):
156
+ return [_redact_sdk_tokens(item) for item in value]
157
+ return value
158
+
159
+
160
+ def _instrument_sdk_transport(transport: Any, cfg: Any) -> bool:
161
+ sender = getattr(transport, "_send", None)
162
+ if not callable(sender):
163
+ return False
164
+
165
+ from .api import _redact_headers, _redact_url_query_secrets, _safe_json, _write_stderr
166
+
167
+ def logged_send(method: str, url: str, headers: dict, body: bytes | None, timeout: float):
168
+ shown_url = _redact_url_query_secrets(url) if cfg.verbose_redaction else url
169
+ lines = [
170
+ f">> Request {method} {shown_url}",
171
+ f" headers={_safe_json(_redact_headers(headers, cfg.verbose_redaction))}",
172
+ ]
173
+ if body is not None:
174
+ lines.extend((f" body_size={len(body)}", f" body={_sdk_log_value(body, cfg)}"))
175
+ _write_stderr(lines)
176
+ started = time.perf_counter()
177
+ try:
178
+ status, text = sender(method, url, headers, body, timeout)
179
+ except Exception as exc:
180
+ duration_ms = (time.perf_counter() - started) * 1000
181
+ _write_stderr(
182
+ [
183
+ f"!! Error {method} {shown_url}",
184
+ f" duration_ms={duration_ms:.1f}",
185
+ f" error={type(exc).__name__}: {exc}",
186
+ ]
187
+ )
188
+ raise
189
+ duration_ms = (time.perf_counter() - started) * 1000
190
+ encoded = text.encode("utf-8", errors="replace")
191
+ response_lines = [
192
+ f"<< Response {method} {shown_url}",
193
+ f" status={status}",
194
+ f" duration_ms={duration_ms:.1f}",
195
+ f" body_size={len(encoded)}",
196
+ ]
197
+ if text:
198
+ response_lines.append(f" body={_sdk_log_value(text, cfg)}")
199
+ _write_stderr(response_lines)
200
+ return status, text
201
+
202
+ transport._send = logged_send
203
+ return True
204
+
205
+
206
+ def _verbose_sdk_transport(cfg: Any, api_env: str) -> Any | None:
207
+ try:
208
+ from instance_repo.transport import Transport
209
+ except ImportError:
210
+ return None
211
+ transport = Transport(cfg.base_url, cfg.token_key or "", env=api_env)
212
+ return transport if _instrument_sdk_transport(transport, cfg) else None
213
+
214
+
125
215
  def load_repo(*, cluster: str | None = None, storage_env: str | None = None) -> "Repo":
126
216
  """按 AP ``Config`` 构造 ``instance_repo.Repo``。
127
217
 
@@ -144,7 +234,10 @@ def load_repo(*, cluster: str | None = None, storage_env: str | None = None) ->
144
234
 
145
235
  # 多 key 的 AP_API_KEYS 在这里直接抛 ConfigurationError:数据面要用这把 key 去
146
236
  # 换 STS,拿不到确定的单 key 就没法继续(与控制面同一口径)。
147
- cfg = Config.from_env(cluster=cluster if cluster is not None else _api._cluster_override)
237
+ cfg = get_config(
238
+ verbose=_api._verbose_override,
239
+ cluster=cluster if cluster is not None else _api._cluster_override,
240
+ )
148
241
 
149
242
  effective_cluster = _cluster_from_headers(cfg.headers)
150
243
  effective_storage_env = _resolve_storage_env(storage_env)
@@ -162,13 +255,16 @@ def load_repo(*, cluster: str | None = None, storage_env: str | None = None) ->
162
255
  kwargs["storage_env"] = effective_storage_env
163
256
  if "api_env" in supported and api_env:
164
257
  kwargs["api_env"] = api_env
258
+ if cfg.verbose and "transport" in supported:
259
+ transport = _verbose_sdk_transport(cfg, api_env)
260
+ if transport is not None:
261
+ kwargs["transport"] = transport
165
262
 
166
263
  if "api_base" not in supported or "cluster" not in supported:
167
264
  # 0.8.x 兼容路径:构造参数缺位,只能退回环境变量契约。
168
265
  _setdefault_env(_LEGACY_API_BASE_VAR, cfg.base_url)
169
266
  _setdefault_env(_LEGACY_TOKEN_VAR, cfg.token_key or "")
170
267
  _setdefault_env(_LEGACY_STORAGE_ENV_VAR, effective_storage_env)
171
- typer.echo(_LEGACY_SDK_NOTE, err=True)
172
268
 
173
269
  return _Repo(**kwargs)
174
270
 
@@ -185,7 +281,9 @@ def _init_parameters(repo_cls: type) -> frozenset[str]:
185
281
  continue
186
282
  if parameter.kind is inspect.Parameter.VAR_KEYWORD:
187
283
  # **kwargs 会吞下任何参数,当作全部支持(1.0.0 之后的宽松签名)。
188
- return frozenset({"token", "api_base", "cluster", "storage_env", "api_env"})
284
+ return frozenset(
285
+ {"token", "api_base", "cluster", "storage_env", "api_env", "transport"}
286
+ )
189
287
  if parameter.kind is not inspect.Parameter.VAR_POSITIONAL:
190
288
  names.add(name)
191
289
  return frozenset(names)
@@ -210,7 +308,7 @@ _BIZ_CODE_RULES: dict[int, tuple[str, int]] = {
210
308
  ),
211
309
  94018: ("当前角色只读,写操作需要 writer 角色;让 admin 执行 ap dataset access grant", 1),
212
310
  94019: ("dataset 标识冲突:检查 --dataset 的取值是否与已有 dataset 重名", 1),
213
- 94020: ("请求未带 dataset 标识(SDK 过旧),升级:pip install -U 'instance-repo[oss]>=1.0.9'", 1),
311
+ 94020: ("请求未带 dataset 标识(SDK 过旧),升级:pip install -U 'instance-repo[oss]>=1.1.0'", 1),
214
312
  94021: ("该 dataset 不存在,先执行:ap dataset create <dataset> --benchmark <b>", 1),
215
313
  94022: (
216
314
  "无该用户存储空间的授权,让空间主人执行:ap fs access grant --user <你的工号> --role reader",
@@ -232,7 +330,7 @@ _CODE_RULES: dict[str, tuple[str, int]] = {
232
330
  "E_DIGEST_MISMATCH": ("内容校验失败,重新拉取或重新打包该 instance 后再试", 1),
233
331
  "E_IMMUTABLE": ("已发布的版本不可修改,创建新版本再操作", 1),
234
332
  "E_FORBIDDEN": ("当前身份无权执行该操作,确认 AP_API_KEY 与 dataset 角色", 1),
235
- "E_OWNER_REF_MISSING": ("缺少上游来源引用,升级:pip install -U 'instance-repo[oss]>=1.0.9'", 1),
333
+ "E_OWNER_REF_MISSING": ("缺少上游来源引用,升级:pip install -U 'instance-repo[oss]>=1.1.0'", 1),
236
334
  "E_LAYOUT": ("instance 目录结构不合规,先执行 ap instance validate <path> 定位问题", 1),
237
335
  "E_SCHEMA": ("instance 元数据不符合 schema,先执行 ap instance validate <path> 定位问题", 1),
238
336
  "E_CRED_EXPIRED": ("临时凭据已过期,重试该命令即可重新换取", 1),
@@ -253,6 +351,7 @@ _ADDRESSING_FIELDS = (
253
351
  "oss_prefix",
254
352
  "scaffold_bucket",
255
353
  "scaffold_root",
354
+ "storage_path",
256
355
  )
257
356
  _ADDRESSING_HINT = (
258
357
  "当前 AP 集群未下发数据面存储配置,确认 AP_CLUSTER 指向支持 dataset 的集群,或联系 Dataset 管理员"
@@ -291,24 +390,20 @@ def update_version_detail(
291
390
  body: dict,
292
391
  *,
293
392
  environment: str,
393
+ metadata_model: str = "",
294
394
  ) -> None:
295
- """``PATCH /apis/v1/datasets/versions/detail``,显式下发 ``environment``。
296
-
297
- 为什么不用 ``repo.versions.update``(临时 shim,已反馈 SDK):它内部用
298
- ``_version_extra_q()`` **无参**调用,只能下发 profile 派生值(dev/staging 集群
299
- → ``pre``);而 CLI 的读路径与 ``versions.create`` 都在 ``online``,于是出现
300
- "``version get`` 找得到、PATCH 却 404 dataset version not found"。SDK 的
301
- ``get``/``list_paged``/``create``/``ingest`` 都接受显式 ``environment``,
302
- 只有 ``update``/``status`` 没有。SDK 补上参数后这里会自动改走 SDK 方法。
303
- """
395
+ """``PATCH /apis/v1/datasets/versions/detail`` with explicit routing selectors."""
304
396
  update = repo.versions.update
305
397
  try:
306
398
  accepted = inspect.signature(update).parameters
307
399
  except (TypeError, ValueError): # pragma: no cover - 内建/装饰过的可调用
308
400
  accepted = {}
309
- if "environment" in accepted:
310
- # 空值不传:让 SDK 按 profile 派生(与其它读路径同口径,见 dataset_commands._environment)
311
- kwargs = dict(body, **({"environment": environment} if environment else {}))
401
+ if "environment" in accepted and "metadata_model" in accepted:
402
+ kwargs = dict(body)
403
+ if environment:
404
+ kwargs["environment"] = environment
405
+ if metadata_model:
406
+ kwargs["metadata_model"] = metadata_model
312
407
  update(dataset, version, **kwargs)
313
408
  return
314
409
 
@@ -317,6 +412,8 @@ def update_version_detail(
317
412
  query = f"dataset_name={quote(dataset, safe='')}&version={quote(version, safe='')}"
318
413
  if environment:
319
414
  query += f"&environment={quote(environment, safe='')}"
415
+ if metadata_model:
416
+ query += f"&metadata_model={quote(metadata_model, safe='')}"
320
417
  repo.transport.patch(f"/apis/v1/datasets/versions/detail?{query}", dict(body))
321
418
 
322
419
 
@@ -343,23 +440,17 @@ def _resolve_rule(exc: Exception) -> tuple[str, int]:
343
440
 
344
441
 
345
442
  def sdk_error_exit(exc: Exception, *, what: str, stage: str) -> NoReturn:
346
- """把 SDK 异常翻译成固定两行文案 + ``typer.Exit``。
347
-
348
- 非零退出走 ``error: <what> <stage>失败:<reason>``;设计 §5.1 里判定为幂等
349
- (24401/24404,已达目标态)的码退出 0,此时首行改用 ``note:``——把"成功"
350
- 印成 ``error:`` 会让脚本与人都误判。第二行恒为 ``hint: <可执行下一步>``。
443
+ """把 SDK 异常翻译成 ``error:`` / ``hint:`` 文案与 ``typer.Exit``。
351
444
 
352
- ``-v/--verbose`` 时额外打印完整调用栈;默认不把 SDK traceback 当输出。
445
+ 设计 §5.1 里判定为幂等的 24401/24404 直接以 0 退出;非零失败输出固定
446
+ ``error: <what> <stage>失败:<reason>`` 与可执行 ``hint:``。
353
447
  """
354
448
  hint, exit_code = _resolve_rule(exc)
355
- reason = _reason(exc)
356
- if exit_code == 0:
357
- typer.echo(f"note: {what} {stage}:{reason}", err=True)
358
- else:
359
- typer.echo(f"error: {what} {stage}失败:{reason}", err=True)
360
- typer.echo(f"hint: {hint}", err=True)
361
- if _is_verbose():
362
- import traceback
449
+ if exit_code != 0:
450
+ typer.echo(f"error: {what} {stage}失败:{_reason(exc)}", err=True)
451
+ typer.echo(f"hint: {hint}", err=True)
452
+ if _is_verbose():
453
+ import traceback
363
454
 
364
- traceback.print_exception(type(exc), exc, exc.__traceback__)
455
+ traceback.print_exception(type(exc), exc, exc.__traceback__)
365
456
  raise typer.Exit(exit_code)
@@ -8,7 +8,7 @@ from typing import Optional
8
8
 
9
9
  import typer
10
10
 
11
- from .dataset_commands import _emit_detail, _fail, _fmt, _note
11
+ from .dataset_commands import _emit_detail, _fail, _fmt
12
12
 
13
13
  #: SDK 等待循环返回后不再变化的状态;其余视为等待超时。
14
14
  _TERMINAL = ("succeeded", "failed", "canceled", "awaiting_approval")
@@ -44,7 +44,10 @@ def dataset_split_publish(
44
44
  if not yes:
45
45
  if not sys.stdin.isatty():
46
46
  raise _fail("非交互发布需要确认", "加 --yes 确认将该 split 发布到 online", 2)
47
- if not typer.confirm(f"Publish {dataset}/{resolved_version}/{split} to online?"):
47
+ target = (
48
+ f"{dataset}/{resolved_version}/{split}" if resolved_version else f"{dataset}/{split}"
49
+ )
50
+ if not typer.confirm(f"Publish {target} to online?"):
48
51
  raise _fail("发布已取消,未提交工作流", "确认后重跑", 1)
49
52
 
50
53
  try:
@@ -90,7 +93,6 @@ def dataset_split_publish(
90
93
  if not workflow_id:
91
94
  raise _fail("发布响应缺少 workflow id", "重跑同一命令可安全重试")
92
95
  hint = f"ap dataset split publish-status {workflow_id}"
93
- _note(f"发布工作流:{workflow_id};查询进度:{hint}")
94
96
  _emit_detail(workflow, fmt)
95
97
 
96
98
  status = str(workflow.get("status") or "")
@@ -104,8 +106,6 @@ def dataset_split_publish(
104
106
  if status not in _TERMINAL:
105
107
  # 等待窗口内未到终态:SDK 返回最后一帧,服务端工作流不受影响。
106
108
  raise _fail("发布等待超时,服务端工作流仍继续运行", hint)
107
- if status == "awaiting_approval":
108
- _note("发布工作流等待审批,尚未完成发布;审批信息见 approval 字段")
109
109
 
110
110
 
111
111
  def dataset_split_publish_status(
@@ -129,5 +129,3 @@ def dataset_split_publish_status(
129
129
  f"发布工作流 {workflow_id} {status}:{(workflow or {}).get('error_msg') or ''}",
130
130
  "检查输出中的 steps 和 outputs,修正失败原因后再提交发布",
131
131
  )
132
- if status == "awaiting_approval":
133
- _note("发布工作流等待审批,尚未完成发布;审批信息见 approval 字段")
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ap-client"
7
- version = "0.3.1.dev1"
7
+ version = "0.3.2.dev0"
8
8
  description = "Agent Platform API Client & CLI"
9
9
  readme = { text = "A lightweight Python SDK and command line interface for Agent Platform. It provides helpers for configuring API access and managing templates, datasets, jobs, and groups.", content-type = "text/markdown" }
10
10
  requires-python = ">=3.10"