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

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, Task, lookup 

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 other layers and unused bundles"), 

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

74 

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) 

86 

87 

88def _shown(spec: Task, cfg: Config | None, layers: tuple[str, ...], every_layer: bool) -> bool: 

89 """Return whether ``list`` prints *spec*. 

90 

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. 

98 

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) 

109 

110 

111def _config() -> Config | None: 

112 """Resolve the config for ``list``, tolerating one that does not resolve. 

113 

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. 

117 

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 

126 

127 

128@app.command("print") 

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

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

131 

132 Args: 

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

134 

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 ) 

153 

154 

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. 

158 

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

167 

168 

169@app.command("version") 

170def version() -> None: 

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

172 console.print(__version__) 

173 

174 

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. 

182 

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. 

187 

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 

200 

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 

206 

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

213 

214 

215def main() -> None: 

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

217 

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