Coverage for src/footman/_progress.py: 95%

207 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-24 13:38 +0000

1"""Duration history and the run estimate behind the progress bar. 

2 

3Runs of the same invocation shape — the same chain with the same values and 

4passthrough, serial or parallel, in the same directory — tend to take 

5similar time. The store keeps the recent wall totals per shape in 

6`<footman cache>/<cwd-key>.times.json`; the estimator turns them into a 

7determinate expectation only when the history genuinely supports one: 

8enough samples, and a controlled right tail. Anything less honest renders 

9as the indeterminate pulse instead. 

10 

11Execution-path only, stdlib only. A missing, corrupt, or read-only store 

12never fails (or even warns about) a run — timing is a nicety, never load 

13bearing. 

14""" 

15 

16from __future__ import annotations 

17 

18import hashlib 

19import json 

20import os 

21import shutil 

22import statistics 

23import threading 

24import time 

25from dataclasses import dataclass 

26from pathlib import Path 

27from typing import TextIO 

28 

29from footman import _paths 

30from footman.split import Segment 

31 

32SCHEMA = 1 

33WINDOW = 50 # samples kept per chain — recency policy, sliding 

34MAX_KEYS = 200 # chains kept per directory 

35IDLE_DAYS = 60 # a chain not run for this long is forgotten 

36MIN_SAMPLES = 5 # fewer → indeterminate 

37TAIL_RATIO = 1.8 # p90 beyond this multiple of p50 → too erratic 

38 

39 

40def default_jobs() -> int: 

41 """The parallel width when nobody chose one: cores - 1, never below 2 — 

42 the machine stays responsive, the fan-out stays real.""" 

43 return max(2, (os.cpu_count() or 3) - 1) 

44 

45 

46def chain_key(segments: list[Segment], *, sequential: bool, jobs: int) -> str: 

47 """A stable hash of the invocation shape. 

48 

49 Values and passthrough are part of the shape on purpose — `fm test -- 

50 -k one` is not `fm test` — and serial/parallel/width variants of the 

51 same chain keep separate histories (different distributions entirely). 

52 """ 

53 shape = [ 

54 { 

55 "task": s.task, 

56 "values": s.values, 

57 "variadic": s.variadic, 

58 "passthrough": s.passthrough, 

59 } 

60 for s in segments 

61 ] 

62 payload = json.dumps( 

63 {"sequential": sequential, "jobs": jobs, "chain": shape}, 

64 sort_keys=True, 

65 default=str, 

66 ) 

67 return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16] 

68 

69 

70@dataclass(frozen=True) 

71class Estimate: 

72 """A determinate expectation for one run. 

73 

74 The bar fills against *scale* (p90 — it usually completes just before 

75 the end and clamps rather than overrunning); the label quotes 

76 *typical* (p50). 

77 """ 

78 

79 typical: float 

80 scale: float 

81 

82 

83def estimate(runs: list[float]) -> Estimate | None: 

84 """A determinate estimate from *runs*, or None when honesty forbids one.""" 

85 if len(runs) < MIN_SAMPLES: 

86 return None 

87 deciles = statistics.quantiles(runs, n=10, method="inclusive") 

88 p50, p90 = statistics.median(runs), deciles[8] 

89 if p50 <= 0 or p90 > TAIL_RATIO * p50: 

90 return None # erratic history: an "estimate" would be a guess 

91 return Estimate(typical=p50, scale=max(p90, 0.1)) 

92 

93 

94# --- the store --------------------------------------------------------------- 

95 

96 

97def load_runs(cwd: Path, key: str) -> list[float]: 

98 """The recent durations for *key*, oldest first; [] when unknown.""" 

99 entry = _load(cwd).get("chains", {}).get(key) 

100 if not isinstance(entry, dict): 

101 return [] 

102 runs = entry.get("runs") 

103 if not isinstance(runs, list): 103 ↛ 104line 103 didn't jump to line 104 because the condition on line 103 was never true

104 return [] 

105 return [float(r) for r in runs if isinstance(r, (int, float))] 

106 

107 

108def load_cmd_width(cwd: Path, key: str) -> int: 

109 """The widest command label of *key*'s previous run — step-line 

110 alignment is right from the first line on a warm run.""" 

111 entry = _load(cwd).get("chains", {}).get(key) 

112 width = entry.get("cmd") if isinstance(entry, dict) else 0 

113 return width if isinstance(width, int) and width > 0 else 0 

114 

115 

116def record(cwd: Path, key: str, seconds: float, cmd_width: int = 0) -> None: 

117 """Append one green run's wall total; prune idle chains, cap sizes. 

118 

119 Best-effort by contract: an unwritable cache directory must never fail 

120 the run that just succeeded. 

121 """ 

122 now = time.time() 

123 data = _load(cwd) 

124 chains = data.get("chains") 

125 if not isinstance(chains, dict): 

126 chains = {} 

127 entry = chains.get(key) 

128 old = entry.get("runs") if isinstance(entry, dict) else None 

129 runs = [r for r in old if isinstance(r, (int, float))] if old else [] 

130 runs.append(round(float(seconds), 3)) 

131 old_cmd = entry.get("cmd") if isinstance(entry, dict) else 0 

132 chains[key] = {"last": now, "runs": runs[-WINDOW:]} 

133 # The widest step label, for next run's alignment — a width-less record 

134 # keeps what the chain already knew. 

135 width = cmd_width if cmd_width > 0 else old_cmd 

136 if isinstance(width, int) and width > 0: 

137 chains[key]["cmd"] = width 

138 

139 horizon = now - IDLE_DAYS * 86400 

140 chains = { 

141 k: v 

142 for k, v in chains.items() 

143 if isinstance(v, dict) and v.get("last", 0) >= horizon 

144 } 

145 if len(chains) > MAX_KEYS: 

146 for stale in sorted(chains, key=lambda k: chains[k].get("last", 0))[ 

147 : len(chains) - MAX_KEYS 

148 ]: 

149 del chains[stale] 

150 

151 path = _paths.times_path(cwd) 

152 try: 

153 # Atomic, like the manifest write: concurrent fm runs (a hook's 

154 # `fm check` racing yours) may lose each other's *sample* — a 

155 # nicety — but a torn file would lose the whole history. 

156 path.parent.mkdir(parents=True, exist_ok=True) 

157 tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp") 

158 tmp.write_text( 

159 json.dumps({"schema": SCHEMA, "chains": chains}), encoding="utf-8" 

160 ) 

161 os.replace(tmp, path) 

162 except OSError: 

163 pass 

164 

165 

166def _load(cwd: Path) -> dict: 

167 try: 

168 data = json.loads(_paths.times_path(cwd).read_text(encoding="utf-8")) 

169 except (OSError, ValueError): 

170 return {} 

171 return data if isinstance(data, dict) else {} 

172 

173 

174# --- the status line --------------------------------------------------------- 

175 

176_CLEAR = "\r\033[K" 

177_BAR_CELLS = 15 

178_PULSE_CELLS = 3 

179 

180 

181def fmt_secs(t: float) -> str: 

182 """`4.1s`, `42s`, `1m10s`, `4h35m` — as short as honesty allows.""" 

183 if t >= 3600: 183 ↛ 184line 183 didn't jump to line 184 because the condition on line 183 was never true

184 return f"{int(t) // 3600}h{(int(t) % 3600) // 60:02d}m" 

185 if t >= 60: 

186 return f"{int(t) // 60}m{int(t) % 60:02d}s" 

187 return f"{t:.0f}s" if t >= 9.5 else f"{t:.1f}s" 

188 

189 

190class StatusLine: 

191 """The one live line for a run, drawn on the real stderr. 

192 

193 Fed by *both* parallel engines — scheduler nodes and `parallel()` 

194 children are the same kind of unit — so a chain and a task-body 

195 fan-out present identically. Determinate runs fill a bar against the 

196 estimate's p90 (clamped at 98% until the run truly ends); anything 

197 else pulses, with elapsed time either way. 

198 

199 Coexistence contract: the output routers report every real-terminal 

200 write via `notify()`, which clears a painted line first and tracks 

201 whether the terminal now sits at column 0. The ticker (and every 

202 event repaint) paints **only at column 0**, so `run()`'s in-place 

203 step rewrites and flushed blocks are never corrupted. The line writes 

204 straight to the real stream, never through the routers. 

205 """ 

206 

207 def __init__( 

208 self, err: TextIO, est: Estimate | None, *, color: bool = True 

209 ) -> None: 

210 self.err = err 

211 self.est = est 

212 self.color = color 

213 self.lock = threading.RLock() 

214 self.started = time.perf_counter() 

215 self.total = 0 

216 self.done = 0 

217 self.failed = 0 

218 self.running: list[str] = [] # a list: anonymous thunks may collide 

219 # Counted progress: what running tasks have *reported* about their 

220 # own work (name -> fraction). Counted beats estimated — a task 

221 # that knows it is 23/150 through is better evidence than any 

222 # history — so a reported fraction outranks the time-based fill. 

223 self.counted: dict[str, tuple[int, int]] = {} 

224 self.painted = False 

225 self.at_col0 = True 

226 self.ticks = 0 

227 self._stop = threading.Event() 

228 self._ticker: threading.Thread | None = None 

229 

230 # -- lifecycle 

231 def open(self) -> None: 

232 """Start the repaint ticker (elapsed must move without events).""" 

233 self._ticker = threading.Thread(target=self._run_ticker, daemon=True) 

234 self._ticker.start() 

235 

236 def close(self) -> None: 

237 self._stop.set() 

238 if self._ticker is not None: 

239 self._ticker.join(timeout=1.0) 

240 with self.lock: 

241 self._clear_locked() 

242 

243 def _run_ticker(self) -> None: 

244 while not self._stop.wait(0.2): 244 ↛ 245line 244 didn't jump to line 245 because the condition on line 244 was never true

245 self.paint() 

246 

247 # -- the engine feed (scheduler nodes and parallel() children alike) 

248 def unit_added(self, count: int = 1) -> None: 

249 with self.lock: 

250 self.total += count 

251 

252 def unit_started(self, name: str) -> None: 

253 with self.lock: 

254 self.running.append(name) 

255 self.paint() 

256 

257 def unit_counted(self, name: str, done: int, total: int) -> None: 

258 """A running task reporting its own progress: `done` of `total`.""" 

259 with self.lock: 

260 self.counted[name] = (done, total) 

261 self.paint() 

262 

263 def unit_finished(self, name: str, ok: bool) -> None: 

264 with self.lock: 

265 if name in self.running: 

266 self.running.remove(name) 

267 self.counted.pop(name, None) 

268 self.done += 1 

269 self.failed += 0 if ok else 1 

270 self.paint() 

271 

272 def unit_skipped(self, name: str) -> None: 

273 """A dependent of a failure never ran: done for counting purposes — 

274 the failure that caused it already counted itself.""" 

275 with self.lock: 

276 self.done += 1 

277 self.paint() 

278 

279 # -- the router feed 

280 def notify(self, s: str) -> None: 

281 """A real-terminal write is about to land: get out of its way.""" 

282 if not s: 

283 return 

284 with self.lock: 

285 self._clear_locked() 

286 self.at_col0 = s.endswith("\n") 

287 

288 # -- painting 

289 def paint(self) -> None: 

290 with self.lock: 

291 if not self.at_col0: 

292 return # someone's mid-line (a live `→ step`): stay away 

293 self.ticks += 1 

294 self.err.write(f"{_CLEAR}{self._render()}") 

295 self.err.flush() 

296 self.painted = True 

297 

298 def _clear_locked(self) -> None: 

299 if self.painted: 

300 self.err.write(_CLEAR) 

301 self.err.flush() 

302 self.painted = False 

303 

304 def _counted_fraction(self) -> tuple[float, str] | None: 

305 """The run's progress from *reported* work, if any task reported. 

306 

307 A reporting task contributes a fractional unit — three tasks done 

308 and a fourth 23/150 through is 3.15/4, not 3/4 — so a run of 

309 reporters fills smoothly and a mixed run is smooth where it can 

310 be. Returns `(fraction, label)`, or `None` when nobody reported. 

311 """ 

312 if not self.counted or self.total <= 0: 

313 return None 

314 partial = 0.0 

315 for done, total in self.counted.values(): 

316 if total > 0: 316 ↛ 315line 316 didn't jump to line 315 because the condition on line 316 was always true

317 partial += min(done / total, 1.0) 

318 fraction = min((self.done + partial) / self.total, 1.0) 

319 if len(self.counted) == 1: # one reporter: show its own counts 319 ↛ 322line 319 didn't jump to line 322 because the condition on line 319 was always true

320 done, total = next(iter(self.counted.values())) 

321 return fraction, f" {done}/{total}" 

322 return fraction, f" {fraction * 100:.0f}%" 

323 

324 def _render(self) -> str: 

325 elapsed = time.perf_counter() - self.started 

326 if (counted := self._counted_fraction()) is not None: 

327 fraction, counts = counted 

328 filled = int(fraction * _BAR_CELLS) 

329 bar = "█" * filled + "░" * (_BAR_CELLS - filled) 

330 label = f" {fmt_secs(elapsed)}{counts}" 

331 elif self.est is not None: 

332 frac = min(elapsed / self.est.scale, 0.98) 

333 filled = int(frac * _BAR_CELLS) 

334 bar = "█" * filled + "░" * (_BAR_CELLS - filled) 

335 label = f" {fmt_secs(elapsed)} ~{fmt_secs(self.est.typical)}" 

336 else: # indeterminate: a bouncing pulse 

337 span = _BAR_CELLS - _PULSE_CELLS 

338 bounce = self.ticks % (2 * span) 

339 pos = bounce if bounce <= span else 2 * span - bounce 

340 bar = "░" * pos + "█" * _PULSE_CELLS + "░" * (span - pos) 

341 label = f" {fmt_secs(elapsed)}" 

342 line = f"[{bar}]{label}" 

343 if self.total > 1: 

344 line += f" {self.done}/{self.total}" 

345 if self.failed: 

346 text = f"{self.failed} failed" 

347 if self.color: 347 ↛ 348line 347 didn't jump to line 348 because the condition on line 347 was never true

348 text = f"\033[31m{text}\033[0m" 

349 line += f" ({text})" 

350 if self.running: 

351 names = ", ".join(list(self.running)[:4]) 

352 if len(self.running) > 4: 352 ↛ 353line 352 didn't jump to line 353 because the condition on line 352 was never true

353 names += " ..." 

354 line += f" running: {names}" 

355 width = shutil.get_terminal_size((80, 24)).columns - 1 

356 return line if len(line) <= width else line[:width]