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

697 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-24 13:38 +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 shutil 

23import signal 

24import subprocess 

25import sys 

26import threading 

27import time 

28from collections.abc import Callable, Iterator, Sequence 

29from contextvars import ContextVar 

30from dataclasses import dataclass, field, replace 

31from pathlib import Path 

32from typing import Any, NoReturn, TextIO 

33 

34 

35class Result(int): 

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

37 

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

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

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

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

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

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

44 """ 

45 

46 command: str 

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

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

49 and what the terminal shows.""" 

50 stdout: str 

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

52 stderr: str 

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

54 duration: float 

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

56 raw: str 

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

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

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

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

61 

62 def __new__( 

63 cls, 

64 code: int, 

65 *, 

66 command: str = "", 

67 stdout: str = "", 

68 stderr: str = "", 

69 duration: float = 0.0, 

70 raw: str = "", 

71 ) -> Result: 

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

73 self.command = command 

74 self.stdout = stdout 

75 self.stderr = stderr 

76 self.duration = duration 

77 self.raw = raw or command 

78 return self 

79 

80 @property 

81 def code(self) -> int: 

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

83 return int(self) 

84 

85 @property 

86 def ok(self) -> bool: 

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

88 return self == 0 

89 

90 @property 

91 def output(self) -> str: 

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

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

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

95 and `stderr` separately.""" 

96 return self.stdout + self.stderr 

97 

98 

99@dataclass 

100class Context: 

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

102 

103 env: dict[str, str] = field(default_factory=dict) 

104 """Extra environment variables overlaid on every `run()` subprocess.""" 

105 cwd: Path | None = None 

106 """Where `run()` executes: the folder that defined the task; `None` 

107 means the process cwd (plain calls outside a footman run).""" 

108 dry_run: bool = False 

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

110 nothing, and reports success.""" 

111 quiet: bool = False 

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

113 verbose: bool = False 

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

115 no_color: bool = False 

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

117 force_color: bool = False 

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

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

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

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

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

123 prog: str = "fm" 

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

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

126 sequential: bool = False 

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

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

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

130 serialise task bodies.""" 

131 assume_yes: bool = False 

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

133 no_input: bool = False 

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

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

136 fetch_backend: str = "" 

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

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

139 shell_default: str = "" 

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

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

142 jobs: int = 0 

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

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

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

146 task: str = "" 

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

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

149 outside runs.""" 

150 fn: Any = None 

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

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

153 name_width: int = 0 

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

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

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

157 tty: bool = False 

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

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

160 sink: TextIO | None = None 

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

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

163 err_sink: TextIO | None = None 

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

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

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

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

168 interactive: bool = False 

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

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

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

172 atomic: bool = False 

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

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

175 keep_going: bool = False 

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

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

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

179 in_task: bool = False 

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

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

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

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

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

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

186 

187 

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

189 

190 

191def current() -> Context: 

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

193 ctx = _current.get() 

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

195 

196 

197@contextlib.contextmanager 

198def use_context(ctx: Context | None = None) -> Iterator[Context]: 

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

200 

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

202 `run()` and `tools.*` inside the block read this context instead of a 

203 fresh default. `footman.testing.recording` builds on it. 

204 

205 ```python 

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

207 deploy() 

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

209 ``` 

210 """ 

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

212 token = _current.set(installed) 

213 try: 

214 yield installed 

215 finally: 

216 _current.reset(token) 

217 

218 

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

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

221 return list(current().passthrough) 

222 

223 

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

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

226 

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

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

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

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

231 run, where the estimator would still be guessing. 

232 

233 ```python 

234 @task 

235 def migrate(): 

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

237 apply(record) 

238 progress(i, len(records)) 

239 ``` 

240 

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

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

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

244 """ 

245 status = active_status() 

246 if status is None: 

247 return 

248 ctx = current() 

249 name = ctx.task or "task" 

250 if total > 0: 

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

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

253 with contextlib.suppress(Exception): 

254 status.counted.pop(name, None) 

255 

256 

257def track(iterable: Any, total: int | None = None) -> Any: 

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

259 

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

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

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

263 keeps whatever progress it had. 

264 

265 ```python 

266 @task 

267 def migrate(): 

268 for record in track(load_records()): 

269 apply(record) 

270 ``` 

271 """ 

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

273 try: 

274 total = len(iterable) 

275 except TypeError: 

276 total = 0 

277 done = 0 

278 try: 

279 for item in iterable: 

280 yield item 

281 done += 1 

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

283 progress(done, total) 

284 finally: 

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

286 progress(0, 0) 

287 

288 

289def inherited() -> Any: 

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

291 

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

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

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

295 

296 ```python 

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

298 @task 

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

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

301 if contracts: 

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

303 ``` 

304 

305 Forwarding is deliberately manual: the two signatures are independent 

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

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

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

309 it also runs to completion 

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

311 extra)` when you want otherwise. 

312 

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

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

315 call straight off it. 

316 """ 

317 from footman import discover 

318 

319 fn = current().fn 

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

321 raise RuntimeError( 

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

323 "task being shadowed from the one currently running" 

324 ) 

325 previous = discover.shadowed(fn) 

326 if previous is None: 

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

328 raise RuntimeError( 

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

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

331 ) 

332 

333 @functools.wraps(previous) 

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

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

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

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

338 ctx = current() 

339 saved = ctx.fn 

340 ctx.fn = previous 

341 try: 

342 return previous(*args, **kwargs) 

343 finally: 

344 ctx.fn = saved 

345 

346 return call_inherited 

347 

348 

349class RunFailed(Exception): 

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

351 

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

353 self.result = result 

354 super().__init__(f"`{result.command}` exited with code {result.code}") 

355 

356 

357class Failed(Exception): 

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

359 

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

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

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

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

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

365 """ 

366 

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

368 self.reason = reason 

369 self.code = code 

370 super().__init__(reason) 

371 

372 

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

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

375 

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

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

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

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

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

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

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

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

384 """ 

385 raise Failed(reason, code=code) 

386 

387 

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

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

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

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

392 

393 

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

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

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

397 if not params: 

398 return None 

399 first = params[0] 

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

401 return first.name 

402 return None 

403 

404 

405# --- output routing ---------------------------------------------------------- 

406 

407 

408# The run's live status line (duck-typed — a `_progress.StatusLine`), 

409# registered by the scheduler for the duration of a run. context stays 

410# ignorant of _progress on purpose; outside a run there is none. 

411_status: Any = None 

412 

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

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

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

416_cmd_width: int = 0 

417 

418 

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

420 global _cmd_width 

421 _cmd_width = max(0, width) 

422 

423 

424def cmd_width() -> int: 

425 return _cmd_width 

426 

427 

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

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

430 global _cmd_width 

431 if len(label) > _cmd_width: 

432 _cmd_width = len(label) 

433 return _cmd_width 

434 

435 

436def set_status(status: Any) -> None: 

437 global _status 

438 _status = status 

439 

440 

441def active_status() -> Any: 

442 return _status 

443 

444 

445class _Router: 

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

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

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

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

450 

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

452 self.real = real 

453 self._err = err 

454 try: 

455 self._tty = real.isatty() 

456 except Exception: 

457 self._tty = False 

458 

459 def _sink(self) -> TextIO | None: 

460 ctx = current() 

461 return ctx.err_sink if self._err else ctx.sink 

462 

463 def write(self, s: str) -> int: 

464 sink = self._sink() 

465 if sink is not None: 

466 return sink.write(s) 

467 # A real-terminal write: the live status line (if any) must clear 

468 # itself first and learn whether the cursor now sits at column 0. 

469 if self._tty and _status is not None: 469 ↛ 470line 469 didn't jump to line 470 because the condition on line 469 was never true

470 _status.notify(s) 

471 return self.real.write(s) 

472 

473 def flush(self) -> None: 

474 (self._sink() or self.real).flush() 

475 

476 def isatty(self) -> bool: 

477 return self.real.isatty() 

478 

479 def __getattr__(self, name: str) -> Any: 

480 return getattr(self.real, name) 

481 

482 

483_router: _Router | None = None 

484_err_router: _Router | None = None 

485 

486 

487def real_stdout() -> TextIO: 

488 """The underlying stdout, bypassing the routing proxy.""" 

489 return _router.real if _router is not None else sys.stdout 

490 

491 

492def real_stderr() -> TextIO: 

493 """The underlying stderr, bypassing the routing proxy.""" 

494 return _err_router.real if _err_router is not None else sys.stderr 

495 

496 

497_UNSET: Any = object() # "no default given" — None is a valid default/value 

498 

499_prompt_lock = threading.Lock() 

500 

501 

502def _stdin_is_tty() -> bool: 

503 try: 

504 return sys.stdin is not None and sys.stdin.isatty() 

505 except Exception: 

506 return False 

507 

508 

509def _scrub(text: str) -> str: 

510 """Drop control characters (ESC included) from text echoed to the terminal, 

511 so an untrusted `select()` label or message can't inject ANSI escapes — the 

512 terminal-injection class that has bitten other CLIs.""" 

513 return "".join(c for c in text if c.isprintable() or c == "\t") 

514 

515 

516def _prompt_core( 

517 message: str = "", *, default: str | None = None, secret: bool = False 

518) -> str: 

519 """The prompt mechanics, unguarded. Writes to the real terminal on stderr 

520 (never captured, never in `--json` stdout), serialises on `_prompt_lock`, 

521 clears the live status line, and degrades off a tty (returns `default`, or 

522 raises). The framework's `ask()` resolution calls this directly; user code 

523 goes through the guarded `prompt()`.""" 

524 if not _stdin_is_tty(): 

525 if default is not None: 

526 return default 

527 raise RuntimeError( 

528 "no terminal is attached, so there is no one to ask. Pass a " 

529 "default for unattended runs, or take the value as a task " 

530 "parameter (a CLI flag) instead." 

531 ) 

532 err = real_stderr() 

533 status = active_status() 

534 with _prompt_lock: 

535 if status is not None: 535 ↛ 536line 535 didn't jump to line 536 because the condition on line 535 was never true

536 status.notify(message or " ") # clear the live status line 

537 if secret: 537 ↛ 538line 537 didn't jump to line 538 because the condition on line 537 was never true

538 import getpass 

539 

540 value = getpass.getpass(message, stream=err).rstrip("\n") 

541 else: 

542 err.write(message) 

543 err.flush() 

544 value = sys.stdin.readline().rstrip("\n") 

545 if status is not None: 545 ↛ 546line 545 didn't jump to line 546 because the condition on line 545 was never true

546 status.notify("\n") # Enter returned the cursor to column 0 

547 return default if value == "" and default is not None else value 

548 

549 

550def _guard_interactive(what: str) -> Context: 

551 """Refuse a mid-body interactive call in a non-interactive task. 

552 

553 Inside an ordinary (captured, possibly parallel) task body the prompt 

554 would be swallowed by the capture buffer or race a sibling for the 

555 terminal, so it is a loud, taught error rather than a silent hang. The 

556 framework's own up-front `ask()` resolution runs with `in_task` unset and 

557 is never caught here. Returns the active context (for `--no-input`/`--yes`).""" 

558 ctx = current() 

559 if ctx.in_task and not ctx.interactive: 

560 raise RuntimeError( 

561 f"{what} was called inside task {ctx.task or '?'!r}, which is not " 

562 f"interactive. Either mark it `@task(interactive=True)` so it owns " 

563 f"the terminal, or declare the value as a parameter with `ask()` so " 

564 f"footman asks before the task runs." 

565 ) 

566 return ctx 

567 

568 

569def prompt( 

570 message: str = "", *, default: str | None = None, secret: bool = False 

571) -> str: 

572 """Ask the person running the task for a line of input. 

573 

574 A bare `input()` doesn't work in a task: its prompt goes to stdout, which 

575 footman buffers per task so parallel output never interleaves (and `--json` 

576 stays one envelope), so the prompt is swallowed and the task looks hung. 

577 `prompt()` writes to the real terminal on stderr instead — never captured — 

578 and serialises concurrent prompts. 

579 

580 Usable only inside an `@task(interactive=True)` task; called in an ordinary 

581 task body it raises a taught error naming the two fixes. Off a terminal, 

582 under `--no-input`, or when it would otherwise block, it returns `default` 

583 if given, else raises — an unattended run fails loudly. For a value a 

584 script must supply, take it as a task parameter (a CLI flag) instead. 

585 """ 

586 ctx = _guard_interactive("prompt()") 

587 if ctx.no_input: 

588 if default is not None: 

589 return default 

590 raise RuntimeError( 

591 "prompt(): --no-input is set, so nothing can be asked. Pass a " 

592 "default, or supply the value as a task parameter (a CLI flag)." 

593 ) 

594 return _prompt_core(message, default=default, secret=secret) 

595 

596 

597def confirm(message: str, *, default: bool = False) -> bool: 

598 """Ask a yes/no question. `--yes` auto-answers yes; Enter alone takes 

599 `default`; off a terminal or under `--no-input` the answer is `default`. 

600 Guarded like `prompt()` — interactive tasks only.""" 

601 ctx = _guard_interactive("confirm()") 

602 if ctx.assume_yes: 

603 return True 

604 if ctx.no_input: 

605 return default 

606 reply = _prompt_core( 

607 f"{message} {'[Y/n]' if default else '[y/N]'} ", 

608 default="y" if default else "n", 

609 ) 

610 return reply.strip().lower() in ("y", "yes") 

611 

612 

613def select( 

614 message: str, 

615 options: Sequence[Any], 

616 *, 

617 multiple: bool = False, 

618 default: Any = _UNSET, 

619) -> Any: 

620 """Let the person pick from a runtime-computed list — the one interactive 

621 case a flag can't cover, because the options aren't known until the task 

622 runs (which changed packages to release, which stale branches to delete). 

623 

624 `options` are strings, or `(label, value)` pairs to show one thing and 

625 return another. `multiple=True` returns the chosen subset as a list; 

626 otherwise one value is returned. Guarded like `prompt()` (interactive tasks 

627 only), and off a terminal or under `--no-input` it returns `default`, or 

628 raises if none was given. 

629 """ 

630 ctx = _guard_interactive("select()") 

631 opts = list(options) 

632 if not opts: 632 ↛ 633line 632 didn't jump to line 633 because the condition on line 632 was never true

633 raise ValueError("select(): no options to choose from") 

634 labels = [o[0] if isinstance(o, tuple) and len(o) == 2 else str(o) for o in opts] 

635 values = [o[1] if isinstance(o, tuple) and len(o) == 2 else o for o in opts] 

636 if ctx.no_input or not _stdin_is_tty(): 

637 if default is not _UNSET: 

638 return default 

639 raise RuntimeError( 

640 "select(): nothing can be asked (no terminal, or --no-input). Pass " 

641 "default=…, or take the choice as a task parameter." 

642 ) 

643 err = real_stderr() 

644 status = active_status() 

645 with _prompt_lock: 

646 if status is not None: 646 ↛ 647line 646 didn't jump to line 647 because the condition on line 646 was never true

647 status.notify(" ") 

648 err.write(_scrub(message.rstrip()) + "\n") 

649 for i, label in enumerate(labels, 1): 

650 err.write(f" {i}) {_scrub(label)}\n") 

651 hint = "numbers, comma-separated; 'all'; 'none'" if multiple else "a number" 

652 err.write(f"select ({hint}): ") 

653 err.flush() 

654 line = sys.stdin.readline().strip() 

655 if status is not None: 655 ↛ 656line 655 didn't jump to line 656 because the condition on line 655 was never true

656 status.notify("\n") 

657 if line == "" and default is not _UNSET: 657 ↛ 658line 657 didn't jump to line 658 because the condition on line 657 was never true

658 return default 

659 if multiple: 

660 return [values[i] for i in _parse_multi(line, len(values))] 

661 return values[_parse_one(line, len(values))] 

662 

663 

664def _parse_one(line: str, n: int) -> int: 

665 try: 

666 i = int(line) 

667 except ValueError: 

668 raise RuntimeError(f"select(): {line!r} is not a number 1-{n}.") from None 

669 if not 1 <= i <= n: 

670 raise RuntimeError(f"select(): {i} is out of range 1-{n}.") 

671 return i - 1 

672 

673 

674def _parse_multi(line: str, n: int) -> list[int]: 

675 low = line.lower() 

676 if low in ("all", "*"): 

677 return list(range(n)) 

678 if low == "none": 

679 return [] 

680 return sorted({_parse_one(tok, n) for tok in line.replace(",", " ").split()}) 

681 

682 

683@contextlib.contextmanager 

684def routing(): 

685 """Install stdout/stderr routers for the duration of a run. 

686 

687 Both streams proxy through the running task's sink, so an in-process tool's 

688 stderr is captured alongside its stdout (matching the merged subprocess 

689 capture) instead of leaking to the terminal. The routers are *stacked*, not 

690 reset to None: a nested run — e.g. `tools.pytest(in_process=True)` driving 

691 the shipped `fm` fixture — restores the outer routers on exit, so the outer 

692 run's capture keeps working afterwards. 

693 """ 

694 global _router, _err_router 

695 prev_out, prev_err = _router, _err_router 

696 real_out, real_err = sys.stdout, sys.stderr 

697 # A tool (or footman's own status line) may emit non-ASCII on a 

698 # locale-encoded pipe (cp1252 on Windows CI, errors='strict' by default); 

699 # degrade unencodable glyphs to '?' instead of crashing the run. 

700 for stream in (real_out, real_err): 

701 with contextlib.suppress(Exception): 

702 if hasattr(stream, "reconfigure"): 

703 stream.reconfigure(errors="replace") # type: ignore[union-attr] 

704 _router, _err_router = _Router(real_out), _Router(real_err, err=True) 

705 sys.stdout, sys.stderr = _router, _err_router # type: ignore[assignment] 

706 try: 

707 # (real stdout, real stderr): task blocks land on the first, the live 

708 # status line on the second — stdout is the answer, stderr commentary. 

709 yield real_out, real_err 

710 finally: 

711 sys.stdout, sys.stderr = real_out, real_err 

712 _router, _err_router = prev_out, prev_err 

713 

714 

715# --- run() ------------------------------------------------------------------- 

716 

717 

718def _is_code(value: Any) -> bool: 

719 return isinstance(value, int) and not isinstance(value, bool) 

720 

721 

722@dataclass(frozen=True) 

723class Invocation: 

724 """What a `run()` call *is*, apart from how it's spelled to execute. 

725 

726 The `tools.*` bridge builds one of these so `run()` can show a readable, 

727 syntax-highlighted command line — options in separated form, tagged by 

728 role — while executing whatever the tool actually needs (attached flags, 

729 or an in-process callable). `parts` is the normalised, human form; 

730 `exact` is the literal argv, shown under `--verbose` and always 

731 copy-pasteable. Passing it is how the two are kept from drifting: both 

732 come from one translation of one call. 

733 """ 

734 

735 parts: tuple[tuple[str, str], ...] 

736 exact: tuple[str, ...] 

737 

738 def text(self, *, exact: bool) -> str: 

739 """The plain command line — the width-measured, non-colour form.""" 

740 if exact: 

741 return " ".join(_shell_quote(a) for a in self.exact) 

742 return " ".join(text for _, text in self.parts) 

743 

744 def painted(self, *, color: bool, exact: bool) -> str: 

745 """The shown command line, role-coloured when the stream wants it.""" 

746 if exact or not color: 

747 return self.text(exact=exact) 

748 from footman._describe import paint_cli 

749 

750 return paint_cli(list(self.parts), color) 

751 

752 

753def _shell_quote(text: str) -> str: 

754 """Quote one token so the shown command line pastes back into a real shell. 

755 

756 Per-platform, so a Windows `.raw`/`--verbose` line actually round-trips: 

757 POSIX uses `shlex.quote`; Windows uses stdlib `subprocess.list2cmdline` (the 

758 exact inverse of the parsing `CreateProcess` does), never `shlex` — which 

759 emits POSIX single-quotes that cmd/PowerShell can't read. `list2cmdline` 

760 handles spaces/quotes/backslashes, not cmd metacharacters (`& | ^`), which 

761 is fine for a display line that already ran.""" 

762 if sys.platform == "win32": 

763 return subprocess.list2cmdline([text]) 

764 return shlex.quote(text) 

765 

766 

767def _exact(cmd: Any, args: tuple[Any, ...]) -> str: 

768 """The exact executed command line for a direct (non-bridge) `run()`. 

769 

770 A string is already a command line; a list is shell-quoted so it pastes; 

771 a callable has no command line, so its label stands in. 

772 """ 

773 if callable(cmd): 

774 return _label(cmd, args) 

775 if isinstance(cmd, str): 

776 return cmd 

777 return " ".join(_shell_quote(str(a)) for a in cmd) 

778 

779 

780def _label(cmd: Any, args: tuple[Any, ...]) -> str: 

781 if callable(cmd): 

782 name = getattr(cmd, "__qualname__", getattr(cmd, "__name__", repr(cmd))) 

783 return " ".join([f"{name}()", *map(str, args)]).strip() 

784 return cmd if isinstance(cmd, str) else " ".join(map(str, cmd)) 

785 

786 

787def _exit_code(exc: SystemExit) -> int: 

788 """A `SystemExit`'s exit code: its int code, 0 for `None`, else 1 (a message). 

789 

790 `sys.exit()` / `raise SystemExit(...)` is a common "fail this step" idiom, and 

791 `SystemExit` is a `BaseException` — so every place that treats a call's 

792 outcome as a code must normalise it the same way, or it escapes uncaught.""" 

793 return exc.code if isinstance(exc.code, int) else (0 if exc.code is None else 1) 

794 

795 

796def _call_for_code(cmd: Callable[..., Any], args: tuple[Any, ...]) -> int: 

797 try: 

798 returned = cmd(*args) 

799 except SystemExit as exc: 

800 return _exit_code(exc) 

801 if isinstance(returned, int) and not isinstance(returned, bool): 

802 return returned 

803 return 0 

804 

805 

806_state_lock = threading.RLock() 

807 

808 

809@contextlib.contextmanager 

810def _process_state(env: dict[str, str], cwd: Path | None) -> Iterator[None]: 

811 """Patch `os.environ` / the process cwd around an in-process callable. 

812 

813 In-process tools must honor the same env overlay and run-from-defining- 

814 folder contract the subprocess branch of the *same* call already obeys. 

815 `os.chdir` and `os.environ` are process-global, so any change is guarded by a 

816 re-entrant lock (a callable may itself call `run()`) and restored on exit — 

817 calls that need a patch therefore serialize. The common case (no overlay, no 

818 cwd — in-memory Group tasks have no defining dir) takes the lock-free fast 

819 path, so barrier-overlap parallelism stays fully concurrent. 

820 """ 

821 if not env and cwd is None: 

822 yield 

823 return 

824 with _state_lock: 

825 saved_env = os.environ.copy() 

826 saved_cwd = os.getcwd() if cwd is not None else None 

827 try: 

828 os.environ.update(env) 

829 if cwd is not None: 

830 os.chdir(cwd) 

831 yield 

832 finally: 

833 if saved_cwd is not None: 

834 os.chdir(saved_cwd) 

835 os.environ.clear() 

836 os.environ.update(saved_env) 

837 

838 

839def _run_callable( 

840 cmd: Callable[..., Any], 

841 args: tuple[Any, ...], 

842 *, 

843 capture: bool = True, 

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

845 cwd: str | Path | None = None, 

846) -> tuple[int, str, str]: 

847 """Run a callable — parallel-safe under the router, honoring env/cwd. 

848 

849 With the router installed, every write this thread makes already 

850 dispatches through `current().sink`/`err_sink`, so capture is a 

851 thread-confined swap of the two to fresh buffers — concurrent in-process 

852 tools never touch each other's output, and the callable's stdout and stderr 

853 land in separate buffers for the step's `Result`. Outside a routed run 

854 (bare calls in scripts/tests) there is no router to lean on, so fall back to 

855 the classic global redirect (still split, into two buffers). 

856 

857 `capture=False` skips the buffers entirely (live output, returns `('', '')` 

858 like the subprocess branch) — for serve-style tasks that must not buffer 

859 unboundedly. The env overlay and cwd are applied process-globally via 

860 `_process_state`; the `capture=False` short-circuit runs *inside* it so 

861 uncaptured callables keep cwd/env too. 

862 """ 

863 ctx = current() 

864 # Colour is decided once for the whole run and published into os.environ at 

865 # the run boundary (`color_environment`), so an in-process tool reads it 

866 # straight from the environment — no per-call patch here, so the lock-free 

867 # fast path in `_process_state` is kept in every colour mode. 

868 overlay = {**ctx.env, **(env or {})} 

869 target_cwd = Path(cwd) if cwd is not None else ctx.cwd 

870 with _process_state(overlay, target_cwd): 

871 if not capture: 

872 return _call_for_code(cmd, args), "", "" 

873 out_buf, err_buf = io.StringIO(), io.StringIO() 

874 if _router is not None: 

875 saved_out, saved_err = ctx.sink, ctx.err_sink 

876 ctx.sink, ctx.err_sink = out_buf, err_buf 

877 try: 

878 code = _call_for_code(cmd, args) 

879 finally: 

880 ctx.sink, ctx.err_sink = saved_out, saved_err 

881 return code, out_buf.getvalue(), err_buf.getvalue() 

882 with ( 

883 contextlib.redirect_stdout(out_buf), 

884 contextlib.redirect_stderr(err_buf), 

885 ): 

886 code = _call_for_code(cmd, args) 

887 return code, out_buf.getvalue(), err_buf.getvalue() 

888 

889 

890# Live subprocesses footman has spawned, so fail-fast can terminate the ones 

891# still running when a sibling fails. A run in-process (a `tools` entry point, 

892# a callable) registers nothing — there is no child to kill, and it finishes. 

893# Each child records its task's keep-going policy, so a fail-fast failure can 

894# reap the fail-fast trees in a mixed run while a keep-going tree runs on. 

895_live_children: dict[subprocess.Popen[str], bool] = {} # proc -> keep_going 

896_children_lock = threading.Lock() 

897_aborting = threading.Event() # set once *any* termination (fail-fast/Ctrl-C) fired 

898_abort_full = threading.Event() # set when the abort spares nothing (Ctrl-C, error) 

899 

900 

901def _kill_tree(proc: subprocess.Popen[str], *, force: bool) -> None: 

902 """Signal a spawned child *and its descendants*, not just the child itself. 

903 

904 A killable child leads its own process group (POSIX) / group (Windows), and 

905 its grandchildren inherit it — so a group-wide signal reaps the workers a 

906 bare `terminate()` would orphan: pytest-xdist, `make -j`, a shell script's 

907 background jobs. POSIX sends SIGTERM, or SIGKILL once *force*. Windows has no 

908 SIGTERM/SIGKILL split, so `taskkill /T` (walk the tree by PID) `/F` (force) 

909 is one forceful shot for both — the escalation below is then a harmless 

910 re-run against a tree that is already gone. 

911 """ 

912 with contextlib.suppress(ProcessLookupError, OSError): 

913 if sys.platform == "win32": 

914 subprocess.run( 

915 ["taskkill", "/F", "/T", "/PID", str(proc.pid)], 

916 stdout=subprocess.DEVNULL, 

917 stderr=subprocess.DEVNULL, 

918 check=False, 

919 ) 

920 return 

921 sig = signal.SIGKILL if force else signal.SIGTERM 

922 # Kill the whole group only when this child actually *leads* one (spawned 

923 # with start_new_session, so pgid == pid) — that is how the group reaches 

924 # its grandchildren. A child that shares footman's group — an interactive 

925 # task's child, or one a caller spawned without isolation — is signalled 

926 # alone: never killpg a group we don't own, or fail-fast could take out 

927 # the runner itself. 

928 if os.getpgid(proc.pid) == proc.pid: 

929 os.killpg(proc.pid, sig) 

930 else: 

931 os.kill(proc.pid, sig) 

932 

933 

934def _register_child(proc: subprocess.Popen[str], keep_going: bool = False) -> None: 

935 # Under the one lock: recording the child and reading the abort flags can't 

936 # interleave with `terminate_live_children` setting them and snapshotting — so 

937 # a child is killed either by that sweep or by this check, never missed. 

938 with _children_lock: 

939 _live_children[proc] = keep_going 

940 aborting = _aborting.is_set() 

941 full = _abort_full.is_set() 

942 # A child spawned after an abort fired self-terminates, so the doomed run 

943 # can't outrun the kill — but a keep-going child spawned after a *fail-fast* 

944 # abort (not a full Ctrl-C) is spared, matching the per-subtree policy. 

945 if aborting and (full or not keep_going): 

946 _kill_tree(proc, force=False) 

947 

948 

949def _forget_child(proc: subprocess.Popen[str]) -> None: 

950 with _children_lock: 

951 _live_children.pop(proc, None) 

952 

953 

954def reset_abort() -> None: 

955 """Clear the abort flags at the start of a run.""" 

956 _aborting.clear() 

957 _abort_full.clear() 

958 

959 

960def terminate_live_children(grace: float = 2.0, *, failfast_only: bool = False) -> None: 

961 """Terminate still-running spawned subprocess *trees* — fail-fast's teeth. 

962 

963 With *failfast_only* (a per-node fail-fast failure) only fail-fast children 

964 are reaped, so a keep-going task in a mixed run keeps running; the default 

965 (a full abort — Ctrl-C, an internal error) reaps everything. 

966 

967 Each killable child was spawned in its own process group, so the SIGTERM 

968 (POSIX) / `taskkill /T` (Windows) here reaches its grandchildren too; the 

969 `communicate()` blocking each task's thread then returns and the task 

970 unwinds. The abort is *latched*, so a subprocess a still-running task spawns 

971 *after* this fires self-terminates on registration (the doomed run can't 

972 outrun the kill; `_register_child` applies the same failfast_only sparing). A 

973 group that ignores SIGTERM is SIGKILLed after *grace* seconds by a daemon 

974 watcher — a hung tool can't wedge the run. In-process runs register nothing, 

975 so they finish on their own — un-killable for free, which is the intended 

976 behaviour. This is also the Ctrl-C reaper: a group-isolated child no longer 

977 receives the terminal's SIGINT, so the abort paths call this by hand. 

978 """ 

979 with _children_lock: 

980 _aborting.set() 

981 if not failfast_only: 

982 _abort_full.set() 

983 procs = [p for p, kg in _live_children.items() if not (failfast_only and kg)] 

984 for proc in procs: 

985 _kill_tree(proc, force=False) 

986 if not procs: 

987 return 

988 

989 def _escalate() -> None: 

990 time.sleep(grace) 

991 for proc in procs: 

992 if proc.poll() is None: # still alive → it ignored SIGTERM, force it 

993 _kill_tree(proc, force=True) 

994 

995 threading.Thread(target=_escalate, daemon=True, name="fm-fail-fast-kill").start() 

996 

997 

998def _run_subprocess( 

999 argv: list[str] | str, 

1000 env: dict[str, str], 

1001 cwd: Path | None, 

1002 capture: bool, 

1003 encoding: str | None = "utf-8", 

1004 killable: bool = True, 

1005 isolate: bool = True, 

1006 keep_going: bool = False, 

1007) -> tuple[int, str, str]: 

1008 # Dev tools (pytest, ruff, git, uv) emit UTF-8 regardless of the OS code 

1009 # page, so decode as UTF-8 by default rather than the locale encoding 

1010 # (cp1252 on Windows would mojibake the capture). `encoding=None` restores 

1011 # locale behavior. `errors="replace"` is the never-crash net either way. 

1012 # 

1013 # Popen + a live registry (not subprocess.run) so a concurrent fail-fast can 

1014 # terminate this child while its thread is blocked in communicate(). 

1015 # 

1016 # An isolated child leads its own process group (POSIX session / Windows 

1017 # group) so `terminate_live_children` can kill the whole tree, not just the 

1018 # child — a tool's own workers (pytest-xdist, `make -j`) die with it. The 

1019 # cost: it no longer receives the terminal's Ctrl-C, so the scheduler's abort 

1020 # paths reap it by hand. Two children opt out and stay in footman's group: 

1021 # `atomic` (fail-fast never kills it, so in-group keeps its Ctrl-C behaviour 

1022 # unchanged) and `interactive` (it owns the real terminal — setsid would strip 

1023 # its controlling tty and a full-screen program would misbehave). The kill 

1024 # guard signals such an in-group child alone, never the shared group. 

1025 group: dict[str, Any] = {} 

1026 if isolate: 

1027 if sys.platform == "win32": 

1028 group["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP 

1029 else: 

1030 group["start_new_session"] = True 

1031 proc = subprocess.Popen( 

1032 argv, 

1033 env=env, 

1034 cwd=cwd, 

1035 stdout=subprocess.PIPE if capture else None, 

1036 stderr=subprocess.PIPE if capture else None, 

1037 text=True, 

1038 encoding=encoding, 

1039 errors="replace", 

1040 **group, 

1041 ) 

1042 # An `@task(atomic=True)` opts its child out of the registry: fail-fast 

1043 # never kills it, so a mid-write (a formatter rewriting a file) can't be 

1044 # truncated. It runs to completion; the run waits for it. 

1045 if killable: 

1046 _register_child(proc, keep_going) 

1047 try: 

1048 out, err = proc.communicate() 

1049 finally: 

1050 if killable: 

1051 _forget_child(proc) 

1052 if not capture: 1052 ↛ 1053line 1052 didn't jump to line 1053 because the condition on line 1052 was never true

1053 return proc.returncode, "", "" 

1054 return proc.returncode, out or "", err or "" 

1055 

1056 

1057def _dim(text: str, color: bool) -> str: 

1058 return f"\033[2m{text}\033[0m" if color else text 

1059 

1060 

1061def _colored(ctx: Context) -> bool: 

1062 """The one colour predicate: does footman dress this run's output — its own 

1063 chrome and the tools it spawns — for colour? 

1064 

1065 `never` (`no_color`) always wins; `always` (`force_color`) forces colour on 

1066 even off a terminal (a pipe into `less -R`); otherwise `auto` follows the 

1067 run's tty-ness (`NO_COLOR` in the environment still bows out).""" 

1068 if ctx.no_color: 

1069 return False 

1070 if ctx.force_color: 

1071 return True 

1072 return ctx.tty and "NO_COLOR" not in os.environ 

1073 

1074 

1075def color_on() -> bool: 

1076 """Whether the current run emits colour — the tools bridge asks this to 

1077 decide whether to force a tool's own `--color` flag (for the few that 

1078 ignore the environment). A plain call outside a run reads a default 

1079 (monochrome) context, so a bare `tools.git.diff()` in a script adds 

1080 nothing.""" 

1081 return _colored(current()) 

1082 

1083 

1084# Every colour variable footman speaks. `color_environment` clears the whole set 

1085# before setting a direction, so forcing colour *off* is the *absence* of 

1086# FORCE_COLOR, not `FORCE_COLOR=0` — some tools (ruff) read the mere presence of 

1087# FORCE_COLOR as "force on", ignoring `NO_COLOR`, so `"0"` would fail to silence 

1088# them. footman emits by presence/absence; it consumes `NO_COLOR` by presence and 

1089# `FORCE_COLOR` by truthiness (`_resolve_color`). 

1090_COLOR_VARS = ("FORCE_COLOR", "CLICOLOR_FORCE", "CLICOLOR", "NO_COLOR") 

1091 

1092 

1093def color_env(on: bool) -> dict[str, str]: 

1094 """The colour variables to *set* to force colour on (or off) for a child. 

1095 

1096 Without a PTY a child's stdout is a pipe, so `isatty()` is false and a 

1097 well-behaved tool auto-disables colour — footman captures the bytes and 

1098 replays them onto its own terminal, so it wants the colour back. `on` sets 

1099 `FORCE_COLOR`/`CLICOLOR_FORCE`; off sets only `NO_COLOR`. Forcing off is 

1100 completed by *removing* any inherited `FORCE_COLOR` (see `_COLOR_VARS` / 

1101 `color_environment`), never by setting it to `"0"`.""" 

1102 if on: 

1103 return {"FORCE_COLOR": "1", "CLICOLOR_FORCE": "1", "CLICOLOR": "1"} 

1104 return {"NO_COLOR": "1"} 

1105 

1106 

1107def run_colour_on( 

1108 *, no_color: bool, force_color: bool, capture: bool, isatty: bool 

1109) -> bool: 

1110 """The run-wide colour decision, computed once from its run-wide inputs. 

1111 

1112 Colour cannot change during a run, so this is `_colored` for every task at 

1113 once — letting the environment be set once at the run boundary rather than 

1114 per call. Capture (a `--json` run) is byte-clean and gates `force_color` off, 

1115 exactly as the scheduler does per task; the rest defers to `_colored`.""" 

1116 if capture: 

1117 return False 

1118 tty = isatty and os.environ.get("TERM") != "dumb" 

1119 return _colored(Context(no_color=no_color, force_color=force_color, tty=tty)) 

1120 

1121 

1122@contextlib.contextmanager 

1123def color_environment(on: bool) -> Iterator[None]: 

1124 """Publish the run-wide colour decision into `os.environ` for the run. 

1125 

1126 One decision, set once: a subprocess inherits it, an in-process tool reads 

1127 it — so no `run()` call has to patch the environment itself (and none takes 

1128 the `_process_state` lock for colour). Every colour variable is cleared 

1129 first, then this direction's are set — so *off* leaves no `FORCE_COLOR` for a 

1130 presence-checking tool to honour, and *on* leaves no stray `NO_COLOR`. 

1131 Restored on exit; nested runs stack, each restoring the value it found.""" 

1132 saved = {key: os.environ.get(key) for key in _COLOR_VARS} 

1133 for key in _COLOR_VARS: 

1134 os.environ.pop(key, None) 

1135 os.environ.update(color_env(on)) 

1136 try: 

1137 yield 

1138 finally: 

1139 for key, previous in saved.items(): 

1140 if previous is None: 

1141 os.environ.pop(key, None) 

1142 else: 

1143 os.environ[key] = previous 

1144 

1145 

1146def _name_col(ctx: Context) -> str: 

1147 """The step line's task-name column, padded so siblings align. 

1148 

1149 Bold on colour terminals; empty (no column at all) outside a run, so a 

1150 plain `run()` call keeps its old shape. 

1151 """ 

1152 if not ctx.task: 

1153 return "" 

1154 padded = f"{ctx.task:<{max(ctx.name_width, len(ctx.task))}}" 

1155 return (f"\033[1m{padded}\033[0m" if _colored(ctx) else padded) + " " 

1156 

1157 

1158def _step_line(ctx: Context, ok: bool, label: str, duration: float) -> str: 

1159 """One completed step: mark · name · dimmed command · aligned time.""" 

1160 from footman._progress import fmt_secs 

1161 

1162 color = _colored(ctx) 

1163 time_text = f"({fmt_secs(duration)})" 

1164 name = _name_col(ctx) 

1165 # Times align to the widest command — remembered from the previous run 

1166 # of this chain (a warm run aligns from its first line), learned as a 

1167 # running max on a cold one. Never the terminal edge: that reads absurd 

1168 # on wide terminals. 

1169 label = f"{label:<{_observe_cmd(label)}}" 

1170 

1171 if not ctx.tty: 

1172 return f"{'ok' if ok else 'FAIL':<4} {name}{label} {time_text}\n" 

1173 

1174 mark = ( 

1175 ("\033[32m✓\033[0m" if ok else "\033[31m✗\033[0m") 

1176 if color 

1177 else ("ok" if ok else "FAIL") 

1178 ) 

1179 shown = f"\033[36m{time_text}\033[0m" if color else time_text 

1180 # The time sits right after the command — a right-aligned column reads 

1181 # absurd on wide terminals, with the time a screen away from its line. 

1182 return f"{mark} {name}{_dim(label, color)} {shown}\n" 

1183 

1184 

1185# The tokens that mean "shell" and nothing else. `run(str)` splits and execs 

1186# directly — no shell — so any of these would ride along as a *literal* argument, 

1187# silently breaking a pipeline or redirect (a `tar … | ssh …` that never pipes). 

1188_SHELL_OPERATORS = frozenset( 

1189 { 

1190 "|", 

1191 "||", 

1192 "|&", 

1193 "&", 

1194 "&&", 

1195 ";", 

1196 ";;", 

1197 ">", 

1198 ">>", 

1199 "<", 

1200 "<<", 

1201 "<<<", 

1202 "<>", 

1203 "2>", 

1204 "2>>", 

1205 "&>", 

1206 } 

1207) 

1208 

1209 

1210def _shell_operator(cmd: str) -> str | None: 

1211 """The first bare shell-operator token in *cmd*, or `None`. 

1212 

1213 Only an operator standing as *its own token* counts — the spaced form a 

1214 shell would honour (`… | …`, `… > out`). A glued `a>b` stays one token and 

1215 is left alone, as is any operator inside quotes. Split failures (unbalanced 

1216 quotes) defer to the exec path, which surfaces them. 

1217 """ 

1218 try: 

1219 # posix=False on Windows: keep backslash paths intact (they'd otherwise 

1220 # be eaten), so a real path token never looks like an operator. 

1221 tokens = shlex.split(cmd, posix=(os.name != "nt")) 

1222 except ValueError: 

1223 return None 

1224 return next((t for t in tokens if t in _SHELL_OPERATORS), None) 

1225 

1226 

1227# A modern bash where PATH alone might miss it (a GUI/cron launch, or Windows, 

1228# where git's bash is not on PATH). Checked before `shutil.which("bash")`. 

1229_BASH_HINTS = ( 

1230 "/opt/homebrew/bin/bash", # macOS Apple Silicon Homebrew 

1231 "/usr/local/bin/bash", # macOS Intel Homebrew 

1232 r"C:\Program Files\Git\bin\bash.exe", # Windows git bash 

1233 r"C:\Program Files\Git\usr\bin\bash.exe", 

1234) 

1235 

1236 

1237def _find_exe(name: str, hints: tuple[str, ...] = ()) -> str | None: 

1238 """A concrete path for *name*: a known *hints* location first, then PATH.""" 

1239 for hint in hints: 

1240 if os.path.isfile(hint): 

1241 return hint 

1242 return shutil.which(name) 

1243 

1244 

1245def _resolve_shell(kind: bool | str, policy: str = "posix") -> list[str]: 

1246 """The interpreter argv prefix — `[executable, run-a-string-flag]` — for a 

1247 `run(shell=…)` request. 

1248 

1249 `True` follows *policy* (POSIX-everywhere by default: bash, then plain sh, 

1250 with git bash on Windows). A string is a concrete shell (`bash`/`zsh`/`sh`/ 

1251 `fish`/`nu`/`pwsh`/`cmd`) or a strategy (`posix`/`native`). Raises a taught 

1252 `ValueError` when the shell can't be found or does not fit the platform — 

1253 never a silent wrong-shell. 

1254 """ 

1255 strat = policy if kind is True else str(kind) 

1256 win = sys.platform == "win32" 

1257 if strat == "posix": 

1258 # bash first (pipefail + POSIX word-splitting, and everywhere incl. git 

1259 # bash on Windows), then plain sh. zsh is excluded — its default word 

1260 # splitting is not POSIX, so ask for it by name if you want it. 

1261 exe = _find_exe("bash", _BASH_HINTS) or _find_exe("sh") 

1262 if exe is None: 

1263 raise ValueError( 

1264 "shell=True needs a POSIX shell and none was found. Install one " 

1265 "(git bash on Windows), or use shell='pwsh' / shell='cmd'." 

1266 ) 

1267 return [exe, "-c"] 

1268 if strat == "native": 

1269 return ( 

1270 [os.environ.get("COMSPEC", "cmd.exe"), "/c"] if win else ["/bin/sh", "-c"] 

1271 ) 

1272 if strat == "cmd": 

1273 if not win: 1273 ↛ 1275line 1273 didn't jump to line 1275 because the condition on line 1273 was always true

1274 raise ValueError("shell='cmd' is Windows-only; use 'bash' or 'pwsh'.") 

1275 return [os.environ.get("COMSPEC", "cmd.exe"), "/c"] 

1276 if strat in ("bash", "sh", "zsh", "fish", "nu"): 

1277 exe = _find_exe(strat, _BASH_HINTS if strat == "bash" else ()) 

1278 if exe is None: 1278 ↛ 1279line 1278 didn't jump to line 1279 because the condition on line 1278 was never true

1279 raise ValueError(f"shell={strat!r}: {strat!r} was not found on PATH.") 

1280 return [exe, "-c"] 

1281 if strat in ("pwsh", "powershell"): 

1282 exe = _find_exe(strat) 

1283 if exe is None: 1283 ↛ 1284line 1283 didn't jump to line 1284 because the condition on line 1283 was never true

1284 raise ValueError(f"shell={strat!r}: {strat!r} was not found on PATH.") 

1285 return [exe, "-Command"] # pwsh's own run-a-string flag (accepts -c too) 

1286 raise ValueError( 

1287 f"shell={kind!r} is not a known shell. Use True (the policy), a strategy " 

1288 f"('posix' / 'native'), or a shell name " 

1289 f"('bash', 'zsh', 'sh', 'fish', 'nu', 'pwsh', 'cmd')." 

1290 ) 

1291 

1292 

1293# `clean=True`: run the interpreter without the user's startup files, so a task's 

1294# shell behaves the same on every machine. For `-c` most POSIX shells already 

1295# skip their rc, but pwsh/cmd load a profile and bash honours $BASH_ENV — so it 

1296# is both these flags and (POSIX) dropping BASH_ENV/ENV from the child env. 

1297_CLEAN_FLAGS = { 

1298 "bash": ("--norc", "--noprofile"), 

1299 "zsh": ("-f",), 

1300 "fish": ("--no-config",), 

1301 "nu": ("-n",), 

1302 "pwsh": ("-NoProfile",), 

1303 "powershell": ("-NoProfile",), 

1304 "cmd": ("/d",), 

1305} 

1306 

1307# `strict=True`: fail on the first error and on a failing pipe stage. Well-defined 

1308# only for POSIX shells and PowerShell — bash/zsh get pipefail, plain sh cannot 

1309# (dash has none) so it degrades to errexit-only with a one-time note; fish/nu/ 

1310# cmd have no errexit at all, so strict there is a taught error, not a silent no-op. 

1311_STRICT_PROLOGUE = { 

1312 "bash": "set -eo pipefail\n", 

1313 "zsh": "set -eo pipefail\n", 

1314 "sh": "set -e\n", 

1315 "pwsh": ( 

1316 "$ErrorActionPreference = 'Stop'\n" 

1317 "$PSNativeCommandUseErrorActionPreference = $true\n" 

1318 ), 

1319 "powershell": "$ErrorActionPreference = 'Stop'\n", 

1320} 

1321 

1322_strict_sh_noted = False 

1323 

1324 

1325def _shell_kind_of(exe: str) -> str: 

1326 """The shell family from its executable path — `/usr/bin/bash` → `bash`.""" 

1327 return os.path.basename(exe).lower().removesuffix(".exe") 

1328 

1329 

1330def _shell_prep( 

1331 kind: str, script: str, *, strict: bool, clean: bool 

1332) -> tuple[list[str], str]: 

1333 """Interpreter flags (from *clean*) and the script (from *strict*) for a shell 

1334 run. Raises a taught error when *strict* can't be honoured (fish/nu/cmd have 

1335 no errexit/pipefail).""" 

1336 flags = list(_CLEAN_FLAGS.get(kind, ())) if clean else [] 

1337 if strict: 

1338 prologue = _STRICT_PROLOGUE.get(kind) 

1339 if prologue is None: 

1340 raise ValueError( 

1341 f"strict=True is not supported for the {kind!r} shell — it has no " 

1342 f"errexit/pipefail. Use bash, zsh, sh, or pwsh, or drop strict." 

1343 ) 

1344 if kind == "sh": 

1345 global _strict_sh_noted 

1346 if not _strict_sh_noted: 1346 ↛ 1352line 1346 didn't jump to line 1352 because the condition on line 1346 was always true

1347 _strict_sh_noted = True 

1348 real_stderr().write( 

1349 "note: strict under sh has no pipefail; using `set -e` only " 

1350 "(install bash for errexit + pipefail).\n" 

1351 ) 

1352 script = prologue + script 

1353 return flags, script 

1354 

1355 

1356def run( 

1357 cmd: str | list[str] | Callable[..., Any], 

1358 *args: Any, 

1359 nofail: bool = False, 

1360 silent: bool = False, 

1361 capture: bool = True, 

1362 title: str | None = None, 

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

1364 cwd: str | Path | None = None, 

1365 encoding: str | None = "utf-8", 

1366 shell: bool | str = False, 

1367 strict: bool = False, 

1368 clean: bool = False, 

1369 _show: Invocation | None = None, 

1370) -> Result: 

1371 """Run a command or a Python callable in the current task's context. 

1372 

1373 Subprocess output is decoded as UTF-8 by default; pass `encoding=` for a 

1374 tool that speaks another code page, or `encoding=None` for the locale 

1375 default. Ignored for callables (in-process, no bytes boundary). 

1376 

1377 `_show` is an internal channel from the `tools.*` bridge: a structured 

1378 view of the call, so the shown command line can be normalised and 

1379 role-coloured while execution runs whatever the tool needs. An explicit 

1380 `title` still wins; a direct `run([...])` is unaffected. 

1381 """ 

1382 ctx = current() 

1383 if (strict or clean) and not shell: 

1384 # strict/clean harden a *shell* run — they mean nothing shell-free, and a 

1385 # silent no-op would be exactly the surprise footman avoids elsewhere. 

1386 which = "strict" if strict else "clean" 

1387 raise ValueError( 

1388 f"run({which}=True) only applies with a shell — it hardens a shell " 

1389 f"run. Pass shell=True (or a shell name), or drop {which}." 

1390 ) 

1391 out = sys.stdout 

1392 color = _colored(ctx) 

1393 if _show is not None and title is None: 

1394 # `label` (recorded as .command, and the step-line receipt) is always 

1395 # the normalised form, so a recording() assertion never depends on 

1396 # --verbose. Only the live "about to / now running" line switches to 

1397 # the exact spelling under --verbose; .raw always carries it. 

1398 label = _show.text(exact=False) 

1399 raw = _show.text(exact=True) 

1400 shown = _show.painted(color=color, exact=ctx.verbose) 

1401 shown_plain = _show.text(exact=ctx.verbose) 

1402 else: 

1403 label = title or _label(cmd, args) 

1404 raw = _exact(cmd, args) 

1405 shown = _dim(label, color) 

1406 shown_plain = label 

1407 

1408 if ctx.dry_run: 

1409 # Record the step even when not executing: `dry_run` + `quiet` is the 

1410 # silent-capture mode `footman.testing` builds on. The recorded label 

1411 # is normalised; only the shown line colours or (under -v) goes exact. 

1412 result = Result(0, command=label, raw=raw) 

1413 ctx.steps.append(result) 

1414 if not ctx.quiet: 

1415 out.write(f"$ {shown if color else shown_plain}\n") 

1416 return result 

1417 

1418 show = not silent and not ctx.quiet 

1419 # `ctx.tty` means "this output dresses for a terminal" (colour, marks); 

1420 # liveness is `sink is None`. A captured block styles for the terminal 

1421 # it will replay onto, but in-place rewrites and the announce line stay 

1422 # live-only: control bytes must never land in a capture buffer. 

1423 live = ctx.sink is None 

1424 if show: 

1425 # The arrow announces what is *running now* — worth a line only 

1426 # while output is live (a TTY rewrites it in place; a streamed CI 

1427 # log may wait minutes under it). A captured block flushes when 

1428 # the task is already done, where "starting X" directly above 

1429 # "finished X" says nothing — the completion line carries it all. 

1430 if ctx.tty and live: 1430 ↛ 1431line 1430 didn't jump to line 1431 because the condition on line 1430 was never true

1431 out.write(f"{_name_col(ctx)}{shown}") 

1432 out.flush() 

1433 elif live: 

1434 out.write(f"{_name_col(ctx)}{shown_plain}\n") 

1435 out.flush() 

1436 

1437 start = time.perf_counter() 

1438 if callable(cmd): 

1439 code, out_s, err_s = _run_callable(cmd, args, capture=capture, env=env, cwd=cwd) 

1440 else: 

1441 argv: list[str] | str 

1442 shell_kind = "" 

1443 if shell: 

1444 # An explicit shell: run the whole string through the resolved 

1445 # interpreter — `[bash, -c, "<cmd>"]` — so pipes/redirects/globs 

1446 # work. A list is the shell-free form; it can't be a shell script. 

1447 if not isinstance(cmd, str): 

1448 raise ValueError( 

1449 "run(shell=…) runs a command *string* through a shell; pass a " 

1450 "str, not a list (a list is the shell-free form)." 

1451 ) 

1452 exe, run_flag = _resolve_shell(shell, ctx.shell_default or "posix") 

1453 shell_kind = _shell_kind_of(exe) 

1454 clean_flags, script = _shell_prep( 

1455 shell_kind, cmd, strict=strict, clean=clean 

1456 ) 

1457 argv = [exe, *clean_flags, run_flag, script] 

1458 elif isinstance(cmd, str): 

1459 if (op := _shell_operator(cmd)) is not None: 

1460 raise ValueError( 

1461 f"run({cmd!r}): {op!r} is a shell operator, but run() does not " 

1462 f"use a shell, so it would be passed as a literal argument (the " 

1463 f"pipeline/redirect would silently not happen). Ask for a shell " 

1464 f"— run(..., shell=True) or shell='bash' — split into separate " 

1465 f"run() steps, or pass a list to use {op!r} as a literal argument." 

1466 ) 

1467 # POSIX shells split on shlex rules; Windows command lines are a 

1468 # single string (CreateProcess) and shlex would mangle backslash 

1469 # paths — hand the string straight to subprocess there. 

1470 argv = cmd if sys.platform == "win32" else shlex.split(cmd) 

1471 else: 

1472 argv = [str(a) for a in cmd] 

1473 # The child inherits the run-wide colour decision from os.environ, set 

1474 # once at the run boundary (`color_environment`) — FORCE_COLOR so it 

1475 # colours past its own pipe, or NO_COLOR so it stays quiet. A task's 

1476 # `ctx.env`/`env=` still overrides it. 

1477 run_env = {**os.environ, **ctx.env, **(env or {})} 

1478 # A clean POSIX shell means no startup files: `--norc`/`--noprofile` 

1479 # cover interactive/login rc, but bash/sh also source $BASH_ENV/$ENV for 

1480 # a non-interactive `-c`, so drop those from the child env too. 

1481 if clean and shell_kind in ("bash", "sh", "zsh"): 1481 ↛ 1482line 1481 didn't jump to line 1482 because the condition on line 1481 was never true

1482 run_env = {k: v for k, v in run_env.items() if k not in ("BASH_ENV", "ENV")} 

1483 cwd_path = Path(cwd) if cwd is not None else ctx.cwd 

1484 code, out_s, err_s = _run_subprocess( 

1485 argv, 

1486 run_env, 

1487 cwd_path, 

1488 capture, 

1489 encoding, 

1490 killable=not ctx.atomic, 

1491 # An interactive task owns the real terminal: keep its child in 

1492 # footman's group so it keeps its controlling tty (and its Ctrl-C). 

1493 isolate=not ctx.atomic and not ctx.interactive, 

1494 # Tag the child with its task's policy: a fail-fast failure elsewhere 

1495 # reaps this tree only if the task is fail-fast, not keep-going. 

1496 keep_going=ctx.keep_going, 

1497 ) 

1498 duration = time.perf_counter() - start 

1499 result = Result( 

1500 code, command=label, stdout=out_s, stderr=err_s, duration=duration, raw=raw 

1501 ) 

1502 ctx.steps.append(result) 

1503 

1504 if show: 

1505 ok = code == 0 

1506 prefix = "\r\033[K" if ctx.tty and live else "" 

1507 out.write(f"{prefix}{_step_line(ctx, ok, label, duration)}") 

1508 # Join the two streams only to *display* them (stdout then stderr); 

1509 # nothing merged is stored — the Result keeps them apart. 

1510 combined = out_s + err_s 

1511 if capture and combined and (not ok or ctx.verbose): 

1512 out.write(combined if combined.endswith("\n") else combined + "\n") 

1513 out.flush() 

1514 

1515 if code != 0 and not nofail: 

1516 raise RunFailed(result) 

1517 return result 

1518 

1519 

1520# --- parallel() -------------------------------------------------------------- 

1521 

1522 

1523def parallel(*calls: Callable[[], Any], keep_going: bool = False) -> list[int]: 

1524 """Run task calls / thunks concurrently; wait; fail if any fail. 

1525 

1526 Each call runs in a child of the current context with its own output buffer, 

1527 flushed atomically on completion so concurrent output never interleaves. 

1528 Pass task functions directly (`parallel(lint, typecheck)`) or thunks for 

1529 arguments (`parallel(lambda: build("web"), lambda: build("api"))`). 

1530 """ 

1531 from concurrent.futures import ThreadPoolExecutor 

1532 

1533 parent = current() 

1534 dest = parent.sink or real_stdout() 

1535 dest_is_real = parent.sink is None 

1536 lock = threading.Lock() 

1537 # parallel() children are units on the live status line, exactly like 

1538 # scheduler nodes — a chain and a task-body fan-out present identically. 

1539 status = _status 

1540 if status is not None: 

1541 status.unit_added(len(calls)) 

1542 

1543 def _call_name(call: Callable[[], Any]) -> str: 

1544 if isinstance(call, functools.partial): # partial(fmt, check=True) 1544 ↛ 1545line 1544 didn't jump to line 1545 because the condition on line 1544 was never true

1545 call = call.func 

1546 name = getattr(call, "__name__", "task") 

1547 return "…" if name == "<lambda>" else name # anonymous: no honest name 

1548 

1549 # Sibling names are known up front, so their step lines can align. 

1550 width = max((len(_call_name(c)) for c in calls), default=0) 

1551 

1552 def invoke(call: Callable[[], Any]) -> tuple[int, BaseException | None]: 

1553 name = _call_name(call) 

1554 if status is not None: 

1555 status.unit_started(name) 

1556 # One buffer for both streams at task level, so the atomic flush keeps 

1557 # this child's stdout/stderr in order; a run() inside it still splits the 

1558 # step's streams via a temporary swap. 

1559 buf = io.StringIO() 

1560 child = replace( 

1561 parent, sink=buf, err_sink=buf, steps=[], task=name, name_width=width 

1562 ) 

1563 token = _current.set(child) 

1564 try: 

1565 returned = call() 

1566 code = returned if _is_code(returned) else 0 

1567 error: BaseException | None = None 

1568 # A thunk that *returns* a non-zero code failed just as surely as one 

1569 # that raised RunFailed. Synthesize the failure here so the gate below 

1570 # treats both uniformly; `keep_going` still collects the code. 

1571 if code != 0: 

1572 thunk = _label(call, ()) 

1573 error = RunFailed(Result(code, command=thunk, raw=thunk)) 

1574 except RunFailed as exc: 

1575 code, error = exc.result.code or 1, exc 

1576 except SystemExit as exc: 

1577 # `sys.exit()` / `raise SystemExit(...)` is a common "fail this task" 

1578 # idiom, but a BaseException — without this it escapes the pool 

1579 # instead of being collected. Treat its code like a returned one: 

1580 # 0 succeeds, non-zero is a synthesized failure the gate below raises. 

1581 # (A string reason is not carried through here: parallel() deliberately 

1582 # normalises failures to a catchable RunFailed, so it must not re-raise 

1583 # a BaseException. A task body's own sys.exit("reason") keeps its 

1584 # message — see executor._call.) 

1585 code = _exit_code(exc) 

1586 error = None 

1587 if code != 0: 

1588 thunk = _label(call, ()) 

1589 error = RunFailed(Result(code, command=thunk, raw=thunk)) 

1590 except Failed as exc: 

1591 # `footman.fail("reason")` in a thunk: an Exception (not a 

1592 # BaseException), so the gate can re-raise it and its reason surfaces 

1593 # at the task level — better than the sys.exit-in-parallel corner. 

1594 # Its own code rides the return list too. 

1595 code, error = exc.code, exc 

1596 except Exception as exc: # a failed call must not crash the pool 

1597 code, error = 1, exc 

1598 finally: 

1599 _current.reset(token) 

1600 with lock: 

1601 blob = child.sink.getvalue() # type: ignore[union-attr] 

1602 # A child that ended mid-colour (a crash, an unterminated SGR) must 

1603 # not bleed into the next child's block when they interleave: cap a 

1604 # colourful run's block with a reset. Only when colour is on, so a 

1605 # byte-clean run stays byte-clean. 

1606 if blob and _colored(parent): 

1607 blob += "\033[0m" 

1608 if status is not None and dest_is_real: 

1609 # This write bypasses the routers (dest is the raw stream): 

1610 # tell the status line to get out of the way itself. 

1611 status.notify(blob) 

1612 dest.write(blob) 

1613 dest.flush() 

1614 # Surface the child's run() steps on the parent, in completion order, 

1615 # so they appear in `--json` and `recording()` (F12). 

1616 parent.steps.extend(child.steps) 

1617 if status is not None: 

1618 status.unit_finished(name, error is None) 

1619 return code, error 

1620 

1621 # -s reaches inside tasks (one worker serialises the calls in 

1622 # submission order), and -j caps the width; same code path either way. 

1623 if parent.sequential: 

1624 workers = 1 

1625 elif parent.jobs > 0: 

1626 workers = max(1, min(parent.jobs, len(calls))) 

1627 else: 

1628 workers = max(1, len(calls)) 

1629 with ThreadPoolExecutor(max_workers=workers) as pool: 

1630 outcomes = list(pool.map(invoke, calls)) 

1631 

1632 if not keep_going: 

1633 for _code, error in outcomes: 

1634 if error is not None: 

1635 raise error 

1636 return [code for code, _ in outcomes]