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.
- dexverse/__init__.py +54 -0
- dexverse/cli.py +422 -0
- dexverse/client.py +128 -0
- dexverse/config.py +85 -0
- dexverse/convert/__init__.py +38 -0
- dexverse/convert/hdf5_to_raw.py +498 -0
- dexverse/convert/image_video_encode.py +449 -0
- dexverse/downloader/__init__.py +26 -0
- dexverse/downloader/base.py +101 -0
- dexverse/downloader/http_backend.py +508 -0
- dexverse/downloader/manager.py +365 -0
- dexverse/downloader/python_s3_backend.py +137 -0
- dexverse/downloader/s5cmd_backend.py +170 -0
- dexverse/downloader/state.py +263 -0
- dexverse/downloader/verify.py +114 -0
- dexverse/manifest.py +284 -0
- dexverse/mock/__init__.py +2 -0
- dexverse/mock/mock_tasks.json +22 -0
- dexverse/mock/server.py +211 -0
- dexverse_cli-0.0.2.dist-info/METADATA +198 -0
- dexverse_cli-0.0.2.dist-info/RECORD +23 -0
- dexverse_cli-0.0.2.dist-info/WHEEL +4 -0
- dexverse_cli-0.0.2.dist-info/entry_points.txt +3 -0
|
@@ -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
|