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

66 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-10-13 02:27 +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 applies: Whether the repository uses what this task operates on, asked by ``list`` 

237 and nothing else. A repository with no Dockerfile has not adopted the Docker 

238 bundle, so ``list`` leaves ``docker-build`` out rather than advertising an image 

239 the repository does not ship; ``list --all`` and running the task by name are 

240 unaffected, and the task still skips through its own guards. None means the 

241 task applies everywhere, which is every gate. 

242 """ 

243 

244 name: str 

245 help: str 

246 section: str 

247 run: Callable[[Config], None] 

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

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

250 hidden: bool = False 

251 layer: str | None = None 

252 applies: Callable[[Config], bool] | None = None 

253 

254 @property 

255 def key(self) -> str: 

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

257 

258 Returns: 

259 The key this task is registered under. 

260 """ 

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

262 

263 

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

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

266 

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

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

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

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

271""" 

272 

273 

274def task( 

275 name: str, 

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

277 section: str, 

278 needs: Sequence[str] = (), 

279 guards: Sequence[Guard] = (), 

280 hidden: bool = False, 

281 layer: str | None = None, 

282 applies: Callable[[Config], bool] | None = None, 

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

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

285 

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

287 without going through the registry or the CLI. 

288 

289 Args: 

290 name: Command name. 

291 help: One-line description. 

292 section: Help grouping. 

293 needs: Prerequisite task names. 

294 guards: Layout preconditions. 

295 hidden: Omit from ``list``. 

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

297 applies: Whether ``list`` shows the task in this repository; None for always. 

298 

299 Returns: 

300 The decorator. 

301 """ 

302 

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

304 """Add the task to the registry. 

305 

306 Args: 

307 fn: The task body. 

308 

309 Returns: 

310 ``fn``, unchanged. 

311 """ 

312 spec = Task( 

313 name=name, 

314 help=help, 

315 section=section, 

316 run=fn, 

317 needs=tuple(needs), 

318 guards=tuple(guards), 

319 hidden=hidden, 

320 layer=layer, 

321 applies=applies, 

322 ) 

323 REGISTRY[spec.key] = spec 

324 return fn 

325 

326 return decorate 

327 

328 

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

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

331 

332 Args: 

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

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

335 

336 Returns: 

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

338 """ 

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

340 

341 

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

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

344 

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

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

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

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

349 

350 Args: 

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

352 layers: The active layers, most significant first. 

353 

354 Returns: 

355 The task, or None when nothing matches. 

356 

357 Examples: 

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

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

360 

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

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

363 'run all tests' 

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

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

366 

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

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

369 is still reachable: 

370 

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

372 'python:test' 

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

374 'rust:test' 

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

376 'rust:test' 

377 

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

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

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

381 no-op stubs: 

382 

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

384 'fmt' 

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

386 True 

387 """ 

388 if ":" in name: 

389 return REGISTRY.get(name) 

390 for layer in layers: 

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

392 return spec 

393 return REGISTRY.get(name) 

394 

395 

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

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

398 

399 Args: 

400 tool: Executable name. 

401 

402 Returns: 

403 True when found. 

404 """ 

405 return shutil.which(tool) is not None