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
« 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.
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 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 """
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
254 @property
255 def key(self) -> str:
256 """Return the registry key: ``layer:name``, or ``name`` when neutral.
258 Returns:
259 The key this task is registered under.
260 """
261 return key(self.name, self.layer)
264REGISTRY: dict[str, Task] = {}
265"""Every registered task, keyed by ``layer:name`` -- or by bare ``name`` when neutral.
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"""
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.
286 Returning the undecorated function keeps every task body directly unit-testable
287 without going through the registry or the CLI.
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.
299 Returns:
300 The decorator.
301 """
303 def decorate(fn: Callable[[Config], None]) -> Callable[[Config], None]:
304 """Add the task to the registry.
306 Args:
307 fn: The task body.
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
326 return decorate
329def key(name: str, layer: str | None = None) -> str:
330 """Return the registry key for a task name in a layer.
332 Args:
333 name: The task name, e.g. ``test``.
334 layer: The layer, or None for a neutral task.
336 Returns:
337 ``layer:name``, or ``name`` when there is no layer.
338 """
339 return f"{layer}:{name}" if layer else name
342def lookup(name: str, layers: Sequence[str] = ()) -> Task | None:
343 """Resolve a task name against the repository's language layers.
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.
350 Args:
351 name: A bare task name, or a ``layer:name`` key.
352 layers: The active layers, most significant first.
354 Returns:
355 The task, or None when nothing matches.
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:
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'
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:
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'
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:
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)
396def have(tool: str) -> bool:
397 """Return whether ``tool`` is on PATH.
399 Args:
400 tool: Executable name.
402 Returns:
403 True when found.
404 """
405 return shutil.which(tool) is not None