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.
Files changed (55) hide show
  1. lunar_toolkit/__init__.py +6 -0
  2. lunar_toolkit/asp.py +432 -0
  3. lunar_toolkit/cli/__init__.py +88 -0
  4. lunar_toolkit/cli/_jobs.py +437 -0
  5. lunar_toolkit/cli/_status.py +368 -0
  6. lunar_toolkit/cli/_utils.py +128 -0
  7. lunar_toolkit/cli/app.py +205 -0
  8. lunar_toolkit/cli/build_db.py +130 -0
  9. lunar_toolkit/cli/download.py +111 -0
  10. lunar_toolkit/cli/kaguya.py +253 -0
  11. lunar_toolkit/cli/ode_search.py +144 -0
  12. lunar_toolkit/cli/pairs.py +236 -0
  13. lunar_toolkit/cli/process.py +138 -0
  14. lunar_toolkit/cli/query.py +70 -0
  15. lunar_toolkit/cli/reference.py +233 -0
  16. lunar_toolkit/cli/search.py +90 -0
  17. lunar_toolkit/cli/serve.py +706 -0
  18. lunar_toolkit/cli/sfs.py +344 -0
  19. lunar_toolkit/cli/sfs_report.py +148 -0
  20. lunar_toolkit/cli/shadowcam.py +246 -0
  21. lunar_toolkit/cli/stereo.py +85 -0
  22. lunar_toolkit/cli/workspace.py +84 -0
  23. lunar_toolkit/config.py +345 -0
  24. lunar_toolkit/constants.py +217 -0
  25. lunar_toolkit/csm.py +340 -0
  26. lunar_toolkit/downloader.py +447 -0
  27. lunar_toolkit/geometry.py +556 -0
  28. lunar_toolkit/isis.py +222 -0
  29. lunar_toolkit/kaguya/__init__.py +45 -0
  30. lunar_toolkit/kaguya/mi.py +274 -0
  31. lunar_toolkit/kaguya/tc.py +370 -0
  32. lunar_toolkit/lroc/__init__.py +6 -0
  33. lunar_toolkit/lroc/cumindex.py +1197 -0
  34. lunar_toolkit/lroc/nac.py +702 -0
  35. lunar_toolkit/observation.py +127 -0
  36. lunar_toolkit/ode.py +485 -0
  37. lunar_toolkit/provenance.py +312 -0
  38. lunar_toolkit/reference.py +1111 -0
  39. lunar_toolkit/selection.py +714 -0
  40. lunar_toolkit/sfs.py +1534 -0
  41. lunar_toolkit/sfs_products.py +676 -0
  42. lunar_toolkit/sfs_report.py +1328 -0
  43. lunar_toolkit/shadowcam/__init__.py +29 -0
  44. lunar_toolkit/shadowcam/sc.py +392 -0
  45. lunar_toolkit/static/index.html +3259 -0
  46. lunar_toolkit/stereo.py +519 -0
  47. lunar_toolkit/stereo_dem.py +145 -0
  48. lunar_toolkit/tools.py +264 -0
  49. lunar_toolkit/workspace.py +1173 -0
  50. lunar_toolkit-0.1.0.dev2.dist-info/METADATA +177 -0
  51. lunar_toolkit-0.1.0.dev2.dist-info/RECORD +55 -0
  52. lunar_toolkit-0.1.0.dev2.dist-info/WHEEL +5 -0
  53. lunar_toolkit-0.1.0.dev2.dist-info/entry_points.txt +2 -0
  54. lunar_toolkit-0.1.0.dev2.dist-info/licenses/LICENSE +28 -0
  55. 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)