Coverage for src/rhiza_task/tasks/doctor.py: 100%

48 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-25 04:13 +0000

1r"""Prerequisite diagnostics: doctor.mk, as a task. 

2 

3The fourth recipe that resists the declarative form. In doctor.mk it is 69 lines of shell 

4containing two functions defined inside a make recipe -- ``version_ge``, which is an awk 

5program comparing dotted versions component by component, and ``check_tool``, which takes 

6five positional arguments including a *quoted shell command to eval* for extracting the 

7version. The escaping is such that the awk field references appear as ``\\$$i``. 

8 

9The change of substance is which tools it asks about at all. doctor.mk probes GNU make, 

10because the whole task layer was make. It is not probed here, and neither is anything else 

11beyond uv and git -- the two a process running ``uvx rhiza-task`` genuinely cannot do 

12without. 

13 

14That is a design boundary rather than a short list. **Optionality is what 

15:class:`~rhiza_task.spec.Guard` is for**: docker, gh, git-lfs, tectonic and marp are each 

16declared as a precondition on the task that wraps them, and a missing one reports itself on 

17the ``skipped`` line of the gate that wanted it, with the install URL in its ``reason``. A 

18diagnostic that also enumerated them would answer the same question one indirection further 

19from where it matters, and would need updating every time a bundle gained a tool. 

20 

21So this task has one tier, not two: everything it names is required, and a miss is a 

22failure. ``make`` was the last inhabitant of the optional tier -- reported as a warning for 

23the sake of a repo-owned Makefile forwarding to the CLI -- and the tier went with it. If a 

24genuinely optional *core* prerequisite ever appears, that is an edit here rather than a 

25mechanism to keep warm for it. 

26""" 

27 

28from __future__ import annotations 

29 

30import re 

31import shutil 

32 

33# The version probe below is a fixed argument vector, with no shell -- which is what bandit's B404 

34# asks about. The reason sits here rather than on the suppression comment itself: bandit reads 

35# everything after that marker as a comma-separated list of test IDs, so a trailing explanation 

36# becomes one `Test in comment:` warning per word. 

37import subprocess # nosec B404 

38from dataclasses import dataclass 

39 

40from ..config import Config 

41from ..spec import Failed, task 

42 

43GREEN = "\033[32m" 

44RED = "\033[31m" 

45RESET = "\033[0m" 

46 

47VERSION_RE = re.compile(r"(\d+(?:\.\d+)+)") 

48 

49 

50@dataclass(frozen=True) 

51class Tool: 

52 """A prerequisite, its minimum version, and where to get it. 

53 

54 Every entry is required; see the module docstring for why there is no optional tier. 

55 

56 Attributes: 

57 name: Executable name. 

58 minimum: Lowest acceptable dotted version. 

59 url: Install instructions, printed when it is missing. 

60 """ 

61 

62 name: str 

63 minimum: str 

64 url: str 

65 

66 

67# uv because a process launched by `uvx rhiza-task` runs *because* uv exists, and git because 

68# `clean` and the release flow drive it directly. Nothing else belongs here: see the module 

69# docstring on why the bundle CLIs are guards rather than entries. 

70TOOLS = ( 

71 Tool("uv", "0.4.0", "https://docs.astral.sh/uv/getting-started/installation/"), 

72 Tool("git", "2.0.0", "https://git-scm.com"), 

73) 

74 

75 

76def parse_version(text: str) -> tuple[int, ...]: 

77 """Extract the first dotted version from a tool's ``--version`` output. 

78 

79 Replaces doctor.mk's per-tool awk extraction commands -- ``uv --version | awk 'NR==1 

80 {print $$2}'`` and the rest -- with one regex, because every tool in ``TOOLS`` prints 

81 its version as the first dotted number on the first line. 

82 

83 Args: 

84 text: The raw ``--version`` output. 

85 

86 Returns: 

87 The version as a tuple of ints, empty when none was found. 

88 """ 

89 match = VERSION_RE.search(text.splitlines()[0] if text.strip() else "") 

90 return tuple(int(p) for p in match.group(1).split(".")) if match else () 

91 

92 

93def at_least(found: tuple[int, ...], minimum: str) -> bool: 

94 """Compare dotted versions, padding the shorter one with zeros. 

95 

96 Args: 

97 found: The installed version. 

98 minimum: The required version, dotted. 

99 

100 Returns: 

101 True when ``found`` is at least ``minimum``. 

102 """ 

103 want = tuple(int(p) for p in minimum.split(".")) 

104 width = max(len(found), len(want)) 

105 return found + (0,) * (width - len(found)) >= want + (0,) * (width - len(want)) 

106 

107 

108@task("doctor", "check local prerequisites", section="Dev") 

109def doctor(cfg: Config) -> None: 

110 """Report on each prerequisite, failing when a required one is missing or too old. 

111 

112 Args: 

113 cfg: The resolved config. 

114 

115 Raises: 

116 Failed: When a required tool is missing or below its minimum version. 

117 """ 

118 failed: list[str] = [] 

119 for tool in TOOLS: 

120 path = shutil.which(tool.name) 

121 if path is None: 

122 _report(tool, "missing", ok=False, note=f"install: {tool.url}") 

123 failed.append(tool.name) 

124 continue 

125 

126 output = subprocess.run( # noqa: S603 # nosec B603 

127 [path, "--version"], 

128 capture_output=True, 

129 text=True, 

130 check=False, 

131 ).stdout 

132 version = parse_version(output) 

133 if not version: 

134 # Not assumed fine. A tool that prints no parseable version is a tool this 

135 # diagnostic cannot vouch for, and saying nothing would be the same as passing. 

136 _report(tool, "unknown", ok=False, note=f"required >= {tool.minimum}") 

137 failed.append(tool.name) 

138 elif at_least(version, tool.minimum): 

139 _report(tool, ".".join(map(str, version)), ok=True, note=f">= {tool.minimum}") 

140 else: 

141 _report(tool, ".".join(map(str, version)), ok=False, note=f"< {tool.minimum}") 

142 failed.append(tool.name) 

143 

144 print(f"\n[INFO] python {cfg.python_version} (from .python-version or config)") 

145 if failed: 

146 raise Failed(1, f"missing or outdated: {', '.join(failed)}") 

147 

148 

149def _report(tool: Tool, version: str, ok: bool, note: str) -> None: 

150 """Print one aligned diagnostic line. 

151 

152 Args: 

153 tool: The tool being reported. 

154 version: What was found. 

155 ok: Whether it satisfies the requirement. 

156 note: Trailing detail. 

157 """ 

158 mark, colour = ("[ OK ]", GREEN) if ok else ("[FAIL]", RED) 

159 print(f"{colour}{mark}{RESET} {tool.name:<8} {version:<10} {note}")