Coverage for src/footman/invocation.py: 95%
38 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"""The handle a lifecycle hook is given: one object per `fm` line.
3A run has three scopes, and they are different animals worth different words:
5* the **invocation** — one `fm` line, described by this object;
6* a **context** — one task, `ctx`, with its own environment overlay and cwd;
7* a **run** — one step, what `run()` spawns.
9`Invocation` is the first. It is born knowing what the line asked for and where
10it was asked, it is *editable* at `pre_tasks` — the one single-threaded moment
11before anything runs — and it is frozen for the parallel window, so a hook on a
12pool thread can read it without a lock and cannot race another one writing it.
14The determinism rule that shapes it: tree and availability edits derive from
15files, config and environment — **never** from `inv.cli`. The manifest is built
16by a detached refresh child that has no command line at all, so an edit that
17depended on one would make completion disagree with the run. The child is given
18an invocation with no `cli`, which turns that rule into something the code
19enforces rather than something the docs ask for.
20"""
22from __future__ import annotations
24from typing import TYPE_CHECKING, Any
26if TYPE_CHECKING:
27 from footman.registry import Tasks
30class Frozen(RuntimeError):
31 """A write to an invocation that is no longer editable."""
34class Invocation:
35 """What one `fm` line is doing, handed to every lifecycle hook.
37 Editable only at `pre_tasks`; every later moment sees the same object with
38 writes refused, so what a task-time hook reads is what the run decided.
39 """
41 __slots__ = (
42 "_frozen",
43 "cli",
44 "config",
45 "cwd",
46 "results",
47 "root",
48 "skipped",
49 "tasks",
50 "total_ms",
51 )
53 _frozen: bool
54 cli: dict[str, Any]
55 config: dict[str, Any]
56 root: str
57 cwd: str
58 tasks: Tasks | None
60 def __init__(
61 self,
62 *,
63 cli: dict[str, Any] | None = None,
64 config: dict[str, Any] | None = None,
65 root: str = "",
66 cwd: str = "",
67 tasks: Tasks | None = None,
68 ) -> None:
69 object.__setattr__(self, "_frozen", False)
70 self.cli = dict(cli or {})
71 """The global options this line carried. Empty in the manifest refresh
72 child, which has no command line — so a tree edit that reads this would
73 be reading nothing there, which is exactly why it must not."""
74 self.config = dict(config or {})
75 """The merged `[tool.footman]` configuration."""
76 self.root = root
77 """The cascade root: the directory of the highest `tasks.py`."""
78 self.cwd = cwd
79 """Where `fm` was invoked."""
80 self.tasks = tasks
81 """The merged command tree, as a `Tasks` view — the same surface a
82 `TaskView` edit uses. Editable at `pre_tasks`, when a hook may disable a
83 task, add a prerequisite, or read provenance."""
84 self.results: tuple[Any, ...] = ()
85 """The run's rows, in the order the work was created, as result
86 views — filled for
87 `post_tasks` (the run itself writes past the freeze; hooks read).
88 Executions, `shared` rows, refusals and `skipped` nodes alike."""
89 self.skipped: tuple[Any, ...] = ()
90 """The `skipped` subset of `results` — what never ran because
91 something it needed failed."""
92 self.total_ms: float = 0.0
93 """Wall-clock for the whole run, the envelope's `total_ms` — filled
94 for `post_tasks`."""
96 def freeze(self) -> None:
97 """Close the invocation for writes — called once, before tasks run."""
98 object.__setattr__(self, "_frozen", True)
100 def __setattr__(self, name: str, value: Any) -> None:
101 if getattr(self, "_frozen", False):
102 raise Frozen(
103 f"inv.{name} cannot be set now: the invocation is editable only "
104 f"at pre_tasks, before anything runs. Per-task state belongs on "
105 f"task.state, and per-task environment on ctx.env."
106 )
107 object.__setattr__(self, name, value)
109 def __repr__(self) -> str:
110 shape = "frozen" if self._frozen else "editable"
111 return f"<Invocation {shape} cwd={self.cwd!r}>"