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