Coverage for src/rhiza_task/uv.py: 100%

58 statements  

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

1"""The ways rhiza reaches a tool, and nothing else. 

2 

3Every recipe in the retired *Python* make layer used one of exactly three forms: 

4 

5* ``uv <subcommand>`` -- uv itself (``venv``, ``sync``, ``lock --check``). 

6* ``uvx <tool>`` -- an isolated one-shot tool run: prek, deptry, bandit, semgrep, 

7 zensical, genbadge. 

8* ``uv run --with a --with b <tool>`` -- a tool run *against the project environment*, 

9 because it imports the project's own code: pytest, interrogate, hypothesis, ty, mypy. 

10 

11The second and third are a real distinction that the make layer already gets right, so it 

12is preserved here rather than unified. 

13 

14rust.mk and go.mk add a fourth: ``$(CARGO) nextest run``, ``$(GO) test`` -- a toolchain 

15binary that is already on PATH, because uv does not provision cargo or go and nothing here 

16pretends otherwise. :func:`tool` is that form. It shares this module's environment handling 

17and echoing rather than being a bare ``subprocess.call`` in each language module, so 

18``$ cargo clippy`` is printed the same way ``$ uvx bandit`` is. 

19 

20go.mk contributes one more, and it is the one that gets missed when these are counted: 

21:func:`capture`, which returns *stdout* rather than an exit status, for the recipe that 

22needs a value back rather than a verdict -- the licence gate, which has to interpolate 

23``go list -m`` into its own arguments. It is easy to overlook precisely because it is the 

24only form whose caller reads the result instead of just its status, and #131 is what that 

25cost: every prose total for this module disagreed with the code, and with the others. So no 

26sentence here gives one -- the public functions below are the authority, and a total in 

27prose goes stale the moment a form is added. 

28 

29Two things disappear: 

30 

31``install-uv`` as a *task*. bootstrap.mk curls ``https://astral.sh/uv/install.sh`` into 

32``./bin`` because make cannot assume uv exists. A process launched by ``uvx rhiza-task`` 

33runs *because* uv exists, so nothing in this package can be the thing that provisions uv -- 

34it would already be too late. The problem does not disappear with it, though: the make 

35layer's contract was that ``make <anything>`` works on a bare runner, so the bootstrap 

36lives on in the Makefile shim as three lines and one file target. What is gone is the 30 

37lines of probe-and-branch shell, and the ``bin/uv`` nobody ran directly. 

38 

39The shell. Commands are argument vectors, never shell strings. rhiza.mk carries a 40-line 

40probe to detect make falling back to ``cmd.exe`` on Windows, because its recipes are 

41POSIX shell; with no shell there is nothing to detect. 

42""" 

43 

44from __future__ import annotations 

45 

46import os 

47import shutil 

48 

49# Every call in this module is a fixed argument vector, and `shell=True` appears nowhere -- which 

50# is what bandit's B404 asks about. The reason sits here rather than on the suppression comment 

51# itself: bandit reads everything after that marker as a comma-separated list of test IDs, so a 

52# trailing explanation becomes one `Test in comment:` warning per word. 

53import subprocess # nosec B404 

54import sys 

55from collections.abc import Mapping, Sequence 

56from pathlib import Path 

57 

58from .spec import Failed 

59 

60BLUE = "\033[36m" 

61RESET = "\033[0m" 

62 

63 

64def _bin(name: str, env_var: str) -> str: 

65 """Resolve a uv executable, honouring an override. 

66 

67 Args: 

68 name: ``uv`` or ``uvx``. 

69 env_var: The override variable, e.g. ``RHIZA_UV_BIN``. 

70 

71 Returns: 

72 An absolute path when one is found, else the bare name so the OS reports the 

73 failure with its own message. 

74 """ 

75 return os.environ.get(env_var) or shutil.which(name) or name 

76 

77 

78def _run(argv: Sequence[str], cwd: Path, env: Mapping[str, str] | None = None) -> int: 

79 """Run a command, streaming its output, and return the exit status. 

80 

81 Args: 

82 argv: The full argument vector. 

83 cwd: Working directory. 

84 env: Extra environment variables, merged over the current environment. 

85 

86 Returns: 

87 The process exit status. 

88 """ 

89 # Built as an explicitly typed dict rather than a `{**a, **b}` literal: subprocess's 

90 # `env` parameter is narrowly typed, and the inferred type of the literal is not 

91 # assignable to it. 

92 merged: dict[str, str] = dict(os.environ) 

93 merged.update(env or {}) 

94 # uv warns when VIRTUAL_ENV points somewhere other than the project venv. rhiza.mk 

95 # handles this with `unexport VIRTUAL_ENV`; this is the same fix. 

96 merged.pop("VIRTUAL_ENV", None) 

97 merged.setdefault("UV_NO_MODIFY_PATH", "1") 

98 print(f"{BLUE}$ {' '.join(argv)}{RESET}", file=sys.stderr, flush=True) 

99 # `list(argv)` on its own line, not inlined into the call below. Inlined, the line held 

100 # two call nodes, and bandit runs every test against each: B603 fired on the subprocess 

101 # call and was suppressed, then returned None for `list(...)` and -- seeing B603 named in 

102 # the line's suppression comment -- warned `encountered (B603), but no failed test` on 

103 # every clean run. The suppression is live: B603 is a real finding here, low severity and 

104 # so below `bandit -ll`'s threshold. Hence one call per line rather than a deletion. 

105 command = list(argv) 

106 return subprocess.call(command, cwd=cwd, env=merged) # noqa: S603 # nosec B603 

107 

108 

109def uv(*args: str, cwd: Path, check: bool = True, env: Mapping[str, str] | None = None) -> int: 

110 """Run uv itself. 

111 

112 Args: 

113 *args: uv subcommand and arguments, e.g. ``("sync", "--frozen")``. 

114 cwd: Working directory. 

115 check: Raise on non-zero rather than returning the status. 

116 env: Extra environment variables. 

117 

118 Returns: 

119 The exit status. 

120 

121 Raises: 

122 Failed: When ``check`` and uv exited non-zero. 

123 """ 

124 code = _run([_bin("uv", "RHIZA_UV_BIN"), *args], cwd, env) 

125 if check and code: 

126 raise Failed(code, f"uv {args[0] if args else ''} failed") 

127 return code 

128 

129 

130def uvx( 

131 tool: str, 

132 *args: str, 

133 cwd: Path, 

134 withs: Sequence[str] = (), 

135 python: str | None = None, 

136 check: bool = True, 

137 env: Mapping[str, str] | None = None, 

138) -> int: 

139 """Run an isolated tool via ``uvx``. 

140 

141 Args: 

142 tool: The tool spec, e.g. ``deptry`` or ``'zensical>=0.0.36'``. 

143 *args: Arguments for the tool. 

144 cwd: Working directory. 

145 withs: Extra packages injected into the tool's environment. book.mk's 

146 ``MKDOCS_EXTRA_PACKAGES`` is the only current user. 

147 python: Interpreter for the tool itself. Usually omitted -- prek and the other 

148 language-neutral tools provision their own toolchains, which is why 

149 quality.mk was able to drop its ``-p ${PYTHON_VERSION}``. 

150 check: Raise on non-zero rather than returning the status. 

151 env: Extra environment variables. 

152 

153 Returns: 

154 The exit status. 

155 

156 Raises: 

157 Failed: When ``check`` and the tool exited non-zero. 

158 """ 

159 argv = [_bin("uvx", "RHIZA_UVX_BIN")] 

160 if python: 

161 argv += ["-p", python] 

162 for w in withs: 

163 argv += ["--with", w] 

164 argv += [tool, *args] 

165 code = _run(argv, cwd, env) 

166 if check and code: 

167 raise Failed(code, f"{tool} failed") 

168 return code 

169 

170 

171def uv_run( 

172 tool: str, 

173 *args: str, 

174 cwd: Path, 

175 withs: Sequence[str] = (), 

176 no_project: bool = False, 

177 check: bool = True, 

178 env: Mapping[str, str] | None = None, 

179) -> int: 

180 """Run a tool against the project environment via ``uv run --with``. 

181 

182 Args: 

183 tool: The executable, e.g. ``pytest``. 

184 *args: Arguments for the tool. 

185 cwd: Working directory. 

186 withs: Packages to inject, e.g. ``("pytest", "pytest-cov")``. 

187 no_project: Pass ``--no-project``, for a tool that must not see the project 

188 environment. marimo.mk's ``marimo`` target is the one case. 

189 check: Raise on non-zero rather than returning the status. 

190 env: Extra environment variables. 

191 

192 Returns: 

193 The exit status. 

194 

195 Raises: 

196 Failed: When ``check`` and the tool exited non-zero. 

197 """ 

198 argv = [_bin("uv", "RHIZA_UV_BIN"), "run"] 

199 if no_project: 

200 argv.append("--no-project") 

201 for w in withs: 

202 argv += ["--with", w] 

203 argv += [tool, *args] 

204 code = _run(argv, cwd, env) 

205 if check and code: 

206 raise Failed(code, f"{tool} failed") 

207 return code 

208 

209 

210def tool( 

211 name: str, 

212 *args: str, 

213 cwd: Path, 

214 check: bool = True, 

215 env: Mapping[str, str] | None = None, 

216) -> int: 

217 """Run a toolchain binary that is expected to be on PATH. 

218 

219 The Rust and Go layers' engines, which uv neither provisions nor knows about: cargo, 

220 rustup, go, and the binaries ``cargo-tools`` and ``go-tools`` install. Nothing is 

221 injected and nothing is isolated -- that is what makes it different from :func:`uvx`, 

222 not an oversight. 

223 

224 Args: 

225 name: The executable, or an absolute path to one. 

226 *args: Its arguments. 

227 cwd: Working directory. 

228 check: Raise on non-zero rather than returning the status. 

229 env: Extra environment variables, e.g. ``RUSTDOCFLAGS``. 

230 

231 Returns: 

232 The exit status. 

233 

234 Raises: 

235 Failed: When ``check`` and the tool exited non-zero. 

236 """ 

237 code = _run([shutil.which(name) or name, *args], cwd, env) 

238 if check and code: 

239 raise Failed(code, f"{Path(name).name} {args[0] if args else ''} failed".strip()) 

240 return code 

241 

242 

243def capture(name: str, *args: str, cwd: Path) -> str: 

244 """Run a tool and return its stdout, for the one recipe that needs a value back. 

245 

246 go.mk's licence gate is that recipe: ``go-licenses check ./... --ignore "$(go list -m)"`` 

247 -- without the module's own path, go-licenses walks the project's own packages and fails 

248 a freshly synced project for having no LICENSE of its own. Found by rhiza's e2e suite 

249 rather than by a dry run, which is why it is carried over rather than rediscovered. 

250 

251 Args: 

252 name: The executable. 

253 *args: Its arguments. 

254 cwd: Working directory. 

255 

256 Returns: 

257 Stripped stdout, or an empty string when the tool failed or is absent. 

258 """ 

259 try: 

260 result = subprocess.run( # noqa: S603 # nosec B603 

261 [shutil.which(name) or name, *args], 

262 cwd=cwd, 

263 capture_output=True, 

264 text=True, 

265 check=False, 

266 ) 

267 except OSError: 

268 return "" 

269 return result.stdout.strip() if result.returncode == 0 else ""