instance-repo 1.0.9.dev6__tar.gz → 1.1.0.dev0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/PKG-INFO +1 -1
  2. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/__init__.py +1 -1
  3. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/_site_defaults.py +12 -0
  4. instance_repo-1.1.0.dev0/instance_repo/artifacts.py +347 -0
  5. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/cli.py +75 -0
  6. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/clients/instances.py +123 -21
  7. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/clients/workflows.py +0 -1
  8. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/repo.py +82 -1
  9. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/store/oss.py +111 -0
  10. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo.egg-info/PKG-INFO +1 -1
  11. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo.egg-info/SOURCES.txt +1 -0
  12. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/pyproject.toml +1 -1
  13. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/README.md +0 -0
  14. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/_routing.py +0 -0
  15. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/bootstrap.py +0 -0
  16. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/cache.py +0 -0
  17. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/clients/__init__.py +0 -0
  18. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/clients/benchmarks.py +0 -0
  19. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/clients/config.py +0 -0
  20. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/clients/datasets.py +0 -0
  21. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/clients/images.py +0 -0
  22. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/clients/reports.py +0 -0
  23. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/clients/scaffold.py +0 -0
  24. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/clients/splits.py +0 -0
  25. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/clients/versions.py +0 -0
  26. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/concurrency.py +0 -0
  27. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/content.py +0 -0
  28. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/endpoints.py +0 -0
  29. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/errors.py +0 -0
  30. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/image.py +0 -0
  31. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/layout.py +0 -0
  32. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/loader.py +0 -0
  33. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/models.py +0 -0
  34. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/paths.py +0 -0
  35. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/release.py +0 -0
  36. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/retry.py +0 -0
  37. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/store/__init__.py +0 -0
  38. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/store/acr.py +0 -0
  39. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/store/base.py +0 -0
  40. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/store/registry.py +0 -0
  41. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/tasktoml.py +0 -0
  42. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/transport.py +0 -0
  43. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/validate.py +0 -0
  44. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo.egg-info/dependency_links.txt +0 -0
  45. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo.egg-info/entry_points.txt +0 -0
  46. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo.egg-info/requires.txt +0 -0
  47. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo.egg-info/top_level.txt +0 -0
  48. {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: instance-repo
3
- Version: 1.0.9.dev6
3
+ Version: 1.1.0.dev0
4
4
  Summary: InstanceRepo SDK — 评测 Instance 统一存储客户端(SDK-only;控制面由 apiserver 承载)
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -18,7 +18,7 @@ from .errors import (
18
18
  TransportError,
19
19
  )
20
20
 
21
- __version__ = "1.0.9.dev6"
21
+ __version__ = "1.1.0.dev0"
22
22
 
23
23
  __all__ = [
24
24
  "Repo", "load_instances",
@@ -25,3 +25,15 @@ from __future__ import annotations
25
25
  #: **var**,内网渠道经 ``-ldflags -X`` 注入)与 Java 侧 ``SiteDefaults.API_BASE``
26
26
  #: (三语言须一致,且各自保留构建期注入方式)。
27
27
  API_BASE = ""
28
+
29
+ #: AP job 查询端点(artifact 下载用)的站点默认值。源码里恒为空串;内网构建时注入。
30
+ AP_ENDPOINT = ""
31
+
32
+ #: artifact 对象的 OSS 前缀根(如 {root}/{job_id}/result.tgz)。源码恒为空;
33
+ #: 内网构建时注入。
34
+ ARTIFACT_PREFIX_ROOT = ""
35
+
36
+ #: 「AP cluster 名 → artifact OSS bucket」映射的 JSON 串。源码恒为空;内网构建时
37
+ #: 注入。运行时也可经环境变量 AP_ARTIFACT_BUCKET_MAP(JSON)覆盖,优先级:
38
+ #: 环境变量 > 本模块注入值 > 空(空则 artifact 下载报错并引导配置)。
39
+ ARTIFACT_BUCKET_MAP_JSON = ""
@@ -0,0 +1,347 @@
1
+ """artifacts.py — AP job artifact 下载(STS 直连 OSS,绕开 dataplane)。
2
+
3
+ 调用方:普通用户 / UTS SDK,透传 apikey + job_id + cluster_id。
4
+ 鉴权:AP workspace ACL(SDK 调 AP GET /api/jobs/{id} 时自动检查)。
5
+ STS:apiserver POST /apis/v1/credentials,resource=oss,artifact 前缀强制只读。
6
+ OSS:{ARTIFACT_PREFIX_ROOT}/{job_id}/result.tgz(前缀根经 _site_defaults 注入)。
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ import os
13
+ import posixpath
14
+ import urllib.error
15
+ import urllib.request
16
+ from concurrent.futures import ThreadPoolExecutor, as_completed
17
+ from typing import Any
18
+
19
+ from .errors import Forbidden, InstanceRepoError
20
+ from . import _site_defaults
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # 常量
26
+ # ---------------------------------------------------------------------------
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # 常量(业务值一律经 _site_defaults 注入,源码里不硬编码,见 _site_defaults.py)
30
+ # ---------------------------------------------------------------------------
31
+
32
+ def _artifact_bucket_map() -> dict[str, str]:
33
+ """解析 artifact bucket 映射:环境变量 > 站点默认 > 空。
34
+
35
+ 空则抛错并引导配置——业务桶名不进公网包源码(脱敏红线)。
36
+ """
37
+ raw = (os.environ.get("AP_ARTIFACT_BUCKET_MAP", "")
38
+ or _site_defaults.ARTIFACT_BUCKET_MAP_JSON).strip()
39
+ if not raw:
40
+ raise InstanceRepoError(
41
+ "artifact bucket map is not configured; set AP_ARTIFACT_BUCKET_MAP "
42
+ "(JSON {\"<cluster>\": \"<bucket>\", ...}) or use an internal build "
43
+ "with the map injected")
44
+ try:
45
+ data = json.loads(raw)
46
+ except ValueError as exc:
47
+ raise InstanceRepoError(
48
+ f"AP_ARTIFACT_BUCKET_MAP must be valid JSON: {exc}") from exc
49
+ if not isinstance(data, dict) or not all(
50
+ isinstance(k, str) and isinstance(v, str) for k, v in data.items()):
51
+ raise InstanceRepoError(
52
+ "AP_ARTIFACT_BUCKET_MAP must be a JSON object of string pairs")
53
+ return data
54
+
55
+
56
+ def _artifact_prefix_root() -> str:
57
+ root = (os.environ.get("AP_ARTIFACT_PREFIX_ROOT", "")
58
+ or _site_defaults.ARTIFACT_PREFIX_ROOT).strip()
59
+ if not root:
60
+ raise InstanceRepoError(
61
+ "artifact prefix root is not configured; set AP_ARTIFACT_PREFIX_ROOT "
62
+ "or use an internal build with the value injected")
63
+ return root
64
+
65
+
66
+ def _ap_endpoint(ap_endpoint: str | None) -> str:
67
+ endpoint = (ap_endpoint
68
+ or os.environ.get("INSTANCE_REPO_AP_ENDPOINT")
69
+ or _site_defaults.AP_ENDPOINT).strip()
70
+ if not endpoint:
71
+ raise InstanceRepoError(
72
+ "AP endpoint is not configured; set INSTANCE_REPO_AP_ENDPOINT "
73
+ "or use an internal build with the default injected")
74
+ return endpoint.rstrip("/")
75
+
76
+ # 批量下载默认并发数
77
+ DEFAULT_MAX_CONCURRENT = 8
78
+
79
+ # OSS 限流退避参数
80
+ OSS_RETRY_INITIAL_WAIT = 1.0 # 秒
81
+ OSS_RETRY_MAX_WAIT = 30.0 # 秒
82
+ OSS_RETRY_MAX_ATTEMPTS = 5
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # 异常
87
+ # ---------------------------------------------------------------------------
88
+
89
+ class ArtifactNotFoundError(InstanceRepoError):
90
+ """job 不存在或无 artifact。"""
91
+
92
+
93
+ class ArtifactPermissionError(Forbidden):
94
+ """用户无权访问该 job(workspace ACL 拒绝)。"""
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # 内部辅助
99
+ # ---------------------------------------------------------------------------
100
+
101
+ def resolve_artifact_bucket(cluster: str) -> str:
102
+ """根据数据面名查 artifact bucket(映射来自注入配置,非源码硬编码)。"""
103
+ cluster = (cluster or "").strip()
104
+ bucket_map = _artifact_bucket_map()
105
+ bucket = bucket_map.get(cluster)
106
+ if not bucket:
107
+ raise InstanceRepoError(
108
+ f"unknown cluster {cluster!r}; known clusters: "
109
+ f"{sorted(bucket_map)}")
110
+ return bucket
111
+
112
+
113
+ def artifact_prefix(job_id: str) -> str:
114
+ """构造 artifact STS 前缀:{root}/{job_id}/"""
115
+ return f"{_artifact_prefix_root()}/{job_id}/"
116
+
117
+
118
+ def artifact_key(job_id: str) -> str:
119
+ """构造 artifact OSS 对象 key:{root}/{job_id}/result.tgz"""
120
+ return f"{_artifact_prefix_root()}/{job_id}/result.tgz"
121
+
122
+
123
+ def fetch_job_info(api_key: str, job_id: str, cluster: str,
124
+ ap_endpoint: str | None = None) -> dict[str, Any]:
125
+ """调 AP GET /api/jobs/{job_id}?include_params=true 获取 job 信息。
126
+
127
+ AP 内部执行 workspace ACL 检查(enforce_workspace_action_req)。
128
+ 403 → ArtifactPermissionError;404 → ArtifactNotFoundError。
129
+ """
130
+ endpoint = _ap_endpoint(ap_endpoint)
131
+ url = f"{endpoint}/api/jobs/{job_id}?include_params=true"
132
+ headers = {
133
+ "X-API-Key": api_key,
134
+ "Accept": "application/json",
135
+ }
136
+ if cluster:
137
+ headers["X-Cluster"] = cluster
138
+
139
+ req = urllib.request.Request(url, headers=headers, method="GET")
140
+ try:
141
+ with urllib.request.urlopen(req, timeout=30) as resp:
142
+ return json.loads(resp.read().decode())
143
+ except urllib.error.HTTPError as e:
144
+ if e.code == 403:
145
+ raise ArtifactPermissionError(
146
+ f"workspace ACL denied access to job {job_id!r}"
147
+ ) from e
148
+ if e.code == 404:
149
+ raise ArtifactNotFoundError(
150
+ f"job {job_id!r} not found"
151
+ ) from e
152
+ raise InstanceRepoError(
153
+ f"AP returned HTTP {e.code} for job {job_id!r}: {e.reason}"
154
+ ) from e
155
+ except urllib.error.URLError as e:
156
+ raise InstanceRepoError(
157
+ f"failed to reach AP at {endpoint}: {e.reason}"
158
+ ) from e
159
+
160
+
161
+ def fetch_group_job_ids(api_key: str, group_id: str, cluster: str,
162
+ ap_endpoint: str | None = None) -> list[str]:
163
+ """调 AP GET /groups/{group_id}/jobs 分页收集全部 job_id。"""
164
+ endpoint = (ap_endpoint or os.environ.get("INSTANCE_REPO_AP_ENDPOINT")
165
+ or DEFAULT_AP_ENDPOINT).rstrip("/")
166
+ job_ids: list[str] = []
167
+ skip = 0
168
+ limit = 500
169
+
170
+ while True:
171
+ url = (f"{endpoint}/groups/{group_id}/jobs"
172
+ f"?skip={skip}&limit={limit}&include_post_process=false")
173
+ headers = {
174
+ "X-API-Key": api_key,
175
+ "Accept": "application/json",
176
+ }
177
+ if cluster:
178
+ headers["X-Cluster"] = cluster
179
+
180
+ req = urllib.request.Request(url, headers=headers, method="GET")
181
+ try:
182
+ with urllib.request.urlopen(req, timeout=60) as resp:
183
+ data = json.loads(resp.read().decode())
184
+ except urllib.error.HTTPError as e:
185
+ if e.code == 403:
186
+ raise ArtifactPermissionError(
187
+ f"workspace ACL denied access to group {group_id!r}"
188
+ ) from e
189
+ if e.code == 404:
190
+ raise ArtifactNotFoundError(
191
+ f"group {group_id!r} not found"
192
+ ) from e
193
+ raise InstanceRepoError(
194
+ f"AP returned HTTP {e.code} for group {group_id!r}"
195
+ ) from e
196
+ except urllib.error.URLError as e:
197
+ raise InstanceRepoError(
198
+ f"failed to reach AP at {endpoint}: {e.reason}"
199
+ ) from e
200
+
201
+ jobs = data.get("jobs", [])
202
+ if not jobs:
203
+ break
204
+ for j in jobs:
205
+ jid = j.get("job_id")
206
+ if jid:
207
+ job_ids.append(jid)
208
+ if len(jobs) < limit:
209
+ break
210
+ skip += limit
211
+
212
+ return job_ids
213
+
214
+
215
+ def _download_one(transport, bucket: str, job_id: str, output_dir: str) -> str:
216
+ """下载单个 job 的 artifact 到 output_dir/{job_id}/{文件名}。
217
+
218
+ artifact 文件名不固定:实测两种命名(result.tgz 与 {job_id}.tgz)。
219
+ 先 list 前缀目录找非空的 .tgz 对象,兼容所有命名;找不到 → ArtifactNotFoundError。
220
+
221
+ 返回本地文件路径。失败时抛异常。
222
+ """
223
+ import oss2 # 延迟导入,避免无 oss2 环境 import 失败
224
+
225
+ prefix = artifact_prefix(job_id)
226
+
227
+ # 获取 STS(同数据面多 job 共享缓存)
228
+ creds = transport.get_oss_credentials(bucket, prefix)
229
+ auth = oss2.StsAuth(
230
+ creds["access_key_id"],
231
+ creds["access_key_secret"],
232
+ creds["security_token"],
233
+ )
234
+
235
+ # 使用 credential 返回的 endpoint(通常是内网)
236
+ endpoint = creds.get("endpoint") or creds.get("oss_endpoint", "")
237
+ if not endpoint:
238
+ raise InstanceRepoError("STS response missing endpoint")
239
+
240
+ bkt = oss2.Bucket(auth, endpoint, bucket)
241
+
242
+ # 1. list 前缀目录,找 artifact tgz 对象(跳过 size=0 的目录占位符)
243
+ key = None
244
+ for obj in oss2.ObjectIterator(bkt, prefix=prefix):
245
+ if obj.size > 0 and obj.key.endswith(".tgz"):
246
+ key = obj.key
247
+ break
248
+ if not key:
249
+ raise ArtifactNotFoundError(
250
+ f"no artifact tgz found under {prefix!r} for job {job_id!r}")
251
+
252
+ # 2. 本地路径(保留 OSS 上的真实文件名)
253
+ job_dir = os.path.join(output_dir, job_id)
254
+ os.makedirs(job_dir, exist_ok=True)
255
+ local_path = os.path.join(job_dir, os.path.basename(key.rstrip("/")))
256
+
257
+ # 3. 下载(带 OSS 限流退避)
258
+ wait = OSS_RETRY_INITIAL_WAIT
259
+ for attempt in range(1, OSS_RETRY_MAX_ATTEMPTS + 1):
260
+ try:
261
+ bkt.get_object_to_file(key, local_path)
262
+ logger.info("downloaded %s → %s", key, local_path)
263
+ return local_path
264
+ except oss2.exceptions.ServerError as e:
265
+ if e.status == 503 and attempt < OSS_RETRY_MAX_ATTEMPTS:
266
+ logger.warning("OSS 503 for %s, retry %d/%d in %.1fs",
267
+ job_id, attempt, OSS_RETRY_MAX_ATTEMPTS, wait)
268
+ import time
269
+ time.sleep(wait)
270
+ wait = min(wait * 2, OSS_RETRY_MAX_WAIT)
271
+ else:
272
+ raise
273
+ return local_path # unreachable, but keeps type checker happy
274
+
275
+
276
+ # ---------------------------------------------------------------------------
277
+ # 公开 API(给 Repo 类调用)
278
+ # ---------------------------------------------------------------------------
279
+
280
+ def download_artifact(transport, api_key: str, job_id: str,
281
+ cluster: str, output_dir: str = ".",
282
+ ap_endpoint: str | None = None) -> str:
283
+ """下载单个 job 的 artifact (result.tgz)。
284
+
285
+ Args:
286
+ transport: SDK Transport 实例(用于 apiserver STS 请求)
287
+ api_key: 用户 API Key(透传给 AP 做 workspace ACL 检查)
288
+ job_id: AP job ID
289
+ cluster: 数据面名
290
+ output_dir: 本地输出目录
291
+ ap_endpoint: AP 控制面 URL(默认从环境变量或常量取)
292
+
293
+ Returns:
294
+ 下载文件的本地路径
295
+ """
296
+ # 1. AP workspace ACL 检查
297
+ fetch_job_info(api_key, job_id, cluster, ap_endpoint)
298
+
299
+ # 2. 确定 bucket
300
+ bucket = resolve_artifact_bucket(cluster)
301
+
302
+ # 3. 下载
303
+ return _download_one(transport, bucket, job_id, output_dir)
304
+
305
+
306
+ def download_artifacts(transport, api_key: str, job_ids: list[str],
307
+ cluster: str, output_dir: str = ".",
308
+ max_concurrent: int = DEFAULT_MAX_CONCURRENT,
309
+ ap_endpoint: str | None = None,
310
+ ) -> dict[str, str | Exception]:
311
+ """批量下载多个 job 的 artifact。
312
+
313
+ 并发粒度是跨 job(不是单文件内多线程)。同数据面的多个 job 共享 STS 凭据。
314
+
315
+ Args:
316
+ transport: SDK Transport 实例
317
+ api_key: 用户 API Key
318
+ job_ids: AP job ID 列表
319
+ cluster: 数据面名
320
+ output_dir: 本地输出目录
321
+ max_concurrent: 最大并发下载数
322
+ ap_endpoint: AP 控制面 URL
323
+
324
+ Returns:
325
+ {job_id: 本地文件路径 或 Exception}
326
+ """
327
+ bucket = resolve_artifact_bucket(cluster)
328
+ results: dict[str, str | Exception] = {}
329
+
330
+ def _do(jid: str) -> tuple[str, str | Exception]:
331
+ try:
332
+ # AP workspace ACL 检查
333
+ fetch_job_info(api_key, jid, cluster, ap_endpoint)
334
+ # 下载
335
+ path = _download_one(transport, bucket, jid, output_dir)
336
+ return jid, path
337
+ except Exception as e:
338
+ logger.error("failed to download artifact for job %s: %s", jid, e)
339
+ return jid, e
340
+
341
+ with ThreadPoolExecutor(max_workers=max_concurrent) as pool:
342
+ futures = {pool.submit(_do, jid): jid for jid in job_ids}
343
+ for future in as_completed(futures):
344
+ jid, result = future.result()
345
+ results[jid] = result
346
+
347
+ return results
@@ -380,6 +380,39 @@ def build_parser() -> argparse.ArgumentParser:
380
380
  ds.add_argument("--creator", default=None, help="workflows creator 过滤")
381
381
  ds.add_argument("--page", type=int, default=1)
382
382
  ds.add_argument("--page-size", type=int, default=20, dest="page_size")
383
+
384
+ # ── Artifact 下载(STS 直连 OSS,workspace ACL 鉴权)──
385
+ art = sub.add_parser("artifact",
386
+ help="AP job artifact 下载(STS 直连 OSS)")
387
+ asub = art.add_subparsers(dest="artifact_cmd", required=True)
388
+
389
+ adl = asub.add_parser("download", help="下载单个 job 的 artifact")
390
+ adl.add_argument("--job-id", required=True, help="AP job ID")
391
+ adl.add_argument("--cluster", default=None,
392
+ help="数据面名(缺省取 SDK profile 绑定的 cluster)")
393
+ adl.add_argument("--output-dir", default=".", dest="output_dir",
394
+ help="本地输出目录")
395
+ adl.add_argument("--ap-endpoint", default=None, dest="ap_endpoint",
396
+ help="AP 控制面 URL(可选)")
397
+
398
+ adlm = asub.add_parser("download-many", help="批量下载多个 job 的 artifact")
399
+ adlm.add_argument("--job-ids", required=True, dest="job_ids",
400
+ help="逗号分隔的 job ID 列表")
401
+ adlm.add_argument("--cluster", default=None)
402
+ adlm.add_argument("--output-dir", default=".", dest="output_dir")
403
+ adlm.add_argument("--concurrent", type=int, default=8,
404
+ help="最大并发下载数(默认 8)")
405
+ adlm.add_argument("--ap-endpoint", default=None, dest="ap_endpoint")
406
+
407
+ adlg = asub.add_parser("download-group",
408
+ help="下载某 group 下全部 job 的 artifact")
409
+ adlg.add_argument("--group-id", required=True, dest="group_id",
410
+ help="AP group ID")
411
+ adlg.add_argument("--cluster", default=None)
412
+ adlg.add_argument("--output-dir", default=".", dest="output_dir")
413
+ adlg.add_argument("--concurrent", type=int, default=8)
414
+ adlg.add_argument("--ap-endpoint", default=None, dest="ap_endpoint")
415
+
383
416
  return ap
384
417
 
385
418
 
@@ -1057,6 +1090,48 @@ def main(argv=None, *, repo: Repo | None = None, out=None) -> int:
1057
1090
  print(f"page={res.page}/{_page_count(res)} total={res.total}", file=out)
1058
1091
  return 0
1059
1092
 
1093
+ if args.cmd == "artifact":
1094
+ if args.artifact_cmd == "download":
1095
+ path = r.download_artifact(
1096
+ args.job_id, cluster=args.cluster,
1097
+ output_dir=args.output_dir,
1098
+ ap_endpoint=args.ap_endpoint)
1099
+ print(str(path), file=out)
1100
+ return 0
1101
+ if args.artifact_cmd == "download-many":
1102
+ job_ids = [j.strip() for j in args.job_ids.split(",") if j.strip()]
1103
+ results = r.download_artifacts(
1104
+ job_ids, cluster=args.cluster,
1105
+ output_dir=args.output_dir,
1106
+ max_concurrent=args.concurrent,
1107
+ ap_endpoint=args.ap_endpoint)
1108
+ ok, fail = 0, 0
1109
+ for jid, res in results.items():
1110
+ if isinstance(res, Exception):
1111
+ print(f"FAIL {jid}: {res}", file=out)
1112
+ fail += 1
1113
+ else:
1114
+ print(f"OK {jid}: {res}", file=out)
1115
+ ok += 1
1116
+ print(f"\ntotal={len(results)} ok={ok} failed={fail}", file=out)
1117
+ return 1 if fail else 0
1118
+ if args.artifact_cmd == "download-group":
1119
+ results = r.download_artifacts_from_group(
1120
+ args.group_id, cluster=args.cluster,
1121
+ output_dir=args.output_dir,
1122
+ max_concurrent=args.concurrent,
1123
+ ap_endpoint=args.ap_endpoint)
1124
+ ok, fail = 0, 0
1125
+ for jid, res in results.items():
1126
+ if isinstance(res, Exception):
1127
+ print(f"FAIL {jid}: {res}", file=out)
1128
+ fail += 1
1129
+ else:
1130
+ print(f"OK {jid}: {res}", file=out)
1131
+ ok += 1
1132
+ print(f"\ntotal={len(results)} ok={ok} failed={fail}", file=out)
1133
+ return 1 if fail else 0
1134
+
1060
1135
  return 2 # pragma: no cover - argparse 已保证 cmd 合法
1061
1136
 
1062
1137
 
@@ -57,10 +57,14 @@ class InstancesClient:
57
57
  self._ingester = ingester
58
58
 
59
59
  def _after_upload(self, register: bool, dataset: str,
60
- version: str) -> dict | None:
60
+ version: str, split: str = "default") -> dict | None:
61
61
  """register=True:上传成功后触发一次 ingest 写库,返回 ingest job 摘要。
62
62
 
63
63
  register=False 返回 None(只传 OSS,由调用方决定何时 ingest)。
64
+
65
+ ``split`` 必须透传给 ingest:split_first + 无 version 时服务端要求显式
66
+ splits,否则 ingest 报 SchemaError——上传成功但 split 记录不落库,文件
67
+ 孤悬 OSS(真机锤实:``instance push --split abc`` 无 --version 场景)。
64
68
  """
65
69
  if not register:
66
70
  return None
@@ -69,7 +73,7 @@ class InstancesClient:
69
73
  "register=True 需要 ingest 回调:请通过 Repo() 构造客户端"
70
74
  "(已注入 versions.ingest),或显式传入 ingester=...;"
71
75
  "直接构造 InstancesClient 且未注入时请用 register=False 只传 OSS")
72
- return self._ingester(dataset, version)
76
+ return self._ingester(dataset, version, split=split)
73
77
 
74
78
  def _layout(self, metadata_model: str | None = None) -> Layout:
75
79
  """按(显式或 profile 的)元数据模型构造布局策略。
@@ -387,7 +391,7 @@ class InstancesClient:
387
391
  # ⑤ 写库:register=True 时经 ingest 扫描入库(替代缺失的 :commit 三段式收尾)。
388
392
  # ingest 幂等(重扫 OSS),skip 与否都由服务端按 OSS 实际字节重算 digest,
389
393
  # 不再有「本地 digest 覆盖别家生产者」的 skip 分支歧义。
390
- ingest_result = self._after_upload(register, dataset, version)
394
+ ingest_result = self._after_upload(register, dataset, version, split=split)
391
395
  digests = {"content.tgz": digest}
392
396
  return PushResult(instance_id=instance_id, uploaded=not skipped,
393
397
  skipped=skipped, image_pushed=image_pushed,
@@ -442,7 +446,7 @@ class InstancesClient:
442
446
  f"{instance_id}: OSS 回读校验失败——上传后未在 OSS 找到 {idx_key}")
443
447
 
444
448
  # ④ 写库:register=True 时经 ingest 扫描入库(替代缺失的 :commit 收尾)
445
- ingest_result = self._after_upload(register, dataset, version)
449
+ ingest_result = self._after_upload(register, dataset, version, split=split)
446
450
 
447
451
  return PushResult(instance_id=instance_id, uploaded=not skipped,
448
452
  skipped=skipped, image_pushed=False,
@@ -533,7 +537,7 @@ class InstancesClient:
533
537
  f"{instance_id}: OSS 回读校验失败——上传后未在 OSS 找到 {idx_key}")
534
538
 
535
539
  # ④ 写库:register=True 时经 ingest 扫描入库(替代缺失的 :commit 收尾)
536
- ingest_result = self._after_upload(register, dataset, version)
540
+ ingest_result = self._after_upload(register, dataset, version, split=split)
537
541
 
538
542
  return PushResult(instance_id=instance_id, uploaded=not skipped,
539
543
  skipped=skipped, image_pushed=False,
@@ -650,21 +654,28 @@ class InstancesClient:
650
654
  # continue_on_error=False 时 map_concurrent 已对失败项抛 ItemError,能走到
651
655
  # 这里说明整批成功;True 时对成功子集 ingest(与 deliver 语义一致)。
652
656
  if register:
653
- self._after_upload(True, dataset, version)
657
+ self._after_upload(True, dataset, version, split=split)
654
658
  return results
655
659
 
656
660
  def pull(self, dataset: str, version: str, instance_id: str, dest_dir,
657
661
  split: str = "default", *, verify: bool = True) -> Path:
658
- """下载实例内容。自动检测格式:harbor 下载 content.tgz,swe 下载 {id}.json。
662
+ """下载实例内容。自动检测格式:harbor 下载 content.tgz,swe 下载 {id}.json,
663
+ custom 下载 {id}.json + assets 目录。
659
664
 
660
665
  格式判断优先级:
661
- 1. verify=True 且 registry 有元数据 → 从 manifest 判断(有 tgz=harbor,否则 swe)
662
- 2. 无元数据(verify=False 或实例未注册)→ 先尝试 SWE(HEAD {id}.json),
663
- 存在则下载 json;不存在则 fallback 到 harbor(下载 content.tgz)
666
+ 1. verify=True 且 registry 有元数据 → 从 manifest 判断
667
+ (有 tgz=harbor,仅 json=swe/custom,需进一步探测)
668
+ 2. 无元数据或 manifest 无法区分 swe/custom → 下载 metadata JSON 检查
669
+ metadata_model 字段(custom → 下载 assets 目录;否则 swe)
670
+ 3. metadata JSON 不存在 → fallback 到 harbor(下载 content.tgz)
664
671
  """
665
672
  validate_instance_id(instance_id, source="pull()")
666
673
  expected = None
667
674
  fmt = None # None = 未确定,需要探测
675
+ lay = self._layout()
676
+ # 已下载的元数据字节(探测阶段下载一次,swe/custom 路径复用,避免二次网络往返)。
677
+ meta_bytes: bytes | None = None
678
+
668
679
  if verify:
669
680
  meta = self._reg.get(dataset, version, instance_id, split)
670
681
  if meta is not None and meta.manifest:
@@ -677,19 +688,35 @@ class InstancesClient:
677
688
  if has_tgz:
678
689
  fmt = "harbor"
679
690
  else:
680
- # manifest 非空但无 tgz → 查找 json entry
691
+ # manifest 有 json entry 但无 tgz → swe 或 custom(需进一步探测)。
692
+ # custom 推的 manifest 由 ingest 扫描生成,与 swe 同形态;
693
+ # 唯一区分手段是读 metadata JSON 检查 metadata_model 字段。
681
694
  for m in meta.manifest:
682
695
  if m.content_format == "json":
683
- fmt = "swe"
696
+ fmt = "swe" # 暂定 swe,后续探测可能修正为 custom
684
697
  expected = m.content_digest
685
698
  break
686
- # manifest 非空但既无 tgz 也无 json → fmt 保持 None,走探测
687
- # 无法从 manifest 确定格式 → 探测 OSS。内建 store 的探测属于下载流程,
688
- # 必须走私网限定的包内方法;自定义 ContentStore 保持旧接口兼容。
689
- lay = self._layout()
699
+
700
+ # 无法从 manifest 确定 harbor → 探测 OSS 区分 swe/custom 或 fallback harbor。
701
+ idx_key = lay.metadata_key(dataset, instance_id, version=version, split=split)
702
+ sts_prefix = lay.sts_prefix(dataset)
703
+
704
+ # ── 探测辅助:下载 metadata JSON 到临时文件并解析,区分 swe / custom ──
705
+ def _probe_meta_json() -> bytes | None:
706
+ """下载 metadata JSON 并返回字节;不存在或解析失败时返回 None。"""
707
+ tmp_dest = Path(dest_dir) / f".{instance_id}.json.probe"
708
+ tmp_dest.parent.mkdir(parents=True, exist_ok=True)
709
+ try:
710
+ self._content.get_object(
711
+ idx_key, tmp_dest, sts_prefix=sts_prefix, dataset=dataset)
712
+ data = tmp_dest.read_bytes()
713
+ return data
714
+ except Exception: # noqa: BLE001 — 下载失败视为不存在
715
+ return None
716
+ finally:
717
+ tmp_dest.unlink(missing_ok=True)
718
+
690
719
  if fmt is None:
691
- idx_key = lay.metadata_key(dataset, instance_id, version=version, split=split)
692
- sts_prefix = lay.sts_prefix(dataset)
693
720
  download_probe = getattr(self._content, "_download_probe", None)
694
721
  if download_probe is not None:
695
722
  exists = download_probe(
@@ -698,22 +725,97 @@ class InstancesClient:
698
725
  exists = self._content.object_exists(
699
726
  idx_key, sts_prefix=sts_prefix)
700
727
  if exists:
701
- fmt = "swe"
728
+ # metadata JSON 存在 → 下载并解析以区分 swe / custom
729
+ fmt = "swe" # 默认;下载后可能修正为 custom
730
+ probed = _probe_meta_json()
731
+ if probed is not None:
732
+ meta_bytes = probed
733
+ try:
734
+ meta_doc = json.loads(meta_bytes)
735
+ if (isinstance(meta_doc, dict)
736
+ and meta_doc.get("metadata_model") == "custom"):
737
+ fmt = "custom"
738
+ except (json.JSONDecodeError, UnicodeDecodeError):
739
+ pass # 无法解析 → 保持 swe
702
740
  else:
703
741
  fmt = "harbor"
704
742
 
743
+ # 若 manifest 判定为 swe 但实际可能是 custom(两者 manifest 形态相同),
744
+ # 下载 metadata JSON 做最终确认。
745
+ if fmt == "swe" and meta_bytes is None:
746
+ probed = _probe_meta_json()
747
+ if probed is not None:
748
+ meta_bytes = probed
749
+ try:
750
+ meta_doc = json.loads(meta_bytes)
751
+ if (isinstance(meta_doc, dict)
752
+ and meta_doc.get("metadata_model") == "custom"):
753
+ fmt = "custom"
754
+ except (json.JSONDecodeError, UnicodeDecodeError):
755
+ pass
756
+
757
+ if fmt == "custom":
758
+ dest = Path(dest_dir) / f"{instance_id}.json"
759
+ if meta_bytes is not None:
760
+ # 已在探测阶段下载,直接写入本地(避免二次网络往返)。
761
+ dest.parent.mkdir(parents=True, exist_ok=True)
762
+ dest.write_bytes(meta_bytes)
763
+ else:
764
+ self._content.get_object(
765
+ idx_key, dest, expected_digest=expected,
766
+ sts_prefix=sts_prefix, dataset=dataset)
767
+ # 下载资产目录({split}-assets/{id}/ 前缀下的所有文件)
768
+ self._download_custom_assets(
769
+ dataset, instance_id, split, dest_dir, lay, sts_prefix)
770
+ return dest
771
+
705
772
  if fmt == "swe":
706
773
  dest = Path(dest_dir) / f"{instance_id}.json"
707
- idx_key = lay.metadata_key(dataset, instance_id, version=version, split=split)
774
+ if meta_bytes is not None:
775
+ # 已在探测阶段下载,直接写入本地。
776
+ dest.parent.mkdir(parents=True, exist_ok=True)
777
+ dest.write_bytes(meta_bytes)
778
+ return dest
708
779
  return self._content.get_object(
709
780
  idx_key, dest, expected_digest=expected,
710
- sts_prefix=lay.sts_prefix(dataset),
781
+ sts_prefix=sts_prefix,
711
782
  dataset=dataset)
783
+
712
784
  # harbor:下载 content.tgz
713
785
  dest = Path(dest_dir) / instance_id / "content.tgz"
714
786
  return self._content.get(dataset, version, instance_id, split, dest,
715
787
  expected_digest=expected, layout=lay)
716
788
 
789
+ def _download_custom_assets(self, dataset: str, instance_id: str,
790
+ split: str, dest_dir, lay, sts_prefix: str
791
+ ) -> list[Path]:
792
+ """下载 custom 格式的资产目录({split}-assets/{id}/ 前缀下的所有文件)。
793
+
794
+ 资产文件按相对路径还原到 ``{dest_dir}/{instance_id}/`` 下。返回已下载
795
+ 的本地文件路径列表。无资产时返回空列表(不报错)。
796
+ """
797
+ asset_prefix = (f"{lay.dataset_root(dataset)}"
798
+ f"{split}-assets/{instance_id}/")
799
+ list_fn = getattr(self._content, "list_objects", None)
800
+ if list_fn is None:
801
+ return []
802
+ keys = list_fn(asset_prefix)
803
+ if not keys:
804
+ return []
805
+ downloaded: list[Path] = []
806
+ dest_root = Path(dest_dir) / instance_id
807
+ for key in keys:
808
+ rel = key[len(asset_prefix):]
809
+ if not rel or rel.endswith("/"):
810
+ continue # 跳过空键和目录占位符
811
+ local_path = dest_root / rel
812
+ local_path.parent.mkdir(parents=True, exist_ok=True)
813
+ self._content.get_object(
814
+ key, local_path,
815
+ sts_prefix=sts_prefix, dataset=dataset)
816
+ downloaded.append(local_path)
817
+ return downloaded
818
+
717
819
  def pull_many(self, instance_ids: list[str], dataset: str, version: str,
718
820
  dest_dir, *, split: str = "default",
719
821
  concurrency: int = 8, continue_on_error: bool = False,
@@ -18,7 +18,6 @@ from __future__ import annotations
18
18
 
19
19
  from urllib.parse import quote
20
20
 
21
- from ..errors import TransportError
22
21
  from ..models import PagedResult, Workflow
23
22
 
24
23
  WORKFLOW_STATUS_VALUES = ("pending", "running", "awaiting_approval",
@@ -1,9 +1,12 @@
1
1
  """repo.py — Repo Facade:组装 transport + store + 三资源客户端。"""
2
2
  from __future__ import annotations
3
3
 
4
+ import logging
4
5
  import os
5
6
  import posixpath
6
7
 
8
+ logger = logging.getLogger(__name__)
9
+
7
10
  from . import bootstrap
8
11
  from . import paths as P
9
12
  from .clients.config import (
@@ -297,7 +300,6 @@ class Repo:
297
300
  """
298
301
  from pathlib import Path as _Path
299
302
  import shutil as _shutil
300
- from . import release as _R
301
303
  from .clients.config import load_profile
302
304
  from .validate import detect_format
303
305
 
@@ -460,6 +462,85 @@ class Repo:
460
462
  pfx += self._clean_user_data_subpath(subpath)
461
463
  return self._content.list_objects(pfx)
462
464
 
465
+ # ── Artifact 下载(STS 直连 OSS,workspace ACL 鉴权)──
466
+
467
+ def download_artifact(self, job_id: str, cluster: str | None = None,
468
+ output_dir: str = ".", *,
469
+ ap_endpoint: str | None = None) -> str:
470
+ """下载单个 job 的 artifact (result.tgz) 到本地。
471
+
472
+ 鉴权:AP workspace ACL(SDK 调 AP GET /api/jobs/{id} 时自动检查)。
473
+ 并发模型:单 job = 单文件单线程。
474
+
475
+ Args:
476
+ job_id: AP job ID
477
+ cluster: 数据面名(默认使用 SDK profile 绑定的 cluster)
478
+ output_dir: 本地输出目录
479
+ ap_endpoint: AP 控制面 URL(可选,默认从环境变量或内置常量取)
480
+
481
+ Returns:
482
+ 下载文件的本地路径
483
+ """
484
+ from .artifacts import download_artifact as _dl
485
+ cluster = cluster or self.profile.cluster or ""
486
+ return _dl(self.transport, self.transport.token, job_id,
487
+ cluster, output_dir, ap_endpoint)
488
+
489
+ def download_artifacts(self, job_ids: list[str],
490
+ cluster: str | None = None,
491
+ output_dir: str = ".", *,
492
+ max_concurrent: int = 8,
493
+ ap_endpoint: str | None = None,
494
+ ) -> dict[str, str | Exception]:
495
+ """批量下载多个 job 的 artifact。
496
+
497
+ 并发粒度是跨 job(不是单文件内多线程)。同数据面的多个 job 共享 STS 凭据。
498
+
499
+ Args:
500
+ job_ids: AP job ID 列表
501
+ cluster: 数据面名
502
+ output_dir: 本地输出目录
503
+ max_concurrent: 最大并发下载数(默认 8)
504
+ ap_endpoint: AP 控制面 URL
505
+
506
+ Returns:
507
+ {job_id: 本地文件路径 或 Exception}
508
+ """
509
+ from .artifacts import download_artifacts as _dl_many
510
+ cluster = cluster or self.profile.cluster or ""
511
+ return _dl_many(self.transport, self.transport.token, job_ids,
512
+ cluster, output_dir, max_concurrent, ap_endpoint)
513
+
514
+ def download_artifacts_from_group(self, group_id: str,
515
+ cluster: str | None = None,
516
+ output_dir: str = ".", *,
517
+ max_concurrent: int = 8,
518
+ ap_endpoint: str | None = None,
519
+ ) -> dict[str, str | Exception]:
520
+ """下载某个 group 下全部 job 的 artifact。
521
+
522
+ 先调 AP GET /groups/{group_id}/jobs 分页收集 job_id,再批量下载。
523
+
524
+ Args:
525
+ group_id: AP group ID
526
+ cluster: 数据面名
527
+ output_dir: 本地输出目录
528
+ max_concurrent: 最大并发下载数
529
+ ap_endpoint: AP 控制面 URL
530
+
531
+ Returns:
532
+ {job_id: 本地文件路径 或 Exception}
533
+ """
534
+ from .artifacts import fetch_group_job_ids, download_artifacts as _dl_many
535
+ cluster = cluster or self.profile.cluster or ""
536
+ job_ids = fetch_group_job_ids(
537
+ self.transport.token, group_id, cluster, ap_endpoint)
538
+ if not job_ids:
539
+ logger.warning("group %s has no jobs", group_id)
540
+ return {}
541
+ return _dl_many(self.transport, self.transport.token, job_ids,
542
+ cluster, output_dir, max_concurrent, ap_endpoint)
543
+
463
544
  # ── 用户数据 ACL(role-bindings 接口,resource_type=user_data)──
464
545
  _USER_DATA_ROLE_MAP = {
465
546
  "reader": "built-in:user-data-reader",
@@ -17,6 +17,7 @@ from .._routing import (
17
17
  _plan_oss_route,
18
18
  _probe_private_endpoint,
19
19
  )
20
+ from ..concurrency import ItemError, map_concurrent
20
21
  from ..content import sha256_file, verify_file_digest
21
22
  from ..errors import DigestMismatch, PrivateNetworkRequired, TransportError
22
23
  from ..retry import retry_transient
@@ -244,6 +245,13 @@ class OssContentStore(BaseContentStore):
244
245
  if not route.standard:
245
246
  raise _unconfirmed_private_endpoint_error(original_endpoint)
246
247
  endpoint = route.private_endpoint
248
+ # Fail-fast: 3 秒探测私网可达性,不通立即报错,避免公网环境下
249
+ # 傻等 TCP 超时(20s × 多轮重试 = 几分钟挂死)。下载仍不 fallback
250
+ # 公网(安全约束),但能快速告知调用方需要私网环境。
251
+ if not self._probe_private_endpoint(endpoint):
252
+ raise _private_endpoint_error(
253
+ endpoint, ConnectionRefusedError(
254
+ f"TCP probe failed for {endpoint} within 3s"))
247
255
  try:
248
256
  bucket = self._open_bucket(
249
257
  sts, prefix, endpoint, private_download=True)
@@ -384,6 +392,109 @@ class OssContentStore(BaseContentStore):
384
392
  return self._execute_oss(
385
393
  _OperationIntent.NON_DOWNLOAD, prefix, None, list_all)
386
394
 
395
+ # ── directory-level helpers(自定义格式实例 = 目录级上传/下载)──
396
+ def put_directory(self, local_dir: Path, oss_prefix: str, *,
397
+ overwrite: bool = False,
398
+ sts_prefix: str | None = None,
399
+ dataset: str | None = None,
400
+ concurrency: int = 8) -> dict:
401
+ """Upload all files under *local_dir* to OSS under *oss_prefix*.
402
+
403
+ Each file is uploaded via :meth:`put_object` so every transfer gets
404
+ proper STS credentials and endpoint routing. Files are uploaded in
405
+ parallel using :func:`~instance_repo.concurrency.map_concurrent`.
406
+
407
+ Returns a summary dict::
408
+
409
+ {"file_count": int, "total_bytes": int, "keys": list[str]}
410
+ """
411
+ local_dir = Path(local_dir)
412
+ if not local_dir.is_dir():
413
+ raise FileNotFoundError(f"local directory not found: {local_dir}")
414
+
415
+ # Normalize prefix to always end with "/" so keys are proper children.
416
+ if oss_prefix and not oss_prefix.endswith("/"):
417
+ oss_prefix += "/"
418
+
419
+ # Collect (local_path, oss_key) pairs — sorted for determinism.
420
+ files: list[tuple[Path, str]] = sorted(
421
+ ((p, oss_prefix + str(p.relative_to(local_dir)))
422
+ for p in local_dir.rglob("*") if p.is_file()),
423
+ key=lambda t: t[1],
424
+ )
425
+
426
+ total_bytes = sum(p.stat().st_size for p, _ in files)
427
+
428
+ def _upload_one(item: tuple[Path, str]) -> str:
429
+ path, key = item
430
+ self.put_object(key, path, overwrite=overwrite,
431
+ sts_prefix=sts_prefix, dataset=dataset)
432
+ return key
433
+
434
+ try:
435
+ keys = map_concurrent(_upload_one, files, concurrency=concurrency)
436
+ except ItemError as exc:
437
+ raise exc.error from exc
438
+ return {
439
+ "file_count": len(files),
440
+ "total_bytes": total_bytes,
441
+ "keys": keys,
442
+ }
443
+
444
+ def get_directory(self, oss_prefix: str, local_dir: Path, *,
445
+ expected_digests: dict[str, str] | None = None,
446
+ sts_prefix: str | None = None,
447
+ dataset: str | None = None,
448
+ concurrency: int = 8) -> Path:
449
+ """Download all objects under *oss_prefix* to *local_dir*.
450
+
451
+ Objects are listed via :meth:`list_objects` and then downloaded in
452
+ parallel. Each file is fetched via :meth:`get_object` so every
453
+ transfer gets proper STS credentials and private-download routing.
454
+
455
+ *expected_digests* optionally maps **relative paths** (e.g.
456
+ ``"subdir/file.bin"``) to their expected content digest strings
457
+ (``"sha256:<hex>"`` or bare md5). Any mismatch raises
458
+ :class:`~instance_repo.errors.DigestMismatch`.
459
+
460
+ Returns the *local_dir* path.
461
+ """
462
+ local_dir = Path(local_dir)
463
+ local_dir.mkdir(parents=True, exist_ok=True)
464
+
465
+ # Normalize prefix for consistent key slicing.
466
+ if oss_prefix and not oss_prefix.endswith("/"):
467
+ oss_prefix += "/"
468
+
469
+ keys = self.list_objects(oss_prefix)
470
+
471
+ def _download_one(key: str) -> Path:
472
+ rel = key[len(oss_prefix):]
473
+ if not rel: # skip the prefix "directory marker" itself
474
+ return local_dir
475
+ dest = local_dir / rel
476
+ dest.parent.mkdir(parents=True, exist_ok=True)
477
+ self._execute_oss(
478
+ _OperationIntent.DOWNLOAD,
479
+ sts_prefix or oss_prefix,
480
+ dataset,
481
+ lambda bucket: bucket.get_object_to_file(key, str(dest)),
482
+ )
483
+ # Per-file digest check.
484
+ if expected_digests is not None:
485
+ expected = expected_digests.get(rel)
486
+ if expected is not None and not verify_file_digest(dest, expected):
487
+ raise DigestMismatch(
488
+ f"{key}: content digest mismatch "
489
+ f"(expected {expected}, got {sha256_file(dest)})")
490
+ return dest
491
+
492
+ try:
493
+ map_concurrent(_download_one, keys, concurrency=concurrency)
494
+ except ItemError as exc:
495
+ raise exc.error from exc
496
+ return local_dir
497
+
387
498
  # ── 实例内容便捷封装(content.tgz + {id}.json) ──
388
499
  def exists(self, instance_id: str, dataset: str, version: str,
389
500
  split: str = "default", *, layout=None) -> bool:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: instance-repo
3
- Version: 1.0.9.dev6
3
+ Version: 1.1.0.dev0
4
4
  Summary: InstanceRepo SDK — 评测 Instance 统一存储客户端(SDK-only;控制面由 apiserver 承载)
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -3,6 +3,7 @@ pyproject.toml
3
3
  instance_repo/__init__.py
4
4
  instance_repo/_routing.py
5
5
  instance_repo/_site_defaults.py
6
+ instance_repo/artifacts.py
6
7
  instance_repo/bootstrap.py
7
8
  instance_repo/cache.py
8
9
  instance_repo/cli.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "instance-repo"
7
- version = "1.0.9.dev6"
7
+ version = "1.1.0.dev0"
8
8
  description = "InstanceRepo SDK — 评测 Instance 统一存储客户端(SDK-only;控制面由 apiserver 承载)"
9
9
  requires-python = ">=3.10"
10
10
  readme = "README.md"