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,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
@@ -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