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

65 statements  

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

1"""The task model: what a rhiza gate *is*, independently of how it is invoked. 

2 

3Reading all ten make fragments back to back, every recipe has the same three parts: 

4 

51. A **guard** -- ``if [ -d ${SOURCE_FOLDER} ]``, or a ``find`` for test files. When it 

6 fails the recipe prints a yellow WARN and exits 0. 

72. A **provision** -- ``uvx <tool>`` or ``uv run --with a --with b <tool>``. 

83. An **invocation** -- a long, mostly static argument list with a few substitutions. 

9 

10Only three recipes in the whole layer need more than that: ``test`` (retry on pytest exit 

113), ``doctor`` (version comparison) and ``book`` (a per-notebook export loop). So the model 

12here is declarative, and the task body is the escape hatch those three use. 

13 

14The split decides what is *data* -- reviewable, diffable, overridable from a consumer's 

15``pyproject.toml`` -- and what is code. 

16 

17The other thing a task carries is its **layer**. rhiza has three language layers whose 

18gates share a name and differ only in engine -- ``test`` is pytest, ``cargo nextest`` or 

19``go test`` -- and the make layer expressed that by syncing exactly one of python.mk, 

20rust.mk and go.mk into a repo, so the question never arose at runtime. Here all three are 

21installed at once, so the layer is part of the key: ``python:test`` and ``rust:test`` are 

22distinct entries, and :func:`lookup` resolves the bare name against the layers the 

23repository actually has. A task with no layer -- ``fmt``, ``todos``, ``book`` -- is 

24language-neutral and answers to its bare name, which is what ``core`` was. 

25""" 

26 

27from __future__ import annotations 

28 

29import shutil 

30from collections.abc import Callable, Iterator, Sequence 

31from dataclasses import dataclass 

32from pathlib import Path 

33from typing import TYPE_CHECKING 

34 

35if TYPE_CHECKING: # pragma: no cover - import cycle: config imports nothing from here 

36 from .config import Config 

37 

38 

39class Skip(Exception): # noqa: N818 - an outcome, not an error; see runner.py 

40 """Raised by a guard or a task body to report that there was nothing to do. 

41 

42 The make layer signals this by printing a WARN and exiting 0, which is how jointview 

43 ended up with a ``rhiza-test`` that "silently passed over nothing" -- its own Makefile 

44 says so. Making it a distinct outcome rather than a success is the point: ``--strict`` 

45 turns every skip into a failure, so CI can assert that a gate measured something. 

46 """ 

47 

48 

49class Failed(Exception): # noqa: N818 - ditto 

50 """Raised when a task's command exited non-zero. Carries the exit status.""" 

51 

52 def __init__(self, code: int, detail: str = "") -> None: 

53 """Store the exit status so the CLI can propagate it. 

54 

55 Args: 

56 code: The failing process's exit status. 

57 detail: Human-readable context. 

58 """ 

59 super().__init__(detail or f"exit {code}") 

60 self.code = code 

61 

62 

63# This class used to hold five flat ``if ... raise Skip`` clauses, which scored C (14) 

64# against `complexity_max = 15` -- one branch of headroom, so the sixth guard kind would 

65# have tripped `rhiza-task complexity` rather than merely approached it. It is now the 

66# decomposition that comment named: :func:`_clauses` yields one ``(unmet, message)`` pair 

67# per precondition and :meth:`Guard.check` raises on the first unmet one. 

68# 

69# Three properties the flat form had, kept deliberately: 

70# 

71# * **The order still reads off the source** -- tool before file before folder before glob, 

72# cheapest and most likely first. That ordering was the whole reason the flat form was 

73# defended, and a generator preserves it where a dict of predicates would not. 

74# * **Evaluation is still lazy.** Because :func:`_clauses` is a generator consumed one pair 

75# at a time, a guard whose tool is absent never stats the filesystem -- the same work the 

76# flat form did, in the same order. A tuple of eagerly-built pairs would have run every 

77# predicate before testing the first. 

78# * **A sixth guard kind now costs one ``yield``**, not two branches in an already-full 

79# block. That is the point of having done this: the next kind is an edit, not a 

80# decomposition. 

81# 

82# `_clauses` is a module-level function rather than a second method, and that is load-bearing 

83# rather than stylistic: radon scores a *class* as the sum of its methods, so moving these 

84# branches to `Guard._clauses` would have relocated the 14 and reduced nothing. As written 

85# the class scores A. Being module-private it also stays inside this module, which rule 4 of 

86# CLAUDE.md's layering invariant requires of any underscore-prefixed name. 

87@dataclass(frozen=True) 

88class Guard: 

89 """A precondition on the repository layout. 

90 

91 ``folder`` names a :class:`~rhiza_task.config.Config` field rather than a path, so 

92 ``Guard("source_folder")`` means "SOURCE_FOLDER must exist" without this module 

93 knowing that jointview sets it to ``src``. 

94 

95 ``glob`` additionally requires a matching file below that folder -- the declarative 

96 form of python.mk's ``find ${TESTS_FOLDER} -name 'test_*.py'``. 

97 

98 ``file`` is the flat case the Rust and Go layers need: their gates are guarded on a 

99 manifest rather than a folder, because ``cargo`` and ``go`` find the sources 

100 themselves. It is a literal path, not a config field -- ``Cargo.toml`` and ``go.mod`` 

101 are named by their toolchains and are not a repository's choice to make. 

102 

103 ``tool`` is a precondition on the *machine* rather than on the repository, and it is 

104 what github.mk's ``require-gh`` was: a target whose whole body is 

105 ``command -v gh >/dev/null || exit 1``, declared as a prerequisite of every helper. 

106 The five bundle-owned fragments are mostly wrappers over a CLI nobody can assume is 

107 installed -- gh, docker, git-lfs, tectonic, marp -- so the check is declared once here 

108 rather than repeated as the first three lines of a dozen task bodies. 

109 

110 A missing tool is a :class:`Skip`, not a failure, which is a deliberate change from 

111 ``require-gh``'s hard exit. Nothing here is a gate, so a machine without docker should 

112 not fail a run that asked for something else too -- and ``--strict`` is the switch for 

113 a caller who does want it to. 

114 """ 

115 

116 folder: str | None = None 

117 glob: str | None = None 

118 reason: str = "" 

119 file: str | None = None 

120 tool: str | None = None 

121 

122 def check(self, root: Path, folders: dict[str, str]) -> None: 

123 """Raise :class:`Skip` when the guard is not satisfied. 

124 

125 Args: 

126 root: Repository root. 

127 folders: Resolved folder settings, e.g. ``{"source_folder": "src"}``. 

128 

129 Raises: 

130 Skip: When the tool is absent, or the file is missing, or the folder is 

131 missing, or the folder holds no file matching ``glob``. 

132 

133 Examples: 

134 A satisfied guard returns nothing, which is the whole of its success case: 

135 

136 >>> import tempfile 

137 >>> from pathlib import Path 

138 >>> tmp = tempfile.TemporaryDirectory() 

139 >>> root = Path(tmp.name) 

140 >>> (root / "src").mkdir() 

141 >>> folders = {"source_folder": "src", "tests_folder": "tests"} 

142 >>> Guard("source_folder").check(root, folders) 

143 

144 Each way of not being satisfied raises :class:`Skip` carrying the line the 

145 runner prints, and ``folder`` is resolved through *folders* -- so the guard 

146 names a setting and never a path: 

147 

148 >>> for guard in ( 

149 ... Guard("tests_folder"), 

150 ... Guard("source_folder", glob="test_*.py"), 

151 ... Guard(file="Cargo.toml"), 

152 ... Guard(tool="a-tool-nobody-has"), 

153 ... ): 

154 ... try: 

155 ... guard.check(root, folders) 

156 ... except Skip as exc: 

157 ... print(exc) 

158 tests_folder 'tests' not found 

159 no test_*.py below 'src' 

160 no Cargo.toml 

161 a-tool-nobody-has not found 

162 

163 ``reason`` replaces the generated message wherever a task has something more 

164 useful to say: 

165 

166 >>> try: 

167 ... Guard("tests_folder", glob="test_*.py", reason="no test files found").check(root, folders) 

168 ... except Skip as exc: 

169 ... print(exc) 

170 no test files found 

171 >>> tmp.cleanup() 

172 """ 

173 # The first unmet precondition wins, so `reason` overrides whichever message that 

174 # clause generated -- see the note above the class for why the clauses live in a 

175 # module-level generator rather than in this body or in a second method. 

176 for unmet, message in _clauses(self, root, folders): 

177 if unmet: 

178 raise Skip(self.reason or message) 

179 

180 

181def _clauses(guard: Guard, root: Path, folders: dict[str, str]) -> Iterator[tuple[bool, str]]: 

182 """Yield each of *guard*'s preconditions as ``(unmet, message)``, in firing order. 

183 

184 The order is the contract: tool before file before folder before glob, cheapest and 

185 most likely first. Being a generator, a pair is only produced -- and its predicate only 

186 evaluated -- once the caller has consumed every earlier one, so the filesystem is left 

187 alone when an earlier clause has already decided the outcome. 

188 

189 ``folder`` is tested with ``is not None`` rather than for truthiness, unlike the other 

190 three: ``Guard(folder="")`` means the repository root, which is a satisfiable guard, 

191 where an empty ``tool`` or ``file`` names nothing at all. 

192 

193 Args: 

194 guard: The guard whose fields describe the preconditions. 

195 root: Repository root. 

196 folders: Resolved folder settings, e.g. ``{"source_folder": "src"}``. 

197 

198 Yields: 

199 One ``(unmet, message)`` pair per precondition the guard declares. ``unmet`` is 

200 True when the precondition fails; ``message`` is the line the runner prints unless 

201 the guard carries its own ``reason``. 

202 """ 

203 if guard.tool: 

204 yield not have(guard.tool), f"{guard.tool} not found" 

205 if guard.file: 

206 yield not (root / guard.file).is_file(), f"no {guard.file}" 

207 if guard.folder is not None: 

208 name = folders.get(guard.folder, guard.folder) 

209 target = root / name 

210 yield not target.is_dir(), f"{guard.folder} '{name}' not found" 

211 if guard.glob: 

212 yield not any(target.rglob(guard.glob)), f"no {guard.glob} below '{name}'" 

213 

214 

215@dataclass(frozen=True) 

216class Task: 

217 """One gate: the unit the CLI exposes and the reusable workflows invoke. 

218 

219 Attributes: 

220 name: The command name, e.g. ``test``. Deliberately identical to the retired make 

221 target, so the Makefile shim and a consumer's muscle memory need no 

222 translation table. 

223 layer: ``python``, ``rust``, ``go``, or None for a language-neutral task. Three 

224 layers can define ``test``; which one answers is decided per repository by 

225 :func:`lookup`, not by which bundle happened to be synced. 

226 help: One line, shown by ``rhiza-task list``. Replaces the ``##`` convention that 

227 rhiza.mk parsed with awk. 

228 section: Help grouping. Replaces ``##@``. 

229 run: The task body. Takes a config, returns nothing, raises :class:`Failed` or 

230 :class:`Skip`. 

231 needs: Tasks to run first. The runner dedupes within one invocation, which is what 

232 make gave for free and the reason ``install`` can be named by eleven tasks 

233 without being run eleven times. 

234 guards: Evaluated in order before the body. 

235 hidden: Omit from ``list``. 

236 """ 

237 

238 name: str 

239 help: str 

240 section: str 

241 run: Callable[[Config], None] 

242 needs: tuple[str, ...] = () 

243 guards: tuple[Guard, ...] = () 

244 hidden: bool = False 

245 layer: str | None = None 

246 

247 @property 

248 def key(self) -> str: 

249 """Return the registry key: ``layer:name``, or ``name`` when neutral. 

250 

251 Returns: 

252 The key this task is registered under. 

253 """ 

254 return key(self.name, self.layer) 

255 

256 

257REGISTRY: dict[str, Task] = {} 

258"""Every registered task, keyed by ``layer:name`` -- or by bare ``name`` when neutral. 

259 

260This dict replaces make's double-colon rules. book.mk has to declare ``test:: ; @:`` 

261no-op stubs so that ``book`` can depend on ``test`` without knowing whether the ``tests`` 

262bundle was synced; here the same question is :func:`lookup`. Four stub declarations and 

263the whole ``::`` mechanism go away with it. 

264""" 

265 

266 

267def task( 

268 name: str, 

269 help: str, # noqa: A002 - matches the CLI's own vocabulary 

270 section: str, 

271 needs: Sequence[str] = (), 

272 guards: Sequence[Guard] = (), 

273 hidden: bool = False, 

274 layer: str | None = None, 

275) -> Callable[[Callable[[Config], None]], Callable[[Config], None]]: 

276 """Register a task and return the function unchanged. 

277 

278 Returning the undecorated function keeps every task body directly unit-testable 

279 without going through the registry or the CLI. 

280 

281 Args: 

282 name: Command name. 

283 help: One-line description. 

284 section: Help grouping. 

285 needs: Prerequisite task names. 

286 guards: Layout preconditions. 

287 hidden: Omit from ``list``. 

288 layer: The language layer this task belongs to, or None for a neutral task. 

289 

290 Returns: 

291 The decorator. 

292 """ 

293 

294 def decorate(fn: Callable[[Config], None]) -> Callable[[Config], None]: 

295 """Add the task to the registry. 

296 

297 Args: 

298 fn: The task body. 

299 

300 Returns: 

301 ``fn``, unchanged. 

302 """ 

303 spec = Task( 

304 name=name, 

305 help=help, 

306 section=section, 

307 run=fn, 

308 needs=tuple(needs), 

309 guards=tuple(guards), 

310 hidden=hidden, 

311 layer=layer, 

312 ) 

313 REGISTRY[spec.key] = spec 

314 return fn 

315 

316 return decorate 

317 

318 

319def key(name: str, layer: str | None = None) -> str: 

320 """Return the registry key for a task name in a layer. 

321 

322 Args: 

323 name: The task name, e.g. ``test``. 

324 layer: The layer, or None for a neutral task. 

325 

326 Returns: 

327 ``layer:name``, or ``name`` when there is no layer. 

328 """ 

329 return f"{layer}:{name}" if layer else name 

330 

331 

332def lookup(name: str, layers: Sequence[str] = ()) -> Task | None: 

333 """Resolve a task name against the repository's language layers. 

334 

335 A layered task shadows a neutral one of the same name, and the layers are tried in 

336 order, so a repository that is both -- a crate with a Python binding package -- gets a 

337 single answer rather than an ambiguity. ``rust:test`` addresses one layer explicitly, 

338 which is the only way to reach the layer that did not win. 

339 

340 Args: 

341 name: A bare task name, or a ``layer:name`` key. 

342 layers: The active layers, most significant first. 

343 

344 Returns: 

345 The task, or None when nothing matches. 

346 

347 Examples: 

348 Importing a task module is what registers its tasks -- the entry point group in 

349 ``pyproject.toml`` only decides *which* modules the CLI imports: 

350 

351 >>> from rhiza_task.tasks import python, quality, rust 

352 >>> lookup("test", ["python"]).help 

353 'run all tests' 

354 >>> lookup("test", ["rust"]).help 

355 'run the test suite with nextest, then the doctests' 

356 

357 The layers are tried in order, so a crate that has grown a Python package gets one 

358 answer rather than an ambiguity -- and the explicit key is how the layer that lost 

359 is still reachable: 

360 

361 >>> lookup("test", ["python", "rust"]).key 

362 'python:test' 

363 >>> lookup("test", ["rust", "python"]).key 

364 'rust:test' 

365 >>> lookup("rust:test", ["python"]).key 

366 'rust:test' 

367 

368 A neutral task answers to its bare name whatever the layers are, and a name no 

369 active layer has is None rather than an error -- which is what lets ``book`` 

370 depend on gates a repository may not have, in place of make's ``test:: ; @:`` 

371 no-op stubs: 

372 

373 >>> lookup("fmt", ["rust"]).key 

374 'fmt' 

375 >>> lookup("cargo-tools", ["python"]) is None 

376 True 

377 """ 

378 if ":" in name: 

379 return REGISTRY.get(name) 

380 for layer in layers: 

381 if (spec := REGISTRY.get(key(name, layer))) is not None: 

382 return spec 

383 return REGISTRY.get(name) 

384 

385 

386def have(tool: str) -> bool: 

387 """Return whether ``tool`` is on PATH. 

388 

389 Args: 

390 tool: Executable name. 

391 

392 Returns: 

393 True when found. 

394 """ 

395 return shutil.which(tool) is not None