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

1"""The handle a lifecycle hook is given: one object per `fm` line. 

2 

3A run has three scopes, and they are different animals worth different words: 

4 

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. 

8 

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. 

13 

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""" 

21 

22from __future__ import annotations 

23 

24from typing import TYPE_CHECKING, Any 

25 

26if TYPE_CHECKING: 

27 from footman.registry import Tasks 

28 

29 

30class Frozen(RuntimeError): 

31 """A write to an invocation that is no longer editable.""" 

32 

33 

34class Invocation: 

35 """What one `fm` line is doing, handed to every lifecycle hook. 

36 

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 """ 

40 

41 __slots__ = ( 

42 "_frozen", 

43 "cli", 

44 "config", 

45 "cwd", 

46 "results", 

47 "root", 

48 "skipped", 

49 "tasks", 

50 "total_ms", 

51 ) 

52 

53 _frozen: bool 

54 cli: dict[str, Any] 

55 config: dict[str, Any] 

56 root: str 

57 cwd: str 

58 tasks: Tasks | None 

59 

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`.""" 

95 

96 def freeze(self) -> None: 

97 """Close the invocation for writes — called once, before tasks run.""" 

98 object.__setattr__(self, "_frozen", True) 

99 

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) 

108 

109 def __repr__(self) -> str: 

110 shape = "frozen" if self._frozen else "editable" 

111 return f"<Invocation {shape} cwd={self.cwd!r}>"