Coverage for src/footman/_app.py: 92%

684 statements  

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

1"""The execution path: load tasks, refresh the manifest, run the chain. 

2 

3This is everything that happens for a real `fm ...` invocation (as opposed to 

4the completion hot path). It imports the user's tasks file — paying that cost is 

5fine here — resolves the command line against the freshly-built manifest, and 

6runs the resulting segments, honouring the global options. 

7""" 

8 

9from __future__ import annotations 

10 

11import contextlib 

12import json 

13import os 

14import shutil 

15import subprocess 

16import sys 

17import time 

18import tomllib 

19from collections.abc import Callable 

20from pathlib import Path 

21 

22from footman import ( 

23 _describe, 

24 _paths, 

25 _progress, 

26 config, 

27 context, 

28 discover, 

29 executor, 

30 manifest, 

31 registry, 

32 schedule, 

33 split, 

34) 

35from footman.app import DEFAULT_BRAND, Brand 

36from footman.split import Segment 

37 

38# The brand (names + version) in effect for the current invocation. Set at the 

39# top of `run()`; a CLI is one invocation per process, so a module global is 

40# the simplest way to reach it from the error/version helpers. The colour 

41# flags follow the same pattern: one per stream, resolved once from the 

42# stream's tty-ness, --no-color, NO_COLOR, and TERM. 

43_brand: Brand = DEFAULT_BRAND 

44_color_out: bool = False 

45_color_err: bool = False 

46 

47 

48_COLOR_MODES = ("auto", "always", "never") 

49 

50 

51def _resolve_color(g: dict[str, object], cfg: dict[str, object] | None = None) -> str: 

52 """The run-wide colour mode: `auto` | `always` | `never`. 

53 

54 Precedence, highest first: an explicit `--color=…` / `--no-color` on the 

55 command line, then `[tool.footman] color`, then the environment 

56 (`NO_COLOR` → never, `FORCE_COLOR` → always), else `auto`. An unrecognised 

57 value is ignored at that rung (validated loudly on the run path); `cfg` is 

58 absent for the pre-run colouring of `--version`/errors, present for the run. 

59 """ 

60 cli = g.get("color") 

61 if isinstance(cli, str) and cli in _COLOR_MODES: 

62 return cli 

63 if g.get("no_color"): 

64 return "never" 

65 if cfg is not None: 

66 cfg_color = cfg.get("color") 

67 if isinstance(cfg_color, str) and cfg_color in _COLOR_MODES: 

68 return cfg_color 

69 if "NO_COLOR" in os.environ: 

70 return "never" 

71 forced = os.environ.get("FORCE_COLOR") 

72 if forced not in (None, "", "0"): 

73 return "always" 

74 return "auto" 

75 

76 

77def _set_colors(mode: str) -> None: 

78 global _color_out, _color_err 

79 _color_out = _describe.wants_color(sys.stdout, mode) 

80 _color_err = _describe.wants_color(sys.stderr, mode) 

81 

82 

83def _error(message: str) -> None: 

84 prog = _describe.red(_brand.prog, _color_err) 

85 sys.stderr.write(f"{prog}: {message}\n") 

86 

87 

88def _refuse(json_mode: bool, message: str, code: int = 2) -> int: 

89 """Report a refusal on stderr — and when `--json` promised an envelope, 

90 keep stdout a single JSON document describing the same refusal, so a 

91 machine consumer never has to parse two formats.""" 

92 _error(message) 

93 if json_mode: 

94 envelope = {"schema": 1, "error": {"code": code, "message": message}} 

95 print(json.dumps({**envelope, "results": []}, indent=2)) 

96 return code 

97 

98 

99def _wants_json(argv: list[str]) -> bool: 

100 """Whether the leading globals include `--json`, tolerant of a malformed 

101 line — the refusal for `fm --json --nope` must still honour the envelope 

102 `--json` already promised. Mirrors `_parse_globals`' walk, minus raising. 

103 """ 

104 i = 0 

105 while i < len(argv) and argv[i].startswith("-") and argv[i] != "--": 

106 name = argv[i].split("=", 1)[0] 

107 if name == "--json": 

108 return True 

109 kind = split._GLOBAL_KIND.get(name) 

110 i += 1 

111 if kind == "option" and "=" not in argv[i - 1] and i < len(argv): 111 ↛ 112line 111 didn't jump to line 112 because the condition on line 111 was never true

112 i += 1 # skip the option's value 

113 elif ( 113 ↛ 119line 113 didn't jump to line 119 because the condition on line 113 was never true

114 kind == "option?" 

115 and "=" not in argv[i - 1] 

116 and i < len(argv) 

117 and not argv[i].startswith("-") 

118 ): 

119 i += 1 

120 return False 

121 

122 

123def _print_version(json_mode: bool) -> int: 

124 if json_mode: 

125 payload = {"schema": 1, "name": _brand.name, "version": _brand.version} 

126 print(json.dumps(payload, indent=2)) 

127 else: 

128 print(f"{_brand.name} {_brand.version}") 

129 return 0 

130 

131 

132def _globals_to_dict(tokens: list[str]) -> dict[str, object]: 

133 """Interpret the splitter's canonical global tokens into a flat mapping.""" 

134 result: dict[str, object] = {} 

135 i = 0 

136 while i < len(tokens): 

137 tok = tokens[i] 

138 name = tok.split("=", 1)[0] 

139 key = name.lstrip("-").replace("-", "_") 

140 if "=" in tok: # a value attached by the splitter (--name=value) 

141 result[key] = tok.split("=", 1)[1] 

142 i += 1 

143 elif split._GLOBAL_KIND.get(name) == "option": 

144 result[key] = tokens[i + 1] if i + 1 < len(tokens) else "" 

145 i += 2 

146 else: # a flag, or an option? given bare 

147 result[key] = True 

148 i += 1 

149 return result 

150 

151 

152def resolve_task_files( 

153 g: dict[str, object], 

154 *, 

155 on_warning: Callable[[str], None] | None = None, 

156 on_note: Callable[[str], None] | None = None, 

157) -> tuple[list[Path], dict[str, object]]: 

158 """The task files and merged config for the cwd + globals — the pure core of 

159 `_discover`, shared with the completion subprocess (`_suggest`) so both 

160 discover exactly the same tasks. 

161 

162 `-f/--tasks-file` loads exactly one file, no cascade; otherwise every 

163 `tasks.py` from the repo root down to the cwd. Raises `config.ConfigError` 

164 on a bad `--config`; an empty file list means nothing matched. The caller 

165 owns how either outcome is surfaced. 

166 """ 

167 cwd = Path.cwd() 

168 ceiling = _paths.find_repo_root(cwd) 

169 cfg = config.load_config( 

170 cwd, 

171 ceiling, 

172 g.get("config"), # type: ignore[arg-type] 

173 on_warning=on_warning, 

174 on_note=on_note, 

175 ) 

176 override = g.get("tasks_file") 

177 if override: 

178 one = Path(str(override)).expanduser() 

179 files = [one] if one.is_file() else [] 

180 else: 

181 filename = cfg.get("tasks") 

182 name = filename if isinstance(filename, str) else _brand.tasks_file 

183 files = _paths.task_files(cwd, ceiling, name) 

184 return files, cfg 

185 

186 

187def _discover( 

188 g: dict[str, object], wants_help: bool, bare: bool 

189) -> tuple[list[Path], dict[str, object]] | int: 

190 """Resolve the task files to load and the merged config for this cwd. 

191 

192 `-f/--tasks-file` is the escape hatch: it loads exactly one file, no 

193 cascade. Otherwise footman collects every `tasks.py` from the repo root 

194 (the `.git` ceiling) down to the cwd. Returns `(files, config)` or, when 

195 nothing was found, the exit code to return (0 for a listing, 2 otherwise). 

196 """ 

197 try: 

198 files, cfg = resolve_task_files( 

199 g, 

200 on_warning=_error, 

201 on_note=_error if g.get("verbose") else None, 

202 ) 

203 except config.ConfigError as exc: 

204 return _refuse(bool(g.get("json")), f"--config: {exc}") 

205 

206 if files: 

207 return files, cfg 

208 

209 looked = g.get("tasks_file") or cfg.get("tasks") or _brand.tasks_file 

210 if wants_help: 

211 # A stuck newcomer asking for help should see the globals (-f/-C are the 

212 # way out) — not a bare one-liner. Global help over an empty tree, then 

213 # the "where did I look" note. 

214 _print_global_help(manifest.build_manifest(registry.Group("root"))["tree"]) 

215 print(f"\n(no tasks file found — looked for {looked})") 

216 return 0 

217 if bare or g.get("list") or g.get("tree"): 

218 # A bare `fm` (like `--list`) is a warm empty state, not a hard error. 

219 if g.get("json"): # the catalog envelope, honestly empty 

220 tree = manifest.build_manifest(registry.Group("root"))["tree"] 

221 print(json.dumps({"schema": 1, "tree": tree}, indent=2)) 

222 else: 

223 print(f"No tasks file found (looked for {looked}).") 

224 return 0 

225 return _refuse( 

226 bool(g.get("json")), 

227 f"no tasks file found (looked for {looked}); " 

228 f"create one or pass -f/--tasks-file.", 

229 ) 

230 

231 

232# --- rendering --------------------------------------------------------------- 

233 

234 

235def _format_value(value: object) -> str: 

236 if value is True: 236 ↛ 237line 236 didn't jump to line 237 because the condition on line 236 was never true

237 return "" 

238 if isinstance(value, list): 238 ↛ 239line 238 didn't jump to line 239 because the condition on line 238 was never true

239 return "[" + ", ".join(str(v) for v in value) + "]" 

240 return str(value) 

241 

242 

243def _plan_line(seg: Segment) -> str: 

244 parts = [] 

245 for name, value in seg.values.items(): 

246 if value is True: 

247 parts.append(f"--{name}") 

248 elif value is False: 

249 parts.append(f"--no-{name}") 

250 else: 

251 parts.append(f"{name}={_format_value(value)}") 

252 if seg.variadic: 

253 parts.append("*" + " ".join(seg.variadic)) 

254 arrow = _describe.dim("->", _color_out) 

255 task = _describe.bold(seg.task, _color_out) 

256 line = f" {arrow} {task} " + " ".join(parts) 

257 if seg.passthrough is not None: 

258 line += _describe.dim(f" [-- {' '.join(seg.passthrough)}]", _color_out) 

259 return line.rstrip() 

260 

261 

262def _print_plan(globals_: list[str], segments: list[Segment]) -> None: 

263 if globals_: 263 ↛ 266line 263 didn't jump to line 266 because the condition on line 263 was always true

264 label = _describe.dim("globals:", _color_out) 

265 print(f" {label} {' '.join(globals_)}") 

266 for seg in segments: 

267 print(_plan_line(seg)) 

268 

269 

270def _print_footer() -> None: 

271 footer = f"Run `{_brand.prog} --help <task>` for a task's options." 

272 print(f"\n{_describe.dim(footer, _color_out)}") 

273 

274 

275def _styled_name(name: str, width: int) -> str: 

276 """A task name for a listing: dim group prefix, bold leaf, padded.""" 

277 pad = " " * (width - len(name)) 

278 prefix, _, leaf = name.rpartition(" ") 

279 lead = _describe.dim(f"{prefix} ", _color_out) if prefix else "" 

280 return f"{lead}{_describe.bold(leaf, _color_out)}{pad}" 

281 

282 

283def _styled_help(help_text: str) -> str: 

284 """A help line for a listing: trailing status notes dimmed.""" 

285 for marker in ("(runs until", "(unavailable:"): 

286 head, sep, note = help_text.partition(marker) 

287 if sep: 

288 return f"{head}{_describe.dim(f'{marker}{note}', _color_out)}" 

289 return help_text 

290 

291 

292def _print_list(tree: dict) -> None: 

293 rows = list(_describe.iter_tasks(tree)) 

294 if not rows: 

295 print("No tasks defined.") 

296 return 

297 width = max(len(name) for name, _ in rows) 

298 print(_describe.bold("Tasks:", _color_out)) 

299 for name, help_text in rows: 

300 line = f" {_styled_name(name, width)} {_styled_help(help_text)}" 

301 print(line.rstrip()) 

302 

303 

304def _print_tree(node: dict, indent: str = "") -> None: 

305 # Top-level empty tree (indent sentinel) → mirror _print_list rather than 

306 # printing zero bytes and exiting 0. 

307 if not indent and not node["tasks"] and not node["groups"]: 

308 print("No tasks defined.") 

309 return 

310 dash = _describe.dim("—", _color_out) 

311 for name, task in node["tasks"].items(): 

312 line = _describe.task_line(task) 

313 help_text = f" {dash} {_styled_help(line)}" if line else "" 

314 print(f"{indent}{_describe.bold(name, _color_out)}{help_text}") 

315 for name, sub in node["groups"].items(): 

316 label = f" {dash} {sub['help']}" if sub["help"] else "" 

317 print(f"{indent}{_describe.bold_cyan(f'{name}/', _color_out)}{label}") 

318 _print_tree(sub, indent + " ") 

319 

320 

321def _print_task_help(tree: dict, path: list[str]) -> None: 

322 # All phrasing (labels, details, examples) lives in `_describe`, shared 

323 # with the markdown exporter so help text and pages can never drift. 

324 node = tree 

325 for name in path[:-1]: 

326 node = node["groups"][name] 

327 task = node["tasks"][path[-1]] 

328 on = _color_out 

329 usage = _describe.paint_cli(_describe.usage_parts(_brand.prog, path, task), on) 

330 print(f"usage: {usage}") 

331 if task["help"]: 331 ↛ 333line 331 didn't jump to line 333 because the condition on line 331 was always true

332 print(f"\n {task['help']}") 

333 if task.get("long"): # the docstring's body, structure preserved 

334 body = "\n".join(f" {ln}".rstrip() for ln in task["long"].splitlines()) 

335 print(f"\n{body}") 

336 if task.get("infinite"): 336 ↛ 337line 336 didn't jump to line 337 because the condition on line 336 was never true

337 print(_describe.dim("\n runs until you stop it — Ctrl-C", on)) 

338 if task.get("disabled"): 

339 print(_describe.dim(f"\n unavailable here: {task['disabled']}", on)) 

340 positionals = [p for p in task["params"] if p["kind"] in ("argument", "variadic")] 

341 options = [p for p in task["params"] if p["kind"] in ("flag", "option")] 

342 for title, params in (("positionals", positionals), ("options", options)): 

343 if not params: 

344 continue 

345 rows = [] 

346 for p in params: 

347 doc, mech = _describe.param_detail_parts(p) 

348 # The author's words stay bright; the mechanics dim beneath them. 

349 mech = _describe.dim(mech, on) if mech else "" 

350 detail = "; ".join(bit for bit in (doc, mech) if bit) 

351 rows.append((_describe.param_label(p), detail)) 

352 width = max(len(label) for label, _ in rows) 

353 print(f"\n{_describe.bold(f'{title}:', on)}") 

354 for label, detail in rows: 

355 pad = " " * (width - len(label)) 

356 print(f" {_describe.bold(label, on)}{pad} {detail}".rstrip()) 

357 example = _describe.paint_cli(_describe.example_parts(path, task, _brand.prog), on) 

358 print(f"\n{_describe.dim('Example:', on)} {example}") 

359 if (shadows := task.get("shadows")) is not None: 

360 # This task overrides one further up the cascade — show the call 

361 # `inherited()` makes, so the forwarding line can be read off it. 

362 where = shadows.get("where") or "the cascade" 

363 print(_describe.dim(f"\nshadows {where} — inherited() calls it", on)) 

364 usage = _describe.paint_cli( 

365 _describe.usage_parts(_brand.prog, path, shadows), on 

366 ) 

367 print(f" {usage}") 

368 

369 

370def _print_group_help(tree: dict, path: list[str]) -> None: 

371 node = tree 

372 for name in path: 

373 node = node["groups"][name] 

374 on = _color_out 

375 default = node.get("default") 

376 parts = [("prog", _brand.prog), *[("group", name) for name in path]] 

377 # A runnable group (one with `@group.default`) can run bare — its default — 

378 # so the task becomes optional. 

379 parts += [("opt", "[<task>]")] if default else [("req", "<task>")] 

380 parts += [("opt", "[options]")] 

381 print(f"usage: {_describe.paint_cli(parts, on)}") 

382 if node["help"]: 382 ↛ 384line 382 didn't jump to line 384 because the condition on line 382 was always true

383 print(f"\n {node['help']}") 

384 if default: 384 ↛ 385line 384 didn't jump to line 385 because the condition on line 384 was never true

385 print(_describe.dim("\n runs its default when no task is named", on)) 

386 rows = list(_describe.iter_tasks(node)) 

387 if rows: 387 ↛ 393line 387 didn't jump to line 393 because the condition on line 387 was always true

388 width = max(len(name) for name, _ in rows) 

389 print(f"\n{_describe.bold('tasks:', on)}") 

390 for name, help_text in rows: 

391 line = f" {_styled_name(name, width)} {_styled_help(help_text)}" 

392 print(line.rstrip()) 

393 params = default["params"] if default else [] 

394 options = [p for p in params if p["kind"] in ("flag", "option")] 

395 if options: 395 ↛ 396line 395 didn't jump to line 396 because the condition on line 395 was never true

396 rows2 = [] 

397 for p in options: 

398 doc, mech = _describe.param_detail_parts(p) 

399 mech = _describe.dim(mech, on) if mech else "" 

400 detail = "; ".join(bit for bit in (doc, mech) if bit) 

401 rows2.append((_describe.param_label(p), detail)) 

402 width2 = max(len(label) for label, _ in rows2) 

403 print(f"\n{_describe.bold('options:', on)}") 

404 for label, detail in rows2: 

405 pad = " " * (width2 - len(label)) 

406 print(f" {_describe.bold(label, on)}{pad} {detail}".rstrip()) 

407 

408 

409def _print_global_help(tree: dict) -> None: 

410 prog = _brand.prog 

411 parts = [ 

412 ("prog", prog), 

413 ("opt", "[globals]"), 

414 ("req", "<task>"), 

415 ("opt", "[options]"), 

416 ("opt", "[<task> ...]"), 

417 ] 

418 print(f"usage: {_describe.paint_cli(parts, _color_out)}") 

419 print(f"\n{_describe.bold('globals (before the first task):', _color_out)}") 

420 rows = [] 

421 for name, alias, _kind, hint, help_text in split.GLOBALS: 

422 label = f"{alias}, {name}" if alias else f" {name}" 

423 if hint: 

424 label += f" {hint}" 

425 # `.replace` (not `.format`) so a help string containing braces can 

426 # never crash help output. 

427 rows.append((label, help_text.replace("{prog}", prog))) 

428 width = max(len(label) for label, _ in rows) 

429 for label, help_text in rows: 

430 pad = " " * (width - len(label)) 

431 print(f" {_describe.bold(label, _color_out)}{pad} {help_text}") 

432 print() 

433 _print_list(tree) 

434 _print_footer() 

435 

436 

437def _wants_help(argv: list[str]) -> bool: 

438 """`-h`/`--help` anywhere before `--` turns the whole line into a help 

439 request — asking for help must never execute anything, wherever it lands 

440 on the line. After `--` it belongs to the passthrough.""" 

441 for tok in argv: 

442 if tok == "--": 

443 return False 

444 if tok in ("-h", "--help"): 

445 return True 

446 return False 

447 

448 

449def _expand_help_path(argv: list[str], start: int) -> list[str]: 

450 """Split a quoted multi-word or dotted path token into its components. 

451 

452 `fm --help "dict add"` (the shell hands the path as one token) and 

453 `fm --help dict.add` (a user dots the path) both name the task at 

454 `dict` → `add`. No group or task name contains a space or a dot, so 

455 splitting a path token on either only ever un-quotes a path the walk can 

456 then resolve — turning a self-referential "did you mean 'dict add'?" into a 

457 real lookup. Option-shaped tokens and everything past `--` are left verbatim. 

458 """ 

459 out = argv[:start] 

460 for j in range(start, len(argv)): 

461 tok = argv[j] 

462 if tok == "--": 

463 out.extend(argv[j:]) # passthrough boundary — leave the rest verbatim 

464 break 

465 if not tok.startswith("-") and (" " in tok or "." in tok): 

466 out.extend(tok.replace(".", " ").split()) 

467 else: 

468 out.append(tok) 

469 return out 

470 

471 

472def _help_targets( 

473 tree: dict, argv: list[str] 

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

475 """Group/task paths mentioned on a `--help` line, resolved leniently — 

476 plus the bare words that resolved to nothing, so the caller can refuse a 

477 `--help typo` instead of shrugging. 

478 

479 The real splitter enforces arity — `--help add` must work even though 

480 `add` alone would be "missing required argument(s)" — so this walks group 

481 and task names only and skips every other token (option-shaped tokens and, 

482 once a target is found, its argument values). 

483 """ 

484 _, i = split._parse_globals(argv, 0) 

485 argv = _expand_help_path(argv, i) 

486 targets: list[tuple[str, list[str]]] = [] 

487 strays: list[str] = [] 

488 while i < len(argv): 

489 if argv[i] == "--": 

490 break 

491 node, path = tree, [] 

492 while i < len(argv) and argv[i] in node["groups"]: 

493 path.append(argv[i]) 

494 node = node["groups"][argv[i]] 

495 i += 1 

496 if i < len(argv) and argv[i] in node["tasks"]: 

497 targets.append(("task", [*path, argv[i]])) 

498 elif path: 

499 targets.append(("group", path)) 

500 continue # the walk already consumed the group name(s) 

501 elif i < len(argv) and not argv[i].startswith("-"): 

502 strays.append(argv[i]) 

503 i += 1 

504 return targets, strays 

505 

506 

507def _print_help(tree: dict, argv: list[str]) -> int: 

508 """`--help` alone covers fm itself; with names, the named groups/tasks. 

509 

510 A name that matches nothing is a refusal (exit 2) with a suggestion — 

511 silently degrading to the global listing looked like an answer while 

512 teaching nothing. With at least one real target found, extra bare words 

513 stay lenient: they are argument values, not typos. 

514 """ 

515 targets, strays = _help_targets(tree, argv) 

516 if not targets: 

517 if strays: 

518 known = [name for name, _ in _describe.iter_tasks(tree)] 

519 known += _describe.iter_group_paths(tree) 

520 # Help's *success* output is the one human-only surface; a refusal 

521 # still honours the envelope `--json` promised. 

522 return _refuse( 

523 _wants_json(argv), 

524 f"--help: unknown task or group {strays[0]!r}" 

525 f"{split._did_you_mean(strays[0], known)}", 

526 ) 

527 _print_global_help(tree) 

528 return 0 

529 for index, (kind, path) in enumerate(targets): 

530 if index: 530 ↛ 531line 530 didn't jump to line 531 because the condition on line 530 was never true

531 print() 

532 if kind == "task": 

533 _print_task_help(tree, path) 

534 else: 

535 _print_group_help(tree, path) 

536 return 0 

537 

538 

539def _where(root: registry.Group, tree: dict, dotted: str) -> int: 

540 path = dotted.replace(".", " ").split() 

541 try: 

542 fn = executor.resolve(root, path) 

543 except (KeyError, IndexError): 

544 names = [name.replace(" ", ".") for name, _ in _describe.iter_tasks(tree)] 

545 _error(f"--where: unknown task {dotted!r}{split._did_you_mean(dotted, names)}") 

546 return 2 

547 chain = discover.shadow_chain(fn) 

548 lines = [] 

549 for index, member in enumerate(chain): 

550 code = getattr(member, "__code__", None) 

551 if code is None: 551 ↛ 552line 551 didn't jump to line 552 because the condition on line 551 was never true

552 continue 

553 where = f"{code.co_filename}:{code.co_firstlineno}" 

554 # The winner first; anything it shadows follows, marked — so 

555 # "am I overriding something, and where is it?" is one command. 

556 lines.append(where if index == 0 else f"{where} (shadowed)") 

557 if not lines: 557 ↛ 558line 557 didn't jump to line 558 because the condition on line 557 was never true

558 _error(f"--where: cannot locate source for {dotted!r}") 

559 return 2 

560 print("\n".join(lines)) 

561 return 0 

562 

563 

564def _print_summary( 

565 results: list[executor.TaskResult], 

566 *, 

567 timings: bool, 

568 total: float, 

569) -> None: 

570 # The summary is commentary about the run, not the run's output — it goes 

571 # to stderr so `fm task > file` captures exactly what the task produced. 

572 # Each receipt is task-shaped (mark · name · time), the same grid as the 

573 # step lines above it, with the name in cyan — same family, one rank up. 

574 color = _color_err 

575 width = max((len(r.task) for r in results), default=0) 

576 for result in results: 

577 ok = result.ok 

578 cancelled = result.cancelled 

579 if color: 

580 if ok: 580 ↛ 582line 580 didn't jump to line 582 because the condition on line 580 was always true

581 mark = "\033[32m✓\033[0m" 

582 elif cancelled: 

583 mark = "\033[33m○\033[0m" # cut off by fail-fast, not a failure 

584 else: 

585 mark = "\033[31m✗\033[0m" 

586 name = f"\033[1;36m{result.task:<{width}}\033[0m" 

587 else: 

588 word = "ok" if ok else ("cut" if cancelled else "FAIL") 

589 mark = f"{word:<4}" 

590 name = f"{result.task:<{width}}" 

591 timing = ( 

592 f"({result.duration * 1000:.0f} ms)" 

593 if timings 

594 else f"({_progress.fmt_secs(result.duration)})" 

595 ) 

596 if color: 

597 timing = f"\033[36m{timing}\033[0m" 

598 print(f"{mark} {name} {timing}", file=sys.stderr) 

599 if cancelled: 599 ↛ 600line 599 didn't jump to line 600 because the condition on line 599 was never true

600 _error(f"{result.task}: cancelled — fail-fast stopped the run") 

601 elif result.error is not None: 

602 # A deliberate stop with a reason (`fail("…")` / `sys.exit("…")`) 

603 # renders verbatim, the way Python prints it — not "Failed: …"; a bare 

604 # `fail()` with no reason falls back to the code line. A real exception 

605 # keeps its type, which signals a crash, not a chosen stop. 

606 err = result.error 

607 if context._is_deliberate_stop(err): 

608 detail = str(err) or f"exited with code {result.code}" 

609 else: 

610 detail = f"{type(err).__name__}: {err}" 

611 _error(f"{result.task}: {detail}") 

612 elif not result.ok: 

613 _error(f"{result.task}: exited with code {result.code}") 

614 if len(results) > 1: # one task's receipt already carries the total 

615 took = f"took {_progress.fmt_secs(total)}" 

616 if color: 

617 took = f"\033[2m{took}\033[0m" 

618 print(took, file=sys.stderr) 

619 

620 

621def _print_json(results: list[executor.TaskResult], *, total: float) -> None: 

622 payload = [] 

623 for r in results: 

624 entry: dict[str, object] = { 

625 "task": r.task, 

626 "ok": r.ok, 

627 "cancelled": r.cancelled, 

628 "code": r.code, 

629 "duration_ms": round(r.duration * 1000, 3), 

630 "output": r.output, 

631 "steps": [ 

632 { 

633 "command": s.command, 

634 "code": s.code, 

635 "duration_ms": round(s.duration * 1000, 3), 

636 "stdout": s.stdout, 

637 "stderr": s.stderr, 

638 } 

639 for s in r.steps 

640 ], 

641 "error": None if r.error is None else str(r.error), 

642 } 

643 value = r.returned 

644 # An int return is the exit-code channel (duty's contract), not data; 

645 # None is "nothing to say". Everything else — bools included — is data. 

646 if value is not None and not ( 

647 isinstance(value, int) and not isinstance(value, bool) 

648 ): 

649 try: 

650 json.dumps(value, default=_describe.json_default) 

651 except (TypeError, ValueError) as exc: # ValueError: circular refs 

652 entry["returned_error"] = str(exc) 

653 _error(f"{r.task}: --json: return value dropped — {exc}") 

654 else: 

655 entry["returned"] = value 

656 payload.append(entry) 

657 # The stable machine surface: an envelope so post-1.0 additions (metadata, 

658 # summaries) never have to break consumers of the results list. 

659 print( 

660 json.dumps( 

661 {"schema": 1, "total_ms": round(total * 1000, 3), "results": payload}, 

662 indent=2, 

663 default=_describe.json_default, 

664 ) 

665 ) 

666 

667 

668def _resolve_shell(shell: object, flag: str) -> str | None: 

669 """Resolve *shell* to a supported name for *flag*, or None after `_error`. 

670 

671 A bare flag (`shell is True`) detects the invoking shell; an explicit value 

672 is lowercased and de-aliased (`nu`→`nushell`, `powershell`→`pwsh`). 

673 """ 

674 from footman import _shellcomp 

675 

676 supported = "|".join(_shellcomp.SHELLS) 

677 if shell is True: 

678 name = _shellcomp.detect_shell() 

679 if name is None: 

680 _error( 

681 f"{flag}: could not detect your shell — " 

682 f"name it explicitly: one of {supported}" 

683 ) 

684 return None 

685 else: 

686 name = str(shell or "").lower() 

687 name = {"powershell": "pwsh", "nu": "nushell"}.get(name, name) # muscle-memory 

688 if name not in _shellcomp.SHELLS: 

689 got = f" (got {name!r})" if name else "" 

690 _error(f"{flag} expects one of {supported}{got}") 

691 return None 

692 return name 

693 

694 

695def _install_completion(shell: object) -> int: 

696 from footman import _shellcomp 

697 

698 name = _resolve_shell(shell, "--install-completion") 

699 if name is None: 

700 return 2 

701 if shell is True: 

702 print(f"detected shell: {name}") 

703 try: 

704 lines = _shellcomp.install(name, _brand.prog) 

705 except _shellcomp.InstallError as exc: 

706 _error(f"--install-completion {name}: {exc}") 

707 return 2 

708 for line in lines: 

709 print(line) 

710 return 0 

711 

712 

713def _uninstall_completion(shell: object) -> int: 

714 from footman import _shellcomp 

715 

716 name = _resolve_shell(shell, "--uninstall-completion") 

717 if name is None: 

718 return 2 

719 if shell is True: 719 ↛ 720line 719 didn't jump to line 720 because the condition on line 719 was never true

720 print(f"detected shell: {name}") 

721 try: 

722 lines = _shellcomp.uninstall(name, _brand.prog) 

723 except _shellcomp.InstallError as exc: 

724 _error(f"--uninstall-completion {name}: {exc}") 

725 return 2 

726 for line in lines: 

727 print(line) 

728 return 0 

729 

730 

731def _setup_completion(shell: object) -> int: 

732 """Print the completion hook to stdout, for the current session only. 

733 

734 `eval "$(prog --setup-completion zsh)"` enables completion without touching 

735 any rc file. A bare flag detects the shell; the detection note goes to 

736 stderr so stdout stays clean for `eval`. 

737 """ 

738 from footman import _shellcomp 

739 

740 name = _resolve_shell(shell, "--setup-completion") 

741 if name is None: 

742 return 2 

743 if shell is True: 

744 print(f"detected shell: {name}", file=sys.stderr) 

745 print(_shellcomp.script_for(name, _brand.prog)) 

746 return 0 

747 

748 

749# --- orchestration ----------------------------------------------------------- 

750 

751 

752def run( 

753 argv: list[str], 

754 brand: Brand = DEFAULT_BRAND, 

755 collect: list[executor.TaskResult] | None = None, 

756) -> int: 

757 """Run the CLI; when *collect* is given, extend it with the TaskResults. 

758 

759 `collect` exists for `footman.testing.Runner`, which needs the structured 

760 results as well as the exit code and printed output. 

761 """ 

762 try: 

763 return _run(argv, brand, collect) 

764 except KeyboardInterrupt: 

765 # In --json mode nothing has reached stdout yet (capture buffers task 

766 # output), so the envelope contract still holds at 130. 

767 return _refuse(_wants_json(argv), "interrupted", 130) 

768 

769 

770_WINDOWS = os.name == "nt" # decided at import; a constant tests can steer 

771 

772 

773GC_INTERVAL_S = 24 * 3600 

774 

775 

776def _maybe_collect(cfg: dict[str, object]) -> None: 

777 """At most daily, and never on a fresh cache, spawn the collector. 

778 

779 A missing stamp is *planted*, not acted on — the first run a cache ever 

780 sees schedules collection for tomorrow, so short-lived caches (a test 

781 suite's tmp dirs) never spawn anything. An aged stamp is re-touched 

782 *before* spawning, the refresh idiom: concurrent runs elect one 

783 collector, and a crashed child costs a day, not correctness. 

784 """ 

785 if cfg.get("gc") is False or os.environ.get("FOOTMAN_NO_GC"): 

786 return 

787 cache = _paths.footman_cache_dir() 

788 stamp = cache / "gc.stamp" 

789 try: 

790 age = time.time() - stamp.stat().st_mtime 

791 except OSError: 

792 with contextlib.suppress(OSError): 

793 cache.mkdir(parents=True, exist_ok=True) 

794 stamp.touch() 

795 return 

796 if age < GC_INTERVAL_S: 

797 return 

798 with contextlib.suppress(OSError): 

799 stamp.touch() 

800 _spawn_gc(cache, _paths.manifest_path(Path.cwd()).stem) 

801 

802 

803def _spawn_gc(cache: Path, skip_stem: str) -> None: 

804 """Detach the collector child — `_complete`'s refresh spawn, verbatim.""" 

805 cmd = [ 

806 sys.executable, 

807 "-c", 

808 "from footman import _gc; _gc.main()", 

809 str(cache), 

810 skip_stem, 

811 ] 

812 null = subprocess.DEVNULL 

813 try: 

814 if _WINDOWS: 

815 flags = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr( 

816 subprocess, "CREATE_NEW_PROCESS_GROUP", 0 

817 ) 

818 subprocess.Popen( 

819 cmd, stdin=null, stdout=null, stderr=null, creationflags=flags 

820 ) 

821 else: 

822 subprocess.Popen( 

823 cmd, stdin=null, stdout=null, stderr=null, start_new_session=True 

824 ) 

825 except OSError: 

826 return # a background collector must never break a run 

827 

828 

829def _uv_handoff(argv: list[str], g: dict[str, object]) -> None: 

830 """Hand a globally-installed invocation to the project's own footman. 

831 

832 The rule, one sentence: when the project's `uv.lock` pins footman and 

833 this interpreter is not already inside the project's environment, the 

834 invocation belongs to `uv run` — the project has declared what `fm` 

835 means there, version and all. Reached only where tasks would be 

836 imported: `--version`, completion management, and the TAB hot path 

837 never arrive here. Opt out with `uv = false` in `[tool.footman]` or 

838 `FOOTMAN_NO_UV=1`. The child carries `FOOTMAN_UV_REEXEC` as a loop 

839 belt for projects whose environment lives outside `.venv`. 

840 

841 On POSIX the process is replaced (`execvp`: tty, signals, and exit 

842 code all belong to the child). Windows `exec*` lies — the parent 

843 exits while the child runs on — so there it spawns and waits, 

844 swallowing its own Ctrl-C (the console already delivered it to the 

845 child, which will exit 130 on its own terms). 

846 """ 

847 if os.environ.get("FOOTMAN_UV_REEXEC") or os.environ.get("FOOTMAN_NO_UV"): 

848 return 

849 try: 

850 probe = Path(str(g.get("directory") or Path.cwd())).resolve(strict=True) 

851 except OSError: 

852 return # a missing -C target: _run's own error path reports it 

853 root = next((p for p in (probe, *probe.parents) if (p / "uv.lock").is_file()), None) 

854 if root is None: 

855 return 

856 venv = root / ".venv" 

857 with contextlib.suppress(OSError): 

858 if venv.is_dir() and Path(sys.prefix).resolve().is_relative_to(venv.resolve()): 

859 return # already the project's environment 

860 uv = shutil.which("uv") 

861 if uv is None: 861 ↛ 862line 861 didn't jump to line 862 because the condition on line 861 was never true

862 return 

863 try: 

864 with open(root / "uv.lock", "rb") as fh: 

865 lock = tomllib.load(fh) 

866 except (OSError, tomllib.TOMLDecodeError): 

867 return 

868 if not any(p.get("name") == "footman" for p in lock.get("package", [])): 

869 return 

870 try: 

871 cfg = config.load_config( 

872 probe, 

873 _paths.find_repo_root(probe), 

874 str(g["config"]) if g.get("config") else None, 

875 on_warning=lambda _: None, # the real run repeats any warning 

876 ) 

877 except config.ConfigError: 

878 return # the real run reports the broken --config properly 

879 if cfg.get("uv") is False: 

880 return 

881 if g.get("verbose"): 881 ↛ 882line 881 didn't jump to line 882 because the condition on line 881 was never true

882 print( 

883 f"{_brand.prog}: handing off to uv run --project {root}", 

884 file=sys.stderr, 

885 ) 

886 os.environ["FOOTMAN_UV_REEXEC"] = "1" 

887 cmd = [uv, "run", "--project", str(root), _brand.prog, *argv] 

888 if _WINDOWS: 

889 proc = subprocess.Popen(cmd) 

890 while True: 

891 try: 

892 raise SystemExit(proc.wait()) 

893 except KeyboardInterrupt: 

894 continue 

895 sys.stdout.flush() 

896 sys.stderr.flush() 

897 os.execvp(uv, cmd) 

898 

899 

900def _run( 

901 argv: list[str], 

902 brand: Brand, 

903 collect: list[executor.TaskResult] | None = None, 

904) -> int: 

905 global _brand 

906 _brand = brand 

907 try: 

908 pre_globals, _ = split._parse_globals(argv, 0) 

909 except split.ChainError as exc: 

910 return _refuse(_wants_json(argv), str(exc)) 

911 g = _globals_to_dict(pre_globals) 

912 _set_colors(_resolve_color(g)) 

913 wants_help = _wants_help(argv) 

914 

915 if g.get("version"): # D7: --version wins even over --help 

916 return _print_version(bool(g.get("json"))) 

917 # Asking for help must never touch the filesystem: `--install-completion 

918 # fish --help` used to write rc files before printing anything. 

919 if "install_completion" in g and not wants_help: 

920 return _install_completion(g.get("install_completion")) 

921 if "setup_completion" in g and not wants_help: 

922 return _setup_completion(g.get("setup_completion")) 

923 if "uninstall_completion" in g and not wants_help: 

924 return _uninstall_completion(g.get("uninstall_completion")) 

925 

926 # May replace the process (POSIX) or exit with the child's code 

927 # (Windows); returns quietly whenever the handoff doesn't apply. 

928 _uv_handoff(argv, g) 

929 

930 if not g.get("directory"): 

931 return _execute(argv, g, wants_help, collect) 

932 

933 # -C must not permanently move the process (a `Runner.invoke` shares the 

934 # host pytest's cwd): chdir, run, then restore in a finally. The original 

935 # dir may have vanished mid-run, so the restore is best-effort. 

936 saved_cwd = os.getcwd() 

937 try: 

938 os.chdir(str(g["directory"])) 

939 except OSError as exc: 

940 return _refuse(bool(g.get("json")), f"-C {g['directory']}: {exc}") 

941 try: 

942 return _execute(argv, g, wants_help, collect) 

943 finally: 

944 with contextlib.suppress(OSError): 

945 os.chdir(saved_cwd) 

946 

947 

948def _execute( 

949 argv: list[str], 

950 g: dict[str, object], 

951 wants_help: bool, 

952 collect: list[executor.TaskResult] | None, 

953) -> int: 

954 """Discover the cascade, load + sync its manifest, then run the tree. 

955 

956 Everything after globals/`--version`/`--install-completion`/`-C`: the 

957 disk-backed half that `run_group` (in-memory) deliberately skips. 

958 """ 

959 # "Bare" means no chain was asked for — globals-only lines (`fm --json`, 

960 # `fm -k`) are listing-shaped, exactly like they are when tasks exist. 

961 _, after_globals = split._parse_globals(argv, 0) 

962 found = _discover(g, wants_help, bare=after_globals >= len(argv)) 

963 if isinstance(found, int): 

964 return found 

965 files, cfg = found 

966 json_mode = bool(g.get("json")) 

967 

968 base = registry.Group("root") 

969 plugins = cfg.get("plugins") 

970 if isinstance(plugins, list) and plugins: 

971 from footman import compose 

972 

973 try: 

974 compose.mount_plugins(base, plugins) 

975 except registry.RegistrationError as exc: 

976 return _refuse(json_mode, str(exc)) 

977 

978 try: 

979 reg = discover.load_tree(files, base=base) 

980 except discover.TasksImportError as exc: 

981 if isinstance(exc.original, registry.RegistrationError): 

982 # a user mistake, not a crash 

983 return _refuse(json_mode, f"{exc.path}: {exc.original}") 

984 return _refuse( 

985 json_mode, 

986 f"failed to import {exc.path}: " 

987 f"{type(exc.original).__name__}: {exc.original}", 

988 ) 

989 except Exception as exc: # report import failures cleanly, don't crash 

990 return _refuse( 

991 json_mode, 

992 f"failed to import the task cascade: {type(exc).__name__}: {exc}", 

993 ) 

994 

995 try: 

996 if g.get("tasks_file"): 

997 # -f loads one arbitrary file, not the cwd's cascade. Cache its 

998 # manifest under a (cwd, file) key — separate from the cwd's, so it 

999 # never poisons plain TAB there — so `fm -f <file> <TAB>` completes 

1000 # that file's tasks. max_age=0: no background refresh (rebuilt on the 

1001 # next -f run); a live refresh is a fast-follow. 

1002 override = str(g.get("tasks_file")) 

1003 tree = manifest.sync_manifest( 

1004 reg, 

1005 Path.cwd(), 

1006 completion_max_age=0, 

1007 tasks_file=override, 

1008 path=_paths.source_manifest_path(Path.cwd(), Path(override)), 

1009 )["tree"] 

1010 else: 

1011 cfg_tasks = cfg.get("tasks") 

1012 tree = manifest.sync_manifest( 

1013 reg, 

1014 Path.cwd(), 

1015 completion_max_age=config.completion_max_age(cfg), 

1016 tasks_file=cfg_tasks 

1017 if isinstance(cfg_tasks, str) 

1018 else _brand.tasks_file, 

1019 )["tree"] 

1020 except manifest.ManifestError as exc: # broken completer, bad markers, … 

1021 return _refuse(json_mode, str(exc)) 

1022 

1023 code = _run_tree(reg, tree, argv, cfg, collect) 

1024 # After the run, so it never adds latency before the user's command — 

1025 # and after the uv handoff by construction (the handoff replaced this 

1026 # process back in _run), so a pinned project's own footman collects. 

1027 _maybe_collect(cfg) 

1028 return code 

1029 

1030 

1031def _run_tree( 

1032 reg: registry.Group, 

1033 tree: dict, 

1034 argv: list[str], 

1035 cfg: dict[str, object], 

1036 collect: list[executor.TaskResult] | None, 

1037) -> int: 

1038 """The post-manifest tail: help/where/split/list/tree/dry-run/run/report. 

1039 

1040 Shared by the disk path (`_execute`) and the in-memory path (`run_group`), 

1041 so both honour `--help`/`--version`/`--list`/`--tree`/`--json` identically. 

1042 Globals are re-derived from `argv` (already validated upstream). 

1043 """ 

1044 g = _globals_to_dict(split._parse_globals(argv, 0)[0]) 

1045 json_mode = bool(g.get("json")) 

1046 

1047 cli_color = g.get("color") 

1048 if isinstance(cli_color, str) and cli_color not in _COLOR_MODES: 

1049 return _refuse( 

1050 json_mode, 

1051 f"--color expects one of {'|'.join(_COLOR_MODES)} (got {cli_color!r})", 

1052 ) 

1053 # Config can set the mode too, so re-resolve now that cfg is in hand and 

1054 # repaint footman's own chrome to match (the pre-run call saw CLI+env only). 

1055 color_mode = _resolve_color(g, cfg) 

1056 _set_colors(color_mode) 

1057 

1058 if _wants_help(argv): 

1059 return _print_help(tree, argv) 

1060 

1061 if g.get("where"): 

1062 # Deliberately plain under --json too: `file:line` already is the 

1063 # machine format. 

1064 return _where(reg, tree, str(g["where"])) 

1065 

1066 try: 

1067 globals_, segments = split.split_chain(tree, argv) 

1068 except split.ChainError as exc: 

1069 return _refuse(json_mode, str(exc)) 

1070 

1071 if not segments: 

1072 if json_mode: 

1073 # The catalog envelope: the manifest tree, params and all — the 

1074 # machine twin of --list/--tree (and of bare `fm`). 

1075 print(json.dumps({"schema": 1, "tree": tree}, indent=2)) 

1076 return 0 

1077 if g.get("tree"): 

1078 _print_tree(tree) 

1079 else: 

1080 _print_list(tree) 

1081 if tree["tasks"] or tree["groups"]: 

1082 _print_footer() 

1083 return 0 

1084 

1085 if g.get("dry_run"): 

1086 if json_mode: 

1087 plan = [ 

1088 { 

1089 "task": s.task, 

1090 "values": s.values, 

1091 "variadic": s.variadic, 

1092 "passthrough": s.passthrough, 

1093 } 

1094 for s in segments 

1095 ] 

1096 payload = {"schema": 1, "globals": globals_, "plan": plan} 

1097 print(json.dumps(payload, indent=2)) 

1098 else: 

1099 _print_plan(globals_, segments) 

1100 return 0 

1101 sequential = bool(g.get("sequential")) or bool(cfg.get("sequential")) 

1102 

1103 # The parallel width: -j/--jobs wins, then config `jobs`, then the 

1104 # cores-minus-one default. Caps both engines (the scheduler's pool and 

1105 # parallel() in task bodies) and is part of the timing key — a -j2 run 

1106 # has a genuinely different duration distribution. 

1107 if g.get("jobs") is not None: 

1108 try: 

1109 jobs = int(str(g["jobs"])) 

1110 except ValueError: 

1111 jobs = 0 

1112 if jobs < 1: 

1113 return _refuse( 

1114 json_mode, 

1115 f"--jobs expects a positive integer (got {g['jobs']!r})", 

1116 ) 

1117 elif ( 1117 ↛ 1122line 1117 didn't jump to line 1122 because the condition on line 1117 was never true

1118 isinstance(cfg_jobs := cfg.get("jobs"), int) 

1119 and not isinstance(cfg_jobs, bool) 

1120 and cfg_jobs >= 1 

1121 ): 

1122 jobs = cfg_jobs 

1123 else: 

1124 jobs = _progress.default_jobs() 

1125 

1126 fetch_cfg = cfg.get("fetch") 

1127 backend = fetch_cfg.get("backend") if isinstance(fetch_cfg, dict) else None 

1128 shell_cfg = cfg.get("shell") 

1129 shell_default = shell_cfg.get("default") if isinstance(shell_cfg, dict) else None 

1130 ctx_config = { 

1131 "fetch_backend": str(backend) if isinstance(backend, str) else "", 

1132 "shell_default": str(shell_default) if isinstance(shell_default, str) else "", 

1133 "quiet": bool(g.get("quiet")), 

1134 "verbose": bool(g.get("verbose")), 

1135 # The resolved tri-state, split into the two Context bits: `never` stops 

1136 # all colour, `always` forces it past a non-terminal (the scheduler still 

1137 # gates that off under capture). `auto` leaves both false — tty decides. 

1138 "no_color": color_mode == "never", 

1139 "force_color": color_mode == "always", 

1140 # Tasks can know who invoked them (a branded CLI's prog) — the 

1141 # taskdocs plugin brands its output with this, for one. 

1142 "prog": _brand.prog, 

1143 # The user's -s/config request, so parallel() in task bodies 

1144 # serialises too — not the scheduler's single-node routing. 

1145 "sequential": sequential, 

1146 "jobs": jobs, 

1147 # Interactivity globals: --yes auto-answers confirm() gates, --no-input 

1148 # refuses to prompt (a required prompt errors instead of hanging). 

1149 "assume_yes": bool(g.get("yes")), 

1150 "no_input": bool(g.get("no_input")), 

1151 } 

1152 

1153 # The timing story: --no-progress (one run) or `progress = false` in 

1154 # config (permanently) turns the whole apparatus off. A run is 

1155 # *predictable* when it's on, every task consented, and this is the real 

1156 # cascade (-f runs pollute no cache, times included) — only then do we 

1157 # estimate from history and record the outcome. 

1158 progress_on = not g.get("no_progress") and cfg.get("progress") is not False 

1159 predictable = ( 

1160 progress_on 

1161 and not g.get("tasks_file") 

1162 and schedule.dag_wants_progress(reg, segments) 

1163 ) 

1164 est = times_key = None 

1165 context.seed_cmd_width(0) # each run learns (or is seeded) afresh 

1166 if predictable: 

1167 times_key = _progress.chain_key(segments, sequential=sequential, jobs=jobs) 

1168 est = _progress.estimate(_progress.load_runs(Path.cwd(), times_key)) 

1169 context.seed_cmd_width(_progress.load_cmd_width(Path.cwd(), times_key)) 

1170 if est is not None and not g.get("quiet") and not sys.stderr.isatty(): 

1171 # No TTY (CI, a pipe): the one-line version of the bar, up front. 

1172 print(f" {'eta':>4} ~{_progress.fmt_secs(est.typical)}", file=sys.stderr) 

1173 

1174 # Tri-state on the command line: `-k` forces keep-going, `--fail-fast` forces 

1175 # fail-fast, neither leaves it to the invoked task's declared policy. 

1176 cli_keep_going = True if g.get("keep_going") else None 

1177 if g.get("fail_fast"): 1177 ↛ 1178line 1177 didn't jump to line 1178 because the condition on line 1177 was never true

1178 cli_keep_going = False 

1179 

1180 start = time.perf_counter() 

1181 try: 

1182 results = schedule.run_plan( 

1183 reg, 

1184 segments, 

1185 sequential=sequential, 

1186 keep_going=cli_keep_going, # None = unspecified; run_plan scopes per node 

1187 capture=json_mode, 

1188 ctx_config=ctx_config, 

1189 estimate=est, 

1190 progress=progress_on, 

1191 jobs=jobs, 

1192 ) 

1193 except split.ChainError as exc: # e.g. passthrough with no *args 

1194 return _refuse(json_mode, str(exc)) 

1195 total = time.perf_counter() - start 

1196 

1197 if collect is not None: 

1198 collect.extend(results) 

1199 if predictable and times_key and results and all(r.ok for r in results): 

1200 # Green runs teach: the duration, and the step-alignment width. 

1201 _progress.record(Path.cwd(), times_key, total, cmd_width=context.cmd_width()) 

1202 

1203 if json_mode: 

1204 _print_json(results, total=total) 

1205 elif not g.get("quiet"): 

1206 _print_summary(results, timings=bool(g.get("timings")), total=total) 

1207 

1208 # The exit code is the first genuine failure's — a cancelled task carries 

1209 # only a kill signal, so it's the fallback, not the headline. 

1210 failed = [r for r in results if not r.ok] 

1211 genuine = next((r.code or 1 for r in failed if not r.cancelled), None) 

1212 if genuine is not None: 

1213 return genuine 

1214 return next((r.code or 1 for r in failed), 0) 

1215 

1216 

1217def run_group( 

1218 root: registry.Group, 

1219 argv: list[str], 

1220 brand: Brand = DEFAULT_BRAND, 

1221 collect: list[executor.TaskResult] | None = None, 

1222) -> int: 

1223 """Drive an in-memory Group tree: globals, `--version`, manifest, run. 

1224 

1225 The in-memory sibling of `_run`, minus discovery/cascade/config and the 

1226 `-C`/`--install-completion` machinery those imply. No KeyboardInterrupt 

1227 wrapper (D13): a test runner must let Ctrl-C reach pytest. This is the 

1228 single shared surface `footman.testing.Runner` drives, so its Group mode 

1229 can never drift from the real CLI's help/version/list/tree/json behaviour. 

1230 """ 

1231 global _brand 

1232 _brand = brand 

1233 try: 

1234 pre_globals, _ = split._parse_globals(argv, 0) 

1235 except split.ChainError as exc: 

1236 return _refuse(_wants_json(argv), str(exc)) 

1237 g = _globals_to_dict(pre_globals) 

1238 _set_colors(_resolve_color(g)) 

1239 

1240 if g.get("version"): 

1241 return _print_version(bool(g.get("json"))) 

1242 

1243 tree = manifest.build_manifest(root)["tree"] 

1244 return _run_tree(root, tree, argv, {}, collect)