acadfetch 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
acadfetch/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """acadfetch: resumable, rate-limited, checksum-verified bulk downloads."""
2
+
3
+ from .consts import __version__
4
+
5
+ __all__ = ["__version__"]
acadfetch/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
acadfetch/auth.py ADDED
@@ -0,0 +1,61 @@
1
+ """Repository tokens, read from the environment.
2
+
3
+ Putting a token in ``--header`` leaks it into shell history and into every
4
+ ``ps`` on the box. Each adapter instead declares the variable it reads, and the
5
+ token is attached per-source so it never travels to a host that has no business
6
+ seeing it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from collections.abc import Mapping
13
+ from dataclasses import dataclass
14
+
15
+ # source name -> variable that holds its token
16
+ TOKEN_ENV = {
17
+ "zenodo": "ZENODO_TOKEN",
18
+ "figshare": "FIGSHARE_TOKEN",
19
+ }
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Token:
24
+ source: str
25
+ env: str
26
+ value: str
27
+
28
+ @property
29
+ def headers(self) -> dict[str, str]:
30
+ return {"Authorization": f"Bearer {self.value}"}
31
+
32
+ @property
33
+ def hint(self) -> str:
34
+ """Enough to tell two tokens apart, not enough to use one."""
35
+ return f"…{self.value[-4:]}" if len(self.value) > 8 else "…"
36
+
37
+ def __str__(self) -> str:
38
+ return f"${self.env} ({self.hint})"
39
+
40
+
41
+ def load(environ: Mapping[str, str] | None = None) -> dict[str, Token]:
42
+ """Collect every token the environment provides, keyed by source."""
43
+ env = os.environ if environ is None else environ
44
+ found: dict[str, Token] = {}
45
+ for source, name in TOKEN_ENV.items():
46
+ value = (env.get(name) or "").strip()
47
+ if value:
48
+ found[source] = Token(source=source, env=name, value=value)
49
+ return found
50
+
51
+
52
+ def headers(tokens: Mapping[str, Token], source: str | None) -> dict[str, str]:
53
+ token = tokens.get(source or "")
54
+ return token.headers if token else {}
55
+
56
+
57
+ def describe(tokens: Mapping[str, Token], source: str | None) -> str:
58
+ token = tokens.get(source or "")
59
+ if token is None:
60
+ return ""
61
+ return f"token from {token}"
acadfetch/cli.py ADDED
@@ -0,0 +1,280 @@
1
+ """Command line interface.
2
+
3
+ Exit codes are part of the contract: 0 all good, 1 everything failed or the
4
+ target was unusable, 2 partial success, 130 interrupted (partials are kept).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import re
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from . import auth, consts, manifest, naming
15
+ from .client import Client
16
+ from .downloader import Options, fetch_record, human
17
+ from .errors import AcFError, PartialError, TargetError
18
+ from .progress import Progress
19
+ from .sources import registry
20
+
21
+ EXIT_OK, EXIT_FAIL, EXIT_PARTIAL, EXIT_INTERRUPT = 0, 1, 2, 130
22
+ _SIZE = re.compile(r"^(\d+(?:\.\d+)?)\s*([KMGT]i?B?|B?)$", re.I)
23
+
24
+
25
+ def parse_size(text: str) -> int:
26
+ hit = _SIZE.match(text.strip())
27
+ if not hit:
28
+ raise argparse.ArgumentTypeError(f"bad size: {text!r}")
29
+ value = float(hit.group(1))
30
+ unit = (hit.group(2) or "").upper().rstrip("B")
31
+ scale = {
32
+ "": 1,
33
+ "K": 1024,
34
+ "KI": 1024,
35
+ "M": 1 << 20,
36
+ "MI": 1 << 20,
37
+ "G": 1 << 30,
38
+ "GI": 1 << 30,
39
+ "T": 1 << 40,
40
+ "TI": 1 << 40,
41
+ }[unit]
42
+ return int(value * scale)
43
+
44
+
45
+ def _count(text: str) -> int:
46
+ try:
47
+ value = int(text)
48
+ except ValueError:
49
+ raise argparse.ArgumentTypeError(f"not a whole number: {text!r}") from None
50
+ if value < 0:
51
+ raise argparse.ArgumentTypeError(f"cannot retry {value} times")
52
+ return value
53
+
54
+
55
+ def cmd_sources(_: argparse.Namespace) -> int:
56
+ for name in registry.names():
57
+ print(name)
58
+ return EXIT_OK
59
+
60
+
61
+ def _client(args: argparse.Namespace) -> Client:
62
+ tokens = {} if getattr(args, "no_token", False) else auth.load()
63
+ args.tokens = tokens
64
+ return Client(
65
+ rate=args.rate,
66
+ retries=args.retries,
67
+ block_retries=args.retry_403,
68
+ headers=_headers(args.header),
69
+ auth={source: token.headers for source, token in tokens.items()},
70
+ )
71
+
72
+
73
+ def _headers(raw: list[str] | None) -> dict[str, str]:
74
+ out = {}
75
+ for item in raw or []:
76
+ key, sep, value = item.partition(":")
77
+ if not sep:
78
+ raise TargetError(f"--header needs 'Name: value', got {item!r}")
79
+ out[key.strip()] = value.strip()
80
+ return out
81
+
82
+
83
+ def _resolve(args: argparse.Namespace, client: Client):
84
+ record = registry.resolve(client, args.target)
85
+ return record
86
+
87
+
88
+ def _token_for(args: argparse.Namespace, record): # noqa: ANN001, ANN202
89
+ return getattr(args, "tokens", {}).get(record.source)
90
+
91
+
92
+ def cmd_manifest(args: argparse.Namespace) -> int:
93
+ client = _client(args)
94
+ record = _resolve(args, client)
95
+ data = manifest.build(record, [], _token_for(args, record))
96
+ out = Path(args.out or "manifest.json")
97
+ manifest.write(out, data)
98
+ print(f"{len(record.files)} file(s) -> {out}")
99
+ _print_header(record)
100
+ if token := _token_for(args, record):
101
+ print(f"auth : token from {token}")
102
+ return EXIT_OK
103
+
104
+
105
+ def _print_header(record) -> None: # noqa: ANN001
106
+ print(f"title : {record.title or '(untitled)'}")
107
+ if record.doi:
108
+ print(f"doi : {record.doi}")
109
+ print(f"license : {record.license or 'UNKNOWN - check before redistributing'}")
110
+ total = record.total_bytes
111
+ print(f"files : {len(record.files)}" + (f", {human(total)}" if total is not None else ""))
112
+ if record.landing:
113
+ print(f"landing : {record.landing}")
114
+
115
+
116
+ def cmd_fetch(args: argparse.Namespace) -> int:
117
+ client = _client(args)
118
+ record = _resolve(args, client)
119
+ _print_header(record)
120
+ token = _token_for(args, record)
121
+ if token:
122
+ print(f"auth : token from {token}")
123
+
124
+ out_dir = Path(args.out or f"acadfetch-out/{record.source}-{_slug(record)}")
125
+ progress = Progress(enabled=not args.quiet)
126
+ opts = Options(
127
+ out=out_dir,
128
+ concurrency=args.concurrency,
129
+ dry_run=args.dry_run,
130
+ max_bytes=args.max_size,
131
+ progress=progress,
132
+ )
133
+
134
+ if args.dry_run:
135
+ for file in record.files:
136
+ print(f" would fetch {naming.sanitize(file.name)} ({human(file.size)})")
137
+ return EXIT_OK
138
+
139
+ try:
140
+ results = fetch_record(record, opts, client, only=args.only)
141
+ except PartialError:
142
+ print(f"no file matched --only {args.only}", file=sys.stderr)
143
+ return EXIT_FAIL
144
+ except KeyboardInterrupt:
145
+ progress.finish()
146
+ raise
147
+ finally:
148
+ progress.finish()
149
+
150
+ failures = [r for r in results if not r.ok]
151
+ for row in results:
152
+ mark = {"downloaded": "ok ", "skipped": "skip", "failed": "FAIL"}[row.state]
153
+ extra = ""
154
+ if row.resumed_from:
155
+ extra = f" (resumed at {human(row.resumed_from)})"
156
+ if row.verified is False:
157
+ extra += " [CHECKSUM MISMATCH]"
158
+ print(f"{mark} {naming.sanitize(row.file.name)} {human(row.bytes)}{extra}")
159
+ if row.error:
160
+ print(f" {row.error}")
161
+
162
+ manifest.write(out_dir / "manifest.json", manifest.build(record, results, token))
163
+ print(f"\n{len(results) - len(failures)}/{len(results)} file(s) in {out_dir}")
164
+ if args.manifest_out:
165
+ manifest.write(Path(args.manifest_out), manifest.build(record, results, token))
166
+
167
+ if failures and len(failures) == len(results):
168
+ return EXIT_FAIL
169
+ return EXIT_PARTIAL if failures else EXIT_OK
170
+
171
+
172
+ def cmd_verify(args: argparse.Namespace) -> int:
173
+ root = Path(args.root or ".")
174
+ rows = manifest.verify_manifest(Path(args.manifest), root)
175
+ bad = 0
176
+ for row in rows:
177
+ if row["status"] != "ok":
178
+ bad += 1
179
+ print(f"{row['status']:>9} {row['name']}")
180
+ print(f"\n{len(rows) - bad}/{len(rows)} ok")
181
+ return EXIT_FAIL if bad else EXIT_OK
182
+
183
+
184
+ def _slug(record) -> str: # noqa: ANN001
185
+ if record.doi:
186
+ return naming.sanitize(record.doi.replace("/", "_"))
187
+ return naming.sanitize(record.target.rsplit("/", 1)[-1] or "record")
188
+
189
+
190
+ def build_parser() -> argparse.ArgumentParser:
191
+ parser = argparse.ArgumentParser(
192
+ prog="acadfetch",
193
+ description="Resumable, rate-limited, checksum-verified bulk downloads from data repositories.",
194
+ )
195
+ parser.add_argument("--version", action="version", version=f"acadfetch {consts.__version__}")
196
+ sub = parser.add_subparsers(dest="command", required=True)
197
+
198
+ def network_flags(p: argparse.ArgumentParser) -> None:
199
+ p.add_argument(
200
+ "--rate",
201
+ type=float,
202
+ default=consts.DEFAULT_RATE,
203
+ help="requests per second, global (default: %(default)s)",
204
+ )
205
+ p.add_argument(
206
+ "--retries",
207
+ type=int,
208
+ default=consts.DEFAULT_RETRIES,
209
+ help="retries per request on 5xx/429/network errors (default: %(default)s)",
210
+ )
211
+ p.add_argument(
212
+ "--retry-403",
213
+ type=_count,
214
+ default=consts.WAF_403_RETRIES,
215
+ metavar="N",
216
+ help="retries for a WAF block page, i.e. a 403 with an HTML body; 0 makes "
217
+ "every 403 final (default: %(default)s)",
218
+ )
219
+ p.add_argument(
220
+ "--header",
221
+ action="append",
222
+ metavar="NAME:VALUE",
223
+ help="extra request header, repeatable (e.g. 'Authorization: Bearer <token>')",
224
+ )
225
+ p.add_argument(
226
+ "--no-token",
227
+ action="store_true",
228
+ help="ignore $ZENODO_TOKEN / $FIGSHARE_TOKEN",
229
+ )
230
+
231
+ fetch = sub.add_parser("fetch", help="download every file of a record")
232
+ fetch.add_argument("target", help="DOI, zenodo:<id>, figshare:<id>, arxiv:<id>, or URL")
233
+ fetch.add_argument("-o", "--out", help="output directory")
234
+ fetch.add_argument("-j", "--concurrency", type=int, default=consts.DEFAULT_CONCURRENCY)
235
+ fetch.add_argument("--max-size", type=parse_size, help="abort above this total, e.g. 500MB")
236
+ fetch.add_argument("--only", nargs="+", metavar="NAME", help="fetch only these file names")
237
+ fetch.add_argument("--manifest-out", help="also write manifest.json here")
238
+ fetch.add_argument("-n", "--dry-run", action="store_true")
239
+ fetch.add_argument("-q", "--quiet", action="store_true")
240
+ network_flags(fetch)
241
+ fetch.set_defaults(func=cmd_fetch)
242
+
243
+ man = sub.add_parser("manifest", help="write a manifest without downloading")
244
+ man.add_argument("target")
245
+ man.add_argument("-o", "--out")
246
+ network_flags(man)
247
+ man.set_defaults(func=cmd_manifest)
248
+
249
+ ver = sub.add_parser("verify", help="re-check a local directory against its manifest")
250
+ ver.add_argument("manifest")
251
+ ver.add_argument("--root", help="directory the manifest's names are relative to")
252
+ ver.set_defaults(func=cmd_verify)
253
+
254
+ src = sub.add_parser("sources", help="list supported sources")
255
+ src.set_defaults(func=cmd_sources)
256
+ return parser
257
+
258
+
259
+ def main(argv: list[str] | None = None) -> int:
260
+ # Repository titles are full of non-ASCII; a cp936/cp1252 console would
261
+ # otherwise abort or mangle them.
262
+ for stream in (sys.stdout, sys.stderr):
263
+ if hasattr(stream, "reconfigure"):
264
+ stream.reconfigure(encoding="utf-8", errors="replace")
265
+ args = build_parser().parse_args(argv)
266
+ try:
267
+ return args.func(args)
268
+ except TargetError as exc:
269
+ print(f"acadfetch: {exc}", file=sys.stderr)
270
+ return EXIT_FAIL
271
+ except AcFError as exc:
272
+ print(f"acadfetch: {exc}", file=sys.stderr)
273
+ return EXIT_FAIL
274
+ except KeyboardInterrupt:
275
+ print("\ninterrupted - partial files kept, rerun to resume", file=sys.stderr)
276
+ return EXIT_INTERRUPT
277
+
278
+
279
+ if __name__ == "__main__":
280
+ sys.exit(main())
acadfetch/client.py ADDED
@@ -0,0 +1,209 @@
1
+ """Single choke point for every HTTP request.
2
+
3
+ Everything outbound goes through :class:`Client` so that rate limiting, the
4
+ User-Agent, retry policy, and ``Retry-After`` handling cannot drift between
5
+ adapters. Only idempotent methods are retried.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import email.utils
11
+ import json
12
+ import random
13
+ import time
14
+ import urllib.error
15
+ import urllib.request
16
+ from collections.abc import Mapping
17
+ from datetime import datetime, timezone
18
+
19
+ from . import consts, verify
20
+ from .errors import HttpError
21
+ from .ratelimit import TokenBucket
22
+
23
+ RETRY_STATUS = {408, 425, 429, 500, 502, 503, 504}
24
+ MAX_RETRY_AFTER = 120.0
25
+ MAX_JSON = 32 << 20
26
+ SNIPPET = 200
27
+
28
+
29
+ def is_block_page(detail: str, content_type: str | None) -> bool:
30
+ """Tell a WAF/load-balancer 403 apart from the API's own permission 403.
31
+
32
+ Block pages are HTML or empty (AWS ELB, Cloudflare and friends all ship
33
+ one — see ``tests/fixtures/figshare_403_block.html``); a repository's real
34
+ permission error is JSON or plain text from the application itself. Both
35
+ ``api.figshare.com`` endpoints answer the HTML one intermittently, and it
36
+ clears within a retry or two, so it must not be terminal.
37
+
38
+ An empty body counts as a block page: retrying costs one request, while
39
+ misreading it as terminal costs the whole download.
40
+ """
41
+ if content_type and "html" in content_type.lower():
42
+ return True
43
+ head = detail.lstrip()[:1]
44
+ return not head or head == "<"
45
+
46
+
47
+ def _blocked_detail(detail: str, retries: int) -> str:
48
+ """Block-page markup is noise in a one-line error; name it instead."""
49
+ gave_up = f"gave up after {retries} retr{'y' if retries == 1 else 'ies'}"
50
+ if detail.startswith("<"):
51
+ return f"WAF block page, {gave_up}"
52
+ return f"{detail} (WAF block page, {gave_up})"
53
+
54
+
55
+ def parse_retry_after(value: str | None) -> float | None:
56
+ """Parse the two legal forms of Retry-After. Returns seconds, clamped."""
57
+ if not value:
58
+ return None
59
+ text = value.strip()
60
+ if text.isdigit():
61
+ return min(float(text), MAX_RETRY_AFTER)
62
+ try:
63
+ when = email.utils.parsedate_to_datetime(text)
64
+ except (TypeError, ValueError):
65
+ return None
66
+ if when is None:
67
+ return None
68
+ if when.tzinfo is None:
69
+ when = when.replace(tzinfo=timezone.utc)
70
+ delta = (when - datetime.now(timezone.utc)).total_seconds()
71
+ return max(0.0, min(delta, MAX_RETRY_AFTER))
72
+
73
+
74
+ class Client:
75
+ def __init__(
76
+ self,
77
+ rate: float = consts.DEFAULT_RATE,
78
+ retries: int = consts.DEFAULT_RETRIES,
79
+ timeout: float = consts.DEFAULT_TIMEOUT,
80
+ user_agent: str = consts.USER_AGENT,
81
+ headers: Mapping[str, str] | None = None,
82
+ auth: Mapping[str, Mapping[str, str]] | None = None,
83
+ block_retries: int = consts.WAF_403_RETRIES,
84
+ ) -> None:
85
+ self.bucket = TokenBucket(rate)
86
+ self.retries = max(0, retries)
87
+ self.timeout = timeout
88
+ self.user_agent = user_agent
89
+ # How often a WAF block page is worth retrying. 0 turns the feature
90
+ # off, which is what a run from a network the WAF likes wants.
91
+ self.block_retries = max(0, block_retries)
92
+ self.headers = dict(headers or {})
93
+ # Per-source credentials. The registry sets ``source`` before dispatch
94
+ # so a Zenodo token is never sent to figshare.
95
+ self.auth = {k: dict(v) for k, v in (auth or {}).items()}
96
+ self.source: str | None = None
97
+
98
+ # -- internals ---------------------------------------------------------
99
+ def _sleep(self, attempt: int, retry_after: float | None) -> None:
100
+ if retry_after is not None:
101
+ time.sleep(retry_after)
102
+ return
103
+ backoff = min(0.8 * (2**attempt), 30.0)
104
+ time.sleep(backoff * (0.7 + random.random() * 0.6))
105
+
106
+ def _request(
107
+ self, url: str, headers: Mapping[str, str] | None = None
108
+ ) -> urllib.request.Request:
109
+ merged: dict[str, str] = {}
110
+ if self.source and self.source in self.auth:
111
+ merged.update(self.auth[self.source])
112
+ merged["User-Agent"] = self.user_agent
113
+ merged["Accept-Encoding"] = "identity"
114
+ merged.update(self.headers) # explicit --header wins over a token
115
+ if headers:
116
+ merged.update(headers)
117
+ return urllib.request.Request(url, headers=merged)
118
+
119
+ def _open(self, req: urllib.request.Request): # noqa: ANN202
120
+ return urllib.request.urlopen(req, timeout=self.timeout)
121
+
122
+ def _with_retries(self, url: str, fn, headers=None, block_retries: int | None = None): # noqa: ANN001, ANN202
123
+ if block_retries is None:
124
+ block_retries = self.block_retries
125
+ last: Exception | None = None
126
+ last_detail = ""
127
+ last_blocked = False
128
+ waf = 0
129
+ for attempt in range(self.retries + 1):
130
+ self.bucket.acquire()
131
+ retry_after = None
132
+ try:
133
+ return fn(self._request(url, headers))
134
+ except urllib.error.HTTPError as exc:
135
+ last = exc
136
+ last_detail = detail = _snippet(exc)
137
+ content_type = exc.headers.get("Content-Type") if exc.headers else None
138
+ last_blocked = exc.code == 403 and is_block_page(detail, content_type)
139
+ retry_after = parse_retry_after(
140
+ exc.headers.get("Retry-After") if exc.headers else None
141
+ )
142
+ exc.close()
143
+ if exc.code in RETRY_STATUS:
144
+ pass
145
+ elif not last_blocked:
146
+ raise HttpError(exc.code, url, detail) from exc
147
+ elif waf >= block_retries:
148
+ raise HttpError(
149
+ 403, url, _blocked_detail(last_detail, block_retries), transient=True
150
+ ) from exc
151
+ else:
152
+ waf += 1
153
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
154
+ last = exc
155
+ last_detail = str(exc)
156
+ last_blocked = False
157
+ if attempt < self.retries:
158
+ self._sleep(attempt, retry_after)
159
+ if isinstance(last, urllib.error.HTTPError):
160
+ detail = _blocked_detail(last_detail, waf) if last_blocked else last_detail
161
+ raise HttpError(last.code, url, detail, transient=True) from last
162
+ raise HttpError(0, url, last_detail or str(last)) from last
163
+
164
+ # -- public ------------------------------------------------------------
165
+ def open(self, url: str, headers: Mapping[str, str] | None = None):
166
+ """Open a stream. Caller must close it."""
167
+ return self._with_retries(url, self._open, headers)
168
+
169
+ def get_json(self, url: str, headers: Mapping[str, str] | None = None) -> dict:
170
+ with self.open(url, headers) as resp:
171
+ raw = resp.read(MAX_JSON)
172
+ try:
173
+ return json.loads(raw.decode("utf-8"))
174
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
175
+ raise HttpError(0, url, f"invalid JSON: {exc}") from exc
176
+
177
+ def head(self, url: str) -> dict:
178
+ def _head(req: urllib.request.Request):
179
+ req.method = "HEAD"
180
+ return self._open(req)
181
+
182
+ try:
183
+ with self._with_retries(url, _head, block_retries=0) as resp:
184
+ return dict(resp.headers)
185
+ except HttpError as exc:
186
+ if exc.status in (403, 405, 501):
187
+ return {}
188
+ raise
189
+
190
+
191
+ def _snippet(exc: urllib.error.HTTPError) -> str:
192
+ try:
193
+ raw = exc.read(SNIPPET)
194
+ except Exception: # noqa: BLE001
195
+ return ""
196
+ text = " ".join(raw.decode("utf-8", "replace").split())
197
+ return text[:SNIPPET]
198
+
199
+
200
+ def checksum_of(headers: Mapping[str, str]) -> str | None:
201
+ """Prefer a strong hash header over nothing (for direct URLs)."""
202
+ for key in ("X-Checksum-Sha256", "X-Amz-Meta-Sha256", "Content-MD5"):
203
+ if key in headers:
204
+ algo = "sha256" if "sha256" in key.lower() else "md5"
205
+ return f"{algo}:{headers[key]}"
206
+ return None
207
+
208
+
209
+ __all__ = ["Client", "parse_retry_after", "checksum_of", "verify"]
acadfetch/consts.py ADDED
@@ -0,0 +1,15 @@
1
+ """Package-wide constants and version."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ USER_AGENT = f"acadfetch/{__version__} (+https://github.com/young-ski/acadfetch)"
6
+
7
+ DEFAULT_CONCURRENCY = 3
8
+ DEFAULT_RATE = 2.0 # requests per second, global
9
+ DEFAULT_RETRIES = 4
10
+ # A 403 that came from a WAF/load-balancer block page clears on its own; a 403
11
+ # from the API's own permission check does not. Cap the former low so a really
12
+ # forbidden file still fails fast.
13
+ WAF_403_RETRIES = 2
14
+ DEFAULT_TIMEOUT = 60.0
15
+ CHUNK = 1 << 16