Coverage for src/footman/executor.py: 91%

272 statements  

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

1"""Bind resolved segments to their task functions and run them. 

2 

3The splitter validated the command line against the manifest and produced 

4string-valued `Segment` objects. Here — on the execution 

5path, with the user's module imported — we resolve each segment to its real 

6function, coerce the strings to the annotated types, and call it. 

7 

8Coercion covers what the manifest grammar promises: `int`/`float`, `Path`, 

9`Enum`/`Literal` choices, `list[...]` (repeatable), and `*args` variadic 

10(which also receives anything after `--`). A task "fails" if it raises or 

11returns a non-zero `int` exit code; failures stop the chain unless 

12`--keep-going` is set. 

13""" 

14 

15from __future__ import annotations 

16 

17import enum 

18import inspect 

19import io 

20import os 

21import time 

22from dataclasses import dataclass, field 

23from pathlib import Path, PurePath 

24from types import MappingProxyType 

25from typing import Any 

26 

27from footman import coerce, context, registry 

28from footman.context import ( 

29 Context, 

30 Failed, 

31 Result, 

32 RunFailed, 

33 _current, 

34 context_param_name, 

35) 

36from footman.discover import defining_dir 

37from footman.manifest import resolved_signature 

38from footman.registry import Group, Task 

39from footman.split import ChainError, Segment 

40 

41 

42@dataclass 

43class TaskResult: 

44 """Outcome of running one segment.""" 

45 

46 task: str 

47 ok: bool 

48 code: int = 0 

49 returned: Any = None 

50 error: BaseException | None = None 

51 duration: float = 0.0 

52 output: str = "" 

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

54 cancelled: bool = False # failed only because fail-fast killed it mid-run 

55 

56 

57def resolve(root: Group, path: list[str]) -> Task: 

58 """Walk *path* (`["docs", "build"]`) to its task function. 

59 

60 A path that lands on a runnable group (`["lint"]`) resolves to that group's 

61 default action — the function `@group.default` registered. 

62 """ 

63 node = root 

64 for name in path[:-1]: 

65 node = node.groups[name] 

66 last = path[-1] 

67 if last in node.tasks: 

68 return node.tasks[last] 

69 group = node.groups[last] 

70 if group.default_task is None: 70 ↛ 71line 70 didn't jump to line 71 because the condition on line 70 was never true

71 raise KeyError(last) # a non-runnable group is never a segment target 

72 return group.default_task 

73 

74 

75_MISSING = object() 

76 

77 

78def _wants_context(fn: Any) -> bool: 

79 """True when a validator accepts a second positional argument — the sibling 

80 parameters coerced so far. Decided by *inspecting* the signature, never by 

81 catching a `TypeError` from the call, so a real arity error raised inside the 

82 validator is not mistaken for the one-argument form.""" 

83 try: 

84 params = inspect.signature(fn).parameters.values() 

85 except (TypeError, ValueError): 

86 return False # a builtin/C callable with no signature — treat as one-arg 

87 positional = 0 

88 for p in params: 

89 if p.kind is p.VAR_POSITIONAL: 

90 return True 

91 if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD): 91 ↛ 88line 91 didn't jump to line 88 because the condition on line 91 was always true

92 positional += 1 

93 return positional >= 2 

94 

95 

96def _run_checks( 

97 value: Any, peeled: coerce.Peeled, label: str, params: dict[str, Any] | None = None 

98) -> Any: 

99 """Apply `check(fn)` validators to one coerced value (element-level). 

100 

101 A validator declaring a second argument receives the sibling parameters 

102 already coerced (those to its left in the signature), read-only — so it can 

103 validate against another input, e.g. a version against the current release of 

104 the package named in an earlier parameter.""" 

105 view: MappingProxyType[str, Any] | None = None 

106 for fn in peeled.checks: 

107 try: 

108 if _wants_context(fn): 

109 if view is None: 109 ↛ 111line 109 didn't jump to line 111 because the condition on line 109 was always true

110 view = MappingProxyType(dict(params) if params else {}) 

111 fn(value, view) 

112 else: 

113 fn(value) 

114 except ValueError as exc: 

115 raise ValueError(f"{label}: {exc}") from exc 

116 return value 

117 

118 

119def _validate_value(value: Any, peeled: coerce.Peeled, label: str) -> Any: 

120 """Validate a value the splitter never saw (env fallback, variadic / 

121 passthrough token) against the constraints it would have enforced eagerly 

122 for a CLI token (choices, bounds, path).""" 

123 choices = coerce.all_choices(peeled.element) 

124 if choices is not None: 124 ↛ 125line 124 didn't jump to line 125 because the condition on line 124 was never true

125 shown = str(value.value) if isinstance(value, enum.Enum) else str(value) 

126 tags = coerce.element_tags(peeled.element) 

127 # A mixed union (`Literal['a','b'] | int`) accepts a choice member OR a 

128 # value that coerces to one of its tags — reject only when neither fits. 

129 type_ok = bool(tags) and coerce.coerce_scalar(str(value), tags)[0] 

130 if shown not in choices and not type_ok: 

131 extra = f", or {coerce.type_phrase(tags)}" if tags else "" 

132 raise ValueError( 

133 f"{label} must be one of {'|'.join(choices)}{extra} (got {value!r})" 

134 ) 

135 if ( 

136 peeled.bounds is not None 

137 and isinstance(value, (int, float)) 

138 and not isinstance(value, bool) 

139 ): 

140 lo, hi = peeled.bounds 

141 # Negated form rejects NaN (compares False to everything), matching the 

142 # splitter's eager bounds check; identical to </> for real numbers. 

143 if (lo is not None and not (value >= lo)) or ( 

144 hi is not None and not (value <= hi) 

145 ): 

146 raise ValueError(f"{label} must be between {lo} and {hi} (got {value!r})") 

147 if peeled.path_req is not None and isinstance(value, PurePath): 147 ↛ 148line 147 didn't jump to line 148 because the condition on line 147 was never true

148 tests = {"exists": Path.exists, "file": Path.is_file, "dir": Path.is_dir} 

149 if not tests[peeled.path_req](Path(value)): 

150 raise ValueError(f"{label}: {value} does not satisfy {peeled.path_req}") 

151 return value 

152 

153 

154def _coerce_extra( 

155 token: str, peeled: coerce.Peeled, label: str, params: dict[str, Any] | None = None 

156) -> Any: 

157 """Coerce + validate one token the splitter never validated (an env 

158 fallback or a `--` passthrough value): strict coercion, then the same 

159 choices / bounds / path / check(fn) checks a CLI token gets.""" 

160 try: 

161 value = coerce.coerce_token(token, peeled.element) 

162 except ValueError as exc: 

163 raise ValueError(f"{label} {exc}") from exc 

164 return _run_checks(_validate_value(value, peeled, label), peeled, label, params) 

165 

166 

167def _env_value( 

168 param: inspect.Parameter, 

169 peeled: coerce.Peeled, 

170 params: dict[str, Any] | None = None, 

171) -> Any: 

172 """The env-fallback path for an absent option: CLI beats env beats default. 

173 

174 The env string flows through the same coercion, bounds, choices, and 

175 `check(fn)` validators a CLI token would — it just runs at binding time 

176 (the splitter never sees the environment). 

177 """ 

178 raw = os.environ.get(peeled.env) if peeled.env is not None else None 

179 if raw is None: 

180 return _MISSING 

181 label = f"--{param.name.replace('_', '-')} (from ${peeled.env})" 

182 

183 def one(token: str) -> Any: 

184 return _coerce_extra(token, peeled, label, params) 

185 

186 if peeled.multiple: 

187 parts = [raw] if peeled.nosplit else [p for p in raw.split(",") if p] or [raw] 

188 return [one(p) for p in parts] 

189 return one(raw) 

190 

191 

192def _prompt_param( 

193 cli: str, 

194 peeled: coerce.Peeled, 

195 ctx: Context | None, 

196 params: dict[str, Any] | None = None, 

197) -> Any: 

198 """Resolve a defaultless `ask()` parameter by prompting, coercing the answer 

199 through the same pipeline as a CLI token and re-asking on a bad value. Off a 

200 terminal or under `--no-input`/`--json` it raises instead — the value must 

201 then be supplied on the command line.""" 

202 marker = peeled.ask 

203 assert marker is not None # bind only calls this when ask() is present 

204 if (ctx is not None and ctx.no_input) or not context._stdin_is_tty(): 

205 raise ValueError( 

206 f"--{cli} is required and nothing supplied it — pass --{cli} " 

207 f"(a terminal is needed to prompt; --no-input and --json never ask)." 

208 ) 

209 choices = coerce.all_choices(peeled.element) 

210 hint = f" ({'/'.join(choices)})" if choices else "" 

211 text = marker.prompt or f"{cli}{hint}: " 

212 while True: 

213 raw = context._prompt_core(text, secret=marker.secret) 

214 if choices is not None and raw not in choices: 

215 out = context.real_stderr() 

216 out.write(f" choose one of {', '.join(choices)}\n") 

217 out.flush() 

218 continue 

219 try: 

220 value = coerce.coerce_token(raw, peeled.element) 

221 return _run_checks(value, peeled, f"--{cli}", params) 

222 except ValueError as exc: 

223 out = context.real_stderr() 

224 out.write(f" {exc}\n") 

225 out.flush() 

226 

227 

228def _left_siblings( 

229 sig: inspect.Signature, current: inspect.Parameter, kwargs: dict[str, Any] 

230) -> dict[str, Any]: 

231 """The effective values of the parameters to *current*'s left — a provided 

232 value where one was resolved, else the parameter's own default — so a 

233 contextual `check` reads what the body will actually receive, never a copy of 

234 the default that can drift out of sync.""" 

235 view: dict[str, Any] = {} 

236 for p in sig.parameters.values(): 236 ↛ 243line 236 didn't jump to line 243 because the loop on line 236 didn't complete

237 if p.name == current.name: 

238 break 

239 if p.name in kwargs: 

240 view[p.name] = kwargs[p.name] 

241 elif p.default is not inspect.Parameter.empty: 

242 view[p.name] = p.default 

243 return view 

244 

245 

246def bind( 

247 seg: Segment, 

248 fn: Task, 

249 ctx: Context | None = None, 

250 forwarded: dict[str, Any] | None = None, 

251) -> tuple[list[Any], dict[str, Any]]: 

252 """Turn a segment's string values into `(*args, **kwargs)` for *fn*. 

253 

254 Coercion (union member selection, list handling, one-or-many collapse) goes 

255 through `footman.coerce`, the same module the manifest and splitter use. 

256 `check(fn)` validators run here on the coerced values, and absent options 

257 fall back to their `env()` variable before their default. 

258 

259 *forwarded* carries values a dispatching task passed down via the `forward` 

260 marker. Precedence is CLI value > forwarded > env > default: a forwarded 

261 value overrides only a parameter that *has* a default (it never rescues a 

262 required one — a prerequisite must still be independently runnable). 

263 """ 

264 sig = resolved_signature(fn) 

265 empty = inspect.Parameter.empty 

266 var_args: list[Any] = [] 

267 kwargs: dict[str, Any] = {} 

268 

269 for param in sig.parameters.values(): 

270 # The parameters bound to this one's left, at their effective values, 

271 # for a contextual check(fn, params). 

272 siblings = _left_siblings(sig, param, kwargs) 

273 if param.kind is inspect.Parameter.VAR_POSITIONAL: 

274 extra = [*seg.variadic, *(seg.passthrough or [])] 

275 if param.annotation is empty: 275 ↛ 276line 275 didn't jump to line 276 because the condition on line 275 was never true

276 var_args = list(extra) 

277 else: 

278 peeled = coerce.peel(param.annotation) 

279 label = f"<{param.name}>" 

280 var_args = [_coerce_extra(v, peeled, label, siblings) for v in extra] 

281 continue 

282 

283 cli = registry.cli_name(param.name) 

284 if cli not in seg.values: 

285 # A forwarded value overrides a defaulted parameter (never a 

286 # required one — the guard on `param.default`), ahead of env/default. 

287 if ( 

288 forwarded is not None 

289 and param.name in forwarded 

290 and param.default is not empty 

291 ): 

292 kwargs[param.name] = forwarded[param.name] 

293 continue 

294 if param.annotation is not empty: 

295 peeled = coerce.peel(param.annotation) 

296 if peeled.env is not None: 

297 value = _env_value(param, peeled, siblings) 

298 if value is not _MISSING: 

299 kwargs[param.name] = value 

300 continue 

301 # ask(): prompt for a required (defaultless) param the CLI and 

302 # env didn't fill — CLI > env > default > prompt, so a default 

303 # short-circuits it and the prompt is the last resort. 

304 if peeled.ask is not None and param.default is empty: 

305 kwargs[param.name] = _prompt_param(cli, peeled, ctx, siblings) 

306 continue 

307 raw = seg.values[cli] 

308 if isinstance(raw, bool): # a flag, already resolved by the splitter 

309 kwargs[param.name] = raw 

310 continue 

311 if param.annotation is empty: 311 ↛ 312line 311 didn't jump to line 312 because the condition on line 311 was never true

312 kwargs[param.name] = raw 

313 continue 

314 

315 peeled = coerce.peel(param.annotation) 

316 label = f"--{cli}" 

317 if peeled.mapping: 

318 result: dict[Any, Any] = {} 

319 for key, value in raw: 

320 k = coerce.coerce_one(key, peeled.key) 

321 v = _run_checks( 

322 coerce.coerce_one(value, peeled.element), peeled, label, siblings 

323 ) 

324 if peeled.value_multiple: 

325 result.setdefault(k, []).append(v) 

326 else: 

327 result[k] = v 

328 kwargs[param.name] = result 

329 elif peeled.multiple: 

330 items = raw if isinstance(raw, list) else [raw] 

331 kwargs[param.name] = [ 

332 _run_checks( 

333 coerce.coerce_one(v, peeled.element), peeled, label, siblings 

334 ) 

335 for v in items 

336 ] 

337 else: 

338 kwargs[param.name] = _run_checks( 

339 coerce.coerce_one(raw, peeled.element), peeled, label, siblings 

340 ) 

341 

342 # Positional-only params (`def build(target, /)`) cannot be passed by 

343 # keyword, so move the leading run of them out of kwargs into positional 

344 # args, in signature order. A defaultless one is splitter-enforced present, 

345 # so a `hole` (a skipped optional) is only ever filled by an existing 

346 # default and never leaves a gap before a supplied later param. 

347 pos: list[Any] = [] 

348 hole: list[Any] = [] 

349 ctx_name = context_param_name(sig) 

350 for param in sig.parameters.values(): 

351 if param.kind is not inspect.Parameter.POSITIONAL_ONLY: 

352 break # positional-only params always lead the signature 

353 if param.name == ctx_name: 353 ↛ 354line 353 didn't jump to line 354 because the condition on line 353 was never true

354 continue # run_task injects ctx as the first positional itself 

355 if param.name in kwargs: 

356 pos += hole 

357 hole = [] 

358 pos.append(kwargs.pop(param.name)) 

359 elif param.default is not empty: 359 ↛ 350line 359 didn't jump to line 350 because the condition on line 359 was always true

360 hole.append(param.default) 

361 

362 # `--` passthrough always has a home now: a task's *args, and/or the run 

363 # context (`passthrough()` / `ctx.passthrough`). So it is never an error. 

364 return [*pos, *var_args], kwargs 

365 

366 

367def forward_map( 

368 fn: Task, seg: Segment, received: dict[str, Any] | None = None 

369) -> dict[str, Any]: 

370 """The `forward`-marked parameter values *fn* passes to what it dispatches. 

371 

372 Read from the segment's CLI value or the parameter's default — never by 

373 prompting, so building the map is side-effect free. Only defaulted 

374 parameters contribute; a required one is never forwarded (matching `bind`). 

375 

376 A value *fn* itself *received* via forwarding wins over its segment/default, 

377 so a forwarded value chains through a callee that re-declares the marker. 

378 """ 

379 sig = resolved_signature(fn) 

380 empty = inspect.Parameter.empty 

381 out: dict[str, Any] = {} 

382 for param in sig.parameters.values(): 

383 if param.annotation is empty or param.default is empty: 

384 continue 

385 peeled = coerce.peel(param.annotation) 

386 if not peeled.forward: 

387 continue 

388 if received is not None and param.name in received: 

389 out[param.name] = received[param.name] 

390 continue 

391 cli = registry.cli_name(param.name) 

392 if cli not in seg.values: 

393 out[param.name] = param.default 

394 continue 

395 raw = seg.values[cli] 

396 if isinstance(raw, bool): 396 ↛ 398line 396 didn't jump to line 398 because the condition on line 396 was always true

397 out[param.name] = raw 

398 elif peeled.multiple: 

399 items = raw if isinstance(raw, list) else [raw] 

400 out[param.name] = [coerce.coerce_one(v, peeled.element) for v in items] 

401 else: 

402 out[param.name] = coerce.coerce_one(raw, peeled.element) 

403 return out 

404 

405 

406def _call( 

407 fn: Task, args: list[Any], kwargs: dict[str, Any] 

408) -> tuple[int, Any, BaseException | None]: 

409 try: 

410 returned = fn(*args, **kwargs) 

411 except SystemExit as exc: 

412 # A non-int, non-None code is Python's `sys.exit("message")` idiom: the 

413 # object is the reason the interpreter would print to stderr. Carry it as 

414 # the failure error so it renders (stderr + --json) like any other failure, 

415 # instead of vanishing into a bare "exited with code 1". An int/None code 

416 # (the "fail with code N" idiom) has no message to surface. 

417 has_reason = not isinstance(exc.code, int) and exc.code is not None 

418 code = 1 if has_reason else (exc.code if isinstance(exc.code, int) else 0) 

419 return code, None, (exc if has_reason else None) 

420 except Failed as exc: 

421 # `footman.fail("reason", code=…)`: a deliberate stop. Honour its code and 

422 # carry the reason as the error, rendered verbatim (see _app / context). 

423 return exc.code, None, exc 

424 except RunFailed as exc: 

425 # A `run()` command failed: propagate its own exit code, not a flat 1, 

426 # so `fm` mirrors the command's code (docs/ci.md's "exited N" contract). 

427 return (exc.result.code or 1), None, exc 

428 except Exception as exc: # a failed task must not crash the runner 

429 return 1, None, exc 

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

431 return returned, returned, None 

432 return 0, returned, None 

433 

434 

435class Unavailable(Exception): 

436 """A `@requires`-gated task was asked to run; the message is the reason.""" 

437 

438 

439def run_task( 

440 fn: Task, seg: Segment, ctx: Context, forwarded: dict[str, Any] | None = None 

441) -> TaskResult: 

442 """Bind *seg* to *fn* and run it within *ctx* (contextvar set for run()). 

443 

444 `ctx` is injected as the first argument if the task declares a `ctx` 

445 parameter. Output routing (per-task buffering for parallel/`--json`) is the 

446 caller's job via `ctx.sink`; here we just capture its final value. 

447 *forwarded* carries `forward`-marked values from a dispatching task. 

448 """ 

449 # `@requires` availability is re-checked live at the moment of execution — 

450 # the manifest's cached answer is only ever a listing annotation. 

451 if (reason := registry.availability(fn)) is not None: 

452 return TaskResult(task=seg.task, ok=False, code=2, error=Unavailable(reason)) 

453 try: 

454 args, kwargs = bind(seg, fn, ctx, forwarded) 

455 except ChainError: 

456 raise # e.g. passthrough with no *args — reported by the app layer 

457 except Exception as exc: # a coercion failure (e.g. a custom-type constructor) 

458 return _result(seg, 2, None, exc, 0.0) 

459 

460 if context_param_name(resolved_signature(fn)): 

461 args = [ctx, *args] # ctx is the first positional parameter 

462 

463 ctx.fn = fn # what inherited() reads to find the shadowed task 

464 ctx.interactive = registry.is_interactive(fn) # arms the prompt guard 

465 ctx.atomic = registry.is_atomic(fn) # its subprocesses opt out of the kill 

466 if ctx.cwd is None and (home := defining_dir(fn)) is not None: 

467 ctx.cwd = Path(home) # run from the folder that defined the task 

468 

469 token = _current.set(ctx) 

470 ctx.in_task = True # a mid-body prompt()/confirm()/select() is now guarded 

471 start = time.perf_counter() 

472 try: 

473 code, returned, error = _call(fn, args, kwargs) 

474 finally: 

475 _current.reset(token) 

476 duration = time.perf_counter() - start 

477 output = ctx.sink.getvalue() if isinstance(ctx.sink, io.StringIO) else "" 

478 result = _result(seg, code, returned, error, duration, output, ctx.steps) 

479 # A task that failed while fail-fast was already aborting the run wasn't a 

480 # genuine failure — it was cut off. Report that honestly, not as "failed". 

481 if not result.ok and context._aborting.is_set(): 

482 result.cancelled = True 

483 return result 

484 

485 

486def _result( 

487 seg: Segment, 

488 code: int, 

489 returned: Any, 

490 error: BaseException | None, 

491 duration: float, 

492 output: str = "", 

493 steps: list[Result] | None = None, 

494) -> TaskResult: 

495 return TaskResult( 

496 task=seg.task, 

497 ok=error is None and code == 0, 

498 # Honor an explicit non-zero code (run_task passes 2 for bind/coercion 

499 # refusals); only synthesize 1 when an error carries no code of its own. 

500 code=code if code != 0 else (1 if error is not None else 0), 

501 returned=returned, 

502 error=error, 

503 duration=duration, 

504 output=output, 

505 steps=steps or [], 

506 ) 

507 

508 

509def run_chain( 

510 root: Group, 

511 segments: list[Segment], 

512 *, 

513 keep_going: bool = False, 

514 capture: bool = False, 

515 ctx_config: dict[str, Any] | None = None, 

516) -> list[TaskResult]: 

517 """Run a chain sequentially (a thin shim over the DAG scheduler).""" 

518 from footman import schedule 

519 

520 return schedule.run_plan( 

521 root, 

522 segments, 

523 sequential=True, 

524 keep_going=keep_going, 

525 capture=capture, 

526 ctx_config=ctx_config, 

527 )