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
« 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.
3Reading all ten make fragments back to back, every recipe has the same three parts:
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.
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.
14The split decides what is *data* -- reviewable, diffable, overridable from a consumer's
15``pyproject.toml`` -- and what is code.
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"""
27from __future__ import annotations
29import shutil
30from collections.abc import Callable, Iterator, Sequence
31from dataclasses import dataclass
32from pathlib import Path
33from typing import TYPE_CHECKING
35if TYPE_CHECKING: # pragma: no cover - import cycle: config imports nothing from here
36 from .config import Config
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.
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 """
49class Failed(Exception): # noqa: N818 - ditto
50 """Raised when a task's command exited non-zero. Carries the exit status."""
52 def __init__(self, code: int, detail: str = "") -> None:
53 """Store the exit status so the CLI can propagate it.
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
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.
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``.
95 ``glob`` additionally requires a matching file below that folder -- the declarative
96 form of python.mk's ``find ${TESTS_FOLDER} -name 'test_*.py'``.
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.
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.
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 """
116 folder: str | None = None
117 glob: str | None = None
118 reason: str = ""
119 file: str | None = None
120 tool: str | None = None
122 def check(self, root: Path, folders: dict[str, str]) -> None:
123 """Raise :class:`Skip` when the guard is not satisfied.
125 Args:
126 root: Repository root.
127 folders: Resolved folder settings, e.g. ``{"source_folder": "src"}``.
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``.
133 Examples:
134 A satisfied guard returns nothing, which is the whole of its success case:
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)
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:
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
163 ``reason`` replaces the generated message wherever a task has something more
164 useful to say:
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)
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.
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.
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.
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"}``.
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}'"
215@dataclass(frozen=True)
216class Task:
217 """One gate: the unit the CLI exposes and the reusable workflows invoke.
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 """
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
247 @property
248 def key(self) -> str:
249 """Return the registry key: ``layer:name``, or ``name`` when neutral.
251 Returns:
252 The key this task is registered under.
253 """
254 return key(self.name, self.layer)
257REGISTRY: dict[str, Task] = {}
258"""Every registered task, keyed by ``layer:name`` -- or by bare ``name`` when neutral.
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"""
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.
278 Returning the undecorated function keeps every task body directly unit-testable
279 without going through the registry or the CLI.
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.
290 Returns:
291 The decorator.
292 """
294 def decorate(fn: Callable[[Config], None]) -> Callable[[Config], None]:
295 """Add the task to the registry.
297 Args:
298 fn: The task body.
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
316 return decorate
319def key(name: str, layer: str | None = None) -> str:
320 """Return the registry key for a task name in a layer.
322 Args:
323 name: The task name, e.g. ``test``.
324 layer: The layer, or None for a neutral task.
326 Returns:
327 ``layer:name``, or ``name`` when there is no layer.
328 """
329 return f"{layer}:{name}" if layer else name
332def lookup(name: str, layers: Sequence[str] = ()) -> Task | None:
333 """Resolve a task name against the repository's language layers.
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.
340 Args:
341 name: A bare task name, or a ``layer:name`` key.
342 layers: The active layers, most significant first.
344 Returns:
345 The task, or None when nothing matches.
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:
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'
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:
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'
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:
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)
386def have(tool: str) -> bool:
387 """Return whether ``tool`` is on PATH.
389 Args:
390 tool: Executable name.
392 Returns:
393 True when found.
394 """
395 return shutil.which(tool) is not None