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
lunar_toolkit/tools.py ADDED
@@ -0,0 +1,264 @@
1
+ """Find and run ISIS / ASP / GDAL tools; keyword options → flags; dry runs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import contextvars
7
+ import json
8
+ import logging
9
+ import os
10
+ import shlex
11
+ import shutil
12
+ import subprocess
13
+ import time
14
+ from collections.abc import Iterator, Mapping, Sequence
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ log = logging.getLogger(__name__)
19
+
20
+ TOOL_ROOT_ENV_VARS = ("ASP_ROOT", "ISISROOT")
21
+
22
+ _DRY_RUN: contextvars.ContextVar[list[list[str]] | None] = contextvars.ContextVar("dry_run", default=None)
23
+ _COMMAND_LOG: contextvars.ContextVar[list[dict[str, Any]] | None] = contextvars.ContextVar(
24
+ "command_log", default=None
25
+ )
26
+
27
+
28
+ class ToolError(RuntimeError):
29
+ """An external tool is missing or exited with a non-zero status."""
30
+
31
+ def __init__(
32
+ self,
33
+ message: str,
34
+ returncode: int | None = None,
35
+ stdout: str | None = None,
36
+ stderr: str | None = None,
37
+ ) -> None:
38
+ super().__init__(message)
39
+ self.returncode = returncode
40
+ self.stdout = stdout
41
+ self.stderr = stderr
42
+
43
+
44
+ def find_tool(name: str) -> Path | None:
45
+ """Path of executable *name*, or None when it cannot be found."""
46
+ for var in TOOL_ROOT_ENV_VARS:
47
+ root = os.environ.get(var)
48
+ if root:
49
+ p = Path(root) / "bin" / name
50
+ if p.is_file() and os.access(p, os.X_OK):
51
+ return p
52
+ found = shutil.which(name)
53
+ return Path(found) if found else None
54
+
55
+
56
+ def tool_env(tool_path: Path) -> dict[str, str]:
57
+ """Environment for *tool_path* as a conda activation would set it.
58
+
59
+ Unset variables come from the env's ``conda env config vars`` (stored in
60
+ ``conda-meta/state``), else ``ISISROOT``, ``ISISDATA``, ``PROJ_DATA`` and
61
+ ``GDAL_DATA`` default to the tool's environment prefix, so tools also run
62
+ from an env that was not activated (ASP and GDAL fail or crash without them).
63
+ """
64
+ env = dict(os.environ)
65
+ prefix = Path(tool_path).parent.parent
66
+ for name, setting in _conda_env_vars(prefix).items():
67
+ env.setdefault(name, setting)
68
+ defaults = {
69
+ "ISISROOT": (prefix, prefix / "IsisPreferences"),
70
+ "ISISDATA": (prefix / "data", prefix / "IsisPreferences"),
71
+ "PROJ_DATA": (prefix / "share" / "proj", prefix / "share" / "proj" / "proj.db"),
72
+ "GDAL_DATA": (prefix / "share" / "gdal", prefix / "share" / "gdal"),
73
+ }
74
+ for var, (value, marker) in defaults.items():
75
+ if not env.get(var) and marker.exists():
76
+ env[var] = str(value)
77
+ # ASP's Python wrappers (mapproject, parallel_*) run `env python` and
78
+ # call sibling tools, so the tool's bin dir must lead PATH.
79
+ bin_dir = str(Path(tool_path).parent)
80
+ path = env.get("PATH", "")
81
+ if path.split(os.pathsep)[0] != bin_dir:
82
+ env["PATH"] = os.pathsep.join([bin_dir, path]) if path else bin_dir
83
+ return env
84
+
85
+
86
+ def _conda_env_vars(prefix: Path) -> dict[str, str]:
87
+ try:
88
+ state = json.loads((prefix / "conda-meta" / "state").read_text())
89
+ except (OSError, ValueError):
90
+ return {}
91
+ env_vars = state.get("env_vars") if isinstance(state, dict) else None
92
+ return {str(k): str(v) for k, v in env_vars.items()} if isinstance(env_vars, dict) else {}
93
+
94
+
95
+ def format_command(argv: Sequence[str]) -> str:
96
+ """*argv* as one command line, quoted for the platform's shell."""
97
+ return subprocess.list2cmdline(argv) if os.name == "nt" else shlex.join(argv)
98
+
99
+
100
+ @contextlib.contextmanager
101
+ def dry_run() -> Iterator[list[list[str]]]:
102
+ """Record commands instead of running them.
103
+
104
+ Yields the list the commands are appended to (tool name first)::
105
+
106
+ with dry_run() as cmds:
107
+ pipeline.run(...)
108
+ for c in cmds:
109
+ print(format_command(c))
110
+ """
111
+ cmds: list[list[str]] = []
112
+ token = _DRY_RUN.set(cmds)
113
+ try:
114
+ yield cmds
115
+ finally:
116
+ _DRY_RUN.reset(token)
117
+
118
+
119
+ @contextlib.contextmanager
120
+ def record_commands() -> Iterator[list[dict[str, Any]]]:
121
+ """Log the commands :func:`run` executes: ``argv``, ``cwd``, ``started`` (Unix time), ``seconds``, ``returncode``."""
122
+ log_: list[dict[str, Any]] = []
123
+ token = _COMMAND_LOG.set(log_)
124
+ try:
125
+ yield log_
126
+ finally:
127
+ _COMMAND_LOG.reset(token)
128
+
129
+
130
+ def is_dry_run() -> bool:
131
+ return _DRY_RUN.get() is not None
132
+
133
+
134
+ def reuse_existing(path: Path, force: bool = False) -> bool:
135
+ """Whether a step can be skipped because its output *path* already exists.
136
+
137
+ Parameters
138
+ ----------
139
+ path : Path
140
+ The step's output.
141
+ force : bool
142
+ Never reuse; the step is always redone.
143
+
144
+ Returns
145
+ -------
146
+ bool
147
+ True (and logged) when *path* exists and *force* is False.
148
+ """
149
+ if Path(path).exists() and not force:
150
+ log.info("Reusing %s", path)
151
+ return True
152
+ return False
153
+
154
+
155
+ def run(
156
+ tool: str,
157
+ args: Sequence[Any] = (),
158
+ *,
159
+ cwd: Path | None = None,
160
+ capture: bool = False,
161
+ check: bool = True,
162
+ ) -> subprocess.CompletedProcess:
163
+ """Run *tool* with *args*; raise :class:`ToolError` on failure.
164
+
165
+ With ``capture=True`` stdout/stderr are returned instead of streamed.
166
+ """
167
+ argv = [str(a) for a in args]
168
+ recorded = _DRY_RUN.get()
169
+ if recorded is not None:
170
+ recorded.append([tool, *argv])
171
+ log.info("[dry-run] %s", format_command([tool, *argv]))
172
+ return subprocess.CompletedProcess([tool, *argv], 0, "", "")
173
+
174
+ path = find_tool(tool)
175
+ if path is None:
176
+ raise ToolError(f"{tool} not found; activate the ISIS / ASP environment or set ASP_ROOT / ISISROOT")
177
+ log.info("▶ %s", tool)
178
+ log.debug(" cmd: %s", format_command([str(path), *argv]))
179
+ start = time.time()
180
+ result = subprocess.run(
181
+ [str(path), *argv], cwd=cwd, env=tool_env(path), text=True, capture_output=capture
182
+ )
183
+ commands = _COMMAND_LOG.get()
184
+ if commands is not None:
185
+ commands.append(
186
+ {
187
+ "argv": [str(path), *argv],
188
+ "cwd": str(cwd) if cwd else None,
189
+ "started": start,
190
+ "seconds": round(time.time() - start, 3),
191
+ "returncode": result.returncode,
192
+ }
193
+ )
194
+ if check and result.returncode != 0:
195
+ tail = (result.stderr or result.stdout or "").strip().splitlines()[-5:]
196
+ detail = ("\n" + "\n".join(tail)) if capture and tail else " Check output above."
197
+ raise ToolError(
198
+ f"{tool} failed (exit {result.returncode}).{detail}",
199
+ result.returncode,
200
+ result.stdout,
201
+ result.stderr,
202
+ )
203
+ log.info("✔ %s complete", tool)
204
+ return result
205
+
206
+
207
+ def asp_options(options: Mapping[str, Any]) -> list[str]:
208
+ """Keyword options → ASP-style flags.
209
+
210
+ ``tr=5`` → ``--tr 5``; ``save_sparingly=True`` → ``--save-sparingly``;
211
+ ``False`` / ``None`` are dropped; tuples and lists expand to several
212
+ arguments (``t_projwin=(x0, y0, x1, y1)``) while a string with spaces stays
213
+ one argument (``remove_outliers_params="75 3 100 100"``). Underscores
214
+ become dashes except in GDAL-style ``t_*`` options (``--t_srs``); single
215
+ letters get one dash (``o="run/run"`` → ``-o run/run``).
216
+ """
217
+ args: list[str] = []
218
+ for key, value in options.items():
219
+ if value is None or value is False:
220
+ continue
221
+ name = key if key.startswith("t_") else key.replace("_", "-")
222
+ flag = f"-{name}" if len(name) == 1 else f"--{name}"
223
+ args += [flag, *_values(value)]
224
+ return args
225
+
226
+
227
+ def gdal_options(options: Mapping[str, Any]) -> list[str]:
228
+ """Keyword options → GDAL-style flags (``a_nodata=0`` → ``-a_nodata 0``)."""
229
+ args: list[str] = []
230
+ for key, value in options.items():
231
+ if value is None or value is False:
232
+ continue
233
+ args += [f"-{key}", *_values(value)]
234
+ return args
235
+
236
+
237
+ def isis_params(params: Mapping[str, Any]) -> list[str]:
238
+ """Keyword parameters → ISIS ``key=value`` arguments.
239
+
240
+ A trailing underscore is dropped (``from_=a.cub`` → ``from=a.cub``),
241
+ booleans become ``true`` / ``false`` and ``None`` is skipped.
242
+ """
243
+ args: list[str] = []
244
+ for key, value in params.items():
245
+ if value is None:
246
+ continue
247
+ if isinstance(value, bool):
248
+ value = "true" if value else "false"
249
+ args.append(f"{key.rstrip('_')}={value}")
250
+ return args
251
+
252
+
253
+ def _values(value: Any) -> list[str]:
254
+ if value is True:
255
+ return []
256
+ if isinstance(value, (list, tuple)):
257
+ return [_fmt(v) for v in value]
258
+ return [_fmt(value)]
259
+
260
+
261
+ def _fmt(value: Any) -> str:
262
+ if isinstance(value, float) and value.is_integer():
263
+ return str(int(value))
264
+ return str(value)