transcript-viewer 0.5.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.
- transcript_viewer/__init__.py +5 -0
- transcript_viewer/ai.py +374 -0
- transcript_viewer/cli.py +87 -0
- transcript_viewer/config.py +193 -0
- transcript_viewer/corpus.py +340 -0
- transcript_viewer/fetch.py +771 -0
- transcript_viewer/library.py +169 -0
- transcript_viewer/page.html +2248 -0
- transcript_viewer/store.py +83 -0
- transcript_viewer/viewer.py +1000 -0
- transcript_viewer-0.5.0.dist-info/METADATA +440 -0
- transcript_viewer-0.5.0.dist-info/RECORD +14 -0
- transcript_viewer-0.5.0.dist-info/WHEEL +4 -0
- transcript_viewer-0.5.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,771 @@
|
|
|
1
|
+
"""Pull trajectories from Hugging Face and GitHub by URL.
|
|
2
|
+
|
|
3
|
+
Two rules shape this module.
|
|
4
|
+
|
|
5
|
+
**Private address space is unreachable.** The page hands a URL to a server
|
|
6
|
+
running as you, on your network, so an unguarded fetcher would let it reach your
|
|
7
|
+
router, a cloud metadata endpoint, or a service bound to localhost. Any URL is
|
|
8
|
+
allowed, but the host is resolved first and refused if it lands on a loopback,
|
|
9
|
+
link-local, private or otherwise reserved address — checked again after
|
|
10
|
+
redirects, since a redirect is a second request to a second host.
|
|
11
|
+
|
|
12
|
+
Hugging Face and GitHub are understood well enough to list a repository; every
|
|
13
|
+
other host is treated as a direct link to one file.
|
|
14
|
+
|
|
15
|
+
**S3 goes through the aws CLI.** `s3://bucket/prefix` is listed and downloaded
|
|
16
|
+
by running `aws`, never by holding a credential here: the CLI already owns the
|
|
17
|
+
SSO session, the profile configuration and the refresh logic, so shelling out to
|
|
18
|
+
it means this code never sees a key and never has to renew one. Arguments are
|
|
19
|
+
passed as a list with no shell, and anything arriving from the page is checked
|
|
20
|
+
against a strict pattern first, since an argument beginning with "-" would
|
|
21
|
+
otherwise be read as a flag.
|
|
22
|
+
|
|
23
|
+
**Nothing is downloaded without being listed first.** A dataset URL can name
|
|
24
|
+
hundreds of files; `plan()` says what would be fetched and how much it weighs,
|
|
25
|
+
and `download()` only runs once that has been seen.
|
|
26
|
+
|
|
27
|
+
Uses urllib rather than an SDK, so the viewer keeps its dependency-free install.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import re
|
|
34
|
+
import ipaddress
|
|
35
|
+
import os
|
|
36
|
+
import shutil
|
|
37
|
+
import socket
|
|
38
|
+
import subprocess
|
|
39
|
+
import urllib.error
|
|
40
|
+
import urllib.request
|
|
41
|
+
from collections.abc import Iterator
|
|
42
|
+
from dataclasses import dataclass
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
from typing import Any, NamedTuple
|
|
45
|
+
from urllib.parse import quote, urlparse
|
|
46
|
+
|
|
47
|
+
# Hosts whose layout is understood well enough to list a repository. Anything
|
|
48
|
+
# else is fetched as a single file.
|
|
49
|
+
HOSTS = {
|
|
50
|
+
"huggingface.co": "hf",
|
|
51
|
+
"cdn-lfs.huggingface.co": "hf",
|
|
52
|
+
"github.com": "github",
|
|
53
|
+
"api.github.com": "github",
|
|
54
|
+
"raw.githubusercontent.com": "github",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# What is worth fetching. Logs and trajectories, and the archives they arrive
|
|
58
|
+
# in: a bucket of agent transcripts is far more likely to hold one zip per
|
|
59
|
+
# session than loose JSONL, so excluding archives made S3 useless for exactly
|
|
60
|
+
# the case it was added for.
|
|
61
|
+
LOGS = (".jsonl", ".json", ".har")
|
|
62
|
+
ARCHIVES = (".zip", ".tgz", ".tar", ".tar.gz", ".tar.bz2", ".tar.xz", ".gz")
|
|
63
|
+
SUFFIXES = LOGS + ARCHIVES
|
|
64
|
+
|
|
65
|
+
# Downloads land beside where the viewer was launched, so they are visible and
|
|
66
|
+
# usable by other tools rather than buried in a dot-directory.
|
|
67
|
+
DEFAULT_DIR = "transcript-downloads"
|
|
68
|
+
|
|
69
|
+
# Somewhere a download must never be pointed at, however the path was typed.
|
|
70
|
+
# Deliberately not /var: macOS puts every temporary directory under
|
|
71
|
+
# /private/var/folders, so banning it refuses ordinary scratch paths.
|
|
72
|
+
PROTECTED = (
|
|
73
|
+
"/bin", "/sbin", "/usr", "/etc", "/dev", "/System", "/Library", "/private/etc",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Bucket, key prefix and profile names, strict enough that nothing reaches the
|
|
77
|
+
# argument list that could be read as a flag.
|
|
78
|
+
S3_BUCKET = re.compile(r"^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$")
|
|
79
|
+
S3_PREFIX = re.compile(r"^[\w!.*'()/ -]*$")
|
|
80
|
+
AWS_PROFILE = re.compile(r"^[\w.@][\w.@-]{0,63}$")
|
|
81
|
+
AWS_TIMEOUT = 300
|
|
82
|
+
|
|
83
|
+
# One folder's listing. Generous, but a browser draws it, so not unbounded.
|
|
84
|
+
BROWSE_LIMIT = 3_000
|
|
85
|
+
|
|
86
|
+
MAX_FILES = 2_000
|
|
87
|
+
MAX_TOTAL_BYTES = 2 * 1024**3
|
|
88
|
+
TIMEOUT = 60
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class FetchError(RuntimeError):
|
|
92
|
+
"""Anything the person who pasted the URL needs to know about."""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class Node(NamedTuple):
|
|
96
|
+
"""One entry in a remote folder listing."""
|
|
97
|
+
|
|
98
|
+
name: str
|
|
99
|
+
path: str # full path within the location, as the remote spells it
|
|
100
|
+
kind: str # "folder" | "file"
|
|
101
|
+
size: int | None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class Plan(NamedTuple):
|
|
105
|
+
"""What a URL would bring in."""
|
|
106
|
+
|
|
107
|
+
service: str
|
|
108
|
+
label: str
|
|
109
|
+
files: list["Remote"]
|
|
110
|
+
web: str # where to browse this on the site, "" if there is nowhere
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class Remote:
|
|
115
|
+
"""One file to fetch."""
|
|
116
|
+
|
|
117
|
+
name: str # path within the repo, used as the local name
|
|
118
|
+
url: str
|
|
119
|
+
size: int | None = None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _check_host(url: str) -> str:
|
|
123
|
+
"""Refuse anything that resolves into private address space.
|
|
124
|
+
|
|
125
|
+
An allowlist was simpler but ruled out every other host; this is the usual
|
|
126
|
+
mitigation and lets a bare link work. It resolves the name and inspects the
|
|
127
|
+
addresses rather than pattern-matching the text, so `127.0.0.1`,
|
|
128
|
+
`localhost`, `0x7f.1`, and a public name pointed at a private address are
|
|
129
|
+
all caught the same way.
|
|
130
|
+
"""
|
|
131
|
+
parsed = urlparse(url)
|
|
132
|
+
if parsed.scheme != "https":
|
|
133
|
+
raise FetchError("Only https URLs are fetched.")
|
|
134
|
+
|
|
135
|
+
host = parsed.hostname
|
|
136
|
+
if not host:
|
|
137
|
+
raise FetchError("That URL has no host.")
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
addresses = {
|
|
141
|
+
info[4][0] for info in socket.getaddrinfo(host, parsed.port or 443)
|
|
142
|
+
}
|
|
143
|
+
except socket.gaierror as exc:
|
|
144
|
+
raise FetchError(f"Could not look up {host}.") from exc
|
|
145
|
+
|
|
146
|
+
for address in addresses:
|
|
147
|
+
try:
|
|
148
|
+
ip = ipaddress.ip_address(address)
|
|
149
|
+
except ValueError:
|
|
150
|
+
continue
|
|
151
|
+
if not ip.is_global or ip.is_multicast:
|
|
152
|
+
raise FetchError(
|
|
153
|
+
f"{host} resolves to {ip}, which is on this machine or this "
|
|
154
|
+
f"network. Only public addresses are fetched."
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
return HOSTS.get(parsed.netloc, "direct")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _open(url: str, token: str | None, service: str) -> Any:
|
|
161
|
+
"""A GET with the right auth header, refusing a redirect off the allowlist."""
|
|
162
|
+
_check_host(url)
|
|
163
|
+
request = urllib.request.Request(url)
|
|
164
|
+
if token:
|
|
165
|
+
# In a header, never in the URL: a URL reaches logs and history.
|
|
166
|
+
request.add_header("Authorization", f"Bearer {token}")
|
|
167
|
+
request.add_header("User-Agent", "transcript-viewer")
|
|
168
|
+
try:
|
|
169
|
+
response = urllib.request.urlopen(request, timeout=TIMEOUT)
|
|
170
|
+
except urllib.error.HTTPError as exc:
|
|
171
|
+
if exc.code in (401, 403):
|
|
172
|
+
where = "Hugging Face" if service == "hf" else "GitHub"
|
|
173
|
+
raise FetchError(
|
|
174
|
+
f"{where} refused that ({exc.code}). "
|
|
175
|
+
f"A gated dataset needs a token in Settings, and its conditions "
|
|
176
|
+
f"accepted on its page first."
|
|
177
|
+
) from exc
|
|
178
|
+
if exc.code == 404:
|
|
179
|
+
raise FetchError("Nothing there — check the URL.") from exc
|
|
180
|
+
raise FetchError(f"{urlparse(url).netloc} said {exc.code}.") from exc
|
|
181
|
+
except (urllib.error.URLError, TimeoutError) as exc:
|
|
182
|
+
raise FetchError(f"Could not reach {urlparse(url).netloc}.") from exc
|
|
183
|
+
|
|
184
|
+
# urllib follows redirects itself, so verify where it actually landed.
|
|
185
|
+
_check_host(response.geturl())
|
|
186
|
+
return response
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _json(url: str, token: str | None, service: str) -> Any:
|
|
190
|
+
with _open(url, token, service) as response:
|
|
191
|
+
return json.loads(response.read())
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ---------------------------------------------------------------------- s3 ---
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _aws(args: list[str], profile: str, json_out: bool = True) -> str:
|
|
198
|
+
"""Run the aws CLI and return its stdout.
|
|
199
|
+
|
|
200
|
+
No shell, and the profile is checked before it reaches the argument list.
|
|
201
|
+
An expired session is reported rather than renewed: logging in is an
|
|
202
|
+
interactive, browser-based act that belongs to the person at the keyboard.
|
|
203
|
+
"""
|
|
204
|
+
if shutil.which("aws") is None:
|
|
205
|
+
raise FetchError(
|
|
206
|
+
"The aws CLI is not installed, and S3 is read through it. "
|
|
207
|
+
"See https://aws.amazon.com/cli/"
|
|
208
|
+
)
|
|
209
|
+
if profile and not AWS_PROFILE.match(profile):
|
|
210
|
+
raise FetchError(f"{profile!r} is not a usable AWS profile name.")
|
|
211
|
+
|
|
212
|
+
command = ["aws", *args]
|
|
213
|
+
if json_out:
|
|
214
|
+
command += ["--output", "json"]
|
|
215
|
+
if profile:
|
|
216
|
+
command += ["--profile", profile]
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
done = subprocess.run(
|
|
220
|
+
command, capture_output=True, text=True, timeout=AWS_TIMEOUT, check=False
|
|
221
|
+
)
|
|
222
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
223
|
+
raise FetchError(f"Could not run the aws CLI: {exc}") from exc
|
|
224
|
+
|
|
225
|
+
if done.returncode != 0:
|
|
226
|
+
lines = (done.stderr or done.stdout).strip().splitlines()
|
|
227
|
+
detail = lines[-1] if lines else f"exit {done.returncode}"
|
|
228
|
+
# The CLI reports a missing session several ways — "Token has expired",
|
|
229
|
+
# "Unable to locate credentials", "NoCredentials" — and its own advice
|
|
230
|
+
# ("run aws login") is not the SSO command. Say the one that works.
|
|
231
|
+
lowered = detail.lower()
|
|
232
|
+
if any(word in lowered for word in ("expired", "sso", "credential", "token")):
|
|
233
|
+
named = profile or "<your-profile>"
|
|
234
|
+
raise FetchError(
|
|
235
|
+
"No usable AWS session. In a terminal, run: "
|
|
236
|
+
f"aws sso login --profile {named}"
|
|
237
|
+
)
|
|
238
|
+
raise FetchError(detail.removeprefix("aws: ").strip())
|
|
239
|
+
return done.stdout
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _profiles() -> list[str]:
|
|
243
|
+
"""Profile names the aws CLI knows about."""
|
|
244
|
+
if shutil.which("aws") is None:
|
|
245
|
+
return []
|
|
246
|
+
try:
|
|
247
|
+
done = subprocess.run(
|
|
248
|
+
["aws", "configure", "list-profiles"],
|
|
249
|
+
capture_output=True, text=True, timeout=20, check=False,
|
|
250
|
+
)
|
|
251
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
252
|
+
return []
|
|
253
|
+
return [line.strip() for line in done.stdout.splitlines() if line.strip()]
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def resolve_profile(configured: str) -> str:
|
|
257
|
+
"""Which AWS profile to use, asking as little as possible.
|
|
258
|
+
|
|
259
|
+
Settings first, then AWS_PROFILE, then — if the machine has exactly one
|
|
260
|
+
profile — that one, because there is nothing to disambiguate. Naming a
|
|
261
|
+
profile should be something only people with several ever have to do.
|
|
262
|
+
"""
|
|
263
|
+
if configured:
|
|
264
|
+
return configured
|
|
265
|
+
from_env = os.environ.get("AWS_PROFILE", "").strip()
|
|
266
|
+
if from_env and AWS_PROFILE.match(from_env):
|
|
267
|
+
return from_env
|
|
268
|
+
known = [p for p in _profiles() if AWS_PROFILE.match(p)]
|
|
269
|
+
return known[0] if len(known) == 1 else ""
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _s3_plan(url: str, profile: str) -> tuple[str, list[Remote], str]:
|
|
273
|
+
rest = url[len("s3://") :].strip("/")
|
|
274
|
+
bucket, _, prefix = rest.partition("/")
|
|
275
|
+
if not S3_BUCKET.match(bucket):
|
|
276
|
+
raise FetchError(f"{bucket!r} is not a valid S3 bucket name.")
|
|
277
|
+
if not S3_PREFIX.match(prefix):
|
|
278
|
+
raise FetchError("That key prefix has characters this will not pass to the CLI.")
|
|
279
|
+
|
|
280
|
+
# Capped rather than exhaustive: a bucket can hold six figures of objects,
|
|
281
|
+
# and pulling the whole listing to then refuse it wastes everyone's time.
|
|
282
|
+
args = ["s3api", "list-objects-v2", "--bucket", bucket,
|
|
283
|
+
"--max-items", str(MAX_FILES + 1)]
|
|
284
|
+
if prefix:
|
|
285
|
+
args += ["--prefix", prefix]
|
|
286
|
+
listing = json.loads(_aws(args, profile) or "{}")
|
|
287
|
+
|
|
288
|
+
cut = len(prefix.rstrip("/")) + 1 if prefix else 0
|
|
289
|
+
files = [
|
|
290
|
+
Remote(
|
|
291
|
+
name=str(row["Key"])[cut:].lstrip("/") or Path(str(row["Key"])).name,
|
|
292
|
+
url=f"s3://{bucket}/{row['Key']}",
|
|
293
|
+
size=row.get("Size"),
|
|
294
|
+
)
|
|
295
|
+
for row in listing.get("Contents", [])
|
|
296
|
+
if str(row.get("Key", "")).endswith(SUFFIXES)
|
|
297
|
+
]
|
|
298
|
+
return bucket, files, f"s3://{bucket}/{prefix}".rstrip("/")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _s3_download(remote: Remote, target: Path, profile: str) -> None:
|
|
302
|
+
"""One object straight to disk; the CLI streams it, so nothing is buffered."""
|
|
303
|
+
_aws(
|
|
304
|
+
["s3", "cp", remote.url, str(target), "--only-show-errors"],
|
|
305
|
+
profile,
|
|
306
|
+
json_out=False,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# ------------------------------------------------------------- hugging face ---
|
|
311
|
+
|
|
312
|
+
_HF = re.compile(
|
|
313
|
+
r"^/(?:(?P<kind>datasets|spaces)/)?(?P<repo>[^/]+/[^/]+)"
|
|
314
|
+
r"(?:/(?P<action>tree|blob|resolve)/(?P<rev>[^/]+)(?P<path>/.*)?)?/?$"
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _hf_plan(url: str, token: str | None) -> tuple[str, list[Remote], str]:
|
|
319
|
+
parsed = urlparse(url)
|
|
320
|
+
match = _HF.match(parsed.path)
|
|
321
|
+
if not match:
|
|
322
|
+
raise FetchError("That does not look like a Hugging Face repo or file URL.")
|
|
323
|
+
|
|
324
|
+
repo = match.group("repo")
|
|
325
|
+
kind = match.group("kind") or "models"
|
|
326
|
+
rev = match.group("rev") or "main"
|
|
327
|
+
inner = (match.group("path") or "").strip("/")
|
|
328
|
+
api_kind = {"datasets": "datasets", "spaces": "spaces", "models": "models"}[kind]
|
|
329
|
+
prefix = f"{kind}/" if match.group("kind") else ""
|
|
330
|
+
|
|
331
|
+
def raw(path: str) -> str:
|
|
332
|
+
return f"https://huggingface.co/{prefix}{repo}/resolve/{rev}/{quote(path)}"
|
|
333
|
+
|
|
334
|
+
web = f"https://huggingface.co/{prefix}{repo}/tree/{rev}"
|
|
335
|
+
if match.group("action") == "blob" or (inner and inner.endswith(SUFFIXES)):
|
|
336
|
+
return repo.replace("/", "--"), [
|
|
337
|
+
Remote(name=inner.split("/")[-1], url=raw(inner))
|
|
338
|
+
], web
|
|
339
|
+
|
|
340
|
+
listing = _json(
|
|
341
|
+
f"https://huggingface.co/api/{api_kind}/{repo}/tree/{rev}/{quote(inner)}"
|
|
342
|
+
f"?recursive=true",
|
|
343
|
+
token,
|
|
344
|
+
"hf",
|
|
345
|
+
)
|
|
346
|
+
files = [
|
|
347
|
+
Remote(name=row["path"], url=raw(row["path"]), size=row.get("size"))
|
|
348
|
+
for row in listing
|
|
349
|
+
if row.get("type") == "file" and str(row.get("path", "")).endswith(SUFFIXES)
|
|
350
|
+
]
|
|
351
|
+
return repo.replace("/", "--"), files, web
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# ------------------------------------------------------------------ github ---
|
|
355
|
+
|
|
356
|
+
_GH = re.compile(
|
|
357
|
+
r"^/(?P<owner>[^/]+)/(?P<repo>[^/]+)"
|
|
358
|
+
r"(?:/(?P<action>tree|blob|raw)/(?P<rev>[^/]+)(?P<path>/.*)?)?/?$"
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _github_plan(url: str, token: str | None) -> tuple[str, list[Remote], str]:
|
|
363
|
+
parsed = urlparse(url)
|
|
364
|
+
|
|
365
|
+
if parsed.netloc == "raw.githubusercontent.com":
|
|
366
|
+
name = parsed.path.strip("/").split("/")[-1]
|
|
367
|
+
return "github", [Remote(name=name, url=url)], ""
|
|
368
|
+
|
|
369
|
+
match = _GH.match(parsed.path)
|
|
370
|
+
if not match:
|
|
371
|
+
raise FetchError("That does not look like a GitHub repo or file URL.")
|
|
372
|
+
|
|
373
|
+
owner, repo = match.group("owner"), match.group("repo").removesuffix(".git")
|
|
374
|
+
rev = match.group("rev")
|
|
375
|
+
inner = (match.group("path") or "").strip("/")
|
|
376
|
+
|
|
377
|
+
if rev is None:
|
|
378
|
+
info = _json(f"https://api.github.com/repos/{owner}/{repo}", token, "github")
|
|
379
|
+
rev = info.get("default_branch") or "main"
|
|
380
|
+
|
|
381
|
+
def raw(path: str) -> str:
|
|
382
|
+
return f"https://raw.githubusercontent.com/{owner}/{repo}/{rev}/{quote(path)}"
|
|
383
|
+
|
|
384
|
+
web = f"https://github.com/{owner}/{repo}/tree/{rev}"
|
|
385
|
+
if match.group("action") in ("blob", "raw"):
|
|
386
|
+
return f"{owner}--{repo}", [
|
|
387
|
+
Remote(name=inner.split("/")[-1], url=raw(inner))
|
|
388
|
+
], web
|
|
389
|
+
|
|
390
|
+
tree = _json(
|
|
391
|
+
f"https://api.github.com/repos/{owner}/{repo}/git/trees/{rev}?recursive=1",
|
|
392
|
+
token,
|
|
393
|
+
"github",
|
|
394
|
+
)
|
|
395
|
+
if tree.get("truncated"):
|
|
396
|
+
raise FetchError(
|
|
397
|
+
"That repository is too large to list in one request. "
|
|
398
|
+
"Link a subdirectory instead."
|
|
399
|
+
)
|
|
400
|
+
files = [
|
|
401
|
+
Remote(name=row["path"], url=raw(row["path"]), size=row.get("size"))
|
|
402
|
+
for row in tree.get("tree", [])
|
|
403
|
+
if row.get("type") == "blob"
|
|
404
|
+
and str(row.get("path", "")).endswith(SUFFIXES)
|
|
405
|
+
and (not inner or str(row.get("path", "")).startswith(f"{inner}/"))
|
|
406
|
+
]
|
|
407
|
+
return f"{owner}--{repo}", files, web
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
# ---------------------------------------------------------------- browsing ---
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def browse(url: str, inside: str, tokens: dict[str, str | None]) -> list[Node]:
|
|
414
|
+
"""One level of a remote location.
|
|
415
|
+
|
|
416
|
+
A level at a time rather than the whole thing: the bucket this was built for
|
|
417
|
+
holds 118,801 objects, and listing all of them to draw a picker would be
|
|
418
|
+
slower and larger than most of the downloads it is meant to avoid.
|
|
419
|
+
"""
|
|
420
|
+
url = (url or "").strip()
|
|
421
|
+
inside = (inside or "").strip("/")
|
|
422
|
+
|
|
423
|
+
if url.startswith("s3://"):
|
|
424
|
+
return _s3_browse(url, inside, resolve_profile(tokens.get("aws") or ""))
|
|
425
|
+
|
|
426
|
+
service = _check_host(url)
|
|
427
|
+
if service == "hf":
|
|
428
|
+
return _hf_browse(url, inside, tokens.get("hf"))
|
|
429
|
+
if service == "github":
|
|
430
|
+
return _github_browse(url, inside, tokens.get("github"))
|
|
431
|
+
raise FetchError("That is a single file, so there is nothing to look inside.")
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _s3_browse(url: str, inside: str, profile: str) -> list[Node]:
|
|
435
|
+
bucket, _, base = url[len("s3://") :].strip("/").partition("/")
|
|
436
|
+
if not S3_BUCKET.match(bucket):
|
|
437
|
+
raise FetchError(f"{bucket!r} is not a valid S3 bucket name.")
|
|
438
|
+
prefix = "/".join(p for p in (base, inside) if p)
|
|
439
|
+
if prefix:
|
|
440
|
+
prefix += "/"
|
|
441
|
+
if not S3_PREFIX.match(prefix):
|
|
442
|
+
raise FetchError("That key prefix has characters this will not pass to the CLI.")
|
|
443
|
+
|
|
444
|
+
args = ["s3api", "list-objects-v2", "--bucket", bucket, "--delimiter", "/",
|
|
445
|
+
"--max-items", str(BROWSE_LIMIT)]
|
|
446
|
+
if prefix:
|
|
447
|
+
args += ["--prefix", prefix]
|
|
448
|
+
listing = json.loads(_aws(args, profile) or "{}")
|
|
449
|
+
|
|
450
|
+
nodes = [
|
|
451
|
+
Node(
|
|
452
|
+
name=row["Prefix"][len(prefix):].strip("/"),
|
|
453
|
+
path=f"{inside}/{row['Prefix'][len(prefix):]}".strip("/"),
|
|
454
|
+
kind="folder",
|
|
455
|
+
size=None,
|
|
456
|
+
)
|
|
457
|
+
for row in listing.get("CommonPrefixes", [])
|
|
458
|
+
]
|
|
459
|
+
nodes += [
|
|
460
|
+
Node(
|
|
461
|
+
name=row["Key"][len(prefix):],
|
|
462
|
+
path=f"{inside}/{row['Key'][len(prefix):]}".strip("/"),
|
|
463
|
+
kind="file",
|
|
464
|
+
size=row.get("Size"),
|
|
465
|
+
)
|
|
466
|
+
for row in listing.get("Contents", [])
|
|
467
|
+
if row["Key"] != prefix and str(row.get("Key", "")).endswith(SUFFIXES)
|
|
468
|
+
]
|
|
469
|
+
return nodes
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _hf_browse(url: str, inside: str, token: str | None) -> list[Node]:
|
|
473
|
+
parsed = urlparse(url)
|
|
474
|
+
match = _HF.match(parsed.path)
|
|
475
|
+
if not match:
|
|
476
|
+
raise FetchError("That does not look like a Hugging Face repo URL.")
|
|
477
|
+
repo = match.group("repo")
|
|
478
|
+
kind = match.group("kind") or "models"
|
|
479
|
+
rev = match.group("rev") or "main"
|
|
480
|
+
base = (match.group("path") or "").strip("/")
|
|
481
|
+
where = "/".join(p for p in (base, inside) if p)
|
|
482
|
+
|
|
483
|
+
rows = _json(
|
|
484
|
+
f"https://huggingface.co/api/{kind if match.group('kind') else 'models'}"
|
|
485
|
+
f"/{repo}/tree/{rev}/{quote(where)}",
|
|
486
|
+
token,
|
|
487
|
+
"hf",
|
|
488
|
+
)
|
|
489
|
+
return _level(rows, "type", "directory", "path", where, inside, "size")
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _github_browse(url: str, inside: str, token: str | None) -> list[Node]:
|
|
493
|
+
parsed = urlparse(url)
|
|
494
|
+
match = _GH.match(parsed.path)
|
|
495
|
+
if not match:
|
|
496
|
+
raise FetchError("That does not look like a GitHub repo URL.")
|
|
497
|
+
owner, repo = match.group("owner"), match.group("repo").removesuffix(".git")
|
|
498
|
+
rev = match.group("rev")
|
|
499
|
+
base = (match.group("path") or "").strip("/")
|
|
500
|
+
if rev is None:
|
|
501
|
+
info = _json(f"https://api.github.com/repos/{owner}/{repo}", token, "github")
|
|
502
|
+
rev = info.get("default_branch") or "main"
|
|
503
|
+
where = "/".join(p for p in (base, inside) if p)
|
|
504
|
+
|
|
505
|
+
rows = _json(
|
|
506
|
+
f"https://api.github.com/repos/{owner}/{repo}/contents/{quote(where)}?ref={rev}",
|
|
507
|
+
token,
|
|
508
|
+
"github",
|
|
509
|
+
)
|
|
510
|
+
if not isinstance(rows, list):
|
|
511
|
+
raise FetchError("That path is a file, not a folder.")
|
|
512
|
+
return _level(rows, "type", "dir", "path", where, inside, "size")
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _level(rows, type_key, folder_word, path_key, where, inside, size_key) -> list[Node]:
|
|
516
|
+
"""Turn one API listing into nodes, keeping only what is worth fetching."""
|
|
517
|
+
nodes: list[Node] = []
|
|
518
|
+
for row in rows:
|
|
519
|
+
full = str(row.get(path_key, ""))
|
|
520
|
+
name = full[len(where):].strip("/") if where else full
|
|
521
|
+
if not name:
|
|
522
|
+
continue
|
|
523
|
+
here = f"{inside}/{name}".strip("/")
|
|
524
|
+
if row.get(type_key) == folder_word:
|
|
525
|
+
nodes.append(Node(name=name, path=here, kind="folder", size=None))
|
|
526
|
+
elif full.endswith(SUFFIXES):
|
|
527
|
+
nodes.append(
|
|
528
|
+
Node(name=name, path=here, kind="file", size=row.get(size_key))
|
|
529
|
+
)
|
|
530
|
+
return nodes
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
# -------------------------------------------------------------------- api ----
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def destination(raw: str | None) -> Path:
|
|
537
|
+
"""Resolve where a download should land.
|
|
538
|
+
|
|
539
|
+
The path comes from the page, so it is resolved before it is trusted: `..`
|
|
540
|
+
and a symlink both collapse here, and a handful of places nothing should
|
|
541
|
+
ever be written into are refused outright.
|
|
542
|
+
"""
|
|
543
|
+
text = (raw or "").strip() or DEFAULT_DIR
|
|
544
|
+
path = Path(text).expanduser()
|
|
545
|
+
if not path.is_absolute():
|
|
546
|
+
path = Path.cwd() / path
|
|
547
|
+
path = path.resolve()
|
|
548
|
+
|
|
549
|
+
home = Path.home().resolve()
|
|
550
|
+
if (
|
|
551
|
+
path == Path(path.anchor)
|
|
552
|
+
or path == home
|
|
553
|
+
or path in home.parents
|
|
554
|
+
or any(path == Path(p) or Path(p) in path.parents for p in PROTECTED)
|
|
555
|
+
):
|
|
556
|
+
raise FetchError(f"{path} is not somewhere to download into — pick a folder.")
|
|
557
|
+
return path
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _sized(plan: "Plan") -> "Plan":
|
|
561
|
+
"""Refuse a plan that is too big before a byte of it is downloaded."""
|
|
562
|
+
if not plan.files:
|
|
563
|
+
raise FetchError(
|
|
564
|
+
"Nothing convertible there — looking for "
|
|
565
|
+
+ ", ".join(LOGS)
|
|
566
|
+
+ " files, or an archive of them."
|
|
567
|
+
)
|
|
568
|
+
if len(plan.files) > MAX_FILES:
|
|
569
|
+
where = "a prefix" if plan.service == "s3" else "a subdirectory"
|
|
570
|
+
raise FetchError(
|
|
571
|
+
f"{len(plan.files):,}+ files is more than this fetches at once "
|
|
572
|
+
f"(limit {MAX_FILES:,}). Point at {where}."
|
|
573
|
+
)
|
|
574
|
+
known = sum(f.size or 0 for f in plan.files)
|
|
575
|
+
if known > MAX_TOTAL_BYTES:
|
|
576
|
+
raise FetchError(
|
|
577
|
+
f"That is {known / 1024**3:.1f} GB, over the "
|
|
578
|
+
f"{MAX_TOTAL_BYTES // 1024**3} GB limit."
|
|
579
|
+
)
|
|
580
|
+
return plan
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _under(name: str, wanted: set[str]) -> bool:
|
|
584
|
+
"""Whether a file was ticked, directly or by one of its folders."""
|
|
585
|
+
name = name.strip("/")
|
|
586
|
+
return name in wanted or any(name.startswith(w + "/") for w in wanted)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _inner_base(url: str) -> str:
|
|
590
|
+
"""The folder a repository URL already points at.
|
|
591
|
+
|
|
592
|
+
Browse paths are relative to that folder, while a plan names files relative
|
|
593
|
+
to the repository root — so one has to be rebased onto the other or a ticked
|
|
594
|
+
folder matches nothing. S3 needs none of this: both are relative to the
|
|
595
|
+
prefix.
|
|
596
|
+
"""
|
|
597
|
+
if url.startswith("s3://"):
|
|
598
|
+
return ""
|
|
599
|
+
parsed = urlparse(url)
|
|
600
|
+
pattern = _HF if HOSTS.get(parsed.netloc) == "hf" else _GH
|
|
601
|
+
match = pattern.match(parsed.path)
|
|
602
|
+
return (match.group("path") or "").strip("/") if match else ""
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _rebase(url: str, paths: list[str]) -> set[str]:
|
|
606
|
+
base = _inner_base(url)
|
|
607
|
+
return {
|
|
608
|
+
"/".join(p for p in (base, path.strip("/")) if p)
|
|
609
|
+
for path in paths
|
|
610
|
+
if path and path.strip("/")
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _s3_under(url: str, paths: set[str], profile: str) -> tuple[list[Remote], bool]:
|
|
615
|
+
"""Every object beneath the ticked paths, and whether a listing was capped.
|
|
616
|
+
|
|
617
|
+
Listed per ticked path rather than filtered out of a listing of the whole
|
|
618
|
+
location. The bucket this was built for holds 118,801 objects and a listing
|
|
619
|
+
is capped, so anything alphabetically past the cap — "screwtape" is, "chippy"
|
|
620
|
+
is not — was simply absent and a selection resolved to nothing.
|
|
621
|
+
"""
|
|
622
|
+
bucket, _, base = url[len("s3://") :].strip("/").partition("/")
|
|
623
|
+
if not S3_BUCKET.match(bucket):
|
|
624
|
+
raise FetchError(f"{bucket!r} is not a valid S3 bucket name.")
|
|
625
|
+
|
|
626
|
+
cut = len(base.rstrip("/")) + 1 if base else 0
|
|
627
|
+
found: dict[str, Remote] = {}
|
|
628
|
+
capped = False
|
|
629
|
+
for path in sorted(paths):
|
|
630
|
+
prefix = "/".join(p for p in (base, path) if p)
|
|
631
|
+
if not S3_PREFIX.match(prefix):
|
|
632
|
+
raise FetchError("That prefix has characters this will not pass on.")
|
|
633
|
+
args = ["s3api", "list-objects-v2", "--bucket", bucket,
|
|
634
|
+
"--max-items", str(MAX_FILES + 1)]
|
|
635
|
+
if prefix:
|
|
636
|
+
args += ["--prefix", prefix]
|
|
637
|
+
rows = json.loads(_aws(args, profile) or "{}").get("Contents", [])
|
|
638
|
+
capped = capped or len(rows) > MAX_FILES
|
|
639
|
+
for row in rows:
|
|
640
|
+
key = str(row.get("Key", ""))
|
|
641
|
+
if not key.endswith(SUFFIXES):
|
|
642
|
+
continue
|
|
643
|
+
# A ticked folder and a ticked file inside it must not count twice.
|
|
644
|
+
found[key] = Remote(
|
|
645
|
+
name=key[cut:] or Path(key).name,
|
|
646
|
+
url=f"s3://{bucket}/{key}",
|
|
647
|
+
size=row.get("Size"),
|
|
648
|
+
)
|
|
649
|
+
return list(found.values()), capped
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def measure(url: str, paths: list[str], tokens: dict[str, str | None]) -> dict:
|
|
653
|
+
"""Exactly how much a selection is, by listing inside what was ticked.
|
|
654
|
+
|
|
655
|
+
A tick on a folder is a promise about its contents, and "1+ files" is not an
|
|
656
|
+
answer to "how much am I getting?". This costs one listing per folder, which
|
|
657
|
+
is the price of a real number.
|
|
658
|
+
"""
|
|
659
|
+
wanted = {p.strip("/") for p in paths if p and p.strip("/")}
|
|
660
|
+
if not wanted:
|
|
661
|
+
return {"files": 0, "bytes": 0, "capped": False}
|
|
662
|
+
|
|
663
|
+
if url.startswith("s3://"):
|
|
664
|
+
found, capped = _s3_under(
|
|
665
|
+
url, wanted, resolve_profile(tokens.get("aws") or "")
|
|
666
|
+
)
|
|
667
|
+
return {
|
|
668
|
+
"files": len(found),
|
|
669
|
+
"bytes": sum(f.size or 0 for f in found),
|
|
670
|
+
"capped": capped,
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
whole = plan(url, tokens, sized=False)
|
|
674
|
+
against = _rebase(url, list(wanted))
|
|
675
|
+
keep = [f for f in whole.files if _under(f.name, against)]
|
|
676
|
+
return {
|
|
677
|
+
"files": len(keep),
|
|
678
|
+
"bytes": sum(f.size or 0 for f in keep),
|
|
679
|
+
"capped": False,
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def select(url: str, paths: list[str], tokens: dict[str, str | None]) -> Plan:
|
|
684
|
+
"""A plan covering only what was ticked.
|
|
685
|
+
|
|
686
|
+
A ticked folder means everything under it, so folders are expanded here
|
|
687
|
+
rather than in the page: the page would have to walk the remote to find out
|
|
688
|
+
what it had just agreed to, which is the work this avoids.
|
|
689
|
+
"""
|
|
690
|
+
picked = {p.strip("/") for p in paths if p and p.strip("/")}
|
|
691
|
+
if not picked:
|
|
692
|
+
raise FetchError("Nothing was ticked.")
|
|
693
|
+
|
|
694
|
+
if url.startswith("s3://"):
|
|
695
|
+
bucket = url[len("s3://") :].strip("/").partition("/")[0]
|
|
696
|
+
found, _ = _s3_under(url, picked, resolve_profile(tokens.get("aws") or ""))
|
|
697
|
+
base = url[len("s3://") :].strip("/").partition("/")[2]
|
|
698
|
+
return _sized(
|
|
699
|
+
Plan("s3", bucket, found, f"s3://{bucket}/{base}".rstrip("/"))
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
whole = plan(url, tokens, sized=False)
|
|
703
|
+
wanted = _rebase(url, paths)
|
|
704
|
+
chosen = [f for f in whole.files if _under(f.name, wanted)]
|
|
705
|
+
return _sized(Plan(whole.service, whole.label, chosen, whole.web))
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def plan(url: str, tokens: dict[str, str | None], sized: bool = True) -> Plan:
|
|
709
|
+
"""What fetching this URL would pull: the service, a label, and the files."""
|
|
710
|
+
url = (url or "").strip()
|
|
711
|
+
if not url:
|
|
712
|
+
raise FetchError("No URL given.")
|
|
713
|
+
|
|
714
|
+
if url.startswith("s3://"):
|
|
715
|
+
bucket, files, where = _s3_plan(url, resolve_profile(tokens.get("aws") or ""))
|
|
716
|
+
made = Plan("s3", bucket, files, where)
|
|
717
|
+
return _sized(made) if sized else made
|
|
718
|
+
|
|
719
|
+
service = _check_host(url)
|
|
720
|
+
token = tokens.get(service)
|
|
721
|
+
|
|
722
|
+
if service == "direct":
|
|
723
|
+
name = Path(urlparse(url).path).name or "download.jsonl"
|
|
724
|
+
if not name.endswith(SUFFIXES):
|
|
725
|
+
raise FetchError(
|
|
726
|
+
f"{name} is not a kind this reads — looking for "
|
|
727
|
+
f"{', '.join(LOGS)} or an archive of them."
|
|
728
|
+
)
|
|
729
|
+
label, files, web = urlparse(url).hostname or "download", [
|
|
730
|
+
Remote(name=name, url=url)
|
|
731
|
+
], ""
|
|
732
|
+
else:
|
|
733
|
+
label, files, web = (_hf_plan if service == "hf" else _github_plan)(url, token)
|
|
734
|
+
made = Plan(service, label, files, web)
|
|
735
|
+
return _sized(made) if sized else made
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def download(
|
|
739
|
+
service: str, files: list[Remote], into: Path, tokens: dict[str, str | None]
|
|
740
|
+
) -> Iterator[Path]:
|
|
741
|
+
"""Fetch each file under ``into``, yielding each as it lands.
|
|
742
|
+
|
|
743
|
+
A generator so a caller can report progress: a dataset of several hundred
|
|
744
|
+
files takes long enough that a still screen reads as a hang.
|
|
745
|
+
"""
|
|
746
|
+
token = tokens.get(service)
|
|
747
|
+
total = 0
|
|
748
|
+
|
|
749
|
+
for remote in files:
|
|
750
|
+
# The name comes from a remote listing, so it is not trusted to stay
|
|
751
|
+
# inside the directory.
|
|
752
|
+
parts = [p for p in Path(remote.name).parts if p not in ("..", "/", "")]
|
|
753
|
+
if not parts:
|
|
754
|
+
continue
|
|
755
|
+
target = into.joinpath(*parts)
|
|
756
|
+
if not target.resolve().is_relative_to(into.resolve()):
|
|
757
|
+
continue
|
|
758
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
759
|
+
|
|
760
|
+
if service == "s3":
|
|
761
|
+
_s3_download(remote, target, resolve_profile(tokens.get("aws") or ""))
|
|
762
|
+
yield target
|
|
763
|
+
continue
|
|
764
|
+
|
|
765
|
+
with _open(remote.url, token, service) as response:
|
|
766
|
+
body = response.read(MAX_TOTAL_BYTES - total + 1)
|
|
767
|
+
total += len(body)
|
|
768
|
+
if total > MAX_TOTAL_BYTES:
|
|
769
|
+
raise FetchError("That is larger than the fetch limit.")
|
|
770
|
+
target.write_bytes(body)
|
|
771
|
+
yield target
|