dexverse-cli 0.0.2__py3-none-any.whl

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.
@@ -0,0 +1,365 @@
1
+ """Download manager — orchestrates skip/resume, backend dispatch, verify, state.
2
+
3
+ Pipeline (doc §17):
4
+
5
+ for each file:
6
+ if dest exists and size matches -> skip (resume)
7
+ else -> queue for backend
8
+ run backend on the queue
9
+ verify every file (size; sha256 under --deep)
10
+ write download-state.json + download.log
11
+
12
+ State lives under ``<out>/.dexverse/tasks/<taskId>/`` and never touches the
13
+ dataset tree.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import Dict, List, Optional, Union
22
+
23
+ from ..manifest import FileSpec, Manifest, resolve_dest
24
+ from . import state as st
25
+ from .base import Backend, ProgressReporter
26
+ from .http_backend import HttpBackend, _HTTP_BACKEND_NAMES
27
+ from .python_s3_backend import PythonS3Backend
28
+ from .s5cmd_backend import S5CmdBackend, S5CmdNotAvailable
29
+ from .verify import verify_files
30
+
31
+
32
+ def _persist_manifest_copy(out: Path, task_id: str, manifest: Manifest) -> None:
33
+ """Write the full manifest copy (used by verify/repair) with mode 0600.
34
+
35
+ The copy may contain S3 credentials and/or presigned URLs, so it is
36
+ restricted to the owning user on POSIX (best-effort; no-op on Windows).
37
+ """
38
+ path = st.manifest_copy_path(out, task_id)
39
+ path.write_text(manifest.model_dump_json(indent=2), encoding="utf-8")
40
+ try:
41
+ os.chmod(path, 0o600)
42
+ except OSError:
43
+ pass
44
+
45
+
46
+ def _sync_task_instructions(
47
+ out: Path,
48
+ manifest: Manifest,
49
+ task_state: st.TaskState,
50
+ ) -> None:
51
+ """Synchronize per-file task instructions after download/verification."""
52
+ successful_statuses = {st.DOWNLOADED, st.SKIPPED, st.VERIFIED}
53
+ for file_spec in manifest.files:
54
+ key = file_spec.effective_key()
55
+ file_state = task_state.files.get(key)
56
+ instruction_path = st.task_instruction_path(out, file_spec.relpath())
57
+ if (
58
+ file_state is not None
59
+ and file_state.status in successful_statuses
60
+ and file_spec.taskInstruction is not None
61
+ ):
62
+ instruction_path.parent.mkdir(parents=True, exist_ok=True)
63
+ instruction_path.write_text(
64
+ json.dumps(
65
+ file_spec.taskInstruction,
66
+ ensure_ascii=False,
67
+ indent=2,
68
+ )
69
+ + "\n",
70
+ encoding="utf-8",
71
+ )
72
+ else:
73
+ instruction_path.unlink(missing_ok=True)
74
+
75
+
76
+ def get_backend(name: str, **kwargs) -> Backend:
77
+ name = (name or "http").lower()
78
+ if name == "s5cmd":
79
+ return S5CmdBackend(**kwargs)
80
+ if name in ("python", "boto3"):
81
+ return PythonS3Backend(**kwargs)
82
+ if name in _HTTP_BACKEND_NAMES:
83
+ return HttpBackend(**kwargs)
84
+ raise ValueError(
85
+ f"unknown backend: {name!r} (expected 's5cmd', 'python', or 'http')"
86
+ )
87
+
88
+
89
+ @dataclass
90
+ class DownloadReport:
91
+ task_id: str
92
+ out: Path
93
+ backend: str
94
+ total: int
95
+ skipped: int
96
+ downloaded: int
97
+ failed: int
98
+ verified: int
99
+ corrupt: int
100
+ task_dir: Path
101
+ state_path: Path
102
+ failures: List[Dict[str, object]] = field(default_factory=list)
103
+
104
+ @property
105
+ def ok(self) -> bool:
106
+ return self.failed == 0 and self.corrupt == 0
107
+
108
+ def summary_line(self) -> str:
109
+ return (
110
+ f"task={self.task_id} backend={self.backend} "
111
+ f"total={self.total} downloaded={self.downloaded} "
112
+ f"skipped={self.skipped} failed={self.failed} "
113
+ f"verified={self.verified} corrupt={self.corrupt}"
114
+ )
115
+
116
+
117
+ class DownloadManager:
118
+ def __init__(self, out: Union[str, Path], backend: str = "http",
119
+ parallel: Optional[int] = None, *, verify: bool = True,
120
+ deep: bool = False, persist_manifest: bool = True,
121
+ insecure: bool = True,
122
+ backend_kwargs: Optional[dict] = None,
123
+ progress: Optional[ProgressReporter] = None):
124
+ self.out = Path(out).expanduser().resolve()
125
+ self.backend_name = backend
126
+ self.parallel = parallel
127
+ self.verify = verify
128
+ self.deep = deep
129
+ self.persist_manifest = persist_manifest
130
+ self.insecure = insecure
131
+ self.backend_kwargs = backend_kwargs or {}
132
+ self.progress = progress
133
+
134
+ # ------------------------------------------------------------------ #
135
+ def _resolve_parallel(self, manifest: Manifest) -> int:
136
+ if self.parallel is not None:
137
+ return self.parallel
138
+ return manifest.download.defaultParallel
139
+
140
+ def _ensure_storage(self, manifest: Manifest) -> None:
141
+ backend = (self.backend_name or "").lower()
142
+ if backend in _HTTP_BACKEND_NAMES:
143
+ # http backend uses per-file presigned URLs; no endpoint/creds needed.
144
+ missing = [f.effective_key() for f in manifest.files if not f.presignedUrl]
145
+ if missing:
146
+ raise ValueError(
147
+ f"--backend http requires a presignedUrl per file; "
148
+ f"{len(missing)} file(s) missing one "
149
+ f"(first: {missing[0]!r})"
150
+ )
151
+ return
152
+ if manifest.storage is None or not manifest.storage.endpoint:
153
+ raise ValueError(
154
+ "manifest has no storage.endpoint — cannot download. "
155
+ "Provide a manifest with storage.endpoint/bucket, or set "
156
+ "AWS env vars, or use --backend http with per-file presignedUrl."
157
+ )
158
+
159
+ # ------------------------------------------------------------------ #
160
+ def run(self, manifest: Manifest) -> DownloadReport:
161
+ self._ensure_storage(manifest)
162
+ self.out.mkdir(parents=True, exist_ok=True)
163
+
164
+ # The on-disk manifest copy is the FULL task manifest (used by verify
165
+ # per §10.4). repair() reuses run() on a *subset* manifest, so it sets
166
+ # persist_manifest=False and writes the full manifest itself afterwards.
167
+ task_id = manifest.effective_task_id()
168
+ st.ensure_task_dir(self.out, task_id)
169
+ if self.persist_manifest:
170
+ _persist_manifest_copy(self.out, task_id, manifest)
171
+ log_path = st.log_file_path(self.out, task_id)
172
+ log_path.parent.mkdir(parents=True, exist_ok=True)
173
+
174
+ backend_kwargs = dict(self.backend_kwargs)
175
+ if (self.backend_name or "").lower() in _HTTP_BACKEND_NAMES:
176
+ backend_kwargs.setdefault("verify", not self.insecure)
177
+ backend = get_backend(self.backend_name, **backend_kwargs)
178
+ parallel = self._resolve_parallel(manifest)
179
+
180
+ task_state = st.TaskState.load_or_create(self.out, manifest)
181
+ task_state.start(backend.name, parallel)
182
+ task_state.save()
183
+
184
+ # ---- decide what to fetch (resume / skip) ---------------------- #
185
+ todo: List[FileSpec] = []
186
+ n_skipped = 0
187
+ for f in manifest.files:
188
+ key = f.effective_key()
189
+ dest = resolve_dest(self.out, f.relpath())
190
+ file_state = task_state.files.get(key)
191
+ expected_size = f.size
192
+ if expected_size is None and file_state is not None:
193
+ expected_size = file_state.expectedSize
194
+ if (
195
+ dest.exists()
196
+ and (
197
+ expected_size is None
198
+ or dest.stat().st_size == expected_size
199
+ )
200
+ ):
201
+ n_skipped += 1
202
+ task_state.mark(key, st.SKIPPED,
203
+ localSize=dest.stat().st_size,
204
+ expectedSize=expected_size)
205
+ else:
206
+ todo.append(f)
207
+ task_state.mark(key, st.PENDING)
208
+ task_state.save()
209
+
210
+ # ---- run backend on the pending subset ------------------------- #
211
+ n_downloaded = n_failed = 0
212
+ if todo:
213
+ known_sizes = [f.size for f in todo if f.size is not None]
214
+ total_bytes = sum(known_sizes) if len(known_sizes) == len(todo) else None
215
+ if self.progress is not None:
216
+ self.progress.start(total_bytes=total_bytes, total_files=len(todo))
217
+ try:
218
+ results = backend.download(
219
+ manifest, todo, self.out,
220
+ parallel=parallel, log_path=log_path,
221
+ progress=self.progress,
222
+ )
223
+ finally:
224
+ if self.progress is not None:
225
+ self.progress.finish()
226
+ for f in todo:
227
+ key = f.effective_key()
228
+ r = results.get(key)
229
+ if r and r.ok:
230
+ n_downloaded += 1
231
+ dest = resolve_dest(self.out, f.relpath())
232
+ task_state.mark(
233
+ key, st.DOWNLOADED,
234
+ localSize=dest.stat().st_size if dest.exists() else None,
235
+ expectedSize=r.expectedSize,
236
+ )
237
+ else:
238
+ n_failed += 1
239
+ err = r.error if r else "no result from backend"
240
+ task_state.mark(
241
+ key, st.FAILED,
242
+ localSize=r.localSize if r else None,
243
+ expectedSize=r.expectedSize if r else None,
244
+ error=err,
245
+ )
246
+ task_state.save()
247
+
248
+ # ---- verify ---------------------------------------------------- #
249
+ # Counts *actions taken this run*, not final statuses — verify promotes
250
+ # DOWNLOADED→VERIFIED, so reading task_state.counts() would hide downloads.
251
+ verified = corrupt = 0
252
+ if self.verify:
253
+ from .verify import sha256_of
254
+ for f in manifest.files:
255
+ key = f.effective_key()
256
+ # A file that failed to download stays FAILED — it is not
257
+ # "corrupt", and re-marking it would hide the real failure.
258
+ fs = task_state.files.get(key)
259
+ if fs and fs.status == st.FAILED:
260
+ continue
261
+ dest = resolve_dest(self.out, f.relpath())
262
+ expected_size = f.size
263
+ if expected_size is None and fs is not None:
264
+ expected_size = fs.expectedSize
265
+ if (
266
+ not dest.exists()
267
+ or (
268
+ expected_size is not None
269
+ and dest.stat().st_size != expected_size
270
+ )
271
+ ):
272
+ corrupt += 1
273
+ task_state.mark(key, st.CORRUPT)
274
+ continue
275
+ if self.deep and f.sha256 and \
276
+ sha256_of(dest).lower() != f.sha256.lower():
277
+ corrupt += 1
278
+ task_state.mark(key, st.CORRUPT)
279
+ continue
280
+ verified += 1
281
+ task_state.mark(key, st.VERIFIED)
282
+
283
+ _sync_task_instructions(self.out, manifest, task_state)
284
+ task_state.finish()
285
+ task_state.save()
286
+
287
+ failures = [
288
+ {"objectKey": fs.objectKey, "relpath": fs.relpath, "error": fs.error}
289
+ for fs in task_state.files.values()
290
+ if fs.status in (st.FAILED, st.CORRUPT)
291
+ ]
292
+
293
+ return DownloadReport(
294
+ task_id=task_id,
295
+ out=self.out,
296
+ backend=backend.name,
297
+ total=len(manifest.files),
298
+ skipped=n_skipped,
299
+ downloaded=n_downloaded,
300
+ failed=n_failed,
301
+ verified=verified,
302
+ corrupt=corrupt,
303
+ task_dir=st.task_dir(self.out, task_id),
304
+ state_path=st.state_file_path(self.out, task_id),
305
+ failures=failures,
306
+ )
307
+
308
+
309
+ def repair(manifest: Manifest, out: Union[str, Path], *,
310
+ backend: str = "http", parallel: Optional[int] = None,
311
+ deep: bool = False, insecure: bool = True,
312
+ backend_kwargs: Optional[dict] = None,
313
+ progress: Optional[ProgressReporter] = None) -> DownloadReport:
314
+ """Re-fetch only the files that are missing or failed size/sha (doc §10.5).
315
+
316
+ Runs the download on the *subset* of broken files, but keeps the on-disk
317
+ task manifest as the FULL manifest so a later `verify` still covers every
318
+ file (§10.4). The subset manifest is pinned to the full task id so state
319
+ lands in the original task dir even when taskId is absent.
320
+ """
321
+ out_path = Path(out).expanduser().resolve()
322
+ full_task_id = manifest.effective_task_id()
323
+ try:
324
+ previous_state = st.TaskState.load(out_path, full_task_id)
325
+ except (OSError, ValueError, TypeError, json.JSONDecodeError):
326
+ previous_state = None
327
+
328
+ # 1. find broken files
329
+ broken: List[FileSpec] = []
330
+ for f in manifest.files:
331
+ key = f.effective_key()
332
+ dest = resolve_dest(out_path, f.relpath())
333
+ if not dest.exists():
334
+ broken.append(f)
335
+ continue
336
+ expected_size = f.size
337
+ if expected_size is None and previous_state is not None:
338
+ file_state = previous_state.files.get(key)
339
+ if file_state is not None:
340
+ expected_size = file_state.expectedSize
341
+ if expected_size is not None and dest.stat().st_size != expected_size:
342
+ broken.append(f)
343
+ continue
344
+ if deep and f.sha256:
345
+ from .verify import sha256_of
346
+ if sha256_of(dest).lower() != f.sha256.lower():
347
+ broken.append(f)
348
+
349
+ # 2. download the subset (persist_manifest=False — full manifest written below).
350
+ sub = manifest.model_copy(update={"files": broken})
351
+ if not sub.taskId:
352
+ sub.taskId = full_task_id # avoid hash-fragmentation on the subset
353
+ mgr = DownloadManager(
354
+ out_path, backend=backend, parallel=parallel,
355
+ verify=True, deep=deep, persist_manifest=False, insecure=insecure,
356
+ backend_kwargs=backend_kwargs, progress=progress,
357
+ )
358
+ report = mgr.run(sub)
359
+ if not broken:
360
+ report.downloaded = 0
361
+
362
+ # 3. the on-disk manifest must remain the FULL manifest (verify covers all files)
363
+ st.ensure_task_dir(out_path, full_task_id)
364
+ _persist_manifest_copy(out_path, full_task_id, manifest)
365
+ return report
@@ -0,0 +1,137 @@
1
+ """Pure-Python S3 backend (doc §4.2): boto3 fallback when s5cmd is unavailable.
2
+
3
+ Downloads each object with a streaming ``get_object`` into ``out / relpath``.
4
+ Parallelism is a thread pool (S3 I/O releases the GIL while waiting on the
5
+ socket). boto3 is imported lazily so the package works without the ``[s3]``
6
+ extra until this backend is actually selected.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from concurrent.futures import ThreadPoolExecutor, as_completed
11
+ from pathlib import Path
12
+ from typing import Dict, List, Optional
13
+
14
+ from ..manifest import FileSpec, Manifest, resolve_dest
15
+ from .base import Credentials, FileResult, ProgressReporter
16
+
17
+
18
+ # 8 MiB streaming chunks.
19
+ _STREAM_CHUNK = 8 * 1024 * 1024
20
+
21
+
22
+ class PythonS3Backend:
23
+ name = "python"
24
+
25
+ def __init__(self, *, client_factory=None, log_path: Optional[Path] = None):
26
+ # client_factory is for tests (e.g. moto). Signature: factory(creds) -> client
27
+ self._client_factory = client_factory
28
+ self._log_path = log_path
29
+
30
+ def _make_client(self, creds: Credentials):
31
+ if self._client_factory:
32
+ return self._client_factory(creds)
33
+
34
+ import boto3 # lazy import — needs the [s3] extra
35
+ from botocore.config import Config
36
+
37
+ cfg = Config(
38
+ s3={"addressing_style": "path"},
39
+ retries={"max_attempts": 5, "mode": "adaptive"},
40
+ )
41
+ kwargs = {
42
+ "service_name": "s3",
43
+ "endpoint_url": creds.endpoint,
44
+ "region_name": creds.region or "us-east-1",
45
+ "config": cfg,
46
+ }
47
+ if creds.access_key and creds.secret_key:
48
+ kwargs["aws_access_key_id"] = creds.access_key
49
+ kwargs["aws_secret_access_key"] = creds.secret_key
50
+ if creds.session_token:
51
+ kwargs["aws_session_token"] = creds.session_token
52
+ return boto3.client(**kwargs)
53
+
54
+ def download(
55
+ self,
56
+ manifest: Manifest,
57
+ files: List[FileSpec],
58
+ out: Path,
59
+ *,
60
+ parallel: int,
61
+ log_path: Optional[Path] = None,
62
+ progress: Optional[ProgressReporter] = None,
63
+ ) -> Dict[str, FileResult]:
64
+ if not files:
65
+ return {}
66
+
67
+ creds = Credentials.from_manifest(manifest)
68
+ if not creds.endpoint:
69
+ raise ValueError("manifest.storage.endpoint is required for the python backend")
70
+
71
+ default_bucket = manifest.storage.bucket if manifest.storage else None
72
+ client = self._make_client(creds)
73
+
74
+ workers = max(1, min(parallel or 4, 32))
75
+ results: Dict[str, FileResult] = {}
76
+
77
+ def _one(f: FileSpec) -> FileResult:
78
+ bucket = f.resolved_bucket(default_bucket)
79
+ if not bucket:
80
+ return FileResult(f.objectKey, False, error="no bucket for file")
81
+ dest = resolve_dest(out, f.relpath())
82
+ # Atomic landing: write to a sibling .part, replace dest only on
83
+ # success, so a failed get_object never leaves a truncated file.
84
+ tmp = dest.with_name(dest.name + ".part")
85
+ try:
86
+ tmp.parent.mkdir(parents=True, exist_ok=True)
87
+ obj = client.get_object(Bucket=bucket, Key=f.objectKey)
88
+ size = 0
89
+ with tmp.open("wb") as fh:
90
+ while True:
91
+ chunk = obj["Body"].read(_STREAM_CHUNK)
92
+ if not chunk:
93
+ break
94
+ fh.write(chunk)
95
+ size += len(chunk)
96
+ if progress is not None:
97
+ progress.advance(len(chunk))
98
+ if f.size is not None and size != f.size:
99
+ tmp.unlink(missing_ok=True)
100
+ return FileResult(
101
+ f.objectKey, False,
102
+ error=f"size mismatch: got {size}, expected {f.size}",
103
+ )
104
+ tmp.replace(dest)
105
+ return FileResult(f.objectKey, True)
106
+ except Exception as exc: # noqa: BLE001 — surface per-file failure
107
+ try:
108
+ tmp.unlink(missing_ok=True)
109
+ except Exception:
110
+ pass
111
+ return FileResult(f.objectKey, False, error=str(exc))
112
+
113
+ with ThreadPoolExecutor(max_workers=workers) as pool:
114
+ futures = [pool.submit(_one, f) for f in files]
115
+ for fut in as_completed(futures):
116
+ r = fut.result()
117
+ results[r.objectKey] = r
118
+ if progress is not None:
119
+ progress.complete_file()
120
+
121
+ self._log(log_path, creds, files, results)
122
+ return results
123
+
124
+ def _log(self, log_path: Optional[Path], creds: Credentials,
125
+ files: List[FileSpec], results: Dict[str, FileResult]) -> None:
126
+ if not log_path:
127
+ return
128
+ log_path.parent.mkdir(parents=True, exist_ok=True)
129
+ ok = sum(1 for r in results.values() if r.ok)
130
+ with log_path.open("a", encoding="utf-8") as logf:
131
+ logf.write(
132
+ f"[python backend] endpoint={creds.endpoint} "
133
+ f"region={creds.region} files={len(files)} ok={ok}\n"
134
+ )
135
+ for r in results.values():
136
+ if not r.ok:
137
+ logf.write(f" FAIL {r.objectKey}: {r.error}\n")
@@ -0,0 +1,170 @@
1
+ """s5cmd backend (doc §7): generate ``download.s5cmd`` and run the binary.
2
+
3
+ The run-file lists one ``cp`` per object, quoting both the s3 source and the
4
+ local destination. Destinations are written with forward slashes (Go's s5cmd
5
+ accepts them on Windows), which avoids fragile backslash escaping.
6
+
7
+ Per-file success is determined *after* the run by checking each target exists
8
+ with the expected size — this is robust regardless of how s5cmd formats its
9
+ output, and lets repair target exactly the objects that failed.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import shutil
15
+ import subprocess
16
+ from pathlib import Path
17
+ from typing import Dict, List, Optional
18
+
19
+ from ..manifest import FileSpec, Manifest, resolve_dest
20
+ from .base import Credentials, FileResult, ProgressReporter
21
+
22
+
23
+ class S5CmdNotAvailable(RuntimeError):
24
+ pass
25
+
26
+
27
+ def find_s5cmd() -> Optional[str]:
28
+ """Locate the s5cmd binary: $DEXVERSE_S5CMD, then PATH, then common spots."""
29
+ override = os.getenv("DEXVERSE_S5CMD")
30
+ if override and Path(override).exists():
31
+ return override
32
+ found = shutil.which("s5cmd")
33
+ if found:
34
+ return found
35
+ candidates = [
36
+ "/usr/local/bin/s5cmd",
37
+ "/usr/bin/s5cmd",
38
+ str(Path.home() / ".local" / "bin" / "s5cmd"),
39
+ ]
40
+ for c in candidates:
41
+ if Path(c).exists():
42
+ return c
43
+ return None
44
+
45
+
46
+ def _q(value: str) -> str:
47
+ """Quote an argument for the s5cmd run-file; escape backslash and quote."""
48
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
49
+ return f'"{escaped}"'
50
+
51
+
52
+ def generate_s5cmd_lines(manifest: Manifest, files: List[FileSpec], out: Path) -> List[str]:
53
+ """One ``cp`` line per file: cp "s3://bucket/key" "/out/rel/path"."""
54
+ default_bucket = manifest.storage.bucket if manifest.storage else None
55
+ lines: List[str] = []
56
+ for f in files:
57
+ bucket = f.resolved_bucket(default_bucket)
58
+ if not bucket:
59
+ raise ValueError(
60
+ f"file {f.objectKey!r} has no bucket (manifest.storage.bucket missing)"
61
+ )
62
+ src = f"s3://{bucket}/{f.objectKey.lstrip('/')}"
63
+ dst = resolve_dest(out, f.relpath()).as_posix()
64
+ lines.append(f"cp {_q(src)} {_q(dst)}")
65
+ return lines
66
+
67
+
68
+ def write_s5cmd_file(manifest: Manifest, files: List[FileSpec], out: Path,
69
+ dest_file: Path) -> Path:
70
+ lines = generate_s5cmd_lines(manifest, files, out)
71
+ dest_file.parent.mkdir(parents=True, exist_ok=True)
72
+ dest_file.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8")
73
+ return dest_file
74
+
75
+
76
+ class S5CmdBackend:
77
+ name = "s5cmd"
78
+
79
+ def __init__(self, s5cmd_path: Optional[str] = None):
80
+ self._s5cmd = s5cmd_path
81
+
82
+ def _resolve(self) -> str:
83
+ if self._s5cmd:
84
+ if Path(self._s5cmd).exists():
85
+ return self._s5cmd
86
+ raise S5CmdNotAvailable(
87
+ f"s5cmd not found at {self._s5cmd}. Set DEXVERSE_S5CMD to a valid "
88
+ "path, or use --backend python."
89
+ )
90
+ path = find_s5cmd()
91
+ if not path:
92
+ raise S5CmdNotAvailable(
93
+ "s5cmd binary not found. Install it "
94
+ "(https://github.com/peak/s5cmd), set DEXVERSE_S5CMD, "
95
+ "or use --backend python."
96
+ )
97
+ return path
98
+
99
+ def download(
100
+ self,
101
+ manifest: Manifest,
102
+ files: List[FileSpec],
103
+ out: Path,
104
+ *,
105
+ parallel: int,
106
+ log_path: Optional[Path] = None,
107
+ progress: Optional[ProgressReporter] = None,
108
+ ) -> Dict[str, FileResult]:
109
+ if not files:
110
+ return {}
111
+ s5cmd = self._resolve()
112
+
113
+ creds = Credentials.from_manifest(manifest)
114
+ if not creds.endpoint:
115
+ raise ValueError("manifest.storage.endpoint is required for the s5cmd backend")
116
+
117
+ # Write the run-file into the task dir beside the state file.
118
+ if log_path:
119
+ run_file = log_path.parent / "download.s5cmd"
120
+ else:
121
+ run_file = Path(out) / ".dexverse" / "_download.s5cmd"
122
+ write_s5cmd_file(manifest, files, out, run_file)
123
+
124
+ cmd = [
125
+ s5cmd,
126
+ "--endpoint-url", creds.endpoint,
127
+ "--numworkers", str(max(1, parallel)),
128
+ "run", str(run_file),
129
+ ]
130
+
131
+ env = dict(os.environ)
132
+ env.update(creds.as_env())
133
+
134
+ if log_path:
135
+ log_path.parent.mkdir(parents=True, exist_ok=True)
136
+ log_handle = log_path.open("a", encoding="utf-8")
137
+ else:
138
+ log_handle = open(os.devnull, "w", encoding="utf-8")
139
+ with log_handle as logf:
140
+ logf.write(f"$ {' '.join(cmd)}\n")
141
+ proc = subprocess.run(
142
+ cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
143
+ text=True,
144
+ )
145
+ logf.write(proc.stdout or "")
146
+ logf.write(f"\n[exit code {proc.returncode}]\n\n")
147
+
148
+ # Post-check per file: exists + size match ⇒ ok.
149
+ results = _postcheck(files, out, proc.returncode)
150
+ if progress is not None:
151
+ for f in files:
152
+ if results[f.objectKey].ok:
153
+ dest = resolve_dest(out, f.relpath())
154
+ progress.advance(dest.stat().st_size)
155
+ progress.complete_file()
156
+ return results
157
+
158
+
159
+ def _postcheck(files: List[FileSpec], out: Path, exit_code: int) -> Dict[str, FileResult]:
160
+ results: Dict[str, FileResult] = {}
161
+ for f in files:
162
+ dest = resolve_dest(out, f.relpath())
163
+ if dest.exists() and (f.size is None or dest.stat().st_size == f.size):
164
+ results[f.objectKey] = FileResult(f.objectKey, True)
165
+ else:
166
+ results[f.objectKey] = FileResult(
167
+ f.objectKey, False,
168
+ error=f"s5cmd exit {exit_code}; {dest} missing or size mismatch",
169
+ )
170
+ return results