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,437 @@
|
|
|
1
|
+
"""Background jobs for ``lunar serve`` / ``lunar app``: index builds and downloads with progress."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import itertools
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from collections import deque
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
TERMINAL = ("done", "error", "cancelled")
|
|
20
|
+
MAX_IDS = 1000
|
|
21
|
+
_RELEASE_RE = re.compile(r"^LROLRC_\d{4}[A-Z]?$")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Cancelled(Exception):
|
|
25
|
+
"""Raised inside a job's thread once the job was cancelled."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class JobContext:
|
|
30
|
+
"""Where jobs read and write: the Parquet data dir, image downloads and reference data."""
|
|
31
|
+
|
|
32
|
+
data_dir: Path
|
|
33
|
+
download_dir: Path
|
|
34
|
+
reference_dir: Path
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Job:
|
|
38
|
+
"""One background task: status, progress (0-1), current file, bytes, rate, ETA and a log tail."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, manager: JobManager, jid: str, kind: str, title: str, key: str, params: dict) -> None:
|
|
41
|
+
self._manager = manager
|
|
42
|
+
self.id, self.kind, self.title, self.key, self.params = jid, kind, title, key, params
|
|
43
|
+
self.status = "queued"
|
|
44
|
+
self.progress = 0.0
|
|
45
|
+
self.busy = False
|
|
46
|
+
self.cancellable = True
|
|
47
|
+
self.stage = ""
|
|
48
|
+
self.file = ""
|
|
49
|
+
self.file_index = self.file_count = 0
|
|
50
|
+
self.bytes_done = 0
|
|
51
|
+
self.bytes_total: int | None = None
|
|
52
|
+
self.rate: float | None = None
|
|
53
|
+
self.eta: float | None = None
|
|
54
|
+
self.error: str | None = None
|
|
55
|
+
self.result: Any = None
|
|
56
|
+
self.created = time.time()
|
|
57
|
+
self.started: float | None = None
|
|
58
|
+
self.finished: float | None = None
|
|
59
|
+
self.log: deque[str] = deque(maxlen=40)
|
|
60
|
+
self._span = (0.0, 1.0)
|
|
61
|
+
self._cancel = threading.Event()
|
|
62
|
+
self._last: tuple[float, int] | None = None
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def cancelled(self) -> bool:
|
|
66
|
+
return self._cancel.is_set()
|
|
67
|
+
|
|
68
|
+
def check(self) -> None:
|
|
69
|
+
"""Raise :class:`Cancelled` if the job was cancelled."""
|
|
70
|
+
if self._cancel.is_set():
|
|
71
|
+
raise Cancelled()
|
|
72
|
+
|
|
73
|
+
def update(self, **fields: Any) -> None:
|
|
74
|
+
with self._manager._cond:
|
|
75
|
+
for k, v in fields.items():
|
|
76
|
+
setattr(self, k, v)
|
|
77
|
+
self._manager._changed()
|
|
78
|
+
|
|
79
|
+
def info(self, msg: str) -> None:
|
|
80
|
+
log.info("job %s: %s", self.id, msg)
|
|
81
|
+
with self._manager._cond:
|
|
82
|
+
self.log.append(f"{time.strftime('%H:%M:%S')} {msg}")
|
|
83
|
+
self._manager._changed()
|
|
84
|
+
|
|
85
|
+
def set_stage(self, stage: str, span: tuple[float, float] | None = None, busy: bool = False) -> None:
|
|
86
|
+
"""Start a stage; with *span*, byte progress maps onto that part of the overall bar."""
|
|
87
|
+
self.check()
|
|
88
|
+
if span is not None:
|
|
89
|
+
self._span = span
|
|
90
|
+
self.info(stage)
|
|
91
|
+
self.update(stage=stage, busy=busy, progress=max(self.progress, self._span[0]))
|
|
92
|
+
|
|
93
|
+
def set_file(self, k: int, n: int, name: str) -> None:
|
|
94
|
+
"""File *k* of *n* (1-based) starts; it covers ``[(k-1)/n, k/n]`` of the bar."""
|
|
95
|
+
self.check()
|
|
96
|
+
self._span = ((k - 1) / n, k / n)
|
|
97
|
+
self._last = None
|
|
98
|
+
self.update(
|
|
99
|
+
file=name, file_index=k, file_count=n, bytes_done=0, bytes_total=None, rate=None, eta=None,
|
|
100
|
+
progress=max(self.progress, self._span[0]), stage=f"File {k} of {n}",
|
|
101
|
+
) # fmt: skip
|
|
102
|
+
|
|
103
|
+
def on_bytes(self, name: str, done: int, total: int | None) -> None:
|
|
104
|
+
"""Download progress callback (:data:`lunar_toolkit.downloader.Progress`)."""
|
|
105
|
+
self.check()
|
|
106
|
+
if done < 0:
|
|
107
|
+
self.update(stage="Waiting: the host asked us to slow down", rate=None, eta=None)
|
|
108
|
+
return
|
|
109
|
+
now = time.monotonic()
|
|
110
|
+
rate = self.rate
|
|
111
|
+
if self._last is None or done < self._last[1]:
|
|
112
|
+
self._last = (now, done)
|
|
113
|
+
elif now - self._last[0] >= 0.5:
|
|
114
|
+
inst = (done - self._last[1]) / (now - self._last[0])
|
|
115
|
+
rate = inst if rate is None else 0.6 * rate + 0.4 * inst
|
|
116
|
+
self._last = (now, done)
|
|
117
|
+
lo, hi = self._span
|
|
118
|
+
frac = min(1.0, done / total) if total else 0.0
|
|
119
|
+
eta = (total - done) / rate if total and rate else None
|
|
120
|
+
self.update(
|
|
121
|
+
file=name, bytes_done=done, bytes_total=total, rate=rate, eta=eta,
|
|
122
|
+
progress=max(self.progress, lo + (hi - lo) * frac),
|
|
123
|
+
) # fmt: skip
|
|
124
|
+
|
|
125
|
+
def to_dict(self, admin: bool = True) -> dict[str, Any]:
|
|
126
|
+
d: dict[str, Any] = {
|
|
127
|
+
k: getattr(self, k)
|
|
128
|
+
for k in (
|
|
129
|
+
"id", "kind", "title", "status", "progress", "busy", "cancellable", "stage", "file",
|
|
130
|
+
"file_index", "file_count", "bytes_done", "bytes_total", "rate", "eta", "created",
|
|
131
|
+
"started", "finished",
|
|
132
|
+
)
|
|
133
|
+
} # fmt: skip
|
|
134
|
+
if admin:
|
|
135
|
+
d.update(params=self.params, error=self.error, result=self.result, log=list(self.log))
|
|
136
|
+
else:
|
|
137
|
+
d.update(error="failed" if self.error else None)
|
|
138
|
+
return d
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class JobManager:
|
|
142
|
+
"""Runs jobs in a small thread pool and lets readers wait for any change (``version``)."""
|
|
143
|
+
|
|
144
|
+
def __init__(
|
|
145
|
+
self, workers: int = 2, keep: int = 50, on_finish: Callable[[Job], None] | None = None
|
|
146
|
+
) -> None:
|
|
147
|
+
self._cond = threading.Condition()
|
|
148
|
+
self._on_finish = on_finish
|
|
149
|
+
self._jobs: dict[str, Job] = {}
|
|
150
|
+
self._pool = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="lunar-job")
|
|
151
|
+
self._ids = itertools.count(1)
|
|
152
|
+
self._keep = keep
|
|
153
|
+
self.version = 0
|
|
154
|
+
self.closed = False
|
|
155
|
+
|
|
156
|
+
def _changed(self) -> None:
|
|
157
|
+
self.version += 1
|
|
158
|
+
self._cond.notify_all()
|
|
159
|
+
|
|
160
|
+
def submit(self, kind: str, title: str, key: str, params: dict, func: Callable[[Job], Any]) -> Job:
|
|
161
|
+
"""Queue *func(job)*; ValueError when a job with the same *key* is still active."""
|
|
162
|
+
with self._cond:
|
|
163
|
+
if self.closed:
|
|
164
|
+
raise RuntimeError("shutting down")
|
|
165
|
+
for other in self._jobs.values():
|
|
166
|
+
if other.key == key and other.status not in TERMINAL:
|
|
167
|
+
raise ValueError(f"{other.title} is already {other.status} (job {other.id})")
|
|
168
|
+
job = Job(self, str(next(self._ids)), kind, title, key, params)
|
|
169
|
+
self._jobs[job.id] = job
|
|
170
|
+
done = [j for j in self._jobs.values() if j.status in TERMINAL]
|
|
171
|
+
for old in done[: max(0, len(self._jobs) - self._keep)]:
|
|
172
|
+
del self._jobs[old.id]
|
|
173
|
+
self._changed()
|
|
174
|
+
self._pool.submit(self._run, job, func)
|
|
175
|
+
return job
|
|
176
|
+
|
|
177
|
+
def _run(self, job: Job, func: Callable[[Job], Any]) -> None:
|
|
178
|
+
if job.cancelled:
|
|
179
|
+
return
|
|
180
|
+
job.update(status="running", started=time.time())
|
|
181
|
+
try:
|
|
182
|
+
result = func(job)
|
|
183
|
+
except Cancelled:
|
|
184
|
+
job.info("Cancelled")
|
|
185
|
+
job.update(status="cancelled", finished=time.time(), busy=False)
|
|
186
|
+
except Exception as exc:
|
|
187
|
+
log.exception("job %s (%s) failed", job.id, job.kind)
|
|
188
|
+
job.info(f"Error: {exc}")
|
|
189
|
+
job.update(status="error", error=str(exc) or type(exc).__name__, finished=time.time(), busy=False)
|
|
190
|
+
else:
|
|
191
|
+
job.update(status="done", result=result, progress=1.0, finished=time.time(), busy=False, eta=None)
|
|
192
|
+
if self._on_finish:
|
|
193
|
+
self._on_finish(job)
|
|
194
|
+
|
|
195
|
+
def get(self, jid: str) -> Job | None:
|
|
196
|
+
with self._cond:
|
|
197
|
+
return self._jobs.get(jid)
|
|
198
|
+
|
|
199
|
+
def jobs(self) -> list[Job]:
|
|
200
|
+
with self._cond:
|
|
201
|
+
return list(self._jobs.values())
|
|
202
|
+
|
|
203
|
+
def cancel(self, jid: str) -> Job | None:
|
|
204
|
+
"""Ask job *jid* to stop (a queued job is cancelled at once); None if unknown."""
|
|
205
|
+
with self._cond:
|
|
206
|
+
job = self._jobs.get(jid)
|
|
207
|
+
if job is None or job.status in TERMINAL:
|
|
208
|
+
return job
|
|
209
|
+
job._cancel.set()
|
|
210
|
+
if job.status == "queued":
|
|
211
|
+
job.status, job.finished = "cancelled", time.time()
|
|
212
|
+
self._changed()
|
|
213
|
+
return job
|
|
214
|
+
|
|
215
|
+
def wait(self, version: int, timeout: float) -> int:
|
|
216
|
+
"""Block until :attr:`version` differs from *version* (or *timeout* / close); the new version."""
|
|
217
|
+
with self._cond:
|
|
218
|
+
self._cond.wait_for(lambda: self.version != version or self.closed, timeout)
|
|
219
|
+
return self.version
|
|
220
|
+
|
|
221
|
+
def close(self) -> None:
|
|
222
|
+
"""Cancel everything and stop accepting jobs (running ones stop at their next check)."""
|
|
223
|
+
with self._cond:
|
|
224
|
+
self.closed = True
|
|
225
|
+
for job in self._jobs.values():
|
|
226
|
+
if job.status not in TERMINAL:
|
|
227
|
+
job._cancel.set()
|
|
228
|
+
self._changed()
|
|
229
|
+
self._pool.shutdown(wait=False, cancel_futures=True)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _flag(p: dict, name: str) -> bool:
|
|
233
|
+
v = p.get(name, False)
|
|
234
|
+
if not isinstance(v, bool):
|
|
235
|
+
raise ValueError(f"{name} must be true or false")
|
|
236
|
+
return v
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _str(p: dict, name: str, default: str | None = None) -> str | None:
|
|
240
|
+
v = p.get(name, default)
|
|
241
|
+
if v is not None and not isinstance(v, str):
|
|
242
|
+
raise ValueError(f"{name} must be a string")
|
|
243
|
+
return v.strip() if v else default
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _strings(p: dict, name: str, limit: int = 100) -> list[str]:
|
|
247
|
+
v = p.get(name)
|
|
248
|
+
if isinstance(v, str):
|
|
249
|
+
v = v.split()
|
|
250
|
+
if not isinstance(v, list) or not all(isinstance(x, str) and x.strip() for x in v) or not v:
|
|
251
|
+
raise ValueError(f"{name} must be a non-empty list of strings")
|
|
252
|
+
if len(v) > limit:
|
|
253
|
+
raise ValueError(f"at most {limit} {name}")
|
|
254
|
+
return [x.strip() for x in v]
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _region(p: dict):
|
|
258
|
+
from .. import reference as ref
|
|
259
|
+
|
|
260
|
+
key = p.get("region")
|
|
261
|
+
if not isinstance(key, (str, int)) or isinstance(key, bool):
|
|
262
|
+
raise ValueError("region must be a region id or name")
|
|
263
|
+
try:
|
|
264
|
+
return ref.get_region(key)
|
|
265
|
+
except KeyError as exc:
|
|
266
|
+
raise ValueError(exc.args[0] if exc.args else str(exc)) from None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _build_db(p: dict, ctx: JobContext) -> tuple[str, str, Callable[[Job], Any]]:
|
|
270
|
+
from ..constants import DATASETS
|
|
271
|
+
|
|
272
|
+
dataset = p.get("dataset")
|
|
273
|
+
if dataset not in DATASETS:
|
|
274
|
+
raise ValueError(f"dataset must be one of {', '.join(DATASETS)}")
|
|
275
|
+
update, force, volume_only = _flag(p, "update"), _flag(p, "force"), _flag(p, "volume_only")
|
|
276
|
+
release = _str(p, "release")
|
|
277
|
+
if release and not _RELEASE_RE.match(release.upper()):
|
|
278
|
+
raise ValueError("release must look like LROLRC_0066C")
|
|
279
|
+
|
|
280
|
+
def run(job: Job) -> dict[str, Any]:
|
|
281
|
+
from ..downloader import report_progress
|
|
282
|
+
from ..lroc.cumindex import CumIndex
|
|
283
|
+
|
|
284
|
+
ci = CumIndex(dataset, data_dir=ctx.data_dir)
|
|
285
|
+
|
|
286
|
+
def on_stage(stage: str) -> None:
|
|
287
|
+
if stage == "download":
|
|
288
|
+
job.set_stage("Downloading the index label and table", span=(0.0, 0.7))
|
|
289
|
+
else:
|
|
290
|
+
job.set_stage("Building the Parquet database (DuckDB)", busy=True)
|
|
291
|
+
job.update(cancellable=False)
|
|
292
|
+
|
|
293
|
+
with report_progress(job.on_bytes):
|
|
294
|
+
if update:
|
|
295
|
+
added = ci.update_db(on_stage=on_stage)
|
|
296
|
+
job.info(f"Appended {', '.join(added)}" if added else "Already up to date")
|
|
297
|
+
else:
|
|
298
|
+
ci.build_db(force=force, release=release, volume_only=volume_only, on_stage=on_stage)
|
|
299
|
+
info = ci.build_info() or {}
|
|
300
|
+
job.info(f"{info.get('rows', 0):,} rows, release {info.get('release')}")
|
|
301
|
+
return {"dataset": dataset, "rows": info.get("rows"), "release": info.get("release")}
|
|
302
|
+
|
|
303
|
+
verb = "Update" if update else "Build"
|
|
304
|
+
return f"{verb} {dataset.upper()} index", f"build-db:{dataset}", run
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _pgda(p: dict, ctx: JobContext) -> tuple[str, str, Callable[[Job], Any]]:
|
|
308
|
+
from .. import reference as ref
|
|
309
|
+
|
|
310
|
+
reg = _region(p)
|
|
311
|
+
if reg.pgda_site is None:
|
|
312
|
+
raise ValueError(f"Region {reg.id} {reg.name} has no PGDA LOLA 5 m site")
|
|
313
|
+
kinds = _strings(p, "kinds", len(ref.PGDA_KINDS))
|
|
314
|
+
bad = [k for k in kinds if k not in ref.PGDA_KINDS]
|
|
315
|
+
if bad:
|
|
316
|
+
raise ValueError(f"unknown PGDA kind(s) {bad}; choose from {', '.join(ref.PGDA_KINDS)}")
|
|
317
|
+
clones = p.get("clones")
|
|
318
|
+
if clones is not None and (not isinstance(clones, int) or isinstance(clones, bool) or clones < 1):
|
|
319
|
+
raise ValueError("clones must be a positive integer")
|
|
320
|
+
site = reg.pgda_site
|
|
321
|
+
|
|
322
|
+
def run(job: Job) -> dict[str, Any]:
|
|
323
|
+
from ..downloader import report_progress, url_filename
|
|
324
|
+
|
|
325
|
+
job.set_stage("Reading the PGDA file index")
|
|
326
|
+
files = [(k, u) for k, urls in ref.pgda_urls(site, kinds, clones).items() for u in urls]
|
|
327
|
+
out = ctx.reference_dir / "pgda" / site
|
|
328
|
+
with report_progress(job.on_bytes):
|
|
329
|
+
for i, (kind, url) in enumerate(files, 1):
|
|
330
|
+
name = url_filename(url)
|
|
331
|
+
job.set_file(i, len(files), name)
|
|
332
|
+
ref.fetch(url, out / ("Clones" if kind == "clones" else "") / name, show_progress=False)
|
|
333
|
+
job.info(f"{len(files)} file(s) for {site}")
|
|
334
|
+
return {"site": site, "files": len(files)}
|
|
335
|
+
|
|
336
|
+
return f"PGDA {site} ({', '.join(kinds)})", f"pgda:{site}", run
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _sdem(p: dict, ctx: JobContext) -> tuple[str, str, Callable[[Job], Any]]:
|
|
340
|
+
reg = _region(p)
|
|
341
|
+
members = _strings(p, "members")
|
|
342
|
+
|
|
343
|
+
def run(job: Job) -> dict[str, Any]:
|
|
344
|
+
from .. import reference as ref
|
|
345
|
+
from ..tools import reuse_existing
|
|
346
|
+
|
|
347
|
+
job.set_stage("Reading the zip directory (HTTP Range)")
|
|
348
|
+
out_dir = ctx.reference_dir / "sdem" / reg.zenodo_file.removesuffix(".zip")
|
|
349
|
+
done = []
|
|
350
|
+
with ref._open_zip(reg) as zf:
|
|
351
|
+
names = ref.select_members(zf.namelist(), members)
|
|
352
|
+
if not names:
|
|
353
|
+
raise ValueError(f"No zip member matches {members}")
|
|
354
|
+
for i, name in enumerate(names, 1):
|
|
355
|
+
info = zf.getinfo(name)
|
|
356
|
+
dest = out_dir / (name.split("/", 1)[1] if "/" in name else name)
|
|
357
|
+
job.set_file(i, len(names), dest.name)
|
|
358
|
+
if not reuse_existing(dest, False):
|
|
359
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
360
|
+
tmp = dest.with_suffix(dest.suffix + ".part")
|
|
361
|
+
n = 0
|
|
362
|
+
with zf.open(name) as src, open(tmp, "wb") as dst:
|
|
363
|
+
while buf := src.read(1 << 20):
|
|
364
|
+
dst.write(buf)
|
|
365
|
+
n += len(buf)
|
|
366
|
+
job.on_bytes(dest.name, n, info.file_size)
|
|
367
|
+
tmp.replace(dest)
|
|
368
|
+
done.append(dest.name)
|
|
369
|
+
job.info(f"{len(done)} member(s) of {reg.zenodo_file}")
|
|
370
|
+
return {"region": reg.id, "files": len(done)}
|
|
371
|
+
|
|
372
|
+
return f"SDEM {reg.name} ({', '.join(members)})", f"sdem:{reg.id}", run
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _download(p: dict, ctx: JobContext) -> tuple[str, str, Callable[[Job], Any]]:
|
|
376
|
+
from ..constants import NAC_ID_RE, WAC_ID_RE
|
|
377
|
+
|
|
378
|
+
ids = [i.upper() for i in _strings(p, "ids", MAX_IDS)]
|
|
379
|
+
bad = [i for i in ids if not (NAC_ID_RE.match(i) or WAC_ID_RE.match(i))]
|
|
380
|
+
if bad:
|
|
381
|
+
raise ValueError(f"not LROC product IDs: {' '.join(bad[:5])}")
|
|
382
|
+
product = p.get("product", "edr")
|
|
383
|
+
if product not in ("edr", "cdr"):
|
|
384
|
+
raise ValueError("product must be edr or cdr")
|
|
385
|
+
dest_s = _str(p, "dest")
|
|
386
|
+
dest = Path(dest_s).expanduser() if dest_s else ctx.download_dir
|
|
387
|
+
if not dest.is_absolute():
|
|
388
|
+
raise ValueError("dest must be an absolute directory")
|
|
389
|
+
|
|
390
|
+
def run(job: Job) -> dict[str, Any]:
|
|
391
|
+
from ..downloader import report_progress
|
|
392
|
+
from ..lroc.nac import NacImageCollection
|
|
393
|
+
|
|
394
|
+
job.set_stage("Looking up the product IDs")
|
|
395
|
+
images = list(NacImageCollection.from_sources(ids, data_dir=ctx.data_dir))
|
|
396
|
+
missing = sorted(set(ids) - {img.product_id for img in images})
|
|
397
|
+
if missing:
|
|
398
|
+
job.info(f"Not in the index: {' '.join(missing[:20])}{' ...' if len(missing) > 20 else ''}")
|
|
399
|
+
ok, failed = [], []
|
|
400
|
+
with report_progress(job.on_bytes):
|
|
401
|
+
for i, img in enumerate(images, 1):
|
|
402
|
+
job.set_file(i, len(images), img.product_id)
|
|
403
|
+
try:
|
|
404
|
+
path = img.download_cdr(dest) if product == "cdr" else img.download_edr(dest)
|
|
405
|
+
except Cancelled:
|
|
406
|
+
raise
|
|
407
|
+
except Exception as exc:
|
|
408
|
+
job.info(f"{img.product_id}: {exc}")
|
|
409
|
+
failed.append(img.product_id)
|
|
410
|
+
else:
|
|
411
|
+
ok.append(path.name)
|
|
412
|
+
job.info(f"{len(ok)} downloaded, {len(failed)} failed, {len(missing)} not found")
|
|
413
|
+
if not ok and (failed or missing):
|
|
414
|
+
raise RuntimeError("nothing was downloaded")
|
|
415
|
+
return {"downloaded": len(ok), "failed": failed, "missing": missing}
|
|
416
|
+
|
|
417
|
+
return f"Download {len(ids)} {product.upper()} image(s)", f"download:{product}:{' '.join(ids)}", run
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
KINDS: dict[str, Callable[[dict, JobContext], tuple[str, str, Callable[[Job], Any]]]] = {
|
|
421
|
+
"build-db": _build_db,
|
|
422
|
+
"pgda": _pgda,
|
|
423
|
+
"sdem": _sdem,
|
|
424
|
+
"download": _download,
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def submit(manager: JobManager, payload: Any, ctx: JobContext) -> Job:
|
|
429
|
+
"""Validate a ``POST /api/jobs`` body and queue the job (ValueError for a bad request)."""
|
|
430
|
+
if not isinstance(payload, dict):
|
|
431
|
+
raise ValueError("expected a JSON object")
|
|
432
|
+
kind = payload.get("kind")
|
|
433
|
+
if kind not in KINDS:
|
|
434
|
+
raise ValueError(f"kind must be one of {', '.join(KINDS)}")
|
|
435
|
+
title, key, func = KINDS[kind](payload, ctx)
|
|
436
|
+
params = {k: v for k, v in payload.items() if k != "kind"}
|
|
437
|
+
return manager.submit(kind, title, key, params, func)
|