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.
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/PKG-INFO +1 -1
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/__init__.py +5 -1
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/clients/datasets.py +3 -1
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/clients/instances.py +449 -40
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/content.py +38 -2
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/models.py +79 -0
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/repo.py +61 -8
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/store/oss.py +1 -1
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/transport.py +20 -7
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo.egg-info/PKG-INFO +1 -1
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/pyproject.toml +1 -1
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/README.md +0 -0
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/_routing.py +0 -0
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/_site_defaults.py +0 -0
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/artifacts.py +0 -0
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/bootstrap.py +0 -0
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/cache.py +0 -0
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/cli.py +0 -0
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/clients/__init__.py +0 -0
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/clients/benchmarks.py +0 -0
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/clients/config.py +0 -0
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/clients/images.py +0 -0
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/clients/reports.py +0 -0
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/clients/scaffold.py +0 -0
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/clients/splits.py +0 -0
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/clients/versions.py +0 -0
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/clients/workflows.py +0 -0
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/concurrency.py +0 -0
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/endpoints.py +0 -0
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/errors.py +0 -0
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/image.py +0 -0
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/layout.py +0 -0
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/loader.py +0 -0
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/paths.py +0 -0
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/release.py +0 -0
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/retry.py +0 -0
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/store/__init__.py +0 -0
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/store/acr.py +0 -0
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/store/base.py +0 -0
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/store/oss_discovery.py +0 -0
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/store/registry.py +0 -0
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/tasktoml.py +0 -0
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo/validate.py +0 -0
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo.egg-info/SOURCES.txt +0 -0
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo.egg-info/dependency_links.txt +0 -0
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo.egg-info/entry_points.txt +0 -0
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo.egg-info/requires.txt +0 -0
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo.egg-info/top_level.txt +0 -0
- {instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/setup.cfg +0 -0
|
@@ -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.
|
|
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. ``
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
858
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1190
|
+
if (e.biz_code in (92001, 92023) and fallback_to_oss
|
|
897
1191
|
and self._content is not None):
|
|
898
|
-
# dataset 未注册
|
|
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
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1103
|
-
f"
|
|
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
|
|
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(
|
|
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,
|
|
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":[
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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, *,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{instance_repo-1.3.3.dev2 → instance_repo-1.3.5}/instance_repo.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|