lunar-toolkit 0.1.0.dev2__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.
- lunar_toolkit/__init__.py +6 -0
- lunar_toolkit/asp.py +432 -0
- lunar_toolkit/cli/__init__.py +88 -0
- lunar_toolkit/cli/_jobs.py +437 -0
- lunar_toolkit/cli/_status.py +368 -0
- lunar_toolkit/cli/_utils.py +128 -0
- lunar_toolkit/cli/app.py +205 -0
- lunar_toolkit/cli/build_db.py +130 -0
- lunar_toolkit/cli/download.py +111 -0
- lunar_toolkit/cli/kaguya.py +253 -0
- lunar_toolkit/cli/ode_search.py +144 -0
- lunar_toolkit/cli/pairs.py +236 -0
- lunar_toolkit/cli/process.py +138 -0
- lunar_toolkit/cli/query.py +70 -0
- lunar_toolkit/cli/reference.py +233 -0
- lunar_toolkit/cli/search.py +90 -0
- lunar_toolkit/cli/serve.py +706 -0
- lunar_toolkit/cli/sfs.py +344 -0
- lunar_toolkit/cli/sfs_report.py +148 -0
- lunar_toolkit/cli/shadowcam.py +246 -0
- lunar_toolkit/cli/stereo.py +85 -0
- lunar_toolkit/cli/workspace.py +84 -0
- lunar_toolkit/config.py +345 -0
- lunar_toolkit/constants.py +217 -0
- lunar_toolkit/csm.py +340 -0
- lunar_toolkit/downloader.py +447 -0
- lunar_toolkit/geometry.py +556 -0
- lunar_toolkit/isis.py +222 -0
- lunar_toolkit/kaguya/__init__.py +45 -0
- lunar_toolkit/kaguya/mi.py +274 -0
- lunar_toolkit/kaguya/tc.py +370 -0
- lunar_toolkit/lroc/__init__.py +6 -0
- lunar_toolkit/lroc/cumindex.py +1197 -0
- lunar_toolkit/lroc/nac.py +702 -0
- lunar_toolkit/observation.py +127 -0
- lunar_toolkit/ode.py +485 -0
- lunar_toolkit/provenance.py +312 -0
- lunar_toolkit/reference.py +1111 -0
- lunar_toolkit/selection.py +714 -0
- lunar_toolkit/sfs.py +1534 -0
- lunar_toolkit/sfs_products.py +676 -0
- lunar_toolkit/sfs_report.py +1328 -0
- lunar_toolkit/shadowcam/__init__.py +29 -0
- lunar_toolkit/shadowcam/sc.py +392 -0
- lunar_toolkit/static/index.html +3259 -0
- lunar_toolkit/stereo.py +519 -0
- lunar_toolkit/stereo_dem.py +145 -0
- lunar_toolkit/tools.py +264 -0
- lunar_toolkit/workspace.py +1173 -0
- lunar_toolkit-0.1.0.dev2.dist-info/METADATA +177 -0
- lunar_toolkit-0.1.0.dev2.dist-info/RECORD +55 -0
- lunar_toolkit-0.1.0.dev2.dist-info/WHEEL +5 -0
- lunar_toolkit-0.1.0.dev2.dist-info/entry_points.txt +2 -0
- lunar_toolkit-0.1.0.dev2.dist-info/licenses/LICENSE +28 -0
- lunar_toolkit-0.1.0.dev2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
"""PdsDownloader — parallel file retrieval from the PDS archive."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import contextvars
|
|
5
|
+
import email.utils
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import random
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.parse
|
|
15
|
+
import urllib.request
|
|
16
|
+
from collections.abc import Callable, Iterator
|
|
17
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import pandas as pd
|
|
21
|
+
|
|
22
|
+
from .constants import APP_NAME, APP_VERSION, DEFAULT_CACHE_DIR, modernize_pds_url
|
|
23
|
+
from .tools import reuse_existing
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger(__name__)
|
|
26
|
+
_USER_AGENT = f"{APP_NAME}/{APP_VERSION} (python-urllib)"
|
|
27
|
+
|
|
28
|
+
Progress = Callable[[str, int, int | None], None]
|
|
29
|
+
"""``progress(file name, bytes done, bytes total or None)``, called per chunk; raising aborts.
|
|
30
|
+
|
|
31
|
+
While the host is paused (throttling cooldown) it is called about once a second with
|
|
32
|
+
``done = -1``.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
_PROGRESS: contextvars.ContextVar[Progress | None] = contextvars.ContextVar("download_progress", default=None)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@contextlib.contextmanager
|
|
39
|
+
def report_progress(callback: Progress) -> Iterator[None]:
|
|
40
|
+
"""Send the progress of every :func:`download_file` in this thread / context to *callback*."""
|
|
41
|
+
token = _PROGRESS.set(callback)
|
|
42
|
+
try:
|
|
43
|
+
yield
|
|
44
|
+
finally:
|
|
45
|
+
_PROGRESS.reset(token)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class PdsDownloader:
|
|
49
|
+
"""Download and cache files from the PDS archive.
|
|
50
|
+
|
|
51
|
+
Parameters
|
|
52
|
+
----------
|
|
53
|
+
cache_dir : Path
|
|
54
|
+
Base directory for the local cache (default ~/.cache/lunar_toolkit).
|
|
55
|
+
max_workers : int
|
|
56
|
+
Thread-pool size for parallel downloads (default 8).
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
cache_dir: Path = DEFAULT_CACHE_DIR,
|
|
62
|
+
max_workers: int = 4,
|
|
63
|
+
) -> None:
|
|
64
|
+
self.cache_dir = Path(cache_dir)
|
|
65
|
+
self.max_workers = max_workers
|
|
66
|
+
|
|
67
|
+
def cache_path(self, url: str) -> Path:
|
|
68
|
+
"""Return the local path that would be used to cache *url*."""
|
|
69
|
+
url_hash = hashlib.md5(url.encode()).hexdigest()[:8]
|
|
70
|
+
return self.cache_dir / f"{url_hash}_{url_filename(url)}"
|
|
71
|
+
|
|
72
|
+
def ensure_cached(
|
|
73
|
+
self,
|
|
74
|
+
url: str,
|
|
75
|
+
force: bool = False,
|
|
76
|
+
show_progress: bool = True,
|
|
77
|
+
expected_md5: str | None = None,
|
|
78
|
+
) -> Path:
|
|
79
|
+
"""Return local path for *url*, downloading if not already cached."""
|
|
80
|
+
dest = self.cache_path(url)
|
|
81
|
+
if dest.exists() and not force:
|
|
82
|
+
return dest
|
|
83
|
+
return download_file(url, dest, show_progress=show_progress, expected_md5=expected_md5)
|
|
84
|
+
|
|
85
|
+
def download(
|
|
86
|
+
self,
|
|
87
|
+
product_rows: pd.DataFrame,
|
|
88
|
+
output_dir: Path,
|
|
89
|
+
url_col: str = "PDS_URL",
|
|
90
|
+
show_progress: bool = True,
|
|
91
|
+
force: bool = False,
|
|
92
|
+
) -> pd.DataFrame:
|
|
93
|
+
"""Download PDS image files in parallel.
|
|
94
|
+
|
|
95
|
+
Parameters
|
|
96
|
+
----------
|
|
97
|
+
product_rows : pd.DataFrame
|
|
98
|
+
Must contain a *url_col* column (e.g. from CumIndex.lookup()).
|
|
99
|
+
output_dir : Path
|
|
100
|
+
Destination directory for .IMG files.
|
|
101
|
+
url_col : str
|
|
102
|
+
Column holding the download URL.
|
|
103
|
+
show_progress : bool
|
|
104
|
+
Show per-file progress bars.
|
|
105
|
+
force : bool
|
|
106
|
+
Download again even when ``<output_dir>/<file name>`` exists
|
|
107
|
+
(by default it is reused; downloads land via ``.part`` files,
|
|
108
|
+
so an existing file is complete).
|
|
109
|
+
|
|
110
|
+
Returns
|
|
111
|
+
-------
|
|
112
|
+
Copy of product_rows with LOCAL_PATH and DOWNLOAD_ERROR columns added.
|
|
113
|
+
"""
|
|
114
|
+
results = self._download_all(list(product_rows[url_col]), output_dir, show_progress, force)
|
|
115
|
+
paths = {u: str(path) if path else None for u, (path, _) in results.items()}
|
|
116
|
+
df = product_rows.copy()
|
|
117
|
+
df["LOCAL_PATH"] = df[url_col].map(paths.get)
|
|
118
|
+
df["DOWNLOAD_ERROR"] = df[url_col].map(lambda u: results.get(u, (None, None))[1])
|
|
119
|
+
return df
|
|
120
|
+
|
|
121
|
+
def download_urls(
|
|
122
|
+
self,
|
|
123
|
+
urls: list[str],
|
|
124
|
+
output_dir: Path,
|
|
125
|
+
show_progress: bool = True,
|
|
126
|
+
force: bool = False,
|
|
127
|
+
) -> dict[str, Path | None]:
|
|
128
|
+
"""Download a plain list of URLs in parallel.
|
|
129
|
+
|
|
130
|
+
Existing files are reused unless *force* (see :meth:`download`).
|
|
131
|
+
Returns a dict mapping each URL to its local path (or None on error).
|
|
132
|
+
"""
|
|
133
|
+
path_map: dict[str, Path | None] = {}
|
|
134
|
+
for url, (path, err) in self._download_all(urls, output_dir, show_progress, force).items():
|
|
135
|
+
if err:
|
|
136
|
+
log.error("%s: %s", url, err)
|
|
137
|
+
path_map[url] = path
|
|
138
|
+
return path_map
|
|
139
|
+
|
|
140
|
+
def _download_all(
|
|
141
|
+
self, urls: list[str], output_dir: Path, show_progress: bool, force: bool
|
|
142
|
+
) -> dict[str, tuple[Path | None, str | None]]:
|
|
143
|
+
"""``url → (local path, error message)`` for parallel downloads into *output_dir*."""
|
|
144
|
+
output_dir = Path(output_dir)
|
|
145
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
results: dict[str, tuple[Path | None, str | None]] = {}
|
|
147
|
+
with ThreadPoolExecutor(max_workers=self.max_workers) as pool:
|
|
148
|
+
futures = [
|
|
149
|
+
pool.submit(_download_one, url, output_dir / url_filename(url), show_progress, force)
|
|
150
|
+
for url in urls
|
|
151
|
+
]
|
|
152
|
+
for future in as_completed(futures):
|
|
153
|
+
url, path, err = future.result()
|
|
154
|
+
results[url] = (path, err)
|
|
155
|
+
return results
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class ChecksumError(IOError):
|
|
159
|
+
"""Raised when a downloaded file does not match its expected MD5."""
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def file_md5(path: Path, chunk: int = 8 << 20) -> str:
|
|
163
|
+
"""Return the hex MD5 digest of *path* (streamed, constant memory)."""
|
|
164
|
+
h = hashlib.md5()
|
|
165
|
+
with open(path, "rb") as f:
|
|
166
|
+
while True:
|
|
167
|
+
buf = f.read(chunk)
|
|
168
|
+
if not buf:
|
|
169
|
+
break
|
|
170
|
+
h.update(buf)
|
|
171
|
+
return h.hexdigest()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def url_filename(url: str) -> str:
|
|
175
|
+
"""File name at the end of *url*'s path (query string ignored)."""
|
|
176
|
+
return Path(urllib.parse.urlparse(url).path).name
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
RETRY_CODES = (429, 500, 502, 503, 504)
|
|
180
|
+
THROTTLE_CODES = (429, 503)
|
|
181
|
+
MAX_PER_HOST_ENV = "LUNAR_MAX_PER_HOST"
|
|
182
|
+
MIN_INTERVAL_ENV = "LUNAR_MIN_INTERVAL"
|
|
183
|
+
MAX_WAIT_ENV = "LUNAR_MAX_WAIT"
|
|
184
|
+
MAX_RETRY_AFTER = 7200.0
|
|
185
|
+
COOLDOWN_FILE = DEFAULT_CACHE_DIR / "host_cooldowns.json"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class _Host:
|
|
189
|
+
"""One host's download slots, request pacing and throttling cooldown.
|
|
190
|
+
|
|
191
|
+
The cooldown is wall-clock time shared by every worker in this process and saved
|
|
192
|
+
to :data:`COOLDOWN_FILE`, so a restarted run honours a server's earlier
|
|
193
|
+
``Retry-After`` instead of tripping its rate limit again.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
def __init__(self, name: str, limit: int, min_interval: float) -> None:
|
|
197
|
+
self.name = name
|
|
198
|
+
self.slots = threading.BoundedSemaphore(limit)
|
|
199
|
+
self.lock = threading.Lock()
|
|
200
|
+
self.min_interval = min_interval
|
|
201
|
+
self.last_start = 0.0
|
|
202
|
+
self.cooldown_until = _saved_cooldowns().get(name, 0.0)
|
|
203
|
+
|
|
204
|
+
def cool(self, seconds: float) -> None:
|
|
205
|
+
with self.lock:
|
|
206
|
+
until = time.time() + seconds
|
|
207
|
+
if until > self.cooldown_until:
|
|
208
|
+
self.cooldown_until = until
|
|
209
|
+
_save_cooldown(self.name, until)
|
|
210
|
+
|
|
211
|
+
def wait(self, poll: Callable[[], None] | None = None) -> None:
|
|
212
|
+
"""Sleep out the cooldown; with *poll*, in 1 s steps calling it (it may raise to abort)."""
|
|
213
|
+
remaining = self.cooldown_until - time.time()
|
|
214
|
+
if remaining > 0:
|
|
215
|
+
resume = time.strftime("%H:%M:%S", time.localtime(self.cooldown_until))
|
|
216
|
+
log.info("%s asked us to slow down; waiting %.0f s (until %s)", self.name, remaining, resume)
|
|
217
|
+
if poll is None:
|
|
218
|
+
time.sleep(remaining)
|
|
219
|
+
return
|
|
220
|
+
while remaining > 0:
|
|
221
|
+
poll()
|
|
222
|
+
time.sleep(min(1.0, remaining))
|
|
223
|
+
remaining = self.cooldown_until - time.time()
|
|
224
|
+
|
|
225
|
+
def pace(self) -> None:
|
|
226
|
+
with self.lock:
|
|
227
|
+
now = time.monotonic()
|
|
228
|
+
start = max(now, self.last_start + self.min_interval)
|
|
229
|
+
self.last_start = start
|
|
230
|
+
if start > now:
|
|
231
|
+
time.sleep(start - now)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
_HOSTS: dict[str, _Host] = {}
|
|
235
|
+
_HOSTS_LOCK = threading.Lock()
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _host(url: str) -> _Host:
|
|
239
|
+
name = urllib.parse.urlparse(url).netloc.lower()
|
|
240
|
+
with _HOSTS_LOCK:
|
|
241
|
+
if name not in _HOSTS:
|
|
242
|
+
limit = max(1, int(os.environ.get(MAX_PER_HOST_ENV, "3")))
|
|
243
|
+
_HOSTS[name] = _Host(name, limit, float(os.environ.get(MIN_INTERVAL_ENV, "1.0")))
|
|
244
|
+
return _HOSTS[name]
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _saved_cooldowns() -> dict[str, float]:
|
|
248
|
+
try:
|
|
249
|
+
data = json.loads(COOLDOWN_FILE.read_text())
|
|
250
|
+
except (OSError, ValueError):
|
|
251
|
+
return {}
|
|
252
|
+
now = time.time()
|
|
253
|
+
return {str(k): float(v) for k, v in data.items() if isinstance(v, (int, float)) and v > now}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _save_cooldown(name: str, until: float) -> None:
|
|
257
|
+
data = _saved_cooldowns()
|
|
258
|
+
data[name] = until
|
|
259
|
+
try:
|
|
260
|
+
COOLDOWN_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
261
|
+
tmp = COOLDOWN_FILE.with_suffix(f".{os.getpid()}.tmp")
|
|
262
|
+
tmp.write_text(json.dumps(data))
|
|
263
|
+
tmp.replace(COOLDOWN_FILE)
|
|
264
|
+
except OSError as exc:
|
|
265
|
+
log.debug("Could not save host cooldowns: %s", exc)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def download_file(
|
|
269
|
+
url: str,
|
|
270
|
+
dest: Path,
|
|
271
|
+
show_progress: bool = True,
|
|
272
|
+
expected_md5: str | None = None,
|
|
273
|
+
resume: bool = True,
|
|
274
|
+
timeout: float = 60.0,
|
|
275
|
+
retries: int = 8,
|
|
276
|
+
retry_codes: tuple[int, ...] = RETRY_CODES,
|
|
277
|
+
max_wait: float | None = None,
|
|
278
|
+
progress: Progress | None = None,
|
|
279
|
+
) -> Path:
|
|
280
|
+
"""Download *url* to *dest*, throttled per host and retried on throttling / errors.
|
|
281
|
+
|
|
282
|
+
Politeness: at most ``$LUNAR_MAX_PER_HOST`` (default 3) downloads run against one
|
|
283
|
+
host at a time, and their requests start at least ``$LUNAR_MIN_INTERVAL`` seconds
|
|
284
|
+
apart (default 1). Throttling replies (429, 503) pause every download to that host
|
|
285
|
+
for the server's ``Retry-After`` (up to 2 h; else exponential back-off) and do not
|
|
286
|
+
count as failures; they give up only after *max_wait* seconds in total (default
|
|
287
|
+
``$LUNAR_MAX_WAIT``, else 6 h). Other *retry_codes* (5xx), connection errors and
|
|
288
|
+
interrupted transfers are retried *retries* times with back-off. Each retry resumes
|
|
289
|
+
the ``.part`` file; checksum mismatches are not retried. See :func:`_download_once`.
|
|
290
|
+
|
|
291
|
+
*progress* (default: the :func:`report_progress` callback, if any) gets the bytes
|
|
292
|
+
done after each chunk; an exception it raises stops the download and keeps the
|
|
293
|
+
``.part`` file for a later resume.
|
|
294
|
+
"""
|
|
295
|
+
progress = progress or _PROGRESS.get()
|
|
296
|
+
url = modernize_pds_url(url)
|
|
297
|
+
host = _host(url)
|
|
298
|
+
budget = max_wait if max_wait is not None else float(os.environ.get(MAX_WAIT_ENV, 6 * 3600))
|
|
299
|
+
deadline = time.time() + budget
|
|
300
|
+
failures = throttles = 0
|
|
301
|
+
poll = (lambda: progress(Path(dest).name, -1, None)) if progress else None
|
|
302
|
+
while True:
|
|
303
|
+
host.wait(poll)
|
|
304
|
+
try:
|
|
305
|
+
with host.slots:
|
|
306
|
+
host.pace()
|
|
307
|
+
return _download_once(url, dest, show_progress, expected_md5, resume, timeout, progress)
|
|
308
|
+
except urllib.error.HTTPError as exc:
|
|
309
|
+
if exc.code not in retry_codes:
|
|
310
|
+
raise
|
|
311
|
+
if exc.code in THROTTLE_CODES:
|
|
312
|
+
wait = _retry_after(exc) or _backoff(throttles)
|
|
313
|
+
throttles += 1
|
|
314
|
+
else:
|
|
315
|
+
failures += 1
|
|
316
|
+
if failures > retries:
|
|
317
|
+
raise
|
|
318
|
+
wait = _backoff(failures - 1)
|
|
319
|
+
if time.time() + wait > deadline:
|
|
320
|
+
raise
|
|
321
|
+
log.warning("HTTP %d from %s; pausing that host %.0f s", exc.code, host.name, wait)
|
|
322
|
+
except ChecksumError:
|
|
323
|
+
raise
|
|
324
|
+
except OSError as exc: # URLError, timeouts, resets, incomplete transfers
|
|
325
|
+
failures += 1
|
|
326
|
+
if failures > retries:
|
|
327
|
+
raise
|
|
328
|
+
wait = _backoff(failures - 1)
|
|
329
|
+
log.warning("%s for %s; retrying in %.0f s", exc, url, wait)
|
|
330
|
+
host.cool(wait)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _backoff(attempt: int) -> float:
|
|
334
|
+
return min(120.0, 2.0 * 2 ** min(attempt, 10)) * random.uniform(0.75, 1.25)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _retry_after(exc: urllib.error.HTTPError) -> float | None:
|
|
338
|
+
value = exc.headers.get("Retry-After") if exc.headers else None
|
|
339
|
+
if value is None:
|
|
340
|
+
return None
|
|
341
|
+
try:
|
|
342
|
+
seconds = float(value)
|
|
343
|
+
except ValueError:
|
|
344
|
+
try:
|
|
345
|
+
seconds = email.utils.parsedate_to_datetime(value).timestamp() - time.time()
|
|
346
|
+
except (TypeError, ValueError):
|
|
347
|
+
return None
|
|
348
|
+
return min(MAX_RETRY_AFTER, max(0.0, seconds))
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _download_once(
|
|
352
|
+
url: str,
|
|
353
|
+
dest: Path,
|
|
354
|
+
show_progress: bool = True,
|
|
355
|
+
expected_md5: str | None = None,
|
|
356
|
+
resume: bool = True,
|
|
357
|
+
timeout: float = 60.0,
|
|
358
|
+
progress: Progress | None = None,
|
|
359
|
+
) -> Path:
|
|
360
|
+
"""Download *url* to *dest* via ``<dest>.part`` with HTTP Range resume.
|
|
361
|
+
|
|
362
|
+
* If a ``.part`` file exists and *resume* is set, a ``Range: bytes=N-``
|
|
363
|
+
request continues it. A ``206`` reply is appended; a ``200`` reply
|
|
364
|
+
(server ignored the range) restarts from zero; ``416`` means the part
|
|
365
|
+
is already complete.
|
|
366
|
+
* When *expected_md5* is given the finished file is verified before the
|
|
367
|
+
atomic rename; on mismatch the part file is deleted and
|
|
368
|
+
:class:`ChecksumError` is raised.
|
|
369
|
+
* On network errors the ``.part`` file is kept so the next call resumes.
|
|
370
|
+
"""
|
|
371
|
+
from tqdm import tqdm
|
|
372
|
+
|
|
373
|
+
url = modernize_pds_url(url)
|
|
374
|
+
dest = Path(dest)
|
|
375
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
376
|
+
tmp = dest.with_suffix(dest.suffix + ".part")
|
|
377
|
+
|
|
378
|
+
offset = tmp.stat().st_size if (resume and tmp.exists()) else 0
|
|
379
|
+
req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
|
|
380
|
+
if offset:
|
|
381
|
+
req.add_header("Range", f"bytes={offset}-")
|
|
382
|
+
|
|
383
|
+
log.info("Downloading %s%s", url, f" (resuming at {offset:,} bytes)" if offset else "")
|
|
384
|
+
try:
|
|
385
|
+
resp = urllib.request.urlopen(req, timeout=timeout)
|
|
386
|
+
except urllib.error.HTTPError as exc:
|
|
387
|
+
if exc.code != 416 or not offset:
|
|
388
|
+
raise
|
|
389
|
+
resp = None # 416: nothing left to fetch — the part file is complete
|
|
390
|
+
|
|
391
|
+
if resp is not None:
|
|
392
|
+
with resp:
|
|
393
|
+
status = getattr(resp, "status", 200)
|
|
394
|
+
if offset and status != 206:
|
|
395
|
+
offset = 0 # server ignored Range → start over
|
|
396
|
+
length = int(resp.headers.get("Content-Length", 0)) or None
|
|
397
|
+
total = (length + offset) if length else None
|
|
398
|
+
with (
|
|
399
|
+
open(tmp, "ab" if offset else "wb") as f,
|
|
400
|
+
tqdm(
|
|
401
|
+
total=total,
|
|
402
|
+
initial=offset,
|
|
403
|
+
unit="B",
|
|
404
|
+
unit_scale=True,
|
|
405
|
+
unit_divisor=1024,
|
|
406
|
+
desc=dest.name,
|
|
407
|
+
disable=not show_progress,
|
|
408
|
+
leave=True,
|
|
409
|
+
) as bar,
|
|
410
|
+
):
|
|
411
|
+
chunk = 1 << 20 # 1 MB
|
|
412
|
+
done = offset
|
|
413
|
+
if progress:
|
|
414
|
+
progress(dest.name, done, total)
|
|
415
|
+
while True:
|
|
416
|
+
buf = resp.read(chunk)
|
|
417
|
+
if not buf:
|
|
418
|
+
break
|
|
419
|
+
f.write(buf)
|
|
420
|
+
bar.update(len(buf))
|
|
421
|
+
done += len(buf)
|
|
422
|
+
if progress:
|
|
423
|
+
progress(dest.name, done, total)
|
|
424
|
+
if total is not None and tmp.stat().st_size < total:
|
|
425
|
+
raise OSError(
|
|
426
|
+
f"Incomplete download of {url}: {tmp.stat().st_size:,}/{total:,} bytes "
|
|
427
|
+
f"(partial file kept at {tmp}; re-run to resume)"
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
if expected_md5:
|
|
431
|
+
actual = file_md5(tmp)
|
|
432
|
+
if actual.lower() != expected_md5.lower():
|
|
433
|
+
tmp.unlink()
|
|
434
|
+
raise ChecksumError(f"MD5 mismatch for {url}: expected {expected_md5}, got {actual}")
|
|
435
|
+
tmp.replace(dest)
|
|
436
|
+
return dest
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _download_one(
|
|
440
|
+
url: str, dest: Path, show_progress: bool, force: bool = False
|
|
441
|
+
) -> tuple[str, Path | None, str | None]:
|
|
442
|
+
if reuse_existing(dest, force):
|
|
443
|
+
return url, dest, None
|
|
444
|
+
try:
|
|
445
|
+
return url, download_file(url, dest, show_progress=show_progress), None
|
|
446
|
+
except Exception as exc:
|
|
447
|
+
return url, None, str(exc)
|