Coverage for src/footman/_executor.py: 93%

1197 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-06 02:59 +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 contextlib 

18import contextvars 

19import enum 

20import inspect 

21import io 

22import json 

23import os 

24import threading 

25import time 

26import types as _types 

27from collections.abc import Generator, Iterator, Mapping, Sequence 

28from dataclasses import dataclass, field 

29from pathlib import Path, PurePath 

30from types import MappingProxyType, SimpleNamespace 

31from typing import Any 

32 

33from footman import ( 

34 _binder, 

35 _coerce, 

36 _futures, 

37 _globals, 

38 _notes, 

39 _signals, 

40 context, 

41 registry, 

42) 

43from footman._discover import defining_dir 

44from footman._manifest import resolved_signature 

45from footman._split import ChainError, Segment 

46from footman.context import ( 

47 Context, 

48 Failed, 

49 Result, 

50 RunFailed, 

51 _current, 

52 context_param_name, 

53 coroutine_refusal, 

54 generator_refusal, 

55) 

56from footman.params import Secret 

57from footman.registry import Group, Task 

58 

59EX_USAGE = 64 

60"""The refusal exit code — footman did not understand the command line. 

61 

62`EX_USAGE` from BSD's `sysexits.h`: an unknown task, an unknown flag, a 

63malformed chain, a value that will not coerce, an unavailable task. Distinct 

64from anything a task says on purpose, so a caller can tell a broken 

65invocation from a real verdict; the low codes (1, 2, …) belong to tasks and 

66their subprocesses. Interrupt stays 130.""" 

67 

68 

69@dataclass 

70class TaskResult: 

71 """Outcome of running one segment.""" 

72 

73 task: str 

74 ok: bool 

75 code: int = 0 

76 returned: Any = None 

77 error: BaseException | None = None 

78 duration: float = 0.0 

79 output: str = "" 

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

81 notes: list[Any] = field(default_factory=list) 

82 """The notes this execution fired (`_notes.Note` records: kind, level, 

83 site, text) — every level, print-gated or not. Empty for a row that 

84 executed nothing (a `shared` row, a refusal).""" 

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

86 timed_out: bool = False 

87 """`@task(timeout=…)` expired before this task concluded. A verdict about 

88 the *deadline*, never about how much work happened — read `stopped` for 

89 that.""" 

90 after_deadline: str = "" 

91 """What became of the work once the deadline fired. Empty when no 

92 deadline fired. 

93 

94 The discriminator is not *how the body ended* but **whether footman 

95 issued a stop before the body returned** — which is fully observable 

96 from here, where "did it really stop?" is not: 

97 

98 - `stopped` — footman issued a stop. Includes a body that caught the 

99 interruption and returned normally: what is recorded is that footman 

100 asked. 

101 - `completed` — no stop was issued and the deadline had already passed. 

102 The body finished on its own, just late. There IS an outcome; the 

103 deadline governs anyway. 

104 

105 **An open set** — the `state` convention, "tolerate values you don't 

106 know". Two more values are designed and deliberately NOT shipped, 

107 because each needs a mechanism that does not exist yet and a value 

108 nothing can emit is a claim the system can never make: 

109 

110 - `escaped` (footman could not stop it; it may still be running) needs 

111 an observer running *concurrently with* the body — a watchdog, a 

112 supervisor, or teardown noticing an unjoined worker. This judgment 

113 happens after the body returns, so a body that never returns never 

114 reaches it: there is no receipt at all, and the run hangs. 

115 - `unknown` arrives with calls that cross a process or machine 

116 boundary, where a deadline expiring says nothing about the far side. 

117 

118 (notes/20260807-timeout-and-retry.md)""" 

119 started: float | None = None 

120 """When this task began, on the run's monotonic clock — the ordering key of 

121 the report. `None` for something that never began (an unavailable task, a 

122 denied confirm), which is why the report places those by cause instead.""" 

123 state: str = "" 

124 """What happened, when `ok`/`code` do not say it: `"shared"` for a request 

125 an execution of this run had already satisfied. Empty means "read it from 

126 `ok`" — `reported_state` is the one place that resolves the parts into a 

127 single word, so a new outcome is a new value here rather than another 

128 boolean beside it. A cross-run cache (a plugin's business) would add 

129 `"cached"`; the two axes keep one word each.""" 

130 blocked_by: str = "" 

131 """The task whose outcome meant this one never ran, when there was one. The 

132 report reads as cause then consequence: a non-run sits directly after 

133 whatever prevented it.""" 

134 thread: str = "" 

135 """The worker the execution ran on — the pool thread's own stable name 

136 (`fm-worker_2`), not the task-shaped name it wore while running. Empty for 

137 a row that executed nothing (a `shared` row, a refusal).""" 

138 thread_id: int = 0 

139 """The OS thread id (`threading.get_native_id`) of that worker, the key a 

140 profiler's timeline uses. `0` when nothing executed.""" 

141 address: str = "" 

142 """The row's tree-derived name — the path of requests that led to it, 

143 with an ordinal once a label repeats among siblings. Deterministic in 

144 request order as written; empty outside a managed run.""" 

145 eligible: float | None = None 

146 """When this node could first have started — its last prerequisite's 

147 finish, on the run's monotonic clock. `started - eligible` is launch 

148 latency: time spent waiting for a free worker, never attributed to the 

149 task's own `duration`. `None` for a node with no prerequisites, and for 

150 anything that never ran.""" 

151 title: str = "" 

152 """A reviewer's label for the row, when one set it (`@pre_record`). 

153 Empty means the task's name speaks for itself, as it always did.""" 

154 seq: int | None = None 

155 """The request-order stamp: when this row's request was made, counted 

156 run-wide — plan order for scheduled segments, the written line for a 

157 `parallel()` block's queued calls, the call moment for body calls. The 

158 report's tie-break for starts landing inside the same instant; `None` 

159 for a row minted outside the request pipeline.""" 

160 lane_waits: list[tuple[str, float]] = field(default_factory=list) 

161 """Lanes this task waited for before its body ran — `(lane, seconds)` 

162 rows, recorded only when the claim actually waited. The label is the 

163 claim's own (a named lane, `serial`, `exclusive`, `console`), so the 

164 report answers "what serialised this task, for how long" without 

165 re-running with eyes on the terminal. Never part of `duration` — the 

166 body had not started.""" 

167 body_returned: Any = None 

168 """What the body actually returned — the value dependents and body 

169 callers receive, snapshotted at the body's exit. `returned` is its 

170 *reported* twin: a reviewer's `set_returned` rewrites that one only.""" 

171 audit: list[context.AuditEntry] = field(default_factory=list) 

172 """The verdict's provenance, when the row was reviewed: the body entry 

173 with what the task itself produced, then a review entry per reviewer, in 

174 execution order. Empty for an unreviewed row — the verdict is the 

175 body's, no one touched it.""" 

176 sections: list[context.Section] = field(default_factory=list) 

177 """Task-authored profiling: the sections, streams and marks the body 

178 recorded while it ran, on the run's clock. Empty for a task that 

179 recorded nothing — most of them.""" 

180 after: tuple[str, ...] = () 

181 """The rows this one waited for, by address — the plan's edges into this 

182 node, stamped by the scheduler. What a profile draws dependency arrows 

183 from. Empty for a root, and for a row outside a managed run.""" 

184 

185 @property 

186 def failed_at(self) -> str | None: 

187 """The lifecycle moment the failure came from, None on success — the 

188 same derived reading a step's `Result` offers.""" 

189 return context._failed_moment(self.code, self.audit) 

190 

191 @property 

192 def work_code(self) -> int | None: 

193 """The code the row carried when the failing moment began — a green 

194 body failed by its reviewer keeps its 0 here.""" 

195 return context._earned_code(self.code, self.audit) 

196 

197 

198def reported_state(result: TaskResult) -> str: 

199 """The one word for what happened, resolved from the parts. 

200 

201 `ok` and `code` stay the exit-code channel; this is the *reported* 

202 spelling, so a new outcome (skipped, unavailable, a cross-run cache hit) 

203 becomes another value here instead of another boolean on the result. 

204 """ 

205 if result.state: 

206 return result.state 

207 if result.cancelled: 

208 return "cancelled" 

209 return "ok" if result.ok else "failed" 

210 

211 

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

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

214 

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

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

217 """ 

218 node = root 

219 for name in path[:-1]: 

220 node = node.groups[name] 

221 last = path[-1] 

222 if last in node.tasks: 

223 return node.tasks[last] 

224 group = node.groups[last] 

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

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

227 return group.default_task 

228 

229 

230_MISSING = object() 

231 

232 

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

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

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

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

237 validator is not mistaken for the one-argument form. 

238 

239 (`default(fn)` asks the same question of itself at declaration time — see 

240 `params.default.reads_siblings` — because the manifest needs the answer and 

241 cannot import this module.)""" 

242 try: 

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

244 except (TypeError, ValueError): 

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

246 positional = 0 

247 for p in params: 

248 if p.kind is p.VAR_POSITIONAL: 

249 return True 

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

251 positional += 1 

252 return positional >= 2 

253 

254 

255def _computed_default(marker: Any, params: Mapping[str, Any] | None = None) -> Any: 

256 """Call a `default(fn)`, handing it the siblings when it asks for them. 

257 

258 The same courtesy `check(fn)` gets, for the same reason: a default is often 

259 a function of the inputs beside it — a window title from the command being 

260 shown, a report name from the target it describes. Read-only, and only what 

261 is to its *left*, so a default can never depend on something not resolved 

262 yet. 

263 """ 

264 if marker.reads_siblings: 

265 return marker.fn(params if params is not None else _EMPTY_VIEW) 

266 return marker.fn() 

267 

268 

269def _run_checks( 

270 value: Any, 

271 peeled: _coerce.Peeled, 

272 label: str, 

273 params: Mapping[str, Any] | None = None, 

274) -> Any: 

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

276 

277 A validator declaring a second argument receives the sibling parameters 

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

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

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

281 for fn in peeled.checks: 

282 try: 

283 if _wants_context(fn): 

284 fn(value, params if params is not None else _EMPTY_VIEW) 

285 else: 

286 fn(value) 

287 except ValueError as exc: 

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

289 return value 

290 

291 

292def _validate_value(value: Any, peeled: _coerce.Peeled, label: str) -> Any: 

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

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

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

296 choices = _coerce.all_choices(peeled.element) 

297 if choices is not None: 

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

299 tags = _coerce.element_tags(peeled.element) 

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

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

302 type_ok = bool(tags) and _coerce.coerce_scalar(str(value), tags)[0] 

303 if shown not in choices and not type_ok: 

304 extra = f", or {_coerce.type_phrase(tags)}" if tags else "" 

305 raise ValueError( 

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

307 ) 

308 if ( 

309 peeled.bounds is not None 

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

311 and not isinstance(value, bool) 

312 ): 

313 lo, hi = peeled.bounds 

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

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

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

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

318 ): 

319 expect = ( 

320 f"at least {lo}" 

321 if hi is None 

322 else f"at most {hi}" 

323 if lo is None 

324 else f"between {lo} and {hi}" 

325 ) 

326 raise ValueError(f"{label} must be {expect} (got {value!r})") 

327 if peeled.path_req is not None and isinstance(value, PurePath): 

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

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

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

331 return value 

332 

333 

334def _coerce_extra( 

335 token: str, 

336 peeled: _coerce.Peeled, 

337 label: str, 

338 params: Mapping[str, Any] | None = None, 

339) -> Any: 

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

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

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

343 try: 

344 value = _coerce.coerce_token(token, peeled.element) 

345 except ValueError as exc: 

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

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

348 

349 

350def _env_value( 

351 param: inspect.Parameter, 

352 peeled: _coerce.Peeled, 

353 params: Mapping[str, Any] | None = None, 

354) -> Any: 

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

356 

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

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

359 (the splitter never sees the environment). 

360 """ 

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

362 if raw is None: 

363 return _MISSING 

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

365 

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

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

368 

369 if peeled.multiple: 

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

371 return _container(peeled, [one(p) for p in parts], label) 

372 return one(raw) 

373 

374 

375def _bind_group( 

376 group: _coerce.Group, 

377 parts: list[str], 

378 many: bool, 

379 label: str, 

380) -> Any: 

381 """Group a flat stream by a declared arity and build each group. 

382 

383 Commas and repetition both feed one stream; the arity says where each 

384 group ends. Nothing is inferred — the arity is declared, so a leftover 

385 is a refusal rather than a rounding. 

386 """ 

387 size = group.total 

388 if many: 

389 if len(parts) % size: 389 ↛ 390line 389 didn't jump to line 390 because the condition on line 389 was never true

390 raise ValueError( 

391 f"{label} takes values in groups of {size} ({group.label()}) " 

392 f"— got {len(parts)}, which leaves {len(parts) % size} over" 

393 ) 

394 chunks = [parts[i : i + size] for i in range(0, len(parts), size)] 

395 else: 

396 # One group, so the count settles a variable arity on its own: any 

397 # length between required and total is unambiguous here, which is 

398 # why optional constructor parameters are allowed bare and refused 

399 # inside a container. 

400 if not group.required <= len(parts) <= size: 400 ↛ 401line 400 didn't jump to line 401 because the condition on line 400 was never true

401 want = ( 

402 f"{group.label()}" 

403 if group.required == size 

404 else f"{group.required} to {size} values ({group.label()})" 

405 ) 

406 raise ValueError(f"{label} takes {want} — got {len(parts)}") 

407 chunks = [parts] 

408 

409 built = [] 

410 for chunk in chunks: 

411 values = [] 

412 for index, token in enumerate(chunk): 

413 slot = group.names[index] if group.names else f"value {index + 1}" 

414 try: 

415 values.append(_coerce.coerce_checked(token, group.types[index])) 

416 except ValueError as exc: 

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

418 built.append(group.build(values)) 

419 return built if many else built[0] 

420 

421 

422def _container(peeled: _coerce.Peeled, values: list[Any], label: str = "") -> Any: 

423 """The collection the annotation named. Every collection shares a list's 

424 grammar and differs only here — handing back a list would give the body a 

425 container its annotation does not name. 

426 

427 A set of an unhashable element is the one way this can fail, and it fails 

428 at the annotation rather than at the value, so it is taught rather than 

429 raised as a bare `TypeError` from deep inside binding.""" 

430 if peeled.container is list: 

431 return values 

432 try: 

433 return peeled.container(values) 

434 except TypeError as exc: # set[T] where T does not hash 

435 name = getattr(peeled.element, "__name__", peeled.element) 

436 raise ValueError( 

437 f"{label} cannot be a {peeled.container.__name__} of {name}: " 

438 f"{name} is not hashable" 

439 ) from exc 

440 

441 

442def _decode_stdin(payload: bytes) -> str: 

443 """stdin bytes as text: UTF-8, universal newlines. A stream that is not 

444 UTF-8 is a taught refusal — bind it as `bytes` instead of guessing.""" 

445 try: 

446 text = payload.decode("utf-8") 

447 except UnicodeDecodeError as exc: 

448 raise ValueError( 

449 f"stdin is not valid UTF-8 ({exc}) — bind the parameter as " 

450 f"`bytes` to read the raw stream" 

451 ) from exc 

452 return text.replace("\r\n", "\n").replace("\r", "\n") 

453 

454 

455def _stdin_json(payload: bytes) -> Any: 

456 """The JSON value on stdin — any shape; the caller owns the fit.""" 

457 if not payload.strip(): 

458 raise ValueError("stdin was empty; expected a JSON document") 

459 try: 

460 return json.loads(_decode_stdin(payload)) 

461 except json.JSONDecodeError as exc: 

462 raise ValueError(f"stdin is not JSON: {exc}") from exc 

463 

464 

465def _stdin_document(payload: bytes) -> dict[str, Any]: 

466 """The JSON object on stdin, for a `stdin(\"field\")` parameter.""" 

467 document = _stdin_json(payload) 

468 if not isinstance(document, dict): 

469 raise ValueError( 

470 f"stdin holds a JSON {type(document).__name__}, not an object — " 

471 f"a stdin(field) parameter reads one top-level key" 

472 ) 

473 return document 

474 

475 

476def _stdin_value( 

477 param: inspect.Parameter, 

478 peeled: _coerce.Peeled, 

479 params: Mapping[str, Any] | None = None, 

480) -> Any: 

481 """The stdin path for an absent option: CLI beats stdin beats env. 

482 

483 The boundary read happens here (once per process, shared — see 

484 `context.stdin_payload`), and the value flows through the same coercion 

485 and validation a CLI token gets. `_MISSING` means stdin did not supply 

486 it: no pipe, or a JSON document without the named field — the parameter 

487 then falls to env, default, or the required-and-absent refusal. 

488 """ 

489 marker = peeled.stdin 

490 assert marker is not None # bind only calls this when stdin is present 

491 payload = context.stdin_payload() 

492 if payload is None: 

493 return _MISSING 

494 label = f"--{param.name.replace('_', '-')} (from stdin)" 

495 

496 if marker.field is not None: 

497 value = _stdin_document(payload).get(marker.field, _MISSING) 

498 if value is _MISSING or value is None: # absent and null both fall 

499 return _MISSING 

500 if isinstance(value, (dict, list)): 

501 raise ValueError( 

502 f"{label}: the {marker.field!r} field is a JSON " 

503 f"{type(value).__name__}, not a single value" 

504 ) 

505 token = value if isinstance(value, str) else str(value) 

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

507 

508 if marker.lines: 

509 lines = _decode_stdin(payload).splitlines() 

510 return [_coerce_extra(line, peeled, label, params) for line in lines] 

511 

512 if peeled.element is bytes: 

513 return _run_checks(payload, peeled, label, params) 

514 

515 target = _document_shape(peeled) 

516 if target is not None: 

517 bound = _binder.bind_document(_stdin_json(payload), target, param.name) 

518 

519 def checked(value: Any) -> Any: 

520 # The binder gives shape, not validation, so the markers have to 

521 # run here as well: a piped `[1, 9]` used to walk past the 

522 # `between(1, 5)` that refuses `--sizes=9` on the command line. 

523 # Same pipeline as `_coerce_extra`, minus the token to coerce. 

524 return _run_checks( 

525 _validate_value(value, peeled, label), peeled, label, params 

526 ) 

527 

528 if isinstance(bound, list): 

529 return _container(peeled, [checked(v) for v in bound], label) 

530 if isinstance(bound, dict): 

531 return {k: checked(v) for k, v in bound.items()} 

532 return checked(bound) 

533 

534 if (group := _coerce.group_of(peeled.element)) is not None: 

535 # A fixed-arity shape with no field names — a plain `tuple[X, Y]`. A 

536 # JSON array is its grouped stream in another dress, so `[1, 2]` fills 

537 # it exactly as `--v=1,2` does. A *named* record never reaches here: 

538 # `is_document_target` claims it above and binds it from an object, 

539 # which is the spelling its field names earn it. 

540 raw = _stdin_json(payload) 

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

542 # JSON's own scalars are rendered back to the text the command line 

543 # would have delivered, so both channels share one grouping and one 

544 # set of messages. It also keeps the two agreeing about `[1, 2]` for 

545 # a `tuple[str, str]`: `--v=1,2` binds `("1", "2")`, so this does. 

546 bound = _bind_group( 

547 group, 

548 [i if isinstance(i, str) else json.dumps(i) for i in items], 

549 peeled.multiple, 

550 label, 

551 ) 

552 return _run_checks(bound, peeled, label, params) 

553 

554 # The scalar fall-through. It used to validate without coercing, so 

555 # `Stdin[Colour]` refused 'red\n' but handed the body the *string* 

556 # 'red' when it passed — half the contract enforced, half dropped. 

557 # Coercing here is what the `marker.field` branch above already does. 

558 text = _decode_stdin(payload) 

559 if peeled.element in (str, Any) or peeled.element is None: 

560 return _run_checks(_validate_value(text, peeled, label), peeled, label, params) 

561 # One trailing newline is the shell's, not the value's: `echo 42 |` is 

562 # the ordinary way to pipe a value, and keeping the newline made every 

563 # validated scalar fail on it. 

564 return _coerce_extra(text.removesuffix("\n"), peeled, label, params) 

565 

566 

567def _document_shape(peeled: _coerce.Peeled) -> Any: 

568 """The JSON shape a bare `stdin` parameter binds to, rebuilt from its 

569 peeled form: the dataclass itself, `list[T]` for a list parameter, 

570 `dict[K, V]` for a mapping. `None` for the text/bytes forms.""" 

571 if peeled.mapping: 

572 value_t: Any = ( 

573 _types.GenericAlias(list, (peeled.element,)) 

574 if peeled.value_multiple 

575 else peeled.element 

576 ) 

577 return _types.GenericAlias(dict, (peeled.key, value_t)) 

578 if peeled.multiple: 

579 return _types.GenericAlias(list, (peeled.element,)) 

580 if _binder.is_document_target(peeled.element): 

581 return peeled.element 

582 return None 

583 

584 

585def _stdin_fillable(peeled: _coerce.Peeled) -> bool: 

586 """Whether the boundary's stdin would fill this parameter — the ask() 

587 front-loader skips a question stdin already answers.""" 

588 payload = context.stdin_payload() 

589 if payload is None: 589 ↛ 590line 589 didn't jump to line 590 because the condition on line 589 was never true

590 return False 

591 if peeled.stdin is not None and peeled.stdin.field is not None: 591 ↛ 597line 591 didn't jump to line 597 because the condition on line 591 was always true

592 try: 

593 value = _stdin_document(payload).get(peeled.stdin.field) 

594 except ValueError: 

595 return False 

596 return value is not None 

597 return True 

598 

599 

600def _prompt_param( 

601 cli: str, 

602 peeled: _coerce.Peeled, 

603 ctx: Context | None, 

604 params: Mapping[str, Any] | None = None, 

605 default: Any = _MISSING, 

606) -> tuple[str, Any]: 

607 """Resolve an `ask()` parameter by prompting, coercing the answer through 

608 the same pipeline as a CLI token and re-asking on a bad value. 

609 

610 *default* is the parameter's declared default, or `_MISSING` when it has 

611 none. With one, the prompt offers it and Enter accepts — and where nobody 

612 can be asked (off a terminal, `--no-input`, `--json`) it is simply used. So 

613 `ask()` is safe on any parameter: a person gets asked, an unattended run 

614 gets the default. Without one there is no other answer, so those cases still 

615 raise. 

616 

617 A secret never shows its default — that would defeat the point — though 

618 Enter still accepts it. 

619 

620 Returns `(raw, value)`: the accepted token and its coerced value — bind uses 

621 the value, the ask front-loader records the raw token so binding re-runs the 

622 one pipeline.""" 

623 marker = peeled.ask 

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

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

626 if default is not _MISSING: 

627 return "", default 

628 raise ValueError( 

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

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

631 ) 

632 

633 def note(text: str) -> None: 

634 out = context.real_stderr() 

635 out.write(context._scrub(text) + "\n") # echoes reflect typed input 

636 out.flush() 

637 

638 # A *strict* live completer is a menu: its choices are law, so show them 

639 # as numbers rather than free text — and `Many[...]` makes it a 

640 # multi-select. (Secrets never menu; best-effort completers only hint.) 

641 if peeled.completer is not None and peeled.completer.strict and not marker.secret: 

642 from footman._manifest import _run_completer 

643 

644 options = _run_completer(peeled.completer, {}) 

645 if options: 645 ↛ 678line 645 didn't jump to line 678 because the condition on line 645 was always true

646 label = marker.prompt or f"{cli}:" 

647 while True: 

648 try: 

649 # The unguarded core: this is the framework's own prompt, 

650 # and bind now runs inside the managed window. 

651 chosen = context._select_core( 

652 label, 

653 options, 

654 multiple=peeled.multiple, 

655 no_input=ctx is not None and ctx.no_input, 

656 ) 

657 except RuntimeError as exc: # a bad number: say so, re-show 

658 note(f" {exc}") 

659 continue 

660 try: 

661 if peeled.multiple: 

662 picked = [ 

663 _run_checks( 

664 _coerce.coerce_token(c, peeled.element), 

665 peeled, 

666 f"--{cli}", 

667 params, 

668 ) 

669 for c in chosen 

670 ] 

671 return ",".join(chosen), picked 

672 value = _coerce.coerce_token(chosen, peeled.element) 

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

674 except ValueError as exc: 

675 note(f" {exc}") 

676 continue 

677 

678 choices = _coerce.all_choices(peeled.element) 

679 hints = choices 

680 if hints is None and peeled.completer is not None: 

681 # A best-effort completer suggests, never enforces: its values ride 

682 # the hint, the answer stays free text. 

683 from footman._manifest import _run_completer 

684 

685 hints = _run_completer(peeled.completer, {}) or None 

686 if hints and len(hints) > 6: 686 ↛ 687line 686 didn't jump to line 687 because the condition on line 686 was never true

687 hints = [*hints[:6], "…"] 

688 hint = f" ({'/'.join(hints)})" if hints else "" 

689 offered = f" [{default}]" if default is not _MISSING and not marker.secret else "" 

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

691 while True: 

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

693 if not raw and default is not _MISSING: 

694 return "", default # Enter accepts what was offered 

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

696 note(f" choose one of {', '.join(choices)}") 

697 continue 

698 try: 

699 value = _coerce.coerce_token(raw, peeled.element) 

700 value = _run_checks(value, peeled, f"--{cli}", params) 

701 except ValueError as exc: 

702 note(f" {exc}") 

703 continue 

704 if marker.secret: 

705 raw = Secret(raw) 

706 if isinstance(value, str): 706 ↛ 708line 706 didn't jump to line 708 because the condition on line 706 was always true

707 value = Secret(value) 

708 return raw, value 

709 

710 

711class _SiblingView(Mapping[str, Any]): 

712 """The left-siblings mapping handed to a contextual `check` or `default`. 

713 

714 A plain dict answered the two ways of asking for an unavailable parameter 

715 with a bare `KeyError` and a silent `None` — and the second is the bad one, 

716 because a defensive `p.get("later")` then feeds the body a wrong value and 

717 the run succeeds. Both spellings now teach. 

718 

719 Reaching *rightwards* is the mistake worth naming: the parameter exists, so 

720 a typo is not the explanation, and the fix is to move it earlier in the 

721 signature. Raising something other than `KeyError` is what makes `.get()` 

722 say so too — `Mapping.get` only swallows `KeyError`. 

723 """ 

724 

725 __slots__ = ("_later", "_reader", "_values") 

726 

727 def __init__( 

728 self, values: dict[str, Any], reader: str, later: frozenset[str] 

729 ) -> None: 

730 self._values = values 

731 self._reader = reader 

732 self._later = later 

733 

734 def __getitem__(self, key: str) -> Any: 

735 if key in self._values: 

736 return self._values[key] 

737 if key == self._reader: 

738 raise ValueError( 

739 f"{self._reader!r} cannot read its own value — it is the one " 

740 f"being resolved" 

741 ) 

742 if key in self._later: 

743 raise ValueError( 

744 f"{self._reader!r} may only read parameters declared before it, " 

745 f"and {key!r} comes after — so it has no value yet. Move " 

746 f"{key!r} above {self._reader!r} in the signature." 

747 ) 

748 raise KeyError(key) 

749 

750 def __iter__(self) -> Iterator[str]: 

751 return iter(self._values) 

752 

753 def __len__(self) -> int: 

754 return len(self._values) 

755 

756 def __repr__(self) -> str: 

757 return f"{type(self).__name__}({self._values!r})" 

758 

759 

760_EMPTY_VIEW = _SiblingView({}, "", frozenset()) 

761 

762 

763def _left_siblings( 

764 sig: inspect.Signature, 

765 current: inspect.Parameter, 

766 kwargs: dict[str, Any], 

767 var_args: Sequence[Any] = (), 

768) -> _SiblingView: 

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

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

771 contextual `check` or `default` reads what the body will actually receive, 

772 never a copy of the default that can drift out of sync. 

773 

774 **Leftward only, and that is the whole safety argument.** The signature 

775 fixes a total order and binding walks it, so a value can only ever depend on 

776 one already resolved: a cycle cannot be written down, and no command line 

777 can vary the order. It is not that computed values are dangerous and 

778 declared ones safe — the view holds *effective* values, and only a left 

779 parameter has one yet. Offering a right one's declared default would serve 

780 a different kind of thing under the same key, which is exactly the staleness 

781 the effective-value rule exists to avoid. 

782 

783 *var_args* carries the `*args` values, which live outside `kwargs` on the 

784 command-line path and so used to be missing from the view there while a body 

785 call (whose `bound.arguments` holds them under the parameter's own name) saw 

786 them — the same validator reading two different worlds depending on how the 

787 task was reached. 

788 """ 

789 values: dict[str, Any] = {} 

790 later: set[str] = set() 

791 seen_current = False 

792 for p in sig.parameters.values(): 

793 if p.name == current.name: 

794 seen_current = True 

795 continue 

796 if seen_current: 

797 later.add(p.name) # named, so reaching for it can be taught 

798 continue 

799 if p.kind is inspect.Parameter.VAR_POSITIONAL: 

800 values[p.name] = tuple(kwargs.get(p.name, var_args)) 

801 elif p.name in kwargs: 

802 values[p.name] = kwargs[p.name] 

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

804 values[p.name] = p.default 

805 return _SiblingView(values, current.name, frozenset(later)) 

806 

807 

808def resolve_asks(fn: Task, seg: Segment, ctx: Context | None) -> None: 

809 """Front-load a node's promptable `ask()` parameters — ask-serial, 

810 run-parallel: asked before anything runs. 

811 

812 Every question whose answer cannot depend on a prerequisite's effects is 

813 asked up front (the scheduler calls this over the DAG in order), so the 

814 human answers once and walks away. A parameter carrying a live `suggest` 

815 completer resolves at node launch instead — its menu may need a dep's 

816 output — and CLI/env/default fills are skipped exactly as `bind` skips 

817 them. The accepted answer lands in `seg.values` as a raw token, so 

818 binding runs the one coercion pipeline: a front-loaded answer is 

819 indistinguishable from a CLI value. A required question with no way to 

820 ask (`--no-input`, no terminal) raises here, refusing the run before 

821 anything starts. 

822 """ 

823 sig = resolved_signature(fn) 

824 empty = inspect.Parameter.empty 

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

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

827 continue 

828 if param.annotation is empty: 

829 continue 

830 cli = registry.cli_name(param.name) 

831 if cli in seg.values: 

832 continue 

833 peeled = _coerce.peel(param.annotation) 

834 if peeled.ask is None or cli in seg.bare: 

835 continue # bare: the caller already said "the declared one" 

836 if peeled.completer is not None: 

837 continue # a live suggest may need a prerequisite's effects 

838 if peeled.stdin is not None and _stdin_fillable(peeled): 

839 continue # the piped payload fills it at bind time 

840 if peeled.env is not None and os.environ.get(peeled.env) is not None: 840 ↛ 841line 840 didn't jump to line 841 because the condition on line 840 was never true

841 continue # the env variable fills it at bind time 

842 raw, _value = _prompt_param( 

843 cli, 

844 peeled, 

845 ctx, 

846 None, 

847 _MISSING if param.default is empty else param.default, 

848 ) 

849 if raw or param.default is empty: 

850 seg.values[cli] = raw 

851 else: 

852 # Enter, or nobody to ask: the declared default stands, and 

853 # recording nothing lets bind reach it by the ordinary route. 

854 seg.bare.add(cli) 

855 

856 

857def bind( 

858 seg: Segment, 

859 fn: Task, 

860 ctx: Context | None = None, 

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

862 forwarded_given: frozenset[str] = frozenset(), 

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

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

865 

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

867 through `footman._coerce`, the same module the manifest and splitter use. 

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

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

870 

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

872 marker, and *forwarded_given* which of them its own caller asked for. 

873 Precedence is CLI value > forwarded > env > default, and a forwarded value 

874 can satisfy a defaultless parameter as well as a defaulted one. 

875 

876 The third return is the **presence set**: the parameters the caller 

877 supplied, as opposed to the ones footman inferred. A CLI value counts (bare 

878 or attached — naming an option is asking for it), so does a piped `stdin` 

879 payload and an answered `ask()` prompt; an `env()` fallback and a default do 

880 not, because nobody asked for those. `Context.given` is stamped from it, and 

881 it is what lets a task tell "the default one, please" from "no opinion". 

882 """ 

883 sig = resolved_signature(fn) 

884 empty = inspect.Parameter.empty 

885 var_args: list[Any] = [] 

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

887 supplied: set[str] = set() 

888 

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

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

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

892 siblings = _left_siblings(sig, param, kwargs, var_args) 

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

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

895 if param.annotation is empty: 

896 var_args = list(extra) 

897 else: 

898 peeled = _coerce.peel(param.annotation) 

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

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

901 continue 

902 

903 cli = registry.cli_name(param.name) 

904 if cli in seg.bare: 

905 # Named without a value: the mention *is* the content. The ladder 

906 # below still runs, so the value is whatever absence would have 

907 # given — what changes is that someone asked for it. 

908 supplied.add(param.name) 

909 if cli not in seg.values: 

910 # A forwarded value outranks env and the default, and may satisfy a 

911 # defaultless parameter too: `ask()` and `stdin` already do, and 

912 # refusing here only pushed authors into giving the receiving 

913 # parameter a default it did not want — weakening its contract when 

914 # the task is run on its own, to work around a rule meant to protect 

915 # exactly that. 

916 if forwarded is not None and param.name in forwarded: 

917 if param.name in forwarded_given: 

918 supplied.add(param.name) # someone asked, further upstream 

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

920 continue 

921 if param.annotation is not empty: 

922 peeled = _coerce.peel(param.annotation) 

923 # CLI > stdin > env > default > prompt: the piped payload 

924 # outranks the ambient environment, and both lose to an 

925 # explicit option. 

926 if peeled.stdin is not None: 

927 value = _stdin_value(param, peeled, siblings) 

928 if value is not _MISSING: 

929 # A piped payload is the caller handing the value over, 

930 # so it counts as supplied — unlike the env fallback 

931 # below, which is ambient and answers for nobody. 

932 supplied.add(param.name) 

933 kwargs[param.name] = value 

934 continue 

935 if peeled.env is not None: 

936 value = _env_value(param, peeled, siblings) 

937 if value is not _MISSING: 

938 kwargs[param.name] = value 

939 continue 

940 if peeled.default_fn is not None: 

941 # A default computed now rather than at import. Used as it 

942 # comes back — it is a real object, and coercion exists for 

943 # command-line strings — but still validated, so one that 

944 # would be refused as a typed value is refused here too. 

945 computed = _computed_default(peeled.default_fn, siblings) 

946 _validate_explicit(computed, peeled, f"--{cli}", siblings) 

947 kwargs[param.name] = computed 

948 continue 

949 # ask(): prompt for a required (defaultless) param nothing 

950 # else filled — the prompt is the last resort. 

951 if peeled.ask is not None and cli not in seg.bare: 

952 # A bare mention is the caller saying "the declared one" — 

953 # they have answered already, so asking again would be 

954 # footman not listening. 

955 asked, value = _prompt_param( 

956 cli, 

957 peeled, 

958 ctx, 

959 siblings, 

960 _MISSING if param.default is empty else param.default, 

961 ) 

962 if asked or param.default is empty: 962 ↛ 966line 962 didn't jump to line 966 because the condition on line 962 was always true

963 # Answered by a person; a silent fall back to the 

964 # default in an unattended run is not someone asking. 

965 supplied.add(param.name) 

966 kwargs[param.name] = value 

967 continue 

968 if peeled.stdin is not None and param.default is empty: 

969 # Required, reads stdin, and nothing supplied it. A taught 

970 # refusal, never a blocking terminal read. 

971 if peeled.stdin.field is not None and ( 

972 context.stdin_payload() is not None 

973 ): 

974 raise ValueError( 

975 f"--{cli} is required and the JSON document on " 

976 f"stdin has no {peeled.stdin.field!r} field — " 

977 f"add the field, or pass --{cli}" 

978 ) 

979 if ( 

980 not peeled.mapping 

981 and not peeled.multiple 

982 and (_binder.is_document_target(peeled.element)) 

983 ): 

984 # A whole-document parameter has no token spelling — 

985 # the boundary is its only source, so teach the pipe. 

986 raise ValueError( 

987 f"<{param.name}> is required and reads a JSON " 

988 f"document from stdin — pipe one in, or replay " 

989 f"a fixture (< payload.json)" 

990 ) 

991 raise ValueError( 

992 f"--{cli} is required and reads stdin — pipe a " 

993 f"document in, redirect a file (< payload), or " 

994 f"pass --{cli}" 

995 ) 

996 continue 

997 # Named on the line — with a value or bare, both of which are the 

998 # caller asking for this parameter rather than footman inferring it. 

999 supplied.add(param.name) 

1000 raw = seg.values[cli] 

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

1002 kwargs[param.name] = raw 

1003 continue 

1004 if param.annotation is empty: 

1005 # No annotation, but a basic default types the parameter anyway 

1006 # (`port=8000` is an int here exactly as it is to a type 

1007 # checker). Substituting the inferred type routes the value 

1008 # through the very path an annotated one takes — same coercion, 

1009 # same taught errors — rather than a parallel one. 

1010 inferred = _coerce.inferred_type(param.default) 

1011 if inferred is None: 

1012 kwargs[param.name] = raw 

1013 continue 

1014 peeled = _coerce.peel(inferred) 

1015 else: 

1016 peeled = _coerce.peel(param.annotation) 

1017 label = f"--{cli}" 

1018 if peeled.mapping: 

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

1020 for key, value in raw: 

1021 k = _coerce.coerce_one(key, peeled.key) 

1022 v = _run_checks( 

1023 _coerce.coerce_one(value, peeled.element), peeled, label, siblings 

1024 ) 

1025 if peeled.value_multiple: 

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

1027 else: 

1028 result[k] = v 

1029 kwargs[param.name] = result 

1030 elif (group := _coerce.group_of(peeled.element)) is not None: 

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

1032 bound = _bind_group(group, items, peeled.multiple, label) 

1033 kwargs[param.name] = _run_checks( 

1034 _container(peeled, bound, label) if peeled.multiple else bound, 

1035 peeled, 

1036 label, 

1037 siblings, 

1038 ) 

1039 elif peeled.multiple: 

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

1041 kwargs[param.name] = _container( 

1042 peeled, 

1043 [ 

1044 _run_checks( 

1045 _coerce.coerce_one(v, peeled.element), peeled, label, siblings 

1046 ) 

1047 for v in items 

1048 ], 

1049 label, 

1050 ) 

1051 else: 

1052 kwargs[param.name] = _run_checks( 

1053 _coerce.coerce_one(raw, peeled.element), peeled, label, siblings 

1054 ) 

1055 

1056 # The parameters that must be passed positionally, in signature order. 

1057 # Positional-only ones (`def build(target, /)`) always — they cannot be 

1058 # passed by keyword. And once there are variadic values, *everything* 

1059 # declared before `*args`: Python only reaches `*args` after every 

1060 # earlier slot is filled positionally, so a keyword left behind either 

1061 # collides with the variadic values ("got multiple values for…") or — 

1062 # absent, relying on its default — silently swallows them. A defaultless 

1063 # one is splitter-enforced present, so a `hole` (a skipped optional) is 

1064 # only ever filled by an existing default and never leaves a gap before 

1065 # a value that must land after it. 

1066 pos: list[Any] = [] 

1067 hole: list[Any] = [] 

1068 ctx_name = context_param_name(sig) 

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

1070 if param.kind is not inspect.Parameter.POSITIONAL_ONLY and not var_args: 

1071 break # nothing to reach past them — keywords serve the rest 

1072 if param.kind not in ( 

1073 inspect.Parameter.POSITIONAL_ONLY, 

1074 inspect.Parameter.POSITIONAL_OR_KEYWORD, 

1075 ): 

1076 break # `*args` itself; keyword-only stays keyword 

1077 if param.name == ctx_name: 

1078 continue # run_task injects ctx as the first positional itself 

1079 if param.name in kwargs: 

1080 pos += hole 

1081 hole = [] 

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

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

1084 hole.append(param.default) 

1085 if var_args: 

1086 pos += hole # every slot before *args is filled to reach it 

1087 

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

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

1090 return [*pos, *var_args], kwargs, frozenset(supplied) 

1091 

1092 

1093def forward_map( 

1094 fn: Task, 

1095 seg: Segment, 

1096 received: dict[str, Any] | None = None, 

1097 received_given: frozenset[str] = frozenset(), 

1098) -> tuple[dict[str, Any], frozenset[str]]: 

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

1100 

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

1102 prompting, so building the map is side-effect free. A defaultless parameter 

1103 contributes when the line carried one: it has a value, and refusing to pass 

1104 it on would only push authors into giving the receiving end a default it 

1105 does not want. 

1106 

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

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

1109 

1110 Returns `(values, given)` — **two channels**. The value always travels, so 

1111 what a prerequisite receives is unchanged; presence travels beside it, so 

1112 `given()` reads the same sentence in a task, a `pre_task` and a `post_task`. 

1113 Forwarding only the values someone asked for would have been the simpler 

1114 rule and is wrong: it drops `env()`-sourced values, leaving a prerequisite 

1115 on its own default when the environment plainly said otherwise. 

1116 """ 

1117 sig = resolved_signature(fn) 

1118 empty = inspect.Parameter.empty 

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

1120 asked: set[str] = set() 

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

1122 if param.annotation is empty: 

1123 continue 

1124 peeled = _coerce.peel(param.annotation) 

1125 if not peeled.forward: 

1126 continue 

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

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

1129 if param.name in received_given: 1129 ↛ 1131line 1129 didn't jump to line 1131 because the condition on line 1129 was always true

1130 asked.add(param.name) 

1131 continue 

1132 cli = registry.cli_name(param.name) 

1133 if cli not in seg.values: 

1134 if param.default is empty: 

1135 # Nothing to send: no token on the line and no default to fall 

1136 # back on. Its value will come from env/ask when *fn* binds, 

1137 # which is after this map is built. 

1138 continue 

1139 out[param.name] = param.default 

1140 continue 

1141 asked.add(param.name) # named on the dispatcher's own segment 

1142 raw = seg.values[cli] 

1143 if isinstance(raw, bool): 

1144 out[param.name] = raw 

1145 elif peeled.multiple: 1145 ↛ 1146line 1145 didn't jump to line 1146 because the condition on line 1145 was never true

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

1147 out[param.name] = _container( 

1148 peeled, [_coerce.coerce_one(v, peeled.element) for v in items] 

1149 ) 

1150 else: 

1151 out[param.name] = _coerce.coerce_one(raw, peeled.element) 

1152 return out, frozenset(asked) 

1153 

1154 

1155@dataclass(frozen=True) 

1156class _PlanParam: 

1157 param: inspect.Parameter 

1158 peeled: _coerce.Peeled 

1159 validates: bool # choices / bounds / path requirement / check(fn) to enforce 

1160 sources: bool # stdin / env / a required ask() to consult when absent 

1161 

1162 

1163@dataclass(frozen=True) 

1164class _CallPlan: 

1165 sig: inspect.Signature # the caller's signature: ctx already stripped 

1166 entries: tuple[_PlanParam, ...] # in signature order 

1167 

1168 

1169_CALL_PLAN = "_footman_call_plan" 

1170 

1171 

1172def _call_plan(fn: Task) -> _CallPlan: 

1173 """The per-task plan a bare call binds through, built on the first body 

1174 call and memoised — a task never called from Python never pays for it. 

1175 

1176 Stamped on the body function itself (the `_footman_*` house pattern), so 

1177 the plan lives and dies with its function — an id-keyed module cache 

1178 would hand a recycled id another function's plan. Two threads racing on 

1179 a first call both build the same plan; the last write wins, harmlessly. 

1180 """ 

1181 from footman._manifest import call_signature 

1182 

1183 body = registry.task_body(fn) 

1184 plan: _CallPlan | None = getattr(body, _CALL_PLAN, None) 

1185 if plan is not None: 

1186 return plan 

1187 sig = call_signature(fn) 

1188 empty = inspect.Parameter.empty 

1189 entries: list[_PlanParam] = [] 

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

1191 if param.annotation is empty or param.kind is inspect.Parameter.VAR_KEYWORD: 

1192 continue 

1193 peeled = _coerce.peel(param.annotation) 

1194 validates = bool( 

1195 peeled.checks 

1196 or peeled.bounds is not None 

1197 or peeled.path_req is not None 

1198 or _coerce.all_choices(peeled.element) is not None 

1199 ) 

1200 # Every source `bind` would consult for an omitted parameter, or a body 

1201 # call silently skips it and the body sees the sentinel default the 

1202 # marker was meant to replace. `ask()` counts whether or not there is a 

1203 # default now that a default is its *offer* rather than a reason not to 

1204 # ask; `default(fn)` counts because computing it is the whole point. 

1205 sources = param.kind is not inspect.Parameter.VAR_POSITIONAL and ( 

1206 peeled.stdin is not None 

1207 or peeled.env is not None 

1208 or peeled.ask is not None 

1209 or peeled.default_fn is not None 

1210 ) 

1211 if validates or sources: 

1212 entries.append(_PlanParam(param, peeled, validates, sources)) 

1213 plan = _CallPlan(sig, tuple(entries)) 

1214 setattr(body, _CALL_PLAN, plan) 

1215 return plan 

1216 

1217 

1218def _validate_explicit( 

1219 value: Any, peeled: _coerce.Peeled, label: str, siblings: Mapping[str, Any] 

1220) -> None: 

1221 """Validate a Python value a caller passed explicitly — the annotation is 

1222 the contract however the task was asked for — without coercing it: the 

1223 static signature already polices the types, and coercion exists because 

1224 the command line only has strings.""" 

1225 

1226 def one(v: Any) -> None: 

1227 if v is None: # an Optional's explicit None: nothing to check 1227 ↛ 1228line 1227 didn't jump to line 1228 because the condition on line 1227 was never true

1228 return 

1229 _run_checks(_validate_value(v, peeled, label), peeled, label, siblings) 

1230 

1231 if peeled.mapping and isinstance(value, dict): 1231 ↛ 1232line 1231 didn't jump to line 1232 because the condition on line 1231 was never true

1232 for v in value.values(): 

1233 if peeled.value_multiple and isinstance(v, (list, tuple)): 

1234 for element in v: 

1235 one(element) 

1236 else: 

1237 one(v) 

1238 elif peeled.multiple and isinstance(value, (list, tuple)): 1238 ↛ 1239line 1238 didn't jump to line 1239 because the condition on line 1238 was never true

1239 for element in value: 

1240 one(element) 

1241 else: 

1242 one(value) 

1243 

1244 

1245def bind_call( 

1246 fn: Task, args: tuple[Any, ...], kwargs: dict[str, Any] 

1247) -> tuple[tuple[Any, ...], dict[str, Any], frozenset[str]]: 

1248 """A body call's arguments through the same ladder `bind` runs. 

1249 

1250 The handle sees the call before Python applies defaults, so an omitted 

1251 parameter is distinguishable from its default passed explicitly: absence 

1252 consults the sources binding would — stdin, then env, then (for a 

1253 defaultless parameter) an `ask()` prompt — and an explicit value wins over 

1254 all of them, exactly as a CLI value does. Explicit values run the 

1255 annotation's validators but are never coerced. 

1256 

1257 Called before the work key is computed, so identity reads the values the 

1258 body will actually receive: a segment, a prerequisite and a body call that 

1259 resolve to the same values *and were asked for the same way* are one piece 

1260 of work. Presence is the second half of that — omitting a parameter and 

1261 passing exactly its default resolve alike but are different requests. 

1262 

1263 The third return is the **presence set**, the same one `bind` produces from 

1264 a segment — so `build(profile=<the default>)` and `fm build --profile` say 

1265 the same thing about their caller, and `build()` and `fm build` say the same 

1266 thing about theirs. `bind_partial` is what makes it knowable: it records 

1267 only what was passed, and it runs before `apply_defaults()` fills the rest. 

1268 """ 

1269 plan = _call_plan(fn) 

1270 try: 

1271 bound = plan.sig.bind_partial(*args, **kwargs) 

1272 except TypeError: 

1273 # Won't bind: let the call raise where it is made, and claim nothing 

1274 # about presence for arguments we could not match to parameters. 

1275 return args, kwargs, frozenset() 

1276 # Bound before anything fills a gap: the one moment on this path where what 

1277 # the caller named is distinguishable from what footman is about to infer. 

1278 # Read ahead of the `entries` shortcut, because a task with nothing to 

1279 # validate and no sources to consult still has a caller with intentions. 

1280 supplied: set[str] = set(bound.arguments) 

1281 if not plan.entries: 

1282 return args, kwargs, frozenset(supplied) 

1283 name = getattr(fn, "__name__", str(fn)) 

1284 empty = inspect.Parameter.empty 

1285 for entry in plan.entries: 

1286 param, peeled = entry.param, entry.peeled 

1287 if param.kind is inspect.Parameter.VAR_POSITIONAL: 1287 ↛ 1288line 1287 didn't jump to line 1288 because the condition on line 1287 was never true

1288 if entry.validates: 

1289 label = f"{name}(*{param.name})" 

1290 siblings = _left_siblings(plan.sig, param, bound.arguments) 

1291 for element in bound.arguments.get(param.name, ()): 

1292 _validate_explicit(element, peeled, label, siblings) 

1293 continue 

1294 if param.name in bound.arguments: 

1295 if entry.validates: 

1296 label = f"{name}({param.name}=…)" 

1297 siblings = _left_siblings(plan.sig, param, bound.arguments) 

1298 _validate_explicit(bound.arguments[param.name], peeled, label, siblings) 

1299 continue 

1300 if not entry.sources: 

1301 continue 

1302 siblings = _left_siblings(plan.sig, param, bound.arguments) 

1303 if peeled.stdin is not None: 

1304 value = _stdin_value(param, peeled, siblings) 

1305 if value is not _MISSING: 1305 ↛ 1309line 1305 didn't jump to line 1309 because the condition on line 1305 was always true

1306 supplied.add(param.name) # piped in: handed over, not inferred 

1307 bound.arguments[param.name] = value 

1308 continue 

1309 if peeled.env is not None: 

1310 value = _env_value(param, peeled, siblings) 

1311 if value is not _MISSING: 1311 ↛ 1316line 1311 didn't jump to line 1316 because the condition on line 1311 was always true

1312 # Deliberately not `supplied`: the environment is ambient, and 

1313 # answers for nobody in particular. 

1314 bound.arguments[param.name] = value 

1315 continue 

1316 if peeled.default_fn is not None: 

1317 computed = _computed_default(peeled.default_fn, siblings) 

1318 label = f"{name}({param.name}=…)" 

1319 _validate_explicit(computed, peeled, label, siblings) 

1320 bound.arguments[param.name] = computed # inferred, so not `supplied` 

1321 continue 

1322 if peeled.ask is not None: 1322 ↛ 1285line 1322 didn't jump to line 1285 because the condition on line 1322 was always true

1323 cli = registry.cli_name(param.name) 

1324 asked, value = _prompt_param( 

1325 cli, 

1326 peeled, 

1327 context._current.get(), 

1328 siblings, 

1329 _MISSING if param.default is empty else param.default, 

1330 ) 

1331 if asked or param.default is empty: 1331 ↛ anywhereline 1331 didn't jump anywhere: it always raised an exception.

1332 supplied.add(param.name) # asked and answered 

1333 bound.arguments[param.name] = value 

1334 bound.apply_defaults() 

1335 return bound.args, dict(bound.kwargs), frozenset(supplied) 

1336 

1337 

1338def _call( 

1339 fn: Task, args: list[Any], kwargs: dict[str, Any], as_call: bool = False 

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

1341 try: 

1342 # The task's own body — never the handle, whose call is the body-call 

1343 # machinery that would route this invocation straight back here. 

1344 body = registry.task_body(fn) 

1345 unwrapped = inspect.unwrap(body) 

1346 if inspect.isgeneratorfunction(unwrapped) or inspect.isasyncgenfunction( 

1347 unwrapped 

1348 ): 

1349 # Declaration, not return value: a plain body may *return* an 

1350 # iterator it built, and that is real work with a real receipt. 

1351 # A body written with `yield` runs nothing when called — the 

1352 # shape a service will claim; until then it refuses by name. 

1353 raise generator_refusal( 

1354 "task", body, is_async=inspect.isasyncgenfunction(unwrapped) 

1355 ) 

1356 returned = body(*args, **kwargs) 

1357 if inspect.iscoroutine(returned): 

1358 # Calling an `async def` builds a coroutine and runs none of it. 

1359 # Left alone this reported `ok` for a body that never executed — 

1360 # a receipt with no work behind it, which is the one thing the 

1361 # design says it will not mint. footman has no event loop on 

1362 # purpose (docs/design.md, "No event loop"), so the refusal names 

1363 # the way in rather than pretending to be one. 

1364 returned.close() # or Python warns "was never awaited" as well 

1365 raise coroutine_refusal("task", registry.task_body(fn)) 

1366 except SystemExit as exc: 

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

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

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

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

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

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

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

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

1375 except Failed as exc: 

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

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

1378 return exc.code, None, exc 

1379 except RunFailed as exc: 

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

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

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

1383 except BrokenPipeError as exc: 

1384 # `fm chatty | head`: the reader closed the pipe, the body's next 

1385 # print raised EPIPE. Not the task's defect and not a crash worth a 

1386 # traceback — the reader said "enough". Reported the way a 

1387 # SIGPIPE-default tool reports it: a calm reason and 128+SIGPIPE, 

1388 # so `set -o pipefail` still sees the cut while `| head` users see 

1389 # no exception spam. Delivered as `Failed`, the deliberate-stop 

1390 # carrier, so the renderer prints the reason without the class name. 

1391 del exc 

1392 return 141, None, Failed("stdout closed by the reader — output cut short") 

1393 except (KeyboardInterrupt, GeneratorExit, _signals.Stop): 

1394 # None of these is the task failing, so none becomes a receipt: all are 

1395 # control flow, and the layer that owns each must be the one to see it. 

1396 # 

1397 # A Ctrl-C is the user stopping the process. It arrives asynchronously 

1398 # wherever it lands, so the task it interrupted is incidental — the app 

1399 # layer answers it with "interrupted" and 130. `Stop` is the same shape 

1400 # with a different sender: a supervisor's SIGTERM/SIGHUP, answered with 

1401 # "terminated" and 143. Both are raised INTO whatever task happens to be 

1402 # running, so reading either as that task's verdict would report the 

1403 # wrong thing and, worse, let the run carry on past a stop it was told 

1404 # to make. 

1405 # 

1406 # A GeneratorExit is *footman's own cancellation signal*, not a stray: 

1407 # `_step`'s pump raises it by calling `gen.close()` on a step that timed 

1408 # out, that an abort caught, or that misused the yield channel. It 

1409 # arrives INTO a step to unwind it, so the generator's own try/finally 

1410 # runs and nothing is left half-held. Turning it into a task receipt 

1411 # would swallow the cancellation the pump just issued — the same 

1412 # mistake as catching SystemExit up in `_app.run`, where it is the uv 

1413 # handoff. The language agrees: a frame that swallows a GeneratorExit 

1414 # is handed a RuntimeError in place of the exit it refused. 

1415 raise 

1416 except BaseException as exc: # a failed task must not crash the runner 

1417 # Everything else leaving the body is the task failing, whichever side 

1418 # of `Exception` it sits on — `sys.exit("reason")` is a BaseException 

1419 # and has always been read that way. `asyncio.CancelledError` is the 

1420 # one that turns up in practice: it inherits BaseException and leaves 

1421 # `asyncio.run()` when a task cancels itself. Catching only `Exception` 

1422 # let it past the report entirely — no row, no `--json` envelope, and a 

1423 # sibling that succeeded went unreported too — while the once-cell 

1424 # already failed a sharer with it, so the runner disagreed with itself. 

1425 return 1, None, exc 

1426 if isinstance(returned, int) and not isinstance(returned, bool) and not as_call: 

1427 # An int return is the exit-code channel — unless the signature 

1428 # declares `Stdout[int]`, in which case the number is the document 

1429 # (a filter like wordcount could not exist otherwise). Declaration 

1430 # wins; a bare `-> int` keeps its long-standing meaning. It is a 

1431 # *segment's* channel, though: a body call asked for the value, and 

1432 # `n = measure()` has always handed the number over. 

1433 declares, _ = _coerce.emitted(resolved_signature(fn).return_annotation) 

1434 if not declares: 

1435 return returned, returned, None 

1436 return 0, returned, None 

1437 

1438 

1439def fold_post_error( 

1440 result: TaskResult, 

1441 post_error: BaseException | None, 

1442 *, 

1443 clear_state: bool = False, 

1444) -> BaseException | None: 

1445 """Fold a post-hook failure into an otherwise-green result — the one 

1446 spelling of "an observer that crashed must not pass silently", which 

1447 three sites used to carry by hand. A deliberate veto (`fail()`) keeps 

1448 its own code, never 0; anything else reads 1. *clear_state* drops a 

1449 `shared` badge — a share a post failed no longer reads as clean. 

1450 Returns the folded error for the caller's error channel, `None` when 

1451 there was nothing to fold or the result already carried its own story. 

1452 """ 

1453 if post_error is None or not result.ok: 

1454 return None 

1455 result.ok = False 

1456 result.code = post_error.code if isinstance(post_error, context.Failed) else 1 

1457 result.error = post_error 

1458 if clear_state: 

1459 result.state = "" 

1460 return post_error 

1461 

1462 

1463class Unavailable(Exception): 

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

1465 

1466 

1467def unavailable(fn: Task, seg: Segment) -> TaskResult | None: 

1468 """The refusal for a `@requires`-gated task, or `None` when it may run. 

1469 

1470 Availability is re-checked live at the moment of execution — the manifest's 

1471 cached answer is only ever a listing annotation — and it is checked before 

1472 binding, so an unavailable task refuses for the reason it is unavailable 

1473 rather than for whatever its arguments would have done. Every way of asking 

1474 a task to run comes through here, so a body call refuses exactly as a 

1475 prerequisite does. 

1476 """ 

1477 reason = registry.availability(fn) 

1478 if reason is None: 

1479 return None 

1480 return TaskResult( 

1481 task=seg.task, 

1482 ok=False, 

1483 code=EX_USAGE, 

1484 error=Unavailable(reason), 

1485 started=time.perf_counter(), 

1486 ) 

1487 

1488 

1489def resolve_cwd(fn: Task, ctx: Context) -> tuple[Path | None, bool]: 

1490 """The task's working directory under the policy ladder, resolved once. 

1491 

1492 Ladder: the task's own declaration — `.opts(cwd=)` over `@task(cwd=)`, 

1493 both read through the same `getattr` an `_Opted` proxies — then the 

1494 config default (`ctx.cwd_policy`), then `"taskfile"`. `rel` resolves the 

1495 same way and is appended to the base. Pure path arithmetic: the 

1496 directory need not exist at resolve time — existence errors surface 

1497 where the path is used. 

1498 

1499 Returns `(cwd, unmanaged)`. Under `"unmanaged"` the cwd is the live 

1500 process cwd at task start (for the body to read) and the flag makes 

1501 `run()` spawn children with `cwd=None`. `"taskfile"` falls back to 

1502 `root` when the task carries no defining-dir stamp (config-mounted 

1503 plugins); with nothing known (bare calls outside discovery) the cwd 

1504 stays `None`, as before. 

1505 """ 

1506 policy = registry.task_cwd(fn) or ctx.cwd_policy or "taskfile" 

1507 rel = registry.task_rel(fn) 

1508 if policy == "unmanaged": 

1509 if rel: 

1510 raise ValueError( 

1511 "rel=… needs a managed base and cwd='unmanaged' has none — " 

1512 "use cwd='asinvoked' for a pinned launch-directory base" 

1513 ) 

1514 return Path.cwd(), True 

1515 base: Path | None 

1516 if isinstance(policy, Path): 

1517 base = policy 

1518 elif policy == "root": 

1519 base = Path(ctx.root_dir) if ctx.root_dir else None 

1520 elif policy == "asinvoked": 

1521 base = Path(ctx.invoked_dir) if ctx.invoked_dir else Path.cwd() 

1522 elif policy == "taskfile": 

1523 home = defining_dir(fn) 

1524 if home is not None: 

1525 base = Path(home) 

1526 else: 

1527 base = Path(ctx.root_dir) if ctx.root_dir else None 

1528 else: # a config default naming an absolute path (validated at startup) 

1529 base = Path(policy) 

1530 if base is None: 

1531 return None, False 

1532 return (base / rel) if rel else base, False 

1533 

1534 

1535@contextlib.contextmanager 

1536def _cwd_hold(ctx: Context) -> Generator[None]: 

1537 """The cwd lane's application: chdir to the task's resolved directory 

1538 and restore — legitimate only under the lane's sole occupancy. The 

1539 foreign-cwd guard then passes naturally: live and target agree.""" 

1540 saved = _globals.real_getcwd() 

1541 try: 

1542 if ctx.cwd is not None and not ctx.cwd_unmanaged: 1542 ↛ 1544line 1542 didn't jump to line 1544 because the condition on line 1542 was always true

1543 _globals.real_chdir(ctx.cwd) 

1544 yield 

1545 finally: 

1546 with contextlib.suppress(OSError): 

1547 _globals.real_chdir(saved) 

1548 

1549 

1550@contextlib.contextmanager 

1551def _serial_globals(ctx: Context) -> Generator[None]: 

1552 """A serial/exclusive body owns the real process globals. 

1553 

1554 Sole occupancy (the lane) is what makes this safe: apply the task's 

1555 resolved cwd with a real chdir and its env overlay onto the real 

1556 `os.environ`, snapshot and restore both. `serial_active` flips the 

1557 routers and guards to pass-through for the duration — this is the 

1558 declared regime where today's conveniences are legitimate again. 

1559 """ 

1560 ctx.serial_active = True 

1561 saved_cwd = _globals.real_getcwd() 

1562 saved_env = dict(os.environ) # passthrough: serial_active is already set 

1563 try: 

1564 if ctx.env: 1564 ↛ 1566line 1564 didn't jump to line 1566 because the condition on line 1564 was always true

1565 os.environ.update(ctx.env) 

1566 if ctx.cwd is not None and not ctx.cwd_unmanaged: 

1567 _globals.real_chdir(ctx.cwd) 

1568 yield 

1569 finally: 

1570 with contextlib.suppress(OSError): # the saved dir may have vanished 

1571 _globals.real_chdir(saved_cwd) 

1572 os.environ.clear() 

1573 os.environ.update(saved_env) 

1574 ctx.serial_active = False 

1575 

1576 

1577def bind_global_options( 

1578 options: Sequence[Any], 

1579 tokens: Sequence[str], 

1580 *, 

1581 config: Mapping[str, Any] | None = None, 

1582) -> str | None: 

1583 """Deliver the parsed leading globals to their owning options — a 

1584 plugin's, and core's own ladder-bearing declarations alike. 

1585 

1586 Every option gets a value for the run — a flag's presence, an option's 

1587 coerced `=`-attached value, or what an absent one falls back to — and 

1588 freezes, so `.value` answers anywhere in-run. The value string runs the same 

1589 coercion, bounds, choices and `check(fn)` pipeline a task parameter's would. 

1590 Returns the teaching message for a value that will not coerce. 

1591 

1592 Presence freezes alongside it. A mention with no value contributes exactly 

1593 that — the option was asked for, and `.value` is whatever it would have been 

1594 anyway — which is what lets one declared value cover absent, named, and 

1595 named-with-a-value. 

1596 

1597 Collections read exactly as a task option's do: mentions accumulate 

1598 (`--tag=a --tag=b`), each value comma-splits unless `nosplit`, a mapping 

1599 takes `KEY=VALUE` pairs. A bool answers to `--no-x` as well as `--x`, 

1600 last mention winning — the negation every task flag already has. 

1601 

1602 An absent option runs the one ladder, **env > config > `default(fn)` > 

1603 the declared default**, through the very helpers `bind` uses. The config 

1604 rung reads *config* for an option declaring `config=True`, coerced 

1605 through the same pipeline — and a present key is validated even when a 

1606 token already decides, so a broken config value teaches on every 

1607 invocation, not only the ones it would steer. Config-sourced is not 

1608 `given`, exactly as env-sourced is not. 

1609 """ 

1610 cfg_values: dict[str, Any] = {} 

1611 if config: 

1612 for opt in options: 

1613 spec = getattr(opt, "config", False) 

1614 if not spec: 1614 ↛ 1615line 1614 didn't jump to line 1615 because the condition on line 1614 was never true

1615 continue 

1616 key = spec if isinstance(spec, str) else opt.name 

1617 if opt._config_scope == "root": 

1618 table: Any = config 

1619 label = f"config key '{key}'" 

1620 else: 

1621 # A provider's section under the reserved `plugins.` child — 

1622 # resolved and refused at discovery, so absence here only 

1623 # means the project wrote nothing. 

1624 plugins_tbl = config.get("plugins") 

1625 table = ( 

1626 plugins_tbl.get(opt._section) 

1627 if isinstance(plugins_tbl, dict) and opt._section 

1628 else None 

1629 ) 

1630 label = f"config key 'plugins.{opt._section}.{key}'" 

1631 if isinstance(table, dict) and key in table: 

1632 try: 

1633 cfg_values[opt.name] = _config_value(opt, table[key], label) 

1634 except ValueError as exc: 

1635 return str(exc) 

1636 by_flag: dict[str, Any] = {} 

1637 negations: dict[str, Any] = {} 

1638 for opt in options: 

1639 by_flag.setdefault("--" + opt.name, opt) 

1640 if opt.annotation is bool: 

1641 negations.setdefault("--no-" + opt.name, opt) 

1642 values: dict[str, Any] = {} 

1643 raws: dict[str, list[str]] = {} 

1644 given: set[str] = set() 

1645 for tok in tokens: 

1646 name, eq, raw = tok.partition("=") 

1647 if (neg := negations.get(name)) is not None: 

1648 # `--no-x`: the caller asked, and asked for off. Last mention 

1649 # wins between the two spellings, same as repeating any scalar. 

1650 given.add(neg.name) 

1651 values[neg.name] = False 

1652 continue 

1653 opt = by_flag.get(name) 

1654 if opt is None: 

1655 continue 

1656 given.add(opt.name) 

1657 if opt.annotation is bool: 

1658 values[opt.name] = True 

1659 continue 

1660 if not eq: 

1661 # Named without a value: presence is the whole content, so leave the 

1662 # value to the absent ladder below — the same reading a task option 

1663 # gives a bare mention. 

1664 continue 

1665 peeled = _coerce.peel(opt.annotation) 

1666 if peeled.mapping or peeled.multiple: 

1667 # A collection assembles below, once every mention is in — the 

1668 # splitter's own accumulate-then-bind reading for a task option. 

1669 raws.setdefault(opt.name, []).append(raw) 

1670 continue 

1671 try: 

1672 values[opt.name] = _coerce_extra(raw, peeled, name) 

1673 except ValueError as exc: 

1674 return str(exc) 

1675 for opt in by_flag.values(): 

1676 if opt.name in raws: 

1677 try: 

1678 values[opt.name] = _assemble_global(opt, raws[opt.name]) 

1679 except ValueError as exc: 

1680 return str(exc) 

1681 opt._given = opt.name in given 

1682 opt._frozen = True 

1683 if opt.name in values: 

1684 opt._value = values[opt.name] 

1685 continue 

1686 opt._value = _absent_global(opt, cfg_values.get(opt.name, _MISSING)) 

1687 return None 

1688 

1689 

1690def _config_value(opt: Any, raw: object, label: str) -> Any: 

1691 """One config value through the option's own pipeline, so a bad TOML 

1692 value is taught rather than silently wrong. TOML types narrow first — 

1693 a bool key must hold a bool, a value option must not hold one — then 

1694 the string pipeline runs the same coercion, bounds and choices a CLI 

1695 token gets. *label* names the key where the project wrote it 

1696 (`config key 'plugins.acme-devkit.region'`), not the flag.""" 

1697 if opt.annotation is bool: 

1698 if isinstance(raw, bool): 

1699 return raw 

1700 raise ValueError(f"{label} expects true or false (got {raw!r})") 

1701 if isinstance(raw, bool): 1701 ↛ 1702line 1701 didn't jump to line 1702 because the condition on line 1701 was never true

1702 raise ValueError(f"{label} expects a value, not true/false (got {raw!r})") 

1703 return _coerce_extra(str(raw), _coerce.peel(opt.annotation), label) 

1704 

1705 

1706def _assemble_global(opt: Any, raws: list[str]) -> Any: 

1707 """A collection-valued global option from its accumulated `=`-attached 

1708 values — the reading a task option's stream gets. Each mention 

1709 comma-splits unless `nosplit`; a mapping takes `KEY=VALUE` pairs, keys 

1710 and values coerced separately (`dict[K, list[V]]` accumulates per key); 

1711 every part runs the same strict coercion and checks an env fallback 

1712 would, because the splitter never validated these tokens either.""" 

1713 label = "--" + opt.name 

1714 peeled = _coerce.peel(opt.annotation) 

1715 parts: list[str] = [] 

1716 for raw in raws: 

1717 if peeled.nosplit: 

1718 parts.append(raw) 

1719 else: 

1720 parts.extend([p for p in raw.split(",") if p] or [raw]) 

1721 if not peeled.mapping: 

1722 return _container( 

1723 peeled, [_coerce_extra(p, peeled, label) for p in parts], label 

1724 ) 

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

1726 for part in parts: 

1727 if "=" not in part: 

1728 raise ValueError(f"{label} expects KEY=VALUE (got {part!r})") 

1729 key, value = part.split("=", 1) 

1730 try: 

1731 k = _coerce.coerce_token(key, peeled.key) 

1732 except ValueError as exc: 

1733 raise ValueError(f"{label} key {exc}") from exc 

1734 v = _coerce_extra(value, peeled, f"{label} value") 

1735 if peeled.value_multiple: 1735 ↛ 1736line 1735 didn't jump to line 1736 because the condition on line 1735 was never true

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

1737 else: 

1738 result[k] = v 

1739 return result 

1740 

1741 

1742def _absent_global(opt: Any, cfg: Any = _MISSING) -> Any: 

1743 """A global option's value when the line carried none: env, then config, 

1744 then a computed default, then the declared one — the ladder `bind` runs 

1745 for a task parameter, on the one synthetic parameter the option itself 

1746 builds (`_as_parameter`, the same construction `_global_spec` describes), 

1747 so the two cannot drift into different answers. 

1748 *cfg* arrives pre-coerced (`_config_value`); an exported variable still 

1749 outranks it, because a variable aims at this invocation and a project 

1750 setting at every one.""" 

1751 peeled = _coerce.peel(opt.annotation) 

1752 param = opt._as_parameter() 

1753 if peeled.env is not None: 

1754 value = _env_value(param, peeled) 

1755 if value is not _MISSING: 1755 ↛ 1757line 1755 didn't jump to line 1757 because the condition on line 1755 was always true

1756 return value 

1757 if cfg is not _MISSING: 

1758 return cfg 

1759 if peeled.default_fn is not None: 

1760 # A global has no siblings by nature: nothing sits beside it. 

1761 return _computed_default(peeled.default_fn) 

1762 return opt.default 

1763 

1764 

1765def _advise_unread_uses(ctx: Context, fn: Task) -> None: 

1766 """A task that declared `uses=[OPT]` but finished without reading 

1767 `.value` gets an advisory — a stale declaration misleads help and 

1768 provenance. The note teaches the idiom that makes its own evidence 

1769 trustworthy: read a declared option unconditionally and branch on the 

1770 value (the read is a cheap lookup), so "never read this run" only ever 

1771 means a genuinely stale declaration — and a deliberately lazy reader 

1772 can reclassify `global-unread:<option>` per project.""" 

1773 for opt in registry.task_uses(fn): 

1774 if (ctx.task or "?") not in opt._reads: 

1775 _globals._note( 

1776 f"global-unread:{opt.name}", 

1777 f"task {ctx.task or '?'} declares --{opt.name} in uses= but " 

1778 f"never read it this run — read it unconditionally and " 

1779 f"branch on the value, or prune the declaration if it is " 

1780 f"stale", 

1781 ) 

1782 

1783 

1784# --- the per-task lifecycle: pre_task / post_task ---------------------------- 

1785 

1786 

1787_hook_owner: contextvars.ContextVar[str | None] = contextvars.ContextVar( 

1788 "footman_hook_owner", default=None 

1789) 

1790 

1791# The run-wide moments, which sit *outside* the run: `pre_tasks` happens at 

1792# discovery (the manifest child included) and `post_tasks` after the plan is 

1793# done. Both are named here while they run, so a task call from one can say 

1794# which moment it is in rather than degrading to a bare function call. 

1795_wide_moment: contextvars.ContextVar[str | None] = contextvars.ContextVar( 

1796 "footman_wide_moment", default=None 

1797) 

1798 

1799 

1800@contextlib.contextmanager 

1801def wide_moment(name: str) -> Any: 

1802 """Mark a run-wide lifecycle moment for the duration of one hook.""" 

1803 token = _wide_moment.set(name) 

1804 try: 

1805 yield 

1806 finally: 

1807 _wide_moment.reset(token) 

1808 

1809 

1810class HookFailed(RuntimeError): 

1811 """A per-task lifecycle hook raised; the message names the plugin.""" 

1812 

1813 

1814@dataclass(frozen=True) 

1815class _HookPlugin: 

1816 name: str # the defining module: what a failure names, what state keys on 

1817 bind_pre: tuple[Task, ...] # pre_bind hooks — before parameters exist 

1818 pre: tuple[Task, ...] 

1819 post: tuple[Task, ...] 

1820 

1821 

1822@dataclass(frozen=True) 

1823class _Lifecycle: 

1824 inv: Any # the frozen Invocation 

1825 plugins: tuple[_HookPlugin, ...] 

1826 finish: tuple[Task, ...] = () # post_tasks hooks, cascade order 

1827 

1828 

1829# The run's per-task hooks, installed by the app layer for the duration of one 

1830# invocation. A module global, not a contextvar: every pool thread and body 

1831# call must see the same ladder. `None` — the common case — is the fast path. 

1832_lifecycle: _Lifecycle | None = None 

1833 

1834 

1835def install_lifecycle(inv: Any, contributions: Mapping[str, Sequence[Task]]) -> None: 

1836 """Group the tree's per-task hooks by plugin and arm them for the run. 

1837 

1838 A plugin is the module that defined the hook: its `pre_task` and 

1839 `post_task` pair through that identity, its `task.state` namespace is 

1840 private to it, and a failure names it. Plugin order is the order of first 

1841 contribution (cascade order); posts unwind in reverse. 

1842 """ 

1843 global _lifecycle 

1844 grouped: dict[str, tuple[list[Task], list[Task], list[Task]]] = {} 

1845 for kind, slot in (("pre_bind", 0), ("pre_task", 1), ("post_task", 2)): 

1846 for hook in contributions.get(kind, ()): 

1847 owner = getattr(hook, "__module__", None) or "<unknown>" 

1848 grouped.setdefault(owner, ([], [], []))[slot].append(hook) 

1849 finish = tuple(contributions.get("post_tasks", ())) 

1850 _lifecycle = ( 

1851 _Lifecycle( 

1852 inv, 

1853 tuple( 

1854 _HookPlugin(name, tuple(bind_pre), tuple(pre), tuple(post)) 

1855 for name, (bind_pre, pre, post) in grouped.items() 

1856 ), 

1857 finish, 

1858 ) 

1859 if grouped or finish 

1860 else None 

1861 ) 

1862 

1863 

1864def clear_lifecycle() -> None: 

1865 global _lifecycle 

1866 _lifecycle = None 

1867 

1868 

1869def run_post_tasks( 

1870 results: Sequence[TaskResult], total: float, json_mode: bool 

1871) -> BaseException | None: 

1872 """Fire the run's `post_tasks` hooks, main thread, before the report. 

1873 

1874 The invocation is handed the whole story — every row as a result view, 

1875 the `skipped` subset, and the wall-clock — written past the freeze by 

1876 the run itself (hooks still cannot write). Under `--json` a hook's 

1877 stdout is rerouted to stderr: the envelope owns stdout. Every hook runs; 

1878 the first failure is kept, named, and fails the invocation. 

1879 """ 

1880 life = _lifecycle 

1881 if life is None or not life.finish: 

1882 return None 

1883 inv = life.inv 

1884 views = tuple(ResultView(r) for r in results) 

1885 skipped = tuple( 

1886 v for r, v in zip(results, views, strict=True) if reported_state(r) == "skipped" 

1887 ) 

1888 object.__setattr__(inv, "results", views) 

1889 object.__setattr__(inv, "skipped", skipped) 

1890 object.__setattr__(inv, "total_ms", round(total * 1000, 3)) 

1891 redirect = ( 

1892 contextlib.redirect_stdout(context.real_stderr()) 

1893 if json_mode 

1894 else contextlib.nullcontext() 

1895 ) 

1896 first: BaseException | None = None 

1897 with redirect, wide_moment("post_tasks"): 

1898 for hook in life.finish: 

1899 try: 

1900 hook(inv) 

1901 except Exception as exc: 

1902 if first is None: 1902 ↛ 1898line 1902 didn't jump to line 1898 because the condition on line 1902 was always true

1903 failure = HookFailed( 

1904 f"post_tasks hook " 

1905 f"{getattr(hook, '__name__', '?')!r} from " 

1906 f"{getattr(hook, '__module__', '?')} failed: " 

1907 f"{type(exc).__name__}: {exc}" 

1908 ) 

1909 failure.__cause__ = exc 

1910 first = failure 

1911 return first 

1912 

1913 

1914class TaskHandle: 

1915 """One task execution, as a per-task lifecycle hook sees it. 

1916 

1917 Read-only facts — `name`, `args`, `source_hash` — plus the two lanes a 

1918 hook may write through: `env`, the task's own environment overlay, and 

1919 `state`, a namespace private to the current plugin and this execution. 

1920 """ 

1921 

1922 __slots__ = ("_bound", "_ctx", "_fn", "_kwargs", "_raw_args", "_states", "name") 

1923 

1924 _fn: Task 

1925 _ctx: Context 

1926 _raw_args: tuple[Any, ...] | None 

1927 _kwargs: dict[str, Any] | None 

1928 _bound: Mapping[str, Any] | None 

1929 _states: dict[str, SimpleNamespace] 

1930 name: str 

1931 

1932 def __init__(self, fn: Task, seg: Segment, ctx: Context) -> None: 

1933 object.__setattr__(self, "_fn", fn) 

1934 object.__setattr__(self, "_ctx", ctx) 

1935 object.__setattr__(self, "_raw_args", None) 

1936 object.__setattr__(self, "_kwargs", None) 

1937 object.__setattr__(self, "_bound", None) 

1938 object.__setattr__(self, "_states", {}) 

1939 object.__setattr__(self, "name", seg.task) 

1940 

1941 def _bind(self, args: Sequence[Any], kwargs: dict[str, Any]) -> None: 

1942 """Hand the handle its bound arguments — binding just happened.""" 

1943 object.__setattr__(self, "_raw_args", tuple(args)) 

1944 object.__setattr__(self, "_kwargs", dict(kwargs)) 

1945 

1946 def __setattr__(self, name: str, value: Any) -> None: 

1947 raise AttributeError( 

1948 f"task.{name} is read-only — per-plugin scratch goes on " 

1949 f"task.state, per-task environment in task.env" 

1950 ) 

1951 

1952 @property 

1953 def args(self) -> Mapping[str, Any]: 

1954 """The bound arguments — what the body actually receives, defaults 

1955 included, read-only. Mutation would let a plugin silently break the 

1956 typed contract the framework exists to enforce. Not readable at 

1957 `pre_bind`, which runs before values exist.""" 

1958 bound: Mapping[str, Any] | None = self._bound 

1959 if bound is None: 1959 ↛ 1977line 1959 didn't jump to line 1977 because the condition on line 1959 was always true

1960 raw_args, kwargs = self._raw_args, self._kwargs 

1961 if raw_args is None or kwargs is None: # set together by _bind 

1962 raise RuntimeError( 

1963 "task.args is not readable at pre_bind — nothing is " 

1964 "bound yet; read the values in pre_task, the post-bind " 

1965 "moment" 

1966 ) 

1967 from footman._manifest import call_signature 

1968 

1969 try: 

1970 b = call_signature(self._fn).bind_partial(*raw_args, **kwargs) 

1971 b.apply_defaults() 

1972 mapping = dict(b.arguments) 

1973 except TypeError: # an unbindable shape: show what was passed 

1974 mapping = dict(kwargs) 

1975 bound = MappingProxyType(mapping) 

1976 object.__setattr__(self, "_bound", bound) 

1977 return bound 

1978 

1979 @property 

1980 def env(self) -> dict[str, str]: 

1981 """The task's environment overlay: `run()` merges it into every 

1982 subprocess, in-body `os.environ` reads see it. Never the process 

1983 globals — a parallel sibling keeps its own.""" 

1984 return self._ctx.env 

1985 

1986 @property 

1987 def state(self) -> SimpleNamespace: 

1988 """Scratch shared between this plugin's pre and post: one namespace 

1989 per (plugin, execution), invisible to every other plugin.""" 

1990 owner = _hook_owner.get() 

1991 if owner is None: 1991 ↛ 1992line 1991 didn't jump to line 1992 because the condition on line 1991 was never true

1992 raise RuntimeError( 

1993 "task.state is per-plugin, so it is only reachable inside a " 

1994 "lifecycle hook" 

1995 ) 

1996 states: dict[str, SimpleNamespace] = self._states 

1997 ns = states.get(owner) 

1998 if ns is None: 

1999 ns = states[owner] = SimpleNamespace() 

2000 return ns 

2001 

2002 @property 

2003 def source_hash(self) -> str | None: 

2004 """`registry.task_source_hash` for this task — a tripwire, not an 

2005 identity: `None` when the source cannot be read, and shallow by 

2006 nature (the body's own source, nothing it calls).""" 

2007 return registry.task_source_hash(self._fn) 

2008 

2009 

2010class ResultView: 

2011 """A `post_task`'s view of the result: the sealed record, read-only. 

2012 

2013 Observers see, never judge: the review window (`pre_record`) closed 

2014 before this view was made, and every write there — code, title, the 

2015 reported value via `set_returned` — is attributed in the record's 

2016 audit. An observer that finds a problem fails the task instead, loudly: 

2017 `footman.fail(reason, code)` from a `post_task` hook is the veto, and 

2018 the failure names the hook and the moment. 

2019 """ 

2020 

2021 __slots__ = ("_result",) 

2022 

2023 def __init__(self, result: TaskResult) -> None: 

2024 object.__setattr__(self, "_result", result) 

2025 

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

2027 return getattr(object.__getattribute__(self, "_result"), name) 

2028 

2029 def __setattr__(self, name: str, value: Any) -> None: 

2030 raise AttributeError( 

2031 f"result.{name} is read-only in post_task — observers see, never " 

2032 f"judge. Amend verdicts in the review window instead " 

2033 f"(pre_record, where set_returned also lives), or veto with " 

2034 f"fail(reason, code)." 

2035 ) 

2036 

2037 

2038def _reserved_note( 

2039 ctx: Context, plugin: str, hook: Task, kind: str = "pre_task" 

2040) -> None: 

2041 # Through the notes channel — level, dedup, the record — with the ctx 

2042 # passed explicitly: the hook machinery holds the task's context here 

2043 # without it necessarily being current. 

2044 _notes.emit( 

2045 f"hook-return:{plugin}", 

2046 f"{plugin} {kind} {getattr(hook, '__name__', '?')!r} returned " 

2047 f"a value; a pre hook's return channel is reserved (for a pre that " 

2048 f"supplies the task's result) — keep per-task state on task.state", 

2049 ctx=ctx, 

2050 ) 

2051 

2052 

2053def _own_hook_error( 

2054 hook: Any, moment: str, task_name: str, exc: Exception 

2055) -> HookFailed: 

2056 failure = HookFailed( 

2057 f"{moment} hook {getattr(hook, '__name__', '?')!r} on task " 

2058 f"{task_name!r} failed: {type(exc).__name__}: {exc}" 

2059 ) 

2060 failure.__cause__ = exc 

2061 return failure 

2062 

2063 

2064def _enter_own_hooks( 

2065 handle: TaskHandle, attr: str, moment: str 

2066) -> BaseException | None: 

2067 """Run the task's own hooks for one enter moment — the handle-attached 

2068 lane, innermost: closest to the task, after any plugin's.""" 

2069 for hook in registry.own_hooks(handle._fn, attr): 

2070 try: 

2071 hook() 

2072 except Exception as exc: 

2073 return _own_hook_error(hook, moment, handle.name, exc) 

2074 return None 

2075 

2076 

2077def _enter_bind_hooks( 

2078 life: _Lifecycle | None, handle: TaskHandle 

2079) -> BaseException | None: 

2080 """Run `pre_bind` hooks before binding: every plugin's in plugin order, 

2081 then the task's own (innermost). 

2082 

2083 The first failure stops the walk and fails the task — binding never 

2084 happens, the body never runs, and the posts still fire when the attempt 

2085 concludes. 

2086 """ 

2087 if life is None: 

2088 return _enter_own_hooks(handle, registry._PRE_BIND_HOOKS, "pre_bind") 

2089 for plugin in life.plugins: 

2090 for hook in plugin.bind_pre: 

2091 token = _hook_owner.set(plugin.name) 

2092 try: 

2093 value = hook(life.inv, handle) 

2094 except Exception as exc: 

2095 failure = HookFailed( 

2096 f"pre_bind hook {getattr(hook, '__name__', '?')!r} from " 

2097 f"{plugin.name} failed for task {handle.name!r}: " 

2098 f"{type(exc).__name__}: {exc}" 

2099 ) 

2100 failure.__cause__ = exc 

2101 return failure 

2102 finally: 

2103 _hook_owner.reset(token) 

2104 if value is not None: 

2105 _reserved_note(handle._ctx, plugin.name, hook, kind="pre_bind") 

2106 return _enter_own_hooks(handle, registry._PRE_BIND_HOOKS, "pre_bind") 

2107 

2108 

2109def _enter_task_hooks( 

2110 life: _Lifecycle | None, handle: TaskHandle 

2111) -> BaseException | None: 

2112 """Run `pre_task` hooks: every plugin's in plugin order, then the task's 

2113 own (innermost — plugins are the wider audience, the task's own hooks 

2114 nest closest to the body). 

2115 

2116 The first failure stops the walk and fails the task — the body will not 

2117 run. It does not gate the posts: a post is the task-finished event, so 

2118 once an execution reached this moment, every registered `post_task` 

2119 fires when it concludes, irrespective of which pres its plugin 

2120 registered or how any of them fared. 

2121 """ 

2122 if life is None: 

2123 return _enter_own_hooks(handle, registry._PRE_TASK_HOOKS, "pre_task") 

2124 for plugin in life.plugins: 

2125 for hook in plugin.pre: 

2126 token = _hook_owner.set(plugin.name) 

2127 try: 

2128 value = hook(life.inv, handle) 

2129 except Exception as exc: 

2130 failure = HookFailed( 

2131 f"pre_task hook {getattr(hook, '__name__', '?')!r} from " 

2132 f"{plugin.name} failed for task {handle.name!r}: " 

2133 f"{type(exc).__name__}: {exc}" 

2134 ) 

2135 failure.__cause__ = exc 

2136 return failure 

2137 finally: 

2138 _hook_owner.reset(token) 

2139 if value is not None: 

2140 _reserved_note(handle._ctx, plugin.name, hook) 

2141 return _enter_own_hooks(handle, registry._PRE_TASK_HOOKS, "pre_task") 

2142 

2143 

2144def _exit_task_hooks( 

2145 life: _Lifecycle | None, 

2146 handle: TaskHandle, 

2147 result: TaskResult, 

2148) -> BaseException | None: 

2149 """Unwind `post_task` hooks: the task's own first (innermost), then 

2150 every plugin's in reverse plugin order; every one runs. 

2151 

2152 The first failure is kept (it fails an otherwise-green task); later 

2153 hooks still unwind, context-manager style. A `fail()` from any of them 

2154 is the veto — its own code, an "observe" audit entry. 

2155 """ 

2156 view = ResultView(result) 

2157 first: BaseException | None = None 

2158 for hook in registry.own_hooks(handle._fn, registry._POST_TASK_HOOKS): 

2159 name = getattr(hook, "__name__", "?") 

2160 try: 

2161 hook(view) 

2162 except context.Failed as exc: 

2163 if not result.audit: 2163 ↛ 2167line 2163 didn't jump to line 2167 because the condition on line 2163 was always true

2164 result.audit.append( 

2165 context._audit_entry("body", result.task, result.code) 

2166 ) 

2167 result.audit.append(context._audit_entry("observe", name, exc.code)) 

2168 if first is None: 2168 ↛ 2158line 2168 didn't jump to line 2158 because the condition on line 2168 was always true

2169 first = exc 

2170 except Exception as exc: 

2171 if not result.audit: 

2172 result.audit.append( 

2173 context._audit_entry("body", result.task, result.code) 

2174 ) 

2175 result.audit.append(context._audit_entry("observe", name, None)) 

2176 if first is None: 

2177 first = _own_hook_error(hook, "post_task", handle.name, exc) 

2178 if life is not None: 

2179 for plugin in reversed(life.plugins): 

2180 for hook in plugin.post: 

2181 token = _hook_owner.set(plugin.name) 

2182 name = getattr(hook, "__name__", "?") 

2183 try: 

2184 hook(life.inv, handle, view) 

2185 except context.Failed as exc: 

2186 # The veto: fail(reason, code) from an observer is a 

2187 # deliberate failure that rides the error channel — loud, 

2188 # attributed, its own code (never 0; fail() refuses that). 

2189 # The audit records the moment; the work's own story stays. 

2190 if not result.audit: 2190 ↛ 2194line 2190 didn't jump to line 2194 because the condition on line 2190 was always true

2191 result.audit.append( 

2192 context._audit_entry("body", result.task, result.code) 

2193 ) 

2194 result.audit.append(context._audit_entry("observe", name, exc.code)) 

2195 if first is None: 2195 ↛ 2212line 2195 didn't jump to line 2212 because the condition on line 2195 was always true

2196 first = exc 

2197 except Exception as exc: 

2198 if not result.audit: 2198 ↛ 2202line 2198 didn't jump to line 2202 because the condition on line 2198 was always true

2199 result.audit.append( 

2200 context._audit_entry("body", result.task, result.code) 

2201 ) 

2202 result.audit.append(context._audit_entry("observe", name, None)) 

2203 if first is None: 2203 ↛ 2212line 2203 didn't jump to line 2212 because the condition on line 2203 was always true

2204 failure = HookFailed( 

2205 f"post_task hook {name!r} " 

2206 f"from {plugin.name} failed for task {handle.name!r}: " 

2207 f"{type(exc).__name__}: {exc}" 

2208 ) 

2209 failure.__cause__ = exc 

2210 first = failure 

2211 finally: 

2212 _hook_owner.reset(token) 

2213 return first 

2214 

2215 

2216def run_task( 

2217 fn: Task, 

2218 seg: Segment, 

2219 ctx: Context, 

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

2221 forwarded_given: frozenset[str] = frozenset(), 

2222 attempt: int = 0, 

2223) -> TaskResult: 

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

2225 

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

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

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

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

2230 """ 

2231 if (refusal := unavailable(fn, seg)) is not None: 

2232 return refusal 

2233 # The managed window opens before binding: with `ctx` current and 

2234 # `in_task` set, a `pre_bind` hook's `task.env` writes reach `env()` 

2235 # fallbacks, coercion and `check(fn)` validators through the environ 

2236 # router — `_env_value` reads `os.environ`, and the router serves the 

2237 # merged view. The guards ride the same window, so hook code answers to 

2238 # the same rules a body does; the framework's own prompts use the real 

2239 # stream and are never caught. 

2240 token = _current.set(ctx) 

2241 ctx.in_task = True 

2242 life = _lifecycle 

2243 own = registry.has_own_hooks(fn) 

2244 handle = TaskHandle(fn, seg, ctx) if life is not None or own else None 

2245 try: 

2246 if ( 

2247 handle is not None 

2248 and (hook_error := _enter_bind_hooks(life, handle)) is not None 

2249 ): 

2250 # A raising pre_bind fails the task; binding never happens, the 

2251 # body never runs — but the attempt concluded, so the posts fire. 

2252 result = _result(seg, 1, None, hook_error, 0.0) 

2253 _exit_task_hooks(life, handle, result) 

2254 return result 

2255 try: 

2256 args, kwargs, ctx.given = bind(seg, fn, ctx, forwarded, forwarded_given) 

2257 except ChainError: 

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

2259 except Exception as exc: # a coercion failure (custom-type constructor) 

2260 result = _result(seg, EX_USAGE, None, exc, 0.0) 

2261 if handle is not None: 

2262 # A bind failure still concluded the attempt: the posts fire 

2263 # (a bind-time span needs closing), with the refusal result. 

2264 _exit_task_hooks(life, handle, result) 

2265 return result 

2266 return run_bound(fn, seg, ctx, args, kwargs, handle=handle, attempt=attempt) 

2267 finally: 

2268 _current.reset(token) 

2269 

2270 

2271def run_bound( 

2272 fn: Task, 

2273 seg: Segment, 

2274 ctx: Context, 

2275 args: list[Any], 

2276 kwargs: dict[str, Any], 

2277 *, 

2278 as_call: bool = False, 

2279 handle: TaskHandle | None = None, 

2280 attempt: int = 0, 

2281) -> TaskResult: 

2282 """Run *fn* with arguments already resolved — everything after binding. 

2283 

2284 The half of `run_task` that owns a task's *execution*: the context wiring, 

2285 the arbiter lane, the body, and the result. Split out because a body call 

2286 (`artifact = build()`) arrives with real Python arguments and must not be 

2287 re-bound from a command line, yet still deserves the full task treatment — 

2288 its own context, its own lane decision, its own `TaskResult`. 

2289 """ 

2290 # Sharing means the same thing whichever way a task was reached, so this 

2291 # request joins the same protocol a body call uses: claim the work, or wait 

2292 # for whoever already holds it and report the answer as `shared`. A request 

2293 # declared `shared=False` never joins — `ctx` carries that answer. The key 

2294 # is computed before `ctx` joins the arguments, so the context object never 

2295 # becomes part of the work's identity — except its environment, the one 

2296 # contextual input a body can observe, which joins deliberately (`_key`). 

2297 # `as_call` means the cell layer is already holding this work's cell (it 

2298 # claimed before delegating here) and will resolve it — claiming again from 

2299 # the same thread would read as this task waiting on itself. 

2300 # `ctx.given` was stamped by `bind` above, so a segment and a body call that 

2301 # resolve to the same values still name different work when one of them 

2302 # asked for a parameter and the other merely got its default. 

2303 work = ( 

2304 _futures.work_of(fn, args, kwargs, ctx.given, ctx.env) 

2305 if ctx.shared and not as_call 

2306 else None 

2307 ) 

2308 if attempt and work is not None: 

2309 # A retry: the previous attempt resolved this cell with its failure, 

2310 # and the memo would hand it straight back — the body would run once 

2311 # and be reported N times. Retire it so this attempt runs fresh and 

2312 # the terminal one is what later requests share. 

2313 _futures.retire(work) 

2314 claimed, cell = _futures.claim(work, seg.task) 

2315 if not claimed: 

2316 # The pair is per request — only the body is shared. The pre fires 

2317 # here, post-bind and before the wait, so a span honestly covers it; 

2318 # the post closes the request with its row, and `result.state` says 

2319 # whether this request executed or was satisfied by another. 

2320 life = _lifecycle 

2321 if life is not None or registry.has_own_hooks(fn): 

2322 if handle is None: 2322 ↛ 2323line 2322 didn't jump to line 2323 because the condition on line 2322 was never true

2323 handle = TaskHandle(fn, seg, ctx) 

2324 handle._bind(args, kwargs) 

2325 if (enter_error := _enter_task_hooks(life, handle)) is not None: 

2326 result = _result(seg, 1, None, enter_error, 0.0) 

2327 _exit_task_hooks(life, handle, result) 

2328 return result 

2329 try: 

2330 value = _futures.join(cell) 

2331 except BaseException as exc: # the execution we waited on failed 

2332 result = _result(seg, 1, None, exc, 0.0) 

2333 # Genuine prevention: the failure this request waited on is why 

2334 # it has no answer — named, so the report seats it after it. 

2335 result.blocked_by = cell.label 

2336 else: 

2337 result = _futures.shared_result(seg.task, value, cell.record) 

2338 result.address = ctx.address # the reference row is this request's 

2339 if handle is not None: 

2340 fold_post_error( 

2341 result, _exit_task_hooks(life, handle, result), clear_state=True 

2342 ) 

2343 return result 

2344 

2345 plain_args = args # the caller-visible arguments, before ctx is injected 

2346 if context_param_name(resolved_signature(fn)): 

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

2348 

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

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

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

2352 # The deadline starts here — at the body, not at the request. Everything 

2353 # before this point (binding, availability, the confirm gate, the 

2354 # prerequisites) is work the caller asked for around this task rather 

2355 # than the task itself, and a human staring at a confirm prompt must not 

2356 # spend the deadline. `.opts(timeout=…)` has already been folded into the 

2357 # task's markers by the time this reads them. 

2358 if (limit := registry.task_timeout(fn)) is not None: 

2359 ctx.deadline = time.perf_counter() + limit 

2360 ctx.timeout_declared = limit 

2361 if ctx.cwd is None: # a preset ctx.cwd (tests / use_context) wins 

2362 try: 

2363 ctx.cwd, ctx.cwd_unmanaged = resolve_cwd(fn, ctx) 

2364 except ValueError as exc: # e.g. rel= under an unmanaged config default 

2365 _futures.resolve(cell, None, exc) # never leave a waiter blocked 

2366 return _result(seg, EX_USAGE, None, exc, 0.0) 

2367 # Here rather than at the run boundary, and here rather than after the 

2368 # line below: the cwd is finally known, `in_task` is still unset so the 

2369 # getcwd guard reads unguarded, and `unmanaged` is the token that means 

2370 # footman stays out — so nothing would be noted and nothing needs warming. 

2371 if not ctx.cwd_unmanaged: 

2372 _globals.warm_tempdir(ctx.cwd) 

2373 

2374 # The arbiter lane is a *scheduling* declaration, acquired here at the 

2375 # task boundary — never mid-body, which is what keeps it deadlock-free. 

2376 # A lineage child (serial_active inherited through a fan-out) extends 

2377 # its ancestor's hold instead of contending with it. 

2378 inherited = ctx.serial_active 

2379 lane_policy = None if inherited else registry.task_lane(fn) 

2380 named = () if inherited else registry.task_lanes(fn) 

2381 console = not inherited and ( 

2382 registry.is_interactive(fn) or any(ln.name == "console" for ln in named) 

2383 ) 

2384 named = tuple(ln for ln in named if ln.name != "console") 

2385 

2386 # Wear the task's name while it runs, so a sampling profiler's timeline 

2387 # reads as tasks rather than `fm-worker_3`; a serial/exclusive hold is 

2388 # badged, so lane occupancy shows. Restored in the finally below; a body 

2389 # call nests naturally (the callee's name while the callee runs). The 

2390 # worker's own stable name and OS id land on the result, the correlation 

2391 # keys a profiler dump uses. 

2392 worker = threading.current_thread() 

2393 born = worker.name 

2394 badge = {"serial": " [serial]", "exclusive": " [exclusive]"}.get( 

2395 lane_policy or "", "" 

2396 ) 

2397 worker.name = f"fm:{seg.task}{badge}" 

2398 

2399 token = _current.set(ctx) 

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

2401 start = time.perf_counter() 

2402 started = start # the report's ordering key: when this task actually began 

2403 life = _lifecycle 

2404 hook_error: BaseException | None = None 

2405 if life is not None or registry.has_own_hooks(fn): 

2406 # The handle rides in from run_task (declared path) or the futures 

2407 # layer (a body call); a direct run_bound caller gets a fresh one. 

2408 # Its arguments are the pre-injection values: ctx is machinery, not 

2409 # a value a hook should read back as one of the task's own. Pre 

2410 # hooks run inside the timed span, so their cost is the task's cost. 

2411 if handle is None: 2411 ↛ 2412line 2411 didn't jump to line 2412 because the condition on line 2411 was never true

2412 handle = TaskHandle(fn, seg, ctx) 

2413 handle._bind(plain_args, kwargs) 

2414 hook_error = _enter_task_hooks(life, handle) 

2415 else: 

2416 handle = None 

2417 lane_waits: list[tuple[str, float]] = [] 

2418 try: 

2419 error: BaseException | None 

2420 if hook_error is not None: 

2421 # A raising pre fails the task like a failed prerequisite: the 

2422 # body never runs, and the failure names the plugin. 

2423 code, returned, error = 1, None, hook_error 

2424 else: 

2425 with _globals.lane( 

2426 lane_policy, 

2427 name=seg.task, 

2428 inherited=inherited, 

2429 console=console, 

2430 named=named, 

2431 ) as lane_waits: 

2432 if lane_policy is not None: 

2433 with _serial_globals(ctx): 

2434 code, returned, error = _call(fn, args, kwargs, as_call) 

2435 elif any(ln.name == "cwd" for ln in named): 

2436 # The cwd lane's hold: sole occupancy of the one real 

2437 # working directory, applied with a real chdir for the 

2438 # duration — the fourth exit in the foreign-cwd taught 

2439 # list, now a first-class claim. 

2440 with _cwd_hold(ctx): 

2441 code, returned, error = _call(fn, args, kwargs, as_call) 

2442 else: 

2443 code, returned, error = _call(fn, args, kwargs, as_call) 

2444 if error is None: 

2445 # The body finished and had every chance to read what it declared. 

2446 _advise_unread_uses(ctx, fn) 

2447 except BaseException as exc: 

2448 # Not an ordinary failure — `_call` turns those into `error` and the 

2449 # run carries on to `resolve` below. This is the class it deliberately 

2450 # does not catch, KeyboardInterrupt above all, and letting it leave by 

2451 # the front door skipped the one line that answers anyone waiting on 

2452 # this task's cell. `resolve`'s own contract says it is "always called 

2453 # for a claimed cell, on every path out"; this was the path out that 

2454 # did not. 

2455 # 

2456 # A sharer then blocked on a cell nobody would ever fill — for the rest 

2457 # of the run, and past a second Ctrl-C, since the wait is not 

2458 # interruptible. Answering it costs nothing and the exception carries 

2459 # on unchanged: the waiter is failed by the same thing that failed the 

2460 # claimant, which is the truth of what happened to it. 

2461 _futures.resolve(cell, None, exc) 

2462 raise 

2463 finally: 

2464 _current.reset(token) 

2465 worker.name = born 

2466 duration = time.perf_counter() - start 

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

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

2469 result.address = ctx.address 

2470 result.body_returned = returned 

2471 result.started = started 

2472 result.thread = born 

2473 result.thread_id = threading.get_native_id() 

2474 result.lane_waits = lane_waits 

2475 result.sections = list(ctx.sections) 

2476 result.notes = list(ctx.notes) 

2477 if result.ok and (banned := _notes.boundary_error(ctx)) is not None: 

2478 # Every noted site already did the safe thing, so nothing was gained 

2479 # by stopping early — and the reckoning happening here, at the 

2480 # boundary, is what surfaces ALL of a task's banned notes in one 

2481 # run. A body that already failed keeps its own failure: the notes 

2482 # still ride the row. 

2483 result.ok = False 

2484 result.code = 1 

2485 result.error = banned 

2486 reviewers = registry.task_reviewers(fn) 

2487 if reviewers and hook_error is None: 

2488 # The row's review window: the body concluded, the record is still a 

2489 # draft, and the task's own reviewers see it before it is sealed, 

2490 # observed, or reported. Reviewers run inside-out; the first failure 

2491 # stops the walk and fails the task with the hook's own error — 

2492 # nothing a broken review half-did is kept. 

2493 audit = [context._audit_entry("body", seg.task, result.code)] 

2494 view = context.ResultView( 

2495 title=seg.task, 

2496 code=result.code, 

2497 stdout=result.output, 

2498 stderr="", 

2499 duration=result.duration, 

2500 raw="", 

2501 command=seg.task, 

2502 returned=result.returned, 

2503 ) 

2504 for reviewer in reviewers: 

2505 name = getattr(reviewer, "__name__", repr(reviewer)) 

2506 try: 

2507 reviewer(view) 

2508 except Exception as exc: 

2509 audit.append(context._audit_entry("review", name, None)) 

2510 failure = HookFailed( 

2511 f"pre_record hook {name!r} failed reviewing task " 

2512 f"{seg.task!r}: {type(exc).__name__}: {exc}" 

2513 ) 

2514 failure.__cause__ = exc 

2515 result.ok = False 

2516 if result.code == 0: 2516 ↛ 2518line 2516 didn't jump to line 2518 because the condition on line 2516 was always true

2517 result.code = 1 

2518 result.error = failure 

2519 error = failure # a waiter shares the failure, not a value 

2520 break 

2521 audit.append( 

2522 context._audit_entry( 

2523 "review", name, view.code if "code" in view._touched else None 

2524 ) 

2525 ) 

2526 else: 

2527 # Every reviewer finished: the verdict is what the review left. 

2528 result.code = view.code 

2529 result.ok = result.error is None and view.code == 0 

2530 if "title" in view._touched: 

2531 result.title = view.title 

2532 if "returned" in view._touched: 

2533 # The *reported* value only: the body's return was 

2534 # snapshotted at the body's exit and resolves below. 

2535 result.returned = view.returned 

2536 result.audit = audit 

2537 if handle is not None: 

2538 post_error = _exit_task_hooks(life, handle, result) 

2539 if (folded := fold_post_error(result, post_error)) is not None: 

2540 error = folded 

2541 # The body's return, snapshotted the moment it was handed over: what 

2542 # a dependent or a body-caller receives is what the annotation promised, 

2543 # whatever a reporter later does to the *reported* result. Resolved after 

2544 # the posts, so a hook-failed task never hands a waiter a green value; a 

2545 # `set_returned` cannot reach it, because `returned` was captured at 

2546 # `_call` exit. Only a shared execution holds a cell (`claim` gives an 

2547 # unshared one none), so what a run reuses never depends on scheduling 

2548 # order. 

2549 _futures.resolve(cell, returned, error, record=result) 

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

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

2552 # Only when the abort actually *reaches* this task, though: the latch is 

2553 # process-wide and a sibling's failure sets it, but a keep-going task is 

2554 # spared the reap and runs to its own conclusion. Reading the latch alone 

2555 # called that conclusion a cancellation — and because the label replaces 

2556 # the error rather than joining it, `--keep-going` reported the first 

2557 # failure and hid every one after it, which is the opposite of the flag. 

2558 if not result.ok and context.abort_reaches(ctx.keep_going): 

2559 result.cancelled = True 

2560 # The deadline is judged after the body, because that is the first moment 

2561 # its breach is knowable for every body shape: a step or a subprocess was 

2562 # cut off at a checkpoint (and said so), or a straight-line body simply 

2563 # ran past it and returned. Both are timeouts; only the first is a stop, 

2564 # which is exactly the distinction `stopped` carries. 

2565 if ctx.overdue(): 

2566 result.timed_out = True 

2567 # Did footman issue a stop before the body returned? A step that 

2568 # timed out is that stop — a killed subprocess tree, or a generator 

2569 # closed at its checkpoint. Nothing here can observe a body that 

2570 # never returned, so `escaped` is not among the values this site can 

2571 # produce; see the field's docstring for why it is not shipped. 

2572 result.after_deadline = ( 

2573 "stopped" if any(step.timed_out for step in result.steps) else "completed" 

2574 ) 

2575 # The deadline governs, even when the body finished with a verdict. 

2576 # Honouring a late success would make the outcome depend on a race 

2577 # between the body and the kill — the same task passing or failing by 

2578 # scheduler timing. Flaky is worse than strict for something used as 

2579 # a gate. The receipt still tells the whole truth: what the body 

2580 # actually did goes into the message rather than being discarded. 

2581 if result.ok: 

2582 result.ok = False 

2583 result.code = 124 

2584 if result.error is None: 2584 ↛ 2603line 2584 didn't jump to line 2603 because the condition on line 2584 was always true

2585 result.error = context.TimedOut( 

2586 seg.task, 

2587 registry.task_timeout(fn) or 0.0, 

2588 after=result.after_deadline, 

2589 body="success", 

2590 at=result.duration, 

2591 ) 

2592 elif result.after_deadline == "completed": 

2593 # A body that failed *and* overran: the deadline is the verdict, 

2594 # but the body's own reason is what the author needs to read. 

2595 result.error = context.TimedOut( 

2596 seg.task, 

2597 registry.task_timeout(fn) or 0.0, 

2598 after="completed", 

2599 body=str(result.error) if result.error else "failure", 

2600 at=result.duration, 

2601 ) 

2602 result.code = 124 

2603 return result 

2604 

2605 

2606def _result( 

2607 seg: Segment, 

2608 code: int, 

2609 returned: Any, 

2610 error: BaseException | None, 

2611 duration: float, 

2612 output: str = "", 

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

2614) -> TaskResult: 

2615 return TaskResult( 

2616 task=seg.task, 

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

2618 # Honor an explicit non-zero code (run_task passes EX_USAGE for 

2619 # bind/coercion refusals); only synthesize 1 when an error carries no 

2620 # code of its own. 

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

2622 returned=returned, 

2623 error=error, 

2624 duration=duration, 

2625 output=output, 

2626 steps=steps or [], 

2627 ) 

2628 

2629 

2630def run_chain( 

2631 root: Group, 

2632 segments: list[Segment], 

2633 *, 

2634 keep_going: bool = False, 

2635 capture: bool = False, 

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

2637) -> list[TaskResult]: 

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

2639 from footman import _schedule 

2640 

2641 return _schedule.run_plan( 

2642 root, 

2643 segments, 

2644 sequential=True, 

2645 keep_going=keep_going, 

2646 capture=capture, 

2647 ctx_config=ctx_config, 

2648 )