Coverage for lunar_toolkit/provenance.py: 95%

179 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-19 02:58 +0000

1"""Run provenance for SfS work dirs (``provenance.json``): environment, inputs, steps and checks.""" 

2 

3from __future__ import annotations 

4 

5import contextlib 

6import hashlib 

7import json 

8import os 

9import platform 

10import sys 

11import time 

12from collections.abc import Iterable, Iterator, Sequence 

13from dataclasses import dataclass 

14from datetime import datetime, timezone 

15from pathlib import Path 

16from typing import TYPE_CHECKING, Any 

17 

18from .tools import find_tool, is_dry_run, record_commands, run, tool_env 

19 

20if TYPE_CHECKING: # pragma: no cover 

21 from .config import RunConfig 

22 

23SCHEMA_VERSION = 1 

24FILE_NAME = "provenance.json" 

25MD5_MAX_BYTES = 2 * 1024**3 

26 

27# (key, tool, args): first output line is the version. 

28VERSION_COMMANDS = ( 

29 ("asp", "sfs", ["--version"]), 

30 ("ale", "isd_generate", ["--version"]), 

31 ("gdal", "gdalinfo", ["--version"]), 

32) 

33KERNEL_GLOBS = { 

34 "lsk": "base/kernels/lsk/naif*.tls", 

35 "lro_sclk": "lro/kernels/sclk/lro_clkcor_*.tsc", 

36} 

37 

38 

39def now() -> str: 

40 """UTC time as ISO 8601 (seconds).""" 

41 return datetime.now(timezone.utc).isoformat(timespec="seconds") 

42 

43 

44def _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 

49def 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 

64def isis_paths() -> dict[str, str | None]: 

65 """``ISISROOT`` / ``ISISDATA`` as the tools see them ([`tool_env`][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 

71def 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 

83def 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 

91def 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 

107def 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 

116def 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 

138def 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 

146def _output_paths(result: Any) -> list[str]: 

147 if isinstance(result, Path): 

148 return [str(result)] if result.exists() else [] 

149 if isinstance(result, dict): 149 ↛ 150line 149 didn't jump to line 150 because the condition on line 149 was never true

150 return _output_paths(list(result.values())) 

151 if isinstance(result, (list, tuple)): 151 ↛ 152line 151 didn't jump to line 152 because the condition on line 151 was never true

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 

157class 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 [`run`][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 

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

227class 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 

239def _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 

248def 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: 278 ↛ 279line 278 didn't jump to line 279 because the condition on line 278 was never true

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"): 286 ↛ 276line 286 didn't jump to line 276 because the condition on line 286 was always true

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)): 288 ↛ 289line 288 didn't jump to line 289 because the loop on line 288 never started

289 diffs.append(Difference("input", p, "used", "not an input now")) 

290 return diffs 

291 

292 

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