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 +5 -0
- acadfetch/__main__.py +6 -0
- acadfetch/auth.py +61 -0
- acadfetch/cli.py +280 -0
- acadfetch/client.py +209 -0
- acadfetch/consts.py +15 -0
- acadfetch/downloader.py +289 -0
- acadfetch/errors.py +43 -0
- acadfetch/manifest.py +89 -0
- acadfetch/naming.py +88 -0
- acadfetch/progress.py +161 -0
- acadfetch/py.typed +0 -0
- acadfetch/ratelimit.py +37 -0
- acadfetch/sources/__init__.py +6 -0
- acadfetch/sources/arxiv.py +70 -0
- acadfetch/sources/base.py +50 -0
- acadfetch/sources/direct.py +53 -0
- acadfetch/sources/doi.py +54 -0
- acadfetch/sources/figshare.py +86 -0
- acadfetch/sources/registry.py +60 -0
- acadfetch/sources/zenodo.py +92 -0
- acadfetch/units.py +25 -0
- acadfetch/verify.py +64 -0
- acadfetch-0.1.0.dist-info/METADATA +204 -0
- acadfetch-0.1.0.dist-info/RECORD +29 -0
- acadfetch-0.1.0.dist-info/WHEEL +5 -0
- acadfetch-0.1.0.dist-info/entry_points.txt +2 -0
- acadfetch-0.1.0.dist-info/licenses/LICENSE +21 -0
- acadfetch-0.1.0.dist-info/top_level.txt +1 -0
acadfetch/__init__.py
ADDED
acadfetch/__main__.py
ADDED
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
|