Coverage for src/rhiza_task/runner.py: 100%

61 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-25 04:13 +0000

1"""Prerequisite resolution, guard evaluation, and outcome bookkeeping. 

2 

3Small on purpose. make gave four behaviours for free, and this module is what buys them 

4back; nothing else belongs here. 

5 

61. **Dedup within one invocation.** Eleven tasks name ``install`` as a prerequisite and 

7 ``all`` names eight of those. Without a seen-set, ``rhiza-task all`` would sync the 

8 environment eight times. 

92. **Depth-first ordering.** ``book`` needs ``test``, which needs ``install``. 

103. **A failed prerequisite stops its dependents.** As make does, rather than running a 

11 gate against a half-built environment. 

124. **A missing prerequisite is not an error.** book.mk declares ``test:: ; @:`` no-op 

13 stubs so ``book`` can depend on gates that may not have been synced; here a 

14 prerequisite absent from the registry is simply not run, and the stubs are gone. 

15 

16Every name goes through :func:`~rhiza_task.spec.lookup` rather than a dict subscript, so 

17``test`` means pytest in a Python repository and ``cargo nextest`` in a crate. That is the 

18question the make layer answered by syncing exactly one language fragment. 

19""" 

20 

21from __future__ import annotations 

22 

23from dataclasses import dataclass, field 

24from enum import StrEnum 

25 

26from .config import Config 

27from .spec import Failed, Skip, lookup 

28 

29 

30class Status(StrEnum): 

31 """The four outcomes a task can have.""" 

32 

33 OK = "ok" 

34 SKIPPED = "skipped" 

35 FAILED = "failed" 

36 BLOCKED = "blocked" 

37 

38 

39@dataclass(frozen=True) 

40class Result: 

41 """What happened to one task. 

42 

43 Attributes: 

44 name: The task name. 

45 status: Its outcome. 

46 detail: Why, for anything other than :attr:`Status.OK`. 

47 code: The failing process's own exit status, carried from 

48 :class:`~rhiza_task.spec.Failed` so :meth:`Run.exit_code` can propagate it. 

49 0 for every outcome that is not a failure. 

50 """ 

51 

52 name: str 

53 status: Status 

54 detail: str = "" 

55 code: int = 0 

56 

57 

58@dataclass 

59class Run: 

60 """One invocation: the results so far, and the tasks already attempted.""" 

61 

62 results: list[Result] = field(default_factory=list) 

63 seen: set[str] = field(default_factory=set) 

64 

65 @property 

66 def failed(self) -> bool: 

67 """Whether any task failed or was blocked. 

68 

69 Returns: 

70 True when the invocation should exit non-zero. 

71 """ 

72 return any(r.status in {Status.FAILED, Status.BLOCKED} for r in self.results) 

73 

74 def status_of(self, name: str) -> Status | None: 

75 """Return the recorded status of a task, if it ran. 

76 

77 Args: 

78 name: Task name. 

79 

80 Returns: 

81 The status, or None when the task was not attempted. 

82 """ 

83 return next((r.status for r in self.results if r.name == name), None) 

84 

85 def exit_code(self) -> int: 

86 """Return the aggregate exit status: 0 when nothing failed or was blocked, else non-zero. 

87 

88 The first real failure's own code is propagated where there is one, so a caller can 

89 still distinguish e.g. pytest's 2 from a gate that merely exited 1. "First real" 

90 means the first :attr:`Status.FAILED` entry: a :attr:`Status.BLOCKED` dependent has 

91 no process of its own, and the failure that blocked it is recorded earlier in the 

92 list, so it is the one that speaks. Anything outside a shell's 1-255 range -- a 

93 code of 0, or the negative signal number ``subprocess`` reports for a killed child 

94 -- collapses to 1, since it cannot be handed to ``exit`` as-is. 

95 

96 Returns: 

97 0 when nothing failed or was blocked; else the first failing task's exit status, 

98 or 1 when that status is unusable. 

99 

100 Examples: 

101 An empty run, and a run whose only entry is a skip, both succeed -- a skip is 

102 an outcome, not a failure, and ``--strict`` is the switch that changes that: 

103 

104 >>> state = Run() 

105 >>> state.exit_code() 

106 0 

107 >>> state.results.append(Result("fmt", Status.SKIPPED, "no .pre-commit-config.yaml")) 

108 >>> state.failed, state.exit_code() 

109 (False, 0) 

110 

111 A failure, and the dependent it blocks, are both non-zero -- and pytest's own 2 

112 is what the run exits with, not a flattened 1: 

113 

114 >>> state.results.append(Result("test", Status.FAILED, "tests failed", 2)) 

115 >>> state.results.append(Result("book", Status.BLOCKED, "prerequisite failed: test")) 

116 >>> state.failed, state.exit_code() 

117 (True, 2) 

118 >>> state.status_of("book") is Status.BLOCKED 

119 True 

120 >>> state.status_of("todos") is None 

121 True 

122 

123 A failure with no usable code of its own -- a guard's own verdict rather than a 

124 child process's, or a blocked dependent standing alone -- is 1: 

125 

126 >>> Run([Result("doctor", Status.FAILED, "missing or outdated: uv")]).exit_code() 

127 1 

128 >>> Run([Result("book", Status.BLOCKED, "prerequisite failed: test")]).exit_code() 

129 1 

130 """ 

131 if not self.failed: 

132 return 0 

133 code = next((r.code for r in self.results if r.status is Status.FAILED), 1) 

134 return code if 1 <= code <= 255 else 1 

135 

136 

137def run(names: list[str], cfg: Config) -> Run: 

138 """Run the named tasks and their prerequisites, in order. 

139 

140 Args: 

141 names: Task names, as typed on the command line. 

142 cfg: The resolved config. 

143 

144 Returns: 

145 The completed :class:`Run`. 

146 

147 Raises: 

148 KeyError: When an explicitly requested task does not exist *in this repository's 

149 layers*. Only for requested names -- an unknown prerequisite is skipped, 

150 whereas an unknown request is a typo and should say so. 

151 """ 

152 unknown = [n for n in names if lookup(n, cfg.layers) is None] 

153 if unknown: 

154 msg = f"unknown task{'s' if len(unknown) > 1 else ''}: {', '.join(unknown)}" 

155 raise KeyError(msg) 

156 

157 run_state = Run() 

158 for name in names: 

159 _run_one(name, cfg, run_state) 

160 return run_state 

161 

162 

163# radon scores this function C (12), which is one branch per *outcome* a task can have: 

164# already seen, unknown, blocked by a prerequisite, skipped, skipped under --strict, 

165# failed, ok. Each writes exactly one line of the run summary, so the branch count is the 

166# size of :class:`Status` plus the two early returns -- flat dispatch over a closed set, 

167# and deliberate. Splitting it would put the outcomes in two places. 

168def _run_one(name: str, cfg: Config, state: Run) -> None: 

169 """Run one task after its prerequisites, recording the outcome. 

170 

171 Args: 

172 name: Task name. 

173 cfg: The resolved config. 

174 state: The invocation state, appended to in place. 

175 """ 

176 spec = lookup(name, cfg.layers) 

177 # The registry key, not the requested name: `rust:test` and `test` are one task in a 

178 # crate, and a run that named both would otherwise run it twice. 

179 if spec is None or spec.key in state.seen: 

180 return 

181 state.seen.add(spec.key) 

182 

183 for need in spec.needs: 

184 _run_one(need, cfg, state) 

185 blocked = [n for n in spec.needs if state.status_of(n) in {Status.FAILED, Status.BLOCKED}] 

186 if blocked: 

187 state.results.append(Result(spec.name, Status.BLOCKED, f"prerequisite failed: {', '.join(blocked)}")) 

188 return 

189 

190 try: 

191 for guard in spec.guards: 

192 guard.check(cfg.root, cfg.folders) 

193 spec.run(cfg) 

194 except Skip as exc: 

195 # The strict switch is the whole reason Skip is a distinct outcome rather than a 

196 # warning printed on the way to exit 0. 

197 if cfg.strict: 

198 state.results.append(Result(spec.name, Status.FAILED, f"skipped under --strict: {exc}")) 

199 else: 

200 state.results.append(Result(spec.name, Status.SKIPPED, str(exc))) 

201 except Failed as exc: 

202 state.results.append(Result(spec.name, Status.FAILED, str(exc), exc.code)) 

203 else: 

204 state.results.append(Result(spec.name, Status.OK))