instance-repo 1.3.5.dev0__tar.gz → 1.3.5.dev2__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.5.dev0 → instance_repo-1.3.5.dev2}/PKG-INFO +1 -1
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo/__init__.py +3 -1
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo/clients/instances.py +354 -97
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo/content.py +38 -2
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo/repo.py +60 -8
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo.egg-info/PKG-INFO +1 -1
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/pyproject.toml +1 -1
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/README.md +0 -0
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo/_routing.py +0 -0
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo/_site_defaults.py +0 -0
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo/artifacts.py +0 -0
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo/bootstrap.py +0 -0
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo/cache.py +0 -0
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo/cli.py +0 -0
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo/clients/__init__.py +0 -0
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo/clients/benchmarks.py +0 -0
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo/clients/config.py +0 -0
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo/clients/datasets.py +0 -0
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo/clients/images.py +0 -0
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo/clients/reports.py +0 -0
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo/clients/scaffold.py +0 -0
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo/clients/splits.py +0 -0
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo/clients/versions.py +0 -0
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo/clients/workflows.py +0 -0
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo/concurrency.py +0 -0
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo/endpoints.py +0 -0
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo/errors.py +0 -0
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo/image.py +0 -0
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo/layout.py +0 -0
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo/loader.py +0 -0
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo/models.py +0 -0
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo/paths.py +0 -0
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo/release.py +0 -0
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo/retry.py +0 -0
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo/store/__init__.py +0 -0
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo/store/acr.py +0 -0
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo/store/base.py +0 -0
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo/store/oss.py +0 -0
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo/store/oss_discovery.py +0 -0
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo/store/registry.py +0 -0
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo/tasktoml.py +0 -0
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo/transport.py +0 -0
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo/validate.py +0 -0
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo.egg-info/SOURCES.txt +0 -0
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo.egg-info/dependency_links.txt +0 -0
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo.egg-info/entry_points.txt +0 -0
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo.egg-info/requires.txt +0 -0
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo.egg-info/top_level.txt +0 -0
- {instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/setup.cfg +0 -0
|
@@ -5,6 +5,7 @@
|
|
|
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,
|
|
@@ -19,7 +20,7 @@ from .errors import (
|
|
|
19
20
|
TransportError,
|
|
20
21
|
)
|
|
21
22
|
|
|
22
|
-
__version__ = "1.3.5.
|
|
23
|
+
__version__ = "1.3.5.dev2"
|
|
23
24
|
|
|
24
25
|
__all__ = [
|
|
25
26
|
"Repo", "load_instances",
|
|
@@ -28,6 +29,7 @@ __all__ = [
|
|
|
28
29
|
"RolloutReport", "RolloutJob", "ScaffoldConfig",
|
|
29
30
|
"DatasetObjectDirectory", "DatasetObjectFile", "DatasetObjectListResult",
|
|
30
31
|
"canonical_json", "parse_feedbacks", "append_feedback",
|
|
32
|
+
"compute_source_hash",
|
|
31
33
|
"InstanceRepoError", "LayoutError", "SchemaError", "OwnerRefMissing",
|
|
32
34
|
"ImageMissing", "Immutable", "DigestMismatch", "Forbidden", "NotVisible",
|
|
33
35
|
"TargetNotAllowed", "NetworkDenied", "CredExpired",
|
|
@@ -389,17 +389,18 @@ class InstancesClient:
|
|
|
389
389
|
def list_objects(self, dataset: str, instance_id: str,
|
|
390
390
|
split: str | None = None, *,
|
|
391
391
|
version: str = "",
|
|
392
|
-
|
|
392
|
+
path: str | None = None,
|
|
393
393
|
page_size: int = 100,
|
|
394
|
-
|
|
394
|
+
max_pages: int = 50,
|
|
395
395
|
environment: str | None = None,
|
|
396
396
|
metadata_model: str | None = None,
|
|
397
397
|
dataset_split_id: str | None = None
|
|
398
398
|
) -> "DatasetObjectListResult":
|
|
399
|
-
"""
|
|
399
|
+
"""列举实例资产目录下的文件/子目录(自动翻页)。
|
|
400
400
|
|
|
401
401
|
调用控制面 ``GET /apis/v1/datasets/objects``,返回指定实例的
|
|
402
402
|
``{split}-assets/{instance_id}/`` 路径下的目录和文件列表。
|
|
403
|
+
自动翻页合并所有结果,直到 ``truncated=False`` 或达到 ``max_pages``。
|
|
403
404
|
|
|
404
405
|
Parameters
|
|
405
406
|
----------
|
|
@@ -411,12 +412,14 @@ class InstancesClient:
|
|
|
411
412
|
Split 名称;为 None 时使用 ``"default"``。
|
|
412
413
|
version : str
|
|
413
414
|
版本号,默认空串(split_first 布局下可为空)。
|
|
414
|
-
|
|
415
|
-
|
|
415
|
+
path : str, optional
|
|
416
|
+
子目录路径过滤(相对于实例资产根目录)。
|
|
417
|
+
为 None 或空串时列举实例根目录下全部内容;
|
|
418
|
+
非空时仅列举该路径下的内容(如 ``"solution"`` 或 ``"tests/sealed"``)。
|
|
416
419
|
page_size : int
|
|
417
420
|
每页大小,默认 100。
|
|
418
|
-
|
|
419
|
-
|
|
421
|
+
max_pages : int
|
|
422
|
+
最大翻页数(安全阀,防无限循环),默认 50。
|
|
420
423
|
environment : str, optional
|
|
421
424
|
``online`` / ``pre``,None 时用 profile 派生值。
|
|
422
425
|
metadata_model : str, optional
|
|
@@ -427,7 +430,7 @@ class InstancesClient:
|
|
|
427
430
|
Returns
|
|
428
431
|
-------
|
|
429
432
|
DatasetObjectListResult
|
|
430
|
-
|
|
433
|
+
合并后的完整结果。``truncated`` 为 True 表示因 ``max_pages`` 截断。
|
|
431
434
|
"""
|
|
432
435
|
from urllib.parse import quote
|
|
433
436
|
from ..models import DatasetObjectListResult
|
|
@@ -439,79 +442,43 @@ class InstancesClient:
|
|
|
439
442
|
split = (split or "default").strip()
|
|
440
443
|
# 计算实例资产根前缀:{split}-assets/{instance_id}/
|
|
441
444
|
instance_asset_prefix = f"{split}-assets/{instance_id.strip()}"
|
|
442
|
-
if
|
|
443
|
-
full_prefix = f"{instance_asset_prefix}/{
|
|
445
|
+
if path:
|
|
446
|
+
full_prefix = f"{instance_asset_prefix}/{path.strip().strip('/')}"
|
|
444
447
|
else:
|
|
445
448
|
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
449
|
# environment / metadata_model:优先显式值,其次 profile 派生
|
|
459
450
|
env = (environment or "").strip()
|
|
460
451
|
if not env:
|
|
461
452
|
env = (getattr(self._p, "dataset_environment", "") or "").strip()
|
|
462
|
-
if env:
|
|
463
|
-
params.append(("environment", env))
|
|
464
453
|
mm = (metadata_model or "").strip()
|
|
465
454
|
if not mm:
|
|
466
455
|
mm = (getattr(self._p, "metadata_model", "") or "").strip()
|
|
467
|
-
|
|
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
|
|
456
|
+
# 自动翻页
|
|
503
457
|
all_dirs: list = []
|
|
504
458
|
all_files: list = []
|
|
505
459
|
tk: str | None = None
|
|
506
460
|
result = DatasetObjectListResult()
|
|
507
461
|
for _ in range(max_pages):
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
462
|
+
params: list[tuple[str, str]] = [
|
|
463
|
+
("dataset_name", dataset.strip()),
|
|
464
|
+
("split", split),
|
|
465
|
+
("prefix", full_prefix),
|
|
466
|
+
("page_size", str(int(page_size)))]
|
|
467
|
+
if (version or "").strip():
|
|
468
|
+
params.append(("version", version.strip()))
|
|
469
|
+
if (dataset_split_id or "").strip():
|
|
470
|
+
params.append(("dataset_split_id", dataset_split_id.strip()))
|
|
471
|
+
if tk:
|
|
472
|
+
params.append(("token", tk))
|
|
473
|
+
if env:
|
|
474
|
+
params.append(("environment", env))
|
|
475
|
+
if mm:
|
|
476
|
+
params.append(("metadata_model", mm))
|
|
477
|
+
qs = "&".join(f"{k}={quote(v, safe='')}" for k, v in params)
|
|
478
|
+
data = self._t.get(f"/apis/v1/datasets/objects?{qs}")
|
|
479
|
+
if not isinstance(data, dict):
|
|
480
|
+
break
|
|
481
|
+
page = DatasetObjectListResult.from_dict(data)
|
|
515
482
|
all_dirs.extend(page.directories)
|
|
516
483
|
all_files.extend(page.files)
|
|
517
484
|
result = page # 保留最后一页的 dataset/version/prefix 元信息
|
|
@@ -520,9 +487,65 @@ class InstancesClient:
|
|
|
520
487
|
tk = page.next_token
|
|
521
488
|
result.directories = all_dirs
|
|
522
489
|
result.files = all_files
|
|
523
|
-
# 若因 max_pages 截断,truncated 保持 True + 保留 next_token
|
|
524
490
|
return result
|
|
525
491
|
|
|
492
|
+
# ── 变更检测辅助 ────────────────────────────────────────────────
|
|
493
|
+
|
|
494
|
+
def list_content_hashes(self, dataset: str, version: str = "",
|
|
495
|
+
split: str | None = None, *,
|
|
496
|
+
environment: str | None = None,
|
|
497
|
+
metadata_model: str | None = None
|
|
498
|
+
) -> dict[str, str]:
|
|
499
|
+
"""批量查询远端实例的 ``source_hash``(存在 ``extend_meta`` 中)。
|
|
500
|
+
|
|
501
|
+
返回 ``{instance_id: source_hash}`` 字典。用于 ``skip_unchanged``
|
|
502
|
+
变更检测:与本地 ``compute_source_hash`` 或自定义哈希比对,
|
|
503
|
+
匹配则跳过 pack + upload。
|
|
504
|
+
"""
|
|
505
|
+
overrides = _registry_overrides(environment, metadata_model)
|
|
506
|
+
instances = self.list(dataset, version, split, **overrides)
|
|
507
|
+
result: dict[str, str] = {}
|
|
508
|
+
for inst in instances:
|
|
509
|
+
h = (inst.extend_meta or {}).get("source_hash", "")
|
|
510
|
+
if h:
|
|
511
|
+
result[inst.instance_id] = h
|
|
512
|
+
return result
|
|
513
|
+
|
|
514
|
+
# ── 实例删除 ────────────────────────────────────────────────────
|
|
515
|
+
|
|
516
|
+
def delete(self, dataset: str, version: str, instance_id: str, *,
|
|
517
|
+
split: str | None = None,
|
|
518
|
+
environment: str | None = None,
|
|
519
|
+
metadata_model: str | None = None) -> None:
|
|
520
|
+
"""删除单个实例的元数据记录(``DELETE /datasets/instances/detail``)。
|
|
521
|
+
|
|
522
|
+
仅删除 MongoDB 记录,不删除 OSS 对象。后续 ingest 扫描可能重建该记录。
|
|
523
|
+
"""
|
|
524
|
+
from urllib.parse import quote
|
|
525
|
+
if not (dataset or "").strip():
|
|
526
|
+
raise SchemaError("dataset is required for instances.delete")
|
|
527
|
+
if not (instance_id or "").strip():
|
|
528
|
+
raise SchemaError("instance_id is required for instances.delete")
|
|
529
|
+
params: list[tuple[str, str]] = [
|
|
530
|
+
("dataset_name", dataset.strip()),
|
|
531
|
+
("instance_id", instance_id.strip())]
|
|
532
|
+
if (version or "").strip():
|
|
533
|
+
params.append(("version", version.strip()))
|
|
534
|
+
if (split or "").strip():
|
|
535
|
+
params.append(("split", (split or "").strip()))
|
|
536
|
+
env = (environment or "").strip()
|
|
537
|
+
if not env:
|
|
538
|
+
env = (getattr(self._p, "dataset_environment", "") or "").strip()
|
|
539
|
+
if env:
|
|
540
|
+
params.append(("environment", env))
|
|
541
|
+
mm = (metadata_model or "").strip()
|
|
542
|
+
if not mm:
|
|
543
|
+
mm = (getattr(self._p, "metadata_model", "") or "").strip()
|
|
544
|
+
if mm:
|
|
545
|
+
params.append(("metadata_model", mm))
|
|
546
|
+
qs = "&".join(f"{k}={quote(v, safe='')}" for k, v in params)
|
|
547
|
+
self._t.delete(f"/apis/v1/datasets/instances/detail?{qs}")
|
|
548
|
+
|
|
526
549
|
def update(self, dataset: str, version: str, instance_id: str, *,
|
|
527
550
|
split: str | None = None, dataset_split_id: str | None = None,
|
|
528
551
|
manifest: list[dict] | None = None,
|
|
@@ -601,7 +624,10 @@ class InstancesClient:
|
|
|
601
624
|
split: str = "default", push_image: bool = True,
|
|
602
625
|
overwrite: bool = False, register: bool = True,
|
|
603
626
|
verify: bool = True,
|
|
604
|
-
image_release_dst_acr: str | None = None
|
|
627
|
+
image_release_dst_acr: str | None = None,
|
|
628
|
+
content_hash: str | None = None,
|
|
629
|
+
skip_unchanged: bool = False,
|
|
630
|
+
compress_level: int | None = None) -> PushResult:
|
|
605
631
|
"""推一个实例:自动检测格式(目录→harbor,.json→swe),validate → 上传。
|
|
606
632
|
|
|
607
633
|
harbor: validate → (可选)推镜像 → 上传 OSS(content.tgz + {id}.json)。
|
|
@@ -615,6 +641,14 @@ class InstancesClient:
|
|
|
615
641
|
verify=True(默认)在真正发生上传后回读校验:确认 OSS 对象确实可 HEAD 到。
|
|
616
642
|
|
|
617
643
|
image_release_dst_acr(可选):目标(prod)ACR host(仅 harbor 格式有效)。
|
|
644
|
+
|
|
645
|
+
content_hash(可选):调用方提供的内容标识(如 git tree id),
|
|
646
|
+
存入 ``extend_meta.source_hash``,供后续 ``skip_unchanged`` 比对。
|
|
647
|
+
|
|
648
|
+
skip_unchanged(可选):若为 True 且 content_hash 非空,先查远端
|
|
649
|
+
``extend_meta.source_hash``,匹配则跳过 pack + upload。
|
|
650
|
+
|
|
651
|
+
compress_level(可选):gzip 压缩级别(1-9),传 None 用默认值。
|
|
618
652
|
"""
|
|
619
653
|
local_path = Path(local_path)
|
|
620
654
|
if register and self._ingester is None:
|
|
@@ -689,14 +723,38 @@ class InstancesClient:
|
|
|
689
723
|
)
|
|
690
724
|
uid = instance_id
|
|
691
725
|
|
|
726
|
+
# 变更检测:skip_unchanged=True 时,若 content_hash 与远端一致,跳过 pack+upload。
|
|
727
|
+
if skip_unchanged and content_hash:
|
|
728
|
+
try:
|
|
729
|
+
remote = self._reg.get(dataset, version, instance_id, split)
|
|
730
|
+
if remote is not None:
|
|
731
|
+
remote_hash = (remote.extend_meta or {}).get("source_hash", "")
|
|
732
|
+
if remote_hash == content_hash:
|
|
733
|
+
logger.info("push %s: skip (source_hash match)", instance_id)
|
|
734
|
+
return PushResult(
|
|
735
|
+
instance_id=instance_id, uploaded=False,
|
|
736
|
+
skipped=True, image_pushed=False,
|
|
737
|
+
docker_image=docker_ref or None,
|
|
738
|
+
digests={}, ingest=None)
|
|
739
|
+
except (TransportError, Exception): # noqa: BLE001
|
|
740
|
+
pass # 查询失败不阻断,继续正常 push
|
|
741
|
+
# 将 content_hash 存入 extend_meta,供下次 skip_unchanged 比对
|
|
742
|
+
if content_hash:
|
|
743
|
+
extend_meta["source_hash"] = content_hash
|
|
744
|
+
meta.extend_meta = extend_meta
|
|
745
|
+
|
|
692
746
|
# 打包 + 构造 manifest(本地)→ register(pending) → 推 ACR → 上传 OSS。
|
|
693
747
|
# register 需要 manifest:apiserver 的 hasFile 校验要求至少一条
|
|
694
748
|
# content_type=file 项,manifest 缺失时 register 被 92005 拒(曾导致
|
|
695
749
|
# custom/harbor 主路径 push(register=True) 失败)。register 仍先于任何
|
|
696
750
|
# 远程写(元数据先行),manifest 在本地打包时即可算出 digest。
|
|
751
|
+
import time as _time
|
|
752
|
+
_t0 = _time.monotonic()
|
|
697
753
|
with tempfile.TemporaryDirectory(prefix="irepo_") as td:
|
|
698
754
|
tgz = Path(td) / "content.tgz"
|
|
699
|
-
digest = pack(local_path, tgz, exclude_image_tar=True
|
|
755
|
+
digest = pack(local_path, tgz, exclude_image_tar=True,
|
|
756
|
+
compresslevel=compress_level)
|
|
757
|
+
_pack_elapsed = _time.monotonic() - _t0
|
|
700
758
|
meta.manifest = _manifest(self._p.oss_bucket, dataset, version,
|
|
701
759
|
instance_id, split, digest, lay)
|
|
702
760
|
|
|
@@ -712,6 +770,11 @@ class InstancesClient:
|
|
|
712
770
|
meta.to_canonical_json(), overwrite=overwrite,
|
|
713
771
|
layout=lay)
|
|
714
772
|
skipped = bool(put.get("skipped"))
|
|
773
|
+
_upload_elapsed = _time.monotonic() - _t0 - _pack_elapsed
|
|
774
|
+
_tgz_size = tgz.stat().st_size if not skipped else 0
|
|
775
|
+
logger.info("push %s: pack=%.1fs (%d bytes) upload=%.1fs skipped=%s",
|
|
776
|
+
instance_id, _pack_elapsed, _tgz_size,
|
|
777
|
+
_upload_elapsed, skipped)
|
|
715
778
|
# 回读校验(防静默成功):只在真正发生上传时校验对象已落库;镜像推送后校验可 inspect。
|
|
716
779
|
if verify:
|
|
717
780
|
if not skipped and not self._content.exists(
|
|
@@ -946,7 +1009,13 @@ class InstancesClient:
|
|
|
946
1009
|
overwrite: bool = False, register: bool = False,
|
|
947
1010
|
verify: bool = True,
|
|
948
1011
|
concurrency: int = 8, continue_on_error: bool = False,
|
|
949
|
-
image_release_dst_acr: str | None = None
|
|
1012
|
+
image_release_dst_acr: str | None = None,
|
|
1013
|
+
skip_unchanged: bool = False,
|
|
1014
|
+
hash_func: "Callable | None" = None,
|
|
1015
|
+
content_hashes: dict[str, str] | None = None,
|
|
1016
|
+
progress_callback: "Callable | None" = None,
|
|
1017
|
+
executor=None,
|
|
1018
|
+
compress_level: int | None = None
|
|
950
1019
|
) -> list:
|
|
951
1020
|
"""并发推一批实例(默认 register=False:只传 OSS,元数据随后由 ingest 扫描入库)。
|
|
952
1021
|
|
|
@@ -963,38 +1032,113 @@ class InstancesClient:
|
|
|
963
1032
|
``owner_reference`` 整批共用同一来源引用(批量交付通常同源;需要逐实例不同来源时
|
|
964
1033
|
请自行循环 ``push``)。CLI 的 push / push-many / deliver 都把它设为必填——交付
|
|
965
1034
|
路径必须能追溯上游来源。
|
|
1035
|
+
|
|
1036
|
+
``skip_unchanged=True``:自动比对本地源文件哈希与远端 ``extend_meta.source_hash``,
|
|
1037
|
+
匹配则跳过 pack+upload。``hash_func`` 可自定义哈希函数(默认用
|
|
1038
|
+
``content.compute_source_hash``);``content_hashes`` 可直接提供
|
|
1039
|
+
``{instance_id: hash}`` 映射跳过本地计算。
|
|
1040
|
+
|
|
1041
|
+
``progress_callback(completed, total, result_or_error)``:每个 case 完成时调用。
|
|
1042
|
+
|
|
1043
|
+
``executor``:外部 ``ThreadPoolExecutor``,传入时复用(不关闭);
|
|
1044
|
+
为 None 时内部新建。
|
|
1045
|
+
|
|
1046
|
+
``compress_level``:gzip 压缩级别(1-9),透传给 ``pack()``。
|
|
966
1047
|
"""
|
|
967
1048
|
dirs = list(local_dirs)
|
|
1049
|
+
|
|
1050
|
+
# ── skip_unchanged:哈希比对 + 过滤 ──
|
|
1051
|
+
skipped_results: list[PushResult] = []
|
|
1052
|
+
hashes_map: dict[str, str] = dict(content_hashes) if content_hashes else {}
|
|
1053
|
+
if skip_unchanged and dirs:
|
|
1054
|
+
from ..content import compute_source_hash as _default_hash
|
|
1055
|
+
_hash_fn = hash_func or _default_hash
|
|
1056
|
+
# 1. 计算本地哈希(未提供的)
|
|
1057
|
+
for d in dirs:
|
|
1058
|
+
iid = Path(d).name
|
|
1059
|
+
if iid not in hashes_map:
|
|
1060
|
+
try:
|
|
1061
|
+
hashes_map[iid] = _hash_fn(str(d))
|
|
1062
|
+
except Exception: # noqa: BLE001
|
|
1063
|
+
pass # 哈希失败不阻断,视为"变化"
|
|
1064
|
+
# 2. 获取远端哈希
|
|
1065
|
+
try:
|
|
1066
|
+
remote_hashes = self.list_content_hashes(
|
|
1067
|
+
dataset, version, split)
|
|
1068
|
+
except Exception: # noqa: BLE001
|
|
1069
|
+
remote_hashes = {}
|
|
1070
|
+
# 3. 过滤:未变化的生成 skip 结果并从 dirs 移除
|
|
1071
|
+
unchanged_ids: set[str] = set()
|
|
1072
|
+
for d in dirs:
|
|
1073
|
+
iid = Path(d).name
|
|
1074
|
+
local_h = hashes_map.get(iid, "")
|
|
1075
|
+
remote_h = remote_hashes.get(iid, "")
|
|
1076
|
+
if local_h and local_h == remote_h:
|
|
1077
|
+
unchanged_ids.add(iid)
|
|
1078
|
+
skipped_results.append(PushResult(
|
|
1079
|
+
instance_id=iid, uploaded=False, skipped=True,
|
|
1080
|
+
image_pushed=False, docker_image=None,
|
|
1081
|
+
digests={}, ingest=None))
|
|
1082
|
+
if unchanged_ids:
|
|
1083
|
+
logger.info("push_many: %d/%d unchanged (skipped)",
|
|
1084
|
+
len(unchanged_ids), len(dirs))
|
|
1085
|
+
dirs = [d for d in dirs if Path(d).name not in unchanged_ids]
|
|
1086
|
+
|
|
968
1087
|
warm = getattr(self._content, "prewarm", None)
|
|
969
1088
|
if warm is not None:
|
|
970
|
-
# prewarm(prefix, dataset) 与 Go Prewarm(prefix, dataset) 对齐;
|
|
971
|
-
# dataset 用于 Dataset RBAC 签发(缺失时 apiserver 回 94020)。
|
|
972
|
-
# sts_prefix 是 dataset 级,两模型一致,但仍统一经 layout 取,避免再触 paths.*。
|
|
973
1089
|
sts_prefix = self._layout().sts_prefix(dataset)
|
|
974
1090
|
try:
|
|
975
1091
|
warm(sts_prefix, dataset)
|
|
976
1092
|
except TypeError:
|
|
977
|
-
# 向后兼容旧签名 prewarm(prefix)(用户自定义 store / 旧版 fake)。
|
|
978
1093
|
warm(sts_prefix)
|
|
979
|
-
# 推镜像时同样预热 ACR 凭据缓存(否则首轮各 worker 会并发重复签同一 host)
|
|
980
1094
|
if push_image and getattr(self._p, "acr_host", None):
|
|
981
|
-
# 签名恒用 apiserver 登记的 host(acr_host,VPC);token 实例级,
|
|
982
|
-
# 对公网 endpoint 同样有效(prefer_public 时连接走公网、签名仍走这里)。
|
|
983
1095
|
try:
|
|
984
1096
|
self._t.get_acr_credentials(self._p.acr_host)
|
|
985
|
-
except Exception: # noqa: BLE001
|
|
1097
|
+
except Exception: # noqa: BLE001
|
|
986
1098
|
pass
|
|
987
1099
|
|
|
988
1100
|
def _one(d):
|
|
989
|
-
|
|
1101
|
+
iid = Path(d).name
|
|
1102
|
+
ch = hashes_map.get(iid)
|
|
990
1103
|
return self.push(d, dataset, version,
|
|
991
1104
|
owner_reference=owner_reference, split=split,
|
|
992
1105
|
push_image=push_image, overwrite=overwrite,
|
|
993
1106
|
register=False, verify=verify,
|
|
994
|
-
image_release_dst_acr=image_release_dst_acr
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
1107
|
+
image_release_dst_acr=image_release_dst_acr,
|
|
1108
|
+
content_hash=ch,
|
|
1109
|
+
compress_level=compress_level)
|
|
1110
|
+
|
|
1111
|
+
# ── 并发执行 + 进度回调 ──
|
|
1112
|
+
total = len(dirs)
|
|
1113
|
+
completed = [0] # mutable counter for closure
|
|
1114
|
+
lock = __import__("threading").Lock()
|
|
1115
|
+
|
|
1116
|
+
def _wrapped(d):
|
|
1117
|
+
result = _one(d)
|
|
1118
|
+
with lock:
|
|
1119
|
+
completed[0] += 1
|
|
1120
|
+
if progress_callback:
|
|
1121
|
+
try:
|
|
1122
|
+
progress_callback(completed[0], total, result)
|
|
1123
|
+
except Exception: # noqa: BLE001
|
|
1124
|
+
pass
|
|
1125
|
+
return result
|
|
1126
|
+
|
|
1127
|
+
if executor is not None:
|
|
1128
|
+
# 外部线程池:submit 并收集结果(保持输入顺序)
|
|
1129
|
+
futures = [executor.submit(_wrapped, d) for d in dirs]
|
|
1130
|
+
results = []
|
|
1131
|
+
for f in futures:
|
|
1132
|
+
r = f.result()
|
|
1133
|
+
results.append(r)
|
|
1134
|
+
if not continue_on_error:
|
|
1135
|
+
from ..concurrency import ItemError
|
|
1136
|
+
for r in results:
|
|
1137
|
+
if isinstance(r, Exception):
|
|
1138
|
+
raise ItemError(r)
|
|
1139
|
+
else:
|
|
1140
|
+
results = map_concurrent(_wrapped, dirs, concurrency=concurrency,
|
|
1141
|
+
return_exceptions=continue_on_error)
|
|
998
1142
|
# Close retired OSS buckets after the batch to release connection pools.
|
|
999
1143
|
# Guard for non-OSS or mock stores that don't have close_retired.
|
|
1000
1144
|
_close = getattr(self._content, "close_retired", None)
|
|
@@ -1004,11 +1148,13 @@ class InstancesClient:
|
|
|
1004
1148
|
# 这里说明整批成功;True 时对成功子集 ingest(与 deliver 语义一致)。
|
|
1005
1149
|
if register:
|
|
1006
1150
|
self._after_upload(True, dataset, version, split=split)
|
|
1007
|
-
|
|
1151
|
+
# 将 skipped 结果合并到返回列表(放在末尾,不影响 input 顺序的 pushed/failed 对应)
|
|
1152
|
+
return list(results) + skipped_results
|
|
1008
1153
|
|
|
1009
1154
|
def pull(self, dataset: str, version: str, instance_id: str, dest_dir,
|
|
1010
1155
|
split: str = "default", *, verify: bool = True,
|
|
1011
|
-
fallback_to_oss: bool = True
|
|
1156
|
+
fallback_to_oss: bool = True,
|
|
1157
|
+
path: str | None = None) -> Path:
|
|
1012
1158
|
"""下载实例内容。自动检测格式:harbor 下载 content.tgz,swe 下载 {id}.json,
|
|
1013
1159
|
custom 下载 {id}.json + assets 目录。
|
|
1014
1160
|
|
|
@@ -1020,10 +1166,16 @@ class InstancesClient:
|
|
|
1020
1166
|
3. metadata JSON 不存在 → fallback 到 harbor(下载 content.tgz)
|
|
1021
1167
|
4. dataset 未注册(92001)→ OSS 回退:list-only 枚举文件树 + 逐文件下载
|
|
1022
1168
|
(仍走 DOWNLOAD 意图:仅私网,公网下载照旧禁止)
|
|
1169
|
+
|
|
1170
|
+
``path`` 参数用于部分下载(仅 custom 和 OSS 回退格式生效):
|
|
1171
|
+
为空时下载全部内容;非空时只下载该路径前缀下的文件。
|
|
1172
|
+
harbor(content.tgz)和 swe(单 JSON)格式不支持部分下载,
|
|
1173
|
+
``path`` 会被忽略。
|
|
1023
1174
|
"""
|
|
1024
1175
|
validate_instance_id(instance_id, source="pull()")
|
|
1025
1176
|
expected = None
|
|
1026
1177
|
fmt = None # None = 未确定,需要探测
|
|
1178
|
+
dir_manifest = None # manifest 中的 directory 条目(如有)
|
|
1027
1179
|
lay = self._layout()
|
|
1028
1180
|
# 已下载的元数据字节(探测阶段下载一次,swe/custom 路径复用,避免二次网络往返)。
|
|
1029
1181
|
meta_bytes: bytes | None = None
|
|
@@ -1036,19 +1188,32 @@ class InstancesClient:
|
|
|
1036
1188
|
and self._content is not None):
|
|
1037
1189
|
# dataset 未注册 → OSS 回退下载(私网 + 系统调用方语义)
|
|
1038
1190
|
return self._pull_unregistered(
|
|
1039
|
-
dataset, version, instance_id, dest_dir, split
|
|
1191
|
+
dataset, version, instance_id, dest_dir, split,
|
|
1192
|
+
path=path)
|
|
1040
1193
|
raise
|
|
1041
1194
|
if meta is not None and meta.manifest:
|
|
1042
1195
|
has_tgz = False
|
|
1196
|
+
has_dir = False
|
|
1043
1197
|
for m in meta.manifest:
|
|
1044
1198
|
if m.content_format == "tgz":
|
|
1045
1199
|
expected = m.content_digest
|
|
1046
1200
|
has_tgz = True
|
|
1047
1201
|
break
|
|
1202
|
+
if m.content_type == "directory":
|
|
1203
|
+
has_dir = True
|
|
1204
|
+
dir_manifest = m
|
|
1048
1205
|
if has_tgz:
|
|
1049
1206
|
fmt = "harbor"
|
|
1207
|
+
elif has_dir:
|
|
1208
|
+
# manifest 同时有 json 和 directory 条目 → custom 格式
|
|
1209
|
+
# (directory 条目指向 assets 目录,需逐文件下载)。
|
|
1210
|
+
fmt = "custom"
|
|
1211
|
+
for m in meta.manifest:
|
|
1212
|
+
if m.content_format == "json":
|
|
1213
|
+
expected = m.content_digest
|
|
1214
|
+
break
|
|
1050
1215
|
else:
|
|
1051
|
-
# manifest 有 json entry 但无 tgz → swe 或 custom(需进一步探测)。
|
|
1216
|
+
# manifest 有 json entry 但无 tgz 也无 directory → swe 或 custom(需进一步探测)。
|
|
1052
1217
|
# custom 推的 manifest 由 ingest 扫描生成,与 swe 同形态;
|
|
1053
1218
|
# 唯一区分手段是读 metadata JSON 检查 metadata_model 字段。
|
|
1054
1219
|
for m in meta.manifest:
|
|
@@ -1137,9 +1302,42 @@ class InstancesClient:
|
|
|
1137
1302
|
if isinstance(meta_doc, dict) and isinstance(
|
|
1138
1303
|
meta_doc.get("assets"), list):
|
|
1139
1304
|
asset_rels = [str(x) for x in meta_doc["assets"]]
|
|
1305
|
+
# manifest 有 directory 条目但 JSON 无 assets 字段时,
|
|
1306
|
+
# 用控制面 list_objects API 递归枚举目录下的全部文件作为 asset_rels。
|
|
1307
|
+
if asset_rels is None and dir_manifest is not None:
|
|
1308
|
+
try:
|
|
1309
|
+
env = getattr(self._p, "dataset_environment", None)
|
|
1310
|
+
mm = getattr(self._p, "metadata_model", None)
|
|
1311
|
+
# 递归列举:从根开始,遇到子目录继续深入
|
|
1312
|
+
asset_rels = []
|
|
1313
|
+
stack: list[str | None] = [None] # None = 实例资产根
|
|
1314
|
+
# list_objects 返回的 f.path 包含完整前缀
|
|
1315
|
+
# (如 test-assets/105-creep-forecast/solution/solve.py),
|
|
1316
|
+
# 需要去掉 {split}-assets/{instance_id}/ 前缀得到相对路径。
|
|
1317
|
+
split_str = (split or "default").strip()
|
|
1318
|
+
assets_root = f"{split_str}-assets/{instance_id.strip()}/"
|
|
1319
|
+
while stack:
|
|
1320
|
+
cur_path = stack.pop()
|
|
1321
|
+
objs = self.list_objects(
|
|
1322
|
+
dataset, instance_id, split=split,
|
|
1323
|
+
version=version, path=cur_path,
|
|
1324
|
+
environment=env, metadata_model=mm)
|
|
1325
|
+
for f in objs.files:
|
|
1326
|
+
rel = f.path
|
|
1327
|
+
if rel.startswith(assets_root):
|
|
1328
|
+
rel = rel[len(assets_root):]
|
|
1329
|
+
asset_rels.append(rel)
|
|
1330
|
+
for d in objs.directories:
|
|
1331
|
+
# directory path 也含完整前缀,去掉后入栈
|
|
1332
|
+
d_path = d.path.rstrip("/")
|
|
1333
|
+
if d_path.startswith(assets_root):
|
|
1334
|
+
d_path = d_path[len(assets_root):]
|
|
1335
|
+
stack.append(d_path)
|
|
1336
|
+
except Exception: # noqa: BLE001
|
|
1337
|
+
asset_rels = None # 枚举失败回退到 OSS LIST
|
|
1140
1338
|
self._download_custom_assets(
|
|
1141
1339
|
dataset, instance_id, version, split, dest_dir, lay, sts_prefix,
|
|
1142
|
-
asset_rels=asset_rels)
|
|
1340
|
+
asset_rels=asset_rels, path=path)
|
|
1143
1341
|
return dest
|
|
1144
1342
|
|
|
1145
1343
|
if fmt == "swe":
|
|
@@ -1148,11 +1346,50 @@ class InstancesClient:
|
|
|
1148
1346
|
# 已在探测阶段下载,直接写入本地。
|
|
1149
1347
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
1150
1348
|
dest.write_bytes(meta_bytes)
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1349
|
+
else:
|
|
1350
|
+
self._content.get_object(
|
|
1351
|
+
idx_key, dest, expected_digest=expected,
|
|
1352
|
+
sts_prefix=sts_prefix,
|
|
1353
|
+
dataset=dataset)
|
|
1354
|
+
# swe 格式也可能附带 assets 目录(JSON + directory 混合格式)。
|
|
1355
|
+
# 尝试用 list_objects 检查并下载目录内容。
|
|
1356
|
+
try:
|
|
1357
|
+
objs = self.list_objects(
|
|
1358
|
+
dataset, instance_id, split=split,
|
|
1359
|
+
version=version,
|
|
1360
|
+
environment=getattr(self._p, "dataset_environment", None),
|
|
1361
|
+
metadata_model=getattr(self._p, "metadata_model", None))
|
|
1362
|
+
if objs.files or objs.directories:
|
|
1363
|
+
# 有 assets 内容 → 递归枚举并下载
|
|
1364
|
+
asset_rels: list[str] = []
|
|
1365
|
+
stack: list[str | None] = [None]
|
|
1366
|
+
split_str = (split or "default").strip()
|
|
1367
|
+
assets_root = f"{split_str}-assets/{instance_id.strip()}/"
|
|
1368
|
+
while stack:
|
|
1369
|
+
cur = stack.pop()
|
|
1370
|
+
page = self.list_objects(
|
|
1371
|
+
dataset, instance_id, split=split,
|
|
1372
|
+
version=version, path=cur,
|
|
1373
|
+
environment=getattr(self._p, "dataset_environment", None),
|
|
1374
|
+
metadata_model=getattr(self._p, "metadata_model", None))
|
|
1375
|
+
for f in page.files:
|
|
1376
|
+
rel = f.path
|
|
1377
|
+
if rel.startswith(assets_root):
|
|
1378
|
+
rel = rel[len(assets_root):]
|
|
1379
|
+
asset_rels.append(rel)
|
|
1380
|
+
for d in page.directories:
|
|
1381
|
+
d_path = d.path.rstrip("/")
|
|
1382
|
+
if d_path.startswith(assets_root):
|
|
1383
|
+
d_path = d_path[len(assets_root):]
|
|
1384
|
+
stack.append(d_path)
|
|
1385
|
+
if asset_rels:
|
|
1386
|
+
self._download_custom_assets(
|
|
1387
|
+
dataset, instance_id, version, split,
|
|
1388
|
+
dest_dir, lay, sts_prefix,
|
|
1389
|
+
asset_rels=asset_rels, path=path)
|
|
1390
|
+
except Exception: # noqa: BLE001
|
|
1391
|
+
pass # assets 目录不存在或枚举失败 → 仅保留 JSON
|
|
1392
|
+
return dest
|
|
1156
1393
|
|
|
1157
1394
|
# harbor:下载 content.tgz
|
|
1158
1395
|
dest = Path(dest_dir) / instance_id / "content.tgz"
|
|
@@ -1162,7 +1399,8 @@ class InstancesClient:
|
|
|
1162
1399
|
def _download_custom_assets(self, dataset: str, instance_id: str,
|
|
1163
1400
|
version: str, split: str, dest_dir, lay,
|
|
1164
1401
|
sts_prefix: str,
|
|
1165
|
-
asset_rels: list[str] | None = None
|
|
1402
|
+
asset_rels: list[str] | None = None,
|
|
1403
|
+
path: str | None = None
|
|
1166
1404
|
) -> list[Path]:
|
|
1167
1405
|
"""下载 custom 格式的资产目录(与 content.tgz 同层、同版本规则)。
|
|
1168
1406
|
|
|
@@ -1172,17 +1410,25 @@ class InstancesClient:
|
|
|
1172
1410
|
``asset_rels`` 为元数据 ``assets`` 字段记录的资产相对路径清单:非 None 时
|
|
1173
1411
|
逐对象 GetObject 还原(普通用户的 dataset-RBAC STS 不含 oss:ListObjects,
|
|
1174
1412
|
不能靠 LIST 枚举);为 None(旧数据无该字段)时回退到 LIST 枚举。
|
|
1413
|
+
|
|
1414
|
+
``path`` 为子目录过滤(相对于资产根目录):非空时仅下载该路径前缀下的文件。
|
|
1175
1415
|
"""
|
|
1176
1416
|
asset_prefix = lay.assets_dir(dataset, instance_id,
|
|
1177
1417
|
version=version, split=split)
|
|
1178
1418
|
dest_root = Path(dest_dir) / instance_id
|
|
1179
1419
|
downloaded: list[Path] = []
|
|
1420
|
+
# path 过滤:非空时只下载 rel 以 path 开头的文件
|
|
1421
|
+
path_prefix = ""
|
|
1422
|
+
if path:
|
|
1423
|
+
path_prefix = path.strip().strip("/") + "/"
|
|
1180
1424
|
|
|
1181
1425
|
if asset_rels is not None:
|
|
1182
1426
|
# 元数据记录了资产清单:逐对象下载,免 LIST。
|
|
1183
1427
|
for rel in asset_rels:
|
|
1184
1428
|
if rel.endswith("/"):
|
|
1185
1429
|
continue # 目录占位符(push 侧不生成;兼容历史数据)
|
|
1430
|
+
if path_prefix and not rel.startswith(path_prefix):
|
|
1431
|
+
continue # path 过滤
|
|
1186
1432
|
_validate_custom_asset_rel(rel)
|
|
1187
1433
|
local_path = dest_root / rel
|
|
1188
1434
|
local_path.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -1196,7 +1442,9 @@ class InstancesClient:
|
|
|
1196
1442
|
list_fn = getattr(self._content, "list_objects", None)
|
|
1197
1443
|
if list_fn is None:
|
|
1198
1444
|
return []
|
|
1199
|
-
|
|
1445
|
+
# path 非空时,LIST 直接用子前缀缩小范围
|
|
1446
|
+
list_prefix = asset_prefix + path_prefix if path_prefix else asset_prefix
|
|
1447
|
+
keys = list_fn(list_prefix)
|
|
1200
1448
|
if not keys:
|
|
1201
1449
|
return []
|
|
1202
1450
|
for key in keys:
|
|
@@ -1213,7 +1461,8 @@ class InstancesClient:
|
|
|
1213
1461
|
return downloaded
|
|
1214
1462
|
|
|
1215
1463
|
def _pull_unregistered(self, dataset: str, version: str,
|
|
1216
|
-
instance_id: str, dest_dir, split: str
|
|
1464
|
+
instance_id: str, dest_dir, split: str,
|
|
1465
|
+
path: str | None = None) -> Path:
|
|
1217
1466
|
"""未注册 dataset 的 pull 回退:list-only 枚举文件树 + 逐文件下载。
|
|
1218
1467
|
|
|
1219
1468
|
下载仍走 :meth:`~instance_repo.store.oss.OssContentStore.get_object`
|
|
@@ -1222,6 +1471,8 @@ class InstancesClient:
|
|
|
1222
1471
|
STS 语义:未注册 dataset 无 RBAC 记录,GetObject STS 仅 system 白名单
|
|
1223
1472
|
调用方可签发(非 system 调用方在签发阶段收到明确错误),与历史行为一致。
|
|
1224
1473
|
|
|
1474
|
+
``path`` 为子目录过滤(相对于实例资产根目录):非空时仅下载该路径前缀下的文件。
|
|
1475
|
+
|
|
1225
1476
|
产物结构(harbor 同层,便于 CLI 整体搬移)::
|
|
1226
1477
|
|
|
1227
1478
|
{dest_dir}/{instance_id}/{instance_id}.json # metadata
|
|
@@ -1247,7 +1498,13 @@ class InstancesClient:
|
|
|
1247
1498
|
metadata_dest = dest_root / f"{instance_id}.json"
|
|
1248
1499
|
|
|
1249
1500
|
self._content.get_object(metadata_key, metadata_dest, dataset=dataset)
|
|
1501
|
+
# path 过滤:非空时只下载 rel 以 path 开头的文件
|
|
1502
|
+
path_prefix = ""
|
|
1503
|
+
if path:
|
|
1504
|
+
path_prefix = path.strip().strip("/") + "/"
|
|
1250
1505
|
for key, rel in files:
|
|
1506
|
+
if path_prefix and not rel.startswith(path_prefix):
|
|
1507
|
+
continue
|
|
1251
1508
|
local_path = dest_root / rel
|
|
1252
1509
|
local_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1253
1510
|
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()
|
|
@@ -130,7 +130,15 @@ class Repo:
|
|
|
130
130
|
overwrite: bool = False, concurrency: int = 8,
|
|
131
131
|
publish: bool = False, ingest: bool = True,
|
|
132
132
|
continue_on_error: bool = False,
|
|
133
|
-
image_release_dst_acr: str | None = None,
|
|
133
|
+
image_release_dst_acr: str | None = None,
|
|
134
|
+
skip_unchanged: bool = False,
|
|
135
|
+
hash_func=None,
|
|
136
|
+
content_hashes: dict | None = None,
|
|
137
|
+
prune: bool = False,
|
|
138
|
+
progress_callback=None,
|
|
139
|
+
executor=None,
|
|
140
|
+
compress_level: int | None = None,
|
|
141
|
+
**ingest_kwargs) -> dict:
|
|
134
142
|
"""一次性交付一批实例:并发上传 OSS(register=False),再触发一次 dataset-ingest 入库。
|
|
135
143
|
|
|
136
144
|
相比逐个 ``push``(每实例一次 STS 签发 + 串行传输),本方法整批共用 STS、
|
|
@@ -138,7 +146,7 @@ class Repo:
|
|
|
138
146
|
|
|
139
147
|
``continue_on_error=False``(默认,全或无):任一实例失败即抛 ``ItemError``。
|
|
140
148
|
``continue_on_error=True``:跳过失败项,只要有成功上传就对成功子集触发 ingest,
|
|
141
|
-
返回 ``{"pushed":[
|
|
149
|
+
返回 ``{"pushed":[...], "skipped":[...], "failed":[...], "ingest":<job|None>}``;
|
|
142
150
|
全部失败时不触发 ingest(job=None)。``ingest=False`` 则只上传不入库。
|
|
143
151
|
|
|
144
152
|
``owner_reference``(可选):整批共用的上游来源引用,透传给每个 push。CLI 的
|
|
@@ -147,22 +155,48 @@ class Repo:
|
|
|
147
155
|
``image_release_dst_acr``(可选):透传给 push,把「实例↔镜像」上架映射写进
|
|
148
156
|
{id}.json 的 extend_meta,随本方法的 ingest 一并入库(见 push 文档)。
|
|
149
157
|
|
|
158
|
+
``skip_unchanged=True``:自动比对本地源文件哈希与远端 ``extend_meta.source_hash``,
|
|
159
|
+
匹配则跳过 pack+upload。``hash_func`` 可自定义哈希函数(默认用
|
|
160
|
+
``content.compute_source_hash``);``content_hashes`` 可直接提供
|
|
161
|
+
``{instance_id: hash}`` 映射跳过本地计算。
|
|
162
|
+
|
|
163
|
+
``prune=True``:交付完成后,删除远端存在但本次 local_dirs 中不存在的实例
|
|
164
|
+
(逐个调 ``instances.delete``)。注意:ingest 本身已有 prune 语义
|
|
165
|
+
(删除 OSS 中不存在的旧记录),此选项额外清理 OSS 中也已删除的情况。
|
|
166
|
+
|
|
167
|
+
``progress_callback(completed, total, result_or_error)``:每个 case 完成时调用。
|
|
168
|
+
|
|
169
|
+
``executor``:外部 ``ThreadPoolExecutor``,传入时复用(不关闭)。
|
|
170
|
+
|
|
171
|
+
``compress_level``:gzip 压缩级别(1-9),透传给 pack。
|
|
172
|
+
|
|
150
173
|
注意:单实例在"推 ACR→传 content.tgz→写 {id}.json"中途失败可能留下孤儿对象/镜像
|
|
151
174
|
(ingest 按 {id}.json 扫描,孤儿对此无害,但占用存储,需要时另行清理)。
|
|
152
175
|
"""
|
|
176
|
+
import logging as _logging
|
|
177
|
+
_logger = _logging.getLogger(__name__)
|
|
178
|
+
|
|
153
179
|
results = self.instances.push_many(
|
|
154
180
|
local_dirs, dataset, version, owner_reference=owner_reference,
|
|
155
181
|
split=split, push_image=push_image,
|
|
156
182
|
overwrite=overwrite, register=False, concurrency=concurrency,
|
|
157
183
|
continue_on_error=continue_on_error,
|
|
158
|
-
image_release_dst_acr=image_release_dst_acr
|
|
159
|
-
|
|
184
|
+
image_release_dst_acr=image_release_dst_acr,
|
|
185
|
+
skip_unchanged=skip_unchanged,
|
|
186
|
+
hash_func=hash_func,
|
|
187
|
+
content_hashes=content_hashes,
|
|
188
|
+
progress_callback=progress_callback,
|
|
189
|
+
executor=executor,
|
|
190
|
+
compress_level=compress_level)
|
|
191
|
+
pushed = [r for r in results
|
|
192
|
+
if not isinstance(r, ItemError)
|
|
193
|
+
and not getattr(r, "skipped", False)]
|
|
194
|
+
skipped = [r for r in results
|
|
195
|
+
if not isinstance(r, ItemError)
|
|
196
|
+
and getattr(r, "skipped", False)]
|
|
160
197
|
failed = [r for r in results if isinstance(r, ItemError)]
|
|
161
198
|
job = None
|
|
162
199
|
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
200
|
ingest_split_kwargs = {}
|
|
167
201
|
if "splits" not in ingest_kwargs and "split" not in ingest_kwargs:
|
|
168
202
|
if split and split != "default":
|
|
@@ -170,7 +204,25 @@ class Repo:
|
|
|
170
204
|
job = self.versions.ingest(
|
|
171
205
|
dataset, version, publish=publish,
|
|
172
206
|
**ingest_split_kwargs, **ingest_kwargs)
|
|
173
|
-
|
|
207
|
+
# prune:删除远端有但本次不在 local_dirs 中的实例
|
|
208
|
+
if prune and not failed:
|
|
209
|
+
local_ids = {Path(d).name for d in local_dirs}
|
|
210
|
+
try:
|
|
211
|
+
remote_instances = self.instances.list(dataset, version, split)
|
|
212
|
+
for inst in remote_instances:
|
|
213
|
+
if inst.instance_id not in local_ids:
|
|
214
|
+
try:
|
|
215
|
+
self.instances.delete(
|
|
216
|
+
dataset, version, inst.instance_id,
|
|
217
|
+
split=split)
|
|
218
|
+
_logger.info("prune: deleted %s", inst.instance_id)
|
|
219
|
+
except Exception: # noqa: BLE001
|
|
220
|
+
_logger.warning("prune: failed to delete %s",
|
|
221
|
+
inst.instance_id)
|
|
222
|
+
except Exception: # noqa: BLE001
|
|
223
|
+
_logger.warning("prune: failed to list remote instances")
|
|
224
|
+
return {"pushed": pushed, "skipped": skipped,
|
|
225
|
+
"failed": failed, "ingest": job}
|
|
174
226
|
|
|
175
227
|
# ── 源到目标交付:上传 → 元数据入库 → 发布 workflow ────────────────────────
|
|
176
228
|
def deliver_to_prod(self, dataset: str, version: str, local_dirs, *,
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{instance_repo-1.3.5.dev0 → instance_repo-1.3.5.dev2}/instance_repo.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|