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 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
+ ...