dexverse 0.1.0__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 +35 -0
- dexverse/cli.py +226 -0
- dexverse/client.py +107 -0
- dexverse/config.py +85 -0
- dexverse/downloader/__init__.py +26 -0
- dexverse/downloader/base.py +82 -0
- dexverse/downloader/http_backend.py +184 -0
- dexverse/downloader/manager.py +282 -0
- dexverse/downloader/python_s3_backend.py +132 -0
- dexverse/downloader/s5cmd_backend.py +162 -0
- dexverse/downloader/state.py +240 -0
- dexverse/downloader/verify.py +113 -0
- dexverse/manifest.py +193 -0
- dexverse/mock/__init__.py +2 -0
- dexverse/mock/mock_tasks.json +22 -0
- dexverse/mock/server.py +211 -0
- dexverse-0.1.0.dist-info/METADATA +163 -0
- dexverse-0.1.0.dist-info/RECORD +20 -0
- dexverse-0.1.0.dist-info/WHEEL +4 -0
- dexverse-0.1.0.dist-info/entry_points.txt +3 -0
dexverse/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""DexVerse Downloader — S3-compatible dataset downloader.
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
|
|
5
|
+
from dexverse import DexVerseClient, download_manifest
|
|
6
|
+
from dexverse import Manifest, FileSpec
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .client import DexVerseClient, DexVerseError, download_manifest
|
|
11
|
+
from .manifest import (
|
|
12
|
+
FileSpec,
|
|
13
|
+
Manifest,
|
|
14
|
+
Storage,
|
|
15
|
+
StorageCredentials,
|
|
16
|
+
DownloadOptions,
|
|
17
|
+
resolve_dest,
|
|
18
|
+
resolve_relpath,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"DexVerseClient",
|
|
25
|
+
"DexVerseError",
|
|
26
|
+
"download_manifest",
|
|
27
|
+
"Manifest",
|
|
28
|
+
"FileSpec",
|
|
29
|
+
"Storage",
|
|
30
|
+
"StorageCredentials",
|
|
31
|
+
"DownloadOptions",
|
|
32
|
+
"resolve_dest",
|
|
33
|
+
"resolve_relpath",
|
|
34
|
+
"__version__",
|
|
35
|
+
]
|
dexverse/cli.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Command-line interface (doc §10).
|
|
2
|
+
|
|
3
|
+
dexverse-dl login --server URL [--token TOKEN]
|
|
4
|
+
dexverse-dl download --manifest PATH | --task ID --out DIR
|
|
5
|
+
[--parallel N] [--backend s5cmd|python]
|
|
6
|
+
[--server URL] [--no-verify] [--deep]
|
|
7
|
+
dexverse-dl verify --manifest PATH --out DIR [--deep]
|
|
8
|
+
dexverse-dl repair --manifest PATH --out DIR [--backend python] [--deep]
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
from rich.table import Table
|
|
18
|
+
|
|
19
|
+
from .client import DexVerseClient, DexVerseError, download_manifest
|
|
20
|
+
from .config import config_path, save_profile
|
|
21
|
+
from .downloader.manager import DownloadManager, repair as repair_fn
|
|
22
|
+
from .downloader.s5cmd_backend import S5CmdNotAvailable
|
|
23
|
+
from .downloader.verify import verify_files, summarize
|
|
24
|
+
from .manifest import Manifest
|
|
25
|
+
|
|
26
|
+
app = typer.Typer(
|
|
27
|
+
name="dexverse-dl",
|
|
28
|
+
help="DexVerse dataset downloader (S3-compatible).",
|
|
29
|
+
no_args_is_help=True,
|
|
30
|
+
)
|
|
31
|
+
console = Console()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# --------------------------------------------------------------------------- #
|
|
35
|
+
# login
|
|
36
|
+
# --------------------------------------------------------------------------- #
|
|
37
|
+
@app.command(help="Save a server + token to the active profile.")
|
|
38
|
+
def login(
|
|
39
|
+
server: str = typer.Option(..., "--server", "-s", help="Platform base URL"),
|
|
40
|
+
token: Optional[str] = typer.Option(
|
|
41
|
+
None, "--token", "-t", help="Bearer token (dev-token in mock mode)"
|
|
42
|
+
),
|
|
43
|
+
profile: str = typer.Option("default", "--profile", help="Profile name"),
|
|
44
|
+
):
|
|
45
|
+
save_profile(profile, server, token)
|
|
46
|
+
console.print(f"[green]✓[/green] Saved profile [bold]{profile}[/bold] → {server}")
|
|
47
|
+
console.print(f" config: {config_path()}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# --------------------------------------------------------------------------- #
|
|
51
|
+
# download
|
|
52
|
+
# --------------------------------------------------------------------------- #
|
|
53
|
+
@app.command(help="Download by --manifest (Phase 0) or --task (Phase 1+).")
|
|
54
|
+
def download(
|
|
55
|
+
out: Path = typer.Option(..., "--out", "-o", help="Local download root"),
|
|
56
|
+
manifest: Optional[Path] = typer.Option(
|
|
57
|
+
None, "--manifest", "-m", help="Local manifest.json path"
|
|
58
|
+
),
|
|
59
|
+
task: Optional[str] = typer.Option(None, "--task", help="Platform download taskId"),
|
|
60
|
+
parallel: Optional[int] = typer.Option(None, "--parallel", "-p"),
|
|
61
|
+
backend: str = typer.Option("s5cmd", "--backend", "-b",
|
|
62
|
+
help="s5cmd | python | http (presigned URLs)"),
|
|
63
|
+
server: Optional[str] = typer.Option(None, "--server", help="Override profile server"),
|
|
64
|
+
token: Optional[str] = typer.Option(None, "--token", help="Override profile token"),
|
|
65
|
+
no_verify: bool = typer.Option(False, "--no-verify", help="Skip post-download verify"),
|
|
66
|
+
deep: bool = typer.Option(False, "--deep", help="Also verify sha256 where recorded"),
|
|
67
|
+
insecure: bool = typer.Option(False, "--insecure", "-k",
|
|
68
|
+
help="Skip TLS verify (self-signed/internal endpoints)"),
|
|
69
|
+
):
|
|
70
|
+
if (manifest is None) == (task is None):
|
|
71
|
+
console.print("[red]error:[/red] pass exactly one of --manifest or --task")
|
|
72
|
+
raise typer.Exit(code=2)
|
|
73
|
+
|
|
74
|
+
_warn_insecure(insecure, backend)
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
if manifest is not None:
|
|
78
|
+
report = download_manifest(
|
|
79
|
+
manifest, out, parallel=parallel, backend=backend,
|
|
80
|
+
verify=not no_verify, deep=deep, insecure=insecure,
|
|
81
|
+
)
|
|
82
|
+
else:
|
|
83
|
+
# download_task returns the out-path per §11.1; run the manager here
|
|
84
|
+
# so the CLI can print a full report.
|
|
85
|
+
with DexVerseClient(server=server, token=token) as client:
|
|
86
|
+
man = client.get_manifest(task)
|
|
87
|
+
report = DownloadManager(
|
|
88
|
+
out, backend=backend, parallel=parallel,
|
|
89
|
+
verify=not no_verify, deep=deep, insecure=insecure,
|
|
90
|
+
).run(man)
|
|
91
|
+
except S5CmdNotAvailable as exc:
|
|
92
|
+
console.print(f"[red]s5cmd not available:[/red] {exc}")
|
|
93
|
+
console.print(" hint: re-run with [cyan]--backend python[/cyan]")
|
|
94
|
+
raise typer.Exit(code=2)
|
|
95
|
+
except DexVerseError as exc:
|
|
96
|
+
console.print(f"[red]error:[/red] {exc}")
|
|
97
|
+
raise typer.Exit(code=1)
|
|
98
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
99
|
+
console.print(f"[red]error:[/red] {exc}")
|
|
100
|
+
raise typer.Exit(code=1)
|
|
101
|
+
|
|
102
|
+
_print_report(report)
|
|
103
|
+
if not report.ok:
|
|
104
|
+
raise typer.Exit(code=1)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# --------------------------------------------------------------------------- #
|
|
108
|
+
# verify
|
|
109
|
+
# --------------------------------------------------------------------------- #
|
|
110
|
+
@app.command(help="Verify downloaded files against a manifest (size, or --deep sha256).")
|
|
111
|
+
def verify(
|
|
112
|
+
manifest: Path = typer.Option(..., "--manifest", "-m"),
|
|
113
|
+
out: Path = typer.Option(..., "--out", "-o"),
|
|
114
|
+
deep: bool = typer.Option(False, "--deep", help="Also check sha256"),
|
|
115
|
+
):
|
|
116
|
+
try:
|
|
117
|
+
man = Manifest.parse_file(manifest)
|
|
118
|
+
results = verify_files(man.files, out, deep=deep)
|
|
119
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
120
|
+
console.print(f"[red]error:[/red] {exc}")
|
|
121
|
+
raise typer.Exit(code=1)
|
|
122
|
+
summary = summarize(results)
|
|
123
|
+
|
|
124
|
+
table = Table(title=f"Verify — task {man.effective_task_id()}")
|
|
125
|
+
table.add_column("status")
|
|
126
|
+
table.add_column("count", justify="right")
|
|
127
|
+
table.add_row("[green]ok[/green]", str(summary.ok))
|
|
128
|
+
table.add_row("missing", str(summary.missing))
|
|
129
|
+
table.add_row("size mismatch", str(summary.size_mismatch))
|
|
130
|
+
table.add_row("sha256 mismatch", str(summary.sha256_mismatch))
|
|
131
|
+
console.print(table)
|
|
132
|
+
|
|
133
|
+
for r in summary.bad_results:
|
|
134
|
+
console.print(f" [red]✗[/red] {r.relpath} ({r.reason})")
|
|
135
|
+
|
|
136
|
+
console.print(
|
|
137
|
+
f"\n{summary.ok}/{summary.total} files verified"
|
|
138
|
+
f"{' [bold green]all good[/bold green]' if summary.all_ok else ''}"
|
|
139
|
+
)
|
|
140
|
+
if not summary.all_ok:
|
|
141
|
+
raise typer.Exit(code=1)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# --------------------------------------------------------------------------- #
|
|
145
|
+
# repair
|
|
146
|
+
# --------------------------------------------------------------------------- #
|
|
147
|
+
@app.command(help="Re-download missing / size-mismatched / sha256-mismatched files. "
|
|
148
|
+
"Use --task to fetch a fresh manifest (refreshes expired presigned URLs).")
|
|
149
|
+
def repair(
|
|
150
|
+
out: Path = typer.Option(..., "--out", "-o"),
|
|
151
|
+
manifest: Optional[Path] = typer.Option(None, "--manifest", "-m"),
|
|
152
|
+
task: Optional[str] = typer.Option(None, "--task", help="Refresh manifest from backend"),
|
|
153
|
+
server: Optional[str] = typer.Option(None, "--server"),
|
|
154
|
+
token: Optional[str] = typer.Option(None, "--token"),
|
|
155
|
+
backend: str = typer.Option("python", "--backend", "-b",
|
|
156
|
+
help="default python (no external binary needed)"),
|
|
157
|
+
parallel: Optional[int] = typer.Option(None, "--parallel", "-p"),
|
|
158
|
+
deep: bool = typer.Option(False, "--deep", help="Detect sha256 corruption too"),
|
|
159
|
+
insecure: bool = typer.Option(False, "--insecure", "-k",
|
|
160
|
+
help="Skip TLS verify (self-signed/internal endpoints)"),
|
|
161
|
+
):
|
|
162
|
+
if (manifest is None) == (task is None):
|
|
163
|
+
console.print("[red]error:[/red] pass exactly one of --manifest or --task")
|
|
164
|
+
raise typer.Exit(code=2)
|
|
165
|
+
|
|
166
|
+
_warn_insecure(insecure, backend)
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
if task is not None:
|
|
170
|
+
# fresh manifest = fresh presigned URLs (the persisted copy's may be expired)
|
|
171
|
+
with DexVerseClient(server=server, token=token) as client:
|
|
172
|
+
man = client.get_manifest(task)
|
|
173
|
+
else:
|
|
174
|
+
man = Manifest.parse_file(manifest)
|
|
175
|
+
report = repair_fn(man, out, backend=backend, parallel=parallel,
|
|
176
|
+
deep=deep, insecure=insecure)
|
|
177
|
+
except S5CmdNotAvailable as exc:
|
|
178
|
+
console.print(f"[red]s5cmd not available:[/red] {exc}")
|
|
179
|
+
console.print(" hint: re-run with [cyan]--backend python[/cyan]")
|
|
180
|
+
raise typer.Exit(code=2)
|
|
181
|
+
except DexVerseError as exc:
|
|
182
|
+
console.print(f"[red]error:[/red] {exc}")
|
|
183
|
+
raise typer.Exit(code=1)
|
|
184
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
185
|
+
console.print(f"[red]error:[/red] {exc}")
|
|
186
|
+
raise typer.Exit(code=1)
|
|
187
|
+
_print_report(report)
|
|
188
|
+
if not report.ok:
|
|
189
|
+
raise typer.Exit(code=1)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# --------------------------------------------------------------------------- #
|
|
193
|
+
# helpers
|
|
194
|
+
# --------------------------------------------------------------------------- #
|
|
195
|
+
def _warn_insecure(insecure: bool, backend: str) -> None:
|
|
196
|
+
if not insecure:
|
|
197
|
+
return
|
|
198
|
+
backend = (backend or "").lower()
|
|
199
|
+
if backend in ("http", "https", "presigned"):
|
|
200
|
+
console.print(
|
|
201
|
+
"[yellow]WARNING:[/yellow] --insecure disables TLS verification. For "
|
|
202
|
+
"self-authenticating presigned URLs, TLS is the only transport-integrity "
|
|
203
|
+
"check — any certificate will be accepted and redirects followed."
|
|
204
|
+
)
|
|
205
|
+
else:
|
|
206
|
+
console.print(
|
|
207
|
+
"[yellow]WARNING:[/yellow] --insecure disables TLS verification "
|
|
208
|
+
"(self-signed/internal endpoint)."
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _print_report(report) -> None:
|
|
213
|
+
console.print(f"\n[bold]Download report[/bold] — {report.summary_line()}")
|
|
214
|
+
console.print(f" out: {report.out}")
|
|
215
|
+
console.print(f" task dir: {report.task_dir}")
|
|
216
|
+
console.print(f" state: {report.state_path}")
|
|
217
|
+
if report.failures:
|
|
218
|
+
console.print(f" [red]failures:[/red]")
|
|
219
|
+
for fl in report.failures[:20]:
|
|
220
|
+
console.print(f" ✗ {fl['relpath']} — {fl.get('error')}")
|
|
221
|
+
if len(report.failures) > 20:
|
|
222
|
+
console.print(f" ... and {len(report.failures) - 20} more")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
if __name__ == "__main__":
|
|
226
|
+
app()
|
dexverse/client.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Python SDK client (doc §11).
|
|
2
|
+
|
|
3
|
+
``DexVerseClient`` talks to the platform backend to fetch a manifest for a
|
|
4
|
+
taskId, then hands it to the download manager. ``download_manifest`` runs the
|
|
5
|
+
manager directly off a local manifest file (Phase 0, no server).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional, Union
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from .config import active_profile
|
|
15
|
+
from .downloader.manager import DownloadManager, DownloadReport, get_backend # noqa: F401
|
|
16
|
+
from .manifest import Manifest
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DexVerseError(RuntimeError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DexVerseClient:
|
|
24
|
+
def __init__(self, server: Optional[str] = None, token: Optional[str] = None, *,
|
|
25
|
+
timeout: float = 30.0):
|
|
26
|
+
# Fall back to the active profile saved by `dexverse-dl login`.
|
|
27
|
+
if not server or not token:
|
|
28
|
+
prof = active_profile()
|
|
29
|
+
if prof:
|
|
30
|
+
server = server or prof.server
|
|
31
|
+
token = token if token is not None else prof.token
|
|
32
|
+
if not server:
|
|
33
|
+
raise DexVerseError("no server configured (run `dexverse-dl login` first)")
|
|
34
|
+
self.server = server.rstrip("/")
|
|
35
|
+
self.token = token
|
|
36
|
+
self._client = httpx.Client(timeout=timeout)
|
|
37
|
+
|
|
38
|
+
def _headers(self) -> dict:
|
|
39
|
+
h = {"Accept": "application/json"}
|
|
40
|
+
if self.token:
|
|
41
|
+
h["Authorization"] = f"Bearer {self.token}"
|
|
42
|
+
return h
|
|
43
|
+
|
|
44
|
+
def get_manifest(self, task_id: str) -> Manifest:
|
|
45
|
+
url = f"{self.server}/api/download-tasks/{task_id}/manifest"
|
|
46
|
+
resp = self._client.get(url, headers=self._headers())
|
|
47
|
+
if resp.status_code == 404:
|
|
48
|
+
raise DexVerseError(f"task not found: {task_id}")
|
|
49
|
+
if resp.status_code in (401, 403):
|
|
50
|
+
raise DexVerseError(f"auth failed ({resp.status_code}) for task {task_id}")
|
|
51
|
+
resp.raise_for_status()
|
|
52
|
+
return Manifest.model_validate(resp.json())
|
|
53
|
+
|
|
54
|
+
def download_task(self, task_id: str, out: Union[str, Path], *,
|
|
55
|
+
parallel: Optional[int] = None,
|
|
56
|
+
backend: str = "s5cmd",
|
|
57
|
+
verify: bool = True,
|
|
58
|
+
deep: bool = False,
|
|
59
|
+
insecure: bool = False) -> Path:
|
|
60
|
+
"""Download a task by id; returns the local out directory (doc §11.1).
|
|
61
|
+
|
|
62
|
+
``local_root = client.download_task(...)`` is the user-supplied ``out``,
|
|
63
|
+
with files landed at their original relative paths. Raises DexVerseError
|
|
64
|
+
if the run finishes with failures.
|
|
65
|
+
"""
|
|
66
|
+
manifest = self.get_manifest(task_id)
|
|
67
|
+
mgr = DownloadManager(out, backend=backend, parallel=parallel,
|
|
68
|
+
verify=verify, deep=deep, insecure=insecure)
|
|
69
|
+
report = mgr.run(manifest)
|
|
70
|
+
if not report.ok:
|
|
71
|
+
raise DexVerseError(
|
|
72
|
+
f"download completed with {report.failed + report.corrupt} problem(s): "
|
|
73
|
+
+ report.summary_line()
|
|
74
|
+
)
|
|
75
|
+
return report.out
|
|
76
|
+
|
|
77
|
+
def close(self) -> None:
|
|
78
|
+
self._client.close()
|
|
79
|
+
|
|
80
|
+
def __enter__(self) -> "DexVerseClient":
|
|
81
|
+
return self
|
|
82
|
+
|
|
83
|
+
def __exit__(self, *exc) -> None:
|
|
84
|
+
self.close()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def download_manifest(manifest_path: Union[str, Path], out: Union[str, Path], *,
|
|
88
|
+
parallel: Optional[int] = None,
|
|
89
|
+
backend: str = "s5cmd",
|
|
90
|
+
verify: bool = True,
|
|
91
|
+
deep: bool = False,
|
|
92
|
+
insecure: bool = False) -> DownloadReport:
|
|
93
|
+
"""Run a download straight from a local manifest file (Phase 0).
|
|
94
|
+
|
|
95
|
+
Returns the detailed DownloadReport (counts + failures). Note this differs
|
|
96
|
+
from ``DexVerseClient.download_task``, which returns the out path per §11.1.
|
|
97
|
+
"""
|
|
98
|
+
manifest = Manifest.parse_file(Path(manifest_path))
|
|
99
|
+
mgr = DownloadManager(out, backend=backend, parallel=parallel,
|
|
100
|
+
verify=verify, deep=deep, insecure=insecure)
|
|
101
|
+
report = mgr.run(manifest)
|
|
102
|
+
if not report.ok:
|
|
103
|
+
raise DexVerseError(
|
|
104
|
+
f"download completed with {report.failed + report.corrupt} problem(s): "
|
|
105
|
+
+ report.summary_line()
|
|
106
|
+
)
|
|
107
|
+
return report
|
dexverse/config.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Login / server configuration.
|
|
2
|
+
|
|
3
|
+
Stores the active server URL and bearer token per *profile* in a TOML file at
|
|
4
|
+
``~/.dexverse/config.toml``. The file is created with mode 0600 where the OS
|
|
5
|
+
supports it. Secrets are never written into the manifest or into Git
|
|
6
|
+
(see doc §13.1).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
import tomlkit
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def config_path() -> Path:
|
|
19
|
+
"""Location of the CLI config file (overridable via DEXVERSE_CONFIG)."""
|
|
20
|
+
override = os.getenv("DEXVERSE_CONFIG")
|
|
21
|
+
if override:
|
|
22
|
+
return Path(override)
|
|
23
|
+
return Path.home() / ".dexverse" / "config.toml"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Profile:
|
|
28
|
+
server: str
|
|
29
|
+
token: Optional[str] = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _read() -> tomlkit.TOMLDocument:
|
|
33
|
+
p = config_path()
|
|
34
|
+
if not p.exists():
|
|
35
|
+
return tomlkit.document()
|
|
36
|
+
try:
|
|
37
|
+
return tomlkit.parse(p.read_text(encoding="utf-8"))
|
|
38
|
+
except Exception:
|
|
39
|
+
return tomlkit.document()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _write(doc: tomlkit.TOMLDocument) -> None:
|
|
43
|
+
p = config_path()
|
|
44
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
# Best-effort restrictive permissions (POSIX only; no-op on Windows).
|
|
46
|
+
p.write_text(tomlkit.dumps(doc), encoding="utf-8")
|
|
47
|
+
try:
|
|
48
|
+
os.chmod(p, 0o600)
|
|
49
|
+
except OSError:
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def save_profile(name: str, server: str, token: Optional[str]) -> None:
|
|
54
|
+
doc = _read()
|
|
55
|
+
profiles = doc.get("profiles")
|
|
56
|
+
if not isinstance(profiles, tomlkit.items.Table) or profiles is None:
|
|
57
|
+
profiles = tomlkit.table()
|
|
58
|
+
doc["profiles"] = profiles
|
|
59
|
+
table = tomlkit.table()
|
|
60
|
+
table["server"] = server
|
|
61
|
+
if token:
|
|
62
|
+
table["token"] = token
|
|
63
|
+
profiles[name] = table # type: ignore[assignment]
|
|
64
|
+
doc["active"] = name
|
|
65
|
+
_write(doc)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def load_profile(name: Optional[str] = None) -> Optional[Profile]:
|
|
69
|
+
"""Load the named profile, or the active one if name is None."""
|
|
70
|
+
doc = _read()
|
|
71
|
+
profiles = doc.get("profiles")
|
|
72
|
+
if not isinstance(profiles, dict):
|
|
73
|
+
return None
|
|
74
|
+
target = name or doc.get("active")
|
|
75
|
+
if not target or target not in profiles:
|
|
76
|
+
return None
|
|
77
|
+
row = profiles[target]
|
|
78
|
+
server = row.get("server")
|
|
79
|
+
if not server:
|
|
80
|
+
return None
|
|
81
|
+
return Profile(server=server, token=row.get("token"))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def active_profile() -> Optional[Profile]:
|
|
85
|
+
return load_profile(None)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Download core: manager, backends, verify, state."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from .manager import DownloadManager, DownloadReport, get_backend, repair
|
|
5
|
+
from .base import Backend, Credentials, FileResult
|
|
6
|
+
from .s5cmd_backend import S5CmdBackend, S5CmdNotAvailable, generate_s5cmd_lines
|
|
7
|
+
from .python_s3_backend import PythonS3Backend
|
|
8
|
+
from .http_backend import HttpBackend
|
|
9
|
+
from .verify import verify_files, summarize
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"DownloadManager",
|
|
13
|
+
"DownloadReport",
|
|
14
|
+
"get_backend",
|
|
15
|
+
"repair",
|
|
16
|
+
"Backend",
|
|
17
|
+
"Credentials",
|
|
18
|
+
"FileResult",
|
|
19
|
+
"S5CmdBackend",
|
|
20
|
+
"S5CmdNotAvailable",
|
|
21
|
+
"generate_s5cmd_lines",
|
|
22
|
+
"PythonS3Backend",
|
|
23
|
+
"HttpBackend",
|
|
24
|
+
"verify_files",
|
|
25
|
+
"summarize",
|
|
26
|
+
]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Backend contract + shared credential resolution.
|
|
2
|
+
|
|
3
|
+
A *backend* takes the manifest, the subset of files that still need fetching,
|
|
4
|
+
the out root, and returns a per-object success/failure outcome. The download
|
|
5
|
+
manager owns skip/resume/verify; backends only move bytes.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Dict, List, Optional, Protocol
|
|
13
|
+
|
|
14
|
+
from ..manifest import FileSpec, Manifest
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Credentials:
|
|
19
|
+
access_key: Optional[str]
|
|
20
|
+
secret_key: Optional[str]
|
|
21
|
+
session_token: Optional[str]
|
|
22
|
+
region: Optional[str]
|
|
23
|
+
endpoint: Optional[str]
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def from_manifest(cls, manifest: Manifest) -> "Credentials":
|
|
27
|
+
"""Prefer manifest credentials; fall back to AWS_* env vars."""
|
|
28
|
+
storage = manifest.storage
|
|
29
|
+
endpoint = storage.endpoint if storage else None
|
|
30
|
+
region = (storage.region if storage else None) or os.getenv("AWS_REGION") \
|
|
31
|
+
or os.getenv("AWS_DEFAULT_REGION") or "us-east-1"
|
|
32
|
+
|
|
33
|
+
ak = sk = st = None
|
|
34
|
+
if storage and storage.credentials and storage.credentials.is_populated():
|
|
35
|
+
ak = storage.credentials.accessKey
|
|
36
|
+
sk = storage.credentials.secretKey
|
|
37
|
+
st = storage.credentials.sessionToken or None
|
|
38
|
+
else:
|
|
39
|
+
ak = os.getenv("AWS_ACCESS_KEY_ID")
|
|
40
|
+
sk = os.getenv("AWS_SECRET_ACCESS_KEY")
|
|
41
|
+
st = os.getenv("AWS_SESSION_TOKEN") or None
|
|
42
|
+
|
|
43
|
+
return cls(
|
|
44
|
+
access_key=ak, secret_key=sk, session_token=st,
|
|
45
|
+
region=region, endpoint=endpoint,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def as_env(self) -> Dict[str, str]:
|
|
49
|
+
"""A dict suitable for merging into a subprocess environment."""
|
|
50
|
+
env: Dict[str, str] = {}
|
|
51
|
+
if self.access_key:
|
|
52
|
+
env["AWS_ACCESS_KEY_ID"] = self.access_key
|
|
53
|
+
if self.secret_key:
|
|
54
|
+
env["AWS_SECRET_ACCESS_KEY"] = self.secret_key
|
|
55
|
+
if self.session_token:
|
|
56
|
+
env["AWS_SESSION_TOKEN"] = self.session_token
|
|
57
|
+
if self.region:
|
|
58
|
+
env["AWS_REGION"] = self.region
|
|
59
|
+
env["AWS_DEFAULT_REGION"] = self.region
|
|
60
|
+
return env
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class FileResult:
|
|
65
|
+
objectKey: str
|
|
66
|
+
ok: bool
|
|
67
|
+
error: Optional[str] = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Backend(Protocol):
|
|
71
|
+
name: str
|
|
72
|
+
|
|
73
|
+
def download(
|
|
74
|
+
self,
|
|
75
|
+
manifest: Manifest,
|
|
76
|
+
files: List[FileSpec],
|
|
77
|
+
out: Path,
|
|
78
|
+
*,
|
|
79
|
+
parallel: int,
|
|
80
|
+
log_path: Optional[Path] = None,
|
|
81
|
+
) -> Dict[str, FileResult]:
|
|
82
|
+
...
|