Coverage for src/footman/_futures.py: 94%

350 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-06 02:59 +0000

1"""Tasks as run-scoped futures: what happens when a task body calls a task. 

2 

3`artifact = build()` inside a task body used to be a plain function call — it 

4ran `build` again even if the DAG had just run it, on the caller's thread, with 

5no context of its own and no `TaskResult` anywhere. That was the one execution 

6path the framework couldn't see. 

7 

8Now a call routes through here, and per **(task, resolved arguments, starting 

9environment)** the run keeps a once-cell: 

10 

11* already run → the memoised value comes back; 

12* running on another thread → the caller blocks on its future; 

13* neither → the caller claims it and runs it **inline, on its own thread**. 

14 

15So `pre=[build]` plus `build()` in the body is a cache hit, not a second build, 

16and the callee gets full task treatment: its own context, its own lane 

17decision, its own reported result. 

18 

19Sharing is a property of the *request*, not of this layer: an unshared request 

20(`@task(shared=False)`, `.opts(shared=False)`, or inherited from the task that 

21asked) neither reads a cell nor becomes one — its execution is its own, and a 

22later shared request runs fresh rather than reusing it. `schedule` resolves the 

23same ladder for DAG nodes, so a declared request and a called one behave the 

24same. 

25 

26Two things are deliberately *not* here. A call outside a run is a plain call — 

27importing a tasks file and calling a function must keep working. And lane 

28acquisition stays at the task boundary, so a body call to a `serial=`/ 

29`exclusive=` task is refused rather than deadlocking mid-body. 

30""" 

31 

32from __future__ import annotations 

33 

34import enum 

35import io 

36import itertools 

37import threading 

38from contextlib import contextmanager 

39from contextvars import ContextVar 

40from typing import TYPE_CHECKING, Any 

41 

42from footman import _coerce, registry 

43from footman._split import ChainError 

44 

45if TYPE_CHECKING: 

46 from collections.abc import Generator 

47 

48 from footman._executor import TaskResult 

49 

50# The memo key's argument half when the arguments cannot be frozen (an 

51# unhashable value with no obvious frozen form — a live object, a generator). 

52# Such a call is honest work every time rather than a wrong cache hit. 

53_UNKEYABLE = object() 

54 

55 

56class _Settled: 

57 """The five-method slice of `Future` this module actually uses. 

58 

59 `concurrent.futures` costs ~4.9 ms to import — it drags `logging` through 

60 `traceback`, and `logging` drags `_colorize` — and every run that executes 

61 a task claims a cell, so every run paid it. Nothing here needs the 

62 executor half of that module: a cell is written once by its owner and read 

63 by whoever waits, which is a `threading.Event` and two slots. 

64 """ 

65 

66 __slots__ = ("_done", "_exc", "_value") 

67 

68 def __init__(self) -> None: 

69 self._done = threading.Event() 

70 self._value: Any = None 

71 self._exc: BaseException | None = None 

72 

73 def done(self) -> bool: 

74 return self._done.is_set() 

75 

76 def set_result(self, value: Any) -> None: 

77 self._value = value 

78 self._done.set() 

79 

80 def set_exception(self, exc: BaseException) -> None: 

81 self._exc = exc 

82 self._done.set() 

83 

84 def result(self) -> Any: 

85 # No timeout: the wait graph above this layer is what refuses a claim 

86 # that could not be satisfied, so a wait here is always one that ends. 

87 self._done.wait() 

88 if self._exc is not None: 

89 raise self._exc 

90 return self._value 

91 

92 

93class _Cell: 

94 """One (task, arguments) execution: its future, its owner, its label.""" 

95 

96 __slots__ = ("future", "label", "owner", "record") 

97 

98 def __init__(self, owner: int, label: str) -> None: 

99 self.future = _Settled() 

100 self.owner = owner # the thread that claimed it (for the wait graph) 

101 self.label = label 

102 self.record: Any = None # the execution's sealed row, for reuse 

103 

104 

105class _Session: 

106 """The run's cells, plus the wait-for graph that keeps them honest.""" 

107 

108 def __init__(self) -> None: 

109 self.lock = threading.Lock() 

110 self.cells: dict[Any, _Cell] = {} 

111 self.waits: dict[int, Any] = {} # thread -> the key it is blocked on 

112 self.results: list[TaskResult] = [] 

113 # Tasks defined *while this run was in flight* — `(group, name, what 

114 # was there before)` — put back the way they were when it ends. 

115 self.ephemeral: list[tuple[Any, str, Any]] = [] 

116 

117 

118_active: _Session | None = None 

119 

120# The run-wide request counter: every request for task-shaped work takes a 

121# number at the moment it is *made* — plan order for scheduled segments, the 

122# written line for a `parallel()` block's queued calls, the call moment for 

123# body calls. The report is ordered by it outright: the clock cannot do the 

124# job, because two independent tasks start in whatever order the pool hands 

125# them workers, and that reshuffles between runs of the same command. 

126_seq = itertools.count() 

127# A queued call re-enters `call()` on a pool worker; the queue moment (the 

128# written line) already took its number, carried here around the invocation. 

129_pending_seq: ContextVar[int | None] = ContextVar("footman_request_seq", default=None) 

130 

131 

132@contextmanager 

133def session() -> Generator[_Session]: 

134 """Install a fresh cell registry for the duration of one run. 

135 

136 Run-scoped by construction: memoisation means "this work happened in *this* 

137 run", never a cache that outlives it. Nested installs (a `Runner` inside a 

138 task, tests) keep the outer session — one run, one memo. 

139 """ 

140 global _active 

141 if _active is not None: 141 ↛ 142line 141 didn't jump to line 142 because the condition on line 141 was never true

142 yield _active 

143 return 

144 _active = _Session() 

145 try: 

146 yield _active 

147 finally: 

148 run, _active = _active, None 

149 _sweep_ephemeral(run) 

150 

151 

152def active_session() -> _Session | None: 

153 """The run's cell registry, or `None` outside a run.""" 

154 return _active 

155 

156 

157def _sweep_ephemeral(run: _Session) -> None: 

158 """Undo the registrations a run made in its own course. 

159 

160 A `@task` written inside a task body is ordinary Python and makes a real, 

161 callable task — but the manifest was written before the run started, so 

162 such a task is invisible to every listing and would go on shadowing the 

163 tree for the rest of the process. It lives for the run that made it, and 

164 the tree it was grafted onto goes back to what it was. 

165 """ 

166 for group, name, previous in reversed(run.ephemeral): 

167 if previous is None: 167 ↛ 170line 167 didn't jump to line 170 because the condition on line 167 was always true

168 group.tasks.pop(name, None) 

169 else: 

170 group.tasks[name] = previous 

171 

172 

173def collected() -> list[TaskResult]: 

174 """Results of tasks run by body calls this run, in completion order.""" 

175 return list(_active.results) if _active is not None else [] 

176 

177 

178def _freeze(value: Any) -> Any: 

179 """A hashable normal form for one argument value, or `_UNKEYABLE`. 

180 

181 Collections get frozen shapes (a list and a tuple of the same items are 

182 the same work), and anything already hashable is its own key. What has no 

183 frozen form is not forced into one: it reads as unkeyable, and its call 

184 runs. 

185 """ 

186 if isinstance(value, enum.Enum): 

187 # Before the scalar line on purpose: IntEnum and str-valued enums 

188 # would otherwise freeze as their value face. The normal form 

189 # canonicalises an enum argument as its token — the member is the 

190 # meaning, the value is representation — so renumbering a member 

191 # does not churn work identity, and every spelling that reached 

192 # binding froze to the same key anyway (they resolve to one member 

193 # before anything downstream looks). 

194 return ("enum", type(value).__name__, _coerce.token_of(value)) 

195 if isinstance(value, (str, bytes, int, float, bool, type(None))): 

196 return value 

197 if isinstance(value, (list, tuple)): 

198 parts = tuple(_freeze(v) for v in value) 

199 return _UNKEYABLE if _UNKEYABLE in parts else parts 

200 if isinstance(value, (set, frozenset)): 

201 parts = tuple(sorted((_freeze(v) for v in value), key=repr)) 

202 return _UNKEYABLE if _UNKEYABLE in parts else frozenset(parts) 

203 if isinstance(value, dict): 

204 items = tuple( 

205 sorted(((_freeze(k), _freeze(v)) for k, v in value.items()), key=repr) 

206 ) 

207 return _UNKEYABLE if any(_UNKEYABLE in p for p in items) else items 

208 try: 

209 hash(value) 

210 except TypeError: 

211 return _UNKEYABLE 

212 return value 

213 

214 

215def unshared(task: Any) -> bool: 

216 """Whether *this request* must run rather than reuse an execution. 

217 

218 The sharing ladder for a call: the reference's own `.opts(shared=…)` or the 

219 task's declaration, then what the calling task inherited (`ctx.shared` — an 

220 unshared request asks unshared for everything it needs), then shared. The 

221 scheduler resolves the same ladder for a node. 

222 """ 

223 from footman import context 

224 

225 own = registry.sharing(task) 

226 if own is not None: 

227 return not own 

228 ctx = context._current.get() 

229 return ctx is not None and not ctx.shared 

230 

231 

232def _env_digest(env: dict[str, str]) -> str: 

233 """The environment half of a cell key. 

234 

235 Order-blind (a dict's insertion order must not split cells), case-blind 

236 exactly where the OS is (`_norm`: Windows), and a digest rather than the 

237 pairs themselves so cell keys stay small — equality is all a key needs. 

238 """ 

239 import hashlib 

240 

241 from footman import _globals 

242 

243 h = hashlib.sha1(usedforsecurity=False) 

244 for k, v in sorted((_globals._norm(k), v) for k, v in env.items()): 

245 h.update(k.encode("utf-8", "surrogateescape")) 

246 h.update(b"=") 

247 h.update(v.encode("utf-8", "surrogateescape")) 

248 h.update(b"\0") 

249 return h.hexdigest() 

250 

251 

252def _key( 

253 task: Any, 

254 args: Any, 

255 kwargs: dict[str, Any], 

256 given: frozenset[str], 

257 env: dict[str, str], 

258) -> Any | None: 

259 """The cell key for this work: the task's dedup identity, its arguments in 

260 the same normal form binding produces, what its caller actually asked 

261 for, and the environment it would start from — so a DAG node and a body 

262 call that resolve to the same arguments the same way name one piece of 

263 work. `None` when the arguments have no frozen form. 

264 

265 *given* joins the key because `apply_defaults()` below erases the very 

266 distinction `given()` reads: `build()` and `build(profile=<the default>)` 

267 freeze to identical arguments, and a task that branches on presence does 

268 different work for each. Keyed on values alone they would silently share a 

269 cell, and the second request would be answered by the first — no error, and 

270 nothing in the report to say the two were ever different. 

271 

272 *env* joins it for the same shape of bug with the other input a body can 

273 observe: a caller that set `ctx.env` before calling would otherwise be 

274 answered by an execution that never saw its variable — a wrong cache hit 

275 with no error. Every DAG node starts from the pinned snapshot and an 

276 untouched caller holds an equal copy, so digests diverge — splitting the 

277 cell, honestly — exactly when someone wrote the environment on purpose. 

278 """ 

279 from footman import _manifest 

280 

281 # The caller's signature: `ctx` is injected at the task boundary, so it is 

282 # stripped before binding — binding against the declared signature would 

283 # put the first positional value in the `ctx` slot and key every call on 

284 # the wrong arguments. 

285 try: 

286 bound = _manifest.call_signature(task).bind(*args, **kwargs) 

287 except TypeError: 

288 return None # a call that won't bind: let it raise where it is made 

289 bound.apply_defaults() 

290 frozen = tuple((name, _freeze(value)) for name, value in bound.arguments.items()) 

291 if any(value is _UNKEYABLE for _, value in frozen): 

292 return None 

293 return (registry.work_key(task), frozen, given, _env_digest(env)) 

294 

295 

296def _deadlock(run: _Session, key: Any, me: int) -> list[str] | None: 

297 """The wait-for chain proving this wait can never resolve, or `None`. 

298 

299 The static DAG has `_check_cycles`; a call graph only exists at runtime, so 

300 it is walked here: follow the key to its owning thread, then that thread's 

301 own wait, until either the chain ends (fine) or it arrives back at this 

302 thread (a cycle — nobody would ever finish). 

303 """ 

304 chain: list[str] = [] 

305 at: Any = key 

306 seen: set[Any] = set() 

307 while at is not None and at not in seen: 

308 seen.add(at) 

309 cell = run.cells.get(at) 

310 if cell is None or cell.future.done(): 310 ↛ 311line 310 didn't jump to line 311 because the condition on line 310 was never true

311 return None # finished work blocks nobody 

312 chain.append(cell.label) 

313 if cell.owner == me: 

314 return chain 

315 at = run.waits.get(cell.owner) 

316 return None 

317 

318 

319def _label(task: Any) -> str: 

320 return registry.cli_name(getattr(task, "__name__", "task")) 

321 

322 

323def call(task: Any, args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any: 

324 """A task called from Python — the body-call path. 

325 

326 Outside a run this is the plain function call it always was. Inside one it 

327 is the once-cell: hit the memo, wait for the thread that is running it, or 

328 claim it and run it here. 

329 """ 

330 from footman import context as _context 

331 

332 # The request takes its number now, in the caller's thread — for a 

333 # queued call this IS the written line; the thunk carries the number 

334 # back in through `_pending_seq` when the pool later runs it. 

335 carried = _pending_seq.get() 

336 seq = carried if carried is not None else next(_seq) 

337 

338 # A `with parallel()` block collects instead of running: the call is 

339 # queued and answers with a `Pending`. Checked first, because the block 

340 # is explicit intent — it holds whether or not a run is in flight. 

341 if (queued := _context._queue_call(task, args, kwargs, seq)) is not None: 

342 return queued 

343 

344 run = _active 

345 if run is None: 

346 _refuse_wide_moment(task) 

347 if isinstance(task, registry._Opted): 

348 return task._plain_call(args, kwargs) 

349 return registry.task_body(task)(*args, **kwargs) 

350 _refuse_unrunnable(task) 

351 from footman import _executor, _schedule, context 

352 

353 label = _label(task) 

354 seg = _schedule._default_seg(task) 

355 # Availability is a per-request gate on the declared path (`run_task` 

356 # checks it before the window opens), so a call checks it in the same 

357 # place: before any hook fires. 

358 if (refusal := _executor.unavailable(task, seg)) is not None: 

359 refusal.seq = seq 

360 _record(refusal) 

361 raise refusal.error or ChainError(f"{label} is unavailable") 

362 

363 # A call binds like a segment: omitted parameters consult the same sources 

364 # binding would (stdin, env, a required `ask()`), explicit values are 

365 # validated against their annotation, and resolution happens before the 

366 # key is computed so identity reads the values the body will receive. 

367 # With a lifecycle armed, the callee's context is born first and made 

368 # current around binding, so a `pre_bind` hook's `task.env` writes reach 

369 # `env()` fallbacks here exactly as they do on the declared path. 

370 life = _executor._lifecycle 

371 child: Any = None 

372 handle: Any = None 

373 if life is not None or registry.has_own_hooks(task): 

374 parent = context.current() 

375 buf = io.StringIO() 

376 child = parent.child( 

377 label, 

378 fn=task, 

379 cwd=None, # let the callee's own cwd policy resolve 

380 sink=buf, 

381 err_sink=buf, 

382 ) 

383 handle = _executor.TaskHandle(task, seg, child) 

384 if (err := _executor._enter_bind_hooks(life, handle)) is not None: 

385 # The attempt concluded before binding: the posts fire, the row 

386 # is recorded, and the failure raises at the call site. 

387 result = _executor._result(seg, 1, None, err, 0.0) 

388 result.seq = seq 

389 _executor._exit_task_hooks(life, handle, result) 

390 _record(result) 

391 raise err 

392 token = context._current.set(child) 

393 child.in_task = True 

394 try: 

395 args, kwargs, supplied = _executor.bind_call(task, args, kwargs) 

396 except Exception as exc: 

397 result = _executor._result(seg, _executor.EX_USAGE, None, exc, 0.0) 

398 result.seq = seq 

399 _executor._exit_task_hooks(life, handle, result) 

400 _record(result) 

401 raise 

402 finally: 

403 context._current.reset(token) 

404 child.given = supplied 

405 else: 

406 args, kwargs, supplied = _executor.bind_call(task, args, kwargs) 

407 # The environment the callee would start from, as known right here: the 

408 # already-born child's (post `pre_bind`, exactly what the declared path 

409 # keys on), else a copy of the caller's — which is what `child()` would 

410 # copy at birth. An untouched caller holds the run snapshot, so this 

411 # digests equal to a DAG node's and sharing is preserved. 

412 birth_env = (child if child is not None else context.current()).env 

413 key = _key(task, args, kwargs, supplied, birth_env) 

414 if key is None: # arguments with no frozen form: honest work every time 

415 return _run_now( 

416 task, 

417 args, 

418 kwargs, 

419 seg=seg, 

420 child=child, 

421 handle=handle, 

422 seq=seq, 

423 given=supplied, 

424 ) 

425 if unshared(task): 

426 # Asked for unshared: it neither reads a cell nor becomes one. Its 

427 # result is its own — the run's answer is only ever an execution that 

428 # was itself shareable, so how much work a run does cannot depend on 

429 # which of two nodes the scheduler happened to start first. 

430 return _run_now( 

431 task, 

432 args, 

433 kwargs, 

434 shared=False, 

435 seg=seg, 

436 child=child, 

437 handle=handle, 

438 seq=seq, 

439 given=supplied, 

440 ) 

441 

442 me = threading.get_ident() 

443 claimed, cell = _claim(run, key, me, label) 

444 if claimed: # nobody had run it: this thread owns it, inline, right here 

445 try: 

446 # The cell rides along so the sealed row lands on it *before* the 

447 # future resolves — a later sharer copies what this execution 

448 # reported (title, audit, reported value) and seats after it. 

449 value = _run_now( 

450 task, 

451 args, 

452 kwargs, 

453 seg=seg, 

454 child=child, 

455 handle=handle, 

456 cell=cell, 

457 seq=seq, 

458 given=supplied, 

459 ) 

460 except BaseException as exc: 

461 cell.future.set_exception(exc) 

462 raise 

463 cell.future.set_result(value) 

464 return value 

465 # The pair is per request — only the body is shared. The pre fires before 

466 # the wait, so a span honestly covers it; the post closes the request 

467 # with its `shared` row. A crashing hook must not pass silently here 

468 # either: it fails this request, at the call site. 

469 if handle is not None: 

470 handle._bind(args, kwargs) 

471 if (hook_error := _executor._enter_task_hooks(life, handle)) is not None: 

472 row = _executor._result(seg, 1, None, hook_error, 0.0) 

473 row.seq = seq 

474 _executor._exit_task_hooks(life, handle, row) 

475 _record(row) 

476 with run.lock: 

477 run.waits.pop(me, None) 

478 raise hook_error 

479 # The waiting request is a unit like any other — a scheduler node 

480 # satisfied by sharing counts, so a body call satisfied by sharing does 

481 # too, and the caller's block on the share is visible on the line. 

482 status = _claim_unit(label) 

483 try: 

484 # A finished cell answers instantly (the memo); a live one blocks until 

485 # the thread that claimed it is done, and both hand back one value. 

486 value = cell.future.result() 

487 except BaseException: 

488 if status is not None: 488 ↛ 489line 488 didn't jump to line 489 because the condition on line 488 was never true

489 status.unit_finished(label, False) 

490 raise 

491 finally: 

492 with run.lock: 

493 run.waits.pop(me, None) 

494 if status is not None: 

495 status.unit_finished(label, True) 

496 row = _shared_result(cell.label, value, cell.record, seq=seq) 

497 row.address = ( 

498 child.address 

499 if child is not None 

500 else context._child_address(context.current(), label) 

501 ) 

502 if handle is not None: 

503 post_error = _executor._exit_task_hooks(life, handle, row) 

504 folded = _executor.fold_post_error(row, post_error, clear_state=True) 

505 if folded is not None: 

506 _record(row) 

507 raise folded 

508 _record(row) 

509 return value 

510 

511 

512def _claim(run: _Session, key: Any, me: int, label: str) -> tuple[bool, _Cell]: 

513 """Take ownership of *key*'s cell, or hand back the cell already there. 

514 

515 Returns `(claimed, cell)` — `claimed` means this thread must run the work. 

516 Waiting on a cell is registered in the wait-for graph while the lock is 

517 held, so a cycle is caught before anyone blocks on it. 

518 """ 

519 with run.lock: 

520 cell = run.cells.get(key) 

521 if cell is None: 

522 cell = _Cell(me, label) 

523 run.cells[key] = cell 

524 return True, cell 

525 if cell.future.done(): 

526 return False, cell # already run this run: the memo answers 

527 if (chain := _deadlock(run, key, me)) is not None: 

528 raise ChainError( 

529 f"{label}: this call can never return — " 

530 f"{' → '.join([*chain, chain[0]])} waits on itself. " 

531 f"A task cannot call (directly or through another task) " 

532 f"a task that is waiting for it; declare the order with " 

533 f"pre= instead." 

534 ) 

535 run.waits[me] = key 

536 return False, cell 

537 

538 

539def work_of( 

540 task: Any, 

541 args: Any, 

542 kwargs: dict[str, Any], 

543 given: frozenset[str], 

544 env: dict[str, str], 

545) -> Any | None: 

546 """This request's cell key, or `None` when it cannot have one.""" 

547 return _key(task, tuple(args), kwargs, given, env) 

548 

549 

550def retire(key: Any) -> None: 

551 """Drop a finished cell so the next request for it runs fresh. 

552 

553 The one caller is the retry loop: an attempt that failed with attempts 

554 left must not be the answer a later request receives. Left in place, the 

555 memo would hand every subsequent attempt the first attempt's failure — 

556 the body would run once and be reported N times, which is the opposite 

557 of "each attempt IS a record". 

558 

559 Only ever called between attempts of work this thread owns, and only on 

560 a resolved cell. A requester that joined *during* the failed attempt has 

561 already been handed it: sharing binds to the terminal attempt for 

562 everyone who asks from here on, not retroactively. 

563 """ 

564 run = _active 

565 if run is None or key is None: 565 ↛ 566line 565 didn't jump to line 566 because the condition on line 565 was never true

566 return 

567 with run.lock: 

568 cell = run.cells.get(key) 

569 if cell is not None and cell.future.done(): 569 ↛ exitline 569 didn't jump to the function exit

570 del run.cells[key] 

571 

572 

573def claim(key: Any, label: str) -> tuple[bool, Any]: 

574 """Take this work, or hand back the cell that already holds it. 

575 

576 `(claimed, cell)` — `claimed` means the caller must perform the work and 

577 then `resolve` the cell. Otherwise the cell is someone else's: `join` it. 

578 `(True, None)` when there is no run to share within, so the caller simply 

579 proceeds. 

580 

581 A node comes through here exactly as a body call does. Peeking would not do: 

582 it can only see a *finished* cell, so two independent requests racing each 

583 other would both run — the duplication the cell exists to prevent, and 

584 unpredictable because it depends on which one started first. 

585 """ 

586 run = _active 

587 if run is None or key is None: 

588 return True, None 

589 return _claim(run, key, threading.get_ident(), label) 

590 

591 

592def join(cell: Any) -> Any: 

593 """Wait for the execution that holds this cell, and return its value. 

594 

595 Raises whatever the work raised, so a request answered by a failed 

596 execution fails too. Occupies this thread while it waits, bounded by 

597 `jobs`, exactly as a waiting body call does. 

598 """ 

599 me = threading.get_ident() 

600 try: 

601 return cell.future.result() 

602 finally: 

603 if (run := _active) is not None: 

604 with run.lock: 

605 run.waits.pop(me, None) 

606 

607 

608def resolve( 

609 cell: Any, value: Any, error: BaseException | None, record: Any = None 

610) -> None: 

611 """Hand this claimed cell its outcome, so anyone waiting is answered. 

612 

613 Always called for a claimed cell, on every path out — an unresolved cell 

614 would leave a waiter blocked for the rest of the run. *record* is the 

615 execution's sealed row, when there is one: a shared answer is the record 

616 reused, so a later request's row copies what this one *reported* — 

617 reviewed title, reported value, audit — not just the body's value. 

618 """ 

619 if cell is None or cell.future.done(): 

620 return 

621 cell.record = record 

622 if error is not None: 

623 cell.future.set_exception(error) 

624 else: 

625 cell.future.set_result(value) 

626 

627 

628def shared_result(label: str, value: Any, record: Any = None) -> TaskResult: 

629 """The report entry for a request an earlier execution satisfied.""" 

630 return _shared_result(label, value, record) 

631 

632 

633def _record(result: TaskResult) -> None: 

634 """Add a called task's outcome to the run's report — ran, refused, denied, 

635 or satisfied by an execution the run had already performed.""" 

636 if (run := _active) is not None: 636 ↛ exitline 636 didn't return from function '_record' because the condition on line 636 was always true

637 run.results.append(result) 

638 

639 

640def _shared_result( 

641 label: str, value: Any, record: Any = None, seq: int | None = None 

642) -> TaskResult: 

643 """The report entry for a request the run had already satisfied. 

644 

645 Recorded rather than left invisible: the work happened, and a reader (or a 

646 journal) should see that a second request for it was answered instead of 

647 silently vanishing. Nothing blocked it — it was answered, instantly or 

648 after a wait — so it carries no blame; `blocked_by` belongs to rows that 

649 are holes. The *request* has a real moment, the instant it concluded, and 

650 that is its `started`: the report seats it where it actually happened, 

651 exactly as an executed body-callee seats after its caller. `ok` is true 

652 because the work did succeed, just earlier, so the exit code is untouched. 

653 """ 

654 import time 

655 

656 from footman._executor import TaskResult 

657 

658 row = TaskResult( 

659 task=label, 

660 ok=True, 

661 returned=value, 

662 state="shared", 

663 started=time.perf_counter(), 

664 ) 

665 if record is not None: 665 ↛ 675line 665 didn't jump to line 675 because the condition on line 665 was always true

666 # A shared answer is the record reused: the row a reader sees carries 

667 # what the execution *reported* — a reviewed title, a rewritten 

668 # reported value, the audit — never a fresher, less honest copy. 

669 row.returned = record.returned 

670 row.title = record.title 

671 row.audit = list(record.audit) 

672 # The share concluded after the execution it joined, and the tie-break 

673 # must never say otherwise: the row's stamp is floored just above its 

674 # record's, whatever number the request itself took. 

675 floor = record.seq + 1 if record is not None and record.seq is not None else None 

676 row.seq = ( 

677 max(seq, floor) 

678 if seq is not None and floor is not None 

679 else (seq if floor is None else floor) 

680 ) 

681 return row 

682 

683 

684def _claim_unit(label: str) -> Any: 

685 """Start this request's unit on the live status line, or claim the one 

686 already counted for it; `None` when no line is running. 

687 

688 A request is a unit — a scheduler node, a `parallel()` child, a body 

689 call. The one thing that must never happen is counting a single piece of 

690 work twice, and `parallel()` cannot tell from the outside whether the 

691 thunk it was handed is a task call in disguise: a closure is opaque. So 

692 it counts every child and hands the unit down (`Context.unit_pending`), 

693 and the first request inside takes it over rather than adding its own. 

694 Cleared on claim, so a second call in the same thunk — and everything 

695 the callee goes on to ask for — counts honestly. 

696 """ 

697 from footman import context 

698 

699 status = context.active_status() 

700 if status is None: 

701 return None 

702 ctx = context.current() 

703 if ctx.unit_pending: 

704 ctx.unit_pending = False 

705 return None # counted and displayed by whoever handed it down 

706 status.unit_added(1) 

707 status.unit_started(label) 

708 return status 

709 

710 

711def _refuse_wide_moment(task: Any) -> None: 

712 """Refuse a task call from `pre_tasks` / `post_tasks`. 

713 

714 Both moments sit outside the run — `pre_tasks` at discovery, `post_tasks` 

715 once the plan is finished — so there is no boundary to give a call: no 

716 result row, no sharing, no availability gate, and a task declaring `ctx` 

717 would take the first argument into that slot. Worse for `pre_tasks`, which 

718 also runs in the child that rebuilds the completion manifest: a call there 

719 executes the task on a <kbd>Tab</kbd> press. 

720 

721 A call from *outside footman entirely* (a REPL, an import of the tasks 

722 module) is untouched — it is the plain function call it looks like. 

723 """ 

724 from footman._executor import _wide_moment 

725 

726 moment = _wide_moment.get() 

727 if moment is None: 

728 return 

729 name = _label(task) 

730 when = ( 

731 "runs at discovery — including in the child that rebuilds the " 

732 "completion manifest, where it would run on a Tab press" 

733 if moment == "pre_tasks" 

734 else "runs after the plan is finished, when the report is already built" 

735 ) 

736 raise ChainError( 

737 f"{name} cannot be called from @{moment}, which {when}. Edit the tree " 

738 f"instead (inv.tasks — say pre=[{name}] on the tasks that need it), or " 

739 f"move the call to a per-task moment (@pre_task / @post_task), which " 

740 f"runs inside the run and gives the call a real task boundary." 

741 ) 

742 

743 

744def _refuse_unrunnable(task: Any) -> None: 

745 """Refuse the two body calls that cannot be given a task boundary. 

746 

747 A `serial=`/`exclusive=` task's lane is acquired at the boundary and never 

748 mid-body — the invariant that keeps the arbiter deadlock-free — and an 

749 `infinite` task is a future that never resolves. 

750 """ 

751 name = _label(task) 

752 if registry.task_lane(task) is not None: 

753 raise ChainError( 

754 f"{name} declares a serial/exclusive lane, so it cannot be called " 

755 f"from a task body — its lane is taken at the task boundary, never " 

756 f"mid-body. Declare it as pre=[{name}] instead." 

757 ) 

758 if registry.is_infinite(task): 

759 raise ChainError( 

760 f"{name} is an infinite task, so calling it from a task body would " 

761 f"never return. Run it as its own segment, or declare it as " 

762 f"pre=[{name}]." 

763 ) 

764 

765 

766def _run_now( 

767 task: Any, 

768 args: tuple[Any, ...], 

769 kwargs: dict[str, Any], 

770 *, 

771 shared: bool = True, 

772 seg: Any = None, 

773 child: Any = None, 

774 handle: Any = None, 

775 cell: Any = None, 

776 seq: int | None = None, 

777 given: frozenset[str] = frozenset(), 

778) -> Any: 

779 """Run *task* here and now with full task semantics, and return its value. 

780 

781 A failure raises in the caller, exactly as a direct call always did — and 

782 the callee's own `TaskResult` still joins the run's report, so the work is 

783 visible even though the value came back through Python. 

784 

785 Availability was checked in `call()`, before any hook fired; `confirm=` 

786 is the one gate that cannot be resolved up front the way the scheduler 

787 resolves a segment's — a call is not knowable before the run — so it is 

788 asked here, at the moment of execution (a request the run has already 

789 answered never re-asks). 

790 """ 

791 from footman import _executor, _globals, _schedule, context 

792 

793 parent = context.current() 

794 label = _label(task) 

795 if seg is None: 795 ↛ 796line 795 didn't jump to line 796 because the condition on line 795 was never true

796 seg = _schedule._default_seg(task) 

797 if (denial := _schedule.confirm_gate(task, seg, parent)) is not None: 

798 denial.seq = seq 

799 _record(denial) 

800 raise denial.error or ChainError(f"{label} was not confirmed") 

801 if child is None: 

802 buf = io.StringIO() 

803 child = parent.child( 

804 label, 

805 fn=task, 

806 cwd=None, # let the callee's own cwd policy resolve 

807 sink=buf, 

808 err_sink=buf, 

809 ) 

810 else: 

811 buf = child.sink 

812 # Presence is the callee's own, never the caller's: the birth inherits 

813 # every unlisted field, so a child born from a parent that was given 

814 # `--agent` would otherwise inherit the claim that *it* was given one. 

815 child.given = given 

816 # Unsharedness propagates: what this callee asks for is asked the same 

817 # way, unless that task declares its own answer. 

818 child.shared = parent.shared and shared 

819 # A body call is a request like a scheduler node or a parallel() child: 

820 # a unit on the live status line, counted the moment it becomes real 

821 # work (a confirm= denial above never was one). 

822 status = _claim_unit(label) 

823 try: 

824 result = _executor.run_bound( 

825 task, seg, child, list(args), dict(kwargs), as_call=True, handle=handle 

826 ) 

827 except BaseException: 

828 if status is not None: 

829 status.unit_finished(label, False) 

830 raise 

831 if status is not None: 

832 status.unit_finished(label, result.ok) 

833 result.seq = seq 

834 _record(result) 

835 if cell is not None: 

836 cell.record = result # the sealed row, for later requests to reuse 

837 if text := buf.getvalue(): 

838 # The callee ran with its own buffer so its output stays one block. 

839 # Where that block goes depends on the caller: a capturing parent 

840 # (`--json`, a document run, a `parallel()` child) owns it and takes 

841 # it; an uncaptured parent is streaming to the terminal, so it goes 

842 # there — the same handoff `parallel()` makes for its children, 

843 # status line warned first because this write bypasses the routers. 

844 if parent.sink is not None: 844 ↛ 845line 844 didn't jump to line 845 because the condition on line 844 was never true

845 parent.sink.write(text) 

846 else: 

847 dest = context.real_stdout() 

848 with _globals.console_gate(): 

849 if status is not None: 849 ↛ 850line 849 didn't jump to line 850 because the condition on line 849 was never true

850 status.notify(text) 

851 dest.write(text) 

852 dest.flush() 

853 if result.error is not None: 

854 # A failure raises in the caller, exactly as a direct call always did. 

855 raise result.error 

856 if not result.ok: # a bare `sys.exit(N)`, which carries no error object 856 ↛ 857line 856 didn't jump to line 857 because the condition on line 856 was never true

857 raise ChainError(f"{label} exited with code {result.code}") 

858 # The body's own return, never the reported one: what a body caller 

859 # receives was snapshotted at the body's exit, whatever a reviewer 

860 # later did to the report. 

861 return result.body_returned