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
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:13 +0000
1"""Prerequisite resolution, guard evaluation, and outcome bookkeeping.
3Small on purpose. make gave four behaviours for free, and this module is what buys them
4back; nothing else belongs here.
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.
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"""
21from __future__ import annotations
23from dataclasses import dataclass, field
24from enum import StrEnum
26from .config import Config
27from .spec import Failed, Skip, lookup
30class Status(StrEnum):
31 """The four outcomes a task can have."""
33 OK = "ok"
34 SKIPPED = "skipped"
35 FAILED = "failed"
36 BLOCKED = "blocked"
39@dataclass(frozen=True)
40class Result:
41 """What happened to one task.
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 """
52 name: str
53 status: Status
54 detail: str = ""
55 code: int = 0
58@dataclass
59class Run:
60 """One invocation: the results so far, and the tasks already attempted."""
62 results: list[Result] = field(default_factory=list)
63 seen: set[str] = field(default_factory=set)
65 @property
66 def failed(self) -> bool:
67 """Whether any task failed or was blocked.
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)
74 def status_of(self, name: str) -> Status | None:
75 """Return the recorded status of a task, if it ran.
77 Args:
78 name: Task name.
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)
85 def exit_code(self) -> int:
86 """Return the aggregate exit status: 0 when nothing failed or was blocked, else non-zero.
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.
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.
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:
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)
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:
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
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:
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
137def run(names: list[str], cfg: Config) -> Run:
138 """Run the named tasks and their prerequisites, in order.
140 Args:
141 names: Task names, as typed on the command line.
142 cfg: The resolved config.
144 Returns:
145 The completed :class:`Run`.
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)
157 run_state = Run()
158 for name in names:
159 _run_one(name, cfg, run_state)
160 return run_state
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.
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)
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
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))