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

1"""The command line, generated from the registry rather than hand-maintained. 

2 

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""" 

9 

10from __future__ import annotations 

11 

12import json 

13import sys 

14from importlib.metadata import entry_points 

15from pathlib import Path 

16 

17import typer 

18from rich.console import Console 

19from rich.table import Table 

20 

21from . import __version__, runner 

22from .config import DEFAULT_CI_OS_MATRIX, LAYERS, Config 

23from .runner import Status 

24from .spec import REGISTRY 

25 

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) 

33 

34STATUS_COLOUR = { 

35 Status.OK: "green", 

36 Status.SKIPPED: "yellow", 

37 Status.FAILED: "red", 

38 Status.BLOCKED: "red", 

39} 

40 

41RESERVED = frozenset({"list", "print", "run", "ci-os-matrix", "version"}) 

42"""Subcommand names, so the bare-task shorthand in :func:`main` can tell them apart.""" 

43 

44 

45def load_tasks() -> None: 

46 """Import every module registered under the ``rhiza_task.tasks`` entry-point group. 

47 

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]") 

56 

57 

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. 

63 

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. 

68 

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) 

79 

80 

81def _layers() -> tuple[str, ...]: 

82 """Return this repository's language layers, tolerating an unresolvable config. 

83 

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. 

87 

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 

96 

97 

98@app.command("print") 

99def print_setting(name: str) -> None: 

100 """Print one resolved setting, replacing make's ``print-%`` pattern rule. 

101 

102 Args: 

103 name: A config field, spelled either way -- ``source_folder`` or ``SOURCE_FOLDER``. 

104 

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 ) 

123 

124 

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. 

128 

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))) 

137 

138 

139@app.command("version") 

140def version() -> None: 

141 """Print the rhiza-task version.""" 

142 console.print(__version__) 

143 

144 

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. 

152 

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. 

157 

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 

170 

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 

176 

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()) 

183 

184 

185def main() -> None: 

186 """Entry point. A bare ``rhiza-task <task>`` is shorthand for ``rhiza-task run <task>``. 

187 

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()