Coverage for src/footman/context.py: 94%

1510 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-06 02:59 +0000

1"""The run context, the `run()` helper, and `parallel()`. 

2 

3A task never *needs* a context parameter: `run()` reads the current context 

4from a contextvar footman sets around each running task, so a task body can just 

5call `run("ruff check src")`. A task MAY declare a first parameter named 

6`ctx` (or annotated `Context`) to get the object explicitly. 

7 

8Output is routed through the context so parallel tasks don't interleave: a global 

9`sys.stdout` proxy dispatches every write to the running task's `sink`. In 

10sequential mode a task's sink is the real stdout (live); in parallel mode it is a 

11per-task buffer, flushed atomically when the task finishes. 

12""" 

13 

14from __future__ import annotations 

15 

16import contextlib 

17import functools 

18import inspect 

19import io 

20import os 

21import shlex 

22import signal 

23import subprocess 

24import sys 

25import threading 

26import time 

27from collections.abc import ( 

28 Callable, 

29 Generator, 

30 Iterable, 

31 Iterator, 

32 Mapping, 

33 Sequence, 

34 Sized, 

35) 

36from contextvars import ContextVar 

37from dataclasses import dataclass, field, replace 

38from datetime import datetime 

39from pathlib import Path 

40from typing import ( 

41 TYPE_CHECKING, 

42 Any, 

43 ClassVar, 

44 Literal, 

45 NamedTuple, 

46 NoReturn, 

47 Protocol, 

48 TextIO, 

49 TypeAlias, 

50 TypeVar, 

51 overload, 

52) 

53 

54from footman import _globals 

55 

56if TYPE_CHECKING: 

57 from footman import _step 

58 from footman import registry as _registry_t 

59 

60 

61class AuditEntry(NamedTuple): 

62 """One entry of a record's audit: a lifecycle moment that acted on the 

63 verdict — who acted, and what they set. 

64 

65 The body entry is always present and carries what the work itself 

66 produced; a review entry names the reviewer, with `code` None when it 

67 was involved without writing the verdict (a reviewer that only set the 

68 title). Entries are in execution order. 

69 """ 

70 

71 moment: str 

72 """Where in the record's lifecycle this happened: `"body"` for the work 

73 itself, `"review"` for a `pre_record` reviewer.""" 

74 actor: str 

75 """Who acted: the command line for the body, the hook's name for a 

76 reviewer.""" 

77 code: int | None 

78 """The code this moment left, or None for involvement without a verdict 

79 write.""" 

80 

81 

82_Moment: TypeAlias = Literal["bind", "enter", "body", "review", "observe"] 

83"""The lifecycle vocabulary, closed at the PRODUCER side only: the public 

84fields stay open strings (the `state` precedent — readers tolerate values 

85they don't know), while footman's own write sites go through 

86`_audit_entry`, so a misspelt moment in framework code is a type error 

87before it is a test failure.""" 

88 

89 

90def _audit_entry(moment: _Moment, actor: str, code: int | None) -> AuditEntry: 

91 """The one door framework code writes audit entries through.""" 

92 return AuditEntry(moment, actor, code) 

93 

94 

95class Argv(list[str]): 

96 """A command line built but not run — what `Result.to_argv()` hands back 

97 (and what a builder like toolroom's `git.push.argv(force=True)` makes). 

98 

99 An ordinary `list[str]` everywhere in Python: it indexes, slices, 

100 iterates, compares and prints exactly like the list it is, and `run(cmd)` 

101 spawns it as-is. Always the raw tokens — the one shape of a command with 

102 no shell in it. Passing the tokens on is plain Python: `run(cmd)` runs 

103 them, a wrapper takes them splatted (`uv.run("--", *cmd)`), and stdlib 

104 helpers take the list directly (`shlex.join(cmd)`). 

105 

106 The moment the line is about to cross into a shell, name that shell: 

107 

108 * `.posix()` — one string, `shlex`-quoted for `sh`/`bash`/`zsh`. 

109 * `.windows()` — one string, quoted the way `CreateProcess` parses. 

110 

111 Naming it is the caller's job because footman cannot know it: the same 

112 handle can target different hosts across calls, the OS does not determine 

113 the shell, and the payload may reach no shell at all. The quoting is the 

114 *destination's*, never this machine's — a line built on Windows for a 

115 Linux box still comes back POSIX-quoted (which is why neither method is 

116 the local-platform `_shell_quote`). 

117 

118 Lives here beside `Result` because `run()` accepts one and 

119 `Result.to_argv()` returns one. toolroom carries its own twin of this 

120 class on purpose — the seam between the packages speaks plain 

121 `list[str]`, so neither ever imports the other's. 

122 """ 

123 

124 __slots__ = () 

125 

126 def posix(self) -> str: 

127 """This command as one string, quoted for a POSIX shell. 

128 

129 What an `ssh` payload wants: ssh joins its remaining arguments with 

130 spaces and hands the remote shell **one string** to re-split, so the 

131 only argument boundaries that survive are the ones quoted into it. 

132 

133 cmd = git.commit.argv(m="ship 1.2.0") 

134 ssh("deploy@host", cmd.posix()) 

135 

136 Quoting a built line that already carries a `.posix()` payload quotes 

137 it once more, which is exactly what each further hop needs. 

138 """ 

139 return shlex.join(self) 

140 

141 def windows(self) -> str: 

142 """This command as one string, quoted the way `CreateProcess` parses. 

143 

144 The Windows counterpart of `.posix()`, for a payload headed to a 

145 Windows box — `subprocess.list2cmdline` quoting, which `cmd` and 

146 PowerShell both read. Chosen by the caller, never sniffed from the 

147 machine footman happens to be standing on. 

148 """ 

149 return subprocess.list2cmdline(self) 

150 

151 

152class Result(int): 

153 """The outcome of one `run()` call — and the value `run()` returns. 

154 

155 A `Result` *is* the exit code: it subclasses `int`, so `code = run(...)`, 

156 `if run(...)`, and `run(...) == 0` all keep working. It also carries the 

157 captured output, split by stream, and the command that produced it — so 

158 `run("git rev-parse HEAD").stdout.strip()` reads the hash without the 

159 stderr noise glued on. `stdout`/`stderr` are separated for both subprocess 

160 and in-process runs; a streamed run (`capture=False`) leaves them empty. 

161 """ 

162 

163 _command: str 

164 _stdout: str 

165 _stderr: str 

166 _duration: float 

167 _raw: str 

168 _shown: str 

169 _timed_out: bool 

170 _address: str 

171 _audit: tuple[AuditEntry, ...] 

172 _tokens: tuple[str, ...] 

173 _started: float | None 

174 _env: dict[str, str] | None 

175 _cwd: Path | None 

176 

177 def __new__( 

178 cls, 

179 code: int, 

180 *, 

181 command: str = "", 

182 stdout: str = "", 

183 stderr: str = "", 

184 duration: float = 0.0, 

185 raw: str = "", 

186 shown: str = "", 

187 timed_out: bool = False, 

188 address: str = "", 

189 audit: tuple[AuditEntry, ...] = (), 

190 tokens: tuple[str, ...] = (), 

191 started: float | None = None, 

192 env: dict[str, str] | None = None, 

193 cwd: Path | None = None, 

194 ) -> Result: 

195 self = super().__new__(cls, code) 

196 object.__setattr__(self, "_env", env) 

197 object.__setattr__(self, "_cwd", cwd) 

198 object.__setattr__(self, "_timed_out", timed_out) 

199 object.__setattr__(self, "_address", address) 

200 object.__setattr__(self, "_started", started) 

201 object.__setattr__(self, "_command", command) 

202 object.__setattr__(self, "_stdout", stdout) 

203 object.__setattr__(self, "_stderr", stderr) 

204 object.__setattr__(self, "_duration", duration) 

205 object.__setattr__(self, "_raw", raw or command) 

206 # What every surface that *prints* this call reads, so the record can 

207 # keep the value the caller passed while nothing shows it. Falls back 

208 # to the command itself, which is what it is for a call with no 

209 # `Secret` in it — the overwhelming majority. 

210 object.__setattr__(self, "_shown", shown or command) 

211 object.__setattr__(self, "_audit", audit) 

212 # The argv as separate tokens, kept so `to_argv()` can re-quote for a 

213 # shell the caller names. `_raw` cannot stand in: it is quoted for the 

214 # local platform, and `shlex` cannot reliably take a `list2cmdline` 

215 # string apart again. 

216 object.__setattr__(self, "_tokens", tokens) 

217 return self 

218 

219 def to_argv(self) -> Argv: 

220 """What this call ran, as the tokens it was spawned from. 

221 

222 `.raw` shows the command line quoted for the machine footman is 

223 standing on, which is right for a `--verbose` line and wrong the 

224 moment the string is sent somewhere else. This returns the same 

225 command as an `Argv` — raw tokens, re-quotable for whichever shell 

226 will actually parse them: 

227 

228 r = git.commit(m="ship 1.2.0") 

229 r.to_argv() # ["git", "commit", "-m", "ship 1.2.0"] 

230 r.to_argv().posix() # "git commit -m 'ship 1.2.0'" 

231 

232 Named apart from toolroom's `.argv` builders on purpose: this one describes 

233 a call that has **already run**, so `git.push().argv(…)` — meaning to 

234 *build* a command line — is an `AttributeError` rather than a push 

235 that quietly happened. For the same reason the two can differ by one 

236 token: what ran may carry a forced-colour switch (git's 

237 `-c color.ui=always`) that a line built to be sent elsewhere does not. 

238 """ 

239 if not self._tokens: 

240 raise ValueError( 

241 f"to_argv(): no argv was recorded for `{self._shown}`. Only a " 

242 f"spawned command has separable tokens — an in-process call, a " 

243 f"Python callable, or a `run()` given a command *string* never " 

244 f"had them apart. Pass a list (`run(['git', 'push'])`) or use " 

245 f"a toolroom handle, whose calls always record their argv." 

246 ) 

247 return Argv(self._tokens) 

248 

249 @property 

250 def command(self) -> str: 

251 """The command line that ran, normalised for reading — options in 

252 separated form, values shell-quoted. What `recording()` asserts 

253 against, and what a dependent reads back.""" 

254 return self._command 

255 

256 @property 

257 def shown(self) -> str: 

258 """`command` as it is safe to **print** — every argument that arrived 

259 as a `Secret` replaced by `***`, everything else identical. 

260 

261 Equal to `command` unless the call carried a secret, so a record is 

262 the same string on both sides in the ordinary case. Every surface 

263 footman shows a command line on reads this one: the step line, the 

264 `--verbose` announce, `--json`, a profile span, the message a 

265 `RunFailed` carries. The record keeps the real value, because a 

266 dependent, `recording()`, and the caller who passed it all read 

267 `command` and none of them are a display.""" 

268 return self._shown 

269 

270 @property 

271 def stdout(self) -> str: 

272 """Captured standard output; empty when the step streamed instead.""" 

273 return self._stdout 

274 

275 @property 

276 def stderr(self) -> str: 

277 """Captured standard error; empty when the step streamed instead.""" 

278 return self._stderr 

279 

280 @property 

281 def duration(self) -> float: 

282 """Wall-clock seconds the step took.""" 

283 return self._duration 

284 

285 @property 

286 def started(self) -> float | None: 

287 """When the step began, on the run's monotonic clock — the same clock 

288 a task row's `started` reads, so a step places inside its task's 

289 span. `None` for a record that never ran (a dry-run's rehearsal).""" 

290 return self._started 

291 

292 @property 

293 def raw(self) -> str: 

294 """The exact command line executed, shell-quoted — the bytes footman 

295 handed the tool, which may spell an option `--flag=value` where 

296 `command` shows `--flag value`. What `--verbose` prints. Equal to 

297 `command` when there is nothing to normalise.""" 

298 return self._raw 

299 

300 @property 

301 def timed_out(self) -> bool: 

302 """Whether `timeout=` expired and footman killed the tree. The code 

303 is 124 — the shell convention — and `stdout`/`stderr` hold whatever 

304 the command managed to say first, which on a hang is the only clue 

305 there is.""" 

306 return self._timed_out 

307 

308 @property 

309 def address(self) -> str: 

310 """The record's tree-derived name: the requester's path, this 

311 record's label, and an ordinal once a label repeats among siblings — 

312 counted in request order as written, so it is deterministic across 

313 runs and hosts. Empty outside a managed context.""" 

314 return self._address 

315 

316 @property 

317 def audit(self) -> tuple[AuditEntry, ...]: 

318 """The verdict's provenance: every lifecycle moment that acted on 

319 it, in execution order — the body entry with what the work itself 

320 produced, a review entry per `pre_record` reviewer. Empty for a 

321 record that executed nothing (a dry-run plan line).""" 

322 return self._audit 

323 

324 @property 

325 def env(self) -> dict[str, str] | None: 

326 """The environment this call would have run in — the task's own, or 

327 the `env=` handed to the call — kept on a **recorded** step only, so 

328 a `recording()` can assert what a command would have seen (`assert 

329 "UV_TOOL_DIR" not in steps[0].env`). `None` on a record that ran: 

330 a live environment can hold secrets, and a record travels into 

331 `--json` and the report.""" 

332 return self._env 

333 

334 @property 

335 def cwd(self) -> Path | None: 

336 """The directory this call would have run in — the task's resolved 

337 one, or the call's `cwd=`/`rel=` — kept on a **recorded** step only, 

338 like `env`. `None` on a record that ran, and for a call that opted 

339 out with `cwd="unmanaged"`.""" 

340 return self._cwd 

341 

342 def __setattr__(self, name: str, value: Any) -> None: 

343 raise AttributeError( 

344 f"Result.{name} is sealed — a committed record is immutable. " 

345 f"Amend verdicts in the review window (pre_record), or veto " 

346 f"with fail(reason, code) from an observer." 

347 ) 

348 

349 @property 

350 def code(self) -> int: 

351 """The exit code (0 is success) — the same value the `Result` itself is.""" 

352 return int(self) 

353 

354 @property 

355 def ok(self) -> bool: 

356 """Whether the command succeeded (exit code 0).""" 

357 return self == 0 

358 

359 @property 

360 def output(self) -> str: 

361 """`stdout` then `stderr`, concatenated — a convenience for "show me 

362 everything". NOT interleaved in real time (each stream is captured 

363 whole); when the order *across* the two streams matters, read `stdout` 

364 and `stderr` separately.""" 

365 return self.stdout + self.stderr 

366 

367 @property 

368 def failed_at(self) -> str | None: 

369 """The lifecycle moment the failure came from, None on success — a 

370 derived reading of the audit: the last moment that wrote the verdict. 

371 A red tool reviewed green reads None (the record succeeded); a green 

372 tool failed by its reviewer reads `"review"`.""" 

373 return _failed_moment(self.code, self.audit) 

374 

375 @property 

376 def work_code(self) -> int | None: 

377 """The code the record carried when the failing moment began — a 

378 derived reading of the audit, None on success or when nothing came 

379 before the failure. A green build failed in review keeps its 0 here, 

380 visible rather than inferred.""" 

381 return _earned_code(self.code, self.audit) 

382 

383 

384def _failed_moment(code: int, audit: Sequence[AuditEntry]) -> str | None: 

385 """The moment the final non-zero verdict came from — the derivation both 

386 record shapes (step `Result`, task row) share.""" 

387 if code == 0 or not audit: 

388 return None 

389 for entry in reversed(audit): 389 ↛ 392line 389 didn't jump to line 392 because the loop on line 389 didn't complete

390 if entry.code is not None: 

391 return entry.moment 

392 return None 

393 

394 

395def _earned_code(code: int, audit: Sequence[AuditEntry]) -> int | None: 

396 """The code carried when the failing moment began; None on success or 

397 when nothing code-bearing came before the failure.""" 

398 if _failed_moment(code, audit) is None: 

399 return None 

400 coded = [entry for entry in audit if entry.code is not None] 

401 return coded[-2].code if len(coded) >= 2 else None 

402 

403 

404class ResultView: 

405 """A step's record, in the review window: the draft a `pre_record` 

406 reviewer receives after the work ran and before the record is sealed. 

407 

408 The verdict is still open — `title` and `code` are plain writable 

409 attributes, and the raise-on-nonzero decision reads the code the review 

410 leaves behind, so "fail by this tool's definition of failure" needs no 

411 `nofail=` at the call site. Everything the run *captured* is read-only: 

412 review sees what the run kept, never edits it — an uncaptured 

413 (`capture=False`) call reviews the code alone. `ok` derives from `code`, 

414 so the two can never disagree. 

415 """ 

416 

417 __slots__ = ( 

418 "_code", 

419 "_command", 

420 "_duration", 

421 "_raw", 

422 "_returned", 

423 "_stderr", 

424 "_stdout", 

425 "_title", 

426 "_touched", 

427 ) 

428 

429 def __init__( 

430 self, 

431 *, 

432 title: str, 

433 code: int, 

434 stdout: str, 

435 stderr: str, 

436 duration: float, 

437 raw: str, 

438 command: str, 

439 returned: Any = None, 

440 ) -> None: 

441 self._title = title 

442 self._code = code 

443 self._stdout = stdout 

444 self._stderr = stderr 

445 self._duration = duration 

446 self._raw = raw 

447 self._command = command 

448 self._returned = returned 

449 self._touched: set[str] = set() 

450 

451 @property 

452 def title(self) -> str: 

453 """The receipt's label. Starts as the shown command (or the call's 

454 `title=`); what the review leaves here is what the report shows.""" 

455 return self._title 

456 

457 @title.setter 

458 def title(self, value: str) -> None: 

459 self._title = value 

460 self._touched.add("title") 

461 

462 @property 

463 def code(self) -> int: 

464 """The verdict. What the review leaves here is the step's exit code — 

465 the raise decision and the receipt both read it. The audit records 

466 whether a reviewer wrote it or left it alone.""" 

467 return self._code 

468 

469 @code.setter 

470 def code(self, value: int) -> None: 

471 self._code = value 

472 self._touched.add("code") 

473 

474 @property 

475 def ok(self) -> bool: 

476 """Whether the code, as it stands, is success — derives from `code`.""" 

477 return self.code == 0 

478 

479 @property 

480 def stdout(self) -> str: 

481 """Captured standard output; empty when the call streamed instead.""" 

482 return self._stdout 

483 

484 @property 

485 def stderr(self) -> str: 

486 """Captured standard error; empty when the call streamed instead.""" 

487 return self._stderr 

488 

489 @property 

490 def output(self) -> str: 

491 """`stdout` then `stderr`, concatenated — the same convenience the 

492 sealed `Result` offers.""" 

493 return self._stdout + self._stderr 

494 

495 @property 

496 def duration(self) -> float: 

497 """Wall-clock seconds the work took — machinery-owned, read-only.""" 

498 return self._duration 

499 

500 @property 

501 def raw(self) -> str: 

502 """The exact command line executed — the review can read what really 

503 ran while retitling what the report shows.""" 

504 return self._raw 

505 

506 @property 

507 def command(self) -> str: 

508 """The normalised command line as it stood before review.""" 

509 return self._command 

510 

511 @property 

512 def returned(self) -> Any: 

513 """What the body returned, when this draft is a task's row — None by 

514 circumstance elsewhere (a subprocess step has no return value).""" 

515 return self._returned 

516 

517 def set_returned(self, value: Any) -> None: 

518 """Rewrite the *reported* value — the summary line and the `--json` 

519 envelope — never what a dependent or a body caller received: that was 

520 snapshotted the moment the body handed it over. The review window is 

521 this write's home: reviewed, attributed in the audit, before the 

522 record seals.""" 

523 self._returned = value 

524 self._touched.add("returned") 

525 

526 def _fill(self, stdout: str, stderr: str, duration: float) -> None: 

527 """Machinery-side: fill the captured streams and timing once the 

528 work concluded — a generator item held its draft *during* the work, 

529 before these were knowable. Never a review write.""" 

530 self._stdout = stdout 

531 self._stderr = stderr 

532 self._duration = duration 

533 

534 

535@dataclass 

536class Context: 

537 """State for one running task: environment, flags, passthrough, output.""" 

538 

539 address: str = "" 

540 """This context's place in the run's tree — the path of requests that 

541 led here. Every record made under it derives its own address from this 

542 one.""" 

543 _labels: dict[str, int] = field(default_factory=dict) 

544 """Per-parent label counts, so same-labelled children get ordinals in 

545 request order as written (the first is bare, the second is `#2`).""" 

546 

547 notes: list[Any] = field(default_factory=list) 

548 """The notes this task fired (`_notes.Note` records) — what the level 

549 system resolved, where each came from, and what the boundary verdict 

550 and the `--json` envelope read. A task's own, never inherited.""" 

551 

552 env: dict[str, str] = field(default_factory=lambda: _globals.base_env()) 

553 """**This task's environment** — a complete one, not a diff. 

554 

555 Starts as a copy of the run's pinned environment, and is what `os.environ` 

556 answers from inside the task, what every child footman spawns receives, 

557 and what `run(env=…)` replaces. Because it is a whole value rather than an 

558 overlay, `del os.environ["FOO"]` is ordinary: it removes the key from this 

559 task's environment and from the children it goes on to spawn, while a 

560 sibling's copy is untouched.""" 

561 cwd: Path | None = None 

562 """Where `run()` executes — resolved once per task by the policy ladder 

563 (`.opts(cwd=)` / `@task(cwd=)` / config `cwd`, default `taskfile`), so it 

564 is concrete inside a run. A preset value (tests, `use_context`) wins. 

565 `None` means unresolved (plain calls outside a footman run).""" 

566 cwd_policy: str = "" 

567 """`[tool.footman] cwd` — the run-wide default policy token (or absolute 

568 path) the ladder bottoms out on. Empty means `taskfile`.""" 

569 root_dir: str = "" 

570 """The `root` token's target: the highest cascade task file's directory 

571 (`files[0].parent`), pinned by discovery. Empty outside a real run.""" 

572 invoked_dir: str = "" 

573 """The `asinvoked` token's target: the process cwd where `fm` was 

574 launched, pinned as a snapshot at startup.""" 

575 cwd_unmanaged: bool = False 

576 """`cwd="unmanaged"`: footman stays out — subprocesses spawn with 

577 `cwd=None` (inherit the live process cwd) even though `ctx.cwd` records 

578 the process cwd at task start for the body to read.""" 

579 serial_active: bool = False 

580 """This task holds (or inherited, through a fan-out) the serial or 

581 exclusive lane: it owns the real process globals — the environ router, 

582 the Popen injection, and the os guards all pass through. Set by the 

583 executor around a `serial=`/`exclusive=` body; `parallel()` children 

584 inherit it, because a lineage extends a hold.""" 

585 dry_run: bool = False 

586 """`--dry-run`: `run()` prints and records the command, executes 

587 nothing, and reports success.""" 

588 quiet: bool = False 

589 """`--quiet`: suppress step lines and the per-task summary.""" 

590 verbose: bool = False 

591 """`--verbose`: replay captured `run()` output even on success.""" 

592 no_color: bool = False 

593 """`--no-color` / `--color=never` (or `NO_COLOR`): never emit ANSI styling.""" 

594 force_color: bool = False 

595 """`--color=always` (or `FORCE_COLOR`): colour even when output is not a 

596 terminal — so `run()` forces the tools it spawns to colour and the shown 

597 command line paints, for a pipe into `less -R`. Gated off under capture 

598 (`--json`), where ANSI would corrupt the envelope. Never sets the live 

599 cursor affordances `tty` governs — those still need a real terminal.""" 

600 machine_read: bool = False 

601 """`--json`: the envelope is the report. footman's own receipt chrome — 

602 the step line, the replayed output block, the audit trail — is left out 

603 of the buffer, because every one of those already has a field on the 

604 step's own row. Task *output* still lands there: a body's prints are 

605 what the buffer is for.""" 

606 prog: str = "fm" 

607 """The invoking CLI's command name — a branded CLI's own `prog`, so 

608 tasks (the taskdocs plugin, say) can speak the brand's name.""" 

609 sequential: bool = False 

610 """The *user asked* for one-at-a-time (`-s` or config) — `parallel()` 

611 honours it too. Deliberately not set by the scheduler's own 

612 single-node routing, which is presentation, not a request to 

613 serialise task bodies.""" 

614 assume_yes: bool = False 

615 """`--yes`: every `confirm()` gate auto-answers yes, for CI and scripts.""" 

616 no_input: bool = False 

617 """`--no-input`: never prompt — a required prompt errors instead of 

618 asking, so an unattended run fails loudly rather than hanging.""" 

619 fetch_backend: str = "" 

620 """`[fetch] backend` from the config ladder — which engine `fetch()` 

621 downloads with. Empty means the default (stdlib urllib).""" 

622 shell_default: str = "" 

623 """`[shell] default` from the config ladder — what `run(shell=True)` resolves 

624 to. Empty means `posix` (a POSIX shell everywhere: bash, then sh).""" 

625 jobs: int = 0 

626 """The effective parallel width (`-j/--jobs`, config `jobs`, or the 

627 cores-minus-one default) — caps `parallel()` pools in task bodies. 

628 `0` means unset (plain calls outside a run): no cap.""" 

629 task: str = "" 

630 """Who is running, for the step lines' name column: the scheduler 

631 sets the dotted task name, `parallel()` its child's name. Empty 

632 outside runs.""" 

633 fn: Any = None 

634 """The running task's own function — what `inherited()` reads to find 

635 the task this one shadows. `None` outside a run.""" 

636 given: frozenset[str] = frozenset() 

637 """The parameter names *the caller supplied* — named on the command line, 

638 passed as a keyword by a body call, or answered at an `ask()` prompt. What 

639 the `given()` function reads. 

640 

641 Its complement is everything footman inferred rather than was told: an 

642 `env()` fallback, a `default(fn)`, the declared default. That split is the 

643 whole point — a value and the fact that someone asked for it are two 

644 different things, and only the second can distinguish `--profile` (write 

645 the default file) from no `--profile` at all (write nothing). 

646 

647 Empty outside a run, and never inherited: a child task's set is its own, 

648 stamped where its context is built.""" 

649 name_width: int = 0 

650 """The widest sibling task name, so step-line columns align.""" 

651 passthrough: list[str] = field(default_factory=list) 

652 """Everything after `--` on the command line, verbatim.""" 

653 tty: bool = False 

654 """Output dresses for a terminal (colour, marks). Live in-place 

655 rewrites additionally require output to be uncaptured.""" 

656 terminal: bool = False 

657 """The run's real stdout is a terminal (on Windows, one that interprets 

658 ANSI) and output is not captured into a `--json` envelope — `tty` with 

659 the colour policy left out. What the `tty()` reader answers: `NO_COLOR` 

660 turns `tty` off because dressed output would be wrong, but the person 

661 is still there.""" 

662 sink: TextIO | None = None 

663 """Where this task's stdout goes: a capture buffer in buffered 

664 (parallel) mode, `None` for the real stdout (live mode).""" 

665 err_sink: TextIO | None = None 

666 """Where this task's stderr goes. At task level it is the *same* buffer as 

667 `sink` (so the atomic parallel flush keeps stdout/stderr in order); a 

668 `run()` capturing an in-process callable temporarily points the two at 

669 separate buffers to split the step's streams for its `Result`.""" 

670 interactive: bool = False 

671 """`@task(interactive=True)`: the task owns the real terminal — output is 

672 not captured and it holds sole stdio, so its body may prompt or run a 

673 REPL. Mid-body `prompt()`/`confirm()`/`select()` are allowed only here.""" 

674 atomic: bool = False 

675 """`@task(atomic=True)`: this task's subprocesses opt out of fail-fast's 

676 kill — they run to completion so a mid-write can't be truncated. Total 

677 on purpose: the child is unregistered and not group-isolated, so a stop 

678 signal to footman's own pid (`timeout`, `docker stop`) ends the run and 

679 leaves the child to finish; a terminal's Ctrl-C reaches it only because 

680 the kernel signals the whole foreground group itself.""" 

681 keep_going: bool = False 

682 """This task's resolved (per-subtree) failure policy, tagged onto the 

683 subprocesses it spawns so a fail-fast failure elsewhere reaps only the 

684 fail-fast trees in a mixed run, sparing a keep-going task's.""" 

685 deadline: float | None = None 

686 """`@task(timeout=…)`, resolved to a `time.perf_counter()` instant when 

687 the body starts — an instant rather than a duration because everything 

688 downstream asks "how long is left?", and a duration would have to be 

689 re-based at each hand-off. 

690 

691 It is the fail-fast event scoped to one task: past it, no new work 

692 starts, in-flight subprocess trees are terminated, and generator steps 

693 unwind at their next checkpoint. Inherited by the steps and `run()` 

694 calls a body makes, so a subprocess is bounded by whatever the task has 

695 left rather than by its own timeout alone.""" 

696 timeout_declared: float | None = None 

697 """The seconds the author wrote in `@task(timeout=…)`, kept beside the 

698 resolved `deadline` so a failure line can name the declared number 

699 rather than the remainder that happened to be left when a call 

700 started.""" 

701 shared: bool = True 

702 """Whether an execution the run has already performed may satisfy this 

703 request. `False` means it gets its own run, and so does everything it asks 

704 for, unless that task declares its own answer. Resolved per node by the 

705 scheduler and per call by the cell layer — the sharing twin of 

706 `keep_going`'s per-subtree policy.""" 

707 in_task: bool = False 

708 """True while a task *body* runs (the scheduler sets it around the call), 

709 so the interactive primitives tell a guarded mid-body call from the 

710 framework's own up-front `ask()` resolution.""" 

711 unit_pending: bool = False 

712 """A live-progress unit is already counted for this call, and unclaimed: 

713 `parallel()` counts every child it is handed, then the first task request 

714 inside that child *claims* the unit instead of counting a second one. A 

715 thunk that runs no task keeps it; a thunk that is only a wrapper around 

716 one call (the `lambda: build("web")` spelling) shows as the single piece 

717 of work it is. Cleared on claim, so the requests after it — and anything 

718 the callee itself asks for — count their own.""" 

719 answers: Any = None 

720 """The scripted answers a `recording(answers=…)` block carries — the 

721 normalised table, shared by reference with every child context so an 

722 ordered sequence is consumed in one place. `None` outside a scripted 

723 recording; a raw mapping handed to `Context(answers=…)` is normalised 

724 on first use.""" 

725 steps: list[Result] = field(default_factory=list) 

726 """Every `run()` this task made, in order — what `recording()` and 

727 the `--json` envelope read.""" 

728 sections: list[Section] = field(default_factory=list) 

729 """Task-authored profiling: what `section()`, `stream()` and `mark()` 

730 recorded while this task ran, on the run's clock. Rides the task's 

731 result row; a `parallel()` child's records fold into its requester's, 

732 the way `steps` do.""" 

733 

734 def time_left(self) -> float | None: 

735 """Seconds until this task's deadline, or `None` when it has none. 

736 

737 Clamped at zero rather than going negative: callers hand it to 

738 `subprocess.communicate(timeout=…)` and to their own comparisons, 

739 and a negative bound reads as "no bound" to some of them. Zero means 

740 the deadline has passed and the caller should not start the work. 

741 """ 

742 if self.deadline is None: 

743 return None 

744 return max(0.0, self.deadline - time.perf_counter()) 

745 

746 def overdue(self) -> bool: 

747 """Has this task's deadline passed? False when there is no deadline.""" 

748 return self.deadline is not None and time.perf_counter() > self.deadline 

749 

750 def child(self, label: str, **overrides: Any) -> Context: 

751 """One birth for every child context — a body callee's, a 

752 `parallel()` child's — so the sites can never drift on which fields 

753 reset. 

754 

755 Identity is fresh: the address and sibling labels, the step and 

756 section records, the unit flag. The environment is a COPY — a 

757 child's writes are its own, which is the router's "children see it, 

758 siblings don't" made structural. Everything else inherits until 

759 *overrides* says otherwise. `given` deliberately inherits here: the 

760 futures layer stamps the callee's own set where binding computes 

761 it, and a step child has no binding to compute one. 

762 """ 

763 born = replace( 

764 self, 

765 address=_child_address(self, label), 

766 _labels={}, 

767 env=dict(self.env), 

768 steps=[], 

769 sections=[], 

770 notes=[], 

771 task=label, 

772 unit_pending=False, 

773 ) 

774 for name, value in overrides.items(): 

775 setattr(born, name, value) 

776 return born 

777 

778 

779_current: ContextVar[Context | None] = ContextVar("footman_context", default=None) 

780 

781 

782def current() -> Context: 

783 """The context of the running task (a fresh default one outside a run).""" 

784 ctx = _current.get() 

785 return ctx if ctx is not None else Context() 

786 

787 

788_WALL_ANCHOR: tuple[float, float] = (time.time(), time.perf_counter()) 

789"""One sampling of both clocks, taken together, so a wall-clock moment maps 

790onto the run clock every record in this module keeps: a retroactive 

791`Stream.section(start=…, end=…)` window lands beside spans that were stamped 

792live. Module-level on purpose — `perf_counter`'s origin is arbitrary but 

793process-wide, so one anchor serves every run in the process.""" 

794 

795 

796@dataclass(frozen=True) 

797class Section: 

798 """One recorded interval of a task's time — what the `--json` envelope 

799 carries and a profile renders. `stream` is `""` for the task's own 

800 timeline (these nest and never overlap, by construction); a named stream 

801 is its own parallel timeline, where overlap is legal. A `mark()` is a 

802 section with no duration.""" 

803 

804 name: str 

805 started: float # the run's monotonic clock, same as a task row's 

806 duration: float # seconds; 0.0 is an instant (a mark) 

807 stream: str = "" 

808 

809 

810class _SectionTimer: 

811 """The context manager `section()` returns: entry stamps the clock, exit 

812 records — a body that raised still spent the time, so the record is made 

813 in `__exit__` unconditionally.""" 

814 

815 __slots__ = ("_begin", "_ctx", "name", "stream") 

816 

817 _ctx: Context 

818 name: str 

819 stream: str 

820 _begin: float 

821 

822 def __init__(self, ctx: Context, name: str, stream: str) -> None: 

823 self._ctx = ctx 

824 self.name = name 

825 self.stream = stream 

826 self._begin = 0.0 

827 

828 def __enter__(self) -> _SectionTimer: 

829 self._begin = time.perf_counter() 

830 return self 

831 

832 def __exit__(self, *exc: object) -> None: 

833 self._ctx.sections.append( 

834 Section( 

835 self.name, self._begin, time.perf_counter() - self._begin, self.stream 

836 ) 

837 ) 

838 

839 

840def _profiled_ctx(where: str) -> Context: 

841 ctx = _current.get() 

842 if ctx is None or not ctx.in_task: 

843 raise RuntimeError( 

844 f"{where} records timing on the *running task*, so it belongs " 

845 f"inside a task body, during a run. From a helper thread, make " 

846 f"the stream in the body and hand the handle over — the handle " 

847 f"remembers its task." 

848 ) 

849 return ctx 

850 

851 

852def _run_clock(moment: datetime | float, arg: str) -> float: 

853 """A wall-clock moment (datetime, or epoch seconds) on the run clock.""" 

854 if isinstance(moment, datetime): 

855 wall = moment.timestamp() # naive reads as local time, aware as itself 

856 elif isinstance(moment, (int, float)) and not isinstance(moment, bool): 

857 wall = float(moment) 

858 else: 

859 raise TypeError( 

860 f"Stream.section({arg}=…) takes a datetime or epoch seconds " 

861 f"(time.time()), got {type(moment).__name__}" 

862 ) 

863 anchor_wall, anchor_clock = _WALL_ANCHOR 

864 return anchor_clock + (wall - anchor_wall) 

865 

866 

867def section(name: str) -> _SectionTimer: 

868 """Time a section of the running task — `with footman.section("resolve"):`. 

869 

870 Records onto the task's own timeline, subdividing its span; nested 

871 blocks nest. For work that overlaps — several waits in flight at once — 

872 use `stream()`, where overlap is legal.""" 

873 return _SectionTimer(_profiled_ctx("section()"), name, "") 

874 

875 

876def mark(name: str) -> None: 

877 """Record an instant on the running task's timeline — a moment worth a 

878 label, no duration.""" 

879 ctx = _profiled_ctx("mark()") 

880 ctx.sections.append(Section(name, time.perf_counter(), 0.0)) 

881 

882 

883class Stream: 

884 """A named parallel timeline of sections under the running task. 

885 

886 Made in the task body (`ci = footman.stream("ci")`); the handle remembers 

887 its task, so a helper thread the body spawned may record through it. 

888 Sections on a stream may overlap — that is what a stream is for.""" 

889 

890 __slots__ = ("_ctx", "name") 

891 

892 _ctx: Context 

893 name: str 

894 

895 def __init__(self, ctx: Context, name: str) -> None: 

896 self._ctx = ctx 

897 self.name = name 

898 

899 @overload 

900 def section(self, name: str) -> _SectionTimer: ... 

901 @overload 

902 def section( 

903 self, name: str, *, start: datetime | float, end: datetime | float 

904 ) -> None: ... 

905 def section( 

906 self, 

907 name: str, 

908 *, 

909 start: datetime | float | None = None, 

910 end: datetime | float | None = None, 

911 ) -> _SectionTimer | None: 

912 """A section on this stream: bracketing (`with ci.section("poll"):`) 

913 or retroactive — `ci.section("build", start=t0, end=t1)` records a 

914 window learned after the fact (a CI check's real run, reported by 

915 its API), placed by wall clock.""" 

916 if (start is None) != (end is None): 

917 raise ValueError( 

918 "Stream.section(): give both start= and end=, or neither — " 

919 "half a window places nothing" 

920 ) 

921 if start is None: 

922 return _SectionTimer(self._ctx, name, self.name) 

923 assert end is not None 

924 begin = _run_clock(start, "start") 

925 finish = _run_clock(end, "end") 

926 if finish < begin: 

927 raise ValueError( 

928 f"Stream.section({name!r}): end is before start — the window " 

929 f"is {begin - finish:.3f}s inside out" 

930 ) 

931 self._ctx.sections.append(Section(name, begin, finish - begin, self.name)) 

932 return None 

933 

934 

935def stream(name: str) -> Stream: 

936 """A named parallel timeline under the running task — see `Stream`.""" 

937 if not name: 

938 raise ValueError( 

939 "stream(''): the empty name is the task's own timeline — " 

940 "sections land there via footman.section()" 

941 ) 

942 return Stream(_profiled_ctx("stream()"), name) 

943 

944 

945@contextlib.contextmanager 

946def chdir( 

947 target: str | Path | None = None, *, rel: str | Path | None = None 

948) -> Generator[None]: 

949 """Really change the process directory — inside a serial/exclusive task. 

950 

951 The sugar for bodies that own the globals: the default target is the 

952 task's own `ctx.cwd`; arguments follow the marker grammar exactly — a 

953 policy token (`root`, `taskfile`, `asinvoked`) or an absolute path, with 

954 `rel=` for a relative suffix; a bare relative path is the same taught 

955 error the markers give. Performs a real `os.chdir`, keeps `ctx.cwd` in 

956 sync (so a nested `run()` roots where the block does), restores both on 

957 exit. In a parallel task it is a taught error — the cwd belongs to no 

958 one there. 

959 """ 

960 ctx = current() 

961 if ctx.in_task and not ctx.serial_active: 

962 raise RuntimeError( 

963 f"task {ctx.task or '?'} calls footman.chdir() in a parallel " 

964 f"task — the process directory belongs to no one there. The " 

965 f"ladder: build paths from footman.cwd() (no chdir at all); or " 

966 f"claim lanes=(cwd_lane,) if the real directory only needs to " 

967 f"*be* the task's cwd for the duration; or mark the task serial " 

968 f"(or exclusive) to own the real globals — footman.chdir() is " 

969 f"legal there." 

970 ) 

971 base: Path 

972 if target is None: 

973 base = ctx.cwd if ctx.cwd is not None else Path(_globals.real_getcwd()) 

974 elif isinstance(target, str) and target == "root" and ctx.root_dir: 974 ↛ 975line 974 didn't jump to line 975 because the condition on line 974 was never true

975 base = Path(ctx.root_dir) 

976 elif isinstance(target, str) and target == "asinvoked" and ctx.invoked_dir: 976 ↛ 977line 976 didn't jump to line 977 because the condition on line 976 was never true

977 base = Path(ctx.invoked_dir) 

978 elif isinstance(target, str) and target == "taskfile": 978 ↛ 979line 978 didn't jump to line 979 because the condition on line 978 was never true

979 from footman._discover import defining_dir 

980 

981 home = defining_dir(ctx.fn) if ctx.fn is not None else None 

982 base = Path(home) if home else Path(_globals.real_getcwd()) 

983 elif isinstance(target, str) and target == "unmanaged": 983 ↛ 984line 983 didn't jump to line 984 because the condition on line 983 was never true

984 raise TypeError("chdir(cwd='unmanaged') has no directory to change to") 

985 else: 

986 base = Path(target) 

987 if not base.is_absolute(): 

988 raise TypeError( 

989 f"chdir({str(target)!r}) is relative — chdir takes a policy " 

990 f"token or an absolute path; a relative suffix goes in rel=…" 

991 ) 

992 if rel is not None: 

993 rel_suffix = Path(rel) 

994 if rel_suffix.is_absolute() or rel_suffix.anchor: 994 ↛ 995line 994 didn't jump to line 995 because the condition on line 994 was never true

995 raise TypeError( 

996 f"rel={str(rel)!r} is absolute — rel is a suffix appended " 

997 f"to the resolved base; an absolute directory goes first." 

998 ) 

999 base = base / rel 

1000 saved_fs, saved_ctx = _globals.real_getcwd(), ctx.cwd 

1001 _globals.real_chdir(base) 

1002 ctx.cwd = base 

1003 try: 

1004 yield 

1005 finally: 

1006 with contextlib.suppress(OSError): 

1007 _globals.real_chdir(saved_fs) 

1008 ctx.cwd = saved_ctx 

1009 

1010 

1011def cache_dir() -> Path: 

1012 """This CLI's cache directory, created if it isn't there yet. 

1013 

1014 Derived data, safe to delete: footman's collector sweeps this folder by 

1015 age, so put nothing here you would mind losing. 

1016 

1017 ```python 

1018 (footman.cache_dir() / "index.json").write_text(payload) 

1019 ``` 

1020 

1021 Where it lands is the branded CLI's business — its own folder, or 

1022 `~/.cache/<name>` by default. A task asks for the kind of folder it 

1023 wants and gets one that exists. 

1024 """ 

1025 from footman import _paths 

1026 

1027 return _make(_paths.footman_cache_dir()) 

1028 

1029 

1030def data_dir() -> Path: 

1031 """This CLI's data directory, created if it isn't there yet. 

1032 

1033 Durable and machine-local — credentials, tokens, generated assets. The 

1034 collector never touches it, which is the whole difference from 

1035 `cache_dir()`. Created owner-only (`0o700`), like `~/.ssh`, because 

1036 credentials are exactly what it is documented to hold. 

1037 

1038 ```python 

1039 (footman.data_dir() / "credentials.json").write_text(token) 

1040 ``` 

1041 """ 

1042 from footman import _paths 

1043 

1044 return _make(_paths.footman_data_dir(), mode=0o700) 

1045 

1046 

1047def prog() -> str: 

1048 """The command this CLI is invoked as — `fm`, or a branded CLI's own. 

1049 

1050 For output that has to *call the CLI back*: a generated CI workflow, a 

1051 README line, a task that writes prose naming the command someone will 

1052 type. A branded runner must emit its own name, or the file it writes 

1053 tells the reader to run a command they do not have. 

1054 

1055 ```python 

1056 (root / ".github/workflows/ci.yml").write_text( 

1057 f" - run: {footman.prog()} check\\n" 

1058 ) 

1059 ``` 

1060 

1061 Inside a task body, prefer `ctx.prog` — declare a `ctx: Context` 

1062 parameter and read it. That is the invocation's own answer rather than 

1063 process state, and it is what footman's own taskdocs plugin uses. This 

1064 accessor exists for the code that never receives a context: a plugin 

1065 building its tree at mount time, a helper called from anywhere. 

1066 """ 

1067 from footman import _paths 

1068 

1069 return _paths.prog() 

1070 

1071 

1072def dist() -> str | None: 

1073 """The distribution that ships this CLI, or `None` if it never said. 

1074 

1075 The name someone would `pip install` — what the uv handoffs reason 

1076 about, and what `self.install` puts on a PATH. `App(dist=…)` sets it; 

1077 stock footman answers `"footman"`. 

1078 

1079 `None` is a real answer, not a gap: declaring a distribution is opt-in 

1080 for a branded CLI, and saying `"footman"` for a brand that never 

1081 declared one would name the wrong package. 

1082 """ 

1083 from footman import _paths 

1084 

1085 return _paths.declared_dist() 

1086 

1087 

1088def config_dir() -> Path: 

1089 """This CLI's config directory — *your* writing, never footman's. 

1090 

1091 The user-level config file and the user-level tasks file live here and 

1092 travel together. Unlike `cache_dir()` and `data_dir()` this one is 

1093 **not** created on demand: an empty config directory says something 

1094 different from a missing one, and inventing it would be footman 

1095 writing where only you should. 

1096 """ 

1097 from footman import _paths 

1098 

1099 return _paths.footman_config_dir() 

1100 

1101 

1102def config_file() -> Path: 

1103 """The user-level config file — `~/.config/<brand>/config.toml` by 

1104 default. May not exist; a runner with no config is the ordinary case.""" 

1105 from footman import _paths 

1106 

1107 return _paths.footman_config_file() 

1108 

1109 

1110def user_tasks_file() -> Path: 

1111 """The user-level tasks file: the cascade's outermost writable rung, 

1112 riding every project. May not exist.""" 

1113 from footman import _paths 

1114 

1115 return _paths.user_tasks_file(_paths.tasks_file_name()) 

1116 

1117 

1118def project_root() -> Path | None: 

1119 """The top of this invocation's task cascade, or `None` outside a 

1120 project. 

1121 

1122 The same answer `--json` reports and the `root` cwd policy resolves 

1123 against — never a guess from the current directory, which may be 

1124 anywhere beneath it. 

1125 """ 

1126 root = current().root_dir 

1127 return Path(root) if root else None 

1128 

1129 

1130def _make(path: Path, mode: int | None = None) -> Path: 

1131 """Return *path*, having made sure it exists. 

1132 

1133 The accessors create rather than merely resolve: every caller would 

1134 otherwise write the same `mkdir`, and forgetting it fails at the write 

1135 rather than here. `_paths` itself stays resolution-only — it is on the 

1136 completion hot path, which must not touch the disk. 

1137 

1138 *mode* hardens the leaf at creation time only: a directory that already 

1139 exists keeps whatever permissions its owner gave it, parents follow the 

1140 umask as usual, and Windows ignores the bits. 

1141 """ 

1142 if mode is None: 

1143 path.mkdir(parents=True, exist_ok=True) 

1144 else: 

1145 path.mkdir(mode=mode, parents=True, exist_ok=True) 

1146 return path 

1147 

1148 

1149def cwd() -> Path: 

1150 """The current task's working directory, always concrete. 

1151 

1152 `ctx.cwd` as the policy ladder resolved it — the blessed base for a task 

1153 body's own path arithmetic (`footman.cwd() / "dist"`), instead of 

1154 relative paths against the process cwd, which belongs to no one in a 

1155 parallel run. Outside a run it is simply the process cwd. 

1156 """ 

1157 resolved = current().cwd 

1158 return resolved if resolved is not None else Path.cwd() 

1159 

1160 

1161@contextlib.contextmanager 

1162def use_context(ctx: Context | None = None) -> Generator[Context]: 

1163 """Install *ctx* as the current run context for the duration of the block. 

1164 

1165 The public seam for calling tasks from other Python code — tests included: 

1166 `run()` and hosted toolroom calls inside the block read this context 

1167 instead of a fresh default. `footman.testing.recording` builds on it. 

1168 

1169 ```python 

1170 with use_context(Context(env={"CI": "1"})) as ctx: 

1171 deploy() 

1172 assert ctx.steps[0].code == 0 

1173 ``` 

1174 """ 

1175 installed = ctx if ctx is not None else Context() 

1176 token = _current.set(installed) 

1177 try: 

1178 yield installed 

1179 finally: 

1180 _current.reset(token) 

1181 

1182 

1183def passthrough() -> list[str]: 

1184 """Arguments after `--` on the command line, for the running task.""" 

1185 return list(current().passthrough) 

1186 

1187 

1188def given(name: str) -> bool: 

1189 """Whether the caller supplied *name*, as opposed to footman inferring it. 

1190 

1191 ```python 

1192 @task 

1193 def build(*, profile: Path = Path("build-profile.json")): 

1194 if given("profile"): # `fm build --profile` writes the default file; 

1195 trace_to(profile) # `fm build` writes nothing at all 

1196 ``` 

1197 

1198 True when the parameter was named on the command line (with or without a 

1199 value — `--profile` alone means its default, and means it on purpose), 

1200 passed as a keyword by a body call, or answered at an `ask()` prompt. False 

1201 when the value came from an `env()` fallback, a `default(fn)`, or the 

1202 declared default, because nobody asked for those. 

1203 

1204 That distinction is the only way to tell "the default one, please" from "no 

1205 opinion", which a value alone cannot express: both hand you the same path. 

1206 

1207 *name* is the **parameter** name, not its flag spelling — `dry_run`, not 

1208 `--dry-run`. Naming something the running task has no parameter for is an 

1209 error rather than a silent `False`, since a typo would otherwise read as a 

1210 perfectly ordinary "not given". 

1211 """ 

1212 ctx = _current.get() 

1213 if ctx is None or not ctx.in_task or ctx.fn is None: 

1214 raise RuntimeError( 

1215 f"given({name!r}) has no answer here — presence is decided when a " 

1216 f"task's arguments are bound, so call it inside a task body during " 

1217 f"a run" 

1218 ) 

1219 from footman import _manifest 

1220 

1221 known = { 

1222 p.name 

1223 for p in _manifest.call_signature(ctx.fn).parameters.values() 

1224 if p.kind is not inspect.Parameter.VAR_KEYWORD 

1225 } 

1226 if name not in known: 

1227 listed = ", ".join(sorted(known)) or "(none)" 

1228 dashed = name.replace("-", "_") 

1229 hint = ( 

1230 f" — did you mean {dashed!r}?" if dashed != name and dashed in known else "" 

1231 ) 

1232 raise ValueError( 

1233 f"given({name!r}): {ctx.task or 'this task'} has no parameter " 

1234 f"{name!r}{hint} (it has: {listed})" 

1235 ) 

1236 return name in ctx.given 

1237 

1238 

1239def attended() -> bool: 

1240 """Whether a prompt would reach a human: stdin is a real terminal and the 

1241 run was not declared unattended (`--no-input`, `--dry-run`). 

1242 

1243 The question `prompt()`/`confirm()`/`select()` already answer for 

1244 themselves by degrading to their defaults — `attended()` is for changing 

1245 the *shape* of a run instead: skip an optional wizard outright, take the 

1246 quiet path in CI. `--yes` deliberately does not count as unattended: it 

1247 auto-answers `confirm()` gates but forbids nothing. 

1248 

1249 Readable anywhere, but it licenses nothing: a mid-body prompt still 

1250 requires `@task(interactive=True)`, because owning the terminal under 

1251 parallelism is a separate question from someone being there to answer. 

1252 """ 

1253 ctx = current() 

1254 return not (ctx.no_input or ctx.dry_run) and _stdin_is_tty() 

1255 

1256 

1257def tty() -> bool: 

1258 """Whether this run's output lands on a live terminal, colour policy 

1259 aside: the real stdout is a terminal (on Windows, one that interprets 

1260 ANSI) and output is not captured into a `--json` envelope. `NO_COLOR` and 

1261 `--color` do not change the answer — a person with colour turned off is 

1262 still watching, so gate pagers and live displays here and colour on 

1263 `colored()`. `False` outside a run: the scheduler stamps it per task.""" 

1264 return current().terminal 

1265 

1266 

1267def colored() -> bool: 

1268 """Whether this task's own output should dress for colour — the same 

1269 `never`/`always`/`auto` cascade footman's own chrome follows: 

1270 `--color=never` (or `NO_COLOR`, or a dumb terminal) says no, 

1271 `--color=always` forces colour even into a pipe (but never into a 

1272 `--json` envelope), and `auto` follows the terminal.""" 

1273 return _colored(current()) 

1274 

1275 

1276def progress(done: int, total: int = 0) -> None: 

1277 """Report this task's own progress: *done* of *total* units. 

1278 

1279 Some work knows exactly how far along it is — 23 of 150 migrations, 

1280 bytes of a download — and that is better evidence than any duration 

1281 history. A reporting task's counts drive the live bar directly 

1282 (counted beats estimated), so the bar is honest on the very first 

1283 run, where the estimator would still be guessing. 

1284 

1285 ```python 

1286 @task 

1287 def migrate(): 

1288 for i, record in enumerate(records, 1): 

1289 apply(record) 

1290 progress(i, len(records)) 

1291 ``` 

1292 

1293 A `total` of 0 (or less) clears the report, returning the run to its 

1294 estimate. Outside a run, or with no live status line, this is a 

1295 no-op — plain calls and captured runs cost nothing. 

1296 """ 

1297 status = active_status() 

1298 if status is None: 

1299 return 

1300 ctx = current() 

1301 name = ctx.task or "task" 

1302 if total > 0: 

1303 status.unit_counted(name, max(done, 0), total) 

1304 else: # a cleared report: back to the estimate 

1305 with contextlib.suppress(Exception): 

1306 status.counted.pop(name, None) 

1307 

1308 

1309def track(iterable: Iterable[_T], total: int | None = None) -> Iterator[_T]: 

1310 """Iterate *iterable*, reporting progress as it goes. 

1311 

1312 The ergonomic form of `progress()`: the total comes from `len()` when 

1313 the iterable has one, or from *total* when you know it for a 

1314 generator. Without either, iteration still works — the run simply 

1315 keeps whatever progress it had. 

1316 

1317 ```python 

1318 @task 

1319 def migrate(): 

1320 for record in track(load_records()): 

1321 apply(record) 

1322 ``` 

1323 """ 

1324 if total is None: 1324 ↛ 1326line 1324 didn't jump to line 1326 because the condition on line 1324 was always true

1325 total = len(iterable) if isinstance(iterable, Sized) else 0 

1326 done = 0 

1327 try: 

1328 for item in iterable: 

1329 yield item 

1330 done += 1 

1331 if total: 1331 ↛ 1328line 1331 didn't jump to line 1328 because the condition on line 1331 was always true

1332 progress(done, total) 

1333 finally: 

1334 if total: # leaving early (a break, an exception) resets the report 1334 ↛ exitline 1334 didn't return from function 'track' because the condition on line 1334 was always true

1335 progress(0, 0) 

1336 

1337 

1338def inherited() -> Callable[..., Any]: 

1339 """The task this one shadows in the cascade — footman's `super()`. 

1340 

1341 A nearer `tasks.py` overriding a task by name usually wants to *extend* 

1342 it, not replace it. Call this inside the overriding task's body to get 

1343 the task it shadows, then call that like the plain function it is: 

1344 

1345 ```python 

1346 # svc/api/tasks.py — the root also defines `check` 

1347 @task 

1348 def check(fix: bool = False, contracts: bool = True): 

1349 inherited()(fix=fix) # arguments are forwarded explicitly 

1350 if contracts: 

1351 run("./verify-contracts.sh") 

1352 ``` 

1353 

1354 Forwarding is deliberately manual: the two signatures are independent 

1355 (a leaf usually adds a parameter), so automatic forwarding could only 

1356 drop arguments silently or fail at run time — where spelling the call 

1357 out shows you the mismatch as you type it. Being an ordinary call, 

1358 it also runs to completion 

1359 before the next statement — and composes with `parallel(inherited(), 

1360 extra)` when you want otherwise. 

1361 

1362 `fm --where <task>` lists the whole shadow chain; `fm --help <task>` 

1363 shows the inherited task's options, so you can read the forwarding 

1364 call straight off it. 

1365 """ 

1366 from footman import _discover 

1367 

1368 fn = current().fn 

1369 if fn is None: 1369 ↛ 1370line 1369 didn't jump to line 1370 because the condition on line 1369 was never true

1370 raise RuntimeError( 

1371 "inherited() works inside a running task — footman resolves the " 

1372 "task being shadowed from the one currently running" 

1373 ) 

1374 previous = _discover.shadowed(fn) 

1375 if previous is None: 

1376 name = current().task or getattr(fn, "__name__", "this task") 

1377 raise RuntimeError( 

1378 f"{name} does not shadow an inherited task — nothing above it in " 

1379 f"the cascade defines that name (fm --where {name} lists the chain)" 

1380 ) 

1381 

1382 @functools.wraps(previous) 

1383 def call_inherited(*args: Any, **kwargs: Any) -> Any: 

1384 # Point the context at the task being called, so an `inherited()` 

1385 # inside *it* walks one level further up instead of resolving to 

1386 # itself — a three-deep cascade would otherwise recurse forever. 

1387 from footman import registry 

1388 

1389 ctx = current() 

1390 saved = ctx.fn 

1391 ctx.fn = previous 

1392 try: 

1393 # The shadowed *body*, run inside this task: an override chain, the 

1394 # way `super()` is — not a second task. It shares this task's 

1395 # context, result, and reported duration, and never becomes a unit 

1396 # of the run in its own right. 

1397 return registry.task_body(previous)(*args, **kwargs) 

1398 finally: 

1399 ctx.fn = saved 

1400 

1401 return call_inherited 

1402 

1403 

1404class RunFailed(Exception): 

1405 """A `run()` command exited non-zero (and `nofail` was not set). 

1406 

1407 The message names the command the way every other surface does — with 

1408 any `Secret` argument redacted — because a failure line is a display, 

1409 and the one place it most reliably lands is somebody's CI log. The 

1410 record it carries, `.result`, is untouched: `.result.command` is the 

1411 real command line, for the handler that reads rather than prints.""" 

1412 

1413 def __init__(self, result: Result) -> None: 

1414 self.result = result 

1415 super().__init__(f"`{result.shown}` exited with code {result.code}") 

1416 

1417 

1418class RunTimeout(RunFailed): 

1419 """A `run(timeout=…)` expired and the process tree was killed. 

1420 

1421 A `RunFailed`, so every `except RunFailed` keeps catching it; catch this 

1422 to tell a hang from an ordinary non-zero exit — the distinction a version 

1423 or help probe branches on.""" 

1424 

1425 def __init__(self, result: Result, timeout: float) -> None: 

1426 self.result = result 

1427 self.timeout = timeout 

1428 Exception.__init__( 

1429 self, f"`{result.shown}` timed out after {timeout:g}s and was killed" 

1430 ) 

1431 

1432 

1433class CommandNotFound(FileNotFoundError): 

1434 """The executable for a `run()` does not exist — nothing was spawned. 

1435 

1436 A `FileNotFoundError`, so an existing `except FileNotFoundError` keeps 

1437 catching it. Deliberately *not* a `RunFailed`, and not silenced by 

1438 `nofail=True`: there is no exit code to interpret, because no command 

1439 ran — the environment is missing the tool, or the name is misspelled 

1440 (which a toolroom handle cannot catch at attribute time: it mints a 

1441 Tool for any spelling). Carries `.command`.""" 

1442 

1443 def __init__(self, command: str) -> None: 

1444 self.command = command 

1445 super().__init__( 

1446 f"no executable {command!r} found on PATH — the tool is not " 

1447 f"installed here, or the name is misspelled. Install it, or gate " 

1448 f"the task with @footman.requires_tool({command!r}, reason='…') " 

1449 f"so it lists as unavailable instead of failing mid-run." 

1450 ) 

1451 

1452 

1453class Failed(Exception): 

1454 """A task chose to fail — the exception `footman.fail()` raises. 

1455 

1456 A *deliberate* stop with a reason (and optional exit code): the user-facing 

1457 sibling of `RunFailed` (which is a *command*'s failure). Carries `.reason` 

1458 and `.code`; footman renders the reason verbatim — no type prefix — in the 

1459 failure line and the `--json` `error` field. Exported so a task can 

1460 `except footman.Failed:`, but `fail()` is the blessed way to raise it. 

1461 """ 

1462 

1463 def __init__(self, reason: str = "", *, code: int = 1) -> None: 

1464 self.reason = reason 

1465 self.code = code 

1466 super().__init__(reason) 

1467 

1468 

1469def effective_timeout(own: float | None, ctx: Context) -> float: 

1470 """The bound that actually applied to a call, for its error message. 

1471 

1472 A call may carry its own `timeout=`, or inherit whatever the enclosing 

1473 `@task(timeout=…)` had left. Reporting the call's own would print `0s` 

1474 for a task-imposed deadline, which reads as a broken timeout rather than 

1475 an enforced one. A derived bound is rounded: it is a remainder computed 

1476 to the microsecond, and `0.399666s` in a failure line is noise where 

1477 `0.4s` is the number the author wrote. 

1478 """ 

1479 if own is not None: 

1480 return own 

1481 if ctx.timeout_declared is not None: 1481 ↛ 1483line 1481 didn't jump to line 1483 because the condition on line 1481 was always true

1482 return ctx.timeout_declared 

1483 return 0.0 

1484 

1485 

1486class TimedOut(Failed): 

1487 """A task's `@task(timeout=…)` deadline expired. 

1488 

1489 A `Failed`, so `except footman.Failed:` keeps catching it and the reason 

1490 renders verbatim in the failure line and the `--json` error field. 

1491 

1492 Carries `.timeout` (the declared seconds) and `.after` — what became of 

1493 the work: `stopped` (footman issued a stop before the body returned) or 

1494 `completed` (no stop was issued and the body finished on its own, just 

1495 late). An open set: `TaskResult.after_deadline` explains which further 

1496 values are designed and why they are not shipped. 

1497 

1498 The message never pretends the work did not happen. Where the body 

1499 completed, it says so and says what the body's own verdict was, then 

1500 states that the deadline governs — the receipt tells the whole truth 

1501 while the task still fails on its contract 

1502 (notes/20260807-timeout-and-retry.md). 

1503 """ 

1504 

1505 def __init__( 

1506 self, 

1507 task: str, 

1508 timeout: float, 

1509 *, 

1510 after: str = "stopped", 

1511 body: str = "", 

1512 at: float = 0.0, 

1513 not_retried: bool = False, 

1514 ) -> None: 

1515 self.timeout = timeout 

1516 self.after = after 

1517 self.not_retried = not_retried 

1518 said = f"{task} exceeded its {timeout:g}s deadline" 

1519 if after == "completed": 

1520 # Report the outcome being discarded rather than presenting this 

1521 # as work that never finished. 

1522 when = f" at {at:g}s" if at else "" 

1523 verdict = f" with {body}" if body else "" 

1524 said += f"; the body completed{when}{verdict} — the deadline governs" 

1525 else: 

1526 said += " and was stopped" 

1527 if not_retried: 1527 ↛ 1531line 1527 didn't jump to line 1531 because the condition on line 1527 was never true

1528 # The diagnosis the author needs is "this body has no 

1529 # checkpoint", and it is only inferable if this reads 

1530 # differently from ordinary retry exhaustion. 

1531 said += " — not retried" 

1532 super().__init__(said, code=124) 

1533 

1534 

1535def coroutine_refusal(kind: str, fn: Any = None) -> Failed: 

1536 """The taught refusal for an `async def` body footman would never run. 

1537 

1538 Calling an `async def` builds a coroutine and executes none of it, so a 

1539 body left like that reports success for work that never happened — the one 

1540 receipt the design refuses to mint (docs/design.md, "No fiction"). footman 

1541 runs no event loop on purpose, and the same page says why, so this names 

1542 the way in rather than pretending to be one. 

1543 

1544 The definition site is part of the message because this is a refusal about 

1545 a *declaration*, not about anything the run did: the answer to "which one?" 

1546 is a place in a file, and a chain across a cascade can hold a great many 

1547 bodies. `unwrap` first, so a decorated body points at the `async def` 

1548 somebody wrote rather than at the wrapper that returned its coroutine. 

1549 

1550 One phrasing for both callers: tasks go through `_executor`, steps through 

1551 `_step`'s pump, and two copies of a message drift. 

1552 """ 

1553 where = "" 

1554 if fn is not None: 1554 ↛ 1558line 1554 didn't jump to line 1558 because the condition on line 1554 was always true

1555 code = getattr(inspect.unwrap(fn), "__code__", None) 

1556 if code is not None: 1556 ↛ 1558line 1556 didn't jump to line 1558 because the condition on line 1556 was always true

1557 where = f" at {code.co_filename}:{code.co_firstlineno}" 

1558 return Failed( 

1559 f"the {kind} body{where} is an `async def` and footman runs no event " 

1560 f"loop, so calling it builds a coroutine and executes nothing. Make " 

1561 f"it a plain `def` and drive the async work from inside it " 

1562 f"(`asyncio.run(...)`)." 

1563 ) 

1564 

1565 

1566def generator_refusal(kind: str, fn: Any = None, *, is_async: bool = False) -> Failed: 

1567 """The taught refusal for a yielding body footman would never run. 

1568 

1569 Calling a generator function builds the generator and executes none of 

1570 its body, so a `@task` written with `yield` reported `ok` for work that 

1571 never happened — the receipt the design refuses to mint. The shape is 

1572 refused rather than pumped on purpose: `yield` on a *task* is reserved 

1573 for the service form (a body that yields once it is ready and tears down 

1574 after), so this refusal is the placeholder for that meaning, not a 

1575 ruling against it. Steps already give `yield` a meaning, which is the 

1576 way out the message names. 

1577 

1578 An `async def` generator is the coroutine refusal's missed sibling — it 

1579 is not a coroutine, so `iscoroutine` never sees it, and it is not a 

1580 plain generator function either, so a step's pump never takes it. Both 

1581 kinds refuse here, tasks and steps alike, with the async spelling named 

1582 so the fix (drop the `async`) is visible in the message. 

1583 

1584 Definition site over traceback, and `unwrap` first, for the same 

1585 reasons as `coroutine_refusal` above; one phrasing for both callers, so 

1586 two copies of a message cannot drift. 

1587 """ 

1588 where = "" 

1589 if fn is not None: 1589 ↛ 1593line 1589 didn't jump to line 1593 because the condition on line 1589 was always true

1590 code = getattr(inspect.unwrap(fn), "__code__", None) 

1591 if code is not None: 1591 ↛ 1593line 1591 didn't jump to line 1593 because the condition on line 1591 was always true

1592 where = f" at {code.co_filename}:{code.co_firstlineno}" 

1593 shape = "an `async def` generator" if is_async else "a generator function" 

1594 if kind == "step": 

1595 remedy = "A step yields from a plain `def` body: drop the `async`." 

1596 else: 

1597 remedy = ( 

1598 "Lift the yielding work into a `@step`, or build and return the " 

1599 "iterator from a non-yielding body." 

1600 ) 

1601 return Failed( 

1602 f"the {kind} body{where} is {shape}, so calling it builds a " 

1603 f"generator and executes nothing. footman does not run a yielding " 

1604 f"{kind} body (the shape is reserved for services to come). {remedy}" 

1605 ) 

1606 

1607 

1608def fail(reason: str = "", *, code: int = 1) -> NoReturn: 

1609 """Fail the current task with a *reason* (and exit *code*, default 1). 

1610 

1611 The blessed way to stop a task deliberately: `fail("no open PR to act on")`, 

1612 or `fail("reserved branch", code=3)` to pick the exit code too. A *function*, 

1613 not a `raise`, on purpose — a task lives in your repo under your linter, and 

1614 `raise SomeError("a literal")` trips flake8-errmsg (EM101) and tryceratops 

1615 (TRY003) at the call site, every failure. A call trips neither, the same 

1616 reason `sys.exit()` and `pytest.fail()` are functions. `return N` still 

1617 spells a bare code; `sys.exit(...)` still works — `fail()` is just the 

1618 footman-native one, with a reason and a code together. 

1619 

1620 `fail()` means failure, so `code=0` — success — is a contradiction it 

1621 refuses rather than a corner it interprets: `return 0` (or a plain 

1622 `return`) spells stopping early with success. 

1623 """ 

1624 if code == 0: 

1625 raise ValueError( 

1626 "fail() means failure and exit code 0 is success — return 0 " 

1627 "(or just return) spells stopping early with success" 

1628 ) 

1629 raise Failed(reason, code=code) 

1630 

1631 

1632def _is_deliberate_stop(err: BaseException) -> bool: 

1633 """Whether *err* is a chosen stop with a message (`fail()`/`sys.exit("…")`) 

1634 rather than a crash — so its reason renders verbatim, no type prefix.""" 

1635 return isinstance(err, (SystemExit, Failed)) 

1636 

1637 

1638def _was_expected(err: BaseException) -> bool: 

1639 """Whether footman saw *err* coming — so no stack belongs with it. 

1640 

1641 A chosen stop, and a command that exited non-zero or ran out of time: three 

1642 outcomes the runner has words for, where a traceback would describe 

1643 footman's own plumbing rather than anything the reader did. Everything else 

1644 is an exception nobody planned — a bug in the tasks file — and the one 

1645 question it raises is *where*, which is why those carry a location and, 

1646 off a terminal, the stack. 

1647 """ 

1648 return isinstance(err, (SystemExit, Failed, RunFailed, RunTimeout)) 

1649 

1650 

1651def context_param_name(sig: inspect.Signature) -> str | None: 

1652 """Name of the task's context parameter (first param `ctx` / `Context`).""" 

1653 params = list(sig.parameters.values()) 

1654 if not params: 

1655 return None 

1656 first = params[0] 

1657 if first.name == "ctx" or first.annotation is Context: 

1658 return first.name 

1659 return None 

1660 

1661 

1662# --- output routing ---------------------------------------------------------- 

1663 

1664 

1665class Status(Protocol): 

1666 """What context asks of a live status line — the duck-typed face of 

1667 `_progress.StatusLine`. context stays ignorant of _progress on purpose; 

1668 this Protocol is the contract, satisfied structurally.""" 

1669 

1670 counted: dict[str, tuple[int, int]] 

1671 

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

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

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

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

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

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

1678 def suspend(self) -> None: ... 

1679 def resume(self) -> None: ... 

1680 

1681 

1682# The run's live status line, registered by the scheduler for the duration 

1683# of a run; outside a run there is none. 

1684_status: Status | None = None 

1685 

1686# The widest command label seen, for aligning the step lines' time column. 

1687# Seeded from the previous run's history (so alignment is right from the 

1688# first line on a warm run) and grown as a running max on a cold one. 

1689_cmd_width: int = 0 

1690 

1691 

1692def seed_cmd_width(width: int) -> None: 

1693 global _cmd_width 

1694 _cmd_width = max(0, width) 

1695 

1696 

1697def cmd_width() -> int: 

1698 return _cmd_width 

1699 

1700 

1701def _observe_cmd(label: str) -> int: 

1702 """Return the padding width for *label*, learning as labels stream by. 

1703 

1704 In terminal cells, like every other column — a `step("构建镜像")` is 

1705 four characters and eight cells wide.""" 

1706 from footman._describe import display_width 

1707 

1708 global _cmd_width 

1709 _cmd_width = max(_cmd_width, display_width(label)) 

1710 return _cmd_width 

1711 

1712 

1713def set_status(status: Status | None) -> None: 

1714 global _status 

1715 _status = status 

1716 

1717 

1718def active_status() -> Status | None: 

1719 """The run's live status line, or `None` outside a run.""" 

1720 return _status 

1721 

1722 

1723class _Router: 

1724 """A `sys.stdout`/`sys.stderr` proxy that sends each write to the current 

1725 task's sink — `err_sink` for the stderr router, `sink` for stdout. At task 

1726 level the two point at one buffer (combined, order-preserving); a `run()` 

1727 capturing an in-process callable splits them to record the step's streams.""" 

1728 

1729 def __init__(self, real: TextIO, *, err: bool = False) -> None: 

1730 self.real = real 

1731 self._err = err 

1732 try: 

1733 self._tty = real.isatty() 

1734 except Exception: 

1735 self._tty = False 

1736 

1737 def _sink(self) -> TextIO | None: 

1738 ctx = current() 

1739 return ctx.err_sink if self._err else ctx.sink 

1740 

1741 def write(self, s: str) -> int: 

1742 sink = self._sink() 

1743 if sink is not None: 

1744 return sink.write(s) 

1745 # A real-terminal write: the live status line (if any) must clear 

1746 # itself first and learn whether the cursor now sits at column 0. 

1747 if self._tty and _status is not None: 1747 ↛ 1748line 1747 didn't jump to line 1748 because the condition on line 1747 was never true

1748 _status.notify(s) 

1749 return self.real.write(s) 

1750 

1751 def flush(self) -> None: 

1752 (self._sink() or self.real).flush() 

1753 

1754 def isatty(self) -> bool: 

1755 return self.real.isatty() 

1756 

1757 def __getattr__(self, name: str) -> Any: 

1758 return getattr(self.real, name) 

1759 

1760 

1761_router: _Router | None = None 

1762_err_router: _Router | None = None 

1763 

1764 

1765def real_stdout() -> TextIO: 

1766 """The underlying stdout, bypassing the routing proxy.""" 

1767 return _router.real if _router is not None else sys.stdout 

1768 

1769 

1770def real_stderr() -> TextIO: 

1771 """The underlying stderr, bypassing the routing proxy.""" 

1772 return _err_router.real if _err_router is not None else sys.stderr 

1773 

1774 

1775def real_stdin() -> TextIO: 

1776 """The underlying stdin, bypassing the guard proxy. 

1777 

1778 The framework's own prompts read here — they already write through 

1779 `real_stderr()` — because the guard exists for *task-body* reads, and the 

1780 managed window now opens before binding, where `ask()` legitimately asks. 

1781 """ 

1782 real = getattr(sys.stdin, "_real", None) 

1783 return real if real is not None else sys.stdin 

1784 

1785 

1786_T = TypeVar("_T") 

1787_V = TypeVar("_V") 

1788 

1789_UNSET: Any = object() # "no default given" — None is a valid default/value 

1790 

1791_prompt_lock = threading.Lock() 

1792 

1793# --- the boundary's one read of stdin ---------------------------------------- 

1794# 

1795# stdin is the fourth process global: task bodies never read it (the guard in 

1796# _globals refuses), and parameters marked `stdin` are filled here, at the 

1797# boundary. The stream is read once, fully, into memory, and the same payload 

1798# serves every parameter in the run that asks — stdin is consumable, so two 

1799# tasks in a chain could never each read it. `None` means "not provided": a 

1800# terminal, a missing stream, or an embedded `Runner.invoke` that injected 

1801# nothing. The read is lazy — only a bind that meets a `stdin` parameter 

1802# calls `stdin_payload()`, so a run without one never touches the stream. 

1803 

1804_STDIN_UNREAD: Any = object() 

1805_stdin_payload: Any = _STDIN_UNREAD 

1806 

1807 

1808def stdin_payload() -> bytes | None: 

1809 """The process's piped stdin as bytes, read once and cached; `None` when 

1810 stdin is a terminal (interactive input is `ask()`'s job, and a pipe 

1811 target must never block on a terminal read).""" 

1812 global _stdin_payload 

1813 if _stdin_payload is _STDIN_UNREAD: 1813 ↛ 1814line 1813 didn't jump to line 1814 because the condition on line 1813 was never true

1814 if _stdin_is_tty() or sys.stdin is None: 

1815 _stdin_payload = None 

1816 else: 

1817 try: 

1818 # `.buffer` reaches the raw stream through the guard proxy — 

1819 # this is the boundary, exactly who may read. 

1820 _stdin_payload = sys.stdin.buffer.read() 

1821 except (AttributeError, OSError, ValueError): 

1822 # No byte buffer (an embedded/captured stream) or a stream 

1823 # that refuses to read: nothing was provided. 

1824 _stdin_payload = None 

1825 payload: bytes | None = _stdin_payload # the sentinel is gone by here 

1826 return payload 

1827 

1828 

1829# --- scripted answers: the table a recording answers run() from --------------- 

1830 

1831 

1832class _Answers: 

1833 """A `recording(answers=…)` table, normalised once and shared by reference. 

1834 

1835 Keys are command prefixes matched against the normalised shown command 

1836 (`Result.command` — what a recording asserts on): a key matches when 

1837 the command *is* it or starts with it followed by a space, and the 

1838 longest matching key wins. No tokenising: a command string is never 

1839 split (footman hands it to the OS unsplit on Windows), so the rule is 

1840 the same on every platform and can never disagree with what would run. 

1841 A tuple key is joined with spaces; a string key has its whitespace 

1842 collapsed. 

1843 

1844 Values: a `str` is stdout with exit 0; an `int` is an exit code; a 

1845 `Result` (footman's or toolroom's) sets code and both streams; an 

1846 exception *instance* is raised at the door — the missing-binary case; a 

1847 `list` is consumed in order across repeated matches, and refuses by 

1848 name once exhausted. Sequence cursors live here, under one lock, so a 

1849 `parallel()` child consuming an entry advances the same script. 

1850 """ 

1851 

1852 __slots__ = ("_cursors", "_lock", "_table") 

1853 

1854 def __init__(self, table: Mapping[Any, Any]) -> None: 

1855 canned: dict[str, Any] = {} 

1856 for key, value in table.items(): 

1857 canned[self._key(key)] = self._checked(value, key) 

1858 self._table = canned 

1859 self._cursors: dict[str, int] = {} 

1860 self._lock = threading.Lock() 

1861 

1862 @staticmethod 

1863 def _key(key: Any) -> str: 

1864 if isinstance(key, str): 

1865 tokens: list[Any] = key.split() 

1866 else: 

1867 try: 

1868 tokens = list(key) 

1869 except TypeError: 

1870 tokens = [] 

1871 if not tokens or not all(isinstance(t, str) for t in tokens): 

1872 raise TypeError( 

1873 f"recording(answers=): a key is a command prefix — one string " 

1874 f'("uv tool list") or a tuple of tokens — not {key!r}' 

1875 ) 

1876 return " ".join(tokens) 

1877 

1878 @classmethod 

1879 def _checked(cls, value: Any, key: Any) -> Any: 

1880 if isinstance(value, list): 

1881 if not value or any(isinstance(v, list) for v in value): 

1882 raise TypeError( 

1883 f"recording(answers=): the sequence for {key!r} must be a " 

1884 f"non-empty list of single answers — one per match" 

1885 ) 

1886 return [cls._checked(v, key) for v in value] 

1887 if isinstance(value, type) and issubclass(value, BaseException): 

1888 raise TypeError( 

1889 f"recording(answers=): the answer for {key!r} is an exception " 

1890 f"class — pass an instance ({value.__name__}(...)), which is " 

1891 f"what the call will raise" 

1892 ) 

1893 if isinstance(value, (str, int, BaseException)) or hasattr(value, "stdout"): 

1894 return value 

1895 raise TypeError( 

1896 f"recording(answers=): the answer for {key!r} must be a str " 

1897 f"(stdout), an int (exit code), a Result, an exception instance, " 

1898 f"or a list of those — not {type(value).__name__}" 

1899 ) 

1900 

1901 def answer(self, command: str) -> tuple[int, str, str] | None: 

1902 """The scripted (code, stdout, stderr) for *command*, `None` when no 

1903 key matches; raises the scripted exception, or `LookupError` when a 

1904 sequence has run dry.""" 

1905 best: str | None = None 

1906 for key in self._table: 

1907 if (command == key or command.startswith(key + " ")) and ( 

1908 best is None or len(key) > len(best) 

1909 ): 

1910 best = key 

1911 if best is None: 

1912 return None 

1913 value = self._table[best] 

1914 if isinstance(value, list): 

1915 with self._lock: 

1916 n = self._cursors.get(best, 0) 

1917 if n >= len(value): 

1918 plural = "y" if len(value) == 1 else "ies" 

1919 raise LookupError( 

1920 f"recording(answers=): the sequence for {best!r} " 

1921 f"answered its {len(value)} entr{plural} and " 

1922 f"`{command}` asked for one more — the script is " 

1923 f"shorter than the story; add an entry, or make it a " 

1924 f"single value to answer every match" 

1925 ) 

1926 self._cursors[best] = n + 1 

1927 value = value[n] 

1928 if isinstance(value, BaseException): 

1929 raise value 

1930 if isinstance(value, str): 

1931 return 0, value, "" 

1932 if hasattr(value, "stdout"): # a Result — footman's or toolroom's twin 

1933 return int(value), value.stdout, value.stderr 

1934 return int(value), "", "" 

1935 

1936 

1937# The table `Runner.invoke(answers=…)` sets for the span of one invocation: 

1938# the executor mints its own contexts, so the ambient one is never consulted 

1939# there, and `run()` falls back to this when its context carries no table. 

1940_injected_answers: _Answers | None = None 

1941 

1942 

1943def _inject_answers(table: _Answers | None) -> _Answers | None: 

1944 global _injected_answers 

1945 previous = _injected_answers 

1946 _injected_answers = table 

1947 return previous 

1948 

1949 

1950def _scripted_answer(ctx: Context, command: str) -> tuple[int, str, str] | None: 

1951 table = ctx.answers if ctx.answers is not None else _injected_answers 

1952 if table is None: 

1953 return None 

1954 if not isinstance(table, _Answers): # a raw mapping given to Context() 1954 ↛ 1955line 1954 didn't jump to line 1955 because the condition on line 1954 was never true

1955 table = _Answers(table) 

1956 ctx.answers = table 

1957 return table.answer(command) 

1958 

1959 

1960def _inject_stdin(payload: bytes | None) -> Any: 

1961 """Set the boundary payload directly (the testing seam) and return the 

1962 previous cache state for `_restore_stdin_payload`. `Runner.invoke` always 

1963 injects — under a test runner the real stream is the harness's, never the 

1964 test's — so `stdin=None` means "a terminal", not "read the harness".""" 

1965 global _stdin_payload 

1966 previous = _stdin_payload 

1967 _stdin_payload = payload 

1968 return previous 

1969 

1970 

1971def _restore_stdin_payload(previous: Any) -> None: 

1972 global _stdin_payload 

1973 _stdin_payload = previous 

1974 

1975 

1976def _stdin_is_tty() -> bool: 

1977 try: 

1978 return sys.stdin is not None and sys.stdin.isatty() 

1979 except Exception: 

1980 return False 

1981 

1982 

1983def _scrub(text: str) -> str: 

1984 """Drop control characters (ESC included) from text echoed to the terminal, 

1985 so an untrusted `select()` label or message can't inject ANSI escapes — the 

1986 terminal-injection class that has bitten other CLIs.""" 

1987 return "".join(c for c in text if c.isprintable() or c == "\t") 

1988 

1989 

1990def _prompt_core( 

1991 message: str = "", *, default: str | None = None, secret: bool = False 

1992) -> str: 

1993 """The prompt mechanics, unguarded. Writes to the real terminal on stderr 

1994 (never captured, never in `--json` stdout), serialises on `_prompt_lock`, 

1995 clears the live status line, and degrades off a tty (returns `default`, or 

1996 raises). The framework's `ask()` resolution calls this directly; user code 

1997 goes through the guarded `prompt()`.""" 

1998 if not _stdin_is_tty(): 

1999 if default is not None: 

2000 return default 

2001 raise RuntimeError( 

2002 "no terminal is attached, so there is no one to ask. Pass a " 

2003 "default for unattended runs, or take the value as a task " 

2004 "parameter (a CLI flag) instead." 

2005 ) 

2006 err = real_stderr() 

2007 status = active_status() 

2008 with _prompt_lock: 

2009 if status is not None: 2009 ↛ 2010line 2009 didn't jump to line 2010 because the condition on line 2009 was never true

2010 status.notify(message or " ") # clear the live status line 

2011 closed = ( 

2012 "stdin closed mid-prompt (Ctrl-D, or piped input ran out) — " 

2013 "nothing left to ask. Pass the value as a flag, or rerun with " 

2014 "a terminal attached." 

2015 ) 

2016 if secret: 2016 ↛ 2017line 2016 didn't jump to line 2017 because the condition on line 2016 was never true

2017 import getpass 

2018 

2019 try: 

2020 value = getpass.getpass(message, stream=err).rstrip("\n") 

2021 except EOFError: 

2022 raise RuntimeError(closed) from None 

2023 else: 

2024 err.write(message) 

2025 err.flush() 

2026 line = real_stdin().readline() 

2027 if line == "": # EOF: a re-ask loop would spin on it forever 

2028 raise RuntimeError(closed) 

2029 value = line.rstrip("\n") 

2030 if status is not None: 2030 ↛ 2031line 2030 didn't jump to line 2031 because the condition on line 2030 was never true

2031 status.notify("\n") # Enter returned the cursor to column 0 

2032 return default if value == "" and default is not None else value 

2033 

2034 

2035def _guard_interactive(what: str) -> Context: 

2036 """Refuse a mid-body interactive call in a non-interactive task. 

2037 

2038 Inside an ordinary (captured, possibly parallel) task body the prompt 

2039 would be swallowed by the capture buffer or race a sibling for the 

2040 terminal, so it is a loud, taught error rather than a silent hang. The 

2041 framework's own up-front `ask()` resolution runs with `in_task` unset and 

2042 is never caught here. Returns the active context (for `--no-input`/`--yes`).""" 

2043 ctx = current() 

2044 if ctx.in_task and not ctx.interactive: 

2045 raise RuntimeError( 

2046 f"{what} was called inside task {ctx.task or '?'!r}, which is not " 

2047 f"interactive. Either mark it `@task(interactive=True)` so it owns " 

2048 f"the terminal, or declare the value as a parameter with `ask()` so " 

2049 f"footman asks before the task runs." 

2050 ) 

2051 return ctx 

2052 

2053 

2054def _refuse_prompt_markers(peeled: Any) -> None: 

2055 """Markers that decide how a *parameter* gets filled have no meaning once 

2056 a prompt is already asking — refused by name, so a copy-pasted annotation 

2057 teaches instead of half-working.""" 

2058 offers = ( 

2059 (peeled.ask is not None, "ask()"), 

2060 (peeled.env is not None, "env()"), 

2061 (peeled.stdin is not None, "stdin"), 

2062 (peeled.forward, "forward"), 

2063 (peeled.hidden, "hidden"), 

2064 (peeled.default_fn is not None, "default(fn)"), 

2065 (peeled.completer is not None, "suggest()"), 

2066 ) 

2067 named = [name for present, name in offers if present] 

2068 if named: 

2069 raise ValueError( 

2070 f"prompt(type=…): {', '.join(named)} decides how a parameter is " 

2071 f"filled, which a live prompt already settles — declare the value " 

2072 f"as a task parameter instead (or use select() for a menu)" 

2073 ) 

2074 

2075 

2076@overload 

2077def prompt( 

2078 message: str = ..., *, default: str | None = ..., secret: bool = ... 

2079) -> str: ... 

2080@overload 

2081def prompt( 

2082 message: str = ..., 

2083 *, 

2084 type: type[_T], 

2085 default: _T | None = ..., 

2086 secret: bool = ..., 

2087) -> _T: ... 

2088@overload 

2089def prompt( 

2090 message: str = ..., *, type: Any, default: Any = ..., secret: bool = ... 

2091) -> Any: ... 

2092 

2093 

2094def prompt( 

2095 message: str = "", 

2096 *, 

2097 type: Any = str, 

2098 default: Any = None, 

2099 secret: bool = False, 

2100) -> Any: 

2101 """Ask the person running the task for a line of input. 

2102 

2103 A bare `input()` doesn't work in a task: its prompt goes to stdout, which 

2104 footman buffers per task so parallel output never interleaves (and `--json` 

2105 stays one envelope), so the prompt is swallowed and the task looks hung. 

2106 `prompt()` writes to the real terminal on stderr instead — never captured — 

2107 and serialises concurrent prompts. 

2108 

2109 Usable only inside an `@task(interactive=True)` task; called in an ordinary 

2110 task body it raises a taught error naming the two fixes. Off a terminal, 

2111 under `--no-input` or `--dry-run`, or when it would otherwise block, it 

2112 returns `default` if given, else raises — an unattended run fails loudly, 

2113 and a rehearsal is unattended by nature. For a value a 

2114 script must supply, take it as a task parameter (a CLI flag) instead. 

2115 

2116 `type=` reuses the parameter type language as the validator: the answer 

2117 runs through the same coercion pipeline a flag does — `int`, `Path`, an 

2118 enum, `Literal[…]`, a union, or `Annotated[…]` carrying `between()` / 

2119 `check()` / `exists` — and a bad answer is taught and re-asked rather 

2120 than raised. An empty answer takes `default` (returned as given, 

2121 uncoerced), and an unattended run takes it the same way. One value per 

2122 prompt: collections, and the markers that decide how a *parameter* gets 

2123 filled (`ask()`, `env()`, `suggest()`, …), are refused by name — declare 

2124 a parameter, or reach for `select()`. 

2125 

2126 `secret=True` hides the typing (getpass) *and* returns a `Secret`, so the 

2127 answer redacts in tracebacks and structured output the same way 

2128 `ask(secret=True)` does — hiding a value while it is typed and then 

2129 printing it in the first traceback would be a strange kind of secret. 

2130 A default returned unattended is wrapped too: where the value came from 

2131 doesn't change what it is. A secret is text by nature, so `secret=True` 

2132 with a non-`str` `type=` is refused — `ask(secret=True)` on a typed 

2133 parameter is that spelling. 

2134 """ 

2135 from footman.params import Secret 

2136 

2137 if secret and type is not str: 

2138 raise ValueError( 

2139 "prompt(): a secret answer is text — drop type=, or declare the " 

2140 "value as a typed parameter with ask(secret=True)" 

2141 ) 

2142 ctx = _guard_interactive("prompt()") 

2143 peeled = None 

2144 if type is not str: 

2145 # Refuse a bad type= before the unattended branch can paper over it 

2146 # with the default: a wrong annotation is a programming error, and 

2147 # those fail the same way attended or not. 

2148 from footman import _coerce 

2149 

2150 peeled = _coerce.peel(type) 

2151 _refuse_prompt_markers(peeled) 

2152 if peeled.multiple or peeled.mapping: 

2153 raise ValueError( 

2154 "prompt() takes one value — for several, use " 

2155 "select(multiple=True) or declare a parameter (Many[…])" 

2156 ) 

2157 if ctx.no_input or ctx.dry_run: 

2158 if default is not None: 

2159 return Secret(default) if secret else default 

2160 why = "--no-input is set" if ctx.no_input else "a dry-run is unattended" 

2161 raise RuntimeError( 

2162 f"prompt(): {why}, so nothing can be asked. Pass a " 

2163 f"default, or supply the value as a task parameter (a CLI flag)." 

2164 ) 

2165 if peeled is None: 

2166 answer = _prompt_core(message, default=default, secret=secret) 

2167 return Secret(answer) if secret else answer 

2168 

2169 from footman._executor import _coerce_extra 

2170 

2171 err = real_stderr() 

2172 while True: 

2173 # An empty answer means the default when one exists, so hand 

2174 # _prompt_core "" as its degrade value too: off a terminal the same 

2175 # default comes back through the same door. 

2176 raw = _prompt_core(message, default="" if default is not None else None) 

2177 if raw == "" and default is not None: 

2178 return default 

2179 try: 

2180 # ask()'s own pipeline for a token the splitter never saw: 

2181 # strict coercion, then choices / bounds / path / check(fn). 

2182 return _coerce_extra(raw, peeled, "the answer") 

2183 except ValueError as exc: 

2184 err.write(_scrub(f" {exc}") + "\n") 

2185 err.flush() 

2186 

2187 

2188def confirm(message: str, *, default: bool = False) -> bool: 

2189 """Ask a yes/no question. `--yes` auto-answers yes; Enter alone takes 

2190 `default`; off a terminal or under `--no-input`/`--dry-run` the answer 

2191 is `default`. Guarded like `prompt()` — interactive tasks only.""" 

2192 ctx = _guard_interactive("confirm()") 

2193 if ctx.assume_yes: 

2194 return True 

2195 if ctx.no_input or ctx.dry_run: 

2196 return default 

2197 reply = _prompt_core( 

2198 f"{message} {'[Y/n]' if default else '[y/N]'} ", 

2199 default="y" if default else "n", 

2200 ) 

2201 return reply.strip().lower() in ("y", "yes") 

2202 

2203 

2204@overload 

2205def select( 

2206 message: str, 

2207 options: Sequence[str], 

2208 *, 

2209 multiple: Literal[True], 

2210 default: list[str] = ..., 

2211) -> list[str]: ... 

2212@overload 

2213def select( 

2214 message: str, 

2215 options: Sequence[str], 

2216 *, 

2217 multiple: Literal[False] = False, 

2218 default: str = ..., 

2219) -> str: ... 

2220@overload 

2221def select( 

2222 message: str, 

2223 options: Sequence[str | tuple[str, _V]], 

2224 *, 

2225 multiple: Literal[True], 

2226 default: list[str | _V] = ..., 

2227) -> list[str | _V]: ... 

2228@overload 

2229def select( 

2230 message: str, 

2231 options: Sequence[str | tuple[str, _V]], 

2232 *, 

2233 multiple: Literal[False] = False, 

2234 default: str | _V = ..., 

2235) -> str | _V: ... 

2236 

2237 

2238def select( 

2239 message: str, 

2240 options: Sequence[Any], 

2241 *, 

2242 multiple: bool = False, 

2243 default: Any = _UNSET, 

2244) -> Any: 

2245 """Let the person pick from a runtime-computed list — the one interactive 

2246 case a flag can't cover, because the options aren't known until the task 

2247 runs (which changed packages to release, which stale branches to delete). 

2248 

2249 `options` are strings, or `(label, value)` pairs to show one thing and 

2250 return another. `multiple=True` returns the chosen subset as a list; 

2251 otherwise one value is returned. Guarded like `prompt()` (interactive tasks 

2252 only), and off a terminal or under `--no-input`/`--dry-run` it returns 

2253 `default`, or raises if none was given. 

2254 """ 

2255 ctx = _guard_interactive("select()") 

2256 return _select_core( 

2257 message, 

2258 options, 

2259 multiple=multiple, 

2260 default=default, 

2261 no_input=ctx.no_input or ctx.dry_run, 

2262 ) 

2263 

2264 

2265def _select_core( 

2266 message: str, 

2267 options: Sequence[Any], 

2268 *, 

2269 multiple: bool = False, 

2270 default: Any = _UNSET, 

2271 no_input: bool = False, 

2272) -> Any: 

2273 """The menu mechanics, unguarded — the framework's `ask()` menus call this 

2274 directly; user code goes through the guarded `select()`. Reads the real 

2275 stream, like every framework prompt.""" 

2276 opts = list(options) 

2277 if not opts: 2277 ↛ 2278line 2277 didn't jump to line 2278 because the condition on line 2277 was never true

2278 raise ValueError("select(): no options to choose from") 

2279 labels = [o[0] if isinstance(o, tuple) and len(o) == 2 else str(o) for o in opts] 

2280 values = [o[1] if isinstance(o, tuple) and len(o) == 2 else o for o in opts] 

2281 if no_input or not _stdin_is_tty(): 

2282 if default is not _UNSET: 

2283 return default 

2284 raise RuntimeError( 

2285 "select(): nothing can be asked (no terminal, or --no-input). Pass " 

2286 "default=…, or take the choice as a task parameter." 

2287 ) 

2288 err = real_stderr() 

2289 status = active_status() 

2290 with _prompt_lock: 

2291 if status is not None: 2291 ↛ 2292line 2291 didn't jump to line 2292 because the condition on line 2291 was never true

2292 status.notify(" ") 

2293 err.write(_scrub(message.rstrip()) + "\n") 

2294 for i, label in enumerate(labels, 1): 

2295 err.write(f" {i}) {_scrub(label)}\n") 

2296 hint = "numbers, comma-separated; 'all'; 'none'" if multiple else "a number" 

2297 err.write(f"select ({hint}): ") 

2298 err.flush() 

2299 line = real_stdin().readline().strip() 

2300 if status is not None: 2300 ↛ 2301line 2300 didn't jump to line 2301 because the condition on line 2300 was never true

2301 status.notify("\n") 

2302 if line == "" and default is not _UNSET: 2302 ↛ 2303line 2302 didn't jump to line 2303 because the condition on line 2302 was never true

2303 return default 

2304 if multiple: 

2305 return [values[i] for i in _parse_multi(line, len(values))] 

2306 return values[_parse_one(line, len(values))] 

2307 

2308 

2309def _parse_one(line: str, n: int) -> int: 

2310 try: 

2311 i = int(line) 

2312 except ValueError: 

2313 raise RuntimeError(f"select(): {line!r} is not a number 1-{n}.") from None 

2314 if not 1 <= i <= n: 

2315 raise RuntimeError(f"select(): {i} is out of range 1-{n}.") 

2316 return i - 1 

2317 

2318 

2319def _parse_multi(line: str, n: int) -> list[int]: 

2320 low = line.lower() 

2321 if low in ("all", "*"): 

2322 return list(range(n)) 

2323 if low == "none": 

2324 return [] 

2325 return sorted({_parse_one(tok, n) for tok in line.replace(",", " ").split()}) 

2326 

2327 

2328@contextlib.contextmanager 

2329def routing() -> Generator[tuple[TextIO, TextIO]]: 

2330 """Install stdout/stderr routers for the duration of a run. 

2331 

2332 Both streams proxy through the running task's sink, so an in-process tool's 

2333 stderr is captured alongside its stdout (matching the merged subprocess 

2334 capture) instead of leaking to the terminal. The routers are *stacked*, not 

2335 reset to None: a nested run — e.g. toolroom's `pytest(in_process=True)` driving 

2336 the shipped `fm` fixture — restores the outer routers on exit, so the outer 

2337 run's capture keeps working afterwards. 

2338 """ 

2339 global _router, _err_router 

2340 prev_out, prev_err = _router, _err_router 

2341 real_out, real_err = sys.stdout, sys.stderr 

2342 # A tool (or footman's own status line) may emit non-ASCII on a 

2343 # locale-encoded pipe (cp1252 on Windows CI, errors='strict' by default); 

2344 # degrade unencodable glyphs to '?' instead of crashing the run. 

2345 for stream in (real_out, real_err): 

2346 with contextlib.suppress(Exception): 

2347 # getattr, not hasattr-then-call: hasattr narrowing is not 

2348 # portable across checkers, the getattr is. 

2349 reconfigure = getattr(stream, "reconfigure", None) 

2350 if reconfigure is not None: 

2351 reconfigure(errors="replace") 

2352 _router, _err_router = _Router(real_out), _Router(real_err, err=True) 

2353 sys.stdout, sys.stderr = _router, _err_router 

2354 try: 

2355 # (real stdout, real stderr): task blocks land on the first, the live 

2356 # status line on the second — stdout is the answer, stderr commentary. 

2357 yield real_out, real_err 

2358 finally: 

2359 sys.stdout, sys.stderr = real_out, real_err 

2360 _router, _err_router = prev_out, prev_err 

2361 

2362 

2363# --- run() ------------------------------------------------------------------- 

2364 

2365 

2366def _is_code(value: Any) -> bool: 

2367 return isinstance(value, int) and not isinstance(value, bool) 

2368 

2369 

2370@dataclass(frozen=True) 

2371class Invocation: 

2372 """What a `run()` call *is*, apart from how it's spelled to execute. 

2373 

2374 toolroom's bridge builds one of these so `run()` can show a readable, 

2375 syntax-highlighted command line — options in separated form, tagged by 

2376 role — while executing whatever the tool actually needs (attached flags, 

2377 or an in-process callable). `parts` is the normalised, human form; 

2378 `exact` is the literal argv, shown under `--verbose` and always 

2379 copy-pasteable. Passing it is how the two are kept from drifting: both 

2380 come from one translation of one call. 

2381 """ 

2382 

2383 parts: tuple[tuple[str, str], ...] 

2384 exact: tuple[str, ...] 

2385 

2386 def text(self, *, exact: bool) -> str: 

2387 """The plain command line — the width-measured, non-colour form.""" 

2388 if exact: 

2389 # `exact` is the literal argv, and the bridge keeps a `Secret` 

2390 # whole in it on purpose — the child needs the real value. That 

2391 # makes this the one rendering path where the marker is still 

2392 # here to act on, and `--verbose` is a showing like any other: a 

2393 # secret must not appear just because the reader asked for the 

2394 # paste-able spelling. `parts` was redacted upstream by the 

2395 # bridge; this is the same rule reaching the other form. 

2396 from footman._describe import redact 

2397 

2398 return " ".join(_shell_quote(str(redact(a))) for a in self.exact) 

2399 return " ".join(text for _, text in self.parts) 

2400 

2401 def painted(self, *, color: bool, exact: bool) -> str: 

2402 """The shown command line, role-coloured when the stream wants it.""" 

2403 if exact or not color: 

2404 return self.text(exact=exact) 

2405 from footman._describe import paint_cli 

2406 

2407 return paint_cli(list(self.parts), color) 

2408 

2409 

2410def _shell_quote(text: str) -> str: 

2411 """Quote one token so the shown command line pastes back into a real shell. 

2412 

2413 Per-platform, so a Windows `.raw`/`--verbose` line actually round-trips: 

2414 POSIX uses `shlex.quote`; Windows uses stdlib `subprocess.list2cmdline` (the 

2415 exact inverse of the parsing `CreateProcess` does), never `shlex` — which 

2416 emits POSIX single-quotes that cmd/PowerShell can't read. `list2cmdline` 

2417 handles spaces/quotes/backslashes, not cmd metacharacters (`& | ^`), which 

2418 is fine for a display line that already ran.""" 

2419 if sys.platform == "win32": 

2420 return subprocess.list2cmdline([text]) 

2421 return shlex.quote(text) 

2422 

2423 

2424def argv_tokens(cmd: Iterable[Any]) -> list[str]: 

2425 """A `run()` argv list as tokens, with a bare container refused. 

2426 

2427 An element that is itself a container has no command-line spelling of its 

2428 own — stringified it becomes the one token `"['a', 'b']"`, which survives 

2429 to the tool and fails there, late and confusingly. `*` already says 

2430 "these are tokens" (`run(["ssh", host, *cmd])`), and a whole command line 

2431 headed for a remote shell is one *quoted* token (`cmd.posix()`), so the 

2432 bare spelling can only be a mistake. Everything else keeps `str()`, which 

2433 is what `Path` and `int` want. 

2434 """ 

2435 out: list[str] = [] 

2436 for item in cmd: 

2437 if isinstance(item, _CONTAINERS): 

2438 raise TypeError(container_error(item, "run()")) 

2439 out.append(str(item)) 

2440 return out 

2441 

2442 

2443# Concrete containers only — never `Iterable`, which would catch `str` and 

2444# explode it into characters. `dict` is here because a mapping has no 

2445# positional reading at all; `set`/`frozenset` because a splat of an 

2446# unordered value would produce a nondeterministic command line. 

2447_CONTAINERS = (list, tuple, set, frozenset, dict) 

2448 

2449 

2450def container_error(value: Any, where: str, *, example: str = "") -> str: 

2451 """The taught refusal for a bare container in an argv slot. 

2452 

2453 toolroom's door teaches the same wording (its own copy, per the twin 

2454 ruling), so the lesson reads the same wherever it is met. An `Argv` — 

2455 footman's own, or any twin carrying the shell renderers (`.posix()`) 

2456 — gets its own wording: it is the one container that plausibly lands 

2457 here on purpose, and the fix differs by what was meant. 

2458 """ 

2459 if isinstance(value, Argv) or callable(getattr(value, "posix", None)): 

2460 return ( 

2461 f"{where}: a built command line (Argv) was passed as one " 

2462 f"positional argument, and that spelling is ambiguous. Say which " 

2463 f"you meant: splat it (`*cmd`) to pass its tokens — what a " 

2464 f"wrapper like `uv run` takes — or serialise it (`cmd.posix()` / " 

2465 f"`cmd.windows()`) to pass one quoted line for the shell that " 

2466 f"will parse it — what an `ssh` payload is." 

2467 ) 

2468 kind = type(value).__name__ 

2469 if isinstance(value, dict): 2469 ↛ 2472line 2469 didn't jump to line 2472 because the condition on line 2469 was never true

2470 # `**` means flags at a bridge call; inside a `run()` list there is 

2471 # no dict spelling at all, so there is nothing to suggest spreading. 

2472 fix = f"Spread it with `**` to mean flags: `{example}`. " if example else "" 

2473 else: 

2474 star = example or "run(['…', *value])" 

2475 fix = f"Spread it with `*` to mean arguments: `{star}`. " 

2476 return ( 

2477 f"{where}: a {kind} was passed as a positional argument, and a " 

2478 f"container has no command-line spelling of its own — it would " 

2479 f"become the one token {str(value)!r}. {fix}To build a whole " 

2480 f"command line to pass on, use `.argv`." 

2481 ) 

2482 

2483 

2484def _exact(cmd: Any, args: tuple[Any, ...]) -> str: 

2485 """The exact executed command line for a direct (non-bridge) `run()`. 

2486 

2487 A string is already a command line; a list is shell-quoted so it pastes; 

2488 a callable has no command line, so its label stands in. 

2489 """ 

2490 if callable(cmd): 2490 ↛ 2491line 2490 didn't jump to line 2491 because the condition on line 2490 was never true

2491 return _label(cmd, args) 

2492 if isinstance(cmd, str): 

2493 return cmd 

2494 return " ".join(_shell_quote(token) for token in argv_tokens(cmd)) 

2495 

2496 

2497def _label(cmd: Any, args: tuple[Any, ...]) -> str: 

2498 if callable(cmd): 2498 ↛ 2499line 2498 didn't jump to line 2499 because the condition on line 2498 was never true

2499 name = getattr(cmd, "__qualname__", getattr(cmd, "__name__", repr(cmd))) 

2500 return " ".join([f"{name}()", *map(str, args)]).strip() 

2501 return cmd if isinstance(cmd, str) else " ".join(argv_tokens(cmd)) 

2502 

2503 

2504def _shown( 

2505 cmd: Any, args: tuple[Any, ...], title: str | None 

2506) -> tuple[str, str | None]: 

2507 """The label and title this call is *shown* by: the same renderer, run 

2508 over a command whose `Secret` arguments have become `***`. 

2509 

2510 The one place a command line is prepared for showing — the announce line, 

2511 the step receipt, `--json`, a profile span and a `RunFailed` message all 

2512 read what this produced. Redaction belongs here and not at the record's 

2513 birth: a record scrubbed on the way in loses the value for `recording()`, 

2514 for a dependent reading `.command`, and for the caller who passed it, 

2515 which is a great deal more than a display gets to decide. 

2516 

2517 A secret the task *interpolated* is untouched, because a `str` operation 

2518 on a `Secret` already yielded a plain `str` — `run(f"login {token}")` 

2519 prints in the clear on purpose, and `reveal()` is that same intent said 

2520 out loud. 

2521 """ 

2522 from footman._describe import redact 

2523 

2524 safe_title = None if title is None else str(redact(title)) 

2525 return safe_title or _label(redact(cmd), redact(args)), safe_title 

2526 

2527 

2528def _exit_code(exc: SystemExit) -> int: 

2529 """A `SystemExit`'s exit code: its int code, 0 for `None`, else 1 (a message). 

2530 

2531 `sys.exit()` / `raise SystemExit(...)` is a common "fail this step" idiom, and 

2532 `SystemExit` is a `BaseException` — so every place that treats a call's 

2533 outcome as a code must normalise it the same way, or it escapes uncaught.""" 

2534 return exc.code if isinstance(exc.code, int) else (0 if exc.code is None else 1) 

2535 

2536 

2537def _call_for_code(cmd: Callable[..., Any], args: tuple[Any, ...]) -> int: 

2538 try: 

2539 returned = cmd(*args) 

2540 except SystemExit as exc: 

2541 return _exit_code(exc) 

2542 if isinstance(returned, int) and not isinstance(returned, bool): 2542 ↛ 2544line 2542 didn't jump to line 2544 because the condition on line 2542 was always true

2543 return returned 

2544 return 0 

2545 

2546 

2547_state_lock = threading.RLock() 

2548 

2549 

2550@contextlib.contextmanager 

2551def _process_state(env: dict[str, str]) -> Generator[None]: 

2552 """Patch `os.environ` around an in-process callable — the *bare-call 

2553 fallback only*. 

2554 

2555 Inside a run the environ router serves reads from the task's overlay 

2556 (`_env_overlay` below: thread-confined, lock-free). Outside a routed run 

2557 (bare calls in scripts/tests) there is no router to lean on, so fall 

2558 back to the classic global patch, guarded by a re-entrant lock and 

2559 restored on exit — exactly the shape of the output router's own 

2560 fallback. The common case (no overlay) is lock-free either way. 

2561 

2562 The patch *replaces* rather than updates: *env* is a whole environment 

2563 (`dict(ctx.env)`, or the caller's `env=`), the same wholesale meaning 

2564 `env=` has on the subprocess and router lanes — and replacement is what 

2565 lets an absent key mean absent, which `color="never"` spells by 

2566 *removing* the force variables rather than writing `"0"`. 

2567 

2568 The process **cwd** is never touched: footman does not chdir. A call 

2569 that needs a different directory runs as a subprocess (explicit `cwd=`, 

2570 fully parallel) — `_run_callable` raises the taught error for the 

2571 in-process case. 

2572 """ 

2573 if not env: 2573 ↛ 2574line 2573 didn't jump to line 2574 because the condition on line 2573 was never true

2574 yield 

2575 return 

2576 with _state_lock: 

2577 saved_env = os.environ.copy() 

2578 try: 

2579 os.environ.clear() 

2580 os.environ.update(env) 

2581 yield 

2582 finally: 

2583 os.environ.clear() 

2584 os.environ.update(saved_env) 

2585 

2586 

2587@contextlib.contextmanager 

2588def _env_overlay(ctx: Context, overlay: dict[str, str]) -> Generator[None]: 

2589 """Thread-confined env for an in-process call inside a run: swap 

2590 `ctx.env` for the call's merged overlay — the environ router serves the 

2591 callable's reads from it, and any child it spawns inherits it. No 

2592 process global is touched, so concurrent calls never serialise.""" 

2593 saved = ctx.env 

2594 ctx.env = overlay 

2595 try: 

2596 yield 

2597 finally: 

2598 ctx.env = saved 

2599 

2600 

2601def _run_callable( 

2602 cmd: Callable[..., Any], 

2603 args: tuple[Any, ...], 

2604 *, 

2605 capture: bool = True, 

2606 env: dict[str, str] | None = None, 

2607 cwd: Path | None = None, 

2608) -> tuple[int, str, str]: 

2609 """Run a callable — parallel-safe under the router, honoring env/cwd. 

2610 

2611 With the router installed, every write this thread makes already 

2612 dispatches through `current().sink`/`err_sink`, so capture is a 

2613 thread-confined swap of the two to fresh buffers — concurrent in-process 

2614 tools never touch each other's output, and the callable's stdout and stderr 

2615 land in separate buffers for the step's `Result`. Outside a routed run 

2616 (bare calls in scripts/tests) there is no router to lean on, so fall back to 

2617 the classic global redirect (still split, into two buffers). 

2618 

2619 `capture=False` skips the buffers entirely (live output, returns `('', '')` 

2620 like the subprocess branch) — for serve-style tasks that must not buffer 

2621 unboundedly. The env overlay is applied process-globally via 

2622 `_process_state`; the `capture=False` short-circuit runs *inside* it so 

2623 uncaptured callables keep env too. 

2624 

2625 cwd is **checked, never applied**: footman does not chdir in a parallel 

2626 task. Equal target and live cwd (the common single-package case) runs 

2627 untouched; a *foreign* target is a taught error naming the exits — 

2628 exactly the case the old chdir silently serialised the run for. 

2629 """ 

2630 ctx = current() 

2631 # Colour is decided once for the whole run and published into os.environ at 

2632 # the run boundary (`color_environment`), so an in-process tool reads it 

2633 # straight from the environment — no per-call patch here, so the lock-free 

2634 # fast path in `_process_state` is kept in every colour mode. 

2635 # Same rule as the subprocess branch: `env=` replaces, absent inherits. 

2636 overlay = dict(env) if env is not None else dict(ctx.env) 

2637 # `cwd` arrives resolved by `_target_cwd`: the task's managed directory 

2638 # or an explicit per-call target. None means footman stays out (the 

2639 # `unmanaged` token, task-level or per-call): no cwd opinion for the 

2640 # callable, exactly as the subprocess branch spawns with cwd=None. 

2641 if cwd is not None: 

2642 live = Path(_globals.real_getcwd()) 

2643 if cwd.resolve() != live: 

2644 raise ValueError( 

2645 f"this in-process call needs cwd {cwd} but the process " 

2646 f"is at {live} — footman no longer chdirs in a parallel task. " 

2647 f"Run it as a subprocess (which gets cwd= for free), build " 

2648 f"paths from footman.cwd(), declare @task(cwd='unmanaged'), " 

2649 f"or pass cwd='unmanaged' on this call if it genuinely " 

2650 f"doesn't care." 

2651 ) 

2652 state = _env_overlay(ctx, overlay) if _globals.active() else _process_state(overlay) 

2653 with state: 

2654 if not capture: 2654 ↛ 2655line 2654 didn't jump to line 2655 because the condition on line 2654 was never true

2655 return _call_for_code(cmd, args), "", "" 

2656 out_buf, err_buf = io.StringIO(), io.StringIO() 

2657 # The dual capture strategy lives once, in `_captured_streams`. 

2658 with _captured_streams(out_buf, err_buf): 

2659 code = _call_for_code(cmd, args) 

2660 return code, out_buf.getvalue(), err_buf.getvalue() 

2661 

2662 

2663def _leaf(text: str) -> str: 

2664 """An address leaf, made parse-safe: the address grammar reserves `/` 

2665 (tree levels) and `#` (ordinals), and leaves are minted from 

2666 user-influenced text — command tokens, titles. Anything outside the 

2667 safe alphabet maps to `-`, runs collapse, leading `.`/`-` strip (so 

2668 `./ship` names `ship`), and a leaf is never empty. Names only: the 

2669 record's `command`/title still show the original verbatim.""" 

2670 out = [] 

2671 last_dash = False 

2672 for ch in text: 

2673 if ch.isalnum() or ch in "_.-": 

2674 out.append(ch) 

2675 last_dash = ch == "-" 

2676 elif not last_dash: 

2677 out.append("-") 

2678 last_dash = True 

2679 leaf = "".join(out).strip(".-") 

2680 return leaf or "step" 

2681 

2682 

2683def _next_label(labels: dict[str, int], label: str) -> str: 

2684 """The next same-labelled sibling's spelling: bare first, `#2` after.""" 

2685 n = labels.get(label, 0) + 1 

2686 labels[label] = n 

2687 return label if n == 1 else f"{label}#{n}" 

2688 

2689 

2690def _addr_leaf(title: str | None, label: str) -> str: 

2691 """A run() record's address leaf: a titled call is named whole; a raw 

2692 command names by its tool word plus its verb when it has one 

2693 (`git-push`, `uv-sync`) — descriptive enough to read and to keep 

2694 `git fetch`/`git push` distinct across runs, while flags stay out so 

2695 an option tweak never re-identifies the step.""" 

2696 if title: 

2697 return title 

2698 tokens = label.split() 

2699 leaf = tokens[0] if tokens else "run" 

2700 if len(tokens) > 1 and not tokens[1].startswith("-"): 

2701 leaf = f"{leaf}-{tokens[1]}" 

2702 return leaf 

2703 

2704 

2705def _child_address(parent: Context, label: str) -> str: 

2706 """The tree-derived name of the next child with this label under 

2707 *parent* — deterministic, because a parent's requests are made from its 

2708 own control flow in written order.""" 

2709 leaf = _next_label(parent._labels, _leaf(label)) 

2710 return f"{parent.address}/{leaf}" if parent.address else leaf 

2711 

2712 

2713@contextlib.contextmanager 

2714def _captured_streams(out_buf: io.StringIO, err_buf: io.StringIO) -> Generator[None]: 

2715 """Capture this thread's stdout/stderr into the two buffers — THE dual 

2716 capture strategy: a thread-confined sink swap under the router 

2717 (parallel-safe), the classic global redirect outside a routed run. 

2718 `_run_callable` and the step pump both drive through here.""" 

2719 ctx = current() 

2720 if _router is not None: 

2721 saved_out, saved_err = ctx.sink, ctx.err_sink 

2722 ctx.sink, ctx.err_sink = out_buf, err_buf 

2723 try: 

2724 yield 

2725 finally: 

2726 ctx.sink, ctx.err_sink = saved_out, saved_err 

2727 return 

2728 with contextlib.redirect_stdout(out_buf), contextlib.redirect_stderr(err_buf): 

2729 yield 

2730 

2731 

2732# Live subprocesses footman has spawned, so fail-fast can terminate the ones 

2733# still running when a sibling fails. A run in-process (a `tools` entry point, 

2734# a callable) registers nothing — there is no child to kill, and it finishes. 

2735# Each child records its task's keep-going policy, so a fail-fast failure can 

2736# reap the fail-fast trees in a mixed run while a keep-going tree runs on. 

2737_live_children: dict[subprocess.Popen[str], bool] = {} # proc -> keep_going 

2738_children_lock = threading.Lock() 

2739_aborting = threading.Event() # set once *any* termination (fail-fast/Ctrl-C) fired 

2740_abort_full = threading.Event() # set when the abort spares nothing (Ctrl-C, error) 

2741 

2742 

2743def abort_reaches(keep_going: bool) -> bool: 

2744 """Does the live abort reach work under this failure policy? 

2745 

2746 The abort flags are process-wide latches, but fail-fast's abort is 

2747 per-subtree: a keep-going subtree is not doomed by a sibling's failure, so 

2748 nothing about it — its children, its verdicts — belongs to that abort. A 

2749 *full* abort (Ctrl-C, an internal error) spares nothing. 

2750 

2751 Every reader of the latches asks this question, so it is answered here 

2752 once. `_register_child` calls it under `_children_lock` to keep its 

2753 snapshot atomic; the executor calls it to decide whether a failure was 

2754 genuine or cut off. 

2755 """ 

2756 return _aborting.is_set() and (_abort_full.is_set() or not keep_going) 

2757 

2758 

2759def _kill_tree(proc: subprocess.Popen[str], *, force: bool) -> None: 

2760 """Signal a spawned child *and its descendants*, not just the child itself. 

2761 

2762 A killable child leads its own process group (POSIX) / group (Windows), and 

2763 its grandchildren inherit it — so a group-wide signal reaps the workers a 

2764 bare `terminate()` would orphan: pytest-xdist, `make -j`, a shell script's 

2765 background jobs. POSIX sends SIGTERM, or SIGKILL once *force*. Windows has no 

2766 SIGTERM/SIGKILL split, so `taskkill /T` (walk the tree by PID) `/F` (force) 

2767 is one forceful shot for both — the escalation below is then a harmless 

2768 re-run against a tree that is already gone. 

2769 """ 

2770 with contextlib.suppress(ProcessLookupError, OSError): 

2771 if sys.platform == "win32": 

2772 subprocess.run( 

2773 ["taskkill", "/F", "/T", "/PID", str(proc.pid)], 

2774 stdout=subprocess.DEVNULL, 

2775 stderr=subprocess.DEVNULL, 

2776 check=False, 

2777 ) 

2778 return 

2779 sig = signal.SIGKILL if force else signal.SIGTERM 

2780 # Kill the whole group only when this child actually *leads* one (spawned 

2781 # with start_new_session, so pgid == pid) — that is how the group reaches 

2782 # its grandchildren. A child that shares footman's group — an interactive 

2783 # task's child, or one a caller spawned without isolation — is signalled 

2784 # alone: never killpg a group we don't own, or fail-fast could take out 

2785 # the runner itself. 

2786 if os.getpgid(proc.pid) == proc.pid: 

2787 os.killpg(proc.pid, sig) 

2788 else: 

2789 os.kill(proc.pid, sig) 

2790 

2791 

2792def _register_child(proc: subprocess.Popen[str], keep_going: bool = False) -> None: 

2793 # Under the one lock: recording the child and reading the abort flags can't 

2794 # interleave with `terminate_live_children` setting them and snapshotting — so 

2795 # a child is killed either by that sweep or by this check, never missed. 

2796 with _children_lock: 

2797 _live_children[proc] = keep_going 

2798 # Read inside the lock, so the answer belongs to the same instant as 

2799 # the registration above. 

2800 doomed = abort_reaches(keep_going) 

2801 # A child spawned after an abort fired self-terminates, so the doomed run 

2802 # can't outrun the kill — but a keep-going child spawned after a *fail-fast* 

2803 # abort (not a full Ctrl-C) is spared, matching the per-subtree policy. 

2804 if doomed: 

2805 _kill_tree(proc, force=False) 

2806 

2807 

2808def _forget_child(proc: subprocess.Popen[str]) -> None: 

2809 with _children_lock: 

2810 _live_children.pop(proc, None) 

2811 

2812 

2813def reset_abort() -> None: 

2814 """Clear the abort flags at the start of a run.""" 

2815 _aborting.clear() 

2816 _abort_full.clear() 

2817 

2818 

2819# How long a terminated child gets to exit before the kill is forced — 

2820# shared by fail-fast and by a timeout, so "ask, then insist" means the 

2821# same interval whichever one asked. 

2822_KILL_GRACE = 2.0 

2823 

2824 

2825def terminate_live_children( 

2826 grace: float = _KILL_GRACE, *, failfast_only: bool = False 

2827) -> None: 

2828 """Terminate still-running spawned subprocess *trees* — fail-fast's teeth. 

2829 

2830 With *failfast_only* (a per-node fail-fast failure) only fail-fast children 

2831 are reaped, so a keep-going task in a mixed run keeps running; the default 

2832 (a full abort — Ctrl-C, an internal error) reaps everything. 

2833 

2834 Each killable child was spawned in its own process group, so the SIGTERM 

2835 (POSIX) / `taskkill /T` (Windows) here reaches its grandchildren too; the 

2836 `communicate()` blocking each task's thread then returns and the task 

2837 unwinds. The abort is *latched*, so a subprocess a still-running task spawns 

2838 *after* this fires self-terminates on registration (the doomed run can't 

2839 outrun the kill; `_register_child` applies the same failfast_only sparing). A 

2840 group that ignores SIGTERM is SIGKILLed after *grace* seconds by a daemon 

2841 watcher — a hung tool can't wedge the run. In-process runs register nothing, 

2842 so they finish on their own — un-killable for free, which is the intended 

2843 behaviour. This is also the Ctrl-C reaper: a group-isolated child no longer 

2844 receives the terminal's SIGINT, so the abort paths call this by hand. 

2845 """ 

2846 with _children_lock: 

2847 _aborting.set() 

2848 if not failfast_only: 

2849 _abort_full.set() 

2850 procs = [p for p, kg in _live_children.items() if not (failfast_only and kg)] 

2851 for proc in procs: 

2852 _kill_tree(proc, force=False) 

2853 if not procs: 

2854 return 

2855 

2856 def _escalate() -> None: 

2857 time.sleep(grace) 

2858 for proc in procs: 

2859 if proc.poll() is None: # still alive → it ignored SIGTERM, force it 

2860 _kill_tree(proc, force=True) 

2861 

2862 threading.Thread(target=_escalate, daemon=True, name="fm-fail-fast-kill").start() 

2863 

2864 

2865def _argv0(argv: list[str] | str) -> str: 

2866 """The executable a spawn would have run — for the taught missing-tool 

2867 error. A string argv is the Windows single-command-line spelling, where 

2868 the program may sit in quotes ahead of its arguments.""" 

2869 if isinstance(argv, list): 2869 ↛ 2871line 2869 didn't jump to line 2871 because the condition on line 2869 was always true

2870 return argv[0] 

2871 line = argv.strip() 

2872 if line.startswith('"') and (end := line.find('"', 1)) > 0: 

2873 return line[1:end] 

2874 return line.split(None, 1)[0] if line else line 

2875 

2876 

2877def _run_subprocess( 

2878 argv: list[str] | str, 

2879 env: dict[str, str], 

2880 cwd: Path | None, 

2881 capture: bool, 

2882 encoding: str | None = "utf-8", 

2883 input: str | None = None, 

2884 killable: bool = True, 

2885 isolate: bool = True, 

2886 keep_going: bool = False, 

2887 timeout: float | None = None, 

2888 no_window: bool = False, 

2889) -> tuple[int, str, str, bool]: 

2890 # Dev tools (pytest, ruff, git, uv) emit UTF-8 regardless of the OS code 

2891 # page, so decode as UTF-8 by default rather than the locale encoding 

2892 # (cp1252 on Windows would mojibake the capture). `encoding=None` restores 

2893 # locale behavior. `errors="replace"` is the never-crash net either way. 

2894 # 

2895 # Popen + a live registry (not subprocess.run) so a concurrent fail-fast can 

2896 # terminate this child while its thread is blocked in communicate(). 

2897 # 

2898 # An isolated child leads its own process group (POSIX session / Windows 

2899 # group) so `terminate_live_children` can kill the whole tree, not just the 

2900 # child — a tool's own workers (pytest-xdist, `make -j`) die with it. The 

2901 # cost: it no longer receives the terminal's Ctrl-C, so the scheduler's abort 

2902 # paths reap it by hand. Two children opt out and stay in footman's group: 

2903 # `atomic` (fail-fast never kills it, so in-group keeps its Ctrl-C behaviour 

2904 # unchanged) and `interactive` (it owns the real terminal — setsid would strip 

2905 # its controlling tty and a full-screen program would misbehave). The kill 

2906 # guard signals such an in-group child alone, never the shared group. 

2907 group: dict[str, Any] = {} 

2908 if sys.platform == "win32": 

2909 flags = subprocess.CREATE_NEW_PROCESS_GROUP if isolate else 0 

2910 # A *captured* read has no business owning a console. Without this, 

2911 # Windows Terminal hands each spawn a visible window, and a tool that 

2912 # interrogates the terminal at start-up blocks forever waiting for a 

2913 # reply no pipe will carry — so whether a read hung depended on which 

2914 # window the run was launched from. CREATE_NO_WINDOW gives it a fresh 

2915 # *hidden* console, which is determinism rather than a cure: a 

2916 # determined interrogator still queries the hidden one. Deliberately 

2917 # not DETACHED_PROCESS, which leaves console-hosted runtimes with no 

2918 # console at all — pwsh dies at start-up, git-bash goes mute. 

2919 # 

2920 # Streaming and interactive runs are exempt: they mean to reach the 

2921 # caller's terminal, which is the thing this takes away. 

2922 if no_window: 

2923 flags |= subprocess.CREATE_NO_WINDOW 

2924 if flags: 

2925 group["creationflags"] = flags 

2926 elif isolate: 

2927 group["start_new_session"] = True 

2928 # Footman's own spawn, not a body's: run() has already worked out both 

2929 # the environment and the directory, so the Popen injector must keep its 

2930 # hands off. Left to it, `cwd=None` reads as "left at the default" and 

2931 # gets ctx.cwd written over it — the exact opposite of what a per-call 

2932 # cwd='unmanaged' declares — and the note would tell the author to prefer 

2933 # run() when run() is what they used. Thread-local, so a sibling task's 

2934 # raw Popen in another thread still earns both. 

2935 try: 

2936 with _globals.internal(): 

2937 proc = subprocess.Popen( 

2938 argv, 

2939 env=env, 

2940 cwd=cwd, 

2941 # A fed child reads a pipe; otherwise stdin is inherited 

2942 # untouched, so an uncaptured child keeps the terminal it 

2943 # always had. 

2944 stdin=subprocess.PIPE if input is not None else None, 

2945 stdout=subprocess.PIPE if capture else None, 

2946 stderr=subprocess.PIPE if capture else None, 

2947 text=True, 

2948 encoding=encoding, 

2949 errors="replace", 

2950 **group, 

2951 ) 

2952 except FileNotFoundError: 

2953 if cwd is not None and not cwd.is_dir(): 

2954 raise # the *directory* is what's missing — keep the honest OS error 

2955 raise CommandNotFound(_argv0(argv)) from None 

2956 # An `@task(atomic=True)` opts its child out of the registry: fail-fast 

2957 # never kills it, so a mid-write (a formatter rewriting a file) can't be 

2958 # truncated. It runs to completion; the run waits for it. 

2959 if killable: 

2960 _register_child(proc, keep_going) 

2961 timed_out = False 

2962 try: 

2963 try: 

2964 # `input` is delivered whole and the pipe closed, so a child that 

2965 # reads to EOF (`uv pip install -r -`) never blocks waiting for 

2966 # more. The post-kill retries below stay bare: after a timeout 

2967 # the convention is to finish the reads without resending. 

2968 out, err = proc.communicate(input=input, timeout=timeout) 

2969 except subprocess.TimeoutExpired: 

2970 # The whole tree, not just the child: a hung tool's own workers 

2971 # would otherwise outlive the call that bounded it. Same escalation 

2972 # fail-fast uses — ask, then insist — and `atomic` does not spare 

2973 # it, because a timeout is this call's own declared bound rather 

2974 # than a sibling's failure reaching across. 

2975 timed_out = True 

2976 _kill_tree(proc, force=False) 

2977 try: 

2978 out, err = proc.communicate(timeout=_KILL_GRACE) 

2979 except subprocess.TimeoutExpired: 

2980 _kill_tree(proc, force=True) 

2981 out, err = proc.communicate() 

2982 except BaseException: 

2983 # An abort unwinding through here — Ctrl-C, most of the time. The 

2984 # `finally` below runs on the way out and takes this child off the 

2985 # registry, so by the time the abort handler upstairs calls 

2986 # `terminate_live_children` there is nothing left to reap and the 

2987 # child outlives the run: `fm` exits 130 while the thing it started 

2988 # keeps going. (Measured: register, forget, then a reaper finding an 

2989 # empty registry.) 

2990 # 

2991 # Killed here instead, by the frame that owns it and knows which 

2992 # one it is. `killable` is the same gate registration used, so an 

2993 # `@task(atomic=True)` child still opts out — a formatter mid-write 

2994 # is exactly what that promise is for. 

2995 if killable: 

2996 _kill_tree(proc, force=False) 

2997 raise 

2998 finally: 

2999 if killable: 

3000 _forget_child(proc) 

3001 if not capture: 

3002 return proc.returncode, "", "", timed_out 

3003 # Whatever it managed to say before the kill: on a hang that partial 

3004 # output is usually the only diagnostic there is. 

3005 return proc.returncode, out or "", err or "", timed_out 

3006 

3007 

3008def _dim(text: str, color: bool) -> str: 

3009 return f"\033[2m{text}\033[0m" if color else text 

3010 

3011 

3012def _colored(ctx: Context) -> bool: 

3013 """The one colour predicate: does footman dress this run's output — its own 

3014 chrome and the tools it spawns — for colour? 

3015 

3016 `never` (`no_color`) always wins; `always` (`force_color`) forces colour on 

3017 even off a terminal (a pipe into `less -R`); otherwise `auto` follows the 

3018 run's tty-ness (`NO_COLOR` in the environment still bows out).""" 

3019 if ctx.no_color: 

3020 return False 

3021 if ctx.force_color: 

3022 return True 

3023 return ctx.tty and "NO_COLOR" not in os.environ 

3024 

3025 

3026# Every colour variable footman speaks. `color_environment` clears the whole set 

3027# before setting a direction, so forcing colour *off* is the *absence* of 

3028# FORCE_COLOR, not `FORCE_COLOR=0` — some tools (ruff) read the mere presence of 

3029# FORCE_COLOR as "force on", ignoring `NO_COLOR`, so `"0"` would fail to silence 

3030# them. footman emits by presence/absence; it consumes `NO_COLOR` by presence and 

3031# `FORCE_COLOR` by truthiness (`_resolve_color`). 

3032_COLOR_VARS = ("FORCE_COLOR", "CLICOLOR_FORCE", "CLICOLOR", "NO_COLOR") 

3033 

3034 

3035def color_env(on: bool) -> dict[str, str]: 

3036 """The colour variables to *set* to force colour on (or off) for a child. 

3037 

3038 Without a PTY a child's stdout is a pipe, so `isatty()` is false and a 

3039 well-behaved tool auto-disables colour — footman captures the bytes and 

3040 replays them onto its own terminal, so it wants the colour back. `on` sets 

3041 `FORCE_COLOR`/`CLICOLOR_FORCE`; off sets only `NO_COLOR`. Forcing off is 

3042 completed by *removing* any inherited `FORCE_COLOR` (see `_COLOR_VARS` / 

3043 `color_environment`), never by setting it to `"0"`.""" 

3044 if on: 

3045 return {"FORCE_COLOR": "1", "CLICOLOR_FORCE": "1", "CLICOLOR": "1"} 

3046 return {"NO_COLOR": "1"} 

3047 

3048 

3049def run_colour_on( 

3050 *, no_color: bool, force_color: bool, capture: bool, isatty: bool 

3051) -> bool: 

3052 """The run-wide colour decision, computed once from its run-wide inputs. 

3053 

3054 Colour cannot change during a run, so this is `_colored` for every task at 

3055 once — letting the environment be set once at the run boundary rather than 

3056 per call. Capture (a `--json` run) is byte-clean and gates `force_color` off, 

3057 exactly as the scheduler does per task; the rest defers to `_colored`.""" 

3058 if capture: 

3059 return False 

3060 tty = isatty and os.environ.get("TERM") != "dumb" 

3061 return _colored(Context(no_color=no_color, force_color=force_color, tty=tty)) 

3062 

3063 

3064@contextlib.contextmanager 

3065def color_environment(on: bool) -> Generator[None]: 

3066 """Publish the run-wide colour decision into `os.environ` for the run. 

3067 

3068 One decision, set once: a subprocess inherits it, an in-process tool reads 

3069 it — so no `run()` call has to patch the environment itself (and none takes 

3070 the `_process_state` lock for colour). Every colour variable is cleared 

3071 first, then this direction's are set — so *off* leaves no `FORCE_COLOR` for a 

3072 presence-checking tool to honour, and *on* leaves no stray `NO_COLOR`. 

3073 Restored on exit; nested runs stack, each restoring the value it found.""" 

3074 saved = {key: os.environ.get(key) for key in _COLOR_VARS} 

3075 for key in _COLOR_VARS: 

3076 os.environ.pop(key, None) 

3077 os.environ.update(color_env(on)) 

3078 try: 

3079 yield 

3080 finally: 

3081 for key, previous in saved.items(): 

3082 if previous is None: 

3083 os.environ.pop(key, None) 

3084 else: 

3085 os.environ[key] = previous 

3086 

3087 

3088def _name_col(ctx: Context) -> str: 

3089 """The step line's task-name column, padded so siblings align. 

3090 

3091 Bold on colour terminals; empty (no column at all) outside a run, so a 

3092 plain `run()` call keeps its old shape. 

3093 """ 

3094 if not ctx.task: 

3095 return "" 

3096 from footman import _describe 

3097 

3098 wide = max(ctx.name_width, _describe.display_width(ctx.task)) 

3099 padded = _describe.pad_to(ctx.task, wide) 

3100 return (f"\033[1m{padded}\033[0m" if _colored(ctx) else padded) + " " 

3101 

3102 

3103def _step_line(ctx: Context, ok: bool, label: str, duration: float) -> str: 

3104 """One completed step: mark · name · dimmed command · aligned time.""" 

3105 from footman._describe import pad_to 

3106 from footman._progress import fmt_secs 

3107 

3108 color = _colored(ctx) 

3109 time_text = f"({fmt_secs(duration)})" 

3110 name = _name_col(ctx) 

3111 # Times align to the widest command — remembered from the previous run 

3112 # of this chain (a warm run aligns from its first line), learned as a 

3113 # running max on a cold one. Never the terminal edge: that reads absurd 

3114 # on wide terminals. 

3115 label = pad_to(label, _observe_cmd(label)) 

3116 

3117 if not ctx.tty: 

3118 return f"{pad_to('ok' if ok else 'FAIL', 4)} {name}{label} {time_text}\n" 

3119 

3120 mark = ( 

3121 ("\033[32m✓\033[0m" if ok else "\033[31m✗\033[0m") 

3122 if color 

3123 else ("ok" if ok else "FAIL") 

3124 ) 

3125 shown = f"\033[36m{time_text}\033[0m" if color else time_text 

3126 # The time sits right after the command — a right-aligned column reads 

3127 # absurd on wide terminals, with the time a screen away from its line. 

3128 return f"{mark} {name}{_dim(label, color)} {shown}\n" 

3129 

3130 

3131# The tokens that mean "shell" and nothing else. `run(str)` splits and execs 

3132# directly — no shell — so any of these would ride along as a *literal* argument, 

3133# silently breaking a pipeline or redirect (a `tar … | ssh …` that never pipes). 

3134_SHELL_OPERATORS = frozenset( 

3135 { 

3136 "|", 

3137 "||", 

3138 "|&", 

3139 "&", 

3140 "&&", 

3141 ";", 

3142 ";;", 

3143 ">", 

3144 ">>", 

3145 "<", 

3146 "<<", 

3147 "<<<", 

3148 "<>", 

3149 "2>", 

3150 "2>>", 

3151 "&>", 

3152 } 

3153) 

3154 

3155 

3156def _shell_operator(cmd: str) -> str | None: 

3157 """The first bare shell-operator token in *cmd*, or `None`. 

3158 

3159 Only an operator standing as *its own token* counts — the spaced form a 

3160 shell would honour (`… | …`, `… > out`). A glued `a>b` stays one token and 

3161 is left alone, as is any operator inside quotes. Split failures (unbalanced 

3162 quotes) defer to the exec path, which surfaces them. 

3163 """ 

3164 try: 

3165 # posix=False on Windows: keep backslash paths intact (they'd otherwise 

3166 # be eaten), so a real path token never looks like an operator. 

3167 tokens = shlex.split(cmd, posix=(os.name != "nt")) 

3168 except ValueError: 

3169 return None 

3170 return next((t for t in tokens if t in _SHELL_OPERATORS), None) 

3171 

3172 

3173# A modern bash where PATH alone might miss it (a GUI/cron launch, or Windows, 

3174# where git's bash is not on PATH). Checked before `shutil.which("bash")`. 

3175_BASH_HINTS = ( 

3176 "/opt/homebrew/bin/bash", # macOS Apple Silicon Homebrew 

3177 "/usr/local/bin/bash", # macOS Intel Homebrew 

3178 r"C:\Program Files\Git\bin\bash.exe", # Windows git bash 

3179 r"C:\Program Files\Git\usr\bin\bash.exe", 

3180) 

3181 

3182 

3183def _find_exe(name: str, hints: tuple[str, ...] = ()) -> str | None: 

3184 """A concrete path for *name*: a known *hints* location first, then PATH.""" 

3185 import shutil 

3186 

3187 for hint in hints: 

3188 if os.path.isfile(hint): 

3189 return hint 

3190 return shutil.which(name) 

3191 

3192 

3193def _resolve_shell(kind: bool | str, policy: str = "posix") -> list[str]: 

3194 """The interpreter argv prefix — `[executable, run-a-string-flag]` — for a 

3195 `run(shell=…)` request. 

3196 

3197 `True` follows *policy* (POSIX-everywhere by default: bash, then plain sh, 

3198 with git bash on Windows). A string is a concrete shell (`bash`/`zsh`/`sh`/ 

3199 `fish`/`nu`/`pwsh`/`cmd`) or a strategy (`posix`/`native`). Raises a taught 

3200 `ValueError` when the shell can't be found or does not fit the platform — 

3201 never a silent wrong-shell. 

3202 """ 

3203 strat = policy if kind is True else str(kind) 

3204 win = sys.platform == "win32" 

3205 if strat == "posix": 

3206 # bash first (pipefail + POSIX word-splitting, and everywhere incl. git 

3207 # bash on Windows), then plain sh. zsh is excluded — its default word 

3208 # splitting is not POSIX, so ask for it by name if you want it. 

3209 exe = _find_exe("bash", _BASH_HINTS) or _find_exe("sh") 

3210 if exe is None: 

3211 raise ValueError( 

3212 "shell=True needs a POSIX shell and none was found. Install one " 

3213 "(git bash on Windows), or use shell='pwsh' / shell='cmd'." 

3214 ) 

3215 return [exe, "-c"] 

3216 if strat == "native": 

3217 return ( 

3218 [os.environ.get("COMSPEC", "cmd.exe"), "/c"] if win else ["/bin/sh", "-c"] 

3219 ) 

3220 if strat == "cmd": 

3221 if not win: 

3222 raise ValueError("shell='cmd' is Windows-only; use 'bash' or 'pwsh'.") 

3223 return [os.environ.get("COMSPEC", "cmd.exe"), "/c"] 

3224 if strat in ("bash", "sh", "zsh", "fish", "nu"): 

3225 exe = _find_exe(strat, _BASH_HINTS if strat == "bash" else ()) 

3226 if exe is None: 

3227 raise ValueError(f"shell={strat!r}: {strat!r} was not found on PATH.") 

3228 return [exe, "-c"] 

3229 if strat in ("pwsh", "powershell"): 

3230 exe = _find_exe(strat) 

3231 if exe is None: 

3232 raise ValueError(f"shell={strat!r}: {strat!r} was not found on PATH.") 

3233 return [exe, "-Command"] # pwsh's own run-a-string flag (accepts -c too) 

3234 raise ValueError( 

3235 f"shell={kind!r} is not a known shell. Use True (the policy), a strategy " 

3236 f"('posix' / 'native'), or a shell name " 

3237 f"('bash', 'zsh', 'sh', 'fish', 'nu', 'pwsh', 'cmd')." 

3238 ) 

3239 

3240 

3241# `clean=True`: run the interpreter without the user's startup files, so a task's 

3242# shell behaves the same on every machine. For `-c` most POSIX shells already 

3243# skip their rc, but pwsh/cmd load a profile and bash honours $BASH_ENV — so it 

3244# is both these flags and (POSIX) dropping BASH_ENV/ENV from the child env. 

3245_CLEAN_FLAGS = { 

3246 "bash": ("--norc", "--noprofile"), 

3247 "zsh": ("-f",), 

3248 "fish": ("--no-config",), 

3249 "nu": ("-n",), 

3250 "pwsh": ("-NoProfile",), 

3251 "powershell": ("-NoProfile",), 

3252 "cmd": ("/d",), 

3253} 

3254 

3255# `strict=True`: fail on the first error and on a failing pipe stage. Well-defined 

3256# only for POSIX shells and PowerShell — bash/zsh get pipefail, plain sh cannot 

3257# (dash has none) so it degrades to errexit-only with a one-time note; fish/nu/ 

3258# cmd have no errexit at all, so strict there is a taught error, not a silent no-op. 

3259_STRICT_PROLOGUE = { 

3260 "bash": "set -eo pipefail\n", 

3261 "zsh": "set -eo pipefail\n", 

3262 "sh": "set -e\n", 

3263 "pwsh": ( 

3264 "$ErrorActionPreference = 'Stop'\n" 

3265 "$PSNativeCommandUseErrorActionPreference = $true\n" 

3266 ), 

3267 "powershell": "$ErrorActionPreference = 'Stop'\n", 

3268} 

3269 

3270_strict_sh_noted = False 

3271 

3272 

3273def _shell_kind_of(exe: str) -> str: 

3274 """The shell family from its executable path — `/usr/bin/bash` → `bash`.""" 

3275 return os.path.basename(exe).lower().removesuffix(".exe") 

3276 

3277 

3278def _shell_prep( 

3279 kind: str, script: str, *, strict: bool, clean: bool 

3280) -> tuple[list[str], str]: 

3281 """Interpreter flags (from *clean*) and the script (from *strict*) for a shell 

3282 run. Raises a taught error when *strict* can't be honoured (fish/nu/cmd have 

3283 no errexit/pipefail).""" 

3284 flags = list(_CLEAN_FLAGS.get(kind, ())) if clean else [] 

3285 if strict: 

3286 prologue = _STRICT_PROLOGUE.get(kind) 

3287 if prologue is None: 

3288 raise ValueError( 

3289 f"strict=True is not supported for the {kind!r} shell — it has no " 

3290 f"errexit/pipefail. Use bash, zsh, sh, or pwsh, or drop strict." 

3291 ) 

3292 if kind == "sh": 

3293 global _strict_sh_noted 

3294 if not _strict_sh_noted: 3294 ↛ 3300line 3294 didn't jump to line 3300 because the condition on line 3294 was always true

3295 _strict_sh_noted = True 

3296 real_stderr().write( 

3297 "note: strict under sh has no pipefail; using `set -e` only " 

3298 "(install bash for errexit + pipefail).\n" 

3299 ) 

3300 script = prologue + script 

3301 return flags, script 

3302 

3303 

3304def _target_cwd( 

3305 ctx: Context, cwd: str | Path | None, rel: str | Path | None 

3306) -> Path | None: 

3307 """The effective directory for one call. Explicit `cwd=` wins; otherwise 

3308 the task's resolved `ctx.cwd` (or none at all under the `unmanaged` 

3309 policy). The literal `cwd="unmanaged"` opts this one call out — the 

3310 same word, the same meaning as the task-level token: no base at all, so 

3311 a child inherits the live process cwd and an in-process callable runs 

3312 under it. `rel=` is a relative suffix on whatever base is in force at 

3313 this call — `ctx.cwd` in the common case.""" 

3314 if cwd == "unmanaged": 

3315 if rel is not None: 

3316 raise ValueError( 

3317 "rel=… needs a managed base and cwd='unmanaged' has none — " 

3318 "pass cwd=… explicitly, or use the asinvoked policy" 

3319 ) 

3320 return None 

3321 base = Path(cwd) if cwd is not None else (None if ctx.cwd_unmanaged else ctx.cwd) 

3322 if rel is None: 

3323 return base 

3324 rel_path = Path(rel) 

3325 if rel_path.is_absolute() or rel_path.anchor: # anchored = absolute on win 

3326 raise ValueError( 

3327 f"rel={str(rel)!r} is absolute — rel is a suffix on the call's cwd " 

3328 f"base; pass an absolute directory as cwd=…" 

3329 ) 

3330 if base is None and ctx.cwd_unmanaged: 

3331 raise ValueError( 

3332 "rel=… needs a managed base and cwd='unmanaged' has none — " 

3333 "pass cwd=… explicitly, or use the asinvoked policy" 

3334 ) 

3335 return (base if base is not None else Path.cwd()) / rel_path 

3336 

3337 

3338def _reviewed( 

3339 ctx: Context, 

3340 pre_record: Callable[[ResultView], None], 

3341 cmd: Any, 

3342 args: tuple[Any, ...], 

3343 label: str, 

3344 show_label: str, 

3345 code: int, 

3346 out_s: str, 

3347 err_s: str, 

3348 duration: float, 

3349 raw: str, 

3350 tokens: tuple[str, ...], 

3351 addr: str, 

3352 audit: tuple[AuditEntry, ...], 

3353 timed_out: bool, 

3354 start: float | None, 

3355) -> tuple[int, str, str, tuple[AuditEntry, ...]]: 

3356 """The review window: the work ran (or was scripted) and the record is 

3357 still a draft. The reviewer reads what was captured and may amend the 

3358 verdict — title and code — before anything is sealed, shown, or raised. 

3359 A raising reviewer fails the call with the hook's own error: a broken 

3360 reviewer is a broken gate, not a shrug. The record keeps what the work 

3361 honestly produced in that case — review never finished, so nothing it 

3362 half-did is kept. Returns the reviewed (code, label, shown label, audit). 

3363 """ 

3364 hook = getattr(pre_record, "__name__", repr(pre_record)) 

3365 view = ResultView( 

3366 title=label, 

3367 code=code, 

3368 stdout=out_s, 

3369 stderr=err_s, 

3370 duration=duration, 

3371 raw=raw, 

3372 command=label, 

3373 ) 

3374 try: 

3375 pre_record(view) 

3376 except Exception as exc: 

3377 ctx.steps.append( 

3378 Result( 

3379 code, 

3380 command=label, 

3381 stdout=out_s, 

3382 stderr=err_s, 

3383 duration=duration, 

3384 raw=raw, 

3385 shown=show_label, 

3386 timed_out=timed_out, 

3387 address=addr, 

3388 audit=(*audit, _audit_entry("review", hook, None)), 

3389 tokens=tokens, 

3390 started=start, 

3391 ) 

3392 ) 

3393 raise RuntimeError( 

3394 f"pre_record hook {hook!r} failed reviewing {show_label!r}: " 

3395 f"{type(exc).__name__}: {exc}" 

3396 ) from exc 

3397 audit = ( 

3398 *audit, 

3399 _audit_entry("review", hook, view.code if "code" in view._touched else None), 

3400 ) 

3401 code = view.code 

3402 if view.title != label: 

3403 # A reviewer may rename the record; the name it goes out under is 

3404 # shown by the same rule the original was. 

3405 show_label = _shown(cmd, args, view.title)[0] 

3406 return code, view.title, show_label, audit 

3407 

3408 

3409def run( 

3410 cmd: str | list[str] | Callable[..., Any], 

3411 *args: Any, 

3412 nofail: bool = False, 

3413 recorded: bool = True, 

3414 capture: bool = True, 

3415 input: str | None = None, 

3416 timeout: float | None = None, 

3417 title: str | None = None, 

3418 pre_record: Callable[[ResultView], None] | None = None, 

3419 env: dict[str, str] | None = None, 

3420 color: str = "auto", 

3421 cwd: str | Path | None = None, 

3422 rel: str | Path | None = None, 

3423 encoding: str | None = "utf-8", 

3424 shell: bool | str = False, 

3425 strict: bool = False, 

3426 clean: bool = False, 

3427 _show: Invocation | None = None, 

3428) -> Result: 

3429 """Run a command or a Python callable in the current task's context. 

3430 

3431 Subprocess output is decoded as UTF-8 by default; pass `encoding=` for a 

3432 tool that speaks another code page, or `encoding=None` for the locale 

3433 default. Ignored for callables (in-process, no bytes boundary). 

3434 

3435 `input=` feeds the child's standard input and closes it — the one lane a 

3436 payload can't take through argv (`uv pip install -r -` reads its 

3437 requirements there). A string, encoded the way the capture is decoded 

3438 (`encoding=`). The *read* side of the process boundary is a task 

3439 parameter marked `stdin`; this is the write side, so a task can sit in 

3440 the middle of a pipeline without a shell between it and either 

3441 neighbour. An in-process tool has no standard input to feed — `input=` 

3442 on one is a taught `TypeError`. 

3443 

3444 `cwd=` roots this one call somewhere other than the task's directory; 

3445 `rel=` appends a relative suffix to the base in force (`ctx.cwd`, or the 

3446 explicit `cwd=`), so `run("npm run build", rel="web")` is the ergonomic 

3447 spelling of `cwd=ctx.cwd / "web"`. `cwd="unmanaged"` opts this one call 

3448 out instead — the task-level token, per call: a subprocess spawns 

3449 inheriting the live process cwd, an in-process callable runs under it — 

3450 for a call that touches no paths while its task keeps `ctx.cwd` 

3451 everywhere else. 

3452 

3453 `color=` decides what this one child emits: `"always"` forces the colour 

3454 variables into its environment, `"never"` writes `NO_COLOR` and *removes* 

3455 any inherited force variables (a tool reading mere presence would honour 

3456 one straight past `NO_COLOR`), and the default `"auto"` follows the run's 

3457 own decision. Explicit beats ambient, so `color="always"` holds under an 

3458 exported `NO_COLOR`. It merges on top of `env=` when both are given, and 

3459 the in-process lane's overlay reads it too — so a per-call colour reaches 

3460 both halves of a tool's colour, its flags and its environment, through 

3461 one door. footman's own chrome (the step lines) stays on the run-wide 

3462 decision: this is about the child's bytes, not the frame around them. 

3463 

3464 **`recorded=False` runs this call off the record.** A call is a *step* 

3465 by default: it earns a receipt line, a row in `--json`, a `recording()` 

3466 entry, and its output joins the task's block. Some calls aren't part of 

3467 the task's story, though — they are how a task *knows* something (`git 

3468 rev-parse HEAD` in a release task). Those return their `Result` and 

3469 report nothing: no record, no receipt, no output replayed. 

3470 

3471 Everything else still applies, because the call is not unmanaged — only 

3472 unreported. It runs in `ctx.cwd` with `ctx.env`, inside the task's lane, 

3473 with colour resolved the same way, it is terminated with the rest on a 

3474 fail-fast, and it still raises unless `nofail=True`. It also *executes* 

3475 under `recording()`, where a step would be faked — unless the recording 

3476 carries a scripted answer for it (`recording(answers=…)`), in which case 

3477 the script wins: a value read is not the story being recorded, and 

3478 faking it would corrupt the story that is — 

3479 the real steps downstream would record whatever a blank answer produced. 

3480 

3481 **`pre_record=` reviews the record before it is sealed.** Some tools 

3482 speak exit codes that need interpreting — `djlint --reformat` exits 1 

3483 when it changed files, which is success for a formatting gate. The 

3484 reviewer receives the draft (`ResultView`) after the work ran: it reads 

3485 what was captured and may set `title` and `code`; the receipt, the 

3486 record, and the raise-on-nonzero decision all read what the review 

3487 leaves behind, so the call site writes no `nofail=`. A reviewer that 

3488 raises fails the call with its own error — a broken reviewer is a 

3489 broken gate — and the record keeps what the work honestly produced. 

3490 Dry-run never reviews (nothing ran, nothing captured), and a 

3491 `recorded=False` call has no record to review (a note, not an error). 

3492 

3493 In-process work is a **step** now: `run()` runs commands. Lift a 

3494 callable instead — `@step` / `step(fn, title=…)` builds an item that 

3495 earns a receipt, and `with step("…"):` records a block where it 

3496 stands. (toolroom keeps its in-process lane through its own 

3497 private channel.) 

3498 

3499 `_show` is an internal channel from toolroom's bridge: a structured 

3500 view of the call, so the shown command line can be normalised and 

3501 role-coloured while execution runs whatever the tool needs. An explicit 

3502 `title` still wins; a direct `run([...])` is unaffected. 

3503 """ 

3504 ctx = current() 

3505 if args and not callable(cmd): 

3506 # The subprocess-style spelling this door does not have. The extra 

3507 # arguments only ever reached the *label*, so the child ran without 

3508 # them and said so in green: `run("echo", "hi")` printed nothing and 

3509 # passed, `run("sh", "-c", "exit 1")` ran a bare shell that sat on 

3510 # the caller's terminal. Silence is the one thing that must not 

3511 # happen here, so it is a refusal naming the spelling that works. 

3512 meant = [cmd, *args] if isinstance(cmd, str) else [*cmd, *args] 

3513 spelled = ", ".join(repr(str(token)) for token in meant) 

3514 raise TypeError( 

3515 f"run() takes one command — a string, or a list of tokens — and " 

3516 f"the {len(args)} after it would be dropped rather than passed " 

3517 f"to it. Put them in the list: run([{spelled}])" 

3518 ) 

3519 if color not in ("auto", "never", "always"): 

3520 raise ValueError( 

3521 f"run(color={color!r}) expects one of auto|never|always — " 

3522 f"auto follows the run's own decision" 

3523 ) 

3524 if color != "auto": 

3525 # The per-call twin of the run boundary's publish: the same tri-state 

3526 # applied to this one child's environment — a private dict, no process 

3527 # global, so it is thread-safe by construction. Off means REMOVING the 

3528 # inherited force variables, never writing "0": some tools honour mere 

3529 # presence straight past NO_COLOR. 

3530 painted_env = dict(env) if env is not None else dict(ctx.env) 

3531 for key in _COLOR_VARS: 

3532 painted_env.pop(key, None) 

3533 painted_env.update(color_env(color == "always")) 

3534 env = painted_env 

3535 if (strict or clean) and not shell: 

3536 # strict/clean harden a *shell* run — they mean nothing shell-free, and a 

3537 # silent no-op would be exactly the surprise footman avoids elsewhere. 

3538 which = "strict" if strict else "clean" 

3539 raise ValueError( 

3540 f"run({which}=True) only applies with a shell — it hardens a shell " 

3541 f"run. Pass shell=True (or a shell name), or drop {which}." 

3542 ) 

3543 if callable(cmd) and _show is None: 

3544 raise TypeError( 

3545 "run() runs commands; in-process work is a step. Lift the " 

3546 "callable — @step on a def, step(fn, title='…') around one you " 

3547 "didn't write, or `with step('…'):` over inline code — and it " 

3548 "earns a real receipt." 

3549 ) 

3550 if input is not None and callable(cmd): 

3551 # Reached only through toolroom's in-process lane: a 

3552 # subprocess has a stdin to feed, a Python call does not. 

3553 raise TypeError( 

3554 "run(input=…) feeds a subprocess's standard input, and an " 

3555 "in-process tool has none — spawn it (in_process=False) to " 

3556 "feed it, or pass the value as an argument" 

3557 ) 

3558 out = sys.stdout 

3559 paint = _colored(ctx) 

3560 if _show is not None and title is None: 

3561 # `label` (recorded as .command, and the step-line receipt) is always 

3562 # the normalised form, so a recording() assertion never depends on 

3563 # --verbose. Only the live "about to / now running" line switches to 

3564 # the exact spelling under --verbose; .raw always carries it. 

3565 label = _show.text(exact=False) 

3566 raw = _show.text(exact=True) 

3567 # A bridged call arrives already rendered to strings, so no `Secret` 

3568 # survives the crossing for `_shown` to find — the tool library owns 

3569 # that half of the redaction. 

3570 show_label, show_title = label, None 

3571 shown = _show.painted(color=paint, exact=ctx.verbose) 

3572 shown_plain = _show.text(exact=ctx.verbose) 

3573 # The bridge already separated the argv; keep it for `to_argv()`. 

3574 tokens = tuple(_show.exact) 

3575 else: 

3576 label = title or _label(cmd, args) 

3577 raw = _exact(cmd, args) 

3578 show_label, show_title = _shown(cmd, args, title) 

3579 shown = _dim(show_label, paint) 

3580 shown_plain = show_label 

3581 # A list `run()` has its tokens apart; a command *string* does not, 

3582 # and splitting one back is platform-dependent guesswork — `to_argv()` 

3583 # teaches that rather than guessing. 

3584 tokens = ( 

3585 () if callable(cmd) or isinstance(cmd, str) else tuple(argv_tokens(cmd)) 

3586 ) 

3587 

3588 # A scripted answer is looked up before the recording branch decides, 

3589 # because an explicit match wins even for an off-record call (below). 

3590 # The lookup itself may raise: an exception value, or a sequence run dry. 

3591 scripted = _scripted_answer(ctx, label) if ctx.dry_run else None 

3592 if ctx.dry_run and (recorded or scripted is not None): 

3593 # Record the step even when not executing: `dry_run` + `quiet` is the 

3594 # silent-capture mode `footman.testing` builds on. The recorded label 

3595 # is normalised; only the shown line colours or (under -v) goes exact. 

3596 # 

3597 # A non-step call runs anyway. It is not the story being recorded — it 

3598 # is how the task learns something — and faking it would corrupt the 

3599 # story that *is*: the real steps downstream would go on to record 

3600 # whatever a blank answer produced (`git tag ` for a missing sha). 

3601 # Unless the test scripted that very answer: a value read the table 

3602 # names is intercepted, off the record or not — the author declaring 

3603 # the world beats honest execution — and only unmatched reads run. 

3604 addr = _child_address(ctx, _addr_leaf(show_title, show_label)) 

3605 # What the call *would* have seen — the same choice the live lane 

3606 # makes below — kept on the record so a recording can assert on it. 

3607 would_env = dict(env) if env is not None else dict(ctx.env) 

3608 try: 

3609 would_cwd = _target_cwd(ctx, cwd, rel) 

3610 except ValueError: 

3611 would_cwd = None # the live lane refuses it; a rehearsal records 

3612 if scripted is None: 

3613 result = Result( 

3614 0, 

3615 command=label, 

3616 raw=raw, 

3617 shown=show_label, 

3618 address=addr, 

3619 tokens=tokens, 

3620 env=would_env, 

3621 cwd=would_cwd, 

3622 ) 

3623 ctx.steps.append(result) 

3624 if not ctx.quiet: 

3625 out.write(f"$ {shown if paint else shown_plain}\n") 

3626 return result 

3627 code, out_s, err_s = scripted 

3628 # A scripted answer is a draft like a live one: reviewers see it — 

3629 # an adjudicator is tested *against* scripted exits — and a 

3630 # non-zero takes the real failing lane. The audit says so. 

3631 audit: tuple[AuditEntry, ...] = (_audit_entry("body", show_label, code),) 

3632 if pre_record is not None and recorded: 

3633 code, label, show_label, audit = _reviewed( 

3634 ctx, 

3635 pre_record, 

3636 cmd, 

3637 args, 

3638 label, 

3639 show_label, 

3640 code, 

3641 out_s, 

3642 err_s, 

3643 0.0, 

3644 raw, 

3645 tokens, 

3646 addr, 

3647 audit, 

3648 False, 

3649 None, 

3650 ) 

3651 result = Result( 

3652 code, 

3653 command=label, 

3654 stdout=out_s, 

3655 stderr=err_s, 

3656 raw=raw, 

3657 shown=show_label, 

3658 address=addr, 

3659 audit=audit, 

3660 tokens=tokens, 

3661 env=would_env, 

3662 cwd=would_cwd, 

3663 ) 

3664 if recorded: 

3665 ctx.steps.append(result) 

3666 if not ctx.quiet: 

3667 out.write(f"$ {shown if paint else shown_plain}\n") 

3668 if code != 0 and not nofail: 

3669 raise RunFailed(result) 

3670 return result 

3671 

3672 if title is not None and not recorded: 

3673 # Not an error: `.opts()` merges along a chain, so a shared tool may 

3674 # carry a title from where it was configured while a call site adds 

3675 # `recorded=False` — neither author wrote the contradiction. Say it once. 

3676 from footman import _globals as _pg_note 

3677 

3678 _pg_note._note( 

3679 "recorded-title", 

3680 f"title= is ignored on a recorded=False call ({show_label}): there is " 

3681 f"no receipt to label.", 

3682 ) 

3683 

3684 if pre_record is not None and not recorded: 

3685 # Same shape as the title note: `.opts()` merges along a chain, so a 

3686 # shared tool may carry a reviewer while one call site goes off the 

3687 # record — neither author wrote the contradiction. Say it once. 

3688 from footman import _globals as _pg_note2 

3689 

3690 _pg_note2._note( 

3691 "pre-record-recorded", 

3692 f"pre_record is ignored on a recorded=False call ({show_label}): " 

3693 f"there is no record to review.", 

3694 ) 

3695 

3696 show = recorded and not ctx.quiet and (ctx.verbose or not capture) 

3697 # `ctx.tty` means "this output dresses for a terminal" (colour, marks); 

3698 # liveness is `sink is None`. A captured block styles for the terminal 

3699 # it will replay onto, but in-place rewrites and the announce line stay 

3700 # live-only: control bytes must never land in a capture buffer. 

3701 live = ctx.sink is None 

3702 if show: 

3703 # The arrow announces what is *running now* — worth a line only 

3704 # while output is live (a TTY rewrites it in place; a streamed CI 

3705 # log may wait minutes under it). A captured block flushes when 

3706 # the task is already done, where "starting X" directly above 

3707 # "finished X" says nothing — the completion line carries it all. 

3708 if ctx.tty and live: 3708 ↛ 3709line 3708 didn't jump to line 3709 because the condition on line 3708 was never true

3709 out.write(f"→ {_name_col(ctx)}{shown}") 

3710 out.flush() 

3711 elif live: 

3712 out.write(f"→ {_name_col(ctx)}{shown_plain}\n") 

3713 out.flush() 

3714 

3715 start = time.perf_counter() 

3716 # Bound before the branch: the in-process path never spawns, so it keeps 

3717 # this call's own (refused below if given), while the subprocess path 

3718 # narrows it to whatever the enclosing task has left. 

3719 spawn_timeout = timeout 

3720 if callable(cmd): 

3721 if timeout is not None: 3721 ↛ 3727line 3721 didn't jump to line 3727 because the condition on line 3721 was never true

3722 # A Python callable cannot be interrupted safely — there is no 

3723 # process to signal and no safe way to unwind another thread — so 

3724 # a bound footman cannot honour is refused rather than ignored. 

3725 # toolroom demotes an in-process *tool* to its subprocess 

3726 # twin instead, exactly as it does for a foreign cwd. 

3727 raise ValueError( 

3728 "run(timeout=…) needs a process to bound, and this call runs " 

3729 "in-process. Spawn it (a list/str command, or " 

3730 ".opts(in_process=False) on a tool), or drop the timeout." 

3731 ) 

3732 code, out_s, err_s = _run_callable( 

3733 cmd, args, capture=capture, env=env, cwd=_target_cwd(ctx, cwd, rel) 

3734 ) 

3735 timed_out = False 

3736 else: 

3737 argv: list[str] | str 

3738 shell_kind = "" 

3739 if shell: 

3740 # An explicit shell: run the whole string through the resolved 

3741 # interpreter — `[bash, -c, "<cmd>"]` — so pipes/redirects/globs 

3742 # work. A list is the shell-free form; it can't be a shell script. 

3743 if not isinstance(cmd, str): 

3744 raise ValueError( 

3745 "run(shell=…) runs a command *string* through a shell; pass a " 

3746 "str, not a list (a list is the shell-free form)." 

3747 ) 

3748 exe, run_flag = _resolve_shell(shell, ctx.shell_default or "posix") 

3749 shell_kind = _shell_kind_of(exe) 

3750 clean_flags, script = _shell_prep( 

3751 shell_kind, cmd, strict=strict, clean=clean 

3752 ) 

3753 argv = [exe, *clean_flags, run_flag, script] 

3754 elif isinstance(cmd, str): 

3755 if (op := _shell_operator(cmd)) is not None: 

3756 raise ValueError( 

3757 f"run({cmd!r}): {op!r} is a shell operator, but run() does not " 

3758 f"use a shell, so it would be passed as a literal argument (the " 

3759 f"pipeline/redirect would silently not happen). Ask for a shell " 

3760 f"— run(..., shell=True) or shell='bash' — split into separate " 

3761 f"run() steps, or pass a list to use {op!r} as a literal argument." 

3762 ) 

3763 # POSIX shells split on shlex rules; Windows command lines are a 

3764 # single string (CreateProcess) and shlex would mangle backslash 

3765 # paths — hand the string straight to subprocess there. 

3766 argv = cmd if sys.platform == "win32" else shlex.split(cmd) 

3767 else: 

3768 # Tokens as given, with a bare container refused rather than 

3769 # stringified into the one token `"['a', 'b']"` — `*cmd` and 

3770 # `cmd.posix()` are the two meant spellings. 

3771 argv = argv_tokens(cmd) 

3772 # `env=` is the child's environment, exactly as `subprocess` means it — 

3773 # what you pass is what it gets. Otherwise the task's own, which 

3774 # already carries the run-wide colour decision published at the run 

3775 # boundary (`color_environment`) and anything the body has set or 

3776 # deleted. To add rather than replace, say so: `{**os.environ, …}` — 

3777 # inside a task `os.environ` *is* this environment, so the copy is 

3778 # exact rather than approximate. 

3779 run_env = dict(env) if env is not None else dict(ctx.env) 

3780 # A clean POSIX shell means no startup files: `--norc`/`--noprofile` 

3781 # cover interactive/login rc, but bash/sh also source $BASH_ENV/$ENV for 

3782 # a non-interactive `-c`, so drop those from the child env too. 

3783 if clean and shell_kind in ("bash", "sh", "zsh"): 3783 ↛ 3784line 3783 didn't jump to line 3784 because the condition on line 3783 was never true

3784 run_env = {k: v for k, v in run_env.items() if k not in ("BASH_ENV", "ENV")} 

3785 # `unmanaged` spawns with cwd=None (inherit the live process cwd), 

3786 # task-level or per-call; a per-call cwd=/rel= override wins — see 

3787 # `_target_cwd`. 

3788 cwd_path = _target_cwd(ctx, cwd, rel) 

3789 # The enclosing task's deadline bounds this spawn too, whichever is 

3790 # tighter. A task that says `timeout=5` means the whole task, so a 

3791 # `run()` with no bound of its own inherits what is left, and one 

3792 # with a longer bound of its own cannot outlive the task. The 

3793 # subprocess sees only a duration — the task's instant is converted 

3794 # here, at the last moment, so time spent queueing is not charged 

3795 # twice. 

3796 if (left := ctx.time_left()) is not None: 

3797 spawn_timeout = left if timeout is None else min(timeout, left) 

3798 code, out_s, err_s, timed_out = _run_subprocess( 

3799 argv, 

3800 run_env, 

3801 cwd_path, 

3802 capture, 

3803 encoding, 

3804 input=input, 

3805 timeout=spawn_timeout, 

3806 killable=not ctx.atomic, 

3807 # An interactive task owns the real terminal: keep its child in 

3808 # footman's group so it keeps its controlling tty (and its Ctrl-C). 

3809 isolate=not ctx.atomic and not ctx.interactive, 

3810 # Tag the child with its task's policy: a fail-fast failure elsewhere 

3811 # reaps this tree only if the task is fail-fast, not keep-going. 

3812 keep_going=ctx.keep_going, 

3813 # A captured child writes to pipes, so it needs no console of its 

3814 # own; an uncaptured or interactive one is reaching for the real 

3815 # terminal on purpose. 

3816 no_window=capture and not ctx.interactive, 

3817 ) 

3818 duration = time.perf_counter() - start 

3819 if timed_out: 

3820 # 124 is the shell convention for "killed by a timeout", so a code 

3821 # travelling out through --json or a branded CLI reads as the thing it 

3822 # is rather than a sentinel of footman's own invention. A Result *is* 

3823 # its exit code, so this is chosen here, not assigned afterwards. 

3824 code = 124 

3825 # The address label is the stable identity — a title names the record, 

3826 # the address names the node (`_addr_leaf` for the naming rule). Minted 

3827 # from the *shown* label: an address is a name, printed wherever the 

3828 # record is, and a name has no business carrying a secret. 

3829 addr = _child_address(ctx, _addr_leaf(show_title, show_label)) 

3830 # The audit: the verdict's provenance. The body entry is always present 

3831 # and carries what the work itself produced; review entries follow. The 

3832 # body actor names the work, which is the command line — so it is the 

3833 # shown one, for the same reason the address is: a name is printed 

3834 # wherever the record travels. 

3835 audit = (_audit_entry("body", show_label, code),) 

3836 if pre_record is not None and recorded: 

3837 code, label, show_label, audit = _reviewed( 

3838 ctx, 

3839 pre_record, 

3840 cmd, 

3841 args, 

3842 label, 

3843 show_label, 

3844 code, 

3845 out_s, 

3846 err_s, 

3847 duration, 

3848 raw, 

3849 tokens, 

3850 addr, 

3851 audit, 

3852 timed_out, 

3853 start, 

3854 ) 

3855 result = Result( 

3856 code, 

3857 command=label, 

3858 stdout=out_s, 

3859 stderr=err_s, 

3860 duration=duration, 

3861 raw=raw, 

3862 shown=show_label, 

3863 timed_out=timed_out, 

3864 address=addr, 

3865 audit=audit, 

3866 tokens=tokens, 

3867 started=start, 

3868 ) 

3869 if recorded: 

3870 ctx.steps.append(result) # what --json, the report and recording() read 

3871 

3872 # Task grain at normal verbosity: a step's receipt shows under 

3873 # --verbose (and for uncaptured, live steps, whose output needs its 

3874 # label) — and ALWAYS when it failed. Green is collapsible; failure is 

3875 # never hidden. 

3876 if ( 

3877 recorded 

3878 and not ctx.quiet 

3879 and not ctx.machine_read 

3880 and (ctx.verbose or not capture or code != 0) 

3881 ): 

3882 ok = code == 0 

3883 prefix = "\r\033[K" if ctx.tty and live else "" 

3884 out.write(f"{prefix}{_step_line(ctx, ok, show_label, duration)}") 

3885 # Join the two streams only to *display* them (stdout then stderr); 

3886 # nothing merged is stored — the Result keeps them apart. 

3887 combined = out_s + err_s 

3888 if capture and combined and (not ok or ctx.verbose): 

3889 out.write(combined if combined.endswith("\n") else combined + "\n") 

3890 if not ok and len(result.audit) > 1: 

3891 # The verdict's story, when anyone touched it: who acted, what 

3892 # they set — the audit, one dim line under the failure. 

3893 trail = " → ".join( 

3894 f"{e.moment} {e.actor}" + (f" {e.code}" if e.code is not None else "") 

3895 for e in result.audit 

3896 ) 

3897 out.write(_dim(f" audit: {trail}", _colored(ctx)) + "\n") 

3898 out.flush() 

3899 

3900 if code != 0 and not nofail: 

3901 if result.timed_out: 

3902 # The bound that actually applied: this call's own, or what the 

3903 # enclosing `@task(timeout=…)` had left when the spawn started. 

3904 # Reporting the call's own would print `0s` for a task-imposed 

3905 # deadline, which reads as a bug in the timeout rather than a 

3906 # deadline being enforced. 

3907 raise RunTimeout(result, effective_timeout(timeout, ctx)) 

3908 raise RunFailed(result) 

3909 return result 

3910 

3911 

3912# --- parallel() -------------------------------------------------------------- 

3913 

3914 

3915def _call_name(call: Callable[..., Any]) -> str: 

3916 """The name a fan-out child shows on the status line and its step lines. 

3917 

3918 A `functools.partial` unwraps to what it wraps; a lambda has no honest 

3919 name of its own, so it shows as an ellipsis rather than `<lambda>`. 

3920 """ 

3921 if isinstance(call, functools.partial): # partial(fmt, check=True) 

3922 call = call.func 

3923 name = getattr(call, "__name__", "task") 

3924 return "…" if name == "<lambda>" else name 

3925 

3926 

3927class Pending: 

3928 """What a task call hands back *inside* a `with parallel()` block. 

3929 

3930 The call has been queued, not run, so there is no value yet — and a 

3931 silent `None` here would be a bug you find much later. Every use is a 

3932 taught error; the values arrive as `p.results` when the block ends. 

3933 """ 

3934 

3935 __slots__ = ("_task",) 

3936 

3937 _task: str 

3938 

3939 def __init__(self, task: str) -> None: 

3940 object.__setattr__(self, "_task", task) 

3941 

3942 def _refuse(self, *_a: Any, **_k: Any) -> NoReturn: 

3943 raise RuntimeError( 

3944 f"{self._task} has not run yet — inside a `with parallel()` block a " 

3945 f"call is queued, not executed, so it has no value to use there. " 

3946 f"Read the values from the block's results after it ends." 

3947 ) 

3948 

3949 __getattr__ = _refuse 

3950 __bool__ = _refuse 

3951 __iter__ = _refuse 

3952 __len__ = _refuse 

3953 __int__ = _refuse 

3954 __float__ = _refuse 

3955 __lt__ = _refuse 

3956 __add__ = _refuse 

3957 __hash__: ClassVar[None] = None # type: ignore[assignment] # unusable too 

3958 

3959 def __str__(self) -> NoReturn: 

3960 self._refuse() 

3961 

3962 def __eq__(self, other: object) -> NoReturn: 

3963 self._refuse() 

3964 

3965 def __repr__(self) -> str: # debuggers and tracebacks stay usable 

3966 return f"<pending {self._task}>" 

3967 

3968 

3969# The queue a `with parallel()` block collects into: a contextvar, so the 

3970# calls a *queued task* makes when it later runs (on a pool thread, which 

3971# starts from contextvar defaults) are ordinary calls, never re-collected. 

3972_collecting: ContextVar[ 

3973 list[tuple[Any, tuple[Any, ...], dict[str, Any], int | None]] | None 

3974] = ContextVar("footman_parallel_block", default=None) 

3975 

3976 

3977class Fanout(list[Result]): 

3978 """The `with parallel() as p:` block — task calls inside it are queued, 

3979 then run together when the block ends. 

3980 

3981 A `list` underneath — of sealed records, each of which IS its exit code, 

3982 so everything that read the block as a list of codes keeps working — and 

3983 empty, so `parallel(*items)` with nothing to do still answers with the 

3984 empty list it always did. 

3985 """ 

3986 

3987 def __init__(self, keep_going: bool = False) -> None: 

3988 super().__init__() 

3989 self.keep_going: bool = keep_going 

3990 self.results: list[Any] = [] 

3991 """What each queued call returned, in the order they were written.""" 

3992 self._queued: list[tuple[Any, tuple[Any, ...], dict[str, Any], int | None]] = [] 

3993 self._token: Any = None 

3994 self._births: list[_step.WorkItem[Any]] = [] 

3995 self._birth_token: Any = None 

3996 

3997 def __enter__(self) -> Fanout: 

3998 from footman import _step as _step_mod 

3999 

4000 self._token = _collecting.set(self._queued) 

4001 self._birth_token = _step_mod._born.set(self._births) 

4002 return self 

4003 

4004 def __call__(self, item: _step.WorkItem[Any], /) -> Pending: 

4005 """Queue a built step item for the block — the lifted counterpart of 

4006 a task call, which queues itself.""" 

4007 from footman import _step as _step_mod 

4008 

4009 if not isinstance(item, _step_mod.WorkItem): 4009 ↛ 4010line 4009 didn't jump to line 4010 because the condition on line 4009 was never true

4010 raise TypeError( 

4011 f"a parallel block queues built step items — p(step(fn)(…)) " 

4012 f"— got {type(item).__name__}. Task calls queue themselves; " 

4013 f"write them as ordinary calls." 

4014 ) 

4015 if _collecting.get() is not self._queued: 

4016 raise RuntimeError( 

4017 "p(item) queues work for a block, so call it inside the " 

4018 "`with` — outside one there is nothing to join." 

4019 ) 

4020 item._claimed = True 

4021 self._queued.append((item, (), {}, None)) 

4022 return Pending(_call_name(item)) 

4023 

4024 def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> Literal[False]: 

4025 from footman import _step as _step_mod 

4026 

4027 _collecting.reset(self._token) 

4028 _step_mod._born.reset(self._birth_token) 

4029 if exc_type is not None: 

4030 return False # the block itself failed: nothing to run 

4031 dead = [w for w in self._births if not w._claimed] 

4032 if dead: 

4033 names = ", ".join(w.__name__ for w in dead) 

4034 raise RuntimeError( 

4035 f"built inside the block but never handed to it: {names}. " 

4036 f"Building an item runs nothing — hand it to the block, " 

4037 f"p(item), or call it to run it in place. Nothing ran." 

4038 ) 

4039 values: list[Any] = [None] * len(self._queued) 

4040 

4041 def thunk( 

4042 index: int, 

4043 task: Any, 

4044 args: tuple[Any, ...], 

4045 kwargs: dict[str, Any], 

4046 seq: int | None, 

4047 ) -> Callable[[], None]: 

4048 def run() -> None: 

4049 # The value goes to the slot, never through the return: a task 

4050 # returning an int would read as an exit code out here. The 

4051 # queue moment's request number rides back in with the call. 

4052 from footman import _futures 

4053 

4054 token = _futures._pending_seq.set(seq) 

4055 try: 

4056 values[index] = task(*args, **kwargs) 

4057 finally: 

4058 _futures._pending_seq.reset(token) 

4059 

4060 run.__name__ = _call_name(task) 

4061 return run 

4062 

4063 codes = _run_thunks( 

4064 [thunk(i, t, a, k, q) for i, (t, a, k, q) in enumerate(self._queued)], 

4065 keep_going=self.keep_going, 

4066 ) 

4067 self.results = values 

4068 self.extend(codes) # the block *is* its list of records (codes included) 

4069 return False 

4070 

4071 

4072def _queue_call( 

4073 task: Any, args: tuple[Any, ...], kwargs: dict[str, Any], seq: int | None = None 

4074) -> Any: 

4075 """Queue a call for the enclosing `with parallel()` block, or `None` when 

4076 there is none — the one hook `_futures.call` needs. The queue moment IS 

4077 the request moment, so the caller's `seq` rides the tuple and the thunk 

4078 hands it back to the re-entering call.""" 

4079 queue = _collecting.get() 

4080 if queue is None: 

4081 return None 

4082 queue.append((task, args, kwargs, seq)) 

4083 from footman import registry 

4084 

4085 return Pending(registry.cli_name(getattr(task, "__name__", "task"))) 

4086 

4087 

4088@overload 

4089def parallel(*, keep_going: bool = False) -> Fanout: ... 

4090@overload 

4091def parallel( 

4092 *calls: _step.WorkItem[Any] 

4093 | _step.StepFn[..., Any] 

4094 | _registry_t.TaskFn[..., Any] 

4095 | _registry_t.Group, 

4096 keep_going: bool = False, 

4097) -> list[Result]: ... 

4098 

4099 

4100def parallel( 

4101 *calls: _step.WorkItem[Any] 

4102 | _step.StepFn[..., Any] 

4103 | _registry_t.TaskFn[..., Any] 

4104 | _registry_t.Group, 

4105 keep_going: bool = False, 

4106) -> Fanout | list[Result]: 

4107 """Run tasks and steps concurrently; wait; fail if any fail. 

4108 

4109 Each one runs in a child of the current context with its own output 

4110 buffer, flushed atomically on completion so concurrent output never 

4111 interleaves. Pass task functions directly (`parallel(lint, typecheck)`) 

4112 and built step items for everything else — `parallel(convert(images))`, 

4113 or `parallel(step(fn)(args))` for a function you didn't write. A bare 

4114 zero-argument maker is welcome too: `parallel(clean)` builds and runs 

4115 `clean()`. 

4116 

4117 Nothing anonymous runs: footman only schedules, records, and safely 

4118 cancels work it owns, and a bare callable is a stranger — no name for 

4119 the report, no place in the plan, no way to stop it cleanly. The lift 

4120 is one word, and it buys the step a receipt. 

4121 

4122 With no arguments it is a **block** instead, and the calls inside it are 

4123 the fan-out — written as ordinary calls, with their values afterwards: 

4124 

4125 with parallel() as p: 

4126 build("web") 

4127 p(step(shutil.rmtree)(tmp, ignore_errors=True)) 

4128 web, cleaned = p.results 

4129 

4130 Inside the block a task call is *queued*, so it has no value there (using 

4131 one is a taught error); a built step item joins through `p(item)`; 

4132 everything runs when the block ends, under the same rules — sharing, 

4133 hooks, `-s`/`-j`. 

4134 """ 

4135 from footman import _step 

4136 

4137 if not calls: 

4138 # Nothing to run: the block form, which is also an empty list of exit 

4139 # codes — so `parallel(*items)` over an empty sequence is unchanged. 

4140 return Fanout(keep_going=keep_going) 

4141 

4142 from footman import registry as _registry 

4143 

4144 accepted: list[Callable[[], Any]] = [] 

4145 for c in calls: 

4146 if isinstance(c, _step.StepFn): 

4147 # A zero-argument maker is welcome bare: build its item here, so 

4148 # `parallel(covered)` and `parallel(covered())` mean the same. 

4149 c = c() 

4150 if isinstance(c, _step.WorkItem): 

4151 accepted.append(c) 

4152 continue 

4153 if getattr(c, "_footman_pre", None) is not None or isinstance( 

4154 c, _registry.Group 

4155 ): 

4156 accepted.append(c) # a task, an opted reference, a runnable group 

4157 continue 

4158 label = _call_name(c) if callable(c) else type(c).__name__ 

4159 raise TypeError( 

4160 f"parallel() runs tasks and steps — {label!r} is neither. " 

4161 f"footman only schedules, records, and safely cancels work it " 

4162 f"owns, and a bare callable is a stranger. Lift it and it earns " 

4163 f"a receipt too: parallel(step(fn)(…)), or step(fn, " 

4164 f"title='…') to name a lambda." 

4165 ) 

4166 return _run_thunks(accepted, keep_going=keep_going) 

4167 

4168 

4169def _run_thunks( 

4170 calls: list[Callable[[], Any]], *, keep_going: bool = False 

4171) -> list[Result]: 

4172 """The fan-out engine: run zero-argument callables the caller vouches 

4173 for. `parallel()` validates and lands here; the block's `__exit__` 

4174 drives it directly with the closures footman itself wrote.""" 

4175 from concurrent.futures import ThreadPoolExecutor 

4176 

4177 parent = current() 

4178 dest = parent.sink or real_stdout() 

4179 dest_is_real = parent.sink is None 

4180 lock = threading.Lock() 

4181 

4182 # parallel() children are units on the live status line, exactly like 

4183 # scheduler nodes — a chain and a task-body fan-out present identically. 

4184 # Every child counts once here, whatever it is: a task, a partial, a 

4185 # lambda wrapping a call, a plain thunk. What the child *does* can't be 

4186 # known from the outside — a closure is opaque — so the count can't 

4187 # depend on reading it. Instead the unit is handed to the child (see 

4188 # `Context.unit_pending`), and the first task request inside claims it 

4189 # rather than counting a second one for the same piece of work. 

4190 status = _status 

4191 if status is not None: 

4192 status.unit_added(len(calls)) 

4193 

4194 # Sibling names are known up front, so their step lines can align. 

4195 width = max((len(_call_name(c)) for c in calls), default=0) 

4196 

4197 def invoke(call: Callable[[], Any]) -> tuple[Result, BaseException | None]: 

4198 name = _call_name(call) 

4199 child_start = time.perf_counter() 

4200 if status is not None: 

4201 status.unit_started(name) 

4202 # One buffer for both streams at task level, so the atomic flush keeps 

4203 # this child's stdout/stderr in order; a run() inside it still splits the 

4204 # step's streams via a temporary swap. 

4205 buf = io.StringIO() 

4206 child = parent.child( 

4207 name, 

4208 sink=buf, 

4209 err_sink=buf, 

4210 name_width=width, 

4211 # This child's unit is counted above; the first task request 

4212 # inside claims it instead of counting its own. 

4213 unit_pending=True, 

4214 ) 

4215 token = _current.set(child) 

4216 try: 

4217 # The arbiter counts every child body (an exclusive drain must 

4218 # see them); a lineage child of a lane holder bypasses the bars — 

4219 # `serial_active` rode in through `replace(parent, …)`. 

4220 with _globals.lane(None, name=name, inherited=child.serial_active): 

4221 returned = call() 

4222 code = returned if _is_code(returned) else 0 

4223 error: BaseException | None = None 

4224 # A thunk that *returns* a non-zero code failed just as surely as one 

4225 # that raised RunFailed. Synthesize the failure here so the gate below 

4226 # treats both uniformly; `keep_going` still collects the code. 

4227 if code != 0: 4227 ↛ 4228line 4227 didn't jump to line 4228 because the condition on line 4227 was never true

4228 thunk = _label(call, ()) 

4229 error = RunFailed(Result(code, command=thunk, raw=thunk)) 

4230 except RunFailed as exc: 

4231 code, error = exc.result.code or 1, exc 

4232 except SystemExit as exc: 

4233 # `sys.exit()` / `raise SystemExit(...)` is a common "fail this task" 

4234 # idiom, but a BaseException — without this it escapes the pool 

4235 # instead of being collected. Treat its code like a returned one: 

4236 # 0 succeeds, non-zero is a synthesized failure the gate below raises. 

4237 # (A string reason is not carried through here: parallel() deliberately 

4238 # normalises failures to a catchable RunFailed, so it must not re-raise 

4239 # a BaseException. A task body's own sys.exit("reason") keeps its 

4240 # message — see executor._call.) 

4241 code = _exit_code(exc) 

4242 error = None 

4243 if code != 0: 

4244 thunk = _label(call, ()) 

4245 error = RunFailed(Result(code, command=thunk, raw=thunk)) 

4246 except Failed as exc: 

4247 # `footman.fail("reason")` in a thunk: an Exception (not a 

4248 # BaseException), so the gate can re-raise it and its reason surfaces 

4249 # at the task level — better than the sys.exit-in-parallel corner. 

4250 # Its own code rides the return list too. 

4251 code, error = exc.code, exc 

4252 except Exception as exc: # a failed call must not crash the pool 

4253 code, error = 1, exc 

4254 finally: 

4255 _current.reset(token) 

4256 gate = _globals.console_gate() if dest_is_real else contextlib.nullcontext() 

4257 with gate, lock: 

4258 blob = buf.getvalue() 

4259 # A child that ended mid-colour (a crash, an unterminated SGR) must 

4260 # not bleed into the next child's block when they interleave: cap a 

4261 # colourful run's block with a reset. Only when colour is on, so a 

4262 # byte-clean run stays byte-clean. 

4263 if blob and _colored(parent): 

4264 blob += "\033[0m" 

4265 if status is not None and dest_is_real: 

4266 # This write bypasses the routers (dest is the raw stream): 

4267 # tell the status line to get out of the way itself. 

4268 status.notify(blob) 

4269 dest.write(blob) 

4270 dest.flush() 

4271 # Surface the child's run() steps on the parent, in completion order, 

4272 # so they appear in `--json` and `recording()` (F12). 

4273 parent.steps.extend(child.steps) 

4274 parent.sections.extend(child.sections) 

4275 if status is not None: 

4276 status.unit_finished(name, error is None) 

4277 # The caller's view of this child: a sealed record that IS its exit 

4278 # code (so every code-reader keeps working), named and addressed. 

4279 record = Result( 

4280 code, 

4281 command=name, 

4282 address=child.address, 

4283 duration=time.perf_counter() - child_start, 

4284 started=child_start, 

4285 ) 

4286 return record, error 

4287 

4288 # -s reaches inside tasks (one worker serialises the calls in 

4289 # submission order), and -j caps the width; same code path either way. 

4290 if parent.sequential: 

4291 workers = 1 

4292 elif parent.jobs > 0: 

4293 workers = max(1, min(parent.jobs, len(calls))) 

4294 else: 

4295 workers = max(1, len(calls)) 

4296 # Parked for the pool wait: this body is blocked in footman code and 

4297 # cannot touch globals, so an exclusive drain may exempt it (the 

4298 # ancestry exemption — its own children still count on their own). 

4299 with ( 

4300 _globals.parked(), 

4301 ThreadPoolExecutor( 

4302 max_workers=workers, thread_name_prefix="fm-parallel" 

4303 ) as pool, 

4304 ): 

4305 try: 

4306 outcomes = list(pool.map(invoke, calls)) 

4307 except BaseException: 

4308 # The same abort arm the scheduler has: on Ctrl-C the interrupt 

4309 # unwinds here, in the main thread, while every worker is still 

4310 # blocked in communicate() on a group-isolated child that never 

4311 # saw the terminal's signal. Without this the pool's `with` exit 

4312 # joins those threads and the interrupt waits out the work it 

4313 # just cancelled (measured: a 300-second child held it 25s+ 

4314 # before a second Ctrl-C escaped and orphaned the tree). 

4315 terminate_live_children() 

4316 pool.shutdown(wait=False, cancel_futures=True) 

4317 raise 

4318 

4319 if not keep_going: 

4320 for _record, error in outcomes: 

4321 if error is not None: 

4322 raise error 

4323 return [record for record, _ in outcomes]