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
« 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()`.
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.
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"""
14from __future__ import annotations
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)
54from footman import _globals
56if TYPE_CHECKING:
57 from footman import _step
58 from footman import registry as _registry_t
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.
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 """
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."""
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."""
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)
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).
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)`).
106 The moment the line is about to cross into a shell, name that shell:
108 * `.posix()` — one string, `shlex`-quoted for `sh`/`bash`/`zsh`.
109 * `.windows()` — one string, quoted the way `CreateProcess` parses.
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`).
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 """
124 __slots__ = ()
126 def posix(self) -> str:
127 """This command as one string, quoted for a POSIX shell.
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.
133 cmd = git.commit.argv(m="ship 1.2.0")
134 ssh("deploy@host", cmd.posix())
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)
141 def windows(self) -> str:
142 """This command as one string, quoted the way `CreateProcess` parses.
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)
152class Result(int):
153 """The outcome of one `run()` call — and the value `run()` returns.
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 """
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
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
219 def to_argv(self) -> Argv:
220 """What this call ran, as the tokens it was spawned from.
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:
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'"
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)
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
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.
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
270 @property
271 def stdout(self) -> str:
272 """Captured standard output; empty when the step streamed instead."""
273 return self._stdout
275 @property
276 def stderr(self) -> str:
277 """Captured standard error; empty when the step streamed instead."""
278 return self._stderr
280 @property
281 def duration(self) -> float:
282 """Wall-clock seconds the step took."""
283 return self._duration
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
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
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
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
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
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
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
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 )
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)
354 @property
355 def ok(self) -> bool:
356 """Whether the command succeeded (exit code 0)."""
357 return self == 0
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
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)
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)
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
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
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.
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 """
417 __slots__ = (
418 "_code",
419 "_command",
420 "_duration",
421 "_raw",
422 "_returned",
423 "_stderr",
424 "_stdout",
425 "_title",
426 "_touched",
427 )
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()
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
457 @title.setter
458 def title(self, value: str) -> None:
459 self._title = value
460 self._touched.add("title")
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
469 @code.setter
470 def code(self, value: int) -> None:
471 self._code = value
472 self._touched.add("code")
474 @property
475 def ok(self) -> bool:
476 """Whether the code, as it stands, is success — derives from `code`."""
477 return self.code == 0
479 @property
480 def stdout(self) -> str:
481 """Captured standard output; empty when the call streamed instead."""
482 return self._stdout
484 @property
485 def stderr(self) -> str:
486 """Captured standard error; empty when the call streamed instead."""
487 return self._stderr
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
495 @property
496 def duration(self) -> float:
497 """Wall-clock seconds the work took — machinery-owned, read-only."""
498 return self._duration
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
506 @property
507 def command(self) -> str:
508 """The normalised command line as it stood before review."""
509 return self._command
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
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")
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
535@dataclass
536class Context:
537 """State for one running task: environment, flags, passthrough, output."""
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`)."""
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."""
552 env: dict[str, str] = field(default_factory=lambda: _globals.base_env())
553 """**This task's environment** — a complete one, not a diff.
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.
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).
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.
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."""
734 def time_left(self) -> float | None:
735 """Seconds until this task's deadline, or `None` when it has none.
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())
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
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.
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
779_current: ContextVar[Context | None] = ContextVar("footman_context", default=None)
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()
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."""
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."""
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 = ""
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."""
815 __slots__ = ("_begin", "_ctx", "name", "stream")
817 _ctx: Context
818 name: str
819 stream: str
820 _begin: float
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
828 def __enter__(self) -> _SectionTimer:
829 self._begin = time.perf_counter()
830 return self
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 )
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
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)
867def section(name: str) -> _SectionTimer:
868 """Time a section of the running task — `with footman.section("resolve"):`.
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, "")
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))
883class Stream:
884 """A named parallel timeline of sections under the running task.
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."""
890 __slots__ = ("_ctx", "name")
892 _ctx: Context
893 name: str
895 def __init__(self, ctx: Context, name: str) -> None:
896 self._ctx = ctx
897 self.name = name
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
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)
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.
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
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
1011def cache_dir() -> Path:
1012 """This CLI's cache directory, created if it isn't there yet.
1014 Derived data, safe to delete: footman's collector sweeps this folder by
1015 age, so put nothing here you would mind losing.
1017 ```python
1018 (footman.cache_dir() / "index.json").write_text(payload)
1019 ```
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
1027 return _make(_paths.footman_cache_dir())
1030def data_dir() -> Path:
1031 """This CLI's data directory, created if it isn't there yet.
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.
1038 ```python
1039 (footman.data_dir() / "credentials.json").write_text(token)
1040 ```
1041 """
1042 from footman import _paths
1044 return _make(_paths.footman_data_dir(), mode=0o700)
1047def prog() -> str:
1048 """The command this CLI is invoked as — `fm`, or a branded CLI's own.
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.
1055 ```python
1056 (root / ".github/workflows/ci.yml").write_text(
1057 f" - run: {footman.prog()} check\\n"
1058 )
1059 ```
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
1069 return _paths.prog()
1072def dist() -> str | None:
1073 """The distribution that ships this CLI, or `None` if it never said.
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"`.
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
1085 return _paths.declared_dist()
1088def config_dir() -> Path:
1089 """This CLI's config directory — *your* writing, never footman's.
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
1099 return _paths.footman_config_dir()
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
1107 return _paths.footman_config_file()
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
1115 return _paths.user_tasks_file(_paths.tasks_file_name())
1118def project_root() -> Path | None:
1119 """The top of this invocation's task cascade, or `None` outside a
1120 project.
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
1130def _make(path: Path, mode: int | None = None) -> Path:
1131 """Return *path*, having made sure it exists.
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.
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
1149def cwd() -> Path:
1150 """The current task's working directory, always concrete.
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()
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.
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.
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)
1183def passthrough() -> list[str]:
1184 """Arguments after `--` on the command line, for the running task."""
1185 return list(current().passthrough)
1188def given(name: str) -> bool:
1189 """Whether the caller supplied *name*, as opposed to footman inferring it.
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 ```
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.
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.
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
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
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`).
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.
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()
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
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())
1276def progress(done: int, total: int = 0) -> None:
1277 """Report this task's own progress: *done* of *total* units.
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.
1285 ```python
1286 @task
1287 def migrate():
1288 for i, record in enumerate(records, 1):
1289 apply(record)
1290 progress(i, len(records))
1291 ```
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)
1309def track(iterable: Iterable[_T], total: int | None = None) -> Iterator[_T]:
1310 """Iterate *iterable*, reporting progress as it goes.
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.
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)
1338def inherited() -> Callable[..., Any]:
1339 """The task this one shadows in the cascade — footman's `super()`.
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:
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 ```
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.
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
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 )
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
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
1401 return call_inherited
1404class RunFailed(Exception):
1405 """A `run()` command exited non-zero (and `nofail` was not set).
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."""
1413 def __init__(self, result: Result) -> None:
1414 self.result = result
1415 super().__init__(f"`{result.shown}` exited with code {result.code}")
1418class RunTimeout(RunFailed):
1419 """A `run(timeout=…)` expired and the process tree was killed.
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."""
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 )
1433class CommandNotFound(FileNotFoundError):
1434 """The executable for a `run()` does not exist — nothing was spawned.
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`."""
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 )
1453class Failed(Exception):
1454 """A task chose to fail — the exception `footman.fail()` raises.
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 """
1463 def __init__(self, reason: str = "", *, code: int = 1) -> None:
1464 self.reason = reason
1465 self.code = code
1466 super().__init__(reason)
1469def effective_timeout(own: float | None, ctx: Context) -> float:
1470 """The bound that actually applied to a call, for its error message.
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
1486class TimedOut(Failed):
1487 """A task's `@task(timeout=…)` deadline expired.
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.
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.
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 """
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)
1535def coroutine_refusal(kind: str, fn: Any = None) -> Failed:
1536 """The taught refusal for an `async def` body footman would never run.
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.
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.
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 )
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.
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.
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.
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 )
1608def fail(reason: str = "", *, code: int = 1) -> NoReturn:
1609 """Fail the current task with a *reason* (and exit *code*, default 1).
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.
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)
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))
1638def _was_expected(err: BaseException) -> bool:
1639 """Whether footman saw *err* coming — so no stack belongs with it.
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))
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
1662# --- output routing ----------------------------------------------------------
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."""
1670 counted: dict[str, tuple[int, int]]
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: ...
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
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
1692def seed_cmd_width(width: int) -> None:
1693 global _cmd_width
1694 _cmd_width = max(0, width)
1697def cmd_width() -> int:
1698 return _cmd_width
1701def _observe_cmd(label: str) -> int:
1702 """Return the padding width for *label*, learning as labels stream by.
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
1708 global _cmd_width
1709 _cmd_width = max(_cmd_width, display_width(label))
1710 return _cmd_width
1713def set_status(status: Status | None) -> None:
1714 global _status
1715 _status = status
1718def active_status() -> Status | None:
1719 """The run's live status line, or `None` outside a run."""
1720 return _status
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."""
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
1737 def _sink(self) -> TextIO | None:
1738 ctx = current()
1739 return ctx.err_sink if self._err else ctx.sink
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)
1751 def flush(self) -> None:
1752 (self._sink() or self.real).flush()
1754 def isatty(self) -> bool:
1755 return self.real.isatty()
1757 def __getattr__(self, name: str) -> Any:
1758 return getattr(self.real, name)
1761_router: _Router | None = None
1762_err_router: _Router | None = None
1765def real_stdout() -> TextIO:
1766 """The underlying stdout, bypassing the routing proxy."""
1767 return _router.real if _router is not None else sys.stdout
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
1775def real_stdin() -> TextIO:
1776 """The underlying stdin, bypassing the guard proxy.
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
1786_T = TypeVar("_T")
1787_V = TypeVar("_V")
1789_UNSET: Any = object() # "no default given" — None is a valid default/value
1791_prompt_lock = threading.Lock()
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.
1804_STDIN_UNREAD: Any = object()
1805_stdin_payload: Any = _STDIN_UNREAD
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
1829# --- scripted answers: the table a recording answers run() from ---------------
1832class _Answers:
1833 """A `recording(answers=…)` table, normalised once and shared by reference.
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.
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 """
1852 __slots__ = ("_cursors", "_lock", "_table")
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()
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)
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 )
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), "", ""
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
1943def _inject_answers(table: _Answers | None) -> _Answers | None:
1944 global _injected_answers
1945 previous = _injected_answers
1946 _injected_answers = table
1947 return previous
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)
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
1971def _restore_stdin_payload(previous: Any) -> None:
1972 global _stdin_payload
1973 _stdin_payload = previous
1976def _stdin_is_tty() -> bool:
1977 try:
1978 return sys.stdin is not None and sys.stdin.isatty()
1979 except Exception:
1980 return False
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")
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
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
2035def _guard_interactive(what: str) -> Context:
2036 """Refuse a mid-body interactive call in a non-interactive task.
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
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 )
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: ...
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.
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.
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.
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()`.
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
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
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
2169 from footman._executor import _coerce_extra
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()
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")
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: ...
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).
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 )
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))]
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
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()})
2328@contextlib.contextmanager
2329def routing() -> Generator[tuple[TextIO, TextIO]]:
2330 """Install stdout/stderr routers for the duration of a run.
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
2363# --- run() -------------------------------------------------------------------
2366def _is_code(value: Any) -> bool:
2367 return isinstance(value, int) and not isinstance(value, bool)
2370@dataclass(frozen=True)
2371class Invocation:
2372 """What a `run()` call *is*, apart from how it's spelled to execute.
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 """
2383 parts: tuple[tuple[str, str], ...]
2384 exact: tuple[str, ...]
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
2398 return " ".join(_shell_quote(str(redact(a))) for a in self.exact)
2399 return " ".join(text for _, text in self.parts)
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
2407 return paint_cli(list(self.parts), color)
2410def _shell_quote(text: str) -> str:
2411 """Quote one token so the shown command line pastes back into a real shell.
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)
2424def argv_tokens(cmd: Iterable[Any]) -> list[str]:
2425 """A `run()` argv list as tokens, with a bare container refused.
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
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)
2450def container_error(value: Any, where: str, *, example: str = "") -> str:
2451 """The taught refusal for a bare container in an argv slot.
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 )
2484def _exact(cmd: Any, args: tuple[Any, ...]) -> str:
2485 """The exact executed command line for a direct (non-bridge) `run()`.
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))
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))
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 `***`.
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.
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
2524 safe_title = None if title is None else str(redact(title))
2525 return safe_title or _label(redact(cmd), redact(args)), safe_title
2528def _exit_code(exc: SystemExit) -> int:
2529 """A `SystemExit`'s exit code: its int code, 0 for `None`, else 1 (a message).
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)
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
2547_state_lock = threading.RLock()
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*.
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.
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"`.
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)
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
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.
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).
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.
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()
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"
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}"
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
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
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
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)
2743def abort_reaches(keep_going: bool) -> bool:
2744 """Does the live abort reach work under this failure policy?
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.
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)
2759def _kill_tree(proc: subprocess.Popen[str], *, force: bool) -> None:
2760 """Signal a spawned child *and its descendants*, not just the child itself.
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)
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)
2808def _forget_child(proc: subprocess.Popen[str]) -> None:
2809 with _children_lock:
2810 _live_children.pop(proc, None)
2813def reset_abort() -> None:
2814 """Clear the abort flags at the start of a run."""
2815 _aborting.clear()
2816 _abort_full.clear()
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
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.
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.
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
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)
2862 threading.Thread(target=_escalate, daemon=True, name="fm-fail-fast-kill").start()
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
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
3008def _dim(text: str, color: bool) -> str:
3009 return f"\033[2m{text}\033[0m" if color else text
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?
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
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")
3035def color_env(on: bool) -> dict[str, str]:
3036 """The colour variables to *set* to force colour on (or off) for a child.
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"}
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.
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))
3064@contextlib.contextmanager
3065def color_environment(on: bool) -> Generator[None]:
3066 """Publish the run-wide colour decision into `os.environ` for the run.
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
3088def _name_col(ctx: Context) -> str:
3089 """The step line's task-name column, padded so siblings align.
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
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) + " "
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
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))
3117 if not ctx.tty:
3118 return f"{pad_to('ok' if ok else 'FAIL', 4)} {name}{label} {time_text}\n"
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"
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)
3156def _shell_operator(cmd: str) -> str | None:
3157 """The first bare shell-operator token in *cmd*, or `None`.
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)
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)
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
3187 for hint in hints:
3188 if os.path.isfile(hint):
3189 return hint
3190 return shutil.which(name)
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.
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 )
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}
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}
3270_strict_sh_noted = False
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")
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
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
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
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.
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).
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`.
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.
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.
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.
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.
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).
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.)
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 )
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
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
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 )
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
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 )
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()
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
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()
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
3912# --- parallel() --------------------------------------------------------------
3915def _call_name(call: Callable[..., Any]) -> str:
3916 """The name a fan-out child shows on the status line and its step lines.
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
3927class Pending:
3928 """What a task call hands back *inside* a `with parallel()` block.
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 """
3935 __slots__ = ("_task",)
3937 _task: str
3939 def __init__(self, task: str) -> None:
3940 object.__setattr__(self, "_task", task)
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 )
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
3959 def __str__(self) -> NoReturn:
3960 self._refuse()
3962 def __eq__(self, other: object) -> NoReturn:
3963 self._refuse()
3965 def __repr__(self) -> str: # debuggers and tracebacks stay usable
3966 return f"<pending {self._task}>"
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)
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.
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 """
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
3997 def __enter__(self) -> Fanout:
3998 from footman import _step as _step_mod
4000 self._token = _collecting.set(self._queued)
4001 self._birth_token = _step_mod._born.set(self._births)
4002 return self
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
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))
4024 def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> Literal[False]:
4025 from footman import _step as _step_mod
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)
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
4054 token = _futures._pending_seq.set(seq)
4055 try:
4056 values[index] = task(*args, **kwargs)
4057 finally:
4058 _futures._pending_seq.reset(token)
4060 run.__name__ = _call_name(task)
4061 return run
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
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
4085 return Pending(registry.cli_name(getattr(task, "__name__", "task")))
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]: ...
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.
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()`.
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.
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:
4125 with parallel() as p:
4126 build("web")
4127 p(step(shutil.rmtree)(tmp, ignore_errors=True))
4128 web, cleaned = p.results
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
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)
4142 from footman import registry as _registry
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)
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
4177 parent = current()
4178 dest = parent.sink or real_stdout()
4179 dest_is_real = parent.sink is None
4180 lock = threading.Lock()
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))
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)
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
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
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]