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/downloader.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""The transfer engine.
|
|
2
|
+
|
|
3
|
+
Resume is built on three ideas:
|
|
4
|
+
|
|
5
|
+
* bytes always land in ``<name>.part`` and are renamed only after verification,
|
|
6
|
+
so a killer ``Ctrl-C`` never leaves a truncated file looking finished;
|
|
7
|
+
* the validator (ETag / Last-Modified) lives in a sidecar so ``If-Range`` can
|
|
8
|
+
prove the remote object did not change between runs;
|
|
9
|
+
* the retry loop re-uses the partial file instead of restarting, which makes a
|
|
10
|
+
mid-stream socket drop cost at most one chunk.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import contextlib
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
21
|
+
from dataclasses import dataclass, replace
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from . import naming, verify
|
|
25
|
+
from .client import Client
|
|
26
|
+
from .consts import CHUNK
|
|
27
|
+
from .errors import HttpError, PartialError
|
|
28
|
+
from .progress import Progress
|
|
29
|
+
from .sources.base import File, Record, Result
|
|
30
|
+
from .units import human
|
|
31
|
+
|
|
32
|
+
TRANSFER_ATTEMPTS = 3
|
|
33
|
+
META_SUFFIX = ".part.meta.json"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Options:
|
|
38
|
+
out: Path
|
|
39
|
+
concurrency: int = 3
|
|
40
|
+
retries: int = TRANSFER_ATTEMPTS
|
|
41
|
+
dry_run: bool = False
|
|
42
|
+
max_bytes: int | None = None
|
|
43
|
+
progress: Progress | None = None
|
|
44
|
+
verify_only: bool = False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _read_meta(path: Path) -> dict:
|
|
48
|
+
try:
|
|
49
|
+
return json.loads(path.read_text("utf-8"))
|
|
50
|
+
except (OSError, ValueError):
|
|
51
|
+
return {}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _write_meta(path: Path, data: dict) -> None:
|
|
55
|
+
tmp = path.with_suffix(".tmp")
|
|
56
|
+
tmp.write_text(json.dumps(data), "utf-8")
|
|
57
|
+
os.replace(tmp, path)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _drop(*paths: Path) -> None:
|
|
61
|
+
for path in paths:
|
|
62
|
+
with contextlib.suppress(OSError):
|
|
63
|
+
path.unlink()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _transfer(client: Client, file: File, part: Path, meta_path: Path, opts: Options) -> int:
|
|
67
|
+
"""Move bytes into ``part``, resuming if possible. Returns bytes written."""
|
|
68
|
+
meta = _read_meta(meta_path) if part.exists() else {}
|
|
69
|
+
if meta.get("url") not in (None, file.url):
|
|
70
|
+
_drop(part, meta_path)
|
|
71
|
+
meta = {}
|
|
72
|
+
|
|
73
|
+
have = part.stat().st_size if part.exists() else 0
|
|
74
|
+
if file.size is not None and have > file.size:
|
|
75
|
+
_drop(part, meta_path)
|
|
76
|
+
have = 0
|
|
77
|
+
|
|
78
|
+
headers = {}
|
|
79
|
+
if have:
|
|
80
|
+
headers["Range"] = f"bytes={have}-"
|
|
81
|
+
validator = meta.get("etag") or meta.get("last_modified")
|
|
82
|
+
if validator:
|
|
83
|
+
headers["If-Range"] = validator
|
|
84
|
+
|
|
85
|
+
written = 0
|
|
86
|
+
resumed = 0
|
|
87
|
+
try:
|
|
88
|
+
resp = client.open(file.url, headers)
|
|
89
|
+
except HttpError as exc:
|
|
90
|
+
if exc.status == 416 and have:
|
|
91
|
+
return 0 # already complete
|
|
92
|
+
raise
|
|
93
|
+
|
|
94
|
+
with resp:
|
|
95
|
+
status = getattr(resp, "status", 200)
|
|
96
|
+
if have and status == 206:
|
|
97
|
+
mode, offset, resumed = "ab", have, have
|
|
98
|
+
else:
|
|
99
|
+
mode, offset = "wb", 0
|
|
100
|
+
_write_meta(
|
|
101
|
+
meta_path,
|
|
102
|
+
{
|
|
103
|
+
"url": file.url,
|
|
104
|
+
"etag": resp.headers.get("ETag"),
|
|
105
|
+
"last_modified": resp.headers.get("Last-Modified"),
|
|
106
|
+
"size": file.size,
|
|
107
|
+
},
|
|
108
|
+
)
|
|
109
|
+
declared = resp.headers.get("Content-Length")
|
|
110
|
+
with part.open(mode) as fh:
|
|
111
|
+
while True:
|
|
112
|
+
chunk = resp.read(CHUNK)
|
|
113
|
+
if not chunk:
|
|
114
|
+
break
|
|
115
|
+
fh.write(chunk)
|
|
116
|
+
written += len(chunk)
|
|
117
|
+
if opts.progress:
|
|
118
|
+
opts.progress.advance(len(chunk))
|
|
119
|
+
if opts.max_bytes and offset + written > opts.max_bytes:
|
|
120
|
+
raise HttpError(0, file.url, f"exceeds --max-size ({human(opts.max_bytes)})")
|
|
121
|
+
if declared is not None and int(declared) != written:
|
|
122
|
+
raise HttpError(0, file.url, f"short read: got {written}, expected {declared}")
|
|
123
|
+
if resumed:
|
|
124
|
+
return written
|
|
125
|
+
return written
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def fetch_file(client: Client, file: File, opts: Options, taken: set[str]) -> Result:
|
|
129
|
+
name = naming.unique(naming.sanitize(file.name), taken)
|
|
130
|
+
final = opts.out / name
|
|
131
|
+
part = opts.out / (name + ".part")
|
|
132
|
+
meta_path = opts.out / (name + META_SUFFIX)
|
|
133
|
+
|
|
134
|
+
if final.exists():
|
|
135
|
+
verdict = verify.matches(final, file.checksum)
|
|
136
|
+
sized_ok = file.size is None or final.stat().st_size == file.size
|
|
137
|
+
if verdict is True or (verdict is None and sized_ok):
|
|
138
|
+
return Result(
|
|
139
|
+
file=file,
|
|
140
|
+
state="skipped",
|
|
141
|
+
path=str(final),
|
|
142
|
+
bytes=final.stat().st_size,
|
|
143
|
+
verified=verdict,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if opts.dry_run:
|
|
147
|
+
return Result(file=file, state="skipped", path=str(final), verified=None)
|
|
148
|
+
|
|
149
|
+
resumed_from = part.stat().st_size if part.exists() else 0
|
|
150
|
+
progress = opts.progress
|
|
151
|
+
if progress:
|
|
152
|
+
progress.start(name, file.size)
|
|
153
|
+
try:
|
|
154
|
+
return _attempt(client, file, part, meta_path, final, opts, resumed_from)
|
|
155
|
+
finally:
|
|
156
|
+
if progress:
|
|
157
|
+
progress.stop(name)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _attempt(
|
|
161
|
+
client: Client,
|
|
162
|
+
file: File,
|
|
163
|
+
part: Path,
|
|
164
|
+
meta_path: Path,
|
|
165
|
+
final: Path,
|
|
166
|
+
opts: Options,
|
|
167
|
+
resumed_from: int,
|
|
168
|
+
) -> Result:
|
|
169
|
+
"""Retry loop, verification and final rename for one file.
|
|
170
|
+
|
|
171
|
+
A 403 that looks like a WAF block page is retried a couple of times with
|
|
172
|
+
the partial file kept, then the file's fallback URLs are tried (a legacy
|
|
173
|
+
endpoint for the same bytes). Everything else terminal fails immediately.
|
|
174
|
+
"""
|
|
175
|
+
last: Exception | None = None
|
|
176
|
+
blocked = 0
|
|
177
|
+
candidates = [file.url, *file.fallbacks]
|
|
178
|
+
done = False
|
|
179
|
+
for index, url in enumerate(candidates):
|
|
180
|
+
source = file if url == file.url else replace(file, url=url)
|
|
181
|
+
for attempt in range(opts.retries + 1):
|
|
182
|
+
try:
|
|
183
|
+
_transfer(client, source, part, meta_path, opts)
|
|
184
|
+
done = True
|
|
185
|
+
break
|
|
186
|
+
except HttpError as exc:
|
|
187
|
+
last = exc
|
|
188
|
+
if exc.status == 0 and "exceeds --max-size" in str(exc):
|
|
189
|
+
return Result(file=file, state="failed", error=str(exc))
|
|
190
|
+
if exc.status == 403 and exc.transient and blocked < client.block_retries:
|
|
191
|
+
blocked += 1
|
|
192
|
+
elif exc.status in (401, 403, 404, 410):
|
|
193
|
+
break # terminal for this URL; a fallback may still work
|
|
194
|
+
except (OSError, ConnectionError) as exc:
|
|
195
|
+
last = exc
|
|
196
|
+
if attempt < opts.retries:
|
|
197
|
+
time.sleep(min(2**attempt, 10))
|
|
198
|
+
resumed_from = part.stat().st_size if part.exists() else 0
|
|
199
|
+
if done:
|
|
200
|
+
break
|
|
201
|
+
if index < len(candidates) - 1:
|
|
202
|
+
_drop(part, meta_path)
|
|
203
|
+
resumed_from = 0
|
|
204
|
+
continue
|
|
205
|
+
_drop(part, meta_path)
|
|
206
|
+
return Result(file=file, state="failed", error=str(last))
|
|
207
|
+
|
|
208
|
+
if last is not None and not part.exists():
|
|
209
|
+
return Result(file=file, state="failed", error=str(last))
|
|
210
|
+
|
|
211
|
+
if file.size is not None and part.exists() and part.stat().st_size != file.size:
|
|
212
|
+
return Result(
|
|
213
|
+
file=file,
|
|
214
|
+
state="failed",
|
|
215
|
+
error=f"size mismatch (want {file.size})",
|
|
216
|
+
bytes=part.stat().st_size,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
verdict = verify.matches(part, file.checksum)
|
|
220
|
+
if verdict is False:
|
|
221
|
+
_drop(part, meta_path)
|
|
222
|
+
try:
|
|
223
|
+
_transfer(client, file, part, meta_path, opts)
|
|
224
|
+
verdict = verify.matches(part, file.checksum)
|
|
225
|
+
except Exception as exc: # noqa: BLE001
|
|
226
|
+
return Result(file=file, state="failed", error=f"re-download failed: {exc}")
|
|
227
|
+
if verdict is False:
|
|
228
|
+
_drop(part, meta_path)
|
|
229
|
+
return Result(file=file, state="failed", error="checksum mismatch")
|
|
230
|
+
|
|
231
|
+
os.replace(part, final)
|
|
232
|
+
_drop(meta_path)
|
|
233
|
+
return Result(
|
|
234
|
+
file=file,
|
|
235
|
+
state="downloaded",
|
|
236
|
+
path=str(final),
|
|
237
|
+
bytes=final.stat().st_size,
|
|
238
|
+
verified=verdict,
|
|
239
|
+
resumed_from=resumed_from,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def fetch_record(
|
|
244
|
+
record: Record, opts: Options, client: Client, only: list[str] | None = None
|
|
245
|
+
) -> list[Result]:
|
|
246
|
+
opts.out.mkdir(parents=True, exist_ok=True)
|
|
247
|
+
files = record.files
|
|
248
|
+
if only:
|
|
249
|
+
wanted = {n.lower() for n in only}
|
|
250
|
+
files = [
|
|
251
|
+
f
|
|
252
|
+
for f in files
|
|
253
|
+
if naming.sanitize(f.name).lower() in wanted or f.name.lower() in wanted
|
|
254
|
+
]
|
|
255
|
+
if not files:
|
|
256
|
+
raise PartialError([])
|
|
257
|
+
|
|
258
|
+
if opts.max_bytes:
|
|
259
|
+
_enforce_total(record, opts.max_bytes)
|
|
260
|
+
|
|
261
|
+
taken: set[str] = set()
|
|
262
|
+
lock = threading.Lock()
|
|
263
|
+
results: list[Result] = []
|
|
264
|
+
|
|
265
|
+
if opts.concurrency <= 1:
|
|
266
|
+
for file in files:
|
|
267
|
+
results.append(fetch_file(client, file, opts, taken))
|
|
268
|
+
return results
|
|
269
|
+
|
|
270
|
+
with ThreadPoolExecutor(max_workers=opts.concurrency) as pool:
|
|
271
|
+
futures = {}
|
|
272
|
+
for file in files:
|
|
273
|
+
with lock:
|
|
274
|
+
futures[pool.submit(fetch_file, client, file, opts, taken)] = file
|
|
275
|
+
for future in as_completed(futures):
|
|
276
|
+
results.append(future.result())
|
|
277
|
+
order = {id(f): i for i, f in enumerate(record.files)}
|
|
278
|
+
results.sort(key=lambda r: order.get(id(r.file), 0))
|
|
279
|
+
return results
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _enforce_total(record: Record, limit: int) -> None:
|
|
283
|
+
total = record.total_bytes
|
|
284
|
+
if total is None:
|
|
285
|
+
return
|
|
286
|
+
if total > limit:
|
|
287
|
+
from .errors import SourceError
|
|
288
|
+
|
|
289
|
+
raise SourceError(f"record is {human(total)}, over --max-size ({human(limit)})")
|
acadfetch/errors.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Exception types. Every failure mode gets a distinct exit code path."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AcFError(Exception):
|
|
7
|
+
"""Base class for all acadfetch errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TargetError(AcFError):
|
|
11
|
+
"""The target string could not be parsed or resolved."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SourceError(AcFError):
|
|
15
|
+
"""A repository API returned an unusable response."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HttpError(AcFError):
|
|
19
|
+
"""An HTTP request failed after all retries.
|
|
20
|
+
|
|
21
|
+
``transient`` marks a failure that is worth retrying further up, currently
|
|
22
|
+
only a 403 that came from a WAF/load-balancer block page rather than from
|
|
23
|
+
the API's own permission check.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, status: int, url: str, detail: str = "", transient: bool = False) -> None:
|
|
27
|
+
self.status = status
|
|
28
|
+
self.url = url
|
|
29
|
+
self.transient = transient
|
|
30
|
+
super().__init__(f"HTTP {status} for {url}{': ' + detail if detail else ''}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class VerifyError(AcFError):
|
|
34
|
+
"""Downloaded bytes do not match the checksum published by the source."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PartialError(AcFError):
|
|
38
|
+
"""One or more files failed while others succeeded."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, failures) -> None: # noqa: ANN001
|
|
41
|
+
self.failures = list(failures)
|
|
42
|
+
names = ", ".join(f.name for f in self.failures)
|
|
43
|
+
super().__init__(f"{len(self.failures)} file(s) failed: {names}")
|
acadfetch/manifest.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""manifest.json: the reproducibility artifact.
|
|
2
|
+
|
|
3
|
+
The point of a manifest is that someone else (or you, in two years) can prove
|
|
4
|
+
the bytes on disk are the bytes that were published, and can tell which
|
|
5
|
+
license they are allowed to redistribute under.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from . import consts, naming, verify
|
|
16
|
+
from .auth import Token
|
|
17
|
+
from .sources.base import Record, Result
|
|
18
|
+
|
|
19
|
+
SCHEMA = 1
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build(record: Record, results: list[Result], token: Token | None = None) -> dict:
|
|
23
|
+
by_url = {r.file.url: r for r in results}
|
|
24
|
+
return {
|
|
25
|
+
"schema": SCHEMA,
|
|
26
|
+
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
27
|
+
"tool": {"name": "acadfetch", "version": consts.__version__},
|
|
28
|
+
"source": record.source,
|
|
29
|
+
"target": record.target,
|
|
30
|
+
"doi": record.doi,
|
|
31
|
+
"title": record.title,
|
|
32
|
+
"license": record.license,
|
|
33
|
+
"publisher": record.publisher,
|
|
34
|
+
"version": record.version,
|
|
35
|
+
"landing": record.landing,
|
|
36
|
+
# Provenance only: the variable name and a tail, never the secret.
|
|
37
|
+
"auth": {"env": token.env, "hint": token.hint} if token else None,
|
|
38
|
+
"files": [
|
|
39
|
+
{
|
|
40
|
+
"name": naming.sanitize(f.name),
|
|
41
|
+
"url": f.url,
|
|
42
|
+
"size": f.size,
|
|
43
|
+
"checksum": f.checksum,
|
|
44
|
+
"sha256": _sha256(by_url.get(f.url)),
|
|
45
|
+
"bytes": by_url[f.url].bytes if f.url in by_url else 0,
|
|
46
|
+
"state": by_url[f.url].state if f.url in by_url else "missing",
|
|
47
|
+
"verified": by_url[f.url].verified if f.url in by_url else None,
|
|
48
|
+
}
|
|
49
|
+
for f in record.files
|
|
50
|
+
],
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _sha256(result: Result | None) -> str | None:
|
|
55
|
+
if result is None or not result.path:
|
|
56
|
+
return None
|
|
57
|
+
path = Path(result.path)
|
|
58
|
+
if not path.is_file():
|
|
59
|
+
return None
|
|
60
|
+
return verify.hash_file(path, "sha256")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def write(path: Path, data: dict) -> None:
|
|
64
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
66
|
+
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", "utf-8")
|
|
67
|
+
os.replace(tmp, path)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def load(path: Path) -> dict:
|
|
71
|
+
data = json.loads(path.read_text("utf-8"))
|
|
72
|
+
if data.get("schema") != SCHEMA:
|
|
73
|
+
raise ValueError(f"unsupported manifest schema: {data.get('schema')!r}")
|
|
74
|
+
return data
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def verify_manifest(path: Path, root: Path) -> list[dict]:
|
|
78
|
+
"""Re-hash every recorded file. Returns one row per file."""
|
|
79
|
+
data = load(path)
|
|
80
|
+
rows = []
|
|
81
|
+
for entry in data.get("files") or []:
|
|
82
|
+
target = root / entry["name"]
|
|
83
|
+
if not target.is_file():
|
|
84
|
+
rows.append({"name": entry["name"], "status": "missing"})
|
|
85
|
+
continue
|
|
86
|
+
actual = verify.hash_file(target, "sha256")
|
|
87
|
+
status = "ok" if actual == entry.get("sha256") else "MISMATCH"
|
|
88
|
+
rows.append({"name": entry["name"], "status": status, "sha256": actual})
|
|
89
|
+
return rows
|
acadfetch/naming.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Turn arbitrary repository file names into safe local paths.
|
|
2
|
+
|
|
3
|
+
Repository metadata is untrusted: it can contain path separators, NULs,
|
|
4
|
+
Windows reserved device names, 300-character names, and duplicates that
|
|
5
|
+
would silently overwrite each other.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import unicodedata
|
|
13
|
+
from urllib.parse import unquote, urlsplit
|
|
14
|
+
|
|
15
|
+
MAX_STEM = 120
|
|
16
|
+
MAX_SUFFIX = 20
|
|
17
|
+
MAX_FULL = 180
|
|
18
|
+
|
|
19
|
+
_ILLEGAL = re.compile(r'[<>:"/\\|?*\x00-\x1f\x7f]')
|
|
20
|
+
_WS = re.compile(r"[ \t\n\r\f\v]+")
|
|
21
|
+
|
|
22
|
+
# CON, NUL, COM1..9, LPT1..9 are unusable as file names on Windows even with
|
|
23
|
+
# an extension, in every directory.
|
|
24
|
+
_RESERVED = {
|
|
25
|
+
"CON",
|
|
26
|
+
"PRN",
|
|
27
|
+
"AUX",
|
|
28
|
+
"NUL",
|
|
29
|
+
*(f"COM{i}" for i in range(1, 10)),
|
|
30
|
+
*(f"LPT{i}" for i in range(1, 10)),
|
|
31
|
+
}
|
|
32
|
+
_RESERVED_WITH_EXT = {
|
|
33
|
+
f"{base}.{ext}" for base in _RESERVED for ext in ("txt", "tar", "gz", "zip", "csv")
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_COMPRESS = {".gz", ".bz2", ".xz", ".zst", ".zip", ".7z", ".rar"}
|
|
38
|
+
_EXT = re.compile(r"\.[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _split_ext(name: str) -> tuple[str, str]:
|
|
42
|
+
"""Split one extension, or a two-part one for ``archive.tar.gz`` style names."""
|
|
43
|
+
stem, ext = os.path.splitext(name)
|
|
44
|
+
if not ext or len(ext) > MAX_SUFFIX or not _EXT.fullmatch(ext):
|
|
45
|
+
return name, ""
|
|
46
|
+
if ext.lower() in _COMPRESS:
|
|
47
|
+
inner_stem, inner_ext = os.path.splitext(stem)
|
|
48
|
+
if inner_stem and _EXT.fullmatch(inner_ext) and len(inner_ext) <= MAX_SUFFIX:
|
|
49
|
+
return inner_stem, inner_ext + ext
|
|
50
|
+
return stem, ext
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def sanitize(name: str, fallback: str = "file") -> str:
|
|
54
|
+
"""Reduce ``name`` to a single safe path component."""
|
|
55
|
+
raw = unquote(name or "")
|
|
56
|
+
raw = urlsplit(raw).path if ("://" in raw or raw.count("/") > 1) else raw
|
|
57
|
+
raw = raw.replace("\\", "/").rsplit("/", 1)[-1]
|
|
58
|
+
raw = unicodedata.normalize("NFC", raw)
|
|
59
|
+
raw = _WS.sub(" ", raw) # tabs/newlines become spaces, not underscores
|
|
60
|
+
raw = _ILLEGAL.sub("_", raw)
|
|
61
|
+
raw = _WS.sub(" ", raw).strip()
|
|
62
|
+
raw = raw.strip(". ")
|
|
63
|
+
|
|
64
|
+
stem, ext = _split_ext(raw)
|
|
65
|
+
stem = stem.strip(". ") or fallback
|
|
66
|
+
if stem.upper() in _RESERVED or f"{stem}{ext}".upper() in _RESERVED_WITH_EXT:
|
|
67
|
+
stem = "_" + stem
|
|
68
|
+
if len(stem) > MAX_STEM:
|
|
69
|
+
stem = stem[:MAX_STEM].rstrip(". ")
|
|
70
|
+
out = stem + ext[:MAX_SUFFIX]
|
|
71
|
+
if len(out) > MAX_FULL:
|
|
72
|
+
keep = MAX_FULL - len(ext[:MAX_SUFFIX])
|
|
73
|
+
out = stem[:keep].rstrip(". ") + ext[:MAX_SUFFIX]
|
|
74
|
+
return out or fallback
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def unique(name: str, taken: set[str]) -> str:
|
|
78
|
+
"""Return a name not already in ``taken`` (case-insensitively)."""
|
|
79
|
+
if name.lower() not in taken:
|
|
80
|
+
taken.add(name.lower())
|
|
81
|
+
return name
|
|
82
|
+
stem, ext = os.path.splitext(name)
|
|
83
|
+
for i in range(2, 10000):
|
|
84
|
+
candidate = f"{stem} ({i}){ext}"
|
|
85
|
+
if candidate.lower() not in taken:
|
|
86
|
+
taken.add(candidate.lower())
|
|
87
|
+
return candidate
|
|
88
|
+
raise RuntimeError(f"could not dedupe {name!r}")
|
acadfetch/progress.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Aggregate transfer progress.
|
|
2
|
+
|
|
3
|
+
One line for the whole run, not one per file: with ``-j 3`` three downloads are
|
|
4
|
+
in flight and three interleaved lines are unreadable. On a TTY the line is
|
|
5
|
+
redrawn in place; when stdout is a pipe (CI, ``tee``) it degrades to occasional
|
|
6
|
+
whole lines so the log stays greppable.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from collections import deque
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from typing import TextIO
|
|
17
|
+
|
|
18
|
+
from .units import duration, human
|
|
19
|
+
|
|
20
|
+
SMOOTHING_SECONDS = 1.0
|
|
21
|
+
TTY_INTERVAL = 0.15
|
|
22
|
+
PIPE_INTERVAL = 5.0
|
|
23
|
+
MAX_WIDTH = 120
|
|
24
|
+
NAME_WIDTH = 40
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _clip(text: str, width: int) -> str:
|
|
28
|
+
return text if len(text) <= width else text[: width - 1] + "…"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Progress:
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
stream: TextIO | None = None,
|
|
35
|
+
enabled: bool = True,
|
|
36
|
+
is_tty: bool | None = None,
|
|
37
|
+
clock: Callable[[], float] = time.monotonic,
|
|
38
|
+
) -> None:
|
|
39
|
+
self.stream = stream if stream is not None else sys.stdout
|
|
40
|
+
self.enabled = enabled
|
|
41
|
+
self.is_tty = (self.stream.isatty() if is_tty is None else is_tty) and enabled
|
|
42
|
+
self._clock = clock
|
|
43
|
+
self._lock = threading.RLock() # _render() re-enters under _maybe_emit
|
|
44
|
+
self._active: dict[str, int] = {}
|
|
45
|
+
self._done = 0
|
|
46
|
+
self._total = 0
|
|
47
|
+
self._samples: deque[tuple[float, int]] = deque()
|
|
48
|
+
self._last_emit = 0.0
|
|
49
|
+
self._printed = 0
|
|
50
|
+
self._last_count = 0
|
|
51
|
+
self._dirty = False
|
|
52
|
+
self._wrote = False
|
|
53
|
+
|
|
54
|
+
# -- structure ---------------------------------------------------------
|
|
55
|
+
def start(self, name: str, size: int | None) -> None:
|
|
56
|
+
if not self.enabled:
|
|
57
|
+
return
|
|
58
|
+
with self._lock:
|
|
59
|
+
self._active[name] = size or 0
|
|
60
|
+
self._total += size or 0
|
|
61
|
+
self._sample()
|
|
62
|
+
self._maybe_emit(force=True)
|
|
63
|
+
|
|
64
|
+
def stop(self, name: str) -> None:
|
|
65
|
+
if not self.enabled:
|
|
66
|
+
return
|
|
67
|
+
self._maybe_emit(force=True) # one last frame that still counts the file
|
|
68
|
+
with self._lock:
|
|
69
|
+
self._active.pop(name, None)
|
|
70
|
+
|
|
71
|
+
def advance(self, count: int) -> None:
|
|
72
|
+
if not self.enabled or count <= 0:
|
|
73
|
+
return
|
|
74
|
+
with self._lock:
|
|
75
|
+
self._done += count
|
|
76
|
+
self._sample()
|
|
77
|
+
self._maybe_emit()
|
|
78
|
+
|
|
79
|
+
# -- rendering ---------------------------------------------------------
|
|
80
|
+
def _sample(self) -> None:
|
|
81
|
+
now = self._clock()
|
|
82
|
+
self._samples.append((now, self._done))
|
|
83
|
+
while len(self._samples) > 2 and now - self._samples[0][0] > SMOOTHING_SECONDS * 4:
|
|
84
|
+
self._samples.popleft()
|
|
85
|
+
self._dirty = True
|
|
86
|
+
|
|
87
|
+
def _speed(self) -> float:
|
|
88
|
+
if len(self._samples) < 2:
|
|
89
|
+
return 0.0
|
|
90
|
+
(t0, b0), (t1, b1) = self._samples[0], self._samples[-1]
|
|
91
|
+
span = t1 - t0
|
|
92
|
+
if span < 0.2:
|
|
93
|
+
return 0.0
|
|
94
|
+
return max(0.0, (b1 - b0) / span)
|
|
95
|
+
|
|
96
|
+
def _eta(self, speed: float) -> float | None:
|
|
97
|
+
if not self._total or speed <= 0:
|
|
98
|
+
return None
|
|
99
|
+
return max(0.0, (self._total - self._done) / speed)
|
|
100
|
+
|
|
101
|
+
def _render(self) -> str:
|
|
102
|
+
speed = self._speed()
|
|
103
|
+
with self._lock:
|
|
104
|
+
active = list(self._active)
|
|
105
|
+
# After the last file stops there is nothing active; keep showing how
|
|
106
|
+
# many files the run covered instead of dropping to "0 file(s)".
|
|
107
|
+
count = len(active) or self._last_count
|
|
108
|
+
self._last_count = count
|
|
109
|
+
parts = [f"{count} file(s)", f"{human(self._done)}"]
|
|
110
|
+
if len(active) == 1:
|
|
111
|
+
parts[0] = _clip(active[0], NAME_WIDTH)
|
|
112
|
+
if self._total:
|
|
113
|
+
parts[1] = f"{human(self._done)}/{human(self._total)}"
|
|
114
|
+
if speed > 0:
|
|
115
|
+
parts.append(f"{human(speed)}/s")
|
|
116
|
+
eta = self._eta(speed)
|
|
117
|
+
if eta is not None:
|
|
118
|
+
parts.append(f"ETA {duration(eta)}")
|
|
119
|
+
line = " ".join(parts)
|
|
120
|
+
return line[:MAX_WIDTH]
|
|
121
|
+
|
|
122
|
+
def _maybe_emit(self, force: bool = False) -> None:
|
|
123
|
+
if not self.enabled:
|
|
124
|
+
return
|
|
125
|
+
now = self._clock()
|
|
126
|
+
interval = TTY_INTERVAL if self.is_tty else PIPE_INTERVAL
|
|
127
|
+
with self._lock:
|
|
128
|
+
if not self._dirty:
|
|
129
|
+
return
|
|
130
|
+
if not force and now - self._last_emit < interval:
|
|
131
|
+
return
|
|
132
|
+
line = self._render()
|
|
133
|
+
self._last_emit = now
|
|
134
|
+
self._dirty = False
|
|
135
|
+
self._write(line, newline=not self.is_tty)
|
|
136
|
+
|
|
137
|
+
def _write(self, line: str, newline: bool) -> None:
|
|
138
|
+
try:
|
|
139
|
+
if self.is_tty:
|
|
140
|
+
pad = max(0, self._printed - len(line))
|
|
141
|
+
self.stream.write("\r" + line + " " * pad)
|
|
142
|
+
self._printed = len(line)
|
|
143
|
+
else:
|
|
144
|
+
self.stream.write(line + ("\n" if newline else ""))
|
|
145
|
+
self.stream.flush()
|
|
146
|
+
self._wrote = True
|
|
147
|
+
except (OSError, ValueError): # stream closed under us
|
|
148
|
+
self.enabled = False
|
|
149
|
+
|
|
150
|
+
def finish(self) -> None:
|
|
151
|
+
if not self.enabled:
|
|
152
|
+
return
|
|
153
|
+
# A run where every file was already on disk never started one, so a
|
|
154
|
+
# frame here would only say "0 file(s) 0 B" right before the tally.
|
|
155
|
+
if self._wrote:
|
|
156
|
+
line = self._render()
|
|
157
|
+
self._write(line, newline=True)
|
|
158
|
+
if self.is_tty:
|
|
159
|
+
self.stream.write("\n")
|
|
160
|
+
self.stream.flush()
|
|
161
|
+
self.enabled = False
|
acadfetch/py.typed
ADDED
|
File without changes
|