Coverage for src/footman/context.py: 94%
697 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-24 13:38 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-24 13:38 +0000
1"""The run context, the `run()` helper, and `parallel()`.
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 shutil
23import signal
24import subprocess
25import sys
26import threading
27import time
28from collections.abc import Callable, Iterator, Sequence
29from contextvars import ContextVar
30from dataclasses import dataclass, field, replace
31from pathlib import Path
32from typing import Any, NoReturn, TextIO
35class Result(int):
36 """The outcome of one `run()` call — and the value `run()` returns.
38 A `Result` *is* the exit code: it subclasses `int`, so `code = run(...)`,
39 `if run(...)`, and `run(...) == 0` all keep working. It also carries the
40 captured output, split by stream, and the command that produced it — so
41 `run("git rev-parse HEAD").stdout.strip()` reads the hash without the
42 stderr noise glued on. `stdout`/`stderr` are separated for both subprocess
43 and in-process runs; a streamed run (`capture=False`) leaves them empty.
44 """
46 command: str
47 """The command line that ran, normalised for reading — options in
48 separated form, values shell-quoted. What `recording()` asserts against,
49 and what the terminal shows."""
50 stdout: str
51 """Captured standard output; empty when the step streamed instead."""
52 stderr: str
53 """Captured standard error; empty when the step streamed instead."""
54 duration: float
55 """Wall-clock seconds the step took."""
56 raw: str
57 """The exact command line executed, shell-quoted — the bytes footman
58 handed the tool, which may spell an option `--flag=value` where
59 `command` shows `--flag value`. What `--verbose` prints. Equal to
60 `command` when there is nothing to normalise."""
62 def __new__(
63 cls,
64 code: int,
65 *,
66 command: str = "",
67 stdout: str = "",
68 stderr: str = "",
69 duration: float = 0.0,
70 raw: str = "",
71 ) -> Result:
72 self = super().__new__(cls, code)
73 self.command = command
74 self.stdout = stdout
75 self.stderr = stderr
76 self.duration = duration
77 self.raw = raw or command
78 return self
80 @property
81 def code(self) -> int:
82 """The exit code (0 is success) — the same value the `Result` itself is."""
83 return int(self)
85 @property
86 def ok(self) -> bool:
87 """Whether the command succeeded (exit code 0)."""
88 return self == 0
90 @property
91 def output(self) -> str:
92 """`stdout` then `stderr`, concatenated — a convenience for "show me
93 everything". NOT interleaved in real time (each stream is captured
94 whole); when the order *across* the two streams matters, read `stdout`
95 and `stderr` separately."""
96 return self.stdout + self.stderr
99@dataclass
100class Context:
101 """State for one running task: environment, flags, passthrough, output."""
103 env: dict[str, str] = field(default_factory=dict)
104 """Extra environment variables overlaid on every `run()` subprocess."""
105 cwd: Path | None = None
106 """Where `run()` executes: the folder that defined the task; `None`
107 means the process cwd (plain calls outside a footman run)."""
108 dry_run: bool = False
109 """`--dry-run`: `run()` prints and records the command, executes
110 nothing, and reports success."""
111 quiet: bool = False
112 """`--quiet`: suppress step lines and the per-task summary."""
113 verbose: bool = False
114 """`--verbose`: replay captured `run()` output even on success."""
115 no_color: bool = False
116 """`--no-color` / `--color=never` (or `NO_COLOR`): never emit ANSI styling."""
117 force_color: bool = False
118 """`--color=always` (or `FORCE_COLOR`): colour even when output is not a
119 terminal — so `run()` forces the tools it spawns to colour and the shown
120 command line paints, for a pipe into `less -R`. Gated off under capture
121 (`--json`), where ANSI would corrupt the envelope. Never sets the live
122 cursor affordances `tty` governs — those still need a real terminal."""
123 prog: str = "fm"
124 """The invoking CLI's command name — a branded CLI's own `prog`, so
125 tasks (the taskdocs plugin, say) can speak the brand's name."""
126 sequential: bool = False
127 """The *user asked* for one-at-a-time (`-s` or config) — `parallel()`
128 honours it too. Deliberately not set by the scheduler's own
129 single-node routing, which is presentation, not a request to
130 serialise task bodies."""
131 assume_yes: bool = False
132 """`--yes`: every `confirm()` gate auto-answers yes, for CI and scripts."""
133 no_input: bool = False
134 """`--no-input`: never prompt — a required prompt errors instead of
135 asking, so an unattended run fails loudly rather than hanging."""
136 fetch_backend: str = ""
137 """`[fetch] backend` from the config ladder — which engine `fetch()`
138 downloads with. Empty means the default (stdlib urllib)."""
139 shell_default: str = ""
140 """`[shell] default` from the config ladder — what `run(shell=True)` resolves
141 to. Empty means `posix` (a POSIX shell everywhere: bash, then sh)."""
142 jobs: int = 0
143 """The effective parallel width (`-j/--jobs`, config `jobs`, or the
144 cores-minus-one default) — caps `parallel()` pools in task bodies.
145 `0` means unset (plain calls outside a run): no cap."""
146 task: str = ""
147 """Who is running, for the step lines' name column: the scheduler
148 sets the dotted task name, `parallel()` its child's name. Empty
149 outside runs."""
150 fn: Any = None
151 """The running task's own function — what `inherited()` reads to find
152 the task this one shadows. `None` outside a run."""
153 name_width: int = 0
154 """The widest sibling task name, so step-line columns align."""
155 passthrough: list[str] = field(default_factory=list)
156 """Everything after `--` on the command line, verbatim."""
157 tty: bool = False
158 """Output dresses for a terminal (colour, marks). Live in-place
159 rewrites additionally require output to be uncaptured."""
160 sink: TextIO | None = None
161 """Where this task's stdout goes: a capture buffer in buffered
162 (parallel) mode, `None` for the real stdout (live mode)."""
163 err_sink: TextIO | None = None
164 """Where this task's stderr goes. At task level it is the *same* buffer as
165 `sink` (so the atomic parallel flush keeps stdout/stderr in order); a
166 `run()` capturing an in-process callable temporarily points the two at
167 separate buffers to split the step's streams for its `Result`."""
168 interactive: bool = False
169 """`@task(interactive=True)`: the task owns the real terminal — output is
170 not captured and it holds sole stdio, so its body may prompt or run a
171 REPL. Mid-body `prompt()`/`confirm()`/`select()` are allowed only here."""
172 atomic: bool = False
173 """`@task(atomic=True)`: this task's subprocesses opt out of fail-fast's
174 kill — they run to completion so a mid-write can't be truncated."""
175 keep_going: bool = False
176 """This task's resolved (per-subtree) failure policy, tagged onto the
177 subprocesses it spawns so a fail-fast failure elsewhere reaps only the
178 fail-fast trees in a mixed run, sparing a keep-going task's."""
179 in_task: bool = False
180 """True while a task *body* runs (the scheduler sets it around the call),
181 so the interactive primitives tell a guarded mid-body call from the
182 framework's own up-front `ask()` resolution."""
183 steps: list[Result] = field(default_factory=list)
184 """Every `run()` this task made, in order — what `recording()` and
185 the `--json` envelope read."""
188_current: ContextVar[Context | None] = ContextVar("footman_context", default=None)
191def current() -> Context:
192 """The context of the running task (a fresh default one outside a run)."""
193 ctx = _current.get()
194 return ctx if ctx is not None else Context()
197@contextlib.contextmanager
198def use_context(ctx: Context | None = None) -> Iterator[Context]:
199 """Install *ctx* as the current run context for the duration of the block.
201 The public seam for calling tasks from other Python code — tests included:
202 `run()` and `tools.*` inside the block read this context instead of a
203 fresh default. `footman.testing.recording` builds on it.
205 ```python
206 with use_context(Context(env={"CI": "1"})) as ctx:
207 deploy()
208 assert ctx.steps[0].code == 0
209 ```
210 """
211 installed = ctx if ctx is not None else Context()
212 token = _current.set(installed)
213 try:
214 yield installed
215 finally:
216 _current.reset(token)
219def passthrough() -> list[str]:
220 """Arguments after `--` on the command line, for the running task."""
221 return list(current().passthrough)
224def progress(done: int, total: int = 0) -> None:
225 """Report this task's own progress: *done* of *total* units.
227 Some work knows exactly how far along it is — 23 of 150 migrations,
228 bytes of a download — and that is better evidence than any duration
229 history. A reporting task's counts drive the live bar directly
230 (counted beats estimated), so the bar is honest on the very first
231 run, where the estimator would still be guessing.
233 ```python
234 @task
235 def migrate():
236 for i, record in enumerate(records, 1):
237 apply(record)
238 progress(i, len(records))
239 ```
241 A `total` of 0 (or less) clears the report, returning the run to its
242 estimate. Outside a run, or with no live status line, this is a
243 no-op — plain calls and captured runs cost nothing.
244 """
245 status = active_status()
246 if status is None:
247 return
248 ctx = current()
249 name = ctx.task or "task"
250 if total > 0:
251 status.unit_counted(name, max(done, 0), total)
252 else: # a cleared report: back to the estimate
253 with contextlib.suppress(Exception):
254 status.counted.pop(name, None)
257def track(iterable: Any, total: int | None = None) -> Any:
258 """Iterate *iterable*, reporting progress as it goes.
260 The ergonomic form of `progress()`: the total comes from `len()` when
261 the iterable has one, or from *total* when you know it for a
262 generator. Without either, iteration still works — the run simply
263 keeps whatever progress it had.
265 ```python
266 @task
267 def migrate():
268 for record in track(load_records()):
269 apply(record)
270 ```
271 """
272 if total is None: 272 ↛ 277line 272 didn't jump to line 277 because the condition on line 272 was always true
273 try:
274 total = len(iterable)
275 except TypeError:
276 total = 0
277 done = 0
278 try:
279 for item in iterable:
280 yield item
281 done += 1
282 if total: 282 ↛ 279line 282 didn't jump to line 279 because the condition on line 282 was always true
283 progress(done, total)
284 finally:
285 if total: # leaving early (a break, an exception) resets the report 285 ↛ exitline 285 didn't return from function 'track' because the condition on line 285 was always true
286 progress(0, 0)
289def inherited() -> Any:
290 """The task this one shadows in the cascade — footman's `super()`.
292 A nearer `tasks.py` overriding a task by name usually wants to *extend*
293 it, not replace it. Call this inside the overriding task's body to get
294 the task it shadows, then call that like the plain function it is:
296 ```python
297 # svc/api/tasks.py — the root also defines `check`
298 @task
299 def check(fix: bool = False, contracts: bool = True):
300 inherited()(fix=fix) # arguments are forwarded explicitly
301 if contracts:
302 run("./verify-contracts.sh")
303 ```
305 Forwarding is deliberately manual: the two signatures are independent
306 (a leaf usually adds a parameter), so automatic forwarding could only
307 drop arguments silently or fail at run time — where spelling the call
308 out shows you the mismatch as you type it. Being an ordinary call,
309 it also runs to completion
310 before the next statement — and composes with `parallel(inherited(),
311 extra)` when you want otherwise.
313 `fm --where <task>` lists the whole shadow chain; `fm --help <task>`
314 shows the inherited task's options, so you can read the forwarding
315 call straight off it.
316 """
317 from footman import discover
319 fn = current().fn
320 if fn is None: 320 ↛ 321line 320 didn't jump to line 321 because the condition on line 320 was never true
321 raise RuntimeError(
322 "inherited() works inside a running task — footman resolves the "
323 "task being shadowed from the one currently running"
324 )
325 previous = discover.shadowed(fn)
326 if previous is None:
327 name = current().task or getattr(fn, "__name__", "this task")
328 raise RuntimeError(
329 f"{name} does not shadow an inherited task — nothing above it in "
330 f"the cascade defines that name (fm --where {name} lists the chain)"
331 )
333 @functools.wraps(previous)
334 def call_inherited(*args: Any, **kwargs: Any) -> Any:
335 # Point the context at the task being called, so an `inherited()`
336 # inside *it* walks one level further up instead of resolving to
337 # itself — a three-deep cascade would otherwise recurse forever.
338 ctx = current()
339 saved = ctx.fn
340 ctx.fn = previous
341 try:
342 return previous(*args, **kwargs)
343 finally:
344 ctx.fn = saved
346 return call_inherited
349class RunFailed(Exception):
350 """A `run()` command exited non-zero (and `nofail` was not set)."""
352 def __init__(self, result: Result) -> None:
353 self.result = result
354 super().__init__(f"`{result.command}` exited with code {result.code}")
357class Failed(Exception):
358 """A task chose to fail — the exception `footman.fail()` raises.
360 A *deliberate* stop with a reason (and optional exit code): the user-facing
361 sibling of `RunFailed` (which is a *command*'s failure). Carries `.reason`
362 and `.code`; footman renders the reason verbatim — no type prefix — in the
363 failure line and the `--json` `error` field. Exported so a task can
364 `except footman.Failed:`, but `fail()` is the blessed way to raise it.
365 """
367 def __init__(self, reason: str = "", *, code: int = 1) -> None:
368 self.reason = reason
369 self.code = code
370 super().__init__(reason)
373def fail(reason: str = "", *, code: int = 1) -> NoReturn:
374 """Fail the current task with a *reason* (and exit *code*, default 1).
376 The blessed way to stop a task deliberately: `fail("no open PR to act on")`,
377 or `fail("reserved branch", code=3)` to pick the exit code too. A *function*,
378 not a `raise`, on purpose — a task lives in your repo under your linter, and
379 `raise SomeError("a literal")` trips flake8-errmsg (EM101) and tryceratops
380 (TRY003) at the call site, every failure. A call trips neither, the same
381 reason `sys.exit()` and `pytest.fail()` are functions. `return N` still
382 spells a bare code; `sys.exit(...)` still works — `fail()` is just the
383 footman-native one, with a reason and a code together.
384 """
385 raise Failed(reason, code=code)
388def _is_deliberate_stop(err: BaseException) -> bool:
389 """Whether *err* is a chosen stop with a message (`fail()`/`sys.exit("…")`)
390 rather than a crash — so its reason renders verbatim, no type prefix."""
391 return isinstance(err, (SystemExit, Failed))
394def context_param_name(sig: inspect.Signature) -> str | None:
395 """Name of the task's context parameter (first param `ctx` / `Context`)."""
396 params = list(sig.parameters.values())
397 if not params:
398 return None
399 first = params[0]
400 if first.name == "ctx" or first.annotation is Context:
401 return first.name
402 return None
405# --- output routing ----------------------------------------------------------
408# The run's live status line (duck-typed — a `_progress.StatusLine`),
409# registered by the scheduler for the duration of a run. context stays
410# ignorant of _progress on purpose; outside a run there is none.
411_status: Any = None
413# The widest command label seen, for aligning the step lines' time column.
414# Seeded from the previous run's history (so alignment is right from the
415# first line on a warm run) and grown as a running max on a cold one.
416_cmd_width: int = 0
419def seed_cmd_width(width: int) -> None:
420 global _cmd_width
421 _cmd_width = max(0, width)
424def cmd_width() -> int:
425 return _cmd_width
428def _observe_cmd(label: str) -> int:
429 """Return the padding width for *label*, learning as labels stream by."""
430 global _cmd_width
431 if len(label) > _cmd_width:
432 _cmd_width = len(label)
433 return _cmd_width
436def set_status(status: Any) -> None:
437 global _status
438 _status = status
441def active_status() -> Any:
442 return _status
445class _Router:
446 """A `sys.stdout`/`sys.stderr` proxy that sends each write to the current
447 task's sink — `err_sink` for the stderr router, `sink` for stdout. At task
448 level the two point at one buffer (combined, order-preserving); a `run()`
449 capturing an in-process callable splits them to record the step's streams."""
451 def __init__(self, real: TextIO, *, err: bool = False) -> None:
452 self.real = real
453 self._err = err
454 try:
455 self._tty = real.isatty()
456 except Exception:
457 self._tty = False
459 def _sink(self) -> TextIO | None:
460 ctx = current()
461 return ctx.err_sink if self._err else ctx.sink
463 def write(self, s: str) -> int:
464 sink = self._sink()
465 if sink is not None:
466 return sink.write(s)
467 # A real-terminal write: the live status line (if any) must clear
468 # itself first and learn whether the cursor now sits at column 0.
469 if self._tty and _status is not None: 469 ↛ 470line 469 didn't jump to line 470 because the condition on line 469 was never true
470 _status.notify(s)
471 return self.real.write(s)
473 def flush(self) -> None:
474 (self._sink() or self.real).flush()
476 def isatty(self) -> bool:
477 return self.real.isatty()
479 def __getattr__(self, name: str) -> Any:
480 return getattr(self.real, name)
483_router: _Router | None = None
484_err_router: _Router | None = None
487def real_stdout() -> TextIO:
488 """The underlying stdout, bypassing the routing proxy."""
489 return _router.real if _router is not None else sys.stdout
492def real_stderr() -> TextIO:
493 """The underlying stderr, bypassing the routing proxy."""
494 return _err_router.real if _err_router is not None else sys.stderr
497_UNSET: Any = object() # "no default given" — None is a valid default/value
499_prompt_lock = threading.Lock()
502def _stdin_is_tty() -> bool:
503 try:
504 return sys.stdin is not None and sys.stdin.isatty()
505 except Exception:
506 return False
509def _scrub(text: str) -> str:
510 """Drop control characters (ESC included) from text echoed to the terminal,
511 so an untrusted `select()` label or message can't inject ANSI escapes — the
512 terminal-injection class that has bitten other CLIs."""
513 return "".join(c for c in text if c.isprintable() or c == "\t")
516def _prompt_core(
517 message: str = "", *, default: str | None = None, secret: bool = False
518) -> str:
519 """The prompt mechanics, unguarded. Writes to the real terminal on stderr
520 (never captured, never in `--json` stdout), serialises on `_prompt_lock`,
521 clears the live status line, and degrades off a tty (returns `default`, or
522 raises). The framework's `ask()` resolution calls this directly; user code
523 goes through the guarded `prompt()`."""
524 if not _stdin_is_tty():
525 if default is not None:
526 return default
527 raise RuntimeError(
528 "no terminal is attached, so there is no one to ask. Pass a "
529 "default for unattended runs, or take the value as a task "
530 "parameter (a CLI flag) instead."
531 )
532 err = real_stderr()
533 status = active_status()
534 with _prompt_lock:
535 if status is not None: 535 ↛ 536line 535 didn't jump to line 536 because the condition on line 535 was never true
536 status.notify(message or " ") # clear the live status line
537 if secret: 537 ↛ 538line 537 didn't jump to line 538 because the condition on line 537 was never true
538 import getpass
540 value = getpass.getpass(message, stream=err).rstrip("\n")
541 else:
542 err.write(message)
543 err.flush()
544 value = sys.stdin.readline().rstrip("\n")
545 if status is not None: 545 ↛ 546line 545 didn't jump to line 546 because the condition on line 545 was never true
546 status.notify("\n") # Enter returned the cursor to column 0
547 return default if value == "" and default is not None else value
550def _guard_interactive(what: str) -> Context:
551 """Refuse a mid-body interactive call in a non-interactive task.
553 Inside an ordinary (captured, possibly parallel) task body the prompt
554 would be swallowed by the capture buffer or race a sibling for the
555 terminal, so it is a loud, taught error rather than a silent hang. The
556 framework's own up-front `ask()` resolution runs with `in_task` unset and
557 is never caught here. Returns the active context (for `--no-input`/`--yes`)."""
558 ctx = current()
559 if ctx.in_task and not ctx.interactive:
560 raise RuntimeError(
561 f"{what} was called inside task {ctx.task or '?'!r}, which is not "
562 f"interactive. Either mark it `@task(interactive=True)` so it owns "
563 f"the terminal, or declare the value as a parameter with `ask()` so "
564 f"footman asks before the task runs."
565 )
566 return ctx
569def prompt(
570 message: str = "", *, default: str | None = None, secret: bool = False
571) -> str:
572 """Ask the person running the task for a line of input.
574 A bare `input()` doesn't work in a task: its prompt goes to stdout, which
575 footman buffers per task so parallel output never interleaves (and `--json`
576 stays one envelope), so the prompt is swallowed and the task looks hung.
577 `prompt()` writes to the real terminal on stderr instead — never captured —
578 and serialises concurrent prompts.
580 Usable only inside an `@task(interactive=True)` task; called in an ordinary
581 task body it raises a taught error naming the two fixes. Off a terminal,
582 under `--no-input`, or when it would otherwise block, it returns `default`
583 if given, else raises — an unattended run fails loudly. For a value a
584 script must supply, take it as a task parameter (a CLI flag) instead.
585 """
586 ctx = _guard_interactive("prompt()")
587 if ctx.no_input:
588 if default is not None:
589 return default
590 raise RuntimeError(
591 "prompt(): --no-input is set, so nothing can be asked. Pass a "
592 "default, or supply the value as a task parameter (a CLI flag)."
593 )
594 return _prompt_core(message, default=default, secret=secret)
597def confirm(message: str, *, default: bool = False) -> bool:
598 """Ask a yes/no question. `--yes` auto-answers yes; Enter alone takes
599 `default`; off a terminal or under `--no-input` the answer is `default`.
600 Guarded like `prompt()` — interactive tasks only."""
601 ctx = _guard_interactive("confirm()")
602 if ctx.assume_yes:
603 return True
604 if ctx.no_input:
605 return default
606 reply = _prompt_core(
607 f"{message} {'[Y/n]' if default else '[y/N]'} ",
608 default="y" if default else "n",
609 )
610 return reply.strip().lower() in ("y", "yes")
613def select(
614 message: str,
615 options: Sequence[Any],
616 *,
617 multiple: bool = False,
618 default: Any = _UNSET,
619) -> Any:
620 """Let the person pick from a runtime-computed list — the one interactive
621 case a flag can't cover, because the options aren't known until the task
622 runs (which changed packages to release, which stale branches to delete).
624 `options` are strings, or `(label, value)` pairs to show one thing and
625 return another. `multiple=True` returns the chosen subset as a list;
626 otherwise one value is returned. Guarded like `prompt()` (interactive tasks
627 only), and off a terminal or under `--no-input` it returns `default`, or
628 raises if none was given.
629 """
630 ctx = _guard_interactive("select()")
631 opts = list(options)
632 if not opts: 632 ↛ 633line 632 didn't jump to line 633 because the condition on line 632 was never true
633 raise ValueError("select(): no options to choose from")
634 labels = [o[0] if isinstance(o, tuple) and len(o) == 2 else str(o) for o in opts]
635 values = [o[1] if isinstance(o, tuple) and len(o) == 2 else o for o in opts]
636 if ctx.no_input or not _stdin_is_tty():
637 if default is not _UNSET:
638 return default
639 raise RuntimeError(
640 "select(): nothing can be asked (no terminal, or --no-input). Pass "
641 "default=…, or take the choice as a task parameter."
642 )
643 err = real_stderr()
644 status = active_status()
645 with _prompt_lock:
646 if status is not None: 646 ↛ 647line 646 didn't jump to line 647 because the condition on line 646 was never true
647 status.notify(" ")
648 err.write(_scrub(message.rstrip()) + "\n")
649 for i, label in enumerate(labels, 1):
650 err.write(f" {i}) {_scrub(label)}\n")
651 hint = "numbers, comma-separated; 'all'; 'none'" if multiple else "a number"
652 err.write(f"select ({hint}): ")
653 err.flush()
654 line = sys.stdin.readline().strip()
655 if status is not None: 655 ↛ 656line 655 didn't jump to line 656 because the condition on line 655 was never true
656 status.notify("\n")
657 if line == "" and default is not _UNSET: 657 ↛ 658line 657 didn't jump to line 658 because the condition on line 657 was never true
658 return default
659 if multiple:
660 return [values[i] for i in _parse_multi(line, len(values))]
661 return values[_parse_one(line, len(values))]
664def _parse_one(line: str, n: int) -> int:
665 try:
666 i = int(line)
667 except ValueError:
668 raise RuntimeError(f"select(): {line!r} is not a number 1-{n}.") from None
669 if not 1 <= i <= n:
670 raise RuntimeError(f"select(): {i} is out of range 1-{n}.")
671 return i - 1
674def _parse_multi(line: str, n: int) -> list[int]:
675 low = line.lower()
676 if low in ("all", "*"):
677 return list(range(n))
678 if low == "none":
679 return []
680 return sorted({_parse_one(tok, n) for tok in line.replace(",", " ").split()})
683@contextlib.contextmanager
684def routing():
685 """Install stdout/stderr routers for the duration of a run.
687 Both streams proxy through the running task's sink, so an in-process tool's
688 stderr is captured alongside its stdout (matching the merged subprocess
689 capture) instead of leaking to the terminal. The routers are *stacked*, not
690 reset to None: a nested run — e.g. `tools.pytest(in_process=True)` driving
691 the shipped `fm` fixture — restores the outer routers on exit, so the outer
692 run's capture keeps working afterwards.
693 """
694 global _router, _err_router
695 prev_out, prev_err = _router, _err_router
696 real_out, real_err = sys.stdout, sys.stderr
697 # A tool (or footman's own status line) may emit non-ASCII on a
698 # locale-encoded pipe (cp1252 on Windows CI, errors='strict' by default);
699 # degrade unencodable glyphs to '?' instead of crashing the run.
700 for stream in (real_out, real_err):
701 with contextlib.suppress(Exception):
702 if hasattr(stream, "reconfigure"):
703 stream.reconfigure(errors="replace") # type: ignore[union-attr]
704 _router, _err_router = _Router(real_out), _Router(real_err, err=True)
705 sys.stdout, sys.stderr = _router, _err_router # type: ignore[assignment]
706 try:
707 # (real stdout, real stderr): task blocks land on the first, the live
708 # status line on the second — stdout is the answer, stderr commentary.
709 yield real_out, real_err
710 finally:
711 sys.stdout, sys.stderr = real_out, real_err
712 _router, _err_router = prev_out, prev_err
715# --- run() -------------------------------------------------------------------
718def _is_code(value: Any) -> bool:
719 return isinstance(value, int) and not isinstance(value, bool)
722@dataclass(frozen=True)
723class Invocation:
724 """What a `run()` call *is*, apart from how it's spelled to execute.
726 The `tools.*` bridge builds one of these so `run()` can show a readable,
727 syntax-highlighted command line — options in separated form, tagged by
728 role — while executing whatever the tool actually needs (attached flags,
729 or an in-process callable). `parts` is the normalised, human form;
730 `exact` is the literal argv, shown under `--verbose` and always
731 copy-pasteable. Passing it is how the two are kept from drifting: both
732 come from one translation of one call.
733 """
735 parts: tuple[tuple[str, str], ...]
736 exact: tuple[str, ...]
738 def text(self, *, exact: bool) -> str:
739 """The plain command line — the width-measured, non-colour form."""
740 if exact:
741 return " ".join(_shell_quote(a) for a in self.exact)
742 return " ".join(text for _, text in self.parts)
744 def painted(self, *, color: bool, exact: bool) -> str:
745 """The shown command line, role-coloured when the stream wants it."""
746 if exact or not color:
747 return self.text(exact=exact)
748 from footman._describe import paint_cli
750 return paint_cli(list(self.parts), color)
753def _shell_quote(text: str) -> str:
754 """Quote one token so the shown command line pastes back into a real shell.
756 Per-platform, so a Windows `.raw`/`--verbose` line actually round-trips:
757 POSIX uses `shlex.quote`; Windows uses stdlib `subprocess.list2cmdline` (the
758 exact inverse of the parsing `CreateProcess` does), never `shlex` — which
759 emits POSIX single-quotes that cmd/PowerShell can't read. `list2cmdline`
760 handles spaces/quotes/backslashes, not cmd metacharacters (`& | ^`), which
761 is fine for a display line that already ran."""
762 if sys.platform == "win32":
763 return subprocess.list2cmdline([text])
764 return shlex.quote(text)
767def _exact(cmd: Any, args: tuple[Any, ...]) -> str:
768 """The exact executed command line for a direct (non-bridge) `run()`.
770 A string is already a command line; a list is shell-quoted so it pastes;
771 a callable has no command line, so its label stands in.
772 """
773 if callable(cmd):
774 return _label(cmd, args)
775 if isinstance(cmd, str):
776 return cmd
777 return " ".join(_shell_quote(str(a)) for a in cmd)
780def _label(cmd: Any, args: tuple[Any, ...]) -> str:
781 if callable(cmd):
782 name = getattr(cmd, "__qualname__", getattr(cmd, "__name__", repr(cmd)))
783 return " ".join([f"{name}()", *map(str, args)]).strip()
784 return cmd if isinstance(cmd, str) else " ".join(map(str, cmd))
787def _exit_code(exc: SystemExit) -> int:
788 """A `SystemExit`'s exit code: its int code, 0 for `None`, else 1 (a message).
790 `sys.exit()` / `raise SystemExit(...)` is a common "fail this step" idiom, and
791 `SystemExit` is a `BaseException` — so every place that treats a call's
792 outcome as a code must normalise it the same way, or it escapes uncaught."""
793 return exc.code if isinstance(exc.code, int) else (0 if exc.code is None else 1)
796def _call_for_code(cmd: Callable[..., Any], args: tuple[Any, ...]) -> int:
797 try:
798 returned = cmd(*args)
799 except SystemExit as exc:
800 return _exit_code(exc)
801 if isinstance(returned, int) and not isinstance(returned, bool):
802 return returned
803 return 0
806_state_lock = threading.RLock()
809@contextlib.contextmanager
810def _process_state(env: dict[str, str], cwd: Path | None) -> Iterator[None]:
811 """Patch `os.environ` / the process cwd around an in-process callable.
813 In-process tools must honor the same env overlay and run-from-defining-
814 folder contract the subprocess branch of the *same* call already obeys.
815 `os.chdir` and `os.environ` are process-global, so any change is guarded by a
816 re-entrant lock (a callable may itself call `run()`) and restored on exit —
817 calls that need a patch therefore serialize. The common case (no overlay, no
818 cwd — in-memory Group tasks have no defining dir) takes the lock-free fast
819 path, so barrier-overlap parallelism stays fully concurrent.
820 """
821 if not env and cwd is None:
822 yield
823 return
824 with _state_lock:
825 saved_env = os.environ.copy()
826 saved_cwd = os.getcwd() if cwd is not None else None
827 try:
828 os.environ.update(env)
829 if cwd is not None:
830 os.chdir(cwd)
831 yield
832 finally:
833 if saved_cwd is not None:
834 os.chdir(saved_cwd)
835 os.environ.clear()
836 os.environ.update(saved_env)
839def _run_callable(
840 cmd: Callable[..., Any],
841 args: tuple[Any, ...],
842 *,
843 capture: bool = True,
844 env: dict[str, str] | None = None,
845 cwd: str | Path | None = None,
846) -> tuple[int, str, str]:
847 """Run a callable — parallel-safe under the router, honoring env/cwd.
849 With the router installed, every write this thread makes already
850 dispatches through `current().sink`/`err_sink`, so capture is a
851 thread-confined swap of the two to fresh buffers — concurrent in-process
852 tools never touch each other's output, and the callable's stdout and stderr
853 land in separate buffers for the step's `Result`. Outside a routed run
854 (bare calls in scripts/tests) there is no router to lean on, so fall back to
855 the classic global redirect (still split, into two buffers).
857 `capture=False` skips the buffers entirely (live output, returns `('', '')`
858 like the subprocess branch) — for serve-style tasks that must not buffer
859 unboundedly. The env overlay and cwd are applied process-globally via
860 `_process_state`; the `capture=False` short-circuit runs *inside* it so
861 uncaptured callables keep cwd/env too.
862 """
863 ctx = current()
864 # Colour is decided once for the whole run and published into os.environ at
865 # the run boundary (`color_environment`), so an in-process tool reads it
866 # straight from the environment — no per-call patch here, so the lock-free
867 # fast path in `_process_state` is kept in every colour mode.
868 overlay = {**ctx.env, **(env or {})}
869 target_cwd = Path(cwd) if cwd is not None else ctx.cwd
870 with _process_state(overlay, target_cwd):
871 if not capture:
872 return _call_for_code(cmd, args), "", ""
873 out_buf, err_buf = io.StringIO(), io.StringIO()
874 if _router is not None:
875 saved_out, saved_err = ctx.sink, ctx.err_sink
876 ctx.sink, ctx.err_sink = out_buf, err_buf
877 try:
878 code = _call_for_code(cmd, args)
879 finally:
880 ctx.sink, ctx.err_sink = saved_out, saved_err
881 return code, out_buf.getvalue(), err_buf.getvalue()
882 with (
883 contextlib.redirect_stdout(out_buf),
884 contextlib.redirect_stderr(err_buf),
885 ):
886 code = _call_for_code(cmd, args)
887 return code, out_buf.getvalue(), err_buf.getvalue()
890# Live subprocesses footman has spawned, so fail-fast can terminate the ones
891# still running when a sibling fails. A run in-process (a `tools` entry point,
892# a callable) registers nothing — there is no child to kill, and it finishes.
893# Each child records its task's keep-going policy, so a fail-fast failure can
894# reap the fail-fast trees in a mixed run while a keep-going tree runs on.
895_live_children: dict[subprocess.Popen[str], bool] = {} # proc -> keep_going
896_children_lock = threading.Lock()
897_aborting = threading.Event() # set once *any* termination (fail-fast/Ctrl-C) fired
898_abort_full = threading.Event() # set when the abort spares nothing (Ctrl-C, error)
901def _kill_tree(proc: subprocess.Popen[str], *, force: bool) -> None:
902 """Signal a spawned child *and its descendants*, not just the child itself.
904 A killable child leads its own process group (POSIX) / group (Windows), and
905 its grandchildren inherit it — so a group-wide signal reaps the workers a
906 bare `terminate()` would orphan: pytest-xdist, `make -j`, a shell script's
907 background jobs. POSIX sends SIGTERM, or SIGKILL once *force*. Windows has no
908 SIGTERM/SIGKILL split, so `taskkill /T` (walk the tree by PID) `/F` (force)
909 is one forceful shot for both — the escalation below is then a harmless
910 re-run against a tree that is already gone.
911 """
912 with contextlib.suppress(ProcessLookupError, OSError):
913 if sys.platform == "win32":
914 subprocess.run(
915 ["taskkill", "/F", "/T", "/PID", str(proc.pid)],
916 stdout=subprocess.DEVNULL,
917 stderr=subprocess.DEVNULL,
918 check=False,
919 )
920 return
921 sig = signal.SIGKILL if force else signal.SIGTERM
922 # Kill the whole group only when this child actually *leads* one (spawned
923 # with start_new_session, so pgid == pid) — that is how the group reaches
924 # its grandchildren. A child that shares footman's group — an interactive
925 # task's child, or one a caller spawned without isolation — is signalled
926 # alone: never killpg a group we don't own, or fail-fast could take out
927 # the runner itself.
928 if os.getpgid(proc.pid) == proc.pid:
929 os.killpg(proc.pid, sig)
930 else:
931 os.kill(proc.pid, sig)
934def _register_child(proc: subprocess.Popen[str], keep_going: bool = False) -> None:
935 # Under the one lock: recording the child and reading the abort flags can't
936 # interleave with `terminate_live_children` setting them and snapshotting — so
937 # a child is killed either by that sweep or by this check, never missed.
938 with _children_lock:
939 _live_children[proc] = keep_going
940 aborting = _aborting.is_set()
941 full = _abort_full.is_set()
942 # A child spawned after an abort fired self-terminates, so the doomed run
943 # can't outrun the kill — but a keep-going child spawned after a *fail-fast*
944 # abort (not a full Ctrl-C) is spared, matching the per-subtree policy.
945 if aborting and (full or not keep_going):
946 _kill_tree(proc, force=False)
949def _forget_child(proc: subprocess.Popen[str]) -> None:
950 with _children_lock:
951 _live_children.pop(proc, None)
954def reset_abort() -> None:
955 """Clear the abort flags at the start of a run."""
956 _aborting.clear()
957 _abort_full.clear()
960def terminate_live_children(grace: float = 2.0, *, failfast_only: bool = False) -> None:
961 """Terminate still-running spawned subprocess *trees* — fail-fast's teeth.
963 With *failfast_only* (a per-node fail-fast failure) only fail-fast children
964 are reaped, so a keep-going task in a mixed run keeps running; the default
965 (a full abort — Ctrl-C, an internal error) reaps everything.
967 Each killable child was spawned in its own process group, so the SIGTERM
968 (POSIX) / `taskkill /T` (Windows) here reaches its grandchildren too; the
969 `communicate()` blocking each task's thread then returns and the task
970 unwinds. The abort is *latched*, so a subprocess a still-running task spawns
971 *after* this fires self-terminates on registration (the doomed run can't
972 outrun the kill; `_register_child` applies the same failfast_only sparing). A
973 group that ignores SIGTERM is SIGKILLed after *grace* seconds by a daemon
974 watcher — a hung tool can't wedge the run. In-process runs register nothing,
975 so they finish on their own — un-killable for free, which is the intended
976 behaviour. This is also the Ctrl-C reaper: a group-isolated child no longer
977 receives the terminal's SIGINT, so the abort paths call this by hand.
978 """
979 with _children_lock:
980 _aborting.set()
981 if not failfast_only:
982 _abort_full.set()
983 procs = [p for p, kg in _live_children.items() if not (failfast_only and kg)]
984 for proc in procs:
985 _kill_tree(proc, force=False)
986 if not procs:
987 return
989 def _escalate() -> None:
990 time.sleep(grace)
991 for proc in procs:
992 if proc.poll() is None: # still alive → it ignored SIGTERM, force it
993 _kill_tree(proc, force=True)
995 threading.Thread(target=_escalate, daemon=True, name="fm-fail-fast-kill").start()
998def _run_subprocess(
999 argv: list[str] | str,
1000 env: dict[str, str],
1001 cwd: Path | None,
1002 capture: bool,
1003 encoding: str | None = "utf-8",
1004 killable: bool = True,
1005 isolate: bool = True,
1006 keep_going: bool = False,
1007) -> tuple[int, str, str]:
1008 # Dev tools (pytest, ruff, git, uv) emit UTF-8 regardless of the OS code
1009 # page, so decode as UTF-8 by default rather than the locale encoding
1010 # (cp1252 on Windows would mojibake the capture). `encoding=None` restores
1011 # locale behavior. `errors="replace"` is the never-crash net either way.
1012 #
1013 # Popen + a live registry (not subprocess.run) so a concurrent fail-fast can
1014 # terminate this child while its thread is blocked in communicate().
1015 #
1016 # An isolated child leads its own process group (POSIX session / Windows
1017 # group) so `terminate_live_children` can kill the whole tree, not just the
1018 # child — a tool's own workers (pytest-xdist, `make -j`) die with it. The
1019 # cost: it no longer receives the terminal's Ctrl-C, so the scheduler's abort
1020 # paths reap it by hand. Two children opt out and stay in footman's group:
1021 # `atomic` (fail-fast never kills it, so in-group keeps its Ctrl-C behaviour
1022 # unchanged) and `interactive` (it owns the real terminal — setsid would strip
1023 # its controlling tty and a full-screen program would misbehave). The kill
1024 # guard signals such an in-group child alone, never the shared group.
1025 group: dict[str, Any] = {}
1026 if isolate:
1027 if sys.platform == "win32":
1028 group["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
1029 else:
1030 group["start_new_session"] = True
1031 proc = subprocess.Popen(
1032 argv,
1033 env=env,
1034 cwd=cwd,
1035 stdout=subprocess.PIPE if capture else None,
1036 stderr=subprocess.PIPE if capture else None,
1037 text=True,
1038 encoding=encoding,
1039 errors="replace",
1040 **group,
1041 )
1042 # An `@task(atomic=True)` opts its child out of the registry: fail-fast
1043 # never kills it, so a mid-write (a formatter rewriting a file) can't be
1044 # truncated. It runs to completion; the run waits for it.
1045 if killable:
1046 _register_child(proc, keep_going)
1047 try:
1048 out, err = proc.communicate()
1049 finally:
1050 if killable:
1051 _forget_child(proc)
1052 if not capture: 1052 ↛ 1053line 1052 didn't jump to line 1053 because the condition on line 1052 was never true
1053 return proc.returncode, "", ""
1054 return proc.returncode, out or "", err or ""
1057def _dim(text: str, color: bool) -> str:
1058 return f"\033[2m{text}\033[0m" if color else text
1061def _colored(ctx: Context) -> bool:
1062 """The one colour predicate: does footman dress this run's output — its own
1063 chrome and the tools it spawns — for colour?
1065 `never` (`no_color`) always wins; `always` (`force_color`) forces colour on
1066 even off a terminal (a pipe into `less -R`); otherwise `auto` follows the
1067 run's tty-ness (`NO_COLOR` in the environment still bows out)."""
1068 if ctx.no_color:
1069 return False
1070 if ctx.force_color:
1071 return True
1072 return ctx.tty and "NO_COLOR" not in os.environ
1075def color_on() -> bool:
1076 """Whether the current run emits colour — the tools bridge asks this to
1077 decide whether to force a tool's own `--color` flag (for the few that
1078 ignore the environment). A plain call outside a run reads a default
1079 (monochrome) context, so a bare `tools.git.diff()` in a script adds
1080 nothing."""
1081 return _colored(current())
1084# Every colour variable footman speaks. `color_environment` clears the whole set
1085# before setting a direction, so forcing colour *off* is the *absence* of
1086# FORCE_COLOR, not `FORCE_COLOR=0` — some tools (ruff) read the mere presence of
1087# FORCE_COLOR as "force on", ignoring `NO_COLOR`, so `"0"` would fail to silence
1088# them. footman emits by presence/absence; it consumes `NO_COLOR` by presence and
1089# `FORCE_COLOR` by truthiness (`_resolve_color`).
1090_COLOR_VARS = ("FORCE_COLOR", "CLICOLOR_FORCE", "CLICOLOR", "NO_COLOR")
1093def color_env(on: bool) -> dict[str, str]:
1094 """The colour variables to *set* to force colour on (or off) for a child.
1096 Without a PTY a child's stdout is a pipe, so `isatty()` is false and a
1097 well-behaved tool auto-disables colour — footman captures the bytes and
1098 replays them onto its own terminal, so it wants the colour back. `on` sets
1099 `FORCE_COLOR`/`CLICOLOR_FORCE`; off sets only `NO_COLOR`. Forcing off is
1100 completed by *removing* any inherited `FORCE_COLOR` (see `_COLOR_VARS` /
1101 `color_environment`), never by setting it to `"0"`."""
1102 if on:
1103 return {"FORCE_COLOR": "1", "CLICOLOR_FORCE": "1", "CLICOLOR": "1"}
1104 return {"NO_COLOR": "1"}
1107def run_colour_on(
1108 *, no_color: bool, force_color: bool, capture: bool, isatty: bool
1109) -> bool:
1110 """The run-wide colour decision, computed once from its run-wide inputs.
1112 Colour cannot change during a run, so this is `_colored` for every task at
1113 once — letting the environment be set once at the run boundary rather than
1114 per call. Capture (a `--json` run) is byte-clean and gates `force_color` off,
1115 exactly as the scheduler does per task; the rest defers to `_colored`."""
1116 if capture:
1117 return False
1118 tty = isatty and os.environ.get("TERM") != "dumb"
1119 return _colored(Context(no_color=no_color, force_color=force_color, tty=tty))
1122@contextlib.contextmanager
1123def color_environment(on: bool) -> Iterator[None]:
1124 """Publish the run-wide colour decision into `os.environ` for the run.
1126 One decision, set once: a subprocess inherits it, an in-process tool reads
1127 it — so no `run()` call has to patch the environment itself (and none takes
1128 the `_process_state` lock for colour). Every colour variable is cleared
1129 first, then this direction's are set — so *off* leaves no `FORCE_COLOR` for a
1130 presence-checking tool to honour, and *on* leaves no stray `NO_COLOR`.
1131 Restored on exit; nested runs stack, each restoring the value it found."""
1132 saved = {key: os.environ.get(key) for key in _COLOR_VARS}
1133 for key in _COLOR_VARS:
1134 os.environ.pop(key, None)
1135 os.environ.update(color_env(on))
1136 try:
1137 yield
1138 finally:
1139 for key, previous in saved.items():
1140 if previous is None:
1141 os.environ.pop(key, None)
1142 else:
1143 os.environ[key] = previous
1146def _name_col(ctx: Context) -> str:
1147 """The step line's task-name column, padded so siblings align.
1149 Bold on colour terminals; empty (no column at all) outside a run, so a
1150 plain `run()` call keeps its old shape.
1151 """
1152 if not ctx.task:
1153 return ""
1154 padded = f"{ctx.task:<{max(ctx.name_width, len(ctx.task))}}"
1155 return (f"\033[1m{padded}\033[0m" if _colored(ctx) else padded) + " "
1158def _step_line(ctx: Context, ok: bool, label: str, duration: float) -> str:
1159 """One completed step: mark · name · dimmed command · aligned time."""
1160 from footman._progress import fmt_secs
1162 color = _colored(ctx)
1163 time_text = f"({fmt_secs(duration)})"
1164 name = _name_col(ctx)
1165 # Times align to the widest command — remembered from the previous run
1166 # of this chain (a warm run aligns from its first line), learned as a
1167 # running max on a cold one. Never the terminal edge: that reads absurd
1168 # on wide terminals.
1169 label = f"{label:<{_observe_cmd(label)}}"
1171 if not ctx.tty:
1172 return f"{'ok' if ok else 'FAIL':<4} {name}{label} {time_text}\n"
1174 mark = (
1175 ("\033[32m✓\033[0m" if ok else "\033[31m✗\033[0m")
1176 if color
1177 else ("ok" if ok else "FAIL")
1178 )
1179 shown = f"\033[36m{time_text}\033[0m" if color else time_text
1180 # The time sits right after the command — a right-aligned column reads
1181 # absurd on wide terminals, with the time a screen away from its line.
1182 return f"{mark} {name}{_dim(label, color)} {shown}\n"
1185# The tokens that mean "shell" and nothing else. `run(str)` splits and execs
1186# directly — no shell — so any of these would ride along as a *literal* argument,
1187# silently breaking a pipeline or redirect (a `tar … | ssh …` that never pipes).
1188_SHELL_OPERATORS = frozenset(
1189 {
1190 "|",
1191 "||",
1192 "|&",
1193 "&",
1194 "&&",
1195 ";",
1196 ";;",
1197 ">",
1198 ">>",
1199 "<",
1200 "<<",
1201 "<<<",
1202 "<>",
1203 "2>",
1204 "2>>",
1205 "&>",
1206 }
1207)
1210def _shell_operator(cmd: str) -> str | None:
1211 """The first bare shell-operator token in *cmd*, or `None`.
1213 Only an operator standing as *its own token* counts — the spaced form a
1214 shell would honour (`… | …`, `… > out`). A glued `a>b` stays one token and
1215 is left alone, as is any operator inside quotes. Split failures (unbalanced
1216 quotes) defer to the exec path, which surfaces them.
1217 """
1218 try:
1219 # posix=False on Windows: keep backslash paths intact (they'd otherwise
1220 # be eaten), so a real path token never looks like an operator.
1221 tokens = shlex.split(cmd, posix=(os.name != "nt"))
1222 except ValueError:
1223 return None
1224 return next((t for t in tokens if t in _SHELL_OPERATORS), None)
1227# A modern bash where PATH alone might miss it (a GUI/cron launch, or Windows,
1228# where git's bash is not on PATH). Checked before `shutil.which("bash")`.
1229_BASH_HINTS = (
1230 "/opt/homebrew/bin/bash", # macOS Apple Silicon Homebrew
1231 "/usr/local/bin/bash", # macOS Intel Homebrew
1232 r"C:\Program Files\Git\bin\bash.exe", # Windows git bash
1233 r"C:\Program Files\Git\usr\bin\bash.exe",
1234)
1237def _find_exe(name: str, hints: tuple[str, ...] = ()) -> str | None:
1238 """A concrete path for *name*: a known *hints* location first, then PATH."""
1239 for hint in hints:
1240 if os.path.isfile(hint):
1241 return hint
1242 return shutil.which(name)
1245def _resolve_shell(kind: bool | str, policy: str = "posix") -> list[str]:
1246 """The interpreter argv prefix — `[executable, run-a-string-flag]` — for a
1247 `run(shell=…)` request.
1249 `True` follows *policy* (POSIX-everywhere by default: bash, then plain sh,
1250 with git bash on Windows). A string is a concrete shell (`bash`/`zsh`/`sh`/
1251 `fish`/`nu`/`pwsh`/`cmd`) or a strategy (`posix`/`native`). Raises a taught
1252 `ValueError` when the shell can't be found or does not fit the platform —
1253 never a silent wrong-shell.
1254 """
1255 strat = policy if kind is True else str(kind)
1256 win = sys.platform == "win32"
1257 if strat == "posix":
1258 # bash first (pipefail + POSIX word-splitting, and everywhere incl. git
1259 # bash on Windows), then plain sh. zsh is excluded — its default word
1260 # splitting is not POSIX, so ask for it by name if you want it.
1261 exe = _find_exe("bash", _BASH_HINTS) or _find_exe("sh")
1262 if exe is None:
1263 raise ValueError(
1264 "shell=True needs a POSIX shell and none was found. Install one "
1265 "(git bash on Windows), or use shell='pwsh' / shell='cmd'."
1266 )
1267 return [exe, "-c"]
1268 if strat == "native":
1269 return (
1270 [os.environ.get("COMSPEC", "cmd.exe"), "/c"] if win else ["/bin/sh", "-c"]
1271 )
1272 if strat == "cmd":
1273 if not win: 1273 ↛ 1275line 1273 didn't jump to line 1275 because the condition on line 1273 was always true
1274 raise ValueError("shell='cmd' is Windows-only; use 'bash' or 'pwsh'.")
1275 return [os.environ.get("COMSPEC", "cmd.exe"), "/c"]
1276 if strat in ("bash", "sh", "zsh", "fish", "nu"):
1277 exe = _find_exe(strat, _BASH_HINTS if strat == "bash" else ())
1278 if exe is None: 1278 ↛ 1279line 1278 didn't jump to line 1279 because the condition on line 1278 was never true
1279 raise ValueError(f"shell={strat!r}: {strat!r} was not found on PATH.")
1280 return [exe, "-c"]
1281 if strat in ("pwsh", "powershell"):
1282 exe = _find_exe(strat)
1283 if exe is None: 1283 ↛ 1284line 1283 didn't jump to line 1284 because the condition on line 1283 was never true
1284 raise ValueError(f"shell={strat!r}: {strat!r} was not found on PATH.")
1285 return [exe, "-Command"] # pwsh's own run-a-string flag (accepts -c too)
1286 raise ValueError(
1287 f"shell={kind!r} is not a known shell. Use True (the policy), a strategy "
1288 f"('posix' / 'native'), or a shell name "
1289 f"('bash', 'zsh', 'sh', 'fish', 'nu', 'pwsh', 'cmd')."
1290 )
1293# `clean=True`: run the interpreter without the user's startup files, so a task's
1294# shell behaves the same on every machine. For `-c` most POSIX shells already
1295# skip their rc, but pwsh/cmd load a profile and bash honours $BASH_ENV — so it
1296# is both these flags and (POSIX) dropping BASH_ENV/ENV from the child env.
1297_CLEAN_FLAGS = {
1298 "bash": ("--norc", "--noprofile"),
1299 "zsh": ("-f",),
1300 "fish": ("--no-config",),
1301 "nu": ("-n",),
1302 "pwsh": ("-NoProfile",),
1303 "powershell": ("-NoProfile",),
1304 "cmd": ("/d",),
1305}
1307# `strict=True`: fail on the first error and on a failing pipe stage. Well-defined
1308# only for POSIX shells and PowerShell — bash/zsh get pipefail, plain sh cannot
1309# (dash has none) so it degrades to errexit-only with a one-time note; fish/nu/
1310# cmd have no errexit at all, so strict there is a taught error, not a silent no-op.
1311_STRICT_PROLOGUE = {
1312 "bash": "set -eo pipefail\n",
1313 "zsh": "set -eo pipefail\n",
1314 "sh": "set -e\n",
1315 "pwsh": (
1316 "$ErrorActionPreference = 'Stop'\n"
1317 "$PSNativeCommandUseErrorActionPreference = $true\n"
1318 ),
1319 "powershell": "$ErrorActionPreference = 'Stop'\n",
1320}
1322_strict_sh_noted = False
1325def _shell_kind_of(exe: str) -> str:
1326 """The shell family from its executable path — `/usr/bin/bash` → `bash`."""
1327 return os.path.basename(exe).lower().removesuffix(".exe")
1330def _shell_prep(
1331 kind: str, script: str, *, strict: bool, clean: bool
1332) -> tuple[list[str], str]:
1333 """Interpreter flags (from *clean*) and the script (from *strict*) for a shell
1334 run. Raises a taught error when *strict* can't be honoured (fish/nu/cmd have
1335 no errexit/pipefail)."""
1336 flags = list(_CLEAN_FLAGS.get(kind, ())) if clean else []
1337 if strict:
1338 prologue = _STRICT_PROLOGUE.get(kind)
1339 if prologue is None:
1340 raise ValueError(
1341 f"strict=True is not supported for the {kind!r} shell — it has no "
1342 f"errexit/pipefail. Use bash, zsh, sh, or pwsh, or drop strict."
1343 )
1344 if kind == "sh":
1345 global _strict_sh_noted
1346 if not _strict_sh_noted: 1346 ↛ 1352line 1346 didn't jump to line 1352 because the condition on line 1346 was always true
1347 _strict_sh_noted = True
1348 real_stderr().write(
1349 "note: strict under sh has no pipefail; using `set -e` only "
1350 "(install bash for errexit + pipefail).\n"
1351 )
1352 script = prologue + script
1353 return flags, script
1356def run(
1357 cmd: str | list[str] | Callable[..., Any],
1358 *args: Any,
1359 nofail: bool = False,
1360 silent: bool = False,
1361 capture: bool = True,
1362 title: str | None = None,
1363 env: dict[str, str] | None = None,
1364 cwd: str | Path | None = None,
1365 encoding: str | None = "utf-8",
1366 shell: bool | str = False,
1367 strict: bool = False,
1368 clean: bool = False,
1369 _show: Invocation | None = None,
1370) -> Result:
1371 """Run a command or a Python callable in the current task's context.
1373 Subprocess output is decoded as UTF-8 by default; pass `encoding=` for a
1374 tool that speaks another code page, or `encoding=None` for the locale
1375 default. Ignored for callables (in-process, no bytes boundary).
1377 `_show` is an internal channel from the `tools.*` bridge: a structured
1378 view of the call, so the shown command line can be normalised and
1379 role-coloured while execution runs whatever the tool needs. An explicit
1380 `title` still wins; a direct `run([...])` is unaffected.
1381 """
1382 ctx = current()
1383 if (strict or clean) and not shell:
1384 # strict/clean harden a *shell* run — they mean nothing shell-free, and a
1385 # silent no-op would be exactly the surprise footman avoids elsewhere.
1386 which = "strict" if strict else "clean"
1387 raise ValueError(
1388 f"run({which}=True) only applies with a shell — it hardens a shell "
1389 f"run. Pass shell=True (or a shell name), or drop {which}."
1390 )
1391 out = sys.stdout
1392 color = _colored(ctx)
1393 if _show is not None and title is None:
1394 # `label` (recorded as .command, and the step-line receipt) is always
1395 # the normalised form, so a recording() assertion never depends on
1396 # --verbose. Only the live "about to / now running" line switches to
1397 # the exact spelling under --verbose; .raw always carries it.
1398 label = _show.text(exact=False)
1399 raw = _show.text(exact=True)
1400 shown = _show.painted(color=color, exact=ctx.verbose)
1401 shown_plain = _show.text(exact=ctx.verbose)
1402 else:
1403 label = title or _label(cmd, args)
1404 raw = _exact(cmd, args)
1405 shown = _dim(label, color)
1406 shown_plain = label
1408 if ctx.dry_run:
1409 # Record the step even when not executing: `dry_run` + `quiet` is the
1410 # silent-capture mode `footman.testing` builds on. The recorded label
1411 # is normalised; only the shown line colours or (under -v) goes exact.
1412 result = Result(0, command=label, raw=raw)
1413 ctx.steps.append(result)
1414 if not ctx.quiet:
1415 out.write(f"$ {shown if color else shown_plain}\n")
1416 return result
1418 show = not silent and not ctx.quiet
1419 # `ctx.tty` means "this output dresses for a terminal" (colour, marks);
1420 # liveness is `sink is None`. A captured block styles for the terminal
1421 # it will replay onto, but in-place rewrites and the announce line stay
1422 # live-only: control bytes must never land in a capture buffer.
1423 live = ctx.sink is None
1424 if show:
1425 # The arrow announces what is *running now* — worth a line only
1426 # while output is live (a TTY rewrites it in place; a streamed CI
1427 # log may wait minutes under it). A captured block flushes when
1428 # the task is already done, where "starting X" directly above
1429 # "finished X" says nothing — the completion line carries it all.
1430 if ctx.tty and live: 1430 ↛ 1431line 1430 didn't jump to line 1431 because the condition on line 1430 was never true
1431 out.write(f"→ {_name_col(ctx)}{shown}")
1432 out.flush()
1433 elif live:
1434 out.write(f"→ {_name_col(ctx)}{shown_plain}\n")
1435 out.flush()
1437 start = time.perf_counter()
1438 if callable(cmd):
1439 code, out_s, err_s = _run_callable(cmd, args, capture=capture, env=env, cwd=cwd)
1440 else:
1441 argv: list[str] | str
1442 shell_kind = ""
1443 if shell:
1444 # An explicit shell: run the whole string through the resolved
1445 # interpreter — `[bash, -c, "<cmd>"]` — so pipes/redirects/globs
1446 # work. A list is the shell-free form; it can't be a shell script.
1447 if not isinstance(cmd, str):
1448 raise ValueError(
1449 "run(shell=…) runs a command *string* through a shell; pass a "
1450 "str, not a list (a list is the shell-free form)."
1451 )
1452 exe, run_flag = _resolve_shell(shell, ctx.shell_default or "posix")
1453 shell_kind = _shell_kind_of(exe)
1454 clean_flags, script = _shell_prep(
1455 shell_kind, cmd, strict=strict, clean=clean
1456 )
1457 argv = [exe, *clean_flags, run_flag, script]
1458 elif isinstance(cmd, str):
1459 if (op := _shell_operator(cmd)) is not None:
1460 raise ValueError(
1461 f"run({cmd!r}): {op!r} is a shell operator, but run() does not "
1462 f"use a shell, so it would be passed as a literal argument (the "
1463 f"pipeline/redirect would silently not happen). Ask for a shell "
1464 f"— run(..., shell=True) or shell='bash' — split into separate "
1465 f"run() steps, or pass a list to use {op!r} as a literal argument."
1466 )
1467 # POSIX shells split on shlex rules; Windows command lines are a
1468 # single string (CreateProcess) and shlex would mangle backslash
1469 # paths — hand the string straight to subprocess there.
1470 argv = cmd if sys.platform == "win32" else shlex.split(cmd)
1471 else:
1472 argv = [str(a) for a in cmd]
1473 # The child inherits the run-wide colour decision from os.environ, set
1474 # once at the run boundary (`color_environment`) — FORCE_COLOR so it
1475 # colours past its own pipe, or NO_COLOR so it stays quiet. A task's
1476 # `ctx.env`/`env=` still overrides it.
1477 run_env = {**os.environ, **ctx.env, **(env or {})}
1478 # A clean POSIX shell means no startup files: `--norc`/`--noprofile`
1479 # cover interactive/login rc, but bash/sh also source $BASH_ENV/$ENV for
1480 # a non-interactive `-c`, so drop those from the child env too.
1481 if clean and shell_kind in ("bash", "sh", "zsh"): 1481 ↛ 1482line 1481 didn't jump to line 1482 because the condition on line 1481 was never true
1482 run_env = {k: v for k, v in run_env.items() if k not in ("BASH_ENV", "ENV")}
1483 cwd_path = Path(cwd) if cwd is not None else ctx.cwd
1484 code, out_s, err_s = _run_subprocess(
1485 argv,
1486 run_env,
1487 cwd_path,
1488 capture,
1489 encoding,
1490 killable=not ctx.atomic,
1491 # An interactive task owns the real terminal: keep its child in
1492 # footman's group so it keeps its controlling tty (and its Ctrl-C).
1493 isolate=not ctx.atomic and not ctx.interactive,
1494 # Tag the child with its task's policy: a fail-fast failure elsewhere
1495 # reaps this tree only if the task is fail-fast, not keep-going.
1496 keep_going=ctx.keep_going,
1497 )
1498 duration = time.perf_counter() - start
1499 result = Result(
1500 code, command=label, stdout=out_s, stderr=err_s, duration=duration, raw=raw
1501 )
1502 ctx.steps.append(result)
1504 if show:
1505 ok = code == 0
1506 prefix = "\r\033[K" if ctx.tty and live else ""
1507 out.write(f"{prefix}{_step_line(ctx, ok, label, duration)}")
1508 # Join the two streams only to *display* them (stdout then stderr);
1509 # nothing merged is stored — the Result keeps them apart.
1510 combined = out_s + err_s
1511 if capture and combined and (not ok or ctx.verbose):
1512 out.write(combined if combined.endswith("\n") else combined + "\n")
1513 out.flush()
1515 if code != 0 and not nofail:
1516 raise RunFailed(result)
1517 return result
1520# --- parallel() --------------------------------------------------------------
1523def parallel(*calls: Callable[[], Any], keep_going: bool = False) -> list[int]:
1524 """Run task calls / thunks concurrently; wait; fail if any fail.
1526 Each call runs in a child of the current context with its own output buffer,
1527 flushed atomically on completion so concurrent output never interleaves.
1528 Pass task functions directly (`parallel(lint, typecheck)`) or thunks for
1529 arguments (`parallel(lambda: build("web"), lambda: build("api"))`).
1530 """
1531 from concurrent.futures import ThreadPoolExecutor
1533 parent = current()
1534 dest = parent.sink or real_stdout()
1535 dest_is_real = parent.sink is None
1536 lock = threading.Lock()
1537 # parallel() children are units on the live status line, exactly like
1538 # scheduler nodes — a chain and a task-body fan-out present identically.
1539 status = _status
1540 if status is not None:
1541 status.unit_added(len(calls))
1543 def _call_name(call: Callable[[], Any]) -> str:
1544 if isinstance(call, functools.partial): # partial(fmt, check=True) 1544 ↛ 1545line 1544 didn't jump to line 1545 because the condition on line 1544 was never true
1545 call = call.func
1546 name = getattr(call, "__name__", "task")
1547 return "…" if name == "<lambda>" else name # anonymous: no honest name
1549 # Sibling names are known up front, so their step lines can align.
1550 width = max((len(_call_name(c)) for c in calls), default=0)
1552 def invoke(call: Callable[[], Any]) -> tuple[int, BaseException | None]:
1553 name = _call_name(call)
1554 if status is not None:
1555 status.unit_started(name)
1556 # One buffer for both streams at task level, so the atomic flush keeps
1557 # this child's stdout/stderr in order; a run() inside it still splits the
1558 # step's streams via a temporary swap.
1559 buf = io.StringIO()
1560 child = replace(
1561 parent, sink=buf, err_sink=buf, steps=[], task=name, name_width=width
1562 )
1563 token = _current.set(child)
1564 try:
1565 returned = call()
1566 code = returned if _is_code(returned) else 0
1567 error: BaseException | None = None
1568 # A thunk that *returns* a non-zero code failed just as surely as one
1569 # that raised RunFailed. Synthesize the failure here so the gate below
1570 # treats both uniformly; `keep_going` still collects the code.
1571 if code != 0:
1572 thunk = _label(call, ())
1573 error = RunFailed(Result(code, command=thunk, raw=thunk))
1574 except RunFailed as exc:
1575 code, error = exc.result.code or 1, exc
1576 except SystemExit as exc:
1577 # `sys.exit()` / `raise SystemExit(...)` is a common "fail this task"
1578 # idiom, but a BaseException — without this it escapes the pool
1579 # instead of being collected. Treat its code like a returned one:
1580 # 0 succeeds, non-zero is a synthesized failure the gate below raises.
1581 # (A string reason is not carried through here: parallel() deliberately
1582 # normalises failures to a catchable RunFailed, so it must not re-raise
1583 # a BaseException. A task body's own sys.exit("reason") keeps its
1584 # message — see executor._call.)
1585 code = _exit_code(exc)
1586 error = None
1587 if code != 0:
1588 thunk = _label(call, ())
1589 error = RunFailed(Result(code, command=thunk, raw=thunk))
1590 except Failed as exc:
1591 # `footman.fail("reason")` in a thunk: an Exception (not a
1592 # BaseException), so the gate can re-raise it and its reason surfaces
1593 # at the task level — better than the sys.exit-in-parallel corner.
1594 # Its own code rides the return list too.
1595 code, error = exc.code, exc
1596 except Exception as exc: # a failed call must not crash the pool
1597 code, error = 1, exc
1598 finally:
1599 _current.reset(token)
1600 with lock:
1601 blob = child.sink.getvalue() # type: ignore[union-attr]
1602 # A child that ended mid-colour (a crash, an unterminated SGR) must
1603 # not bleed into the next child's block when they interleave: cap a
1604 # colourful run's block with a reset. Only when colour is on, so a
1605 # byte-clean run stays byte-clean.
1606 if blob and _colored(parent):
1607 blob += "\033[0m"
1608 if status is not None and dest_is_real:
1609 # This write bypasses the routers (dest is the raw stream):
1610 # tell the status line to get out of the way itself.
1611 status.notify(blob)
1612 dest.write(blob)
1613 dest.flush()
1614 # Surface the child's run() steps on the parent, in completion order,
1615 # so they appear in `--json` and `recording()` (F12).
1616 parent.steps.extend(child.steps)
1617 if status is not None:
1618 status.unit_finished(name, error is None)
1619 return code, error
1621 # -s reaches inside tasks (one worker serialises the calls in
1622 # submission order), and -j caps the width; same code path either way.
1623 if parent.sequential:
1624 workers = 1
1625 elif parent.jobs > 0:
1626 workers = max(1, min(parent.jobs, len(calls)))
1627 else:
1628 workers = max(1, len(calls))
1629 with ThreadPoolExecutor(max_workers=workers) as pool:
1630 outcomes = list(pool.map(invoke, calls))
1632 if not keep_going:
1633 for _code, error in outcomes:
1634 if error is not None:
1635 raise error
1636 return [code for code, _ in outcomes]