Coverage for src/rhiza_task/cli.py: 100%
87 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 command line, generated from the registry rather than hand-maintained.
3rhiza.mk builds its help by running awk over ``$(MAKEFILE_LIST)`` looking for ``##`` and
4``##@`` comments -- a parser for a documentation convention that exists only because make
5has no notion of a task description. Typer has one, so help text, sections, per-task help
6and the "unknown task" error all come from the same registry the runner uses, and cannot
7drift from it.
8"""
10from __future__ import annotations
12import json
13import sys
14from importlib.metadata import entry_points
15from pathlib import Path
17import typer
18from rich.console import Console
19from rich.table import Table
21from . import __version__, runner
22from .config import DEFAULT_CI_OS_MATRIX, LAYERS, Config
23from .runner import Status
24from .spec import REGISTRY, Task, lookup
26app = typer.Typer(
27 add_completion=False,
28 no_args_is_help=True,
29 help="rhiza developer tasks. Run `rhiza-task list` to see what is available.",
30)
31console = Console()
32err = Console(stderr=True)
34STATUS_COLOUR = {
35 Status.OK: "green",
36 Status.SKIPPED: "yellow",
37 Status.FAILED: "red",
38 Status.BLOCKED: "red",
39}
41RESERVED = frozenset({"list", "print", "run", "ci-os-matrix", "version"})
42"""Subcommand names, so the bare-task shorthand in :func:`main` can tell them apart."""
45def load_tasks() -> None:
46 """Import every module registered under the ``rhiza_task.tasks`` entry-point group.
48 Failures are reported and skipped rather than fatal: a broken third-party task module
49 should not take the built-in gates down with it.
50 """
51 for entry in entry_points(group="rhiza_task.tasks"):
52 try:
53 entry.load()
54 except Exception as exc: # noqa: BLE001 - a plugin must not break the runner
55 err.print(f"[yellow]could not load task module {entry.name}: {exc}[/yellow]")
58@app.command("list")
59def list_tasks(
60 every_layer: bool = typer.Option(False, "--all", help="include other layers and unused bundles"),
61) -> None:
62 """Show the available tasks, grouped by section.
64 A Go module is not helped by being shown ``benchmark`` and ``marimo-validate``, so the
65 default is this repository's own layers plus the language-neutral tasks -- which is
66 what the make layer showed, having synced exactly one language fragment. ``--all`` is
67 for the question the make layer could not answer: what the other layers call things.
69 The same argument applies to the bundles. A library with no Dockerfile, paper or slide
70 deck is not helped by ``docker-build`` or ``presentation-pdf`` either, so a task whose
71 ``applies`` says the repository does not use it is left out too, and dropped from the
72 ``needs`` cell of the tasks that name it -- ``book`` lists ``paper`` only where there is
73 one. ``--all`` shows everything again.
75 Args:
76 every_layer: Show every task, from every layer, whether or not this repository uses it.
77 """
78 cfg = None if every_layer else _config()
79 layers = LAYERS if cfg is None else cfg.layers
80 shown = {k: spec for k, spec in REGISTRY.items() if _shown(spec, cfg, layers, every_layer)}
81 table = Table("task", "section", "needs", "does", box=None, header_style="bold")
82 for _, spec in sorted(shown.items(), key=lambda kv: (kv[1].section, kv[0])):
83 needs = [n for n in spec.needs if (dep := lookup(n, layers)) is None or dep.key in shown]
84 table.add_row(spec.name, spec.section, " ".join(needs), spec.help)
85 console.print(table)
88def _shown(spec: Task, cfg: Config | None, layers: tuple[str, ...], every_layer: bool) -> bool:
89 """Return whether ``list`` prints *spec*.
91 Args:
92 spec: The task.
93 cfg: The resolved config, or None when it did not resolve or ``--all`` was given --
94 both of which show every bundle, the unresolvable case because a superset is the
95 honest fallback.
96 layers: The active layers.
97 every_layer: Whether ``--all`` was given.
99 Returns:
100 False for a hidden task, a task from another layer, or one this repository does not use.
101 """
102 if spec.hidden:
103 return False
104 if every_layer:
105 return True
106 if spec.layer is not None and spec.layer not in layers:
107 return False
108 return cfg is None or spec.applies is None or spec.applies(cfg)
111def _config() -> Config | None:
112 """Resolve the config for ``list``, tolerating one that does not resolve.
114 ``list`` is what you run *because* something is wrong, so a config error must not be
115 the thing that stops it printing. Showing every layer and bundle is the honest
116 fallback: it is a superset, and the alternative is showing nothing.
118 Returns:
119 The config, or None when it does not resolve.
120 """
121 try:
122 return Config.load()
123 except (ValueError, OSError) as exc:
124 err.print(f"[yellow]could not resolve the config ({exc}); listing every layer[/yellow]")
125 return None
128@app.command("print")
129def print_setting(name: str) -> None:
130 """Print one resolved setting, replacing make's ``print-%`` pattern rule.
132 Args:
133 name: A config field, spelled either way -- ``source_folder`` or ``SOURCE_FOLDER``.
135 Raises:
136 typer.Exit: With status 2 when the setting does not exist.
137 """
138 cfg = Config.load()
139 field = Config.field_for(name)
140 if not hasattr(cfg, field):
141 err.print(f"[red]unknown setting: {name}[/red]")
142 raise typer.Exit(2)
143 value = getattr(cfg, field)
144 # markup=False, highlight=False: ``print`` is the command you reach for when a setting
145 # is not doing what you expect, so it must show the stored value and nothing else.
146 # ``mkdocs_extra_packages = ("mkdocstrings[python]",)`` printed as ``mkdocstrings``
147 # otherwise, rich having read ``[python]`` as a style tag.
148 console.print(
149 " ".join(map(str, value)) if isinstance(value, tuple) else str(value),
150 markup=False,
151 highlight=False,
152 )
155@app.command("ci-os-matrix")
156def ci_os_matrix() -> None:
157 """Emit the CI OS matrix as a JSON array, for a GitHub Actions matrix input.
159 Never emits ``[]``. A GitHub matrix with no OS in it does not fail the workflow -- it
160 expands to zero jobs, so the ``test`` job disappears and CI goes green having run
161 nothing. The retired make recipe guarded that with ``$(or $(RHIZA_CI_OS_MATRIX),
162 ["ubuntu-latest"])`` and this is the same floor: after :func:`~rhiza_task.config`
163 resolution an empty value can only come from an explicit ``RHIZA_CI_OS_MATRIX=[]``,
164 which is a mistake in every case a caller has ever meant.
165 """
166 print(json.dumps(list(Config.load().ci_os_matrix) or list(DEFAULT_CI_OS_MATRIX)))
169@app.command("version")
170def version() -> None:
171 """Print the rhiza-task version."""
172 console.print(__version__)
175@app.command("run", no_args_is_help=True)
176def run_tasks(
177 names: list[str] = typer.Argument(..., help="Tasks to run, in order"),
178 strict: bool = typer.Option(False, "--strict", help="Treat a skipped gate as a failure"),
179 root: Path | None = typer.Option(None, "--root", help="Repository to operate on"),
180) -> None:
181 """Run one or more tasks, with their prerequisites.
183 Args:
184 names: Task names.
185 strict: Fail rather than skip when a gate has nothing to measure.
186 root: Repository root; defaults to the current directory.
188 Raises:
189 typer.Exit: With 0 when everything passed, 2 on a usage error, and otherwise the
190 first failing task's own exit status -- pytest's 2 or 4, ``cargo``'s 101 --
191 falling back to 1 when it has none. A usage error and a task that exited 2
192 therefore share a status; the run summary above distinguishes them, and the
193 alternative is discarding the code every consumer's CI wants.
194 """
195 try:
196 cfg = Config.load(root=root, strict=strict or None)
197 except ValueError as exc: # invalid configuration, e.g. typechecker=tpye
198 err.print(f"[red]{exc}[/red]")
199 raise typer.Exit(2) from exc
201 try:
202 state = runner.run(names, cfg)
203 except KeyError as exc:
204 err.print(f"[red]{exc.args[0]}[/red] (try `rhiza-task list`)")
205 raise typer.Exit(2) from exc
207 console.print()
208 for result in state.results:
209 colour = STATUS_COLOUR[result.status]
210 detail = f" [dim]{result.detail}[/dim]" if result.detail else ""
211 console.print(f"[{colour}]{result.status.value:>8}[/{colour}] {result.name}{detail}")
212 raise typer.Exit(state.exit_code())
215def main() -> None:
216 """Entry point. A bare ``rhiza-task <task>`` is shorthand for ``rhiza-task run <task>``.
218 Not sugar -- it is the compatibility contract. The reusable workflows and a repo-owned
219 forwarding ``Makefile`` both invoke ``rhiza-task test``, and a consumer's muscle memory
220 is ``make test``. Requiring ``run`` would put a word between the two for no gain.
221 """
222 load_tasks()
223 argv = sys.argv[1:]
224 if argv and argv[0] not in RESERVED and not argv[0].startswith("-"):
225 sys.argv = [sys.argv[0], "run", *argv]
226 app()