instance-repo 1.3.2.dev5__tar.gz → 1.3.5.dev0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/PKG-INFO +2 -2
  2. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo/__init__.py +3 -1
  3. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo/clients/datasets.py +16 -1
  4. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo/clients/instances.py +215 -6
  5. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo/clients/splits.py +48 -19
  6. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo/clients/versions.py +20 -3
  7. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo/models.py +79 -0
  8. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo/store/oss.py +9 -6
  9. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo/store/oss_discovery.py +73 -14
  10. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo.egg-info/PKG-INFO +2 -2
  11. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo.egg-info/requires.txt +1 -1
  12. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/pyproject.toml +2 -2
  13. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/README.md +0 -0
  14. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo/_routing.py +0 -0
  15. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo/_site_defaults.py +0 -0
  16. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo/artifacts.py +0 -0
  17. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo/bootstrap.py +0 -0
  18. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo/cache.py +0 -0
  19. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo/cli.py +0 -0
  20. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo/clients/__init__.py +0 -0
  21. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo/clients/benchmarks.py +0 -0
  22. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo/clients/config.py +0 -0
  23. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo/clients/images.py +0 -0
  24. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo/clients/reports.py +0 -0
  25. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo/clients/scaffold.py +0 -0
  26. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo/clients/workflows.py +0 -0
  27. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo/concurrency.py +0 -0
  28. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo/content.py +0 -0
  29. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo/endpoints.py +0 -0
  30. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo/errors.py +0 -0
  31. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo/image.py +0 -0
  32. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo/layout.py +0 -0
  33. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo/loader.py +0 -0
  34. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo/paths.py +0 -0
  35. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo/release.py +0 -0
  36. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo/repo.py +0 -0
  37. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo/retry.py +0 -0
  38. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo/store/__init__.py +0 -0
  39. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo/store/acr.py +0 -0
  40. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo/store/base.py +0 -0
  41. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo/store/registry.py +0 -0
  42. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo/tasktoml.py +0 -0
  43. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo/transport.py +0 -0
  44. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo/validate.py +0 -0
  45. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo.egg-info/SOURCES.txt +0 -0
  46. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo.egg-info/dependency_links.txt +0 -0
  47. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo.egg-info/entry_points.txt +0 -0
  48. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/instance_repo.egg-info/top_level.txt +0 -0
  49. {instance_repo-1.3.2.dev5 → instance_repo-1.3.5.dev0}/setup.cfg +0 -0
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: instance-repo
3
- Version: 1.3.2.dev5
3
+ Version: 1.3.5.dev0
4
4
  Summary: InstanceRepo SDK — 评测 Instance 统一存储客户端(SDK-only;控制面由 apiserver 承载)
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
7
- Requires-Dist: irepo-artifact<0.2.0,>=0.1.1
7
+ Requires-Dist: irepo-artifact<0.3.0,>=0.1.1
8
8
  Requires-Dist: tomli>=1.1.0; python_version < "3.11"
9
9
  Provides-Extra: oss
10
10
  Requires-Dist: oss2>=2.19; extra == "oss"
@@ -9,6 +9,7 @@ from .models import (
9
9
  DatasetSeries, DatasetVersion, DatasetInstance, ManifestEntry,
10
10
  OwnerReference, Feedback, Identity, PushResult,
11
11
  RolloutReport, RolloutJob, ScaffoldConfig,
12
+ DatasetObjectDirectory, DatasetObjectFile, DatasetObjectListResult,
12
13
  canonical_json, parse_feedbacks, append_feedback,
13
14
  )
14
15
  from .errors import (
@@ -18,13 +19,14 @@ from .errors import (
18
19
  TransportError,
19
20
  )
20
21
 
21
- __version__ = "1.3.2.dev5"
22
+ __version__ = "1.3.5.dev0"
22
23
 
23
24
  __all__ = [
24
25
  "Repo", "load_instances",
25
26
  "DatasetSeries", "DatasetVersion", "DatasetInstance", "ManifestEntry",
26
27
  "OwnerReference", "Feedback", "Identity", "PushResult",
27
28
  "RolloutReport", "RolloutJob", "ScaffoldConfig",
29
+ "DatasetObjectDirectory", "DatasetObjectFile", "DatasetObjectListResult",
28
30
  "canonical_json", "parse_feedbacks", "append_feedback",
29
31
  "InstanceRepoError", "LayoutError", "SchemaError", "OwnerRefMissing",
30
32
  "ImageMissing", "Immutable", "DigestMismatch", "Forbidden", "NotVisible",
@@ -160,7 +160,8 @@ class DatasetsClient:
160
160
  return DatasetDetail.from_dict(data) if isinstance(data, dict) else DatasetDetail()
161
161
  except TransportError as e:
162
162
  if (e.biz_code == 92001 and fallback_to_oss
163
- and self._content is not None):
163
+ and self._content is not None
164
+ and self._discovery_env_match(environment)):
164
165
  return self._discover_detail(name, storage_env=environment,
165
166
  metadata_model=metadata_model)
166
167
  raise
@@ -396,6 +397,20 @@ class DatasetsClient:
396
397
 
397
398
  # ── OSS fallback discovery (private) ─────────────────────────────────
398
399
 
400
+ def _discovery_env_match(self, environment: str | None) -> bool:
401
+ """OSS 回退环境兼容性检查:请求的 environment 须与 profile storage_env 派生环境一致。"""
402
+ req = (environment or "").strip()
403
+ if not req:
404
+ return True
405
+ if self._p is None:
406
+ return True
407
+ from .config import dataset_environment_for
408
+ derived = dataset_environment_for(
409
+ getattr(self._p, "storage_env", ""))
410
+ if not derived:
411
+ return True
412
+ return req == derived
413
+
399
414
  def _discover_detail(self, name: str, *,
400
415
  storage_env: str | None = None,
401
416
  metadata_model: str | None = None) -> DatasetDetail:
@@ -177,12 +177,14 @@ class InstancesClient:
177
177
  except TransportError as e:
178
178
  # 路径 1: dataset 不存在(92001) → OSS 回退
179
179
  if (e.biz_code == 92001
180
- and fallback_to_oss and self._content is not None):
180
+ and fallback_to_oss and self._content is not None
181
+ and self._discovery_env_match(environment)):
181
182
  return self._discover_instance(
182
183
  dataset, version, instance_id, split)
183
184
  raise
184
185
  # 路径 2: instance 不存在(None, registry 吞了 92003) → OSS 回退
185
- if result is None and fallback_to_oss and self._content is not None:
186
+ if result is None and fallback_to_oss and self._content is not None \
187
+ and self._discovery_env_match(environment):
186
188
  return self._discover_instance(
187
189
  dataset, version, instance_id, split)
188
190
  return result
@@ -203,7 +205,8 @@ class InstancesClient:
203
205
  return self._reg.query(dataset, version, split, **overrides)
204
206
  except TransportError as e:
205
207
  if (e.biz_code == 92001
206
- and fallback_to_oss and self._content is not None):
208
+ and fallback_to_oss and self._content is not None
209
+ and self._discovery_env_match(environment)):
207
210
  return self._discover_instances(dataset, version, split)
208
211
  raise
209
212
 
@@ -281,7 +284,8 @@ class InstancesClient:
281
284
  "GET", f"{self._BASE}?{query}")
282
285
  except TransportError as e:
283
286
  if (e.biz_code == 92001
284
- and fallback_to_oss and self._content is not None):
287
+ and fallback_to_oss and self._content is not None
288
+ and self._discovery_env_match(environment)):
285
289
  return self._discover_instances_paged(
286
290
  dataset, version, split,
287
291
  q=q, instance_id=instance_id,
@@ -295,6 +299,20 @@ class InstancesClient:
295
299
 
296
300
  # ── OSS 回退发现(未注册 dataset 的 instance 合成) ──
297
301
 
302
+ def _discovery_env_match(self, environment: str | None) -> bool:
303
+ """OSS 回退环境兼容性检查:请求的 environment 须与 profile storage_env 派生环境一致。"""
304
+ req = (environment or "").strip()
305
+ if not req:
306
+ return True
307
+ if self._p is None:
308
+ return True
309
+ from .config import dataset_environment_for
310
+ derived = dataset_environment_for(
311
+ getattr(self._p, "storage_env", ""))
312
+ if not derived:
313
+ return True
314
+ return req == derived
315
+
298
316
  def _discover_instances(self, dataset: str, version: str,
299
317
  split: str | None = None) -> list[DatasetInstance]:
300
318
  """OSS 回退的 instances.list — 从 OSS 列举指定 version/split 下的实例。"""
@@ -366,6 +384,145 @@ class InstancesClient:
366
384
  result[parts[0]] = ""
367
385
  return result
368
386
 
387
+ # ── 实例目录/文件列举(GET /datasets/objects)──────────────────
388
+
389
+ def list_objects(self, dataset: str, instance_id: str,
390
+ split: str | None = None, *,
391
+ version: str = "",
392
+ prefix: str | None = None,
393
+ page_size: int = 100,
394
+ token: str | None = None,
395
+ environment: str | None = None,
396
+ metadata_model: str | None = None,
397
+ dataset_split_id: str | None = None
398
+ ) -> "DatasetObjectListResult":
399
+ """列举实例资产目录下的文件/子目录(单页)。
400
+
401
+ 调用控制面 ``GET /apis/v1/datasets/objects``,返回指定实例的
402
+ ``{split}-assets/{instance_id}/`` 路径下的目录和文件列表。
403
+
404
+ Parameters
405
+ ----------
406
+ dataset : str
407
+ Dataset 名称(``L1/L2`` 格式)。
408
+ instance_id : str
409
+ 实例 ID。
410
+ split : str, optional
411
+ Split 名称;为 None 时使用 ``"default"``。
412
+ version : str
413
+ 版本号,默认空串(split_first 布局下可为空)。
414
+ prefix : str, optional
415
+ 子目录前缀过滤(相对于实例资产根目录);为 None 时列举实例根目录。
416
+ page_size : int
417
+ 每页大小,默认 100。
418
+ token : str, optional
419
+ 分页游标(首次传 None,后续传上次返回的 ``next_token``)。
420
+ environment : str, optional
421
+ ``online`` / ``pre``,None 时用 profile 派生值。
422
+ metadata_model : str, optional
423
+ ``split_first`` / ``version_first``,None 时用 profile 派生值。
424
+ dataset_split_id : str, optional
425
+ Split ID,可直接指定以避免 split 名称解析。
426
+
427
+ Returns
428
+ -------
429
+ DatasetObjectListResult
430
+ 包含 ``directories``、``files``、``next_token``、``truncated``。
431
+ """
432
+ from urllib.parse import quote
433
+ from ..models import DatasetObjectListResult
434
+ if not (dataset or "").strip():
435
+ raise SchemaError("dataset is required for instances.list_objects")
436
+ if not (instance_id or "").strip():
437
+ raise SchemaError(
438
+ "instance_id is required for instances.list_objects")
439
+ split = (split or "default").strip()
440
+ # 计算实例资产根前缀:{split}-assets/{instance_id}/
441
+ instance_asset_prefix = f"{split}-assets/{instance_id.strip()}"
442
+ if prefix:
443
+ full_prefix = f"{instance_asset_prefix}/{prefix.strip().strip('/')}"
444
+ else:
445
+ full_prefix = instance_asset_prefix
446
+ # 构建查询参数
447
+ params: list[tuple[str, str]] = [
448
+ ("dataset_name", dataset.strip()),
449
+ ("split", split),
450
+ ("prefix", full_prefix),
451
+ ("page_size", str(int(page_size)))]
452
+ if (version or "").strip():
453
+ params.append(("version", version.strip()))
454
+ if (dataset_split_id or "").strip():
455
+ params.append(("dataset_split_id", dataset_split_id.strip()))
456
+ if (token or "").strip():
457
+ params.append(("token", token.strip()))
458
+ # environment / metadata_model:优先显式值,其次 profile 派生
459
+ env = (environment or "").strip()
460
+ if not env:
461
+ env = (getattr(self._p, "dataset_environment", "") or "").strip()
462
+ if env:
463
+ params.append(("environment", env))
464
+ mm = (metadata_model or "").strip()
465
+ if not mm:
466
+ mm = (getattr(self._p, "metadata_model", "") or "").strip()
467
+ if mm:
468
+ params.append(("metadata_model", mm))
469
+ qs = "&".join(f"{k}={quote(v, safe='')}" for k, v in params)
470
+ data = self._t.get(f"/apis/v1/datasets/objects?{qs}")
471
+ if isinstance(data, dict):
472
+ return DatasetObjectListResult.from_dict(data)
473
+ return DatasetObjectListResult()
474
+
475
+ def list_all_objects(self, dataset: str, instance_id: str,
476
+ split: str | None = None, *,
477
+ version: str = "",
478
+ prefix: str | None = None,
479
+ environment: str | None = None,
480
+ metadata_model: str | None = None,
481
+ dataset_split_id: str | None = None,
482
+ page_size: int = 100,
483
+ max_pages: int = 50
484
+ ) -> "DatasetObjectListResult":
485
+ """自动翻页列举实例资产目录下的全部文件/子目录。
486
+
487
+ 反复调用 :meth:`list_objects` 直到 ``truncated=False`` 或达到
488
+ ``max_pages`` 上限,合并所有页的 ``directories`` 和 ``files``。
489
+
490
+ Parameters
491
+ ----------
492
+ max_pages : int
493
+ 最大翻页数(安全阀,防无限循环),默认 50。
494
+
495
+ 其余参数同 :meth:`list_objects`。
496
+
497
+ Returns
498
+ -------
499
+ DatasetObjectListResult
500
+ 合并后的完整结果,``truncated`` 反映是否因 ``max_pages`` 截断。
501
+ """
502
+ from ..models import DatasetObjectListResult
503
+ all_dirs: list = []
504
+ all_files: list = []
505
+ tk: str | None = None
506
+ result = DatasetObjectListResult()
507
+ for _ in range(max_pages):
508
+ page = self.list_objects(
509
+ dataset, instance_id, split,
510
+ version=version, prefix=prefix,
511
+ page_size=page_size, token=tk,
512
+ environment=environment,
513
+ metadata_model=metadata_model,
514
+ dataset_split_id=dataset_split_id)
515
+ all_dirs.extend(page.directories)
516
+ all_files.extend(page.files)
517
+ result = page # 保留最后一页的 dataset/version/prefix 元信息
518
+ if not page.truncated or not page.next_token:
519
+ break
520
+ tk = page.next_token
521
+ result.directories = all_dirs
522
+ result.files = all_files
523
+ # 若因 max_pages 截断,truncated 保持 True + 保留 next_token
524
+ return result
525
+
369
526
  def update(self, dataset: str, version: str, instance_id: str, *,
370
527
  split: str | None = None, dataset_split_id: str | None = None,
371
528
  manifest: list[dict] | None = None,
@@ -850,7 +1007,8 @@ class InstancesClient:
850
1007
  return results
851
1008
 
852
1009
  def pull(self, dataset: str, version: str, instance_id: str, dest_dir,
853
- split: str = "default", *, verify: bool = True) -> Path:
1010
+ split: str = "default", *, verify: bool = True,
1011
+ fallback_to_oss: bool = True) -> Path:
854
1012
  """下载实例内容。自动检测格式:harbor 下载 content.tgz,swe 下载 {id}.json,
855
1013
  custom 下载 {id}.json + assets 目录。
856
1014
 
@@ -860,6 +1018,8 @@ class InstancesClient:
860
1018
  2. 无元数据或 manifest 无法区分 swe/custom → 下载 metadata JSON 检查
861
1019
  metadata_model 字段(custom → 下载 assets 目录;否则 swe)
862
1020
  3. metadata JSON 不存在 → fallback 到 harbor(下载 content.tgz)
1021
+ 4. dataset 未注册(92001)→ OSS 回退:list-only 枚举文件树 + 逐文件下载
1022
+ (仍走 DOWNLOAD 意图:仅私网,公网下载照旧禁止)
863
1023
  """
864
1024
  validate_instance_id(instance_id, source="pull()")
865
1025
  expected = None
@@ -869,7 +1029,15 @@ class InstancesClient:
869
1029
  meta_bytes: bytes | None = None
870
1030
 
871
1031
  if verify:
872
- meta = self._reg.get(dataset, version, instance_id, split)
1032
+ try:
1033
+ meta = self._reg.get(dataset, version, instance_id, split)
1034
+ except TransportError as e:
1035
+ if (e.biz_code == 92001 and fallback_to_oss
1036
+ and self._content is not None):
1037
+ # dataset 未注册 → OSS 回退下载(私网 + 系统调用方语义)
1038
+ return self._pull_unregistered(
1039
+ dataset, version, instance_id, dest_dir, split)
1040
+ raise
873
1041
  if meta is not None and meta.manifest:
874
1042
  has_tgz = False
875
1043
  for m in meta.manifest:
@@ -1044,6 +1212,47 @@ class InstancesClient:
1044
1212
  downloaded.append(local_path)
1045
1213
  return downloaded
1046
1214
 
1215
+ def _pull_unregistered(self, dataset: str, version: str,
1216
+ instance_id: str, dest_dir, split: str) -> Path:
1217
+ """未注册 dataset 的 pull 回退:list-only 枚举文件树 + 逐文件下载。
1218
+
1219
+ 下载仍走 :meth:`~instance_repo.store.oss.OssContentStore.get_object`
1220
+ 的 DOWNLOAD 意图——**仅私网可用,公网下载照旧被拒**(与注册路径一致)。
1221
+
1222
+ STS 语义:未注册 dataset 无 RBAC 记录,GetObject STS 仅 system 白名单
1223
+ 调用方可签发(非 system 调用方在签发阶段收到明确错误),与历史行为一致。
1224
+
1225
+ 产物结构(harbor 同层,便于 CLI 整体搬移)::
1226
+
1227
+ {dest_dir}/{instance_id}/{instance_id}.json # metadata
1228
+ {dest_dir}/{instance_id}/<assets 相对路径> # assets 文件
1229
+ {dest_dir}/{instance_id}/report/<report 相对路径> # report 文件
1230
+
1231
+ Returns the metadata JSON 本地路径。
1232
+ """
1233
+ from ..store.oss_discovery import discover_instance_file_tree
1234
+
1235
+ tree = discover_instance_file_tree(
1236
+ self._content, dataset, version, instance_id, split,
1237
+ oss_prefix=self._oss_prefix)
1238
+ if tree is None:
1239
+ from ..errors import DatasetNotFound
1240
+ raise DatasetNotFound(
1241
+ f"instance {instance_id!r} not found in OSS for "
1242
+ f"dataset {dataset!r}")
1243
+
1244
+ metadata_key, files = tree
1245
+ dest_root = Path(dest_dir) / instance_id
1246
+ dest_root.mkdir(parents=True, exist_ok=True)
1247
+ metadata_dest = dest_root / f"{instance_id}.json"
1248
+
1249
+ self._content.get_object(metadata_key, metadata_dest, dataset=dataset)
1250
+ for key, rel in files:
1251
+ local_path = dest_root / rel
1252
+ local_path.parent.mkdir(parents=True, exist_ok=True)
1253
+ self._content.get_object(key, local_path, dataset=dataset)
1254
+ return metadata_dest
1255
+
1047
1256
  def pull_many(self, instance_ids: list[str], dataset: str, version: str,
1048
1257
  dest_dir, *, split: str = "default",
1049
1258
  concurrency: int = 8, continue_on_error: bool = False,
@@ -103,7 +103,8 @@ class SplitsClient:
103
103
  except TransportError as e:
104
104
  # ── 2. 92001 异常 → 直接进入 OSS 回退 ──
105
105
  if (e.biz_code == 92001
106
- and fallback_to_oss and self._content is not None):
106
+ and fallback_to_oss and self._content is not None
107
+ and self._discovery_env_match(environment)):
107
108
  return self._discover_splits(
108
109
  dataset, version, scope, status, page, page_size)
109
110
  raise
@@ -114,7 +115,8 @@ class SplitsClient:
114
115
  if not items and not (status or "").strip():
115
116
  items = self._legacy_list(dataset, version, self._env(environment))
116
117
  # ── 3b. 仍为空 → OSS 回退 ──
117
- if not items and fallback_to_oss and self._content is not None:
118
+ if not items and fallback_to_oss and self._content is not None \
119
+ and self._discovery_env_match(environment):
118
120
  return self._discover_splits(
119
121
  dataset, version, scope, status, page, page_size)
120
122
  return PagedResult.from_response(items, pagination, int(page), int(page_size))
@@ -156,7 +158,8 @@ class SplitsClient:
156
158
  if result is not None:
157
159
  return result
158
160
  # Legacy 回退无果 → OSS 回退
159
- if fallback_to_oss and self._content is not None:
161
+ if fallback_to_oss and self._content is not None \
162
+ and self._discovery_env_match(environment):
160
163
  return self._discover_split(dataset, split, version)
161
164
  return None
162
165
  raise
@@ -261,11 +264,14 @@ class SplitsClient:
261
264
  page: int, page_size: int) -> PagedResult:
262
265
  """OSS 回退的 splits.list — 处理 version_scope 和 status 过滤。
263
266
 
264
- 注意:回退场景(dataset 未注册)下 ``version_scope`` 是 MongoDB 语义,
265
- OSS 探测无从区分 versioned/unversioned——**忽略 scope 并返回全部探测结果**
266
- (G4:否则 ap CLI 无 --version 的 split list 恒空)。``version`` 非空时
267
- 仍精确匹配该 version;version 为空时额外包含 split_first 无版本 split
268
- (version="" 归属)。
267
+ split_first 模式下 OSS 探测可区分无版本 split(level-1 目录只有 .json)
268
+ 和有版本 split(level-1 目录含子目录),因此 ``version_scope`` 生效:
269
+
270
+ - ``"unversioned"``: 只返回无版本 split
271
+ - ``"versioned"``: 只返回有版本 split
272
+ - ``"all"`` / None: 返回全部(SDK 默认行为)
273
+
274
+ ``version`` 非空时仍精确匹配该 version。
269
275
  """
270
276
  from ..store.oss_discovery import (
271
277
  parse_dataset_level, build_synthetic_split,
@@ -276,21 +282,23 @@ class SplitsClient:
276
282
  metadata_model=self._discovery_mm())
277
283
  _warn_if_truncated(info, dataset, 500)
278
284
  splits: list[DatasetSplit] = []
285
+ scope = (version_scope or "all").strip().lower()
279
286
  # split_first 无版本布局:dataset 根下直接是 split 目录
280
- if not version:
287
+ if not version and scope in ("all", "unversioned"):
281
288
  for us in info.unversioned_splits:
282
289
  splits.append(build_synthetic_split(
283
290
  dataset, "", us.name, instance_count=us.instance_count))
284
- for v in info.versions:
285
- if version and v.version != version:
286
- continue
287
- if v.has_default_instances:
288
- splits.append(build_synthetic_split(
289
- dataset, v.version, "default",
290
- instance_count=v.default_instance_count))
291
- for s in v.splits:
292
- splits.append(build_synthetic_split(
293
- dataset, v.version, s))
291
+ if scope in ("all", "exact"):
292
+ for v in info.versions:
293
+ if version and v.version != version:
294
+ continue
295
+ if v.has_default_instances:
296
+ splits.append(build_synthetic_split(
297
+ dataset, v.version, "default",
298
+ instance_count=v.default_instance_count))
299
+ for s in v.splits:
300
+ splits.append(build_synthetic_split(
301
+ dataset, v.version, s))
294
302
  if (status or "").strip():
295
303
  splits = [s for s in splits if s.status == status.strip()]
296
304
  return _in_memory_paged(splits, int(page), int(page_size))
@@ -329,6 +337,27 @@ class SplitsClient:
329
337
  getattr(self._p, "metadata_model", None)
330
338
  if self._p is not None else None)
331
339
 
340
+ def _discovery_env_match(self, environment: str | None) -> bool:
341
+ """OSS 回退环境兼容性检查。
342
+
343
+ 请求的 ``environment`` 必须与 profile 的 storage_env 派生环境一致,
344
+ 否则 OSS 回退返回空——因为 bucket 是按 storage_env 绑定的,dev 桶里
345
+ 的数据不应作为 online 结果返回(反之亦然)。
346
+
347
+ ``environment`` 为空时视为兼容(跟随 profile 默认环境)。
348
+ """
349
+ req = (environment or "").strip()
350
+ if not req:
351
+ return True
352
+ if self._p is None:
353
+ return True
354
+ from .config import dataset_environment_for
355
+ derived = dataset_environment_for(
356
+ getattr(self._p, "storage_env", ""))
357
+ if not derived:
358
+ return True # 无法派生时不做限制
359
+ return req == derived
360
+
332
361
  # ── 写 ──
333
362
 
334
363
  def _post_create(self, dataset: str, split: str, version: str,
@@ -228,7 +228,8 @@ class VersionsClient:
228
228
  return [DatasetVersion(**_pick(r)) for r in (rows or [])]
229
229
  except TransportError as e:
230
230
  if (e.biz_code == 92001 and fallback_to_oss
231
- and self._content is not None):
231
+ and self._content is not None
232
+ and self._discovery_env_match(environment)):
232
233
  return self._discover_versions(dataset, metadata_model=metadata_model)
233
234
  raise
234
235
 
@@ -262,7 +263,8 @@ class VersionsClient:
262
263
  data, pagination = self._t.request_with_meta("GET", f"{self._BASE}?{q}")
263
264
  except TransportError as e:
264
265
  if (e.biz_code == 92001 and fallback_to_oss
265
- and self._content is not None):
266
+ and self._content is not None
267
+ and self._discovery_env_match(environment)):
266
268
  return self._discover_versions_paged(
267
269
  dataset, status, int(page), int(page_size),
268
270
  metadata_model=metadata_model)
@@ -295,7 +297,8 @@ class VersionsClient:
295
297
  return DatasetVersion.from_dict_full(data) if isinstance(data, dict) else DatasetVersion(version=version, dataset=dataset)
296
298
  except TransportError as e:
297
299
  if (e.biz_code == 92001 and fallback_to_oss
298
- and self._content is not None):
300
+ and self._content is not None
301
+ and self._discovery_env_match(environment)):
299
302
  return self._discover_version(dataset, version,
300
303
  metadata_model=metadata_model)
301
304
  raise
@@ -939,6 +942,20 @@ class VersionsClient:
939
942
  getattr(self._p, "metadata_model", None)
940
943
  if self._p is not None else None)
941
944
 
945
+ def _discovery_env_match(self, environment: str | None) -> bool:
946
+ """OSS 回退环境兼容性检查:请求的 environment 须与 profile storage_env 派生环境一致。"""
947
+ req = (environment or "").strip()
948
+ if not req:
949
+ return True
950
+ if self._p is None:
951
+ return True
952
+ from .config import dataset_environment_for
953
+ derived = dataset_environment_for(
954
+ getattr(self._p, "storage_env", ""))
955
+ if not derived:
956
+ return True
957
+ return req == derived
958
+
942
959
 
943
960
  # 与 apiserver `DatasetVersionListItem`(pkg/server/dataset_service.go:315)对齐:
944
961
  # 列表项带 instance_count/split_count/updated_at/last_ingested_at,详情接口才带
@@ -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}
@@ -467,7 +467,8 @@ class OssContentStore(BaseContentStore):
467
467
  lambda bucket: bucket.object_exists(key),
468
468
  )
469
469
 
470
- def list_objects(self, prefix: str, *, list_only: bool = False) -> list[str]:
470
+ def list_objects(self, prefix: str, *, list_only: bool = False,
471
+ dataset: str | None = None) -> list[str]:
471
472
  def list_all(bucket):
472
473
  keys: list[str] = []
473
474
  marker = ""
@@ -481,10 +482,11 @@ class OssContentStore(BaseContentStore):
481
482
  return keys
482
483
 
483
484
  return self._execute_oss(
484
- _OperationIntent.NON_DOWNLOAD, prefix, None, list_all,
485
+ _OperationIntent.NON_DOWNLOAD, prefix, dataset, list_all,
485
486
  list_only=list_only)
486
487
 
487
- def list_prefixes(self, prefix: str, *, list_only: bool = False) -> list[str]:
488
+ def list_prefixes(self, prefix: str, *, list_only: bool = False,
489
+ dataset: str | None = None) -> list[str]:
488
490
  """List immediate child *directories* under *prefix* using ``delimiter='/'``.
489
491
 
490
492
  Returns stripped directory names (without the parent prefix or trailing
@@ -515,11 +517,12 @@ class OssContentStore(BaseContentStore):
515
517
  return names
516
518
 
517
519
  return self._execute_oss(
518
- _OperationIntent.NON_DOWNLOAD, prefix, None, list_dirs,
520
+ _OperationIntent.NON_DOWNLOAD, prefix, dataset, list_dirs,
519
521
  list_only=list_only)
520
522
 
521
523
  def list_level(self, prefix: str, *,
522
- list_only: bool = False) -> tuple[list[str], list[str]]:
524
+ list_only: bool = False,
525
+ dataset: str | None = None) -> tuple[list[str], list[str]]:
523
526
  """Single-level listing with ``delimiter='/'``.
524
527
 
525
528
  Returns ``(dir_names, file_keys)`` where *dir_names* are stripped
@@ -549,7 +552,7 @@ class OssContentStore(BaseContentStore):
549
552
  return names, keys
550
553
 
551
554
  return self._execute_oss(
552
- _OperationIntent.NON_DOWNLOAD, prefix, None, list_one_level,
555
+ _OperationIntent.NON_DOWNLOAD, prefix, dataset, list_one_level,
553
556
  list_only=list_only)
554
557
 
555
558
  # ── directory-level helpers(自定义格式实例 = 目录级上传/下载)──
@@ -235,7 +235,7 @@ def parse_dataset_level(
235
235
  # -- Level 1: list immediate children under the dataset root -----------
236
236
  try:
237
237
  level1_names, level1_all_keys = content_store.list_level(
238
- root, list_only=True)
238
+ root, list_only=True, dataset=dataset)
239
239
  except TransportError as e:
240
240
  if e.status == 403:
241
241
  # OSS fallback requires ListObjects permission, which is only granted
@@ -293,7 +293,7 @@ def parse_dataset_level(
293
293
  group_dir = f"{root}{name}/"
294
294
  try:
295
295
  level2_names, level2_keys = content_store.list_level(
296
- group_dir, list_only=True)
296
+ group_dir, list_only=True, dataset=dataset)
297
297
  except Exception: # noqa: BLE001
298
298
  level2_names, level2_keys = [], []
299
299
  l2_groups = _group_level_names(level2_names)
@@ -316,7 +316,7 @@ def parse_dataset_level(
316
316
  split_dir = f"{group_dir}{s_name}/"
317
317
  try:
318
318
  _s_dirs, s_keys = content_store.list_level(
319
- split_dir, list_only=True)
319
+ split_dir, list_only=True, dataset=dataset)
320
320
  instance_count += len(_direct_json_keys(s_keys, split_dir))
321
321
  except Exception: # noqa: BLE001
322
322
  pass # best-effort
@@ -540,7 +540,8 @@ def build_synthetic_instances(
540
540
  """
541
541
  metadata_prefix = _metadata_prefix(dataset, version, split, oss_prefix)
542
542
 
543
- all_keys = content_store.list_objects(metadata_prefix, list_only=True)
543
+ all_keys = content_store.list_objects(
544
+ metadata_prefix, list_only=True, dataset=dataset)
544
545
  json_keys = _direct_json_keys(all_keys, metadata_prefix)
545
546
 
546
547
  instances: list[DatasetInstance] = []
@@ -585,6 +586,62 @@ def _format_from_key(key: str) -> str:
585
586
  return ""
586
587
 
587
588
 
589
+ def discover_instance_file_tree(
590
+ content_store,
591
+ dataset: str,
592
+ version: str,
593
+ instance_id: str,
594
+ split: str | None,
595
+ *,
596
+ oss_prefix: str,
597
+ ) -> tuple[str, list[tuple[str, str]]] | None:
598
+ """用 LIST 枚举实例的完整文件树(发现模式,list-only STS)。
599
+
600
+ Returns ``(metadata_key, files)`` where *files* is a list of
601
+ ``(oss_key, rel_path)`` tuples covering ``{split}-assets/{id}/`` 与
602
+ ``{split}-report/{id}/`` 下的所有对象。*rel_path* 相对实例根(assets
603
+ 文件为原相对路径,report 文件加 ``report/`` 前缀避免与 assets 同名冲突)。
604
+
605
+ 实例不存在(metadata .json 的精确 key LIST 未命中)时返回 ``None``。
606
+ """
607
+ if version:
608
+ metadata_key = P.metadata_key(
609
+ dataset, version, instance_id, split, prefix=oss_prefix)
610
+ else:
611
+ l1, l2 = P.split_dataset(dataset)
612
+ base = f"{oss_prefix}/{l1}/{l2}"
613
+ metadata_key = (f"{base}/{split}/{instance_id}.json"
614
+ if split and split != "default"
615
+ else f"{base}/{instance_id}.json")
616
+
617
+ # 存在性判定用 LIST(list-only STS 无 GetObject,不能 HEAD)。
618
+ # 不能用「prefix=精确 key」的 LIST:OSS 的 prefix 过滤是字符串前缀语义,
619
+ # 且 RAM 条件只放行 {parent}/*(精确 key 会带出同前缀兄弟对象,见 apiserver
620
+ # sts/client.go),改为父目录 LIST + 客户端成员判断。
621
+ parent = metadata_key.rsplit("/", 1)[0] + "/"
622
+ try:
623
+ if metadata_key not in content_store.list_objects(
624
+ parent, list_only=True, dataset=dataset):
625
+ return None
626
+ except Exception: # noqa: BLE001
627
+ return None
628
+
629
+ files: list[tuple[str, str]] = []
630
+ for suffix, rel_prefix in (("assets", ""), ("report", "report/")):
631
+ dir_prefix = _instance_dir(dataset, version, split, instance_id,
632
+ suffix, oss_prefix)
633
+ try:
634
+ for k in content_store.list_objects(
635
+ dir_prefix, list_only=True, dataset=dataset):
636
+ if k.endswith("/") or not k.startswith(dir_prefix):
637
+ continue # 目录标记 / 异常 key
638
+ rel = rel_prefix + k[len(dir_prefix):]
639
+ files.append((k, rel))
640
+ except Exception: # noqa: BLE001
641
+ pass # best-effort:无 assets/report 目录时忽略
642
+ return metadata_key, files
643
+
644
+
588
645
  def _instance_manifest(
589
646
  content_store,
590
647
  dataset: str,
@@ -600,15 +657,13 @@ def _instance_manifest(
600
657
  发现模式(list-only STS)无 GetObject,读不到 metadata JSON 内容,但目录
601
658
  结构可通过 LIST 完整呈现——恰好满足「查看实例的目录文件结构」需求。
602
659
  """
603
- keys = [metadata_key]
604
- for suffix in ("assets", "report"):
605
- try:
606
- keys.extend(k for k in content_store.list_objects(
607
- _instance_dir(dataset, version, split, instance_id,
608
- suffix, oss_prefix),
609
- list_only=True) if not k.endswith("/"))
610
- except Exception: # noqa: BLE001
611
- pass # best-effort:无 assets/report 目录时忽略
660
+ tree = discover_instance_file_tree(
661
+ content_store, dataset, version, instance_id, split,
662
+ oss_prefix=oss_prefix)
663
+ if tree is None:
664
+ return []
665
+ _meta_key, files = tree
666
+ keys = [_meta_key] + [k for k, _ in files]
612
667
  bucket = getattr(content_store, "bucket_name", "") or ""
613
668
  return [
614
669
  ManifestEntry(
@@ -657,8 +712,12 @@ def build_synthetic_instance(
657
712
  # 存在性判定用 LIST(list_only=True 仅需 ListObjects)而非 HEAD——
658
713
  # list-only STS 无 oss:GetObject,object_exists 的 HEAD 会在 STS 签发或
659
714
  # OSS 侧被拒,导致已存在的实例恒被误判为不存在。
715
+ # 不能用「prefix=精确 key」的 LIST(OSS 字符串前缀语义会带出兄弟对象,
716
+ # RAM 条件也只放行父目录形态),改父目录 LIST + 客户端成员判断。
717
+ parent = key.rsplit("/", 1)[0] + "/"
660
718
  try:
661
- keys = content_store.list_objects(key, list_only=True)
719
+ keys = content_store.list_objects(
720
+ parent, list_only=True, dataset=dataset)
662
721
  except Exception: # noqa: BLE001
663
722
  return None
664
723
 
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: instance-repo
3
- Version: 1.3.2.dev5
3
+ Version: 1.3.5.dev0
4
4
  Summary: InstanceRepo SDK — 评测 Instance 统一存储客户端(SDK-only;控制面由 apiserver 承载)
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
7
- Requires-Dist: irepo-artifact<0.2.0,>=0.1.1
7
+ Requires-Dist: irepo-artifact<0.3.0,>=0.1.1
8
8
  Requires-Dist: tomli>=1.1.0; python_version < "3.11"
9
9
  Provides-Extra: oss
10
10
  Requires-Dist: oss2>=2.19; extra == "oss"
@@ -1,4 +1,4 @@
1
- irepo-artifact<0.2.0,>=0.1.1
1
+ irepo-artifact<0.3.0,>=0.1.1
2
2
 
3
3
  [:python_version < "3.11"]
4
4
  tomli>=1.1.0
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "instance-repo"
7
- version = "1.3.2.dev5"
7
+ version = "1.3.5.dev0"
8
8
  description = "InstanceRepo SDK — 评测 Instance 统一存储客户端(SDK-only;控制面由 apiserver 承载)"
9
9
  requires-python = ">=3.10"
10
10
  readme = "README.md"
11
11
  # tomllib 是 3.11 内置;3.10 上回退到 tomli(见 validate.py),故按 Python 版本条件依赖。
12
12
  dependencies = [
13
- "irepo-artifact>=0.1.1,<0.2.0",
13
+ "irepo-artifact>=0.1.1,<0.3.0",
14
14
  "tomli>=1.1.0; python_version < '3.11'",
15
15
  ]
16
16