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
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:13 +0000
1r"""Prerequisite diagnostics: doctor.mk, as a task.
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``.
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.
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.
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"""
28from __future__ import annotations
30import re
31import shutil
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
40from ..config import Config
41from ..spec import Failed, task
43GREEN = "\033[32m"
44RED = "\033[31m"
45RESET = "\033[0m"
47VERSION_RE = re.compile(r"(\d+(?:\.\d+)+)")
50@dataclass(frozen=True)
51class Tool:
52 """A prerequisite, its minimum version, and where to get it.
54 Every entry is required; see the module docstring for why there is no optional tier.
56 Attributes:
57 name: Executable name.
58 minimum: Lowest acceptable dotted version.
59 url: Install instructions, printed when it is missing.
60 """
62 name: str
63 minimum: str
64 url: str
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)
76def parse_version(text: str) -> tuple[int, ...]:
77 """Extract the first dotted version from a tool's ``--version`` output.
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.
83 Args:
84 text: The raw ``--version`` output.
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 ()
93def at_least(found: tuple[int, ...], minimum: str) -> bool:
94 """Compare dotted versions, padding the shorter one with zeros.
96 Args:
97 found: The installed version.
98 minimum: The required version, dotted.
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))
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.
112 Args:
113 cfg: The resolved config.
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
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)
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)}")
149def _report(tool: Tool, version: str, ok: bool, note: str) -> None:
150 """Print one aligned diagnostic line.
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}")