instance-repo 1.3.3.dev2__tar.gz → 1.3.5__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 (49) hide show
  1. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/PKG-INFO +1 -1
  2. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/__init__.py +5 -1
  3. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/clients/datasets.py +3 -1
  4. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/clients/instances.py +449 -40
  5. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/content.py +38 -2
  6. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/models.py +79 -0
  7. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/repo.py +61 -8
  8. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/store/oss.py +1 -1
  9. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/transport.py +20 -7
  10. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo.egg-info/PKG-INFO +1 -1
  11. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/pyproject.toml +1 -1
  12. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/README.md +0 -0
  13. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/_routing.py +0 -0
  14. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/_site_defaults.py +0 -0
  15. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/artifacts.py +0 -0
  16. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/bootstrap.py +0 -0
  17. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/cache.py +0 -0
  18. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/cli.py +0 -0
  19. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/clients/__init__.py +0 -0
  20. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/clients/benchmarks.py +0 -0
  21. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/clients/config.py +0 -0
  22. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/clients/images.py +0 -0
  23. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/clients/reports.py +0 -0
  24. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/clients/scaffold.py +0 -0
  25. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/clients/splits.py +0 -0
  26. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/clients/versions.py +0 -0
  27. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/clients/workflows.py +0 -0
  28. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/concurrency.py +0 -0
  29. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/endpoints.py +0 -0
  30. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/errors.py +0 -0
  31. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/image.py +0 -0
  32. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/layout.py +0 -0
  33. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/loader.py +0 -0
  34. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/paths.py +0 -0
  35. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/release.py +0 -0
  36. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/retry.py +0 -0
  37. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/store/__init__.py +0 -0
  38. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/store/acr.py +0 -0
  39. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/store/base.py +0 -0
  40. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/store/oss_discovery.py +0 -0
  41. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/store/registry.py +0 -0
  42. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/tasktoml.py +0 -0
  43. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/validate.py +0 -0
  44. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo.egg-info/SOURCES.txt +0 -0
  45. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo.egg-info/dependency_links.txt +0 -0
  46. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo.egg-info/entry_points.txt +0 -0
  47. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo.egg-info/requires.txt +0 -0
  48. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo.egg-info/top_level.txt +0 -0
  49. {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: instance-repo
3
- Version: 1.3.3.dev2
3
+ Version: 1.3.5
4
4
  Summary: InstanceRepo SDK — 评测 Instance 统一存储客户端(SDK-only;控制面由 apiserver 承载)
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -5,10 +5,12 @@
5
5
  """
6
6
  from .repo import Repo
7
7
  from .loader import load_instances
8
+ from .content import compute_source_hash
8
9
  from .models import (
9
10
  DatasetSeries, DatasetVersion, DatasetInstance, ManifestEntry,
10
11
  OwnerReference, Feedback, Identity, PushResult,
11
12
  RolloutReport, RolloutJob, ScaffoldConfig,
13
+ DatasetObjectDirectory, DatasetObjectFile, DatasetObjectListResult,
12
14
  canonical_json, parse_feedbacks, append_feedback,
13
15
  )
14
16
  from .errors import (
@@ -18,14 +20,16 @@ from .errors import (
18
20
  TransportError,
19
21
  )
20
22
 
21
- __version__ = "1.3.3.dev2"
23
+ __version__ = "1.3.5"
22
24
 
23
25
  __all__ = [
24
26
  "Repo", "load_instances",
25
27
  "DatasetSeries", "DatasetVersion", "DatasetInstance", "ManifestEntry",
26
28
  "OwnerReference", "Feedback", "Identity", "PushResult",
27
29
  "RolloutReport", "RolloutJob", "ScaffoldConfig",
30
+ "DatasetObjectDirectory", "DatasetObjectFile", "DatasetObjectListResult",
28
31
  "canonical_json", "parse_feedbacks", "append_feedback",
32
+ "compute_source_hash",
29
33
  "InstanceRepoError", "LayoutError", "SchemaError", "OwnerRefMissing",
30
34
  "ImageMissing", "Immutable", "DigestMismatch", "Forbidden", "NotVisible",
31
35
  "TargetNotAllowed", "NetworkDenied", "CredExpired",
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
 
4
4
  from urllib.parse import quote
5
5
 
6
+ from .config import Profile
6
7
  from ..errors import SchemaError, TransportError
7
8
  from ..layout import normalize_metadata_model
8
9
  from ..models import (ClaimWorkspace, DatasetDetail, DatasetSeries,
@@ -15,10 +16,11 @@ class DatasetsClient:
15
16
  # 曾误写成 /apis/v1/datasets,该路径在 apiserver 上不存在,所有调用恒 404。
16
17
  _BASE = "/apis/v1/datasets/series"
17
18
 
18
- def __init__(self, transport, *, content_store=None,
19
+ def __init__(self, transport, *, profile: Profile | None = None, content_store=None,
19
20
  oss_prefix: str = "swe/datasets",
20
21
  metadata_model: str | None = None) -> None:
21
22
  self._t = transport
23
+ self._p = profile
22
24
  self._content = content_store
23
25
  self._oss_prefix = oss_prefix
24
26
  # OSS 回退探测的布局语义(split_first 默认;version_first 显式切换)
@@ -166,7 +166,8 @@ class InstancesClient:
166
166
 
167
167
  **OSS 双触发回退**(设计文档 §4.4.4):
168
168
  a. ``TransportError(biz_code=92001)``(dataset 不存在)→ OSS 回退。
169
- b. ``result is None``(registry 吞了 92003 instance 不存在)→ OSS 回退。
169
+ b. ``TransportError(biz_code=92023)``(split 不存在)→ OSS 回退。
170
+ c. ``result is None``(registry 吞了 92003 instance 不存在)→ OSS 回退。
170
171
  """
171
172
  # 只在调用方显式指定时才下发覆盖参数:注册表是可注入实现,
172
173
  # 保持缺省调用形态与历史一致,避免破坏既有实现。
@@ -175,8 +176,8 @@ class InstancesClient:
175
176
  result = self._reg.get(
176
177
  dataset, version, instance_id, split, **overrides)
177
178
  except TransportError as e:
178
- # 路径 1: dataset 不存在(92001) → OSS 回退
179
- if (e.biz_code == 92001
179
+ # 路径 1: dataset 不存在(92001) 或 split 不存在(92023) → OSS 回退
180
+ if (e.biz_code in (92001, 92023)
180
181
  and fallback_to_oss and self._content is not None
181
182
  and self._discovery_env_match(environment)):
182
183
  return self._discover_instance(
@@ -196,7 +197,8 @@ class InstancesClient:
196
197
  fallback_to_oss: bool = True) -> list[DatasetInstance]:
197
198
  """自动翻页取全量;``environment`` / ``metadata_model`` 语义同 :meth:`get`。
198
199
 
199
- **OSS 回退**:当 ``TransportError(biz_code=92001)``(dataset 不存在)时,
200
+ **OSS 回退**:当 ``TransportError(biz_code=92001)``(dataset 不存在)
201
+ 或 ``biz_code=92023``(split 不存在)时,
200
202
  若 ``fallback_to_oss=True`` 且 ``content_store`` 已注入,自动回退到 OSS
201
203
  存储层列举实例。
202
204
  """
@@ -204,7 +206,7 @@ class InstancesClient:
204
206
  try:
205
207
  return self._reg.query(dataset, version, split, **overrides)
206
208
  except TransportError as e:
207
- if (e.biz_code == 92001
209
+ if (e.biz_code in (92001, 92023)
208
210
  and fallback_to_oss and self._content is not None
209
211
  and self._discovery_env_match(environment)):
210
212
  return self._discover_instances(dataset, version, split)
@@ -228,7 +230,7 @@ class InstancesClient:
228
230
  给出修复提示);``difficulty`` easy/medium/hard。**服务端无排序参数**
229
231
  (固定 instance_id asc),SDK 不暴露。
230
232
 
231
- **OSS 回退**:当 ``TransportError(biz_code=92001)`` 时回退到 OSS 存储层,
233
+ **OSS 回退**:当 ``TransportError(biz_code=92001)`` 或 ``92023`` 时回退到 OSS 存储层,
232
234
  在客户端做过滤(q/tags/difficulty/instance_id)+ 内存分页。
233
235
  """
234
236
  from urllib.parse import quote
@@ -283,7 +285,7 @@ class InstancesClient:
283
285
  data, pagination = self._t.request_with_meta(
284
286
  "GET", f"{self._BASE}?{query}")
285
287
  except TransportError as e:
286
- if (e.biz_code == 92001
288
+ if (e.biz_code in (92001, 92023)
287
289
  and fallback_to_oss and self._content is not None
288
290
  and self._discovery_env_match(environment)):
289
291
  return self._discover_instances_paged(
@@ -384,6 +386,167 @@ class InstancesClient:
384
386
  result[parts[0]] = ""
385
387
  return result
386
388
 
389
+ # ── 实例目录/文件列举(GET /datasets/objects)──────────────────
390
+
391
+ def list_objects(self, dataset: str, instance_id: str,
392
+ split: str | None = None, *,
393
+ version: str = "",
394
+ path: str | None = None,
395
+ page_size: int = 100,
396
+ max_pages: int = 50,
397
+ environment: str | None = None,
398
+ metadata_model: str | None = None,
399
+ dataset_split_id: str | None = None
400
+ ) -> "DatasetObjectListResult":
401
+ """列举实例资产目录下的文件/子目录(自动翻页)。
402
+
403
+ 调用控制面 ``GET /apis/v1/datasets/objects``,返回指定实例的
404
+ ``{split}-assets/{instance_id}/`` 路径下的目录和文件列表。
405
+ 自动翻页合并所有结果,直到 ``truncated=False`` 或达到 ``max_pages``。
406
+
407
+ Parameters
408
+ ----------
409
+ dataset : str
410
+ Dataset 名称(``L1/L2`` 格式)。
411
+ instance_id : str
412
+ 实例 ID。
413
+ split : str, optional
414
+ Split 名称;为 None 时使用 ``"default"``。
415
+ version : str
416
+ 版本号,默认空串(split_first 布局下可为空)。
417
+ path : str, optional
418
+ 子目录路径过滤(相对于实例资产根目录)。
419
+ 为 None 或空串时列举实例根目录下全部内容;
420
+ 非空时仅列举该路径下的内容(如 ``"solution"`` 或 ``"tests/sealed"``)。
421
+ page_size : int
422
+ 每页大小,默认 100。
423
+ max_pages : int
424
+ 最大翻页数(安全阀,防无限循环),默认 50。
425
+ environment : str, optional
426
+ ``online`` / ``pre``,None 时用 profile 派生值。
427
+ metadata_model : str, optional
428
+ ``split_first`` / ``version_first``,None 时用 profile 派生值。
429
+ dataset_split_id : str, optional
430
+ Split ID,可直接指定以避免 split 名称解析。
431
+
432
+ Returns
433
+ -------
434
+ DatasetObjectListResult
435
+ 合并后的完整结果。``truncated`` 为 True 表示因 ``max_pages`` 截断。
436
+ """
437
+ from urllib.parse import quote
438
+ from ..models import DatasetObjectListResult
439
+ if not (dataset or "").strip():
440
+ raise SchemaError("dataset is required for instances.list_objects")
441
+ if not (instance_id or "").strip():
442
+ raise SchemaError(
443
+ "instance_id is required for instances.list_objects")
444
+ split = (split or "default").strip()
445
+ # 计算实例资产根前缀:{split}-assets/{instance_id}/
446
+ instance_asset_prefix = f"{split}-assets/{instance_id.strip()}"
447
+ if path:
448
+ full_prefix = f"{instance_asset_prefix}/{path.strip().strip('/')}"
449
+ else:
450
+ full_prefix = instance_asset_prefix
451
+ # environment / metadata_model:优先显式值,其次 profile 派生
452
+ env = (environment or "").strip()
453
+ if not env:
454
+ env = (getattr(self._p, "dataset_environment", "") or "").strip()
455
+ mm = (metadata_model or "").strip()
456
+ if not mm:
457
+ mm = (getattr(self._p, "metadata_model", "") or "").strip()
458
+ # 自动翻页
459
+ all_dirs: list = []
460
+ all_files: list = []
461
+ tk: str | None = None
462
+ result = DatasetObjectListResult()
463
+ for _ in range(max_pages):
464
+ params: list[tuple[str, str]] = [
465
+ ("dataset_name", dataset.strip()),
466
+ ("split", split),
467
+ ("prefix", full_prefix),
468
+ ("page_size", str(int(page_size))),
469
+ ("version", (version or "").strip())]
470
+ if (dataset_split_id or "").strip():
471
+ params.append(("dataset_split_id", dataset_split_id.strip()))
472
+ if tk:
473
+ params.append(("token", tk))
474
+ if env:
475
+ params.append(("environment", env))
476
+ if mm:
477
+ params.append(("metadata_model", mm))
478
+ qs = "&".join(f"{k}={quote(v, safe='')}" for k, v in params)
479
+ data = self._t.get(f"/apis/v1/datasets/objects?{qs}")
480
+ if not isinstance(data, dict):
481
+ break
482
+ page = DatasetObjectListResult.from_dict(data)
483
+ all_dirs.extend(page.directories)
484
+ all_files.extend(page.files)
485
+ result = page # 保留最后一页的 dataset/version/prefix 元信息
486
+ if not page.truncated or not page.next_token:
487
+ break
488
+ tk = page.next_token
489
+ result.directories = all_dirs
490
+ result.files = all_files
491
+ return result
492
+
493
+ # ── 变更检测辅助 ────────────────────────────────────────────────
494
+
495
+ def list_content_hashes(self, dataset: str, version: str = "",
496
+ split: str | None = None, *,
497
+ environment: str | None = None,
498
+ metadata_model: str | None = None
499
+ ) -> dict[str, str]:
500
+ """批量查询远端实例的 ``source_hash``(存在 ``extend_meta`` 中)。
501
+
502
+ 返回 ``{instance_id: source_hash}`` 字典。用于 ``skip_unchanged``
503
+ 变更检测:与本地 ``compute_source_hash`` 或自定义哈希比对,
504
+ 匹配则跳过 pack + upload。
505
+ """
506
+ overrides = _registry_overrides(environment, metadata_model)
507
+ instances = self.list(dataset, version, split, **overrides)
508
+ result: dict[str, str] = {}
509
+ for inst in instances:
510
+ h = (inst.extend_meta or {}).get("source_hash", "")
511
+ if h:
512
+ result[inst.instance_id] = h
513
+ return result
514
+
515
+ # ── 实例删除 ────────────────────────────────────────────────────
516
+
517
+ def delete(self, dataset: str, version: str, instance_id: str, *,
518
+ split: str | None = None,
519
+ environment: str | None = None,
520
+ metadata_model: str | None = None) -> None:
521
+ """删除单个实例的元数据记录(``DELETE /datasets/instances/detail``)。
522
+
523
+ 仅删除 MongoDB 记录,不删除 OSS 对象。后续 ingest 扫描可能重建该记录。
524
+ """
525
+ from urllib.parse import quote
526
+ if not (dataset or "").strip():
527
+ raise SchemaError("dataset is required for instances.delete")
528
+ if not (instance_id or "").strip():
529
+ raise SchemaError("instance_id is required for instances.delete")
530
+ params: list[tuple[str, str]] = [
531
+ ("dataset_name", dataset.strip()),
532
+ ("instance_id", instance_id.strip())]
533
+ if (version or "").strip():
534
+ params.append(("version", version.strip()))
535
+ if (split or "").strip():
536
+ params.append(("split", (split or "").strip()))
537
+ env = (environment or "").strip()
538
+ if not env:
539
+ env = (getattr(self._p, "dataset_environment", "") or "").strip()
540
+ if env:
541
+ params.append(("environment", env))
542
+ mm = (metadata_model or "").strip()
543
+ if not mm:
544
+ mm = (getattr(self._p, "metadata_model", "") or "").strip()
545
+ if mm:
546
+ params.append(("metadata_model", mm))
547
+ qs = "&".join(f"{k}={quote(v, safe='')}" for k, v in params)
548
+ self._t.delete(f"/apis/v1/datasets/instances/detail?{qs}")
549
+
387
550
  def update(self, dataset: str, version: str, instance_id: str, *,
388
551
  split: str | None = None, dataset_split_id: str | None = None,
389
552
  manifest: list[dict] | None = None,
@@ -462,7 +625,10 @@ class InstancesClient:
462
625
  split: str = "default", push_image: bool = True,
463
626
  overwrite: bool = False, register: bool = True,
464
627
  verify: bool = True,
465
- image_release_dst_acr: str | None = None) -> PushResult:
628
+ image_release_dst_acr: str | None = None,
629
+ content_hash: str | None = None,
630
+ skip_unchanged: bool = False,
631
+ compress_level: int | None = None) -> PushResult:
466
632
  """推一个实例:自动检测格式(目录→harbor,.json→swe),validate → 上传。
467
633
 
468
634
  harbor: validate → (可选)推镜像 → 上传 OSS(content.tgz + {id}.json)。
@@ -476,6 +642,14 @@ class InstancesClient:
476
642
  verify=True(默认)在真正发生上传后回读校验:确认 OSS 对象确实可 HEAD 到。
477
643
 
478
644
  image_release_dst_acr(可选):目标(prod)ACR host(仅 harbor 格式有效)。
645
+
646
+ content_hash(可选):调用方提供的内容标识(如 git tree id),
647
+ 存入 ``extend_meta.source_hash``,供后续 ``skip_unchanged`` 比对。
648
+
649
+ skip_unchanged(可选):若为 True 且 content_hash 非空,先查远端
650
+ ``extend_meta.source_hash``,匹配则跳过 pack + upload。
651
+
652
+ compress_level(可选):gzip 压缩级别(1-9),传 None 用默认值。
479
653
  """
480
654
  local_path = Path(local_path)
481
655
  if register and self._ingester is None:
@@ -550,14 +724,38 @@ class InstancesClient:
550
724
  )
551
725
  uid = instance_id
552
726
 
727
+ # 变更检测:skip_unchanged=True 时,若 content_hash 与远端一致,跳过 pack+upload。
728
+ if skip_unchanged and content_hash:
729
+ try:
730
+ remote = self._reg.get(dataset, version, instance_id, split)
731
+ if remote is not None:
732
+ remote_hash = (remote.extend_meta or {}).get("source_hash", "")
733
+ if remote_hash == content_hash:
734
+ logger.info("push %s: skip (source_hash match)", instance_id)
735
+ return PushResult(
736
+ instance_id=instance_id, uploaded=False,
737
+ skipped=True, image_pushed=False,
738
+ docker_image=docker_ref or None,
739
+ digests={}, ingest=None)
740
+ except (TransportError, Exception): # noqa: BLE001
741
+ pass # 查询失败不阻断,继续正常 push
742
+ # 将 content_hash 存入 extend_meta,供下次 skip_unchanged 比对
743
+ if content_hash:
744
+ extend_meta["source_hash"] = content_hash
745
+ meta.extend_meta = extend_meta
746
+
553
747
  # 打包 + 构造 manifest(本地)→ register(pending) → 推 ACR → 上传 OSS。
554
748
  # register 需要 manifest:apiserver 的 hasFile 校验要求至少一条
555
749
  # content_type=file 项,manifest 缺失时 register 被 92005 拒(曾导致
556
750
  # custom/harbor 主路径 push(register=True) 失败)。register 仍先于任何
557
751
  # 远程写(元数据先行),manifest 在本地打包时即可算出 digest。
752
+ import time as _time
753
+ _t0 = _time.monotonic()
558
754
  with tempfile.TemporaryDirectory(prefix="irepo_") as td:
559
755
  tgz = Path(td) / "content.tgz"
560
- digest = pack(local_path, tgz, exclude_image_tar=True)
756
+ digest = pack(local_path, tgz, exclude_image_tar=True,
757
+ compresslevel=compress_level)
758
+ _pack_elapsed = _time.monotonic() - _t0
561
759
  meta.manifest = _manifest(self._p.oss_bucket, dataset, version,
562
760
  instance_id, split, digest, lay)
563
761
 
@@ -572,7 +770,14 @@ class InstancesClient:
572
770
  put = self._content.put(instance_id, dataset, version, split, tgz,
573
771
  meta.to_canonical_json(), overwrite=overwrite,
574
772
  layout=lay)
773
+ _tgz_size = tgz.stat().st_size
575
774
  skipped = bool(put.get("skipped"))
775
+ _upload_elapsed = _time.monotonic() - _t0 - _pack_elapsed
776
+ if skipped:
777
+ _tgz_size = 0
778
+ logger.info("push %s: pack=%.1fs (%d bytes) upload=%.1fs skipped=%s",
779
+ instance_id, _pack_elapsed, _tgz_size,
780
+ _upload_elapsed, skipped)
576
781
  # 回读校验(防静默成功):只在真正发生上传时校验对象已落库;镜像推送后校验可 inspect。
577
782
  if verify:
578
783
  if not skipped and not self._content.exists(
@@ -807,7 +1012,13 @@ class InstancesClient:
807
1012
  overwrite: bool = False, register: bool = False,
808
1013
  verify: bool = True,
809
1014
  concurrency: int = 8, continue_on_error: bool = False,
810
- image_release_dst_acr: str | None = None
1015
+ image_release_dst_acr: str | None = None,
1016
+ skip_unchanged: bool = False,
1017
+ hash_func: "Callable | None" = None,
1018
+ content_hashes: dict[str, str] | None = None,
1019
+ progress_callback: "Callable | None" = None,
1020
+ executor=None,
1021
+ compress_level: int | None = None
811
1022
  ) -> list:
812
1023
  """并发推一批实例(默认 register=False:只传 OSS,元数据随后由 ingest 扫描入库)。
813
1024
 
@@ -824,38 +1035,113 @@ class InstancesClient:
824
1035
  ``owner_reference`` 整批共用同一来源引用(批量交付通常同源;需要逐实例不同来源时
825
1036
  请自行循环 ``push``)。CLI 的 push / push-many / deliver 都把它设为必填——交付
826
1037
  路径必须能追溯上游来源。
1038
+
1039
+ ``skip_unchanged=True``:自动比对本地源文件哈希与远端 ``extend_meta.source_hash``,
1040
+ 匹配则跳过 pack+upload。``hash_func`` 可自定义哈希函数(默认用
1041
+ ``content.compute_source_hash``);``content_hashes`` 可直接提供
1042
+ ``{instance_id: hash}`` 映射跳过本地计算。
1043
+
1044
+ ``progress_callback(completed, total, result_or_error)``:每个 case 完成时调用。
1045
+
1046
+ ``executor``:外部 ``ThreadPoolExecutor``,传入时复用(不关闭);
1047
+ 为 None 时内部新建。
1048
+
1049
+ ``compress_level``:gzip 压缩级别(1-9),透传给 ``pack()``。
827
1050
  """
828
1051
  dirs = list(local_dirs)
1052
+
1053
+ # ── skip_unchanged:哈希比对 + 过滤 ──
1054
+ skipped_results: list[PushResult] = []
1055
+ hashes_map: dict[str, str] = dict(content_hashes) if content_hashes else {}
1056
+ if skip_unchanged and dirs:
1057
+ from ..content import compute_source_hash as _default_hash
1058
+ _hash_fn = hash_func or _default_hash
1059
+ # 1. 计算本地哈希(未提供的)
1060
+ for d in dirs:
1061
+ iid = Path(d).name
1062
+ if iid not in hashes_map:
1063
+ try:
1064
+ hashes_map[iid] = _hash_fn(str(d))
1065
+ except Exception: # noqa: BLE001
1066
+ pass # 哈希失败不阻断,视为"变化"
1067
+ # 2. 获取远端哈希
1068
+ try:
1069
+ remote_hashes = self.list_content_hashes(
1070
+ dataset, version, split)
1071
+ except Exception: # noqa: BLE001
1072
+ remote_hashes = {}
1073
+ # 3. 过滤:未变化的生成 skip 结果并从 dirs 移除
1074
+ unchanged_ids: set[str] = set()
1075
+ for d in dirs:
1076
+ iid = Path(d).name
1077
+ local_h = hashes_map.get(iid, "")
1078
+ remote_h = remote_hashes.get(iid, "")
1079
+ if local_h and local_h == remote_h:
1080
+ unchanged_ids.add(iid)
1081
+ skipped_results.append(PushResult(
1082
+ instance_id=iid, uploaded=False, skipped=True,
1083
+ image_pushed=False, docker_image=None,
1084
+ digests={}, ingest=None))
1085
+ if unchanged_ids:
1086
+ logger.info("push_many: %d/%d unchanged (skipped)",
1087
+ len(unchanged_ids), len(dirs))
1088
+ dirs = [d for d in dirs if Path(d).name not in unchanged_ids]
1089
+
829
1090
  warm = getattr(self._content, "prewarm", None)
830
1091
  if warm is not None:
831
- # prewarm(prefix, dataset) 与 Go Prewarm(prefix, dataset) 对齐;
832
- # dataset 用于 Dataset RBAC 签发(缺失时 apiserver 回 94020)。
833
- # sts_prefix 是 dataset 级,两模型一致,但仍统一经 layout 取,避免再触 paths.*。
834
1092
  sts_prefix = self._layout().sts_prefix(dataset)
835
1093
  try:
836
1094
  warm(sts_prefix, dataset)
837
1095
  except TypeError:
838
- # 向后兼容旧签名 prewarm(prefix)(用户自定义 store / 旧版 fake)。
839
1096
  warm(sts_prefix)
840
- # 推镜像时同样预热 ACR 凭据缓存(否则首轮各 worker 会并发重复签同一 host)
841
1097
  if push_image and getattr(self._p, "acr_host", None):
842
- # 签名恒用 apiserver 登记的 host(acr_host,VPC);token 实例级,
843
- # 对公网 endpoint 同样有效(prefer_public 时连接走公网、签名仍走这里)。
844
1098
  try:
845
1099
  self._t.get_acr_credentials(self._p.acr_host)
846
- except Exception: # noqa: BLE001 — 预热失败不阻断,真正推镜像时再报
1100
+ except Exception: # noqa: BLE001
847
1101
  pass
848
1102
 
849
1103
  def _one(d):
850
- # 逐实例强制 register=False:批量 ingest 只在整批结束后执行一次
1104
+ iid = Path(d).name
1105
+ ch = hashes_map.get(iid)
851
1106
  return self.push(d, dataset, version,
852
1107
  owner_reference=owner_reference, split=split,
853
1108
  push_image=push_image, overwrite=overwrite,
854
1109
  register=False, verify=verify,
855
- image_release_dst_acr=image_release_dst_acr)
856
-
857
- results = map_concurrent(_one, dirs, concurrency=concurrency,
858
- return_exceptions=continue_on_error)
1110
+ image_release_dst_acr=image_release_dst_acr,
1111
+ content_hash=ch,
1112
+ compress_level=compress_level)
1113
+
1114
+ # ── 并发执行 + 进度回调 ──
1115
+ total = len(dirs)
1116
+ completed = [0] # mutable counter for closure
1117
+ lock = __import__("threading").Lock()
1118
+
1119
+ def _wrapped(d):
1120
+ result = _one(d)
1121
+ with lock:
1122
+ completed[0] += 1
1123
+ if progress_callback:
1124
+ try:
1125
+ progress_callback(completed[0], total, result)
1126
+ except Exception: # noqa: BLE001
1127
+ pass
1128
+ return result
1129
+
1130
+ if executor is not None:
1131
+ # 外部线程池:submit 并收集结果(保持输入顺序)
1132
+ futures = [executor.submit(_wrapped, d) for d in dirs]
1133
+ results = []
1134
+ for f in futures:
1135
+ r = f.result()
1136
+ results.append(r)
1137
+ if not continue_on_error:
1138
+ from ..concurrency import ItemError
1139
+ for r in results:
1140
+ if isinstance(r, Exception):
1141
+ raise ItemError(r)
1142
+ else:
1143
+ results = map_concurrent(_wrapped, dirs, concurrency=concurrency,
1144
+ return_exceptions=continue_on_error)
859
1145
  # Close retired OSS buckets after the batch to release connection pools.
860
1146
  # Guard for non-OSS or mock stores that don't have close_retired.
861
1147
  _close = getattr(self._content, "close_retired", None)
@@ -865,11 +1151,13 @@ class InstancesClient:
865
1151
  # 这里说明整批成功;True 时对成功子集 ingest(与 deliver 语义一致)。
866
1152
  if register:
867
1153
  self._after_upload(True, dataset, version, split=split)
868
- return results
1154
+ # 将 skipped 结果合并到返回列表(放在末尾,不影响 input 顺序的 pushed/failed 对应)
1155
+ return list(results) + skipped_results
869
1156
 
870
1157
  def pull(self, dataset: str, version: str, instance_id: str, dest_dir,
871
1158
  split: str = "default", *, verify: bool = True,
872
- fallback_to_oss: bool = True) -> Path:
1159
+ fallback_to_oss: bool = True,
1160
+ path: str | None = None) -> Path:
873
1161
  """下载实例内容。自动检测格式:harbor 下载 content.tgz,swe 下载 {id}.json,
874
1162
  custom 下载 {id}.json + assets 目录。
875
1163
 
@@ -881,10 +1169,16 @@ class InstancesClient:
881
1169
  3. metadata JSON 不存在 → fallback 到 harbor(下载 content.tgz)
882
1170
  4. dataset 未注册(92001)→ OSS 回退:list-only 枚举文件树 + 逐文件下载
883
1171
  (仍走 DOWNLOAD 意图:仅私网,公网下载照旧禁止)
1172
+
1173
+ ``path`` 参数用于部分下载(仅 custom 和 OSS 回退格式生效):
1174
+ 为空时下载全部内容;非空时只下载该路径前缀下的文件。
1175
+ harbor(content.tgz)和 swe(单 JSON)格式不支持部分下载,
1176
+ ``path`` 会被忽略。
884
1177
  """
885
1178
  validate_instance_id(instance_id, source="pull()")
886
1179
  expected = None
887
1180
  fmt = None # None = 未确定,需要探测
1181
+ dir_manifest = None # manifest 中的 directory 条目(如有)
888
1182
  lay = self._layout()
889
1183
  # 已下载的元数据字节(探测阶段下载一次,swe/custom 路径复用,避免二次网络往返)。
890
1184
  meta_bytes: bytes | None = None
@@ -893,23 +1187,39 @@ class InstancesClient:
893
1187
  try:
894
1188
  meta = self._reg.get(dataset, version, instance_id, split)
895
1189
  except TransportError as e:
896
- if (e.biz_code == 92001 and fallback_to_oss
1190
+ if (e.biz_code in (92001, 92023) and fallback_to_oss
897
1191
  and self._content is not None):
898
- # dataset 未注册 → OSS 回退下载(私网 + 系统调用方语义)
1192
+ # 92001: dataset 未注册
1193
+ # 92023: dataset 已注册但 split 在当前 environment 不存在
1194
+ # (如数据在 online 但 SDK 查了 pre)
1195
+ # 两种情况均回退到 OSS 直接下载(私网 + 系统调用方语义)
899
1196
  return self._pull_unregistered(
900
- dataset, version, instance_id, dest_dir, split)
1197
+ dataset, version, instance_id, dest_dir, split,
1198
+ path=path)
901
1199
  raise
902
1200
  if meta is not None and meta.manifest:
903
1201
  has_tgz = False
1202
+ has_dir = False
904
1203
  for m in meta.manifest:
905
1204
  if m.content_format == "tgz":
906
1205
  expected = m.content_digest
907
1206
  has_tgz = True
908
1207
  break
1208
+ if m.content_type == "directory":
1209
+ has_dir = True
1210
+ dir_manifest = m
909
1211
  if has_tgz:
910
1212
  fmt = "harbor"
1213
+ elif has_dir:
1214
+ # manifest 同时有 json 和 directory 条目 → custom 格式
1215
+ # (directory 条目指向 assets 目录,需逐文件下载)。
1216
+ fmt = "custom"
1217
+ for m in meta.manifest:
1218
+ if m.content_format == "json":
1219
+ expected = m.content_digest
1220
+ break
911
1221
  else:
912
- # manifest 有 json entry 但无 tgz → swe 或 custom(需进一步探测)。
1222
+ # manifest 有 json entry 但无 tgz 也无 directory → swe 或 custom(需进一步探测)。
913
1223
  # custom 推的 manifest 由 ingest 扫描生成,与 swe 同形态;
914
1224
  # 唯一区分手段是读 metadata JSON 检查 metadata_model 字段。
915
1225
  for m in meta.manifest:
@@ -998,9 +1308,42 @@ class InstancesClient:
998
1308
  if isinstance(meta_doc, dict) and isinstance(
999
1309
  meta_doc.get("assets"), list):
1000
1310
  asset_rels = [str(x) for x in meta_doc["assets"]]
1311
+ # manifest 有 directory 条目但 JSON 无 assets 字段时,
1312
+ # 用控制面 list_objects API 递归枚举目录下的全部文件作为 asset_rels。
1313
+ if asset_rels is None and dir_manifest is not None:
1314
+ try:
1315
+ env = getattr(self._p, "dataset_environment", None)
1316
+ mm = getattr(self._p, "metadata_model", None)
1317
+ # 递归列举:从根开始,遇到子目录继续深入
1318
+ asset_rels = []
1319
+ stack: list[str | None] = [None] # None = 实例资产根
1320
+ # list_objects 返回的 f.path 包含完整前缀
1321
+ # (如 test-assets/105-creep-forecast/solution/solve.py),
1322
+ # 需要去掉 {split}-assets/{instance_id}/ 前缀得到相对路径。
1323
+ split_str = (split or "default").strip()
1324
+ assets_root = f"{split_str}-assets/{instance_id.strip()}/"
1325
+ while stack:
1326
+ cur_path = stack.pop()
1327
+ objs = self.list_objects(
1328
+ dataset, instance_id, split=split,
1329
+ version=version, path=cur_path,
1330
+ environment=env, metadata_model=mm)
1331
+ for f in objs.files:
1332
+ rel = f.path
1333
+ if rel.startswith(assets_root):
1334
+ rel = rel[len(assets_root):]
1335
+ asset_rels.append(rel)
1336
+ for d in objs.directories:
1337
+ # directory path 也含完整前缀,去掉后入栈
1338
+ d_path = d.path.rstrip("/")
1339
+ if d_path.startswith(assets_root):
1340
+ d_path = d_path[len(assets_root):]
1341
+ stack.append(d_path)
1342
+ except Exception: # noqa: BLE001
1343
+ asset_rels = None # 枚举失败回退到 OSS LIST
1001
1344
  self._download_custom_assets(
1002
1345
  dataset, instance_id, version, split, dest_dir, lay, sts_prefix,
1003
- asset_rels=asset_rels)
1346
+ asset_rels=asset_rels, path=path)
1004
1347
  return dest
1005
1348
 
1006
1349
  if fmt == "swe":
@@ -1009,11 +1352,50 @@ class InstancesClient:
1009
1352
  # 已在探测阶段下载,直接写入本地。
1010
1353
  dest.parent.mkdir(parents=True, exist_ok=True)
1011
1354
  dest.write_bytes(meta_bytes)
1012
- return dest
1013
- return self._content.get_object(
1014
- idx_key, dest, expected_digest=expected,
1015
- sts_prefix=sts_prefix,
1016
- dataset=dataset)
1355
+ else:
1356
+ self._content.get_object(
1357
+ idx_key, dest, expected_digest=expected,
1358
+ sts_prefix=sts_prefix,
1359
+ dataset=dataset)
1360
+ # swe 格式也可能附带 assets 目录(JSON + directory 混合格式)。
1361
+ # 尝试用 list_objects 检查并下载目录内容。
1362
+ try:
1363
+ objs = self.list_objects(
1364
+ dataset, instance_id, split=split,
1365
+ version=version,
1366
+ environment=getattr(self._p, "dataset_environment", None),
1367
+ metadata_model=getattr(self._p, "metadata_model", None))
1368
+ if objs.files or objs.directories:
1369
+ # 有 assets 内容 → 递归枚举并下载
1370
+ asset_rels: list[str] = []
1371
+ stack: list[str | None] = [None]
1372
+ split_str = (split or "default").strip()
1373
+ assets_root = f"{split_str}-assets/{instance_id.strip()}/"
1374
+ while stack:
1375
+ cur = stack.pop()
1376
+ page = self.list_objects(
1377
+ dataset, instance_id, split=split,
1378
+ version=version, path=cur,
1379
+ environment=getattr(self._p, "dataset_environment", None),
1380
+ metadata_model=getattr(self._p, "metadata_model", None))
1381
+ for f in page.files:
1382
+ rel = f.path
1383
+ if rel.startswith(assets_root):
1384
+ rel = rel[len(assets_root):]
1385
+ asset_rels.append(rel)
1386
+ for d in page.directories:
1387
+ d_path = d.path.rstrip("/")
1388
+ if d_path.startswith(assets_root):
1389
+ d_path = d_path[len(assets_root):]
1390
+ stack.append(d_path)
1391
+ if asset_rels:
1392
+ self._download_custom_assets(
1393
+ dataset, instance_id, version, split,
1394
+ dest_dir, lay, sts_prefix,
1395
+ asset_rels=asset_rels, path=path)
1396
+ except Exception: # noqa: BLE001
1397
+ pass # assets 目录不存在或枚举失败 → 仅保留 JSON
1398
+ return dest
1017
1399
 
1018
1400
  # harbor:下载 content.tgz
1019
1401
  dest = Path(dest_dir) / instance_id / "content.tgz"
@@ -1023,7 +1405,8 @@ class InstancesClient:
1023
1405
  def _download_custom_assets(self, dataset: str, instance_id: str,
1024
1406
  version: str, split: str, dest_dir, lay,
1025
1407
  sts_prefix: str,
1026
- asset_rels: list[str] | None = None
1408
+ asset_rels: list[str] | None = None,
1409
+ path: str | None = None
1027
1410
  ) -> list[Path]:
1028
1411
  """下载 custom 格式的资产目录(与 content.tgz 同层、同版本规则)。
1029
1412
 
@@ -1033,17 +1416,25 @@ class InstancesClient:
1033
1416
  ``asset_rels`` 为元数据 ``assets`` 字段记录的资产相对路径清单:非 None 时
1034
1417
  逐对象 GetObject 还原(普通用户的 dataset-RBAC STS 不含 oss:ListObjects,
1035
1418
  不能靠 LIST 枚举);为 None(旧数据无该字段)时回退到 LIST 枚举。
1419
+
1420
+ ``path`` 为子目录过滤(相对于资产根目录):非空时仅下载该路径前缀下的文件。
1036
1421
  """
1037
1422
  asset_prefix = lay.assets_dir(dataset, instance_id,
1038
1423
  version=version, split=split)
1039
1424
  dest_root = Path(dest_dir) / instance_id
1040
1425
  downloaded: list[Path] = []
1426
+ # path 过滤:非空时只下载 rel 以 path 开头的文件
1427
+ path_prefix = ""
1428
+ if path:
1429
+ path_prefix = path.strip().strip("/") + "/"
1041
1430
 
1042
1431
  if asset_rels is not None:
1043
1432
  # 元数据记录了资产清单:逐对象下载,免 LIST。
1044
1433
  for rel in asset_rels:
1045
1434
  if rel.endswith("/"):
1046
1435
  continue # 目录占位符(push 侧不生成;兼容历史数据)
1436
+ if path_prefix and not rel.startswith(path_prefix):
1437
+ continue # path 过滤
1047
1438
  _validate_custom_asset_rel(rel)
1048
1439
  local_path = dest_root / rel
1049
1440
  local_path.parent.mkdir(parents=True, exist_ok=True)
@@ -1057,7 +1448,9 @@ class InstancesClient:
1057
1448
  list_fn = getattr(self._content, "list_objects", None)
1058
1449
  if list_fn is None:
1059
1450
  return []
1060
- keys = list_fn(asset_prefix)
1451
+ # path 非空时,LIST 直接用子前缀缩小范围
1452
+ list_prefix = asset_prefix + path_prefix if path_prefix else asset_prefix
1453
+ keys = list_fn(list_prefix, list_only=True, dataset=dataset)
1061
1454
  if not keys:
1062
1455
  return []
1063
1456
  for key in keys:
@@ -1074,7 +1467,8 @@ class InstancesClient:
1074
1467
  return downloaded
1075
1468
 
1076
1469
  def _pull_unregistered(self, dataset: str, version: str,
1077
- instance_id: str, dest_dir, split: str) -> Path:
1470
+ instance_id: str, dest_dir, split: str,
1471
+ path: str | None = None) -> Path:
1078
1472
  """未注册 dataset 的 pull 回退:list-only 枚举文件树 + 逐文件下载。
1079
1473
 
1080
1474
  下载仍走 :meth:`~instance_repo.store.oss.OssContentStore.get_object`
@@ -1083,6 +1477,8 @@ class InstancesClient:
1083
1477
  STS 语义:未注册 dataset 无 RBAC 记录,GetObject STS 仅 system 白名单
1084
1478
  调用方可签发(非 system 调用方在签发阶段收到明确错误),与历史行为一致。
1085
1479
 
1480
+ ``path`` 为子目录过滤(相对于实例资产根目录):非空时仅下载该路径前缀下的文件。
1481
+
1086
1482
  产物结构(harbor 同层,便于 CLI 整体搬移)::
1087
1483
 
1088
1484
  {dest_dir}/{instance_id}/{instance_id}.json # metadata
@@ -1098,9 +1494,16 @@ class InstancesClient:
1098
1494
  oss_prefix=self._oss_prefix)
1099
1495
  if tree is None:
1100
1496
  from ..errors import DatasetNotFound
1497
+ bucket = getattr(self._p, "oss_bucket", "") or ""
1498
+ from .config import dataset_environment_for
1499
+ env = dataset_environment_for(
1500
+ getattr(self._p, "storage_env", "")) or ""
1101
1501
  raise DatasetNotFound(
1102
- f"instance {instance_id!r} not found in OSS for "
1103
- f"dataset {dataset!r}")
1502
+ f"instance {instance_id!r} not found in OSS bucket "
1503
+ f"{bucket!r} (environment={env or 'unknown'!r}) for "
1504
+ f"dataset {dataset!r}; the data may be registered in a "
1505
+ f"different storage environment — select a cluster whose "
1506
+ f"storage_env matches the dataset's environment")
1104
1507
 
1105
1508
  metadata_key, files = tree
1106
1509
  dest_root = Path(dest_dir) / instance_id
@@ -1108,7 +1511,13 @@ class InstancesClient:
1108
1511
  metadata_dest = dest_root / f"{instance_id}.json"
1109
1512
 
1110
1513
  self._content.get_object(metadata_key, metadata_dest, dataset=dataset)
1514
+ # path 过滤:非空时只下载 rel 以 path 开头的文件
1515
+ path_prefix = ""
1516
+ if path:
1517
+ path_prefix = path.strip().strip("/") + "/"
1111
1518
  for key, rel in files:
1519
+ if path_prefix and not rel.startswith(path_prefix):
1520
+ continue
1112
1521
  local_path = dest_root / rel
1113
1522
  local_path.parent.mkdir(parents=True, exist_ok=True)
1114
1523
  self._content.get_object(key, local_path, dataset=dataset)
@@ -63,17 +63,24 @@ def _normalize(ti: tarfile.TarInfo) -> tarfile.TarInfo:
63
63
 
64
64
 
65
65
  def pack(local_dir: str | Path, out_tgz: str | Path, *,
66
- exclude_image_tar: bool = True) -> str:
66
+ exclude_image_tar: bool = True,
67
+ compresslevel: int | None = None) -> str:
67
68
  """把 local_dir 打包成 out_tgz(gzip tar),返回 content_digest。
68
69
 
69
70
  确定性:条目按相对路径排序、元数据(mtime/uid/gid/uname/gname)归零、gzip
70
71
  header mtime=0,故同一目录内容恒得同一 digest(跨时间/机器可复现)。
72
+
73
+ ``compresslevel``:gzip 压缩级别(1-9),默认 None 使用 gzip 库默认值(9)。
74
+ 较低级别(如 1)可显著减少 CPU 时间但产出稍大的文件。
71
75
  """
72
76
  src = Path(local_dir)
73
77
  out = Path(out_tgz)
74
78
  files = sorted(p for p in src.rglob("*") if p.is_file())
79
+ gz_kw: dict = {"filename": "", "mode": "wb", "mtime": 0}
80
+ if compresslevel is not None:
81
+ gz_kw["compresslevel"] = compresslevel
75
82
  with open(out, "wb") as raw, \
76
- gzip.GzipFile(filename="", fileobj=raw, mode="wb", mtime=0) as gz, \
83
+ gzip.GzipFile(fileobj=raw, **gz_kw) as gz, \
77
84
  tarfile.open(fileobj=gz, mode="w") as tar:
78
85
  for p in files:
79
86
  rel = p.relative_to(src).as_posix()
@@ -81,3 +88,32 @@ def pack(local_dir: str | Path, out_tgz: str | Path, *,
81
88
  continue
82
89
  tar.add(str(p), arcname="./" + rel, filter=_normalize)
83
90
  return sha256_file(out)
91
+
92
+
93
+ def compute_source_hash(local_dir: str | Path, *,
94
+ exclude_image_tar: bool = True) -> str:
95
+ """快速计算源目录的内容哈希(不打包、不压缩)。
96
+
97
+ 遍历目录所有文件,按相对路径排序,对每个文件算 SHA-256,
98
+ 最终对所有 ``(path, sha256)`` 对再算一次 SHA-256。
99
+ 返回 ``"src-sha256:<hex>"``。
100
+
101
+ 与 :func:`pack` 的 digest 不同:本函数不做 tar+gzip,只做文件读取 + 哈希,
102
+ CPU 开销大幅降低(省掉压缩),适合作为变更检测的快速指纹。
103
+
104
+ ``exclude_image_tar`` 语义同 :func:`pack`。
105
+ """
106
+ src = Path(local_dir)
107
+ files = sorted(p for p in src.rglob("*") if p.is_file())
108
+ h = hashlib.sha256()
109
+ for p in files:
110
+ rel = p.relative_to(src).as_posix()
111
+ if exclude_image_tar and rel == _EXCLUDE_REL:
112
+ continue
113
+ fh = hashlib.sha256()
114
+ with open(p, "rb") as f:
115
+ for chunk in iter(lambda: f.read(1 << 20), b""):
116
+ fh.update(chunk)
117
+ h.update(rel.encode("utf-8"))
118
+ h.update(fh.digest())
119
+ return "src-sha256:" + h.hexdigest()
@@ -871,3 +871,82 @@ class RoleBinding:
871
871
  role_id=d.get("role_id", ""),
872
872
  added_by=d.get("added_by", ""),
873
873
  added_at=d.get("added_at", ""))
874
+
875
+
876
+ # ── Dataset Objects(目录/文件列举)──────────────────────────────────
877
+
878
+ @dataclass
879
+ class DatasetObjectDirectory:
880
+ """``GET /datasets/objects`` 返回的目录项。"""
881
+ name: str
882
+ path: str
883
+
884
+ @classmethod
885
+ def from_dict(cls, d: dict) -> "DatasetObjectDirectory":
886
+ return cls(name=d.get("name", ""), path=d.get("path", ""))
887
+
888
+ def to_dict(self) -> dict:
889
+ return {"name": self.name, "path": self.path}
890
+
891
+
892
+ @dataclass
893
+ class DatasetObjectFile:
894
+ """``GET /datasets/objects`` 返回的文件项。"""
895
+ name: str
896
+ path: str
897
+ size_bytes: int = 0
898
+ content_type: str = ""
899
+ previewable: bool = False
900
+
901
+ @classmethod
902
+ def from_dict(cls, d: dict) -> "DatasetObjectFile":
903
+ return cls(
904
+ name=d.get("name", ""),
905
+ path=d.get("path", ""),
906
+ size_bytes=int(d.get("size_bytes", 0)),
907
+ content_type=d.get("content_type", ""),
908
+ previewable=bool(d.get("previewable", False)))
909
+
910
+ def to_dict(self) -> dict:
911
+ return {"name": self.name, "path": self.path,
912
+ "size_bytes": self.size_bytes,
913
+ "content_type": self.content_type,
914
+ "previewable": self.previewable}
915
+
916
+
917
+ @dataclass
918
+ class DatasetObjectListResult:
919
+ """``GET /datasets/objects`` 分页响应。
920
+
921
+ ``directories`` 和 ``files`` 为当前页条目;``truncated`` 为 True 时
922
+ 可用 ``next_token`` 继续翻页。
923
+ """
924
+ dataset: str = ""
925
+ version: str = ""
926
+ prefix: str = ""
927
+ directories: list[DatasetObjectDirectory] = field(default_factory=list)
928
+ files: list[DatasetObjectFile] = field(default_factory=list)
929
+ next_token: str = ""
930
+ truncated: bool = False
931
+
932
+ @classmethod
933
+ def from_dict(cls, d: dict) -> "DatasetObjectListResult":
934
+ return cls(
935
+ dataset=d.get("dataset", ""),
936
+ version=d.get("version", ""),
937
+ prefix=d.get("prefix", ""),
938
+ directories=[DatasetObjectDirectory.from_dict(x)
939
+ for x in d.get("directories", [])],
940
+ files=[DatasetObjectFile.from_dict(x)
941
+ for x in d.get("files", [])],
942
+ next_token=d.get("next_token", ""),
943
+ truncated=bool(d.get("truncated", False)))
944
+
945
+ def to_dict(self) -> dict:
946
+ return {
947
+ "dataset": self.dataset, "version": self.version,
948
+ "prefix": self.prefix,
949
+ "directories": [d.to_dict() for d in self.directories],
950
+ "files": [f.to_dict() for f in self.files],
951
+ "next_token": self.next_token,
952
+ "truncated": self.truncated}
@@ -83,6 +83,7 @@ class Repo:
83
83
  metadata_model=normalize_metadata_model(self.profile.metadata_model))
84
84
  self.datasets = DatasetsClient(
85
85
  self.transport,
86
+ profile=self.profile,
86
87
  content_store=self._content,
87
88
  oss_prefix=self.profile.oss_prefix,
88
89
  metadata_model=self.profile.metadata_model)
@@ -130,7 +131,15 @@ class Repo:
130
131
  overwrite: bool = False, concurrency: int = 8,
131
132
  publish: bool = False, ingest: bool = True,
132
133
  continue_on_error: bool = False,
133
- image_release_dst_acr: str | None = None, **ingest_kwargs) -> dict:
134
+ image_release_dst_acr: str | None = None,
135
+ skip_unchanged: bool = False,
136
+ hash_func=None,
137
+ content_hashes: dict | None = None,
138
+ prune: bool = False,
139
+ progress_callback=None,
140
+ executor=None,
141
+ compress_level: int | None = None,
142
+ **ingest_kwargs) -> dict:
134
143
  """一次性交付一批实例:并发上传 OSS(register=False),再触发一次 dataset-ingest 入库。
135
144
 
136
145
  相比逐个 ``push``(每实例一次 STS 签发 + 串行传输),本方法整批共用 STS、
@@ -138,7 +147,7 @@ class Repo:
138
147
 
139
148
  ``continue_on_error=False``(默认,全或无):任一实例失败即抛 ``ItemError``。
140
149
  ``continue_on_error=True``:跳过失败项,只要有成功上传就对成功子集触发 ingest,
141
- 返回 ``{"pushed":[PushResult...], "failed":[ItemError...], "ingest":<job|None>}``;
150
+ 返回 ``{"pushed":[...], "skipped":[...], "failed":[...], "ingest":<job|None>}``;
142
151
  全部失败时不触发 ingest(job=None)。``ingest=False`` 则只上传不入库。
143
152
 
144
153
  ``owner_reference``(可选):整批共用的上游来源引用,透传给每个 push。CLI 的
@@ -147,22 +156,48 @@ class Repo:
147
156
  ``image_release_dst_acr``(可选):透传给 push,把「实例↔镜像」上架映射写进
148
157
  {id}.json 的 extend_meta,随本方法的 ingest 一并入库(见 push 文档)。
149
158
 
159
+ ``skip_unchanged=True``:自动比对本地源文件哈希与远端 ``extend_meta.source_hash``,
160
+ 匹配则跳过 pack+upload。``hash_func`` 可自定义哈希函数(默认用
161
+ ``content.compute_source_hash``);``content_hashes`` 可直接提供
162
+ ``{instance_id: hash}`` 映射跳过本地计算。
163
+
164
+ ``prune=True``:交付完成后,删除远端存在但本次 local_dirs 中不存在的实例
165
+ (逐个调 ``instances.delete``)。注意:ingest 本身已有 prune 语义
166
+ (删除 OSS 中不存在的旧记录),此选项额外清理 OSS 中也已删除的情况。
167
+
168
+ ``progress_callback(completed, total, result_or_error)``:每个 case 完成时调用。
169
+
170
+ ``executor``:外部 ``ThreadPoolExecutor``,传入时复用(不关闭)。
171
+
172
+ ``compress_level``:gzip 压缩级别(1-9),透传给 pack。
173
+
150
174
  注意:单实例在"推 ACR→传 content.tgz→写 {id}.json"中途失败可能留下孤儿对象/镜像
151
175
  (ingest 按 {id}.json 扫描,孤儿对此无害,但占用存储,需要时另行清理)。
152
176
  """
177
+ import logging as _logging
178
+ _logger = _logging.getLogger(__name__)
179
+
153
180
  results = self.instances.push_many(
154
181
  local_dirs, dataset, version, owner_reference=owner_reference,
155
182
  split=split, push_image=push_image,
156
183
  overwrite=overwrite, register=False, concurrency=concurrency,
157
184
  continue_on_error=continue_on_error,
158
- image_release_dst_acr=image_release_dst_acr)
159
- pushed = [r for r in results if not isinstance(r, ItemError)]
185
+ image_release_dst_acr=image_release_dst_acr,
186
+ skip_unchanged=skip_unchanged,
187
+ hash_func=hash_func,
188
+ content_hashes=content_hashes,
189
+ progress_callback=progress_callback,
190
+ executor=executor,
191
+ compress_level=compress_level)
192
+ pushed = [r for r in results
193
+ if not isinstance(r, ItemError)
194
+ and not getattr(r, "skipped", False)]
195
+ skipped = [r for r in results
196
+ if not isinstance(r, ItemError)
197
+ and getattr(r, "skipped", False)]
160
198
  failed = [r for r in results if isinstance(r, ItemError)]
161
199
  job = None
162
200
  if ingest and pushed: # 仅在有成功上传时才 ingest
163
- # 透传 split 给 ingest:用户若未通过 ingest_kwargs 显式指定 splits/split,
164
- # 且 split 非默认值,则自动透传,避免 ingest 扫描所有 split 目录时
165
- # 对未交付的空 split 报 92005(split path has no instance files)。
166
201
  ingest_split_kwargs = {}
167
202
  if "splits" not in ingest_kwargs and "split" not in ingest_kwargs:
168
203
  if split and split != "default":
@@ -170,7 +205,25 @@ class Repo:
170
205
  job = self.versions.ingest(
171
206
  dataset, version, publish=publish,
172
207
  **ingest_split_kwargs, **ingest_kwargs)
173
- return {"pushed": pushed, "failed": failed, "ingest": job}
208
+ # prune:删除远端有但本次不在 local_dirs 中的实例
209
+ if prune and not failed:
210
+ local_ids = {Path(d).name for d in local_dirs}
211
+ try:
212
+ remote_instances = self.instances.list(dataset, version, split)
213
+ for inst in remote_instances:
214
+ if inst.instance_id not in local_ids:
215
+ try:
216
+ self.instances.delete(
217
+ dataset, version, inst.instance_id,
218
+ split=split)
219
+ _logger.info("prune: deleted %s", inst.instance_id)
220
+ except Exception: # noqa: BLE001
221
+ _logger.warning("prune: failed to delete %s",
222
+ inst.instance_id)
223
+ except Exception: # noqa: BLE001
224
+ _logger.warning("prune: failed to list remote instances")
225
+ return {"pushed": pushed, "skipped": skipped,
226
+ "failed": failed, "ingest": job}
174
227
 
175
228
  # ── 源到目标交付:上传 → 元数据入库 → 发布 workflow ────────────────────────
176
229
  def deliver_to_prod(self, dataset: str, version: str, local_dirs, *,
@@ -629,7 +629,7 @@ class OssContentStore(BaseContentStore):
629
629
  if oss_prefix and not oss_prefix.endswith("/"):
630
630
  oss_prefix += "/"
631
631
 
632
- keys = self.list_objects(oss_prefix)
632
+ keys = self.list_objects(oss_prefix, list_only=True, dataset=dataset)
633
633
 
634
634
  def _download_one(key: str) -> Path:
635
635
  rel = key[len(oss_prefix):]
@@ -327,12 +327,13 @@ class Transport:
327
327
  dataset_id: str | None = None,
328
328
  dataset_name: str | None = None,
329
329
  permission: str | None = None,
330
- list_only: bool = False) -> dict:
330
+ list_only: bool = False,
331
+ dataset_source: str | None = None) -> dict:
331
332
  """换 OSS STS 三元组(收窄到 bucket+prefix)。返回归一化小写字段。
332
333
 
333
- 同 (bucket,prefix,duration,dataset,permission,list_only) 在有效期内复用缓存,
334
- 把 N 次签发降到 ~1 次。dataset_id / dataset_name 至少传一个(细粒度授权场景),
335
- 二者可并存,服务端校验一致性。
334
+ 同 (bucket,prefix,duration,dataset,permission,list_only,dataset_source)
335
+ 在有效期内复用缓存,把 N 次签发降到 ~1 次。dataset_id / dataset_name
336
+ 至少传一个(细粒度授权场景),二者可并存,服务端校验一致性。
336
337
 
337
338
  ``permission``(可选,admin/writer/reader)是**权限封顶**:apiserver 取
338
339
  ``min(调用方真实角色, permission)``,**只能收窄不能提权**——传 admin 不会把
@@ -343,12 +344,20 @@ class Transport:
343
344
  ``list_only`` 是**发现模式开关**(与 permission 封顶正交):True 时签发仅含
344
345
  oss:ListObjects + oss:GetBucketInfo 的令牌,跳过 DatasetRBAC 检查,用于未注册
345
346
  dataset 的 OSS 回退目录探测。二者可并存(list_only 分支优先)。
347
+
348
+ ``dataset_source``(可选,"db"/"oss")指定数据集管理源:
349
+ - 空/"db"(默认):走 MongoDB 注册 + RBAC 授权流程
350
+ - "oss":纯 OSS 模式,跳过 RBAC,dataset_name/dataset_id 可不填,
351
+ 直接按 bucket+prefix 签发 STS
346
352
  """
347
353
  # 空串与 None 一律视为"不封顶"(与 Go 的 Permission == "" 语义对齐)。
348
354
  if permission and permission not in PERMISSION_VALUES:
349
355
  raise SchemaError(
350
356
  f"invalid permission '{permission}'; valid values: "
351
357
  f"{', '.join(PERMISSION_VALUES)}")
358
+ if dataset_source and dataset_source not in ("db", "oss"):
359
+ raise SchemaError(
360
+ f"invalid dataset_source '{dataset_source}'; valid values: db, oss")
352
361
 
353
362
  def sign() -> dict:
354
363
  body: dict[str, Any] = {"resource": "oss",
@@ -363,6 +372,8 @@ class Transport:
363
372
  body["permission"] = permission
364
373
  if list_only:
365
374
  body["list_only"] = True
375
+ if dataset_source:
376
+ body["dataset_source"] = dataset_source
366
377
  r = self.post("/apis/v1/credentials", body, idempotent=True)
367
378
  return {
368
379
  "access_key_id": self._require(r, "OSS_ACCESS_KEY_ID"),
@@ -376,11 +387,13 @@ class Transport:
376
387
  }
377
388
 
378
389
  ttl = float(min(duration_seconds or MIN_STS_DURATION, MAX_STS_DURATION))
379
- # permission 与 list_only 都必须进缓存 key:否则请求 reader 封顶的调用方可能
390
+ # permission、list_only、dataset_source 都必须进缓存 key:否则请求 reader 封顶的调用方可能
380
391
  # 命中此前那份未封顶(可写)的缓存令牌,权限收窄被静默绕过;list-only 令牌
381
- # 也可能被普通请求复用(反之亦然),发现模式与读写模式互相污染。
392
+ # 也可能被普通请求复用(反之亦然),发现模式与读写模式互相污染;
393
+ # oss 模式凭据不能被 db 模式复用(反之亦然)。
382
394
  cache_key = ("oss", bucket, prefix, duration_seconds,
383
- dataset_id, dataset_name, permission or None, list_only)
395
+ dataset_id, dataset_name, permission or None, list_only,
396
+ dataset_source or None)
384
397
  return self._cached_cred(cache_key, sign, fallback_ttl=ttl)
385
398
 
386
399
  def get_acr_credentials(self, acr_registry_url: str, *,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: instance-repo
3
- Version: 1.3.3.dev2
3
+ Version: 1.3.5
4
4
  Summary: InstanceRepo SDK — 评测 Instance 统一存储客户端(SDK-only;控制面由 apiserver 承载)
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "instance-repo"
7
- version = "1.3.3.dev2"
7
+ version = "1.3.5"
8
8
  description = "InstanceRepo SDK — 评测 Instance 统一存储客户端(SDK-only;控制面由 apiserver 承载)"
9
9
  requires-python = ">=3.10"
10
10
  readme = "README.md"