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,312 @@
|
|
|
1
|
+
"""Run provenance for SfS work dirs (``provenance.json``): environment, inputs, steps and checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import platform
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Iterable, Iterator, Sequence
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import TYPE_CHECKING, Any
|
|
17
|
+
|
|
18
|
+
from .tools import find_tool, is_dry_run, record_commands, run, tool_env
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
21
|
+
from .config import RunConfig
|
|
22
|
+
|
|
23
|
+
SCHEMA_VERSION = 1
|
|
24
|
+
FILE_NAME = "provenance.json"
|
|
25
|
+
MD5_MAX_BYTES = 2 * 1024**3
|
|
26
|
+
|
|
27
|
+
# (key, tool, args): first output line is the version.
|
|
28
|
+
VERSION_COMMANDS = (
|
|
29
|
+
("asp", "sfs", ["--version"]),
|
|
30
|
+
("ale", "isd_generate", ["--version"]),
|
|
31
|
+
("gdal", "gdalinfo", ["--version"]),
|
|
32
|
+
)
|
|
33
|
+
KERNEL_GLOBS = {
|
|
34
|
+
"lsk": "base/kernels/lsk/naif*.tls",
|
|
35
|
+
"lro_sclk": "lro/kernels/sclk/lro_clkcor_*.tsc",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def now() -> str:
|
|
40
|
+
"""UTC time as ISO 8601 (seconds)."""
|
|
41
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _first_line(text: str) -> str | None:
|
|
45
|
+
lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
|
|
46
|
+
return lines[0] if lines else None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def tool_versions() -> dict[str, str | None]:
|
|
50
|
+
"""ASP, ALE, GDAL and ISIS versions (None when a tool is missing); empty under a dry run."""
|
|
51
|
+
if is_dry_run():
|
|
52
|
+
return {}
|
|
53
|
+
out: dict[str, str | None] = {}
|
|
54
|
+
for key, tool, args in VERSION_COMMANDS:
|
|
55
|
+
if find_tool(tool) is None:
|
|
56
|
+
out[key] = None
|
|
57
|
+
continue
|
|
58
|
+
res = run(tool, args, capture=True, check=False)
|
|
59
|
+
out[key] = _first_line(f"{res.stdout or ''}\n{res.stderr or ''}")
|
|
60
|
+
out["isis"] = isis_version(isis_paths().get("ISISROOT"))
|
|
61
|
+
return out
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def isis_paths() -> dict[str, str | None]:
|
|
65
|
+
"""``ISISROOT`` / ``ISISDATA`` as the tools see them (:func:`~lunar_toolkit.tools.tool_env`)."""
|
|
66
|
+
tool = find_tool("spiceinit") or find_tool("sfs")
|
|
67
|
+
env = tool_env(tool) if tool else dict(os.environ)
|
|
68
|
+
return {k: env.get(k) for k in ("ISISROOT", "ISISDATA")}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def isis_version(isisroot: str | None) -> str | None:
|
|
72
|
+
"""First field of ``$ISISROOT/isis_version.txt`` (e.g. ``10.0.0``)."""
|
|
73
|
+
if not isisroot:
|
|
74
|
+
return None
|
|
75
|
+
try:
|
|
76
|
+
text = (Path(isisroot) / "isis_version.txt").read_text()
|
|
77
|
+
except OSError:
|
|
78
|
+
return None
|
|
79
|
+
first = _first_line(text)
|
|
80
|
+
return first.split("#")[0].strip() if first else None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def kernel_files(isisdata: str | None) -> dict[str, list[str]]:
|
|
84
|
+
"""Names of the leap-second and LRO clock kernels under *isisdata*."""
|
|
85
|
+
if not isisdata:
|
|
86
|
+
return {}
|
|
87
|
+
root = Path(isisdata)
|
|
88
|
+
return {k: sorted(p.name for p in root.glob(pattern)) for k, pattern in KERNEL_GLOBS.items()}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def environment() -> dict[str, Any]:
|
|
92
|
+
"""Package, Python, platform, tool versions, ISIS paths and kernels."""
|
|
93
|
+
from .constants import APP_VERSION
|
|
94
|
+
|
|
95
|
+
paths = isis_paths()
|
|
96
|
+
return {
|
|
97
|
+
"lunar_toolkit": APP_VERSION,
|
|
98
|
+
"python": platform.python_version(),
|
|
99
|
+
"platform": platform.platform(),
|
|
100
|
+
"executable": sys.executable,
|
|
101
|
+
"tools": tool_versions(),
|
|
102
|
+
"isis_paths": paths,
|
|
103
|
+
"kernels": kernel_files(paths.get("ISISDATA")),
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def md5sum(path: Path, chunk: int = 1 << 20) -> str:
|
|
108
|
+
"""MD5 of *path*, read in *chunk*-byte pieces."""
|
|
109
|
+
h = hashlib.md5()
|
|
110
|
+
with open(path, "rb") as f:
|
|
111
|
+
while block := f.read(chunk):
|
|
112
|
+
h.update(block)
|
|
113
|
+
return h.hexdigest()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def file_record(
|
|
117
|
+
path: Path, checksum: bool = True, known: dict[str, Any] | None = None, max_bytes: int = MD5_MAX_BYTES
|
|
118
|
+
) -> dict[str, Any]:
|
|
119
|
+
"""``path``, ``size``, ``mtime`` and ``md5`` (None if skipped) of one input file.
|
|
120
|
+
|
|
121
|
+
The MD5 of *known* (an earlier record of the same file) is reused when size
|
|
122
|
+
and mtime match, so resumed runs do not hash again.
|
|
123
|
+
"""
|
|
124
|
+
p = Path(path)
|
|
125
|
+
rec: dict[str, Any] = {"path": str(p), "size": None, "mtime": None, "md5": None}
|
|
126
|
+
try:
|
|
127
|
+
st = p.stat()
|
|
128
|
+
except OSError:
|
|
129
|
+
return rec
|
|
130
|
+
rec.update(size=st.st_size, mtime=round(st.st_mtime, 3))
|
|
131
|
+
if known and known.get("size") == rec["size"] and known.get("mtime") == rec["mtime"]:
|
|
132
|
+
rec["md5"] = known.get("md5")
|
|
133
|
+
elif checksum and p.is_file() and st.st_size <= max_bytes:
|
|
134
|
+
rec["md5"] = md5sum(p)
|
|
135
|
+
return rec
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def read(path: Path) -> dict[str, Any]:
|
|
139
|
+
"""A ``provenance.json`` (an empty history when missing)."""
|
|
140
|
+
p = Path(path)
|
|
141
|
+
if not p.exists():
|
|
142
|
+
return {"schema": SCHEMA_VERSION, "runs": []}
|
|
143
|
+
return json.loads(p.read_text())
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _output_paths(result: Any) -> list[str]:
|
|
147
|
+
if isinstance(result, Path):
|
|
148
|
+
return [str(result)] if result.exists() else []
|
|
149
|
+
if isinstance(result, dict):
|
|
150
|
+
return _output_paths(list(result.values()))
|
|
151
|
+
if isinstance(result, (list, tuple)):
|
|
152
|
+
return [p for r in result for p in _output_paths(r)]
|
|
153
|
+
path = getattr(result, "mapprojected", None) or getattr(result, "cube", None)
|
|
154
|
+
return [str(path)] if isinstance(path, Path) and path.exists() else []
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class Provenance:
|
|
158
|
+
"""Append one run (environment, config, inputs, steps) to ``<work_dir>/provenance.json``.
|
|
159
|
+
|
|
160
|
+
Earlier runs are kept, so a resumed work dir holds its whole history. The
|
|
161
|
+
file is rewritten (atomically) after each step.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
def __init__(self, work_dir: Path, config: RunConfig, inputs: Iterable[Path], checksums: bool = True):
|
|
165
|
+
from .config import config_hash, to_dict
|
|
166
|
+
|
|
167
|
+
self.path = Path(work_dir) / FILE_NAME
|
|
168
|
+
self.data = read(self.path)
|
|
169
|
+
known = {r["path"]: r for r in last_run(self.data).get("inputs", [])}
|
|
170
|
+
self.run: dict[str, Any] = {
|
|
171
|
+
"started": now(),
|
|
172
|
+
"finished": None,
|
|
173
|
+
"status": "running",
|
|
174
|
+
**environment(),
|
|
175
|
+
"config_sha256": config_hash(config),
|
|
176
|
+
"config": to_dict(config),
|
|
177
|
+
"inputs": [file_record(p, checksums, known.get(str(p))) for p in inputs],
|
|
178
|
+
"steps": [],
|
|
179
|
+
}
|
|
180
|
+
self.data.setdefault("runs", []).append(self.run)
|
|
181
|
+
self.save()
|
|
182
|
+
|
|
183
|
+
def save(self) -> None:
|
|
184
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
185
|
+
tmp = self.path.with_suffix(".json.part")
|
|
186
|
+
tmp.write_text(json.dumps(self.data, indent=1))
|
|
187
|
+
os.replace(tmp, self.path)
|
|
188
|
+
|
|
189
|
+
@contextlib.contextmanager
|
|
190
|
+
def step(self, name: str) -> Iterator[dict[str, Any]]:
|
|
191
|
+
"""Record a step: times, the commands :func:`~lunar_toolkit.tools.run` executes and status.
|
|
192
|
+
|
|
193
|
+
Set ``entry["result"]`` inside the block to list its existing output files.
|
|
194
|
+
"""
|
|
195
|
+
entry: dict[str, Any] = {"name": name, "started": now(), "finished": None, "status": "running"}
|
|
196
|
+
self.run["steps"].append(entry)
|
|
197
|
+
self.save()
|
|
198
|
+
t0 = time.time()
|
|
199
|
+
with record_commands() as cmds:
|
|
200
|
+
try:
|
|
201
|
+
yield entry
|
|
202
|
+
except BaseException as exc:
|
|
203
|
+
entry.update(status="error", error=f"{type(exc).__name__}: {exc}")
|
|
204
|
+
raise
|
|
205
|
+
else:
|
|
206
|
+
entry["status"] = "ok"
|
|
207
|
+
finally:
|
|
208
|
+
entry.update(
|
|
209
|
+
finished=now(),
|
|
210
|
+
seconds=round(time.time() - t0, 3),
|
|
211
|
+
commands=cmds,
|
|
212
|
+
outputs=_output_paths(entry.pop("result", None)),
|
|
213
|
+
)
|
|
214
|
+
self.save()
|
|
215
|
+
|
|
216
|
+
def finish(self, status: str = "ok") -> None:
|
|
217
|
+
self.run.update(finished=now(), status=status)
|
|
218
|
+
self.save()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def last_run(data: dict[str, Any]) -> dict[str, Any]:
|
|
222
|
+
runs = data.get("runs") or []
|
|
223
|
+
return runs[-1] if runs else {}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@dataclass(frozen=True)
|
|
227
|
+
class Difference:
|
|
228
|
+
"""One mismatch between a recorded run and now: *kind* (``environment``, ``config``, ``input``)."""
|
|
229
|
+
|
|
230
|
+
kind: str
|
|
231
|
+
key: str
|
|
232
|
+
recorded: Any
|
|
233
|
+
current: Any
|
|
234
|
+
|
|
235
|
+
def __str__(self) -> str:
|
|
236
|
+
return f"{self.kind:11s} {self.key}: {self.recorded!r} -> {self.current!r}"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _flatten(d: Any, prefix: str = "") -> dict[str, Any]:
|
|
240
|
+
if not isinstance(d, dict):
|
|
241
|
+
return {prefix: d}
|
|
242
|
+
out: dict[str, Any] = {}
|
|
243
|
+
for k, v in d.items():
|
|
244
|
+
out.update(_flatten(v, f"{prefix}.{k}" if prefix else str(k)))
|
|
245
|
+
return out
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def compare(
|
|
249
|
+
recorded: dict[str, Any],
|
|
250
|
+
current_env: dict[str, Any],
|
|
251
|
+
current_config: dict[str, Any] | None = None,
|
|
252
|
+
inputs: Sequence[Path] | None = None,
|
|
253
|
+
checksums: bool = True,
|
|
254
|
+
) -> list[Difference]:
|
|
255
|
+
"""Differences between a recorded run and the current environment, config and inputs.
|
|
256
|
+
|
|
257
|
+
*inputs* default to the recorded input paths; a file whose size or mtime
|
|
258
|
+
changed is hashed again and reported only when its MD5 differs (or cannot be
|
|
259
|
+
compared).
|
|
260
|
+
"""
|
|
261
|
+
diffs: list[Difference] = []
|
|
262
|
+
keys = ("lunar_toolkit", "python", "tools", "isis_paths", "kernels")
|
|
263
|
+
rec_env = _flatten({k: recorded.get(k) for k in keys})
|
|
264
|
+
cur_env = _flatten({k: current_env.get(k) for k in keys})
|
|
265
|
+
for key in sorted(set(rec_env) | set(cur_env)):
|
|
266
|
+
if rec_env.get(key) != cur_env.get(key):
|
|
267
|
+
diffs.append(Difference("environment", key, rec_env.get(key), cur_env.get(key)))
|
|
268
|
+
if current_config is not None:
|
|
269
|
+
rec_cfg = _flatten(recorded.get("config") or {})
|
|
270
|
+
cur_cfg = _flatten(current_config)
|
|
271
|
+
for key in sorted(set(rec_cfg) | set(cur_cfg)):
|
|
272
|
+
if rec_cfg.get(key) != cur_cfg.get(key):
|
|
273
|
+
diffs.append(Difference("config", key, rec_cfg.get(key), cur_cfg.get(key)))
|
|
274
|
+
known = {r["path"]: r for r in recorded.get("inputs", [])}
|
|
275
|
+
paths = [str(p) for p in inputs] if inputs is not None else list(known)
|
|
276
|
+
for p in paths:
|
|
277
|
+
old = known.get(p)
|
|
278
|
+
if old is None:
|
|
279
|
+
diffs.append(Difference("input", p, None, "added"))
|
|
280
|
+
continue
|
|
281
|
+
new = file_record(Path(p), checksum=False)
|
|
282
|
+
if new["size"] is None:
|
|
283
|
+
diffs.append(Difference("input", p, old.get("md5") or old.get("size"), "missing"))
|
|
284
|
+
elif (new["size"], new["mtime"]) != (old.get("size"), old.get("mtime")):
|
|
285
|
+
md5 = md5sum(Path(p)) if checksums and old.get("md5") and new["size"] <= MD5_MAX_BYTES else None
|
|
286
|
+
if md5 is None or md5 != old.get("md5"):
|
|
287
|
+
diffs.append(Difference("input", p, old.get("md5") or old.get("size"), md5 or new["size"]))
|
|
288
|
+
for p in sorted(set(known) - set(paths)):
|
|
289
|
+
diffs.append(Difference("input", p, "used", "not an input now"))
|
|
290
|
+
return diffs
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def check(
|
|
294
|
+
work_dir: Path,
|
|
295
|
+
config: RunConfig | None = None,
|
|
296
|
+
inputs: Sequence[Path] | None = None,
|
|
297
|
+
checksums: bool = True,
|
|
298
|
+
) -> list[Difference]:
|
|
299
|
+
"""Compare the last run in ``<work_dir>/provenance.json`` with the current environment.
|
|
300
|
+
|
|
301
|
+
Raises
|
|
302
|
+
------
|
|
303
|
+
FileNotFoundError
|
|
304
|
+
When the work dir has no provenance yet.
|
|
305
|
+
"""
|
|
306
|
+
from .config import to_dict
|
|
307
|
+
|
|
308
|
+
path = Path(work_dir) / FILE_NAME
|
|
309
|
+
if not path.exists():
|
|
310
|
+
raise FileNotFoundError(f"No {FILE_NAME} in {work_dir}")
|
|
311
|
+
recorded = last_run(read(path))
|
|
312
|
+
return compare(recorded, environment(), to_dict(config) if config else None, inputs, checksums)
|