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,368 @@
|
|
|
1
|
+
"""What ``lunar serve`` reports in ``/api/status``: indexes, caches, reference data, runs, tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
MAX_SCAN = 20000
|
|
16
|
+
MAX_LISTED = 200
|
|
17
|
+
CACHE_SECONDS = 30.0
|
|
18
|
+
_PATH_KEYS = {"path", "root", "dest", "executable"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class StatusDirs:
|
|
23
|
+
"""The directories the status page describes."""
|
|
24
|
+
|
|
25
|
+
data_dir: Path
|
|
26
|
+
cache_dir: Path
|
|
27
|
+
download_dir: Path
|
|
28
|
+
reference_dir: Path
|
|
29
|
+
runs_dir: Path | None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _walk(root: Path, limit: int, state: dict[str, bool] | None = None) -> Iterator[os.DirEntry]:
|
|
33
|
+
"""Files under *root* (no symlinks followed), stopping after *limit* entries (``state["truncated"]``)."""
|
|
34
|
+
stack = [root]
|
|
35
|
+
n = 0
|
|
36
|
+
while stack:
|
|
37
|
+
try:
|
|
38
|
+
it = os.scandir(stack.pop())
|
|
39
|
+
except OSError:
|
|
40
|
+
continue
|
|
41
|
+
with it:
|
|
42
|
+
for entry in it:
|
|
43
|
+
n += 1
|
|
44
|
+
if n > limit:
|
|
45
|
+
if state is not None:
|
|
46
|
+
state["truncated"] = True
|
|
47
|
+
return
|
|
48
|
+
try:
|
|
49
|
+
if entry.is_dir(follow_symlinks=False):
|
|
50
|
+
stack.append(Path(entry.path))
|
|
51
|
+
elif entry.is_file(follow_symlinks=False):
|
|
52
|
+
yield entry
|
|
53
|
+
except OSError:
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def dir_size(root: Path, limit: int = MAX_SCAN) -> dict[str, Any]:
|
|
58
|
+
"""``size`` (bytes) and ``files`` under *root*, ``truncated`` when more than *limit* entries."""
|
|
59
|
+
size = files = 0
|
|
60
|
+
state = {"truncated": False}
|
|
61
|
+
for entry in _walk(Path(root), limit, state):
|
|
62
|
+
try:
|
|
63
|
+
size += entry.stat(follow_symlinks=False).st_size
|
|
64
|
+
except OSError:
|
|
65
|
+
continue
|
|
66
|
+
files += 1
|
|
67
|
+
return {"size": size, "files": files, "truncated": state["truncated"]}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _file(path: Path, name: str | None = None) -> dict[str, Any]:
|
|
71
|
+
try:
|
|
72
|
+
st = path.stat()
|
|
73
|
+
except OSError:
|
|
74
|
+
return {"name": name or path.name, "path": str(path), "size": None, "mtime": None}
|
|
75
|
+
return {"name": name or path.name, "path": str(path), "size": st.st_size, "mtime": st.st_mtime}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def indexes(data_dir: Path) -> list[dict[str, Any]]:
|
|
79
|
+
"""Each dataset's Parquet: existence, size and the build sidecar (rows, release, times)."""
|
|
80
|
+
from ..constants import DATASETS
|
|
81
|
+
|
|
82
|
+
out = []
|
|
83
|
+
for ds, entry in DATASETS.items():
|
|
84
|
+
p = Path(data_dir) / entry["parquet"]
|
|
85
|
+
meta: dict[str, Any] = {}
|
|
86
|
+
try:
|
|
87
|
+
meta = json.loads(p.with_name(p.stem + ".meta.json").read_text())
|
|
88
|
+
except (OSError, ValueError):
|
|
89
|
+
pass
|
|
90
|
+
row = {"dataset": ds, "label": entry["label"], "exists": p.is_file(), **_file(p)}
|
|
91
|
+
row.update({k: meta.get(k) for k in ("rows", "release", "source", "built_at", "updated_at")})
|
|
92
|
+
row["volumes"] = len(meta.get("volumes") or {})
|
|
93
|
+
out.append(row)
|
|
94
|
+
return out
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def cache_summary(cache_dir: Path) -> dict[str, Any]:
|
|
98
|
+
"""Top-level entries of the cache dir with their sizes."""
|
|
99
|
+
items = []
|
|
100
|
+
try:
|
|
101
|
+
entries = sorted(Path(cache_dir).iterdir())
|
|
102
|
+
except OSError:
|
|
103
|
+
entries = []
|
|
104
|
+
for p in entries[:MAX_LISTED]:
|
|
105
|
+
if p.is_dir():
|
|
106
|
+
items.append({"name": p.name + "/", "path": str(p), "dir": True, **dir_size(p)})
|
|
107
|
+
else:
|
|
108
|
+
items.append({**_file(p, _cache_name(p.name)), "dir": False, "files": 1})
|
|
109
|
+
total = sum(i["size"] or 0 for i in items)
|
|
110
|
+
return {"path": str(cache_dir), "exists": Path(cache_dir).is_dir(), "size": total, "items": items}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _cache_name(name: str) -> str:
|
|
114
|
+
"""``226215a6_CUMINDEX.TAB`` -> ``CUMINDEX.TAB (226215a6)`` (PdsDownloader cache names)."""
|
|
115
|
+
head, sep, rest = name.partition("_")
|
|
116
|
+
if sep and len(head) == 8 and all(c in "0123456789abcdef" for c in head):
|
|
117
|
+
return f"{rest} ({head})"
|
|
118
|
+
return name
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def downloads(download_dir: Path) -> dict[str, Any]:
|
|
122
|
+
"""Image files in the download dir (newest first), with ``.part`` files flagged as partial."""
|
|
123
|
+
files = []
|
|
124
|
+
for entry in _walk(Path(download_dir), MAX_SCAN):
|
|
125
|
+
p = Path(entry.path)
|
|
126
|
+
f = _file(p, p.relative_to(download_dir).as_posix())
|
|
127
|
+
f["partial"] = p.suffix == ".part"
|
|
128
|
+
files.append(f)
|
|
129
|
+
files.sort(key=lambda f: -(f["mtime"] or 0))
|
|
130
|
+
return {
|
|
131
|
+
"path": str(download_dir),
|
|
132
|
+
"exists": Path(download_dir).is_dir(),
|
|
133
|
+
"size": sum(f["size"] or 0 for f in files),
|
|
134
|
+
"count": len(files),
|
|
135
|
+
"files": files[:MAX_LISTED],
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def reference(reference_dir: Path) -> dict[str, Any]:
|
|
140
|
+
"""PGDA sites / kinds and extracted Zenodo SDEM members under the reference cache."""
|
|
141
|
+
from .. import reference as ref
|
|
142
|
+
|
|
143
|
+
site_region = {r.pgda_site: f"{r.id} {r.name}" for r in ref.REGIONS if r.pgda_site}
|
|
144
|
+
pgda = []
|
|
145
|
+
root = Path(reference_dir) / "pgda"
|
|
146
|
+
for site_dir in sorted(p for p in root.glob("*") if p.is_dir()) if root.is_dir() else []:
|
|
147
|
+
files = []
|
|
148
|
+
for entry in _walk(site_dir, MAX_SCAN):
|
|
149
|
+
p = Path(entry.path)
|
|
150
|
+
kind = ref._classify_pgda(p.relative_to(root).as_posix())
|
|
151
|
+
files.append({**_file(p, p.relative_to(site_dir).as_posix()), "kind": kind})
|
|
152
|
+
kinds: dict[str, int] = {}
|
|
153
|
+
for f in files:
|
|
154
|
+
if f["kind"]:
|
|
155
|
+
kinds[f["kind"]] = kinds.get(f["kind"], 0) + 1
|
|
156
|
+
pgda.append(
|
|
157
|
+
{
|
|
158
|
+
"site": site_dir.name,
|
|
159
|
+
"region": site_region.get(site_dir.name),
|
|
160
|
+
"kinds": kinds,
|
|
161
|
+
"size": sum(f["size"] or 0 for f in files),
|
|
162
|
+
"files": sorted(files, key=lambda f: f["name"])[:MAX_LISTED],
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
sdem = []
|
|
166
|
+
sroot = Path(reference_dir) / "sdem"
|
|
167
|
+
for zdir in sorted(p for p in sroot.glob("*") if p.is_dir()) if sroot.is_dir() else []:
|
|
168
|
+
members = [Path(e.path) for e in _walk(zdir, MAX_SCAN) if not e.name.endswith(".part")]
|
|
169
|
+
sdem.append(
|
|
170
|
+
{
|
|
171
|
+
"zip": zdir.name,
|
|
172
|
+
"path": str(zdir),
|
|
173
|
+
"members": sorted(p.relative_to(zdir).as_posix() for p in members)[:MAX_LISTED],
|
|
174
|
+
"count": len(members),
|
|
175
|
+
"size": sum(p.stat().st_size for p in members if p.exists()),
|
|
176
|
+
}
|
|
177
|
+
)
|
|
178
|
+
return {"path": str(reference_dir), "exists": Path(reference_dir).is_dir(), "pgda": pgda, "sdem": sdem}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def runs(runs_dir: Path | None) -> dict[str, Any]:
|
|
182
|
+
"""Run dirs with a ``provenance.json``: last run's status, times and steps, and a report flag."""
|
|
183
|
+
if runs_dir is None:
|
|
184
|
+
return {"configured": False, "runs": []}
|
|
185
|
+
out: list[dict[str, Any]] = []
|
|
186
|
+
try:
|
|
187
|
+
dirs = sorted(p for p in Path(runs_dir).iterdir() if p.is_dir() and not p.name.startswith("."))
|
|
188
|
+
except OSError:
|
|
189
|
+
dirs = []
|
|
190
|
+
for d in dirs[:MAX_LISTED]:
|
|
191
|
+
prov = d / "provenance.json"
|
|
192
|
+
if not prov.is_file():
|
|
193
|
+
continue
|
|
194
|
+
try:
|
|
195
|
+
data = json.loads(prov.read_text())
|
|
196
|
+
except (OSError, ValueError):
|
|
197
|
+
out.append({"name": d.name, "path": str(d), "status": "unreadable", "steps": []})
|
|
198
|
+
continue
|
|
199
|
+
history = data.get("runs") or [{}]
|
|
200
|
+
last = history[-1]
|
|
201
|
+
steps = [
|
|
202
|
+
{"name": s.get("name"), "status": s.get("status"), "seconds": s.get("seconds")}
|
|
203
|
+
for s in last.get("steps") or []
|
|
204
|
+
]
|
|
205
|
+
out.append(
|
|
206
|
+
{
|
|
207
|
+
"name": d.name,
|
|
208
|
+
"path": str(d),
|
|
209
|
+
"status": last.get("status"),
|
|
210
|
+
"started": last.get("started"),
|
|
211
|
+
"finished": last.get("finished"),
|
|
212
|
+
"runs": len(history),
|
|
213
|
+
"steps": steps,
|
|
214
|
+
"report": (d / "report" / "report.html").is_file(),
|
|
215
|
+
}
|
|
216
|
+
)
|
|
217
|
+
out.sort(key=lambda r: r.get("started") or "", reverse=True)
|
|
218
|
+
return {"configured": True, "path": str(runs_dir), "exists": Path(runs_dir).is_dir(), "runs": out}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
_ENV_LOCK = threading.Lock()
|
|
222
|
+
_ENV: dict[str, Any] | None = None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def environment(refresh: bool = False) -> dict[str, Any]:
|
|
226
|
+
"""ASP / ISIS roots, ISISDATA subsets and tool versions (``sfs --version`` etc., cached)."""
|
|
227
|
+
global _ENV
|
|
228
|
+
with _ENV_LOCK:
|
|
229
|
+
if _ENV is None or refresh:
|
|
230
|
+
_ENV = _environment()
|
|
231
|
+
return _ENV
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _environment() -> dict[str, Any]:
|
|
235
|
+
from ..provenance import isis_paths, kernel_files, tool_versions
|
|
236
|
+
from ..tools import find_tool
|
|
237
|
+
|
|
238
|
+
paths = isis_paths()
|
|
239
|
+
isisdata = paths.get("ISISDATA")
|
|
240
|
+
subsets: dict[str, bool] = {}
|
|
241
|
+
if isisdata:
|
|
242
|
+
root = Path(isisdata)
|
|
243
|
+
for key, rel in (
|
|
244
|
+
("base kernels", "base/kernels"),
|
|
245
|
+
("base dems", "base/dems"),
|
|
246
|
+
("lro kernels", "lro/kernels"),
|
|
247
|
+
("lro calibration", "lro/calibration"),
|
|
248
|
+
):
|
|
249
|
+
subsets[key] = (root / rel).is_dir()
|
|
250
|
+
tools = {}
|
|
251
|
+
for name in (
|
|
252
|
+
"sfs",
|
|
253
|
+
"parallel_sfs",
|
|
254
|
+
"bundle_adjust",
|
|
255
|
+
"mapproject",
|
|
256
|
+
"spiceinit",
|
|
257
|
+
"lronac2isis",
|
|
258
|
+
"gdalinfo",
|
|
259
|
+
):
|
|
260
|
+
p = find_tool(name)
|
|
261
|
+
tools[name] = {"found": p is not None, "path": str(p) if p else None}
|
|
262
|
+
asp_root = os.environ.get("ASP_ROOT")
|
|
263
|
+
return {
|
|
264
|
+
"roots": [
|
|
265
|
+
{"name": "ASP_ROOT", "path": asp_root, "set": bool(asp_root)},
|
|
266
|
+
{"name": "ISISROOT", "path": paths.get("ISISROOT"), "set": bool(paths.get("ISISROOT"))},
|
|
267
|
+
{"name": "ISISDATA", "path": isisdata, "set": bool(isisdata)},
|
|
268
|
+
],
|
|
269
|
+
"isisdata": subsets,
|
|
270
|
+
"kernels": kernel_files(isisdata),
|
|
271
|
+
"versions": tool_versions(),
|
|
272
|
+
"tools": tools,
|
|
273
|
+
"checked": time.time(),
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def cooldowns() -> list[dict[str, Any]]:
|
|
278
|
+
"""Hosts paused after a throttling reply (persisted and in this process)."""
|
|
279
|
+
from .. import downloader
|
|
280
|
+
|
|
281
|
+
now = time.time()
|
|
282
|
+
hosts = dict(downloader._saved_cooldowns())
|
|
283
|
+
for name, h in list(downloader._HOSTS.items()):
|
|
284
|
+
if h.cooldown_until > now:
|
|
285
|
+
hosts[name] = max(hosts.get(name, 0.0), h.cooldown_until)
|
|
286
|
+
return [
|
|
287
|
+
{
|
|
288
|
+
"host": h,
|
|
289
|
+
"until": t,
|
|
290
|
+
"until_text": time.strftime("%H:%M:%S", time.localtime(t)),
|
|
291
|
+
"seconds": t - now,
|
|
292
|
+
}
|
|
293
|
+
for h, t in sorted(hosts.items())
|
|
294
|
+
if t > now
|
|
295
|
+
]
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def disks(dirs: StatusDirs) -> list[dict[str, Any]]:
|
|
299
|
+
"""Free / total space of the file systems holding the data, cache and runs dirs."""
|
|
300
|
+
out: list[dict[str, Any]] = []
|
|
301
|
+
seen: set[int] = set()
|
|
302
|
+
for label, d in (("data", dirs.data_dir), ("cache", dirs.cache_dir), ("runs", dirs.runs_dir)):
|
|
303
|
+
if d is None:
|
|
304
|
+
continue
|
|
305
|
+
p = Path(d)
|
|
306
|
+
while not p.exists() and p != p.parent:
|
|
307
|
+
p = p.parent
|
|
308
|
+
try:
|
|
309
|
+
dev = p.stat().st_dev
|
|
310
|
+
usage = shutil.disk_usage(p)
|
|
311
|
+
except OSError:
|
|
312
|
+
continue
|
|
313
|
+
if dev in seen:
|
|
314
|
+
next(o for o in out if o["dev"] == dev)["labels"].append(label)
|
|
315
|
+
continue
|
|
316
|
+
seen.add(dev)
|
|
317
|
+
out.append({"labels": [label], "dev": dev, "path": str(p), "free": usage.free, "total": usage.total})
|
|
318
|
+
for o in out:
|
|
319
|
+
del o["dev"]
|
|
320
|
+
return out
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def redact(obj: Any) -> Any:
|
|
324
|
+
"""*obj* without file system paths (``path`` / ``root`` / ... keys), for read-only clients."""
|
|
325
|
+
if isinstance(obj, dict):
|
|
326
|
+
return {k: redact(v) for k, v in obj.items() if k not in _PATH_KEYS and k != "kernels"}
|
|
327
|
+
if isinstance(obj, list):
|
|
328
|
+
return [redact(v) for v in obj]
|
|
329
|
+
return obj
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class StatusCache:
|
|
333
|
+
"""``/api/status`` documents, rebuilt at most every :data:`CACHE_SECONDS` (or on refresh)."""
|
|
334
|
+
|
|
335
|
+
def __init__(self, dirs: StatusDirs) -> None:
|
|
336
|
+
self.dirs = dirs
|
|
337
|
+
self._lock = threading.Lock()
|
|
338
|
+
self._doc: dict[str, Any] | None = None
|
|
339
|
+
self._at = 0.0
|
|
340
|
+
|
|
341
|
+
def invalidate(self) -> None:
|
|
342
|
+
with self._lock:
|
|
343
|
+
self._doc = None
|
|
344
|
+
|
|
345
|
+
def get(self, admin: bool, refresh: bool = False) -> dict[str, Any]:
|
|
346
|
+
with self._lock:
|
|
347
|
+
if self._doc is None or refresh or time.time() - self._at > CACHE_SECONDS:
|
|
348
|
+
self._doc = self._build(refresh)
|
|
349
|
+
self._at = time.time()
|
|
350
|
+
doc = self._doc
|
|
351
|
+
live = {**doc, "cooldowns": cooldowns(), "admin": admin}
|
|
352
|
+
return live if admin else redact(live)
|
|
353
|
+
|
|
354
|
+
def _build(self, refresh: bool) -> dict[str, Any]:
|
|
355
|
+
from ..constants import APP_VERSION
|
|
356
|
+
|
|
357
|
+
d = self.dirs
|
|
358
|
+
return {
|
|
359
|
+
"version": APP_VERSION,
|
|
360
|
+
"data": {"path": str(d.data_dir), "exists": d.data_dir.is_dir(), "indexes": indexes(d.data_dir)},
|
|
361
|
+
"cache": cache_summary(d.cache_dir),
|
|
362
|
+
"downloads": downloads(d.download_dir),
|
|
363
|
+
"reference": reference(d.reference_dir),
|
|
364
|
+
"runs": runs(d.runs_dir),
|
|
365
|
+
"environment": environment(refresh),
|
|
366
|
+
"disks": disks(d),
|
|
367
|
+
"built": time.time(),
|
|
368
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Shared helpers for CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load_sources(args) -> list[str]:
|
|
14
|
+
"""Collect source strings from positional args and/or --from-file."""
|
|
15
|
+
sources: list[str] = list(getattr(args, "sources", []) or [])
|
|
16
|
+
from_file = getattr(args, "from_file", None)
|
|
17
|
+
if from_file:
|
|
18
|
+
p = Path(from_file)
|
|
19
|
+
if not p.exists():
|
|
20
|
+
print(f"Error: file not found: {from_file}", file=sys.stderr)
|
|
21
|
+
sys.exit(1)
|
|
22
|
+
for line in p.read_text().splitlines():
|
|
23
|
+
line = line.strip()
|
|
24
|
+
if line and not line.startswith("#"):
|
|
25
|
+
sources.append(line)
|
|
26
|
+
return sources
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def extract_product_id(source: str) -> str:
|
|
30
|
+
"""Derive a product ID from a product ID string, local path, or URL."""
|
|
31
|
+
s = source.strip()
|
|
32
|
+
if s.startswith(("http://", "https://")):
|
|
33
|
+
return Path(urlparse(s).path).stem.upper()
|
|
34
|
+
if not _looks_like_id(s):
|
|
35
|
+
return Path(s).stem.upper()
|
|
36
|
+
return s.upper()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _looks_like_id(s: str) -> bool:
|
|
40
|
+
from ..constants import NAC_ID_RE, WAC_ID_RE
|
|
41
|
+
|
|
42
|
+
return bool(NAC_ID_RE.match(s) or WAC_ID_RE.match(s))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def resolve_images(sources: list[str], raw_dir: Path, data_dir=None, download: bool = True) -> list[Path]:
|
|
46
|
+
"""Local .IMG / .cub paths as-is; NAC product IDs downloaded (EDR) to *raw_dir*.
|
|
47
|
+
|
|
48
|
+
With ``download=False`` IDs map to ``<raw_dir>/<ID>.IMG`` without fetching.
|
|
49
|
+
"""
|
|
50
|
+
from ..lroc.nac import NacImageCollection
|
|
51
|
+
|
|
52
|
+
paths = [Path(s) for s in sources if Path(s).exists()]
|
|
53
|
+
ids = [s for s in sources if not Path(s).exists()]
|
|
54
|
+
if ids and not download:
|
|
55
|
+
return paths + [Path(raw_dir) / f"{i.upper()}.IMG" for i in ids]
|
|
56
|
+
if ids:
|
|
57
|
+
found = NacImageCollection.from_sources(ids, data_dir=data_dir)
|
|
58
|
+
missing = {i.upper() for i in ids} - {img.product_id for img in found}
|
|
59
|
+
if missing:
|
|
60
|
+
raise SystemExit(
|
|
61
|
+
f"Not in the local index: {', '.join(sorted(missing))} "
|
|
62
|
+
"(run `lunar build-db edr` or pass .IMG paths)"
|
|
63
|
+
)
|
|
64
|
+
downloaded = found.download_edrs(raw_dir)
|
|
65
|
+
failed = [pid for pid, p in downloaded.items() if p is None]
|
|
66
|
+
if failed:
|
|
67
|
+
raise SystemExit(f"Download failed: {', '.join(failed)}")
|
|
68
|
+
paths += [p for p in (downloaded[img.product_id] for img in found) if p is not None]
|
|
69
|
+
return paths
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def region_args(args: argparse.Namespace) -> dict[str, Any]:
|
|
73
|
+
"""Search-region keywords from ``--bbox`` / ``--point`` + ``--radius-km`` / ``--aoi`` (empty if none)."""
|
|
74
|
+
if args.bbox:
|
|
75
|
+
west, south, east, north = args.bbox
|
|
76
|
+
return {"lat": (south, north), "lon": (west, east)}
|
|
77
|
+
if args.point:
|
|
78
|
+
return {"point": tuple(args.point), "radius_km": args.radius_km}
|
|
79
|
+
if getattr(args, "aoi", None):
|
|
80
|
+
return {"aoi": args.aoi}
|
|
81
|
+
return {}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def ode_filters(args: argparse.Namespace) -> dict[str, Any]:
|
|
85
|
+
""":meth:`OdeClient.search` filters from ``--inc`` / ``--emi`` / ``--phase`` / ``--time``."""
|
|
86
|
+
return {
|
|
87
|
+
"incidence": tuple(args.inc) if args.inc else None,
|
|
88
|
+
"emission": tuple(args.emi) if args.emi else None,
|
|
89
|
+
"phase": tuple(args.phase) if args.phase else None,
|
|
90
|
+
"time_range": tuple(args.time) if args.time else None,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def print_dry_run(func: Callable[[], object]) -> None:
|
|
95
|
+
"""Call *func* with tool commands recorded instead of run, then print them."""
|
|
96
|
+
from ..tools import dry_run, format_command
|
|
97
|
+
|
|
98
|
+
with dry_run() as cmds:
|
|
99
|
+
func()
|
|
100
|
+
for c in cmds:
|
|
101
|
+
print(format_command(c))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def parse_options(items: list[str] | None) -> dict:
|
|
105
|
+
"""``["max_iterations=5", "float_albedo=true", "x=none"]`` -> typed dict."""
|
|
106
|
+
out: dict = {}
|
|
107
|
+
for item in items or []:
|
|
108
|
+
key, sep, value = item.partition("=")
|
|
109
|
+
if not sep:
|
|
110
|
+
raise SystemExit(f"Expected KEY=VALUE, got {item!r}")
|
|
111
|
+
out[key.strip().replace("-", "_")] = _typed(value.strip())
|
|
112
|
+
return out
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _typed(value: str):
|
|
116
|
+
low = value.lower()
|
|
117
|
+
if low in ("true", "yes", "on", ""):
|
|
118
|
+
return True
|
|
119
|
+
if low in ("false", "no", "off"):
|
|
120
|
+
return False
|
|
121
|
+
if low == "none":
|
|
122
|
+
return None
|
|
123
|
+
for cast in (int, float):
|
|
124
|
+
try:
|
|
125
|
+
return cast(value)
|
|
126
|
+
except ValueError:
|
|
127
|
+
pass
|
|
128
|
+
return value
|
lunar_toolkit/cli/app.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""app - the web app in a desktop window (pywebview, else a Chromium --app window, else a browser tab)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import importlib.util
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import threading
|
|
12
|
+
import webbrowser
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, cast
|
|
15
|
+
|
|
16
|
+
COMMAND = "app"
|
|
17
|
+
TITLE = "Lunar Toolkit"
|
|
18
|
+
SIZE = (1440, 900)
|
|
19
|
+
MIN_SIZE = (900, 600)
|
|
20
|
+
BACKENDS = ("auto", "webview", "chromium", "browser")
|
|
21
|
+
|
|
22
|
+
_CHROMIUM_NAMES = (
|
|
23
|
+
"google-chrome",
|
|
24
|
+
"google-chrome-stable",
|
|
25
|
+
"chromium",
|
|
26
|
+
"chromium-browser",
|
|
27
|
+
"microsoft-edge",
|
|
28
|
+
"microsoft-edge-stable",
|
|
29
|
+
"brave-browser",
|
|
30
|
+
)
|
|
31
|
+
_MAC_APPS = ("Google Chrome", "Chromium", "Microsoft Edge", "Brave Browser")
|
|
32
|
+
_WIN_PATHS = (
|
|
33
|
+
("Google", "Chrome", "Application", "chrome.exe"),
|
|
34
|
+
("Microsoft", "Edge", "Application", "msedge.exe"),
|
|
35
|
+
("Chromium", "Application", "chrome.exe"),
|
|
36
|
+
("BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def add_parser(sub: argparse._SubParsersAction) -> None:
|
|
41
|
+
from .serve import add_server_args
|
|
42
|
+
|
|
43
|
+
p = sub.add_parser(
|
|
44
|
+
COMMAND,
|
|
45
|
+
help="Open the Lunar Toolkit web app in a desktop window.",
|
|
46
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
47
|
+
epilog="""
|
|
48
|
+
Examples
|
|
49
|
+
--------
|
|
50
|
+
lunar app
|
|
51
|
+
lunar app --images ~/lunar_runs
|
|
52
|
+
lunar app --host 0.0.0.0 --port 8080 # window here, read-only page for the network
|
|
53
|
+
lunar app --backend browser # a normal browser tab instead
|
|
54
|
+
|
|
55
|
+
The server runs on this machine (a free port on 127.0.0.1 by default) and stops when
|
|
56
|
+
the window closes. Window backends, first available wins (--backend auto):
|
|
57
|
+
webview pywebview: Qt WebEngine on Linux (pip install PyQt6 PyQt6-WebEngine qtpy,
|
|
58
|
+
or PyGObject + WebKitGTK), Edge WebView2 on Windows, WebKit on macOS
|
|
59
|
+
chromium Chrome / Chromium / Edge / Brave in --app mode (own profile in the cache dir)
|
|
60
|
+
browser the default browser (Ctrl-C stops the server)
|
|
61
|
+
""",
|
|
62
|
+
)
|
|
63
|
+
add_server_args(p, "127.0.0.1", 0)
|
|
64
|
+
p.add_argument(
|
|
65
|
+
"--backend",
|
|
66
|
+
choices=BACKENDS,
|
|
67
|
+
default="auto",
|
|
68
|
+
help="Window backend (default: auto, in the order above).",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def webview_gui(platform: str | None = None) -> str | None:
|
|
73
|
+
"""The pywebview GUI to use here (``qt``, ``gtk``, ``cocoa``, ``edgechromium``), or None."""
|
|
74
|
+
platform = platform or sys.platform
|
|
75
|
+
if not _has("webview"):
|
|
76
|
+
return None
|
|
77
|
+
if platform == "darwin":
|
|
78
|
+
return "cocoa" if _has("WebKit") else None
|
|
79
|
+
if platform == "win32":
|
|
80
|
+
return "edgechromium" if _has("clr") else None
|
|
81
|
+
if _has("qtpy") and (_has("PyQt6.QtWebEngineWidgets") or _has("PySide6.QtWebEngineWidgets")):
|
|
82
|
+
return "qt"
|
|
83
|
+
if _has("gi") and _gtk_webkit():
|
|
84
|
+
return "gtk"
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _has(module: str) -> bool:
|
|
89
|
+
try:
|
|
90
|
+
return importlib.util.find_spec(module) is not None
|
|
91
|
+
except (ImportError, ValueError):
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _gtk_webkit() -> bool:
|
|
96
|
+
try:
|
|
97
|
+
gi = importlib.import_module("gi")
|
|
98
|
+
for version in ("4.1", "4.0"):
|
|
99
|
+
try:
|
|
100
|
+
gi.require_version("WebKit2", version)
|
|
101
|
+
return True
|
|
102
|
+
except ValueError:
|
|
103
|
+
continue
|
|
104
|
+
except ImportError:
|
|
105
|
+
pass
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def chromium_binary(platform: str | None = None) -> str | None:
|
|
110
|
+
"""A Chrome / Chromium / Edge / Brave executable, or None."""
|
|
111
|
+
platform = platform or sys.platform
|
|
112
|
+
if platform == "darwin":
|
|
113
|
+
for app in _MAC_APPS:
|
|
114
|
+
p = Path("/Applications") / f"{app}.app" / "Contents" / "MacOS" / app
|
|
115
|
+
if p.is_file():
|
|
116
|
+
return str(p)
|
|
117
|
+
elif platform == "win32":
|
|
118
|
+
roots = [os.environ.get(v) for v in ("PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA")]
|
|
119
|
+
for root in filter(None, roots):
|
|
120
|
+
for parts in _WIN_PATHS:
|
|
121
|
+
p = Path(root, *parts)
|
|
122
|
+
if p.is_file():
|
|
123
|
+
return str(p)
|
|
124
|
+
for name in _CHROMIUM_NAMES:
|
|
125
|
+
found = shutil.which(name)
|
|
126
|
+
if found:
|
|
127
|
+
return found
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def choose_backend(requested: str = "auto", platform: str | None = None) -> tuple[str, str | None]:
|
|
132
|
+
"""``(backend, detail)``: the pywebview GUI name, the Chromium binary, or None for the browser."""
|
|
133
|
+
if requested in ("auto", "webview"):
|
|
134
|
+
gui = webview_gui(platform)
|
|
135
|
+
if gui:
|
|
136
|
+
return "webview", gui
|
|
137
|
+
if requested in ("auto", "chromium", "webview"):
|
|
138
|
+
binary = chromium_binary(platform)
|
|
139
|
+
if binary:
|
|
140
|
+
return "chromium", binary
|
|
141
|
+
return "browser", None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _run_webview(url: str, gui: str) -> None:
|
|
145
|
+
import webview
|
|
146
|
+
|
|
147
|
+
from ..constants import DEFAULT_CACHE_DIR
|
|
148
|
+
|
|
149
|
+
webview.create_window(TITLE, url, width=SIZE[0], height=SIZE[1], min_size=MIN_SIZE)
|
|
150
|
+
storage = DEFAULT_CACHE_DIR / "app-profile" / "webview"
|
|
151
|
+
storage.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
webview.start(gui=cast(Any, gui), private_mode=False, storage_path=str(storage))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _run_chromium(url: str, binary: str) -> None:
|
|
156
|
+
from ..constants import DEFAULT_CACHE_DIR
|
|
157
|
+
|
|
158
|
+
profile = DEFAULT_CACHE_DIR / "app-profile"
|
|
159
|
+
profile.mkdir(parents=True, exist_ok=True)
|
|
160
|
+
cmd = [
|
|
161
|
+
binary,
|
|
162
|
+
f"--app={url}",
|
|
163
|
+
f"--user-data-dir={profile}",
|
|
164
|
+
f"--window-size={SIZE[0]},{SIZE[1]}",
|
|
165
|
+
"--no-first-run",
|
|
166
|
+
"--no-default-browser-check",
|
|
167
|
+
]
|
|
168
|
+
subprocess.run(cmd, check=False)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def run(args: argparse.Namespace) -> int:
|
|
172
|
+
from .serve import make_server, print_banner
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
server, cfg = make_server(args)
|
|
176
|
+
except (ValueError, OSError) as exc:
|
|
177
|
+
print(exc)
|
|
178
|
+
return 1
|
|
179
|
+
url = print_banner(server, cfg, args.host, TITLE)
|
|
180
|
+
thread = threading.Thread(target=server.serve_forever, name="lunar-server", daemon=True)
|
|
181
|
+
thread.start()
|
|
182
|
+
backend, detail = choose_backend(args.backend)
|
|
183
|
+
try:
|
|
184
|
+
if backend == "webview" and detail:
|
|
185
|
+
print(f" window : pywebview ({detail})")
|
|
186
|
+
try:
|
|
187
|
+
_run_webview(url, detail)
|
|
188
|
+
return 0
|
|
189
|
+
except Exception as exc: # no display, broken GUI toolkit, ...
|
|
190
|
+
print(f" pywebview failed ({exc}); trying a Chromium window")
|
|
191
|
+
backend, detail = choose_backend("chromium")
|
|
192
|
+
if backend == "chromium" and detail:
|
|
193
|
+
print(f" window : {Path(detail).name} --app")
|
|
194
|
+
_run_chromium(url, detail)
|
|
195
|
+
return 0
|
|
196
|
+
print(" window : none found (pip install PyQt6 PyQt6-WebEngine qtpy); opening a browser tab")
|
|
197
|
+
print(" Ctrl-C to stop.\n")
|
|
198
|
+
webbrowser.open(url)
|
|
199
|
+
while thread.is_alive():
|
|
200
|
+
thread.join(0.5)
|
|
201
|
+
except KeyboardInterrupt:
|
|
202
|
+
print("\nStopped.")
|
|
203
|
+
finally:
|
|
204
|
+
server.close()
|
|
205
|
+
return 0
|