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
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)
|