vizo 0.0.1__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.
vizo/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """VIZO command-line tool and, later, Python SDK.
2
+
3
+ The `vizo` command (datasets, download, verify) is in cli.py; the manifest
4
+ format it reads is in manifest.py. "design §N" in comments refers to
5
+ roadmap_v2/cli_design.md in the vizo-platform repository.
6
+ """
7
+
8
+ __version__ = "0.0.1"
vizo/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
vizo/api.py ADDED
@@ -0,0 +1,221 @@
1
+ """HTTP client for the platform's /api/v1 (design §2-§4).
2
+
3
+ Authenticates with the API key from VIZO_API_KEY, sends the CLI version and the
4
+ run id with every request, and turns refusals into ApiError with the code and
5
+ message the platform returned. Standard library only, so the CLI installs
6
+ anywhere Python does.
7
+ """
8
+
9
+ import json
10
+ import os
11
+ import re
12
+ import socket
13
+ import time
14
+ import urllib.error
15
+ import urllib.parse
16
+ import urllib.request
17
+ from dataclasses import dataclass
18
+ from typing import Any, Dict, List, Optional, Tuple
19
+
20
+ from . import __version__
21
+
22
+ DEFAULT_API_URL = "https://vizo-platform.vercel.app"
23
+ ENV_API_KEY = "VIZO_API_KEY"
24
+ ENV_API_URL = "VIZO_API_URL"
25
+ KEY_PREFIX = "vz_sk_live_"
26
+ # The platform issues the prefix plus base64url characters. Anything else (a
27
+ # line break from `$(cat keyfile)`, a space) must stop here: the HTTP layer
28
+ # would refuse the header with an error that spells the key out.
29
+ KEY_PATTERN = re.compile(re.escape(KEY_PREFIX) + r"[A-Za-z0-9_-]+")
30
+ LOCAL_HOSTS = ("localhost", "127.0.0.1", "::1")
31
+
32
+ RETRIES = 4
33
+ RETRY_STATUSES = {502, 503, 504}
34
+
35
+
36
+ class ApiError(Exception):
37
+ """A refusal or failure from the platform, with the code it gave."""
38
+
39
+ def __init__(self, status: int, code: str, message: str, extra: Optional[Dict[str, Any]] = None):
40
+ super().__init__(message)
41
+ self.status = status
42
+ self.code = code
43
+ self.message = message
44
+ self.extra = extra or {}
45
+
46
+ def __str__(self) -> str:
47
+ hint = ""
48
+ if self.extra.get("license_url"):
49
+ hint = f"\nRequest a license at {self.extra['license_url']}"
50
+ elif self.extra.get("api_keys_url"):
51
+ hint = f"\nManage keys at {self.extra['api_keys_url']}"
52
+ return f"{self.message} [{self.code}]{hint}"
53
+
54
+
55
+ @dataclass
56
+ class Link:
57
+ file_id: str
58
+ url: str
59
+ expires_at: str
60
+
61
+
62
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
63
+ def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[override]
64
+ return None
65
+
66
+
67
+ def api_key_from_env(api_url: Optional[str] = None) -> str:
68
+ key = os.environ.get(ENV_API_KEY, "").strip()
69
+ if not key:
70
+ raise ApiError(0, "missing_key", f"Set {ENV_API_KEY} to your API key.",
71
+ {"api_keys_url": resolve_api_url(api_url) + "/api-keys"})
72
+ if not key.startswith(KEY_PREFIX):
73
+ raise ApiError(0, "invalid_key", f"{ENV_API_KEY} does not look like a VIZO key (expected {KEY_PREFIX}...).")
74
+ if not KEY_PATTERN.fullmatch(key):
75
+ raise ApiError(0, "invalid_key", f"{ENV_API_KEY} has characters a VIZO key never contains "
76
+ "(a space or a line break inside it?); set it to the key alone.")
77
+ return key
78
+
79
+
80
+ def resolve_api_url(api_url: Optional[str] = None) -> str:
81
+ url = (api_url or os.environ.get(ENV_API_URL) or DEFAULT_API_URL).rstrip("/")
82
+ u = urllib.parse.urlparse(url)
83
+ if u.scheme != "https" and not (u.scheme == "http" and u.hostname in LOCAL_HOSTS):
84
+ raise ValueError(f"the API URL {url} must use https (plain http only for localhost), "
85
+ "or your API key would travel in cleartext")
86
+ return url
87
+
88
+
89
+ class PlatformClient:
90
+ def __init__(self, api_key: str, run_id: str, api_url: Optional[str] = None, timeout: int = 60):
91
+ self.api_key = api_key
92
+ self.run_id = run_id
93
+ self.api_url = resolve_api_url(api_url)
94
+ self.timeout = timeout
95
+ self.key_expires_at: Optional[str] = None
96
+ # Redirects are handled by hand: the manifest endpoint may answer 302 to
97
+ # a presigned R2 URL, which must be fetched WITHOUT our bearer header.
98
+ self._opener = urllib.request.build_opener(_NoRedirect)
99
+
100
+ # -- plumbing ---------------------------------------------------------
101
+
102
+ def _headers(self, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]:
103
+ h = {
104
+ "Authorization": f"Bearer {self.api_key}",
105
+ "X-Vizo-CLI-Version": __version__,
106
+ "X-Vizo-Run-Id": self.run_id,
107
+ "User-Agent": f"vizo-cli/{__version__}",
108
+ "Accept": "application/json, text/csv",
109
+ }
110
+ if extra:
111
+ h.update(extra)
112
+ return h
113
+
114
+ def _request(self, method: str, path: str, body: Optional[Dict[str, Any]] = None) -> Tuple[int, Dict[str, str], bytes]:
115
+ data = json.dumps(body).encode() if body is not None else None
116
+ headers = self._headers({"Content-Type": "application/json"} if data else None)
117
+ return self._open(urllib.request.Request(self.api_url + path, data=data, method=method, headers=headers),
118
+ self.api_url)
119
+
120
+ def _open(self, req: urllib.request.Request, where: str) -> Tuple[int, Dict[str, str], bytes]:
121
+ last_error: Optional[Exception] = None
122
+ for attempt in range(RETRIES):
123
+ try:
124
+ with self._opener.open(req, timeout=self.timeout) as resp:
125
+ return resp.status, dict(resp.headers), resp.read()
126
+ except urllib.error.HTTPError as e:
127
+ payload = e.read()
128
+ if self._retryable(e.code, payload) and attempt < RETRIES - 1:
129
+ last_error = e
130
+ time.sleep(2 ** attempt)
131
+ continue
132
+ if 300 <= e.code < 400:
133
+ return e.code, dict(e.headers), payload
134
+ err = self._api_error(e.code, payload)
135
+ if e.headers.get("Retry-After"):
136
+ err.extra["retry_after"] = e.headers["Retry-After"]
137
+ raise err
138
+ except (urllib.error.URLError, socket.timeout, ConnectionError) as e:
139
+ last_error = e
140
+ if attempt < RETRIES - 1:
141
+ time.sleep(2 ** attempt)
142
+ continue
143
+ raise ApiError(0, "unreachable", f"Could not reach {where}: {last_error}")
144
+
145
+ @classmethod
146
+ def _retryable(cls, status: int, payload: bytes) -> bool:
147
+ if status == 503:
148
+ # the platform marks a transient 503 `unavailable`; one without a JSON error comes from a proxy
149
+ return cls._api_error(status, payload).code in ("unavailable", f"http_{status}")
150
+ return status in RETRY_STATUSES
151
+
152
+ @staticmethod
153
+ def _api_error(status: int, payload: bytes) -> ApiError:
154
+ try:
155
+ err = json.loads(payload.decode("utf-8"))["error"]
156
+ code, message = err.pop("code"), err.pop("message")
157
+ return ApiError(status, code, message, err)
158
+ except (ValueError, KeyError, TypeError):
159
+ return ApiError(status, f"http_{status}", payload.decode("utf-8", "replace")[:300] or f"HTTP {status}")
160
+
161
+ def _note_expiry(self, headers: Dict[str, str]) -> None:
162
+ exp = headers.get("X-Vizo-Key-Expires-At") or headers.get("x-vizo-key-expires-at")
163
+ if exp:
164
+ self.key_expires_at = exp
165
+
166
+ # -- endpoints --------------------------------------------------------
167
+
168
+ @staticmethod
169
+ def _unexpected(status: int, path: str, e: Exception) -> ApiError:
170
+ return ApiError(status, "unexpected_response", f"The platform sent an unexpected response to {path} ({e!r}).")
171
+
172
+ def datasets(self) -> List[Dict[str, Any]]:
173
+ path = "/api/v1/datasets"
174
+ status, headers, body = self._request("GET", path)
175
+ self._note_expiry(headers)
176
+ try:
177
+ data = json.loads(body)
178
+ datasets = data["datasets"]
179
+ if not all(isinstance(d, dict) and "id" in d for d in datasets):
180
+ raise TypeError("datasets entries must be objects with an id")
181
+ self.key_expires_at = data.get("key_expires_at") or self.key_expires_at
182
+ except (ValueError, KeyError, TypeError, AttributeError) as e:
183
+ raise self._unexpected(status, path, e)
184
+ return datasets
185
+
186
+ def manifest(self, dataset: str) -> bytes:
187
+ """The current release's manifest as bytes (gzip or plain CSV; the reader detects which)."""
188
+ path = f"/api/v1/datasets/{urllib.parse.quote(dataset)}/manifest"
189
+ try:
190
+ status, headers, body = self._request("GET", path)
191
+ except ApiError as e:
192
+ if e.code == "manifest_not_published":
193
+ raise ApiError(e.status, e.code, f"The current {dataset} release has not been published yet; "
194
+ "try again once it has.", e.extra)
195
+ raise
196
+ self._note_expiry(headers)
197
+ if status in (301, 302, 303, 307, 308):
198
+ location = headers.get("Location") or headers.get("location")
199
+ if not location:
200
+ raise ApiError(status, "bad_redirect", "The platform redirected without a location.")
201
+ # A presigned URL: no bearer header, or R2 rejects the request.
202
+ req = urllib.request.Request(location, headers={"User-Agent": f"vizo-cli/{__version__}"})
203
+ try:
204
+ status, _, body = self._open(req, "the manifest storage")
205
+ except ApiError as e:
206
+ # status 0: storage being unreachable or refusing says nothing about the key or license
207
+ reason = f"HTTP {e.status}" if e.status else e.message
208
+ raise ApiError(0, "manifest_fetch_failed", f"Fetching the manifest from storage failed: {reason}")
209
+ return body
210
+
211
+ def links(self, dataset: str, file_ids: List[str]) -> Tuple[List[Link], List[str]]:
212
+ path = f"/api/v1/datasets/{urllib.parse.quote(dataset)}/links"
213
+ status, headers, body = self._request("POST", path, {"file_ids": file_ids})
214
+ self._note_expiry(headers)
215
+ try:
216
+ data = json.loads(body)
217
+ links = [Link(l["file_id"], l["url"], l["expires_at"])
218
+ for l in data.get("links", [])]
219
+ return links, list(data.get("unknown_file_ids", []))
220
+ except (ValueError, KeyError, TypeError, AttributeError) as e:
221
+ raise self._unexpected(status, path, e)
vizo/cli.py ADDED
@@ -0,0 +1,387 @@
1
+ """The `vizo` command: datasets, download, verify (design §1, §5).
2
+
3
+ Exit codes: 0 done · 1 usage or local error · 2 authentication (key) ·
4
+ 3 not entitled · 4 download incomplete or platform unreachable.
5
+ """
6
+
7
+ import argparse
8
+ import datetime as dt
9
+ import io
10
+ import json
11
+ import os
12
+ import sys
13
+ import time
14
+ from typing import List, Optional
15
+
16
+ from . import __version__
17
+ from .api import ApiError, PlatformClient, api_key_from_env
18
+ from .download import Downloader, LocalWriteError, Progress
19
+ from .filters import Selection, Shard, select, shard_files, totals, unknown_values
20
+ from .manifest import ManifestError, parse_manifest
21
+ from .state import PathOutsideOutput, Workspace, free_bytes, new_run_id
22
+ from .verify import verify
23
+
24
+ EXIT_OK, EXIT_USAGE, EXIT_AUTH, EXIT_ENTITLEMENT, EXIT_INCOMPLETE = 0, 1, 2, 3, 4
25
+ KEY_WARNING_DAYS = 7
26
+ LOG_PROGRESS_SECONDS = 60
27
+ REFERENCE_BYTES_PER_SECOND = 10_000_000 / 8 # 10 Mbit/s, for the dry run's finish estimate
28
+
29
+
30
+ def human(n: float) -> str:
31
+ for unit in ("B", "KB", "MB", "GB", "TB", "PB"):
32
+ if n < 1024 or unit == "PB":
33
+ return f"{n:.1f} {unit}" if unit != "B" else f"{int(n)} B"
34
+ n /= 1024
35
+ return f"{n:.1f} PB"
36
+
37
+
38
+ def duration(seconds: float) -> str:
39
+ s = int(seconds)
40
+ if s < 60:
41
+ return f"{s}s"
42
+ if s < 3600:
43
+ return f"{s // 60}m {s % 60:02d}s"
44
+ return f"{s // 3600}h {s % 3600 // 60:02d}m"
45
+
46
+
47
+ def exit_code_for(e: ApiError) -> int:
48
+ if e.status in (401, 426) or e.code in ("missing_key", "invalid_key"):
49
+ return EXIT_AUTH
50
+ if e.code == "unknown_dataset":
51
+ return EXIT_USAGE
52
+ if e.status == 403:
53
+ return EXIT_ENTITLEMENT
54
+ return EXIT_INCOMPLETE
55
+
56
+
57
+ def key_expiry(client: PlatformClient) -> Optional[dt.date]:
58
+ """The key's expiry date, once a platform response has reported it."""
59
+ if not client.key_expires_at:
60
+ return None
61
+ try:
62
+ # only the date matters; the time part's format varies between deployments
63
+ return dt.date.fromisoformat(client.key_expires_at[:10])
64
+ except ValueError:
65
+ return None
66
+
67
+
68
+ def warn_key_expiry(client: PlatformClient) -> bool:
69
+ """Warn when the key expires soon. Returns whether the expiry was known."""
70
+ exp = key_expiry(client)
71
+ if exp is None:
72
+ return False
73
+ days = (exp - dt.datetime.now(dt.timezone.utc).date()).days
74
+ if days < KEY_WARNING_DAYS:
75
+ print(f"warning: your API key expires in {max(days, 0)} day(s) ({exp}); "
76
+ f"create a new one on the API Keys page before long downloads.", file=sys.stderr)
77
+ return True
78
+
79
+
80
+ # -- selectors shared by download and verify ---------------------------------
81
+
82
+ def add_selectors(p: argparse.ArgumentParser) -> None:
83
+ p.add_argument("--parts", "--datasets", nargs="+", metavar="PART",
84
+ help="parts to include (Ego-Exo4D --parts / Ego4D --datasets names); default: everything")
85
+ p.add_argument("--uids", "--video_uids", "--video-uids", nargs="+", metavar="UID",
86
+ help="video / take / capture uids (Ego-Exo4D take names also work)")
87
+ p.add_argument("--uid-file", "--video_uid_file", metavar="FILE", help="file with one uid per line")
88
+ p.add_argument("--universities", "-u", nargs="+", metavar="ID")
89
+ p.add_argument("--benchmarks", nargs="+", metavar="NAME", help="case-insensitive (EM, FHO, NLQ, keystep, ...)")
90
+ p.add_argument("--splits", nargs="+", metavar="SPLIT", help="train val test (Ego4D also accepts nlq:val style)")
91
+ p.add_argument("--views", nargs="+", choices=["ego", "exo"])
92
+ p.add_argument("--metadata", action="store_true", help="also fetch the metadata part (ego4d.json, takes.json, ...)")
93
+
94
+
95
+ def selection_from(args: argparse.Namespace) -> Selection:
96
+ uids = list(args.uids or [])
97
+ if args.uid_file:
98
+ with open(args.uid_file, encoding="utf-8") as fp:
99
+ uids += [line.strip() for line in fp if line.strip() and not line.startswith("#")]
100
+ return Selection.from_lists(parts=args.parts, uids=uids, universities=args.universities,
101
+ benchmarks=args.benchmarks, splits=args.splits, views=args.views,
102
+ include_metadata=args.metadata)
103
+
104
+
105
+ def check_selection(rows, sel: Selection) -> None:
106
+ """An unknown part is an error; any other selector value on no row is a warning."""
107
+ unknown = unknown_values(rows, sel)
108
+ if "parts" in unknown:
109
+ raise ValueError(f"unknown part(s): {' '.join(sorted(unknown.pop('parts')))}; "
110
+ f"this release has: {' '.join(sorted({f.part for f in rows}))}")
111
+ for name, values in unknown.items():
112
+ shown = sorted(values)
113
+ more = f" (and {len(shown) - 10} more)" if len(shown) > 10 else ""
114
+ print(f"warning: --{name} {' '.join(shown[:10])}{more}: on no file in the manifest", file=sys.stderr)
115
+
116
+
117
+ def print_plan(dataset: str, release: str, chosen, shard: Optional[Shard]) -> None:
118
+ t = totals(chosen)
119
+ meta = f" + {t.meta_files} metadata file{'s' if t.meta_files != 1 else ''} ({human(t.meta_bytes)})" if t.meta_files else ""
120
+ print(f"{dataset} {release}: {t.files} files, {human(t.bytes)}{meta}"
121
+ + (f" (shard {shard.index}/{shard.count})" if shard else ""))
122
+ for part, pt in sorted(t.by_part.items()):
123
+ extra = f" + {pt.meta_files} metadata ({human(pt.meta_bytes)})" if pt.meta_files else ""
124
+ print(f" {part:28} {pt.files:>8} files {human(pt.bytes):>10}{extra}")
125
+ if t.files == 0 and t.meta_files:
126
+ print("note: no data files match this selection; only the consortium metadata files that "
127
+ "accompany the chosen part(s) would be fetched.", file=sys.stderr)
128
+
129
+
130
+ def report_temp_files(ws: Workspace) -> None:
131
+ count, size = ws.temp_files()
132
+ if count:
133
+ print(f"note: {count} temporary *.part file(s) ({human(size)}) from unfinished downloads are in "
134
+ f"{ws.out_dir}; they are safe to delete when no other vizo run is using this folder.", file=sys.stderr)
135
+
136
+
137
+ # -- commands -----------------------------------------------------------------
138
+
139
+ def cmd_datasets(args: argparse.Namespace) -> int:
140
+ client = PlatformClient(api_key_from_env(args.api_url), new_run_id(), args.api_url)
141
+ datasets = client.datasets()
142
+ warn_key_expiry(client)
143
+ if args.json:
144
+ print(json.dumps({"datasets": datasets, "key_expires_at": client.key_expires_at}, indent=2))
145
+ return EXIT_OK
146
+ print(f"{'dataset':12} {'access':14} {'release':8} {'files':>8} {'size':>10}")
147
+ for d in datasets:
148
+ access = "download" if d.get("entitled") else (d.get("license_status") or "no license")
149
+ rel = d.get("release") or {}
150
+ print(f"{d['id']:12} {access:14} {rel.get('release', '-'):8} {rel.get('file_count', 0):>8} {human(rel.get('total_bytes', 0)):>10}")
151
+ if not d.get("entitled"):
152
+ label = {"pending": "under review", "approved": "license"}.get(d.get("license_status"), "request access")
153
+ print(f"{'':12} {label}: {d.get('license_url')}")
154
+ return EXIT_OK
155
+
156
+
157
+ def load_or_fetch_manifest(client: PlatformClient, ws: Workspace, dataset: str, refresh: bool):
158
+ if ws.has_manifest():
159
+ try:
160
+ header, rows = ws.load_manifest()
161
+ except ManifestError:
162
+ if not refresh:
163
+ raise
164
+ else:
165
+ # one dataset per folder, refreshing included
166
+ if header.dataset != dataset:
167
+ raise SystemExit(f"{ws.out_dir} already holds a {header.dataset} download; use another output folder.")
168
+ if not refresh:
169
+ return header, rows, "saved"
170
+ data = client.manifest(dataset)
171
+ try:
172
+ header, rows = parse_manifest(io.BytesIO(data))
173
+ except ManifestError as e:
174
+ raise ApiError(0, "bad_manifest", f"The platform sent a manifest this CLI cannot read ({e}); nothing was saved.")
175
+ ws.save_manifest(data)
176
+ return header, rows, "fetched"
177
+
178
+
179
+ def cmd_download(args: argparse.Namespace) -> int:
180
+ run_id = new_run_id()
181
+ client = PlatformClient(api_key_from_env(args.api_url), run_id, args.api_url)
182
+ ws = Workspace(args.output, run_id)
183
+ shard = Shard.parse(args.shard) if args.shard else None
184
+
185
+ sel = selection_from(args)
186
+ header, rows, source = load_or_fetch_manifest(client, ws, args.dataset, args.refresh_manifest)
187
+ expiry_known = warn_key_expiry(client)
188
+ check_selection(rows, sel)
189
+ chosen = select(rows, sel, args.dataset)
190
+ if shard:
191
+ chosen = shard_files(chosen, shard)
192
+ if not chosen:
193
+ print("Nothing matches the selection.", file=sys.stderr)
194
+ return EXIT_USAGE
195
+
196
+ print(f"manifest: {source} ({header.files} files in release {header.release})")
197
+ print_plan(args.dataset, header.release, chosen, shard)
198
+
199
+ tty = sys.stdout.isatty()
200
+ last_line = [0.0]
201
+
202
+ def on_progress(p: Progress) -> None:
203
+ if not tty:
204
+ # a job log gets a line about once a minute, not a redraw every half second
205
+ if last_line[0] and time.monotonic() - last_line[0] < LOG_PROGRESS_SECONDS:
206
+ return
207
+ last_line[0] = time.monotonic()
208
+ speed = p.speed()
209
+ left = p.bytes_total - p.bytes_skipped - p.bytes_received
210
+ eta = duration(left / speed) if speed > 0 else "?"
211
+ line = (f"{p.files_done + p.files_skipped}/{p.files_total} files "
212
+ f"{human(p.bytes_received + p.bytes_skipped)}/{human(p.bytes_total)} "
213
+ f"{human(speed)}/s eta {eta}")
214
+ if tty:
215
+ print(f"\r{line} ", end="", flush=True)
216
+ else:
217
+ print(line, flush=True)
218
+
219
+ def on_links() -> None:
220
+ # a resumed run's first request is for links: the first chance to learn the expiry
221
+ nonlocal expiry_known
222
+ if not expiry_known:
223
+ expiry_known = warn_key_expiry(client)
224
+
225
+ dl = Downloader(client, args.dataset, ws, chosen, workers=args.workers, on_progress=on_progress, on_links=on_links)
226
+ todo = dl.pending()
227
+ remaining = sum(f.size for f in todo)
228
+ print(f"to fetch: {len(todo)} files, {human(remaining)} ({dl.progress.files_skipped} already complete)")
229
+ if args.dry_run:
230
+ if not expiry_known:
231
+ # no response so far reported it (a saved manifest means no request at all): ask
232
+ try:
233
+ client.datasets()
234
+ except ApiError as e:
235
+ print(f"key: expiry could not be checked ({e})")
236
+ return EXIT_OK
237
+ exp = key_expiry(client)
238
+ if exp is None:
239
+ print("key: never expires")
240
+ else:
241
+ seconds = remaining / REFERENCE_BYTES_PER_SECOND
242
+ finish = dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=seconds)
243
+ when = "before" if exp <= finish.date() else "after"
244
+ print(f"key: expires {exp}, {when} the estimated finish ({duration(seconds)} at 10 Mbit/s)")
245
+ return EXIT_OK
246
+
247
+ free = free_bytes(ws.out_dir) # exists: the manifest was saved in it
248
+ if free is not None and free < remaining and not args.ignore_disk_space:
249
+ print(f"error: {human(free)} free at {ws.out_dir}, {human(remaining)} needed (--ignore-disk-space to proceed anyway)", file=sys.stderr)
250
+ return EXIT_USAGE
251
+ if not args.yes and sys.stdin.isatty() and remaining > 0:
252
+ answer = input("Proceed? [y/N] ").strip().lower()
253
+ if answer not in ("y", "yes"):
254
+ return EXIT_USAGE
255
+
256
+ try:
257
+ result = dl.run()
258
+ except (LocalWriteError, ApiError) as e:
259
+ if tty:
260
+ print()
261
+ print(f"error: {e}", file=sys.stderr)
262
+ print(f"stopped: {dl.progress.files_done} fetched, {len(dl.failures)} failed. log: {ws.log_path}", file=sys.stderr)
263
+ report_temp_files(ws)
264
+ return exit_code_for(e) if isinstance(e, ApiError) else EXIT_INCOMPLETE
265
+ if tty:
266
+ print()
267
+ if result.failures:
268
+ print(f"{len(result.failures)} file(s) failed:", file=sys.stderr)
269
+ for fail in result.failures[:20]:
270
+ print(f" {fail.path}: {fail.reason}", file=sys.stderr)
271
+ if len(result.failures) > 20:
272
+ print(f" ... and {len(result.failures) - 20} more (see {ws.log_path})", file=sys.stderr)
273
+ if result.interrupted:
274
+ print("interrupted; run the same command again to resume", file=sys.stderr)
275
+ print(f"done: {result.progress.files_done} fetched, {result.progress.files_skipped} already present, "
276
+ f"{len(result.failures)} failed. log: {ws.log_path}")
277
+ report_temp_files(ws)
278
+ return EXIT_OK if result.ok else EXIT_INCOMPLETE
279
+
280
+
281
+ def present_or_refused(ws: Workspace, f) -> bool:
282
+ """In verify's default scope: the file is here, or its path is one verify() reports as refused."""
283
+ try:
284
+ return os.path.exists(ws.final_path(f.path))
285
+ except PathOutsideOutput:
286
+ return True
287
+
288
+
289
+ def cmd_verify(args: argparse.Namespace) -> int:
290
+ ws = Workspace(args.output, new_run_id())
291
+ if not ws.has_manifest():
292
+ print(f"no saved manifest in {ws.root}; this folder was not produced by vizo download", file=sys.stderr)
293
+ return EXIT_USAGE
294
+ header, rows = ws.load_manifest()
295
+ sel = selection_from(args)
296
+ check_selection(rows, sel)
297
+ chosen = select(rows, sel, header.dataset)
298
+ if args.shard:
299
+ chosen = shard_files(chosen, Shard.parse(args.shard))
300
+ # The saved manifest lists the whole release, not what this folder asked
301
+ # for. Without a selection, check the files that are here; with one (or
302
+ # --all), check the selection and report what is missing from it.
303
+ scoped = sel.is_everything() and not sel.include_metadata and not args.shard and not args.all
304
+ if scoped:
305
+ chosen = [f for f in chosen if present_or_refused(ws, f)]
306
+ human_out = sys.stderr if args.json else sys.stdout # --json keeps stdout parseable
307
+ report = verify(ws, chosen, checksum=args.checksum,
308
+ on_file=(lambda f, s: print(f"{s:12} {f.path}", file=human_out)) if args.verbose else None)
309
+ scope = "files present" if scoped else "files in selection"
310
+ print(f"{header.dataset} {header.release}: {report.ok}/{report.checked} {scope} ok"
311
+ + ("" if scoped else f", {len(report.missing)} missing")
312
+ + f", {len(report.wrong_size)} wrong size, {len(report.bad_checksum)} bad checksum"
313
+ + (f", {len(report.refused)} refused" if report.refused else ""), file=human_out)
314
+ if args.json:
315
+ print(json.dumps({**report.__dict__, "scope": scope}, indent=2))
316
+ report_temp_files(ws)
317
+ return EXIT_OK if report.clean else EXIT_INCOMPLETE
318
+
319
+
320
+ # -- entry --------------------------------------------------------------------
321
+
322
+ class Parser(argparse.ArgumentParser):
323
+ def error(self, message: str): # type: ignore[override]
324
+ # argparse exits 2 on a usage error; here 2 means a key problem
325
+ self.print_usage(sys.stderr)
326
+ self.exit(EXIT_USAGE, f"{self.prog}: error: {message}\n")
327
+
328
+
329
+ def build_parser() -> argparse.ArgumentParser:
330
+ p = Parser(prog="vizo", description="Download licensed datasets from the VIZO platform.")
331
+ p.add_argument("--version", action="version", version=f"vizo {__version__}")
332
+ p.add_argument("--api-url", help="platform URL (default: VIZO_API_URL or the production platform)")
333
+ sub = p.add_subparsers(dest="command", required=True)
334
+
335
+ d = sub.add_parser("datasets", help="list datasets and your access to them")
336
+ d.add_argument("--json", action="store_true")
337
+ d.set_defaults(func=cmd_datasets)
338
+
339
+ dl = sub.add_parser("download", help="download all or part of a dataset")
340
+ dl.add_argument("dataset", help="dataset id, e.g. ego4d or egoexo4d")
341
+ dl.add_argument("-o", "--output", required=True, help="output folder (files use the official layout)")
342
+ add_selectors(dl)
343
+ dl.add_argument("--shard", metavar="N/M",
344
+ help="fetch only group N of M: the selection is split into M groups of about equal size, "
345
+ "the same way on every machine, so running 1/M to M/M with the same command fetches "
346
+ "everything exactly once")
347
+ dl.add_argument("--workers", type=int, default=8, help="parallel downloads (default 8)")
348
+ dl.add_argument("--dry-run", action="store_true",
349
+ help="show what would be fetched and exit; it still creates the output folder and saves "
350
+ "the manifest there, so the real run targets the same release")
351
+ dl.add_argument("-y", "--yes", action="store_true", help="do not ask for confirmation")
352
+ dl.add_argument("--refresh-manifest", action="store_true", help="fetch the current manifest even if one is saved")
353
+ dl.add_argument("--ignore-disk-space", action="store_true")
354
+ dl.set_defaults(func=cmd_download)
355
+
356
+ v = sub.add_parser("verify", help="check a downloaded folder against its saved manifest")
357
+ v.add_argument("output", help="the folder given to download -o")
358
+ add_selectors(v)
359
+ v.add_argument("--shard", metavar="N/M")
360
+ v.add_argument("--all", action="store_true",
361
+ help="check every file of the release and report missing ones (default: only files present, unless selectors are given)")
362
+ v.add_argument("--checksum", action="store_true", help="also compare MD5 (slow for large downloads)")
363
+ v.add_argument("--verbose", "-v", action="store_true")
364
+ v.add_argument("--json", action="store_true")
365
+ v.set_defaults(func=cmd_verify)
366
+ return p
367
+
368
+
369
+ def main(argv: Optional[List[str]] = None) -> int:
370
+ args = build_parser().parse_args(argv)
371
+ try:
372
+ return args.func(args)
373
+ except ApiError as e:
374
+ print(f"error: {e}", file=sys.stderr)
375
+ return exit_code_for(e)
376
+ except (ManifestError, ValueError, OSError) as e:
377
+ print(f"error: {e}", file=sys.stderr)
378
+ return EXIT_USAGE
379
+ except SystemExit:
380
+ raise
381
+ except KeyboardInterrupt:
382
+ print("\ninterrupted", file=sys.stderr)
383
+ return EXIT_INCOMPLETE
384
+
385
+
386
+ if __name__ == "__main__":
387
+ sys.exit(main())
@@ -0,0 +1,28 @@
1
+ """Per-dataset knowledge: how a file's path maps to the attributes the CLI filters on.
2
+
3
+ Each dataset module exposes `classify(path) -> Optional[FileAttrs]`, returning
4
+ None for a path that does not belong to the dataset's known layout so the
5
+ manifest builder can report it instead of guessing.
6
+
7
+ Each dataset module also has an enricher: a class built from the consortium
8
+ metadata laid out as in the bucket (its `INPUTS` are the files it reads) whose
9
+ `enrich(attrs, path)` fills in what the path cannot and returns the row's
10
+ meta_status. The manifest builder uses it; the CLI never does.
11
+ """
12
+
13
+ from typing import Callable, Dict, Optional
14
+
15
+ from . import ego4d, egoexo4d
16
+ from .common import FileAttrs
17
+
18
+ Classifier = Callable[[str], Optional[FileAttrs]]
19
+
20
+ CLASSIFIERS: Dict[str, Classifier] = {
21
+ "ego4d": ego4d.classify,
22
+ "egoexo4d": egoexo4d.classify,
23
+ }
24
+
25
+ ENRICHERS = {
26
+ "ego4d": ego4d.Ego4DMetadata,
27
+ "egoexo4d": egoexo4d.EgoExo4DMetadata,
28
+ }