ap-client 0.3.1.dev2__tar.gz → 0.3.2__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.dev2
3
+ Version: 0.3.2
4
4
  Summary: Agent Platform API Client & CLI
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: pyyaml>=6.0
@@ -9,9 +9,9 @@ Requires-Dist: rich>=13.0.0
9
9
  Requires-Dist: typer>=0.9.0
10
10
  Requires-Dist: websockets>=13.0
11
11
  Provides-Extra: all
12
- Requires-Dist: instance-repo[oss]>=1.1.0; extra == 'all'
12
+ Requires-Dist: instance-repo[oss]<2.0.0,>=1.1.2; extra == 'all'
13
13
  Provides-Extra: dataset
14
- Requires-Dist: instance-repo[oss]>=1.1.0; extra == 'dataset'
14
+ Requires-Dist: instance-repo[oss]<2.0.0,>=1.1.2; extra == 'dataset'
15
15
  Description-Content-Type: text/markdown
16
16
 
17
17
  A lightweight Python SDK and command line interface for Agent Platform. It provides helpers for configuring API access and managing templates, datasets, jobs, and groups.
@@ -844,8 +844,6 @@ def _print_detail(data: object) -> None:
844
844
  rows.append((key, _format_nested_detail(value)))
845
845
  elif key == "image" and isinstance(value, str):
846
846
  rows.append((key, _shorten_image(value)))
847
- elif value is None:
848
- continue
849
847
  else:
850
848
  rows.append((key, _format_plain_value(value, max_len=0)))
851
849
  width = max((len(label) for label, _text in rows), default=0)
@@ -120,6 +120,7 @@ _NO_BENCHMARK = "none"
120
120
  _FORMAT_HELP = "Output format: plain/table/json/yaml (default: AP_FORMAT or command default)"
121
121
 
122
122
  _METADATA_MODELS = ("version_first", "split_first")
123
+ _VERSION_SPLIT_METADATA_MODEL = "version_first"
123
124
 
124
125
  #: CLI **不替 SDK 决定元数据模型**:不下发 ``metadata_model``,由 SDK 按 profile 归一
125
126
  #: (当前为 ``split_first``)。
@@ -155,10 +156,10 @@ _NO_VERSION_NOTE = "未指定 --version 表示无版本空间(split_first),不
155
156
  # ---------------------------------------------------------------------------
156
157
 
157
158
 
158
- def _fmt(output_format: Optional[str]) -> str:
159
+ def _fmt(output_format: Optional[str], *, default: str = "plain") -> str:
159
160
  from .cli import _normalize_output_format
160
161
 
161
- return _normalize_output_format(output_format, keep_table=True)
162
+ return _normalize_output_format(output_format, default=default, keep_table=True)
162
163
 
163
164
 
164
165
  def _fail(message: str, hint: str, exit_code: int = 1) -> "typer.Exit":
@@ -168,11 +169,6 @@ def _fail(message: str, hint: str, exit_code: int = 1) -> "typer.Exit":
168
169
  return typer.Exit(exit_code)
169
170
 
170
171
 
171
- def _note(message: str) -> None:
172
- """退出码 0 的路径只用 ``note:``(设计 §5.1);一律 stderr。"""
173
- typer.echo(f"note: {message}", err=True)
174
-
175
-
176
172
  def _describe(exc: BaseException) -> str:
177
173
  message = getattr(exc, "message", None)
178
174
  text = str(message or "").strip() or str(exc).strip()
@@ -412,6 +408,21 @@ def _api_biz_code(exc: BaseException) -> Optional[int]:
412
408
  return None
413
409
 
414
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
+
415
426
  #: apiserver dataset 控制面业务码 → 固定 hint(设计 §5.1)。
416
427
  _DATASET_CODE_HINTS: dict[int, str] = {
417
428
  92008: "该环境未开启 dataset 能力,请联系管理员",
@@ -524,7 +535,12 @@ def _updated_range(from_value: Optional[str], to_value: Optional[str]) -> tuple[
524
535
 
525
536
 
526
537
  def _fetch_version(
527
- 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 = "",
528
544
  ) -> dict:
529
545
  """读 version 详情(含内嵌 ``splits[]``);空 version 的报错必须点明"无版本空间"语义。
530
546
 
@@ -538,7 +554,12 @@ def _fetch_version(
538
554
  env = _environment(environment)
539
555
  repo = _dataset_repo()
540
556
  try:
541
- 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
+ )
542
563
  except Exception as exc: # noqa: BLE001 - 统一走设计 §5.1 的映射
543
564
  if version:
544
565
  hint = (
@@ -650,47 +671,6 @@ def _dataset_repo() -> "Repo":
650
671
  raise typer.Exit(2) from exc
651
672
 
652
673
 
653
- def _dataset_available_envs(repo: "Repo", dataset: str) -> list[str]:
654
- """dataset 元数据分布的 environment 候选;详情读不到时退回固定枚举。"""
655
- envs: list[str] = []
656
- try:
657
- detail = repo.datasets.get_detail(dataset)
658
- except Exception: # noqa: BLE001 - 引导信息尽力而为,不打断主流程
659
- detail = None
660
- raw = detail.get("available_envs") if isinstance(detail, dict) else None
661
- if isinstance(raw, list):
662
- envs = [str(env).strip() for env in raw if str(env).strip()]
663
- from .irepo_sdk import DATASET_ENVIRONMENTS
664
-
665
- for fallback in DATASET_ENVIRONMENTS:
666
- if fallback not in envs:
667
- envs.append(fallback)
668
- return envs
669
-
670
-
671
- def _note_environment_distribution(repo: "Repo", dataset: str, probe, *, what: str) -> None:
672
- """空结果时逐 environment 探测数据分布,引导用户换 ``--environment`` 重试。
673
-
674
- ``probe(env)`` 返回该 environment 下的条数;读失败按 -1 计。全部为空/读失败
675
- (dataset 真没数据)时不提示,避免噪声。
676
- """
677
- counts: list[tuple[str, int]] = []
678
- for env in _dataset_available_envs(repo, dataset):
679
- try:
680
- counts.append((env, int(probe(env))))
681
- except Exception: # noqa: BLE001 - 旧 SDK 不接受 environment 等情况
682
- counts.append((env, -1))
683
- if not any(count > 0 for _env, count in counts):
684
- return
685
- shown = ", ".join(f"{env}={count if count >= 0 else '读失败'}" for env, count in counts)
686
- with_data = [env for env, count in counts if count > 0]
687
- suggest = with_data[0] if len(with_data) == 1 else f"<{'/'.join(with_data)}>"
688
- _note(
689
- f"{what}在本次查询的 environment 下没有数据;各 environment 探测:{shown}。"
690
- f"数据可能在另一个元数据 environment,加 --environment {suggest} 重试"
691
- )
692
-
693
-
694
674
  def _patch_version(
695
675
  dataset: str,
696
676
  version: str,
@@ -699,6 +679,7 @@ def _patch_version(
699
679
  stage: str,
700
680
  fallback_hint: str = "",
701
681
  environment: Optional[str] = None,
682
+ metadata_model: str = "",
702
683
  ) -> None:
703
684
  """split 写操作统一走 SDK;body 为 splits/split_run_types 单键。
704
685
 
@@ -710,7 +691,14 @@ def _patch_version(
710
691
 
711
692
  repo = _dataset_repo()
712
693
  try:
713
- 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
+ )
714
702
  except Exception as exc: # noqa: BLE001 - 统一走设计 §5.1 的映射
715
703
  code = getattr(exc, "biz_code", None)
716
704
  if code == 94018:
@@ -846,14 +834,16 @@ def _bindings(repo, dataset: str) -> list[dict]:
846
834
 
847
835
 
848
836
  def _bindings_of(repo, dataset: str, principal_id: str, principal_type: str) -> list[dict]:
849
- """某 principal 在此 dataset 上的现存绑定;读失败按"读不到"处理,不阻断主动作。"""
837
+ """某 principal 在此 dataset 上的现存绑定;读失败时停止变更。"""
850
838
  from .irepo_sdk import _reason
851
839
 
852
840
  try:
853
841
  rows = _bindings(repo, dataset)
854
- except Exception as exc: # noqa: BLE001 - 读不到绑定不该拦住授权本身
855
- _note(f"读取现有绑定失败({_reason(exc)}),跳过角色去重")
856
- return []
842
+ except Exception as exc: # noqa: BLE001
843
+ raise _fail(
844
+ f"dataset {dataset} 现有授权查询失败:{_reason(exc)}",
845
+ "确认 dataset-admin 权限和当前集群后重试",
846
+ ) from exc
857
847
  return [
858
848
  row
859
849
  for row in rows
@@ -934,7 +924,7 @@ def dataset_list(
934
924
  """List datasets from the registry. Defaults to JSON; use --format table for a table."""
935
925
  from .cli import _print_formatted, get_client
936
926
 
937
- output_format = _fmt(output_format)
927
+ output_format = _fmt(output_format, default="json")
938
928
  resolved_query = str(query or "").strip()
939
929
  legacy_search = str(search or "").strip()
940
930
  if legacy_search:
@@ -947,10 +937,6 @@ def dataset_list(
947
937
  f"或 ap dataset list --query {query}",
948
938
  exit_code=2,
949
939
  )
950
- if not resolved_query:
951
- _note(
952
- f"位置参数搜索已兼容为 --query 的等价形式:ap dataset list --query {legacy_search}"
953
- )
954
940
  resolved_query = legacy_search
955
941
 
956
942
  if legacy:
@@ -981,7 +967,6 @@ def dataset_list(
981
967
  exit_code=2,
982
968
  )
983
969
  result = get_client().list_all_datasets(resolved_query or None)
984
- _note("--legacy 使用旧数据源(与 ap job create --dataset 读取的一致),已自动翻页拉全量")
985
970
  if output_format == "table":
986
971
  from .cli import _print_records_table
987
972
 
@@ -1047,14 +1032,6 @@ def dataset_list(
1047
1032
  records, result = fetch(resolved_page)
1048
1033
  pagination = _pagination_envelope(result)
1049
1034
 
1050
- total = _pagination_total(pagination)
1051
- if total is not None and total > len(records):
1052
- _note(
1053
- f"共 {total} 个 dataset,本次返回第 {resolved_page} 页的 {len(records)} 条"
1054
- f"(每页 {resolved_page_size});继续翻页用 --page/--page-size,"
1055
- f"拉全量用 --legacy"
1056
- )
1057
-
1058
1035
  payload: dict[str, Any] = {}
1059
1036
  if mine:
1060
1037
  payload["owned_by_me"] = True
@@ -1063,11 +1040,7 @@ def dataset_list(
1063
1040
  payload["datasets"] = [_legacy_dataset_record(record) for record in records]
1064
1041
  payload["total"] = _pagination_total(pagination, fallback=len(records))
1065
1042
  payload["pagination"] = pagination
1066
- if output_format == "table":
1067
- _print_table(rows, _DATASET_COLUMNS, output_format)
1068
- else:
1069
- # Legacy plain output is JSON, as for dataset versions / instances.
1070
- _print_formatted(payload, output_format)
1043
+ _emit(payload, rows, _DATASET_COLUMNS, output_format)
1071
1044
 
1072
1045
 
1073
1046
  #: ``dataset get`` 尾部 environment 摘要表的列。
@@ -1115,6 +1088,13 @@ def dataset_get(
1115
1088
  """Show dataset detail, including version / split summary stats."""
1116
1089
  from .irepo_sdk import _reason
1117
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
+ )
1118
1098
  output_format = _fmt(output_format)
1119
1099
  env = _environment(environment)
1120
1100
  repo = _dataset_repo()
@@ -1263,7 +1243,6 @@ def dataset_claim(
1263
1243
  if not detail.get("claimable", False):
1264
1244
  # 幂等:已认领就不是错误(设计 §4.1),stdout 仍按 --format 输出现状。
1265
1245
  owner = str(detail.get("owner") or "").strip() or "<unknown>"
1266
- _note(f"dataset {dataset} 已被认领(owner={owner}),无需重复认领")
1267
1246
  _emit_detail(
1268
1247
  {
1269
1248
  "dataset": dataset,
@@ -1279,21 +1258,30 @@ def dataset_claim(
1279
1258
 
1280
1259
  if not (workspace or "").strip():
1281
1260
  candidates: list[Any] = []
1261
+ candidate_error = ""
1282
1262
  try:
1283
1263
  candidates = repo.datasets.list_claim_workspaces() or []
1284
1264
  except Exception as exc: # noqa: BLE001 - 列不出来不影响报用法错
1285
- _note(f"可选 workspace 列举失败:{_reason(exc)}")
1265
+ candidate_error = _reason(exc)
1266
+ choices: list[str] = []
1286
1267
  for item in candidates:
1287
1268
  queue_id = str(getattr(item, "queue_id", "") or getattr(item, "id", "") or "").strip()
1288
1269
  name = str(getattr(item, "name", "") or "").strip()
1289
1270
  if not queue_id and isinstance(item, dict):
1290
1271
  queue_id = str(item.get("queue_id") or item.get("id") or "")
1291
1272
  name = str(item.get("name") or "")
1292
- 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>"
1293
1282
  raise _fail(
1294
1283
  f"dataset {dataset} 认领失败:服务端要求 workspace_id,但没给 --workspace",
1295
- "从上面的候选里挑一个:ap dataset claim "
1296
- f"{dataset} --workspace <queue-id> --benchmark <benchmark>",
1284
+ hint,
1297
1285
  exit_code=2,
1298
1286
  )
1299
1287
 
@@ -1311,9 +1299,7 @@ def dataset_claim(
1311
1299
  require_benchmark=bool(benchmark_id),
1312
1300
  )
1313
1301
  except Exception as exc: # noqa: BLE001 - 统一走设计 §5.1 的映射
1314
- code = getattr(exc, "biz_code", None)
1315
- if code is None:
1316
- code = _api_biz_code(exc)
1302
+ code = _business_code(exc)
1317
1303
  if code == 92010:
1318
1304
  # 已被认领:以服务端 92010 为准(不本地推断),幂等 exit 0。
1319
1305
  owner = str(detail.get("owner") or "").strip()
@@ -1324,7 +1310,6 @@ def dataset_claim(
1324
1310
  except Exception: # noqa: BLE001 - 取不到 owner 不影响幂等退出
1325
1311
  owner = ""
1326
1312
  owner = owner or "<unknown>"
1327
- _note(f"dataset {dataset} 已被认领(owner={owner}),无需重复认领")
1328
1313
  _emit_detail(
1329
1314
  {
1330
1315
  "dataset": dataset,
@@ -1422,7 +1407,13 @@ def dataset_access_grant(
1422
1407
  try:
1423
1408
  repo.datasets.revoke(dataset, principal_id, stale, principal_type=resolved_type)
1424
1409
  except Exception as exc: # noqa: BLE001
1425
- 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:
1426
1417
  raise _fail(
1427
1418
  f"dataset {dataset} 旧角色 {stale} 撤销失败:{_reason(exc)}",
1428
1419
  f"先手动撤销:ap dataset access revoke {dataset} "
@@ -1430,19 +1421,14 @@ def dataset_access_grant(
1430
1421
  ) from exc
1431
1422
  else:
1432
1423
  replaced.append(stale)
1433
- _note(f"已撤销 {principal_id} 在 {dataset} 上的旧角色 {stale}")
1434
1424
 
1435
1425
  already = False
1436
1426
  try:
1437
1427
  repo.datasets.grant(dataset, principal_id, resolved_role, principal_type=resolved_type)
1438
1428
  except Exception as exc: # noqa: BLE001 - 统一走设计 §5.1 的映射
1439
- code = getattr(exc, "biz_code", None)
1429
+ code = _business_code(exc)
1440
1430
  if code == 24404:
1441
1431
  already = True
1442
- _note(
1443
- f"dataset {dataset} 上 {principal_id} 的 {resolved_role} 绑定已存在"
1444
- f"({_reason(exc)})"
1445
- )
1446
1432
  elif code == 24402:
1447
1433
  raise _fail(
1448
1434
  f"dataset {dataset} 授权失败:入参非法({_reason(exc)})",
@@ -1516,7 +1502,6 @@ def dataset_access_revoke(
1516
1502
  if row["role"] in _ROLE_IDS
1517
1503
  ]
1518
1504
  if not targets:
1519
- _note(f"dataset {dataset} 上 {principal_id} 已无绑定,无需撤销")
1520
1505
  _emit_detail(
1521
1506
  {
1522
1507
  "dataset": dataset,
@@ -1536,14 +1521,9 @@ def dataset_access_revoke(
1536
1521
  try:
1537
1522
  repo.datasets.revoke(dataset, principal_id, target, principal_type=resolved_type)
1538
1523
  except Exception as exc: # noqa: BLE001 - 统一走设计 §5.1 的映射
1539
- code = getattr(exc, "biz_code", None)
1524
+ code = _business_code(exc)
1540
1525
  if code == 24401:
1541
- # 幂等:目标态已达成(设计 §5.1),不用 error: 前缀。
1542
1526
  missing.append(target)
1543
- _note(
1544
- f"dataset {dataset} 上 {principal_id} 的 {target} 绑定不存在,"
1545
- f"已处于目标状态({_reason(exc)})"
1546
- )
1547
1527
  continue
1548
1528
  if code == 24402:
1549
1529
  raise _fail(
@@ -1551,6 +1531,11 @@ def dataset_access_revoke(
1551
1531
  "检查工号/用户组 id 与 --principal-type 是否匹配",
1552
1532
  exit_code=2,
1553
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
1554
1539
  raise _fail(
1555
1540
  f"dataset {dataset} 撤销 {target} 失败:{_reason(exc)}",
1556
1541
  "撤销需要 dataset-admin;确认该环境已开启 dataset RBAC",
@@ -1615,37 +1600,6 @@ def dataset_version_list(
1615
1600
  ) from exc
1616
1601
 
1617
1602
  records = [_model_dict(record) for record in (getattr(result, "items", None) or [])]
1618
- if not records:
1619
- # 空结果常见根因是元数据 environment 不匹配(version 落在另一个 env),
1620
- # 探测一遍并引导切换,而不是只回一张空表。
1621
- _note_environment_distribution(
1622
- repo,
1623
- dataset,
1624
- lambda env: (
1625
- _pagination_total(
1626
- _pagination_envelope(
1627
- repo.versions.list_paged(
1628
- dataset,
1629
- status=resolved_status or None,
1630
- page=1,
1631
- page_size=1,
1632
- environment=env,
1633
- )
1634
- ),
1635
- fallback=0,
1636
- )
1637
- or 0
1638
- ),
1639
- what="version",
1640
- )
1641
- if records and all(
1642
- "split_count" not in record and "instance_count" not in record for record in records
1643
- ):
1644
- # 旧 SDK(1.0.9.dev0 及以前)的版本列表投影丢服务端统计字段:如实说明,不静默空列。
1645
- _note(
1646
- "已安装的 instance-repo 未返回版本统计(split_count/instance_count),"
1647
- "SPLITS/INSTANCES 两列为空;升级到 1.1.0 及以上可恢复"
1648
- )
1649
1603
  rows = [_version_row(record) for record in records]
1650
1604
  _emit(
1651
1605
  {"dataset": dataset, "versions": records, "pagination": _pagination_envelope(result)},
@@ -1673,10 +1627,15 @@ def dataset_version_get(
1673
1627
  def dataset_version_create(
1674
1628
  version: str = typer.Argument(..., help="Dataset version to create"),
1675
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
+ ),
1676
1635
  output_format: str = typer.Option(None, "--format", help=_FORMAT_HELP),
1677
1636
  ):
1678
1637
  """Create a draft version. storage_path is auto-computed from data-plane addressing."""
1679
- 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
1680
1639
 
1681
1640
  output_format = _fmt(output_format)
1682
1641
  resolved = _version_value(version)
@@ -1694,9 +1653,19 @@ def dataset_version_create(
1694
1653
  except SdkMissing as exc:
1695
1654
  typer.echo(str(exc), err=True)
1696
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
1697
1660
  try:
1698
- record = repo.versions.create(dataset, resolved)
1661
+ record = repo.versions.create(dataset, resolved, **create_kwargs)
1699
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
1700
1669
  sdk_error_exit(exc, what=f"dataset {dataset} 版本 {resolved}", stage="创建")
1701
1670
  # 与 ``version get`` 同一个视图:同一个对象在两个命令里字段集、字段顺序、空值处理
1702
1671
  # 必须一致(``to_dict()`` 原样输出会把 SDK 补的 False/[]/{} 也打出来)。
@@ -1746,7 +1715,11 @@ def dataset_split_list(
1746
1715
 
1747
1716
  if resolved_version:
1748
1717
  detail = _fetch_version(
1749
- 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,
1750
1723
  )
1751
1724
  records = _split_records(detail)
1752
1725
  rows = [_split_row(record, detail) for record in records]
@@ -1755,27 +1728,6 @@ def dataset_split_list(
1755
1728
  rows = [_split_row(record, {}) for record in records]
1756
1729
  if resolved_run_type:
1757
1730
  rows = [row for row in rows if str(row["run_type"]).lower() == resolved_run_type]
1758
- if not rows and not resolved_run_type:
1759
- # 空结果常见根因是元数据 environment 不匹配,探测一遍并引导切换。
1760
- probe_repo = _dataset_repo()
1761
- if resolved_version:
1762
-
1763
- def _probe(env: str) -> int:
1764
- try:
1765
- detail = probe_repo.versions.get(dataset, resolved_version, environment=env)
1766
- except Exception: # noqa: BLE001 - 该 env 没有这个版本
1767
- return -1
1768
- return len(_split_records(_model_dict(detail)))
1769
-
1770
- else:
1771
-
1772
- def _probe(env: str) -> int:
1773
- page = probe_repo.splits.list(
1774
- dataset, version_scope="unversioned", page_size=1, environment=env
1775
- )
1776
- return _pagination_total(_pagination_envelope(page), fallback=0) or 0
1777
-
1778
- _note_environment_distribution(probe_repo, dataset, _probe, what="split")
1779
1731
  _emit(
1780
1732
  {"dataset": dataset, "version": resolved_version, "splits": rows},
1781
1733
  rows,
@@ -1798,7 +1750,11 @@ def dataset_split_get(
1798
1750
 
1799
1751
  if resolved_version:
1800
1752
  detail = _fetch_version(
1801
- 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,
1802
1758
  )
1803
1759
  for record in _split_records(detail):
1804
1760
  if str(record.get("name") or "") == split:
@@ -1863,20 +1819,22 @@ def dataset_split_create(
1863
1819
  )
1864
1820
 
1865
1821
  detail = _fetch_version(
1866
- 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,
1867
1827
  )
1868
1828
 
1869
1829
  existing = _split_names(detail)
1870
1830
  if split in existing:
1871
1831
  # 幂等:已存在同名 split(设计 §4.3)。
1872
- _note(f"dataset {dataset} 版本 {_version_label(resolved_version)} 已有 split {split}")
1873
- if resolved_run_type:
1874
- _note(f"要改 run_type 请用:ap dataset split update {split} --dataset {dataset}")
1875
1832
  for record in _split_records(detail):
1876
1833
  if str(record.get("name") or "") == split:
1877
1834
  payload = dict(record)
1878
1835
  payload.setdefault("dataset", dataset)
1879
1836
  payload.setdefault("version", resolved_version)
1837
+ payload["created"] = False
1880
1838
  _emit_detail(payload, output_format)
1881
1839
  return
1882
1840
  _emit_detail(
@@ -1893,6 +1851,7 @@ def dataset_split_create(
1893
1851
  {"splits": names},
1894
1852
  stage="split 创建",
1895
1853
  environment=environment,
1854
+ metadata_model=_VERSION_SPLIT_METADATA_MODEL,
1896
1855
  )
1897
1856
 
1898
1857
  # ② run_type 只能第二次 PATCH:服务端 splits 与 split_run_types 互斥。
@@ -1910,6 +1869,7 @@ def dataset_split_create(
1910
1869
  # 第一段 PATCH 已建出 split,失败后的恢复动作是补打 run_type,不是重跑 create。
1911
1870
  fallback_hint=f"split {split} 已创建但 run_type 未设置,补打:{retry}",
1912
1871
  environment=environment,
1872
+ metadata_model=_VERSION_SPLIT_METADATA_MODEL,
1913
1873
  )
1914
1874
 
1915
1875
  _emit_detail(
@@ -1949,8 +1909,9 @@ def dataset_split_update(
1949
1909
  resolved_run_type = _check_choice(run_type, _RUN_TYPES, "--run-type")
1950
1910
 
1951
1911
  if not resolved_version:
1952
- # 无版本空间改 run_type 走 recordsvc 的 split 记录(PATCH /datasets/splits/detail),
1953
- # 服务端**显式** pre 会 400;缺省不下发(SDK 不带该参数 → 服务端按 online 处理)。
1912
+ # 无版本空间改 run_type 走 recordsvc 的 split 记录(PATCH /datasets/splits/detail):
1913
+ # 写端点仅限 online——SDK 1.1.2 起派生/显式 pre 都会前置 SchemaError 拒绝,
1914
+ # 不再出现"存在性检查读 pre、写入默默落 online"的读写环境撕裂。
1954
1915
  env = _environment(environment)
1955
1916
  if env == "pre":
1956
1917
  raise _fail(
@@ -1997,7 +1958,11 @@ def dataset_split_update(
1997
1958
  return
1998
1959
 
1999
1960
  detail = _fetch_version(
2000
- dataset, resolved_version, stage="split 更新前查询", environment=environment
1961
+ dataset,
1962
+ resolved_version,
1963
+ stage="split 更新前查询",
1964
+ environment=environment,
1965
+ metadata_model=_VERSION_SPLIT_METADATA_MODEL,
2001
1966
  )
2002
1967
 
2003
1968
  if split not in _split_names(detail):
@@ -2015,6 +1980,7 @@ def dataset_split_update(
2015
1980
  {"split_run_types": [{"name": split, "run_type": resolved_run_type}]},
2016
1981
  stage="split 更新",
2017
1982
  environment=environment,
1983
+ metadata_model=_VERSION_SPLIT_METADATA_MODEL,
2018
1984
  )
2019
1985
  _emit_detail(
2020
1986
  {
@@ -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
 
@@ -24,6 +24,7 @@ from __future__ import annotations
24
24
 
25
25
  import inspect
26
26
  import shutil
27
+ import tempfile
27
28
  from pathlib import Path
28
29
  from typing import Any, List, Optional
29
30
 
@@ -120,11 +121,6 @@ def _fail(message: str, hint: str, exit_code: int = 1) -> "typer.Exit":
120
121
  return typer.Exit(exit_code)
121
122
 
122
123
 
123
- def _note(message: str) -> None:
124
- """退出码 0 的路径只用 ``note:``(设计 §5.1);一律 stderr。"""
125
- typer.echo(f"note: {message}", err=True)
126
-
127
-
128
124
  def _load_repo_or_exit():
129
125
  """懒加载 SDK;缺 extra → 安装提示 + 退出码 2(设计 §2)。"""
130
126
  from .irepo_sdk import SdkMissing, load_repo
@@ -136,6 +132,25 @@ def _load_repo_or_exit():
136
132
  raise typer.Exit(2) from exc
137
133
 
138
134
 
135
+ def _probe_pull_private_endpoint(repo: Any, dataset: str) -> bool | None:
136
+ content = getattr(repo, "_content", None)
137
+ instances = getattr(repo, "instances", None)
138
+ credentials = getattr(content, "_credentials", None)
139
+ probe = getattr(content, "_probe_private_endpoint", None)
140
+ layout_factory = getattr(instances, "_layout", None)
141
+ if not all(callable(value) for value in (credentials, probe, layout_factory)):
142
+ return None
143
+
144
+ from instance_repo._routing import _plan_oss_route
145
+
146
+ layout = layout_factory()
147
+ sts = credentials(layout.sts_prefix(dataset), dataset)
148
+ route = _plan_oss_route(sts.get("endpoint", ""))
149
+ if not route.standard:
150
+ return None
151
+ return bool(probe(route.private_endpoint))
152
+
153
+
139
154
  #: SDK ``instance_repo.validate.detect_format`` 的取值 → 对应校验函数名。分派必须与 SDK
140
155
  #: ``InstancesClient.validate`` 一致;认不出的格式回退 ``check_layout``(与 SDK 同样的兜底)。
141
156
  _VALIDATOR_BY_FORMAT: dict[str, str] = {
@@ -217,12 +232,7 @@ def _describe(exc: BaseException) -> str:
217
232
 
218
233
 
219
234
  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
- """
235
+ """按 SDK 能力下发 environment / metadata_model,不允许静默忽略显式值。"""
226
236
  fn = getattr(sdk_obj, method, None)
227
237
  accepted: set[str] = set()
228
238
  if fn is not None:
@@ -245,10 +255,10 @@ def _sdk_read_kwargs(sdk_obj: Any, method: str, *, environment: str, metadata_mo
245
255
  elif value:
246
256
  dropped.append(key)
247
257
  if dropped:
248
- # 只有真正丢弃了调用方给的值才提示;无条件打印会暗示"想指定却被忽略"。
249
- _note(
250
- "已安装的 instance-repo 不支持按调用指定 "
251
- f"{'/'.join(dropped)},本次按 profile 派生值读取;需要精确指定请升级到 1.1.0 及以上"
258
+ raise _fail(
259
+ f"已安装的 instance-repo 不支持按调用指定 {'/'.join(dropped)}",
260
+ "升级到 instance-repo 1.1.2 及以上后重试",
261
+ exit_code=2,
252
262
  )
253
263
  return kwargs
254
264
 
@@ -394,6 +404,12 @@ def instance_list(
394
404
  output_format = _fmt(output_format)
395
405
  resolved_version = _version_value(version)
396
406
  repo = _load_repo_or_exit()
407
+ read_kwargs = _sdk_read_kwargs(
408
+ repo.instances,
409
+ "list_paged",
410
+ environment=_environment(environment),
411
+ metadata_model=_metadata_model_for_split(metadata_model, split=split),
412
+ )
397
413
  try:
398
414
  result = repo.instances.list_paged(
399
415
  dataset,
@@ -401,12 +417,7 @@ def instance_list(
401
417
  split=split,
402
418
  page=page,
403
419
  page_size=page_size,
404
- **_sdk_read_kwargs(
405
- repo.instances,
406
- "list_paged",
407
- environment=_environment(environment),
408
- metadata_model=_metadata_model_for_split(metadata_model, split=split),
409
- ),
420
+ **read_kwargs,
410
421
  )
411
422
  except Exception as exc: # noqa: BLE001 - 统一走设计 §5.1 的映射
412
423
  raise _fail(
@@ -445,18 +456,19 @@ def instance_get(
445
456
  output_format = _fmt(output_format)
446
457
  resolved_version = _version_value(version)
447
458
  repo = _load_repo_or_exit()
459
+ read_kwargs = _sdk_read_kwargs(
460
+ repo.instances,
461
+ "get",
462
+ environment=_environment(environment),
463
+ metadata_model=_metadata_model_for_split(metadata_model, split=split),
464
+ )
448
465
  try:
449
466
  record = repo.instances.get(
450
467
  dataset,
451
468
  resolved_version,
452
469
  instance,
453
470
  split=split,
454
- **_sdk_read_kwargs(
455
- repo.instances,
456
- "get",
457
- environment=_environment(environment),
458
- metadata_model=_metadata_model_for_split(metadata_model, split=split),
459
- ),
471
+ **read_kwargs,
460
472
  )
461
473
  except Exception as exc: # noqa: BLE001 - 统一走设计 §5.1 的映射
462
474
  raise _fail(
@@ -499,7 +511,6 @@ def _confirm_version_autocreate(repo, dataset: str, version: str, split: str, *,
499
511
 
500
512
  label = _version_label(version)
501
513
  if yes:
502
- _note(f"版本 {label} 不存在,上传时将由服务端自动创建(已用 --yes 跳过确认)")
503
514
  return
504
515
  if not sys.stdin.isatty():
505
516
  raise _fail(
@@ -559,6 +570,7 @@ def instance_push(
559
570
  pushed: list[dict] = []
560
571
  failed: list[dict] = []
561
572
  unconfirmed: list[str] = []
573
+ result_count_mismatch = False
562
574
  if len(targets) == 1:
563
575
  try:
564
576
  result = repo.instances.push(
@@ -609,15 +621,18 @@ def instance_push(
609
621
  )
610
622
  else:
611
623
  results_list = list(results_many or [])
612
- if len(results_list) != len(targets):
613
- _note(
614
- f"SDK 返回了 {len(results_list)}/{len(targets)} 个上传结果,摘要可能不完整;"
615
- f"用 ap instance list --dataset {dataset} --split {split} 核对实际内容"
616
- )
624
+ result_count_mismatch = len(results_list) != len(targets)
625
+ if len(results_list) < len(targets):
626
+ unconfirmed.extend(targets[len(results_list) :])
617
627
  for path, result in zip(targets, results_list):
618
628
  pushed.append(_push_row(path, result))
619
629
 
620
630
  _print_push_summary(dataset, resolved, split, pushed, failed, unconfirmed, output_format)
631
+ if result_count_mismatch:
632
+ raise _fail(
633
+ f"SDK 返回了 {len(results_list)}/{len(targets)} 个上传结果,无法确认完整写入状态",
634
+ f"用 ap instance list --dataset {dataset} --split {split} 核对实际内容",
635
+ )
621
636
 
622
637
 
623
638
  def _push_row(path: str, result: Any) -> dict:
@@ -691,19 +706,46 @@ def instance_pull(
691
706
 
692
707
  repo = _load_repo_or_exit()
693
708
  try:
694
- root.mkdir(parents=True, exist_ok=True)
695
- written = repo.instances.pull(dataset, resolved, instance, root, split)
709
+ private_endpoint_reachable = _probe_pull_private_endpoint(repo, dataset)
696
710
  except Exception as exc: # noqa: BLE001
697
- sdk_error_exit(exc, what=f"instance {instance}", stage="拉取")
711
+ sdk_error_exit(exc, what=f"instance {instance}", stage="拉取前私网探测")
712
+ if private_endpoint_reachable is False:
713
+ raise _fail(
714
+ f"instance {instance} 拉取失败:当前网络无法访问私网 OSS endpoint",
715
+ "instance pull 不支持公网下载;请在 AP dev cluster / ROCK VPC 内执行",
716
+ )
698
717
 
699
- written = _place_under_target(Path(str(written)), target)
718
+ # SDK 的 swe/custom 下载会先往输出目录写 <id>.json 与 .probe 中间文件,再由
719
+ # _place_under_target 搬进 <id>/——把用户目录直接交给它,未加 --overwrite 也会
720
+ # 覆盖目标同级的同名文件。先落到独立临时目录,归一后再整体搬入最终目标。
721
+ staging = Path(tempfile.mkdtemp(prefix=f"ap-pull-{instance}-"))
722
+ try:
723
+ try:
724
+ written = repo.instances.pull(dataset, resolved, instance, staging, split)
725
+ except Exception as exc: # noqa: BLE001
726
+ sdk_error_exit(exc, what=f"instance {instance}", stage="拉取")
727
+ staged = _place_under_target(Path(str(written)), staging / instance)
728
+ root.mkdir(parents=True, exist_ok=True)
729
+ if target.exists():
730
+ if not overwrite:
731
+ raise _fail(
732
+ f"instance {instance} 拉取目标 {target} 已存在",
733
+ "换一个 --output/-o 目录,或加 --overwrite 允许覆盖",
734
+ )
735
+ if target.is_dir():
736
+ shutil.rmtree(target)
737
+ else:
738
+ target.unlink()
739
+ shutil.move(str(staging / instance), str(target))
740
+ finally:
741
+ shutil.rmtree(staging, ignore_errors=True)
700
742
  payload = {
701
743
  "instance": instance,
702
744
  "dataset": dataset,
703
745
  "version": resolved,
704
746
  "split": split,
705
747
  "output": str(target),
706
- "content": str(written),
748
+ "content": str(target / staged.name),
707
749
  }
708
750
  _emit_detail(payload, output_format)
709
751
 
@@ -24,19 +24,20 @@
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
@@ -59,14 +60,6 @@ _INSTALL_HINT = (
59
60
  "Install it with: pip install 'ap-client[dataset]'"
60
61
  )
61
62
 
62
- #: 旧 SDK(无 ``api_base``/``cluster`` 构造参数)时的升级提示,一行 stderr。
63
- _LEGACY_SDK_NOTE = (
64
- "note: installed instance-repo does not accept Repo(api_base=/cluster=); "
65
- "falling back to INSTANCEREPO_* environment injection. "
66
- "Upgrade with: pip install -U 'instance-repo[oss]>=1.1.0' "
67
- "for cluster<->storage_env validation and symmetric read/write paths."
68
- )
69
-
70
63
  # storage_env 解析顺序:显式参数 > 规范环境变量(1.0.0) > 旧别名(0.8.x)。
71
64
  # **没有默认值**——猜一个 storage_env 会把数据写到错误的存储环境。
72
65
  _STORAGE_ENV_VARS = ("INSTANCE_REPO_STORAGE_ENV", "IR_STORAGE_ENV")
@@ -128,6 +121,97 @@ def _setdefault_env(name: str, value: str) -> None:
128
121
  os.environ[name] = value
129
122
 
130
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
+
131
215
  def load_repo(*, cluster: str | None = None, storage_env: str | None = None) -> "Repo":
132
216
  """按 AP ``Config`` 构造 ``instance_repo.Repo``。
133
217
 
@@ -150,7 +234,10 @@ def load_repo(*, cluster: str | None = None, storage_env: str | None = None) ->
150
234
 
151
235
  # 多 key 的 AP_API_KEYS 在这里直接抛 ConfigurationError:数据面要用这把 key 去
152
236
  # 换 STS,拿不到确定的单 key 就没法继续(与控制面同一口径)。
153
- 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
+ )
154
241
 
155
242
  effective_cluster = _cluster_from_headers(cfg.headers)
156
243
  effective_storage_env = _resolve_storage_env(storage_env)
@@ -168,13 +255,16 @@ def load_repo(*, cluster: str | None = None, storage_env: str | None = None) ->
168
255
  kwargs["storage_env"] = effective_storage_env
169
256
  if "api_env" in supported and api_env:
170
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
171
262
 
172
263
  if "api_base" not in supported or "cluster" not in supported:
173
264
  # 0.8.x 兼容路径:构造参数缺位,只能退回环境变量契约。
174
265
  _setdefault_env(_LEGACY_API_BASE_VAR, cfg.base_url)
175
266
  _setdefault_env(_LEGACY_TOKEN_VAR, cfg.token_key or "")
176
267
  _setdefault_env(_LEGACY_STORAGE_ENV_VAR, effective_storage_env)
177
- typer.echo(_LEGACY_SDK_NOTE, err=True)
178
268
 
179
269
  return _Repo(**kwargs)
180
270
 
@@ -191,7 +281,9 @@ def _init_parameters(repo_cls: type) -> frozenset[str]:
191
281
  continue
192
282
  if parameter.kind is inspect.Parameter.VAR_KEYWORD:
193
283
  # **kwargs 会吞下任何参数,当作全部支持(1.0.0 之后的宽松签名)。
194
- return frozenset({"token", "api_base", "cluster", "storage_env", "api_env"})
284
+ return frozenset(
285
+ {"token", "api_base", "cluster", "storage_env", "api_env", "transport"}
286
+ )
195
287
  if parameter.kind is not inspect.Parameter.VAR_POSITIONAL:
196
288
  names.add(name)
197
289
  return frozenset(names)
@@ -216,7 +308,7 @@ _BIZ_CODE_RULES: dict[int, tuple[str, int]] = {
216
308
  ),
217
309
  94018: ("当前角色只读,写操作需要 writer 角色;让 admin 执行 ap dataset access grant", 1),
218
310
  94019: ("dataset 标识冲突:检查 --dataset 的取值是否与已有 dataset 重名", 1),
219
- 94020: ("请求未带 dataset 标识(SDK 过旧),升级:pip install -U 'instance-repo[oss]>=1.1.0'", 1),
311
+ 94020: ("请求未带 dataset 标识(SDK 过旧),升级:pip install -U 'instance-repo[oss]>=1.1.2'", 1),
220
312
  94021: ("该 dataset 不存在,先执行:ap dataset create <dataset> --benchmark <b>", 1),
221
313
  94022: (
222
314
  "无该用户存储空间的授权,让空间主人执行:ap fs access grant --user <你的工号> --role reader",
@@ -238,7 +330,7 @@ _CODE_RULES: dict[str, tuple[str, int]] = {
238
330
  "E_DIGEST_MISMATCH": ("内容校验失败,重新拉取或重新打包该 instance 后再试", 1),
239
331
  "E_IMMUTABLE": ("已发布的版本不可修改,创建新版本再操作", 1),
240
332
  "E_FORBIDDEN": ("当前身份无权执行该操作,确认 AP_API_KEY 与 dataset 角色", 1),
241
- "E_OWNER_REF_MISSING": ("缺少上游来源引用,升级:pip install -U 'instance-repo[oss]>=1.1.0'", 1),
333
+ "E_OWNER_REF_MISSING": ("缺少上游来源引用,升级:pip install -U 'instance-repo[oss]>=1.1.2'", 1),
242
334
  "E_LAYOUT": ("instance 目录结构不合规,先执行 ap instance validate <path> 定位问题", 1),
243
335
  "E_SCHEMA": ("instance 元数据不符合 schema,先执行 ap instance validate <path> 定位问题", 1),
244
336
  "E_CRED_EXPIRED": ("临时凭据已过期,重试该命令即可重新换取", 1),
@@ -259,6 +351,7 @@ _ADDRESSING_FIELDS = (
259
351
  "oss_prefix",
260
352
  "scaffold_bucket",
261
353
  "scaffold_root",
354
+ "storage_path",
262
355
  )
263
356
  _ADDRESSING_HINT = (
264
357
  "当前 AP 集群未下发数据面存储配置,确认 AP_CLUSTER 指向支持 dataset 的集群,或联系 Dataset 管理员"
@@ -297,24 +390,20 @@ def update_version_detail(
297
390
  body: dict,
298
391
  *,
299
392
  environment: str,
393
+ metadata_model: str = "",
300
394
  ) -> None:
301
- """``PATCH /apis/v1/datasets/versions/detail``,显式下发 ``environment``。
302
-
303
- 为什么不用 ``repo.versions.update``(临时 shim,已反馈 SDK):它内部用
304
- ``_version_extra_q()`` **无参**调用,只能下发 profile 派生值(dev/staging 集群
305
- → ``pre``);而 CLI 的读路径与 ``versions.create`` 都在 ``online``,于是出现
306
- "``version get`` 找得到、PATCH 却 404 dataset version not found"。SDK 的
307
- ``get``/``list_paged``/``create``/``ingest`` 都接受显式 ``environment``,
308
- 只有 ``update``/``status`` 没有。SDK 补上参数后这里会自动改走 SDK 方法。
309
- """
395
+ """``PATCH /apis/v1/datasets/versions/detail`` with explicit routing selectors."""
310
396
  update = repo.versions.update
311
397
  try:
312
398
  accepted = inspect.signature(update).parameters
313
399
  except (TypeError, ValueError): # pragma: no cover - 内建/装饰过的可调用
314
400
  accepted = {}
315
- if "environment" in accepted:
316
- # 空值不传:让 SDK 按 profile 派生(与其它读路径同口径,见 dataset_commands._environment)
317
- 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
318
407
  update(dataset, version, **kwargs)
319
408
  return
320
409
 
@@ -323,6 +412,8 @@ def update_version_detail(
323
412
  query = f"dataset_name={quote(dataset, safe='')}&version={quote(version, safe='')}"
324
413
  if environment:
325
414
  query += f"&environment={quote(environment, safe='')}"
415
+ if metadata_model:
416
+ query += f"&metadata_model={quote(metadata_model, safe='')}"
326
417
  repo.transport.patch(f"/apis/v1/datasets/versions/detail?{query}", dict(body))
327
418
 
328
419
 
@@ -349,23 +440,17 @@ def _resolve_rule(exc: Exception) -> tuple[str, int]:
349
440
 
350
441
 
351
442
  def sdk_error_exit(exc: Exception, *, what: str, stage: str) -> NoReturn:
352
- """把 SDK 异常翻译成固定两行文案 + ``typer.Exit``。
353
-
354
- 非零退出走 ``error: <what> <stage>失败:<reason>``;设计 §5.1 里判定为幂等
355
- (24401/24404,已达目标态)的码退出 0,此时首行改用 ``note:``——把"成功"
356
- 印成 ``error:`` 会让脚本与人都误判。第二行恒为 ``hint: <可执行下一步>``。
443
+ """把 SDK 异常翻译成 ``error:`` / ``hint:`` 文案与 ``typer.Exit``。
357
444
 
358
- ``-v/--verbose`` 时额外打印完整调用栈;默认不把 SDK traceback 当输出。
445
+ 设计 §5.1 里判定为幂等的 24401/24404 直接以 0 退出;非零失败输出固定
446
+ ``error: <what> <stage>失败:<reason>`` 与可执行 ``hint:``。
359
447
  """
360
448
  hint, exit_code = _resolve_rule(exc)
361
- reason = _reason(exc)
362
- if exit_code == 0:
363
- typer.echo(f"note: {what} {stage}:{reason}", err=True)
364
- else:
365
- typer.echo(f"error: {what} {stage}失败:{reason}", err=True)
366
- typer.echo(f"hint: {hint}", err=True)
367
- if _is_verbose():
368
- 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
369
454
 
370
- traceback.print_exception(type(exc), exc, exc.__traceback__)
455
+ traceback.print_exception(type(exc), exc, exc.__traceback__)
371
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")
@@ -93,7 +93,6 @@ def dataset_split_publish(
93
93
  if not workflow_id:
94
94
  raise _fail("发布响应缺少 workflow id", "重跑同一命令可安全重试")
95
95
  hint = f"ap dataset split publish-status {workflow_id}"
96
- _note(f"发布工作流:{workflow_id};查询进度:{hint}")
97
96
  _emit_detail(workflow, fmt)
98
97
 
99
98
  status = str(workflow.get("status") or "")
@@ -107,8 +106,6 @@ def dataset_split_publish(
107
106
  if status not in _TERMINAL:
108
107
  # 等待窗口内未到终态:SDK 返回最后一帧,服务端工作流不受影响。
109
108
  raise _fail("发布等待超时,服务端工作流仍继续运行", hint)
110
- if status == "awaiting_approval":
111
- _note("发布工作流等待审批,尚未完成发布;审批信息见 approval 字段")
112
109
 
113
110
 
114
111
  def dataset_split_publish_status(
@@ -132,5 +129,3 @@ def dataset_split_publish_status(
132
129
  f"发布工作流 {workflow_id} {status}:{(workflow or {}).get('error_msg') or ''}",
133
130
  "检查输出中的 steps 和 outputs,修正失败原因后再提交发布",
134
131
  )
135
- if status == "awaiting_approval":
136
- _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.dev2"
7
+ version = "0.3.2"
8
8
  description = "Agent Platform API Client & CLI"
9
9
  readme = { text = "A lightweight Python SDK and command line interface for Agent Platform. It provides helpers for configuring API access and managing templates, datasets, jobs, and groups.", content-type = "text/markdown" }
10
10
  requires-python = ">=3.10"
@@ -18,10 +18,10 @@ dependencies = [
18
18
 
19
19
  [project.optional-dependencies]
20
20
  dataset = [
21
- "instance-repo[oss]>=1.1.0",
21
+ "instance-repo[oss]>=1.1.2,<2.0.0",
22
22
  ]
23
23
  all = [
24
- "instance-repo[oss]>=1.1.0",
24
+ "instance-repo[oss]>=1.1.2,<2.0.0",
25
25
  ]
26
26
 
27
27
  [project.scripts]