Coverage for src/rhiza_task/cli.py: 100%
78 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 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
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 the other languages' layers"),
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 Args:
70 every_layer: Show tasks from every language layer, not only this repository's.
71 """
72 layers = () if every_layer else _layers()
73 table = Table("task", "section", "needs", "does", box=None, header_style="bold")
74 for _, spec in sorted(REGISTRY.items(), key=lambda kv: (kv[1].section, kv[0])):
75 if spec.hidden or (not every_layer and spec.layer is not None and spec.layer not in layers):
76 continue
77 table.add_row(spec.name, spec.section, " ".join(spec.needs), spec.help)
78 console.print(table)
81def _layers() -> tuple[str, ...]:
82 """Return this repository's language layers, tolerating an unresolvable config.
84 ``list`` is what you run *because* something is wrong, so a config error must not be
85 the thing that stops it printing. Showing every layer is the honest fallback: it is a
86 superset, and the alternative is showing nothing.
88 Returns:
89 The active layers, or every layer when the config does not resolve.
90 """
91 try:
92 return Config.load().layers
93 except (ValueError, OSError) as exc:
94 err.print(f"[yellow]could not resolve the config ({exc}); listing every layer[/yellow]")
95 return LAYERS
98@app.command("print")
99def print_setting(name: str) -> None:
100 """Print one resolved setting, replacing make's ``print-%`` pattern rule.
102 Args:
103 name: A config field, spelled either way -- ``source_folder`` or ``SOURCE_FOLDER``.
105 Raises:
106 typer.Exit: With status 2 when the setting does not exist.
107 """
108 cfg = Config.load()
109 field = Config.field_for(name)
110 if not hasattr(cfg, field):
111 err.print(f"[red]unknown setting: {name}[/red]")
112 raise typer.Exit(2)
113 value = getattr(cfg, field)
114 # markup=False, highlight=False: ``print`` is the command you reach for when a setting
115 # is not doing what you expect, so it must show the stored value and nothing else.
116 # ``mkdocs_extra_packages = ("mkdocstrings[python]",)`` printed as ``mkdocstrings``
117 # otherwise, rich having read ``[python]`` as a style tag.
118 console.print(
119 " ".join(map(str, value)) if isinstance(value, tuple) else str(value),
120 markup=False,
121 highlight=False,
122 )
125@app.command("ci-os-matrix")
126def ci_os_matrix() -> None:
127 """Emit the CI OS matrix as a JSON array, for a GitHub Actions matrix input.
129 Never emits ``[]``. A GitHub matrix with no OS in it does not fail the workflow -- it
130 expands to zero jobs, so the ``test`` job disappears and CI goes green having run
131 nothing. The retired make recipe guarded that with ``$(or $(RHIZA_CI_OS_MATRIX),
132 ["ubuntu-latest"])`` and this is the same floor: after :func:`~rhiza_task.config`
133 resolution an empty value can only come from an explicit ``RHIZA_CI_OS_MATRIX=[]``,
134 which is a mistake in every case a caller has ever meant.
135 """
136 print(json.dumps(list(Config.load().ci_os_matrix) or list(DEFAULT_CI_OS_MATRIX)))
139@app.command("version")
140def version() -> None:
141 """Print the rhiza-task version."""
142 console.print(__version__)
145@app.command("run", no_args_is_help=True)
146def run_tasks(
147 names: list[str] = typer.Argument(..., help="Tasks to run, in order"),
148 strict: bool = typer.Option(False, "--strict", help="Treat a skipped gate as a failure"),
149 root: Path | None = typer.Option(None, "--root", help="Repository to operate on"),
150) -> None:
151 """Run one or more tasks, with their prerequisites.
153 Args:
154 names: Task names.
155 strict: Fail rather than skip when a gate has nothing to measure.
156 root: Repository root; defaults to the current directory.
158 Raises:
159 typer.Exit: With 0 when everything passed, 2 on a usage error, and otherwise the
160 first failing task's own exit status -- pytest's 2 or 4, ``cargo``'s 101 --
161 falling back to 1 when it has none. A usage error and a task that exited 2
162 therefore share a status; the run summary above distinguishes them, and the
163 alternative is discarding the code every consumer's CI wants.
164 """
165 try:
166 cfg = Config.load(root=root, strict=strict or None)
167 except ValueError as exc: # invalid configuration, e.g. typechecker=tpye
168 err.print(f"[red]{exc}[/red]")
169 raise typer.Exit(2) from exc
171 try:
172 state = runner.run(names, cfg)
173 except KeyError as exc:
174 err.print(f"[red]{exc.args[0]}[/red] (try `rhiza-task list`)")
175 raise typer.Exit(2) from exc
177 console.print()
178 for result in state.results:
179 colour = STATUS_COLOUR[result.status]
180 detail = f" [dim]{result.detail}[/dim]" if result.detail else ""
181 console.print(f"[{colour}]{result.status.value:>8}[/{colour}] {result.name}{detail}")
182 raise typer.Exit(state.exit_code())
185def main() -> None:
186 """Entry point. A bare ``rhiza-task <task>`` is shorthand for ``rhiza-task run <task>``.
188 Not sugar -- it is the compatibility contract. The reusable workflows and a repo-owned
189 forwarding ``Makefile`` both invoke ``rhiza-task test``, and a consumer's muscle memory
190 is ``make test``. Requiring ``run`` would put a word between the two for no gain.
191 """
192 load_tasks()
193 argv = sys.argv[1:]
194 if argv and argv[0] not in RESERVED and not argv[0].startswith("-"):
195 sys.argv = [sys.argv[0], "run", *argv]
196 app()