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
« 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.
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.
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"""
15from __future__ import annotations
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
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
59EX_USAGE = 64
60"""The refusal exit code — footman did not understand the command line.
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."""
69@dataclass
70class TaskResult:
71 """Outcome of running one segment."""
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.
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:
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.
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:
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.
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."""
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)
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)
198def reported_state(result: TaskResult) -> str:
199 """The one word for what happened, resolved from the parts.
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"
212def resolve(root: Group, path: list[str]) -> Task:
213 """Walk *path* (`["docs", "build"]`) to its task function.
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
230_MISSING = object()
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.
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
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.
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()
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).
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
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
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)
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.
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})"
366 def one(token: str) -> Any:
367 return _coerce_extra(token, peeled, label, params)
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)
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.
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]
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]
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.
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
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")
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
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
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.
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)"
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)
508 if marker.lines:
509 lines = _decode_stdin(payload).splitlines()
510 return [_coerce_extra(line, peeled, label, params) for line in lines]
512 if peeled.element is bytes:
513 return _run_checks(payload, peeled, label, params)
515 target = _document_shape(peeled)
516 if target is not None:
517 bound = _binder.bind_document(_stdin_json(payload), target, param.name)
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 )
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)
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)
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)
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
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
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.
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.
617 A secret never shows its default — that would defeat the point — though
618 Enter still accepts it.
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 )
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()
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
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
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
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
711class _SiblingView(Mapping[str, Any]):
712 """The left-siblings mapping handed to a contextual `check` or `default`.
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.
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 """
725 __slots__ = ("_later", "_reader", "_values")
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
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)
750 def __iter__(self) -> Iterator[str]:
751 return iter(self._values)
753 def __len__(self) -> int:
754 return len(self._values)
756 def __repr__(self) -> str:
757 return f"{type(self).__name__}({self._values!r})"
760_EMPTY_VIEW = _SiblingView({}, "", frozenset())
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.
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.
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))
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.
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)
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*.
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.
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.
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()
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
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 )
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
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)
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.
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.
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.
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)
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
1163@dataclass(frozen=True)
1164class _CallPlan:
1165 sig: inspect.Signature # the caller's signature: ctx already stripped
1166 entries: tuple[_PlanParam, ...] # in signature order
1169_CALL_PLAN = "_footman_call_plan"
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.
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
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
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."""
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)
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)
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.
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.
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.
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)
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
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
1463class Unavailable(Exception):
1464 """A `@requires`-gated task was asked to run; the message is the reason."""
1467def unavailable(fn: Task, seg: Segment) -> TaskResult | None:
1468 """The refusal for a `@requires`-gated task, or `None` when it may run.
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 )
1489def resolve_cwd(fn: Task, ctx: Context) -> tuple[Path | None, bool]:
1490 """The task's working directory under the policy ladder, resolved once.
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.
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
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)
1550@contextlib.contextmanager
1551def _serial_globals(ctx: Context) -> Generator[None]:
1552 """A serial/exclusive body owns the real process globals.
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
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.
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.
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.
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.
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
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)
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
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
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 )
1784# --- the per-task lifecycle: pre_task / post_task ----------------------------
1787_hook_owner: contextvars.ContextVar[str | None] = contextvars.ContextVar(
1788 "footman_hook_owner", default=None
1789)
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)
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)
1810class HookFailed(RuntimeError):
1811 """A per-task lifecycle hook raised; the message names the plugin."""
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, ...]
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
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
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.
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 )
1864def clear_lifecycle() -> None:
1865 global _lifecycle
1866 _lifecycle = None
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.
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
1914class TaskHandle:
1915 """One task execution, as a per-task lifecycle hook sees it.
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 """
1922 __slots__ = ("_bound", "_ctx", "_fn", "_kwargs", "_raw_args", "_states", "name")
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
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)
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))
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 )
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
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
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
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
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)
2010class ResultView:
2011 """A `post_task`'s view of the result: the sealed record, read-only.
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 """
2021 __slots__ = ("_result",)
2023 def __init__(self, result: TaskResult) -> None:
2024 object.__setattr__(self, "_result", result)
2026 def __getattr__(self, name: str) -> Any:
2027 return getattr(object.__getattribute__(self, "_result"), name)
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 )
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 )
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
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
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).
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")
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).
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")
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.
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
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()).
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)
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.
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
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
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)
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")
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}"
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
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 )
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
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 )