ap-client 0.5.3.dev7__tar.gz → 0.5.3.dev8__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.
Files changed (24) hide show
  1. {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/PKG-INFO +1 -1
  2. {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/instance_commands.py +482 -51
  3. ap_client-0.5.3.dev8/ap_client/instance_options.py +94 -0
  4. {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/irepo_sdk.py +18 -0
  5. {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/pyproject.toml +1 -1
  6. {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/__init__.py +0 -0
  7. {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/akpool_commands.py +0 -0
  8. {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/api.py +0 -0
  9. {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/cli.py +0 -0
  10. {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/config.py +0 -0
  11. {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/dataset_commands.py +0 -0
  12. {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/dataset_register.py +0 -0
  13. {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/exporter.py +0 -0
  14. {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/fs_commands.py +0 -0
  15. {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/managed_ak.py +0 -0
  16. {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/manifest_view.py +0 -0
  17. {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/ossutil_commands.py +0 -0
  18. {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/ossutil_io.py +0 -0
  19. {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/profile_commands.py +0 -0
  20. {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/repo_read_guard.py +0 -0
  21. {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/share_api.py +0 -0
  22. {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/split_publish.py +0 -0
  23. {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/tbb.py +0 -0
  24. {ap_client-0.5.3.dev7 → ap_client-0.5.3.dev8}/ap_client/waiter.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ap-client
3
- Version: 0.5.3.dev7
3
+ Version: 0.5.3.dev8
4
4
  Summary: Agent Platform API Client & CLI
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: click>=8.0.0
@@ -1,23 +1,8 @@
1
- """``ap instance`` 命令族(实现设计 §4.4)。
1
+ """Instance metadata, directory browsing, transfer and source-hash commands.
2
2
 
3
- ``ap instance``(设计 §4.4):
4
-
5
- * ``validate <path>...`` —— ``repo.instances.validate``,纯本地,不发远端请求;
6
- * ``list`` / ``get`` —— **控制面**,走 ``api.list_dataset_series_instances`` /
7
- ``api.get_dataset_series_instance``,``--split`` 必填,不需要 SDK;
8
- * ``push`` / ``pull`` —— 数据面,走 ``repo.instances.push_many`` / ``pull``。
9
-
10
- 分层约束(设计 §2):``instance list/get`` 属控制面,必须在没装
11
- ``ap-client[dataset]`` 时照常工作——所以 SDK 只能在**需要它的那几个命令函数
12
- 内部**懒加载:
13
-
14
- from .irepo_sdk import SdkMissing, load_repo, sdk_error_exit
15
-
16
- ``SdkMissing`` → 打印 ``str(exc)`` 到 stderr 并 ``typer.Exit(2)``(缺依赖是使用
17
- 错误);SDK 抛出的业务异常 → ``sdk_error_exit(exc, what=..., stage=...)``。
18
-
19
- 渲染与退出码沿用 ``ap_client.cli`` 的既有 helper,同样函数内部 import 以避免与
20
- ``cli`` 循环依赖。默认列:``INSTANCE SPLIT VERSION DIGEST UPDATED_AT REGISTERED``。
3
+ SDK imports are lazy so help remains available without Dataset support.
4
+ Local validation and hashing do not construct a Repo or contact AP.
5
+ Remote operations share SDK configuration and the CLI output/error contract.
21
6
  """
22
7
 
23
8
  from __future__ import annotations
@@ -63,6 +48,7 @@ _VERSION_HELP = (
63
48
  )
64
49
 
65
50
 
51
+ from . import instance_options # noqa: E402
66
52
  from .dataset_commands import ( # noqa: E402
67
53
  _ENVIRONMENT_HELP,
68
54
  _METADATA_MODEL_HELP,
@@ -124,6 +110,22 @@ def _fail(message: str, hint: str, exit_code: int = 1) -> "typer.Exit":
124
110
  return typer.Exit(exit_code)
125
111
 
126
112
 
113
+ def _path_value(value: Optional[str]) -> Optional[str]:
114
+ try:
115
+ return instance_options.relative_path(value)
116
+ except ValueError as exc:
117
+ raise _fail(str(exc), "Use --path solution, --path tests/sealed, or --path .", 2) from exc
118
+
119
+
120
+ def _enhanced_options(owner: Any, method: str, options: dict[str, Any]) -> dict[str, Any]:
121
+ try:
122
+ return instance_options.sdk_options(owner, method, options)
123
+ except (TypeError, ValueError) as exc:
124
+ raise _fail(
125
+ str(exc), "Upgrade Dataset support: pip install --upgrade 'ap-client[dataset]'", 2
126
+ ) from exc
127
+
128
+
127
129
  def _load_repo_or_exit():
128
130
  """懒加载 SDK;缺 extra → 安装提示 + 退出码 2(设计 §2)。
129
131
 
@@ -464,15 +466,40 @@ def instance_get(
464
466
  metadata_model: Optional[str] = typer.Option(
465
467
  None, "--metadata-model", help=_METADATA_MODEL_HELP
466
468
  ),
469
+ path: Optional[str] = typer.Option(
470
+ None,
471
+ "--path",
472
+ help="List a subdirectory relative to the instance assets; use '.' for the root",
473
+ ),
474
+ page_size: int = typer.Option(
475
+ 100, "--page-size", min=1, max=1000, help="Directory listing page size"
476
+ ),
477
+ max_pages: int = typer.Option(50, "--max-pages", min=1, help="Maximum directory listing pages"),
467
478
  output_format: str = typer.Option(None, "--format", help=_FORMAT_HELP),
468
479
  ):
469
- """Show full instance metadata (SDK-backed)."""
480
+ """Show instance metadata and root assets; use --path to browse a directory."""
470
481
  from .irepo_sdk import _reason
471
482
 
472
483
  output_format = _fmt(output_format)
484
+ resolved_path = _path_value(path)
473
485
  resolved_version = _version_value(version)
474
486
  repo = _load_repo_or_exit()
475
487
  env = _environment(environment)
488
+ if resolved_path is not None:
489
+ _list_instance_objects(
490
+ repo,
491
+ dataset,
492
+ resolved_version,
493
+ instance,
494
+ split,
495
+ resolved_path,
496
+ env,
497
+ _metadata_model_for_split(metadata_model, split=split),
498
+ page_size,
499
+ max_pages,
500
+ output_format,
501
+ )
502
+ return
476
503
  read_kwargs = _sdk_read_kwargs(
477
504
  repo.instances,
478
505
  "get",
@@ -498,17 +525,99 @@ def instance_get(
498
525
  f"Failed to query instance {instance}: not found",
499
526
  f"Check the instance id: ap instance list --dataset {dataset} --split {split}",
500
527
  )
501
- payload = _instance_record(record)
502
- manifest = payload.get("manifest")
503
- if output_format in ("json", "yaml") or not isinstance(manifest, list):
504
- _emit_detail(payload, output_format)
505
- return
506
- from .manifest_view import manifest_lines
528
+ _list_instance_objects(
529
+ repo,
530
+ dataset,
531
+ resolved_version,
532
+ instance,
533
+ split,
534
+ "",
535
+ env,
536
+ _metadata_model_for_split(metadata_model, split=split),
537
+ page_size,
538
+ max_pages,
539
+ output_format,
540
+ metadata=_instance_record(record),
541
+ )
507
542
 
508
- _emit_detail({key: value for key, value in payload.items() if key != "manifest"}, output_format)
509
- typer.echo()
510
- for line in manifest_lines(manifest):
511
- typer.echo(line)
543
+
544
+ def _list_instance_objects(
545
+ repo: Any,
546
+ dataset: str,
547
+ version: str,
548
+ instance: str,
549
+ split: str,
550
+ path: str,
551
+ environment: str,
552
+ metadata_model: str,
553
+ page_size: int,
554
+ max_pages: int,
555
+ output_format: str,
556
+ *,
557
+ metadata: dict | None = None,
558
+ ) -> None:
559
+ from .irepo_sdk import _reason
560
+
561
+ kwargs = _enhanced_options(
562
+ repo.instances,
563
+ "list_objects",
564
+ {
565
+ "version": version,
566
+ "path": path,
567
+ "page_size": page_size,
568
+ "max_pages": max_pages,
569
+ },
570
+ )
571
+ kwargs.update(
572
+ _sdk_read_kwargs(
573
+ repo.instances, "list_objects", environment=environment, metadata_model=metadata_model
574
+ )
575
+ )
576
+ try:
577
+ result = repo.instances.list_objects(dataset, instance, split=split, **kwargs)
578
+ except Exception as exc: # noqa: BLE001
579
+ raise _fail(
580
+ f"Failed to list instance {instance} directory {path or '.'}: {_reason(exc)}",
581
+ "Check the instance, directory, environment and read permission",
582
+ ) from exc
583
+ payload = _model_dict(result)
584
+ payload.update(instance=instance, path=path or ".")
585
+ rows = [dict(item, type="directory", size_bytes="") for item in payload.get("directories", [])]
586
+ rows.extend(dict(item, type="file") for item in payload.get("files", []))
587
+ from .manifest_view import _visible
588
+
589
+ rows = [
590
+ {key: _visible(value) if isinstance(value, str) else value for key, value in row.items()}
591
+ for row in rows
592
+ ]
593
+ if metadata is not None and output_format in ("json", "yaml"):
594
+ _emit_detail(dict(metadata, objects=payload), output_format)
595
+ else:
596
+ if metadata is not None:
597
+ manifest = metadata.get("manifest")
598
+ if isinstance(manifest, list):
599
+ from .manifest_view import manifest_lines
600
+
601
+ _emit_detail(
602
+ {key: value for key, value in metadata.items() if key != "manifest"},
603
+ output_format,
604
+ )
605
+ typer.echo()
606
+ for line in manifest_lines(manifest):
607
+ typer.echo(line)
608
+ else:
609
+ _emit_detail(metadata, output_format)
610
+ typer.echo("\nassets: .")
611
+ _emit(
612
+ payload,
613
+ rows,
614
+ (("type", "TYPE"), ("name", "NAME"), ("path", "PATH"), ("size_bytes", "BYTES")),
615
+ output_format,
616
+ )
617
+ if payload.get("truncated"):
618
+ typer.echo(
619
+ "warning: directory listing is incomplete; increase --max-pages and retry", err=True
620
+ )
512
621
 
513
622
 
514
623
  def _check_version_autocreate(repo, dataset: str, version: str, *, environment: str) -> bool:
@@ -538,7 +647,14 @@ def _check_version_autocreate(repo, dataset: str, version: str, *, environment:
538
647
 
539
648
 
540
649
  def _confirm_upload(
541
- dataset: str, version: str, split: str, count: int, *, yes: bool, auto_create: bool
650
+ dataset: str,
651
+ version: str,
652
+ split: str,
653
+ count: int,
654
+ *,
655
+ yes: bool,
656
+ auto_create: bool,
657
+ prune: bool = False,
542
658
  ) -> None:
543
659
  """push 写远端前统一二次确认(可 ``-y/--yes`` 跳过),与 split publish 同口径。"""
544
660
  if yes:
@@ -546,6 +662,10 @@ def _confirm_upload(
546
662
  label = _version_label(version)
547
663
  target = f"dataset {dataset} version {label} split {split}"
548
664
  note = f" Version {label} does not exist and will be auto-created." if auto_create else ""
665
+ if prune:
666
+ note += (
667
+ " Remote instance records absent from these paths will be removed; OSS files remain."
668
+ )
549
669
  if not sys.stdin.isatty():
550
670
  raise _fail(
551
671
  f"Uploading {count} instance(s) to {target} requires confirmation.{note}",
@@ -568,6 +688,29 @@ def instance_push(
568
688
  version: Optional[str] = typer.Option(None, "--version", help=_VERSION_HELP),
569
689
  environment: Optional[str] = typer.Option(None, "--environment", help=_ENVIRONMENT_HELP),
570
690
  overwrite: bool = typer.Option(False, "--overwrite", help="Allow overwriting existing objects"),
691
+ skip_unchanged: bool = typer.Option(
692
+ False, "--skip-unchanged", help="Skip cases with matching source hashes"
693
+ ),
694
+ content_hashes: Optional[str] = typer.Option(
695
+ None, "--content-hashes", help="JSON file mapping instance IDs to source hashes"
696
+ ),
697
+ compress_level: Optional[int] = typer.Option(
698
+ None,
699
+ "--compress-level",
700
+ min=1,
701
+ max=9,
702
+ help="Gzip compression level; 1 is fastest, 9 is smallest",
703
+ ),
704
+ concurrency: int = typer.Option(8, "--concurrency", min=1, help="Concurrent instance uploads"),
705
+ continue_on_error: bool = typer.Option(
706
+ False, "--continue-on-error", help="Finish other uploads and report failed cases"
707
+ ),
708
+ progress: bool = typer.Option(False, "--progress", help="Write SDK upload progress to stderr"),
709
+ prune: bool = typer.Option(
710
+ False,
711
+ "--prune",
712
+ help="After delivery, remove remote metadata absent from the supplied cases; OSS files remain",
713
+ ),
571
714
  yes: bool = typer.Option(
572
715
  False,
573
716
  "--yes",
@@ -594,6 +737,22 @@ def instance_push(
594
737
  f"{len(broken)}/{len(results)} instances failed local validation; nothing was uploaded",
595
738
  "Run ap instance validate <path>... to fix the issues, then retry",
596
739
  )
740
+ try:
741
+ hashes = instance_options.upload_hashes(
742
+ results, instance_options.read_hashes(content_hashes), skip_unchanged
743
+ )
744
+ if prune:
745
+ if not resolved:
746
+ raise ValueError("--prune requires an explicit --version")
747
+ if any(not Path(record["path"]).is_dir() for record in results):
748
+ raise ValueError("--prune requires a collection of instance directories")
749
+ instance_options.require_working_prune()
750
+ if skip_unchanged or prune:
751
+ ids = [record["instance"] for record in results]
752
+ if len(ids) != len(set(ids)):
753
+ raise ValueError("Each supplied path must have a distinct instance ID")
754
+ except (ImportError, OSError, ValueError) as exc:
755
+ raise _fail(str(exc), "Check the hashes, input paths and installed Dataset SDK", 2) from exc
597
756
 
598
757
  # ② 写远端前统一二次确认(-y/--yes 跳过);版本不存在时提示将被自动创建。
599
758
  # (split 已发布不可写由**服务端**裁决——pre 环境里 ingest 产出的 split 记录状态
@@ -604,13 +763,41 @@ def instance_push(
604
763
 
605
764
  repo = _load_repo_or_exit()
606
765
  _check_storage_side(repo, env)
766
+ enhanced: dict[str, Any] = {}
767
+ if skip_unchanged:
768
+ enhanced["skip_unchanged"] = True
769
+ if compress_level is not None:
770
+ enhanced["compress_level"] = compress_level
771
+ # The SDK batch path compares stored hashes before selecting format-specific uploads.
772
+ batch = len(targets) > 1 or prune or skip_unchanged
773
+ if hashes:
774
+ if batch:
775
+ enhanced["content_hashes"] = hashes
776
+ else:
777
+ source_hash = hashes.get(results[0]["instance"])
778
+ if source_hash:
779
+ enhanced["content_hash"] = source_hash
780
+ if batch and progress:
781
+ enhanced["progress_callback"] = instance_options.progress_callback
782
+ if batch and concurrency != 8:
783
+ enhanced["concurrency"] = concurrency
784
+ owner, method = (
785
+ (repo, "deliver") if prune else (repo.instances, "push_many" if batch else "push")
786
+ )
787
+ if prune:
788
+ enhanced["prune"] = True
789
+ if enhanced:
790
+ _enhanced_options(owner, method, enhanced)
607
791
  auto_create = _check_version_autocreate(repo, dataset, resolved, environment=env)
608
- _confirm_upload(dataset, resolved, split, len(targets), yes=yes, auto_create=auto_create)
792
+ _confirm_upload(
793
+ dataset, resolved, split, len(targets), yes=yes, auto_create=auto_create, prune=prune
794
+ )
609
795
  pushed: list[dict] = []
796
+ skipped: list[dict] = []
610
797
  failed: list[dict] = []
611
798
  unconfirmed: list[str] = []
612
799
  result_count_mismatch = False
613
- if len(targets) == 1:
800
+ if not batch:
614
801
  try:
615
802
  result = repo.instances.push(
616
803
  targets[0],
@@ -620,6 +807,7 @@ def instance_push(
620
807
  push_image=False,
621
808
  overwrite=overwrite,
622
809
  register=True,
810
+ **enhanced,
623
811
  )
624
812
  except Exception as exc: # noqa: BLE001 - 统一走设计 §5.1 的映射
625
813
  failed.append({"path": targets[0], "error": _describe(exc)})
@@ -628,20 +816,41 @@ def instance_push(
628
816
  )
629
817
  sdk_error_exit(exc, what=f"instance {targets[0]}", stage="upload")
630
818
  else:
631
- pushed.append(_push_row(targets[0], result))
819
+ row = _push_row(targets[0], result)
820
+ (skipped if row["skipped"] else pushed).append(row)
821
+ if progress:
822
+ instance_options.progress_callback(1, 1, result)
632
823
  else:
633
824
  # 多路径:``continue_on_error=False`` 是"全或无",首个失败即抛 ItemError。
634
825
  try:
635
- results_many = repo.instances.push_many(
636
- targets,
637
- dataset,
638
- resolved,
639
- split=split,
640
- push_image=False,
641
- overwrite=overwrite,
642
- register=True,
643
- continue_on_error=False,
644
- )
826
+ if prune:
827
+ delivered = repo.deliver(
828
+ dataset,
829
+ resolved,
830
+ targets,
831
+ split=split,
832
+ push_image=False,
833
+ overwrite=overwrite,
834
+ continue_on_error=continue_on_error,
835
+ **enhanced,
836
+ )
837
+ results_many = [
838
+ *delivered.get("pushed", []),
839
+ *delivered.get("skipped", []),
840
+ *delivered.get("failed", []),
841
+ ]
842
+ else:
843
+ results_many = repo.instances.push_many(
844
+ targets,
845
+ dataset,
846
+ resolved,
847
+ split=split,
848
+ push_image=False,
849
+ overwrite=overwrite,
850
+ register=True,
851
+ continue_on_error=continue_on_error,
852
+ **enhanced,
853
+ )
645
854
  except Exception as exc: # noqa: BLE001
646
855
  item = getattr(exc, "item", None)
647
856
  cause = getattr(exc, "error", None) or exc
@@ -661,17 +870,61 @@ def instance_push(
661
870
  else:
662
871
  results_list = list(results_many or [])
663
872
  result_count_mismatch = len(results_list) != len(targets)
664
- if len(results_list) < len(targets):
665
- unconfirmed.extend(targets[len(results_list) :])
666
- for path, result in zip(targets, results_list):
667
- pushed.append(_push_row(path, result))
873
+ pushed, skipped, failed, unconfirmed = _collect_upload_results(
874
+ targets, results, results_list, by_id=skip_unchanged or prune
875
+ )
668
876
 
669
- _print_push_summary(dataset, resolved, split, pushed, failed, unconfirmed, output_format)
670
- if result_count_mismatch:
877
+ _print_push_summary(
878
+ dataset,
879
+ resolved,
880
+ split,
881
+ pushed,
882
+ failed,
883
+ unconfirmed,
884
+ output_format,
885
+ skipped=skipped if skip_unchanged or skipped else None,
886
+ )
887
+ if result_count_mismatch or unconfirmed:
671
888
  raise _fail(
672
889
  f"SDK returned {len(results_list)}/{len(targets)} upload results; cannot confirm the full write state",
673
890
  f"Verify the actual content with ap instance list --dataset {dataset} --split {split}",
674
891
  )
892
+ if failed:
893
+ raise _fail(
894
+ f"{len(failed)} instance upload(s) failed",
895
+ "Check the failure summary and retry the failed paths",
896
+ )
897
+
898
+
899
+ def _collect_upload_results(
900
+ targets: list[str],
901
+ records: list[dict],
902
+ values: list[Any],
903
+ *,
904
+ by_id: bool,
905
+ ) -> tuple[list[dict], list[dict], list[dict], list[str]]:
906
+ remaining = list(targets)
907
+ paths_by_id = {record["instance"]: record["path"] for record in records}
908
+ pushed, skipped, failed = [], [], []
909
+ for value in values:
910
+ if isinstance(value, BaseException):
911
+ item = str(getattr(value, "item", ""))
912
+ path = next((raw for raw in remaining if Path(raw) == Path(item)), "") if item else ""
913
+ if path:
914
+ remaining.remove(path)
915
+ failed.append({"path": path, "error": _describe(getattr(value, "error", value))})
916
+ continue
917
+ path = (
918
+ paths_by_id.get(getattr(value, "instance_id", ""), "")
919
+ if by_id
920
+ else (remaining[0] if remaining else "")
921
+ )
922
+ if path not in remaining:
923
+ continue
924
+ remaining.remove(path)
925
+ row = _push_row(path, value)
926
+ (skipped if row["skipped"] else pushed).append(row)
927
+ return pushed, skipped, failed, remaining
675
928
 
676
929
 
677
930
  def _push_row(path: str, result: Any) -> dict:
@@ -691,6 +944,8 @@ def _print_push_summary(
691
944
  failed: list[dict],
692
945
  unconfirmed: list[str],
693
946
  output_format: str,
947
+ *,
948
+ skipped: Optional[list[dict]] = None,
694
949
  ) -> None:
695
950
  payload = {
696
951
  "dataset": dataset,
@@ -701,6 +956,9 @@ def _print_push_summary(
701
956
  "unconfirmed": unconfirmed,
702
957
  }
703
958
  rows = [dict(row, status="pushed", error="") for row in pushed]
959
+ if skipped is not None:
960
+ payload["skipped"] = skipped
961
+ rows += [dict(row, status="skipped", error="") for row in skipped]
704
962
  rows += [
705
963
  {"path": row["path"], "instance": "", "status": "failed", "error": row["error"]}
706
964
  for row in failed
@@ -728,12 +986,18 @@ def instance_pull(
728
986
  None, "--output", "-o", help="Output directory (default: current directory)"
729
987
  ),
730
988
  overwrite: bool = typer.Option(False, "--overwrite", help="Allow overwriting the target"),
989
+ path: Optional[str] = typer.Option(
990
+ None,
991
+ "--path",
992
+ help="Download a relative subdirectory (Custom/OSS assets); use '.' for all assets",
993
+ ),
731
994
  output_format: str = typer.Option(None, "--format", help=_FORMAT_HELP),
732
995
  ):
733
996
  """Download instance content and metadata into <output>/<instance>/."""
734
997
  from .irepo_sdk import sdk_error_exit
735
998
 
736
999
  output_format = _fmt(output_format)
1000
+ resolved_path = _path_value(path)
737
1001
  resolved = _version_value(version)
738
1002
  root = Path(output or ".")
739
1003
  target = root / instance
@@ -744,6 +1008,9 @@ def instance_pull(
744
1008
  )
745
1009
 
746
1010
  repo = _load_repo_or_exit()
1011
+ pull_options = {}
1012
+ if resolved_path is not None:
1013
+ pull_options = _enhanced_options(repo.instances, "pull", {"path": resolved_path})
747
1014
  try:
748
1015
  private_endpoint_reachable = _probe_pull_private_endpoint(repo, dataset)
749
1016
  except Exception as exc: # noqa: BLE001
@@ -760,10 +1027,17 @@ def instance_pull(
760
1027
  staging = Path(tempfile.mkdtemp(prefix=f"ap-pull-{instance}-"))
761
1028
  try:
762
1029
  try:
763
- written = repo.instances.pull(dataset, resolved, instance, staging, split)
1030
+ written = repo.instances.pull(
1031
+ dataset, resolved, instance, staging, split, **pull_options
1032
+ )
764
1033
  except Exception as exc: # noqa: BLE001
765
1034
  sdk_error_exit(exc, what=f"instance {instance}", stage="pull")
766
1035
  staged = _place_under_target(Path(str(written)), staging / instance)
1036
+ if resolved_path and not (staging / instance / resolved_path).is_dir():
1037
+ raise _fail(
1038
+ f"No files were downloaded for instance subdirectory {resolved_path}",
1039
+ "Check the directory with ap instance get --path; the SDK must support this instance format",
1040
+ )
767
1041
  root.mkdir(parents=True, exist_ok=True)
768
1042
  if target.exists():
769
1043
  if not overwrite:
@@ -786,6 +1060,8 @@ def instance_pull(
786
1060
  "output": str(target),
787
1061
  "content": str(target / staged.name),
788
1062
  }
1063
+ if resolved_path is not None:
1064
+ payload["path"] = resolved_path or "."
789
1065
  _emit_detail(payload, output_format)
790
1066
 
791
1067
 
@@ -808,3 +1084,158 @@ def _place_under_target(written: Path, target: Path) -> Path:
808
1084
  final.unlink()
809
1085
  shutil.move(str(written), str(final))
810
1086
  return final
1087
+
1088
+
1089
+ @instance_app.command("hash")
1090
+ def instance_hash(
1091
+ paths: List[str] = typer.Argument(..., help="Local instance directories"),
1092
+ output_format: str = typer.Option(None, "--format", help=_FORMAT_HELP),
1093
+ ):
1094
+ """Compute source hashes locally without contacting AP."""
1095
+ output_format = _fmt(output_format)
1096
+ try:
1097
+ from instance_repo import compute_source_hash
1098
+ except ImportError as exc:
1099
+ raise _fail(
1100
+ "Source hashing requires instance-repo 1.3.5.dev1 or newer",
1101
+ "Install Dataset support: pip install --upgrade 'ap-client[dataset]'",
1102
+ 2,
1103
+ ) from exc
1104
+ hashes, failed = [], []
1105
+ for raw in paths:
1106
+ path = Path(raw)
1107
+ try:
1108
+ if not path.is_dir():
1109
+ raise ValueError("source hashing requires an existing instance directory")
1110
+ hashes.append(
1111
+ {"path": raw, "instance": path.name, "source_hash": compute_source_hash(path)}
1112
+ )
1113
+ except (OSError, ValueError) as exc:
1114
+ failed.append({"path": raw, "error": _describe(exc)})
1115
+ _emit(
1116
+ {"hashes": hashes, "failed": failed},
1117
+ hashes,
1118
+ (("path", "PATH"), ("instance", "INSTANCE"), ("source_hash", "SOURCE_HASH")),
1119
+ output_format,
1120
+ )
1121
+ if failed:
1122
+ raise _fail(f"{len(failed)} instance directory hash(es) failed", "Check the local paths")
1123
+
1124
+
1125
+ @instance_app.command("hashes")
1126
+ def instance_hashes(
1127
+ dataset: str = typer.Option(..., "--dataset", help="Dataset name"),
1128
+ split: str = typer.Option(..., "--split", help="Split name"),
1129
+ version: Optional[str] = typer.Option(None, "--version", help=_VERSION_HELP),
1130
+ environment: Optional[str] = typer.Option(None, "--environment", help=_ENVIRONMENT_HELP),
1131
+ metadata_model: Optional[str] = typer.Option(
1132
+ None, "--metadata-model", help=_METADATA_MODEL_HELP
1133
+ ),
1134
+ output_format: str = typer.Option(None, "--format", help=_FORMAT_HELP),
1135
+ ):
1136
+ """List source hashes stored on remote instances."""
1137
+ output_format = _fmt(output_format)
1138
+ resolved = _version_value(version)
1139
+ env = _environment(environment)
1140
+ repo = _load_repo_or_exit()
1141
+ _enhanced_options(repo.instances, "list_content_hashes", {})
1142
+ kwargs = _sdk_read_kwargs(
1143
+ repo.instances,
1144
+ "list_content_hashes",
1145
+ environment=env,
1146
+ metadata_model=_metadata_model_for_split(metadata_model, split=split),
1147
+ )
1148
+ try:
1149
+ try:
1150
+ fallback = oss_fallback_kwargs(repo, repo.instances, "list_content_hashes", env)
1151
+ except RuntimeError:
1152
+ # This SDK helper cannot disable its inner list() discovery yet.
1153
+ list_kwargs = _sdk_read_kwargs(
1154
+ repo.instances,
1155
+ "list",
1156
+ environment=env,
1157
+ metadata_model=_metadata_model_for_split(metadata_model, split=split),
1158
+ )
1159
+ list_kwargs.update(oss_fallback_kwargs(repo, repo.instances, "list", env))
1160
+ records = repo.instances.list(dataset, resolved, split=split, **list_kwargs)
1161
+ hashes = {}
1162
+ for record in records:
1163
+ served = _model_dict(record)
1164
+ source_hash = (served.get("extend_meta") or {}).get("source_hash")
1165
+ if source_hash:
1166
+ hashes[served["instance_id"]] = source_hash
1167
+ else:
1168
+ hashes = repo.instances.list_content_hashes(
1169
+ dataset, resolved, split=split, **kwargs, **fallback
1170
+ )
1171
+ except Exception as exc: # noqa: BLE001
1172
+ raise _fail(
1173
+ f"Failed to list source hashes of dataset {dataset}: {_describe(exc)}",
1174
+ "Check the environment, bound storage and read permission",
1175
+ ) from exc
1176
+ rows = [
1177
+ {"instance": instance, "source_hash": value} for instance, value in sorted(hashes.items())
1178
+ ]
1179
+ _emit(
1180
+ {"dataset": dataset, "version": resolved, "split": split, "hashes": hashes},
1181
+ rows,
1182
+ (("instance", "INSTANCE"), ("source_hash", "SOURCE_HASH")),
1183
+ output_format,
1184
+ )
1185
+
1186
+
1187
+ @instance_app.command("delete")
1188
+ def instance_delete(
1189
+ instance: str = typer.Argument(..., help="Instance id"),
1190
+ dataset: str = typer.Option(..., "--dataset", help="Dataset name"),
1191
+ version: str = typer.Option(..., "--version", help="Dataset version containing the instance"),
1192
+ split: str = typer.Option(..., "--split", help="Split name"),
1193
+ environment: Optional[str] = typer.Option(None, "--environment", help=_ENVIRONMENT_HELP),
1194
+ metadata_model: Optional[str] = typer.Option(
1195
+ None, "--metadata-model", help=_METADATA_MODEL_HELP
1196
+ ),
1197
+ yes: bool = typer.Option(False, "--yes", "-y", help="Confirm deleting instance metadata"),
1198
+ output_format: str = typer.Option(None, "--format", help=_FORMAT_HELP),
1199
+ ):
1200
+ """Delete instance metadata. OSS content remains available to later ingest."""
1201
+ from .irepo_sdk import sdk_error_exit
1202
+
1203
+ output_format = _fmt(output_format)
1204
+ env = _environment(environment)
1205
+ resolved = _version_value(version)
1206
+ if not resolved:
1207
+ raise _fail(
1208
+ "--version must be nonempty for metadata deletion", "Specify the dataset version", 2
1209
+ )
1210
+ repo = _load_repo_or_exit()
1211
+ _enhanced_options(repo.instances, "delete", {})
1212
+ kwargs = _sdk_read_kwargs(
1213
+ repo.instances,
1214
+ "delete",
1215
+ environment=env,
1216
+ metadata_model=_metadata_model_for_split(metadata_model, split=split),
1217
+ )
1218
+ if not yes:
1219
+ if not sys.stdin.isatty():
1220
+ raise _fail("Deleting instance metadata requires confirmation", "Add --yes (-y)", 2)
1221
+ if not typer.confirm(
1222
+ f"Delete metadata for {dataset} version {resolved} split {split} instance {instance}? OSS files remain."
1223
+ ):
1224
+ raise _fail(
1225
+ "Metadata deletion canceled", "Rerun when you want to delete the instance record"
1226
+ )
1227
+ try:
1228
+ repo.instances.delete(dataset, resolved, instance, split=split, **kwargs)
1229
+ except Exception as exc: # noqa: BLE001
1230
+ sdk_error_exit(exc, what=f"instance {instance}", stage="metadata deletion")
1231
+ _emit_detail(
1232
+ {
1233
+ "dataset": dataset,
1234
+ "version": resolved,
1235
+ "split": split,
1236
+ "instance": instance,
1237
+ "deleted": True,
1238
+ "content_deleted": False,
1239
+ },
1240
+ output_format,
1241
+ )
@@ -0,0 +1,94 @@
1
+ """Local option parsing for instance directory reads and uploads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import json
7
+ from importlib import metadata
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import typer
12
+
13
+
14
+ def relative_path(value: str | None) -> str | None:
15
+ if value is None:
16
+ return None
17
+ path = value.strip().rstrip("/")
18
+ if path in ("", ".") and not value.strip().startswith("/"):
19
+ return ""
20
+ if (
21
+ path.startswith("/")
22
+ or "\\" in path
23
+ or ":" in path
24
+ or any(ord(char) < 32 or ord(char) == 127 for char in path)
25
+ or any(part in ("", ".", "..") for part in path.split("/"))
26
+ ):
27
+ raise ValueError("--path must be relative to the instance root; use '.' for the root")
28
+ return path
29
+
30
+
31
+ def sdk_options(owner: Any, method: str, options: dict[str, Any]) -> dict[str, Any]:
32
+ function = getattr(owner, method, None)
33
+ if not callable(function):
34
+ raise ValueError(f"The installed instance-repo does not support {method}")
35
+ parameters = inspect.signature(function).parameters
36
+ if not any(p.kind is inspect.Parameter.VAR_KEYWORD for p in parameters.values()):
37
+ missing = set(options) - set(parameters)
38
+ if missing:
39
+ raise ValueError(
40
+ f"The installed instance-repo does not support {method} options: "
41
+ + ", ".join(sorted(missing))
42
+ )
43
+ return options
44
+
45
+
46
+ def read_hashes(path: str | None) -> dict[str, str]:
47
+ if path is None:
48
+ return {}
49
+ value = json.loads(Path(path).read_text(encoding="utf-8"))
50
+ if not isinstance(value, dict) or any(
51
+ not isinstance(key, str) or not key.strip() or not isinstance(item, str) or not item.strip()
52
+ for key, item in value.items()
53
+ ):
54
+ raise ValueError(
55
+ "--content-hashes must contain a JSON object of instance IDs to nonempty hashes"
56
+ )
57
+ return value
58
+
59
+
60
+ def upload_hashes(records: list[dict], supplied: dict[str, str], skip: bool) -> dict[str, str]:
61
+ hashes = dict(supplied)
62
+ if not skip:
63
+ return hashes
64
+ from instance_repo import compute_source_hash
65
+
66
+ for record in records:
67
+ instance = record["instance"]
68
+ path = Path(record["path"])
69
+ if not path.is_dir():
70
+ raise ValueError("--skip-unchanged currently supports instance directories only")
71
+ if instance in hashes:
72
+ continue
73
+ hashes[instance] = compute_source_hash(path)
74
+ return hashes
75
+
76
+
77
+ def require_working_prune() -> None:
78
+ try:
79
+ version = metadata.version("instance-repo")
80
+ except metadata.PackageNotFoundError:
81
+ return
82
+ if version in {"1.3.5.dev1", "1.3.5.dev2"}:
83
+ raise ValueError(
84
+ f"instance-repo {version} cannot complete --prune; upgrade to a fixed SDK before uploading"
85
+ )
86
+
87
+
88
+ def progress_callback(completed: int, total: int, result: Any) -> None:
89
+ instance = str(getattr(result, "instance_id", "") or type(result).__name__)
90
+ instance = "".join(char if char.isprintable() else repr(char)[1:-1] for char in instance)
91
+ status = "skipped" if getattr(result, "skipped", False) else "uploaded"
92
+ if isinstance(result, BaseException):
93
+ status = "failed"
94
+ typer.echo(f"progress: {completed}/{total} {instance} {status}", err=True)
@@ -951,10 +951,28 @@ def load_repo(
951
951
  _setdefault_env(_LEGACY_STORAGE_ENV_VAR, effective_storage_env)
952
952
 
953
953
  repo = _Repo(**kwargs)
954
+ _bind_dataset_profile(repo)
954
955
  _install_oss_probe_cache(repo, verbose=cfg.verbose)
955
956
  return repo
956
957
 
957
958
 
959
+ def _bind_dataset_profile(repo: Any) -> None:
960
+ """Supply the configured context missing from SDK 1.3.5 dataset discovery.
961
+
962
+ Its environment check reads ``_p``, but its constructor does not initialize
963
+ that field. Bind only a missing field to this Repo's resolved profile;
964
+ SDKs that initialize it retain their own context.
965
+ """
966
+ client = getattr(repo, "datasets", None)
967
+ profile = getattr(repo, "profile", None)
968
+ if (
969
+ profile is not None
970
+ and callable(getattr(client, "_discovery_env_match", None))
971
+ and not hasattr(client, "_p")
972
+ ):
973
+ client._p = profile
974
+
975
+
958
976
  def _init_parameters(repo_cls: type) -> frozenset[str]:
959
977
  """``Repo.__init__`` 接受的关键字参数名;签名不可读时返回空集合。"""
960
978
  try:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ap-client"
7
- version = "0.5.3.dev7"
7
+ version = "0.5.3.dev8"
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"