Coverage for lunar_toolkit/downloader.py: 93%

248 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-19 02:58 +0000

1"""PdsDownloader — parallel file retrieval from the PDS archive.""" 

2 

3import contextlib 

4import contextvars 

5import email.utils 

6import hashlib 

7import json 

8import logging 

9import os 

10import random 

11import threading 

12import time 

13import urllib.error 

14import urllib.parse 

15import urllib.request 

16from collections.abc import Callable, Iterator 

17from concurrent.futures import ThreadPoolExecutor, as_completed 

18from pathlib import Path 

19 

20import pandas as pd 

21 

22from .constants import APP_NAME, APP_VERSION, DEFAULT_CACHE_DIR, modernize_pds_url 

23from .tools import reuse_existing 

24 

25log = logging.getLogger(__name__) 

26_USER_AGENT = f"{APP_NAME}/{APP_VERSION} (python-urllib)" 

27 

28Progress = Callable[[str, int, int | None], None] 

29"""``progress(file name, bytes done, bytes total or None)``, called per chunk; raising aborts. 

30 

31While 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 

39def report_progress(callback: Progress) -> Iterator[None]: 

40 """Send the progress of every [`download_file`][lunar_toolkit.downloader.download_file] in this thread / 

41 context to *callback*.""" 

42 token = _PROGRESS.set(callback) 

43 try: 

44 yield 

45 finally: 

46 _PROGRESS.reset(token) 

47 

48 

49class PdsDownloader: 

50 """Download and cache files from the PDS archive. 

51 

52 Parameters 

53 ---------- 

54 cache_dir : Path 

55 Base directory for the local cache (default ~/.cache/lunar_toolkit). 

56 max_workers : int 

57 Thread-pool size for parallel downloads (default 8). 

58 """ 

59 

60 def __init__( 

61 self, 

62 cache_dir: Path = DEFAULT_CACHE_DIR, 

63 max_workers: int = 4, 

64 ) -> None: 

65 self.cache_dir = Path(cache_dir) 

66 self.max_workers = max_workers 

67 

68 def cache_path(self, url: str) -> Path: 

69 """Return the local path that would be used to cache *url*.""" 

70 url_hash = hashlib.md5(url.encode()).hexdigest()[:8] 

71 return self.cache_dir / f"{url_hash}_{url_filename(url)}" 

72 

73 def ensure_cached( 

74 self, 

75 url: str, 

76 force: bool = False, 

77 show_progress: bool = True, 

78 expected_md5: str | None = None, 

79 ) -> Path: 

80 """Return local path for *url*, downloading if not already cached.""" 

81 dest = self.cache_path(url) 

82 if dest.exists() and not force: 82 ↛ 84line 82 didn't jump to line 84 because the condition on line 82 was always true

83 return dest 

84 return download_file(url, dest, show_progress=show_progress, expected_md5=expected_md5) 

85 

86 def download( 

87 self, 

88 product_rows: pd.DataFrame, 

89 output_dir: Path, 

90 url_col: str = "PDS_URL", 

91 show_progress: bool = True, 

92 force: bool = False, 

93 ) -> pd.DataFrame: 

94 """Download PDS image files in parallel. 

95 

96 Parameters 

97 ---------- 

98 product_rows : pd.DataFrame 

99 Must contain a *url_col* column (e.g. from CumIndex.lookup()). 

100 output_dir : Path 

101 Destination directory for .IMG files. 

102 url_col : str 

103 Column holding the download URL. 

104 show_progress : bool 

105 Show per-file progress bars. 

106 force : bool 

107 Download again even when ``<output_dir>/<file name>`` exists 

108 (by default it is reused; downloads land via ``.part`` files, 

109 so an existing file is complete). 

110 

111 Returns 

112 ------- 

113 Copy of product_rows with LOCAL_PATH and DOWNLOAD_ERROR columns added. 

114 """ 

115 results = self._download_all(list(product_rows[url_col]), output_dir, show_progress, force) 

116 paths = {u: str(path) if path else None for u, (path, _) in results.items()} 

117 df = product_rows.copy() 

118 df["LOCAL_PATH"] = df[url_col].map(paths.get) 

119 df["DOWNLOAD_ERROR"] = df[url_col].map(lambda u: results.get(u, (None, None))[1]) 

120 return df 

121 

122 def download_urls( 

123 self, 

124 urls: list[str], 

125 output_dir: Path, 

126 show_progress: bool = True, 

127 force: bool = False, 

128 ) -> dict[str, Path | None]: 

129 """Download a plain list of URLs in parallel. 

130 

131 Existing files are reused unless *force* (see 

132 [`download`][lunar_toolkit.downloader.PdsDownloader.download]). Returns a dict mapping each URL to its 

133 local path (or None on error). 

134 """ 

135 path_map: dict[str, Path | None] = {} 

136 for url, (path, err) in self._download_all(urls, output_dir, show_progress, force).items(): 

137 if err: 137 ↛ 138line 137 didn't jump to line 138 because the condition on line 137 was never true

138 log.error("%s: %s", url, err) 

139 path_map[url] = path 

140 return path_map 

141 

142 def _download_all( 

143 self, urls: list[str], output_dir: Path, show_progress: bool, force: bool 

144 ) -> dict[str, tuple[Path | None, str | None]]: 

145 """``url → (local path, error message)`` for parallel downloads into *output_dir*.""" 

146 output_dir = Path(output_dir) 

147 output_dir.mkdir(parents=True, exist_ok=True) 

148 results: dict[str, tuple[Path | None, str | None]] = {} 

149 with ThreadPoolExecutor(max_workers=self.max_workers) as pool: 

150 futures = [ 

151 pool.submit(_download_one, url, output_dir / url_filename(url), show_progress, force) 

152 for url in urls 

153 ] 

154 for future in as_completed(futures): 

155 url, path, err = future.result() 

156 results[url] = (path, err) 

157 return results 

158 

159 

160class ChecksumError(IOError): 

161 """Raised when a downloaded file does not match its expected MD5.""" 

162 

163 

164def file_md5(path: Path, chunk: int = 8 << 20) -> str: 

165 """Return the hex MD5 digest of *path* (streamed, constant memory).""" 

166 h = hashlib.md5() 

167 with open(path, "rb") as f: 

168 while True: 

169 buf = f.read(chunk) 

170 if not buf: 

171 break 

172 h.update(buf) 

173 return h.hexdigest() 

174 

175 

176def url_filename(url: str) -> str: 

177 """File name at the end of *url*'s path (query string ignored).""" 

178 return Path(urllib.parse.urlparse(url).path).name 

179 

180 

181RETRY_CODES = (429, 500, 502, 503, 504) 

182THROTTLE_CODES = (429, 503) 

183MAX_PER_HOST_ENV = "LUNAR_MAX_PER_HOST" 

184MIN_INTERVAL_ENV = "LUNAR_MIN_INTERVAL" 

185MAX_WAIT_ENV = "LUNAR_MAX_WAIT" 

186MAX_RETRY_AFTER = 7200.0 

187COOLDOWN_FILE = DEFAULT_CACHE_DIR / "host_cooldowns.json" 

188 

189 

190class _Host: 

191 """One host's download slots, request pacing and throttling cooldown. 

192 

193 The cooldown is wall-clock time shared by every worker in this process and saved 

194 to `COOLDOWN_FILE`, so a restarted run honours a server's earlier 

195 ``Retry-After`` instead of tripping its rate limit again. 

196 """ 

197 

198 def __init__(self, name: str, limit: int, min_interval: float) -> None: 

199 self.name = name 

200 self.slots = threading.BoundedSemaphore(limit) 

201 self.lock = threading.Lock() 

202 self.min_interval = min_interval 

203 self.last_start = 0.0 

204 self.cooldown_until = _saved_cooldowns().get(name, 0.0) 

205 

206 def cool(self, seconds: float) -> None: 

207 with self.lock: 

208 until = time.time() + seconds 

209 if until > self.cooldown_until: 209 ↛ exitline 209 didn't jump to the function exit

210 self.cooldown_until = until 

211 _save_cooldown(self.name, until) 

212 

213 def wait(self, poll: Callable[[], None] | None = None) -> None: 

214 """Sleep out the cooldown; with *poll*, in 1 s steps calling it (it may raise to abort).""" 

215 remaining = self.cooldown_until - time.time() 

216 if remaining > 0: 

217 resume = time.strftime("%H:%M:%S", time.localtime(self.cooldown_until)) 

218 log.info("%s asked us to slow down; waiting %.0f s (until %s)", self.name, remaining, resume) 

219 if poll is None: 

220 time.sleep(remaining) 

221 return 

222 while remaining > 0: 222 ↛ exitline 222 didn't return from function 'wait' because the condition on line 222 was always true

223 poll() 

224 time.sleep(min(1.0, remaining)) 

225 remaining = self.cooldown_until - time.time() 

226 

227 def pace(self) -> None: 

228 with self.lock: 

229 now = time.monotonic() 

230 start = max(now, self.last_start + self.min_interval) 

231 self.last_start = start 

232 if start > now: 232 ↛ 233line 232 didn't jump to line 233 because the condition on line 232 was never true

233 time.sleep(start - now) 

234 

235 

236_HOSTS: dict[str, _Host] = {} 

237_HOSTS_LOCK = threading.Lock() 

238 

239 

240def _host(url: str) -> _Host: 

241 name = urllib.parse.urlparse(url).netloc.lower() 

242 with _HOSTS_LOCK: 

243 if name not in _HOSTS: 

244 limit = max(1, int(os.environ.get(MAX_PER_HOST_ENV, "3"))) 

245 _HOSTS[name] = _Host(name, limit, float(os.environ.get(MIN_INTERVAL_ENV, "1.0"))) 

246 return _HOSTS[name] 

247 

248 

249def _saved_cooldowns() -> dict[str, float]: 

250 try: 

251 data = json.loads(COOLDOWN_FILE.read_text()) 

252 except (OSError, ValueError): 

253 return {} 

254 now = time.time() 

255 return {str(k): float(v) for k, v in data.items() if isinstance(v, (int, float)) and v > now} 

256 

257 

258def _save_cooldown(name: str, until: float) -> None: 

259 data = _saved_cooldowns() 

260 data[name] = until 

261 try: 

262 COOLDOWN_FILE.parent.mkdir(parents=True, exist_ok=True) 

263 tmp = COOLDOWN_FILE.with_suffix(f".{os.getpid()}.tmp") 

264 tmp.write_text(json.dumps(data)) 

265 tmp.replace(COOLDOWN_FILE) 

266 except OSError as exc: 

267 log.debug("Could not save host cooldowns: %s", exc) 

268 

269 

270def download_file( 

271 url: str, 

272 dest: Path, 

273 show_progress: bool = True, 

274 expected_md5: str | None = None, 

275 resume: bool = True, 

276 timeout: float = 60.0, 

277 retries: int = 8, 

278 retry_codes: tuple[int, ...] = RETRY_CODES, 

279 max_wait: float | None = None, 

280 progress: Progress | None = None, 

281) -> Path: 

282 """Download *url* to *dest*, throttled per host and retried on throttling / errors. 

283 

284 Politeness: at most ``$LUNAR_MAX_PER_HOST`` (default 3) downloads run against one 

285 host at a time, and their requests start at least ``$LUNAR_MIN_INTERVAL`` seconds 

286 apart (default 1). Throttling replies (429, 503) pause every download to that host 

287 for the server's ``Retry-After`` (up to 2 h; else exponential back-off) and do not 

288 count as failures; they give up only after *max_wait* seconds in total (default 

289 ``$LUNAR_MAX_WAIT``, else 6 h). Other *retry_codes* (5xx), connection errors and 

290 interrupted transfers are retried *retries* times with back-off. Each retry resumes 

291 the ``.part`` file; checksum mismatches are not retried. See `_download_once`. 

292 

293 *progress* (default: the [`report_progress`][lunar_toolkit.downloader.report_progress] callback, if any) 

294 gets the bytes done after each chunk; an exception it raises stops the download and keeps the 

295 ``.part`` file for a later resume. 

296 """ 

297 progress = progress or _PROGRESS.get() 

298 url = modernize_pds_url(url) 

299 host = _host(url) 

300 budget = max_wait if max_wait is not None else float(os.environ.get(MAX_WAIT_ENV, 6 * 3600)) 

301 deadline = time.time() + budget 

302 failures = throttles = 0 

303 poll = (lambda: progress(Path(dest).name, -1, None)) if progress else None 

304 while True: 

305 host.wait(poll) 

306 try: 

307 with host.slots: 

308 host.pace() 

309 return _download_once(url, dest, show_progress, expected_md5, resume, timeout, progress) 

310 except urllib.error.HTTPError as exc: 

311 if exc.code not in retry_codes: 

312 raise 

313 if exc.code in THROTTLE_CODES: 

314 wait = _retry_after(exc) or _backoff(throttles) 

315 throttles += 1 

316 else: 

317 failures += 1 

318 if failures > retries: 

319 raise 

320 wait = _backoff(failures - 1) 

321 if time.time() + wait > deadline: 

322 raise 

323 log.warning("HTTP %d from %s; pausing that host %.0f s", exc.code, host.name, wait) 

324 except ChecksumError: 

325 raise 

326 except OSError as exc: # URLError, timeouts, resets, incomplete transfers 

327 failures += 1 

328 if failures > retries: 

329 raise 

330 wait = _backoff(failures - 1) 

331 log.warning("%s for %s; retrying in %.0f s", exc, url, wait) 

332 host.cool(wait) 

333 

334 

335def _backoff(attempt: int) -> float: 

336 return min(120.0, 2.0 * 2 ** min(attempt, 10)) * random.uniform(0.75, 1.25) 

337 

338 

339def _retry_after(exc: urllib.error.HTTPError) -> float | None: 

340 value = exc.headers.get("Retry-After") if exc.headers else None 

341 if value is None: 

342 return None 

343 try: 

344 seconds = float(value) 

345 except ValueError: 

346 try: 

347 seconds = email.utils.parsedate_to_datetime(value).timestamp() - time.time() 

348 except (TypeError, ValueError): 

349 return None 

350 return min(MAX_RETRY_AFTER, max(0.0, seconds)) 

351 

352 

353def _download_once( 

354 url: str, 

355 dest: Path, 

356 show_progress: bool = True, 

357 expected_md5: str | None = None, 

358 resume: bool = True, 

359 timeout: float = 60.0, 

360 progress: Progress | None = None, 

361) -> Path: 

362 """Download *url* to *dest* via ``<dest>.part`` with HTTP Range resume. 

363 

364 * If a ``.part`` file exists and *resume* is set, a ``Range: bytes=N-`` 

365 request continues it. A ``206`` reply is appended; a ``200`` reply 

366 (server ignored the range) restarts from zero; ``416`` means the part 

367 is already complete. 

368 * When *expected_md5* is given the finished file is verified before the 

369 atomic rename; on mismatch the part file is deleted and 

370 [`ChecksumError`][lunar_toolkit.downloader.ChecksumError] is raised. 

371 * On network errors the ``.part`` file is kept so the next call resumes. 

372 """ 

373 from tqdm import tqdm 

374 

375 url = modernize_pds_url(url) 

376 dest = Path(dest) 

377 dest.parent.mkdir(parents=True, exist_ok=True) 

378 tmp = dest.with_suffix(dest.suffix + ".part") 

379 

380 offset = tmp.stat().st_size if (resume and tmp.exists()) else 0 

381 req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT}) 

382 if offset: 

383 req.add_header("Range", f"bytes={offset}-") 

384 

385 log.info("Downloading %s%s", url, f" (resuming at {offset:,} bytes)" if offset else "") 

386 try: 

387 resp = urllib.request.urlopen(req, timeout=timeout) 

388 except urllib.error.HTTPError as exc: 

389 if exc.code != 416 or not offset: 

390 raise 

391 resp = None # 416: nothing left to fetch — the part file is complete 

392 

393 if resp is not None: 

394 with resp: 

395 status = getattr(resp, "status", 200) 

396 if offset and status != 206: 

397 offset = 0 # server ignored Range → start over 

398 length = int(resp.headers.get("Content-Length", 0)) or None 

399 total = (length + offset) if length else None 

400 with ( 

401 open(tmp, "ab" if offset else "wb") as f, 

402 tqdm( 

403 total=total, 

404 initial=offset, 

405 unit="B", 

406 unit_scale=True, 

407 unit_divisor=1024, 

408 desc=dest.name, 

409 disable=not show_progress, 

410 leave=True, 

411 ) as bar, 

412 ): 

413 chunk = 1 << 20 # 1 MB 

414 done = offset 

415 if progress: 

416 progress(dest.name, done, total) 

417 while True: 

418 buf = resp.read(chunk) 

419 if not buf: 

420 break 

421 f.write(buf) 

422 bar.update(len(buf)) 

423 done += len(buf) 

424 if progress: 

425 progress(dest.name, done, total) 

426 if total is not None and tmp.stat().st_size < total: 426 ↛ 427line 426 didn't jump to line 427 because the condition on line 426 was never true

427 raise OSError( 

428 f"Incomplete download of {url}: {tmp.stat().st_size:,}/{total:,} bytes " 

429 f"(partial file kept at {tmp}; re-run to resume)" 

430 ) 

431 

432 if expected_md5: 

433 actual = file_md5(tmp) 

434 if actual.lower() != expected_md5.lower(): 

435 tmp.unlink() 

436 raise ChecksumError(f"MD5 mismatch for {url}: expected {expected_md5}, got {actual}") 

437 tmp.replace(dest) 

438 return dest 

439 

440 

441def _download_one( 

442 url: str, dest: Path, show_progress: bool, force: bool = False 

443) -> tuple[str, Path | None, str | None]: 

444 if reuse_existing(dest, force): 

445 return url, dest, None 

446 try: 

447 return url, download_file(url, dest, show_progress=show_progress), None 

448 except Exception as exc: 

449 return url, None, str(exc)