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 +8 -0
- vizo/__main__.py +5 -0
- vizo/api.py +221 -0
- vizo/cli.py +387 -0
- vizo/datasets/__init__.py +28 -0
- vizo/datasets/common.py +60 -0
- vizo/datasets/ego4d.py +214 -0
- vizo/datasets/egoexo4d.py +409 -0
- vizo/download.py +440 -0
- vizo/filters.py +212 -0
- vizo/manifest.py +293 -0
- vizo/state.py +137 -0
- vizo/verify.py +58 -0
- vizo-0.0.1.dist-info/METADATA +67 -0
- vizo-0.0.1.dist-info/RECORD +18 -0
- vizo-0.0.1.dist-info/WHEEL +5 -0
- vizo-0.0.1.dist-info/entry_points.txt +2 -0
- vizo-0.0.1.dist-info/top_level.txt +1 -0
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
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
|
+
}
|