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
« 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.
3Every recipe in the retired *Python* make layer used one of exactly three forms:
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.
11The second and third are a real distinction that the make layer already gets right, so it
12is preserved here rather than unified.
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.
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.
29Two things disappear:
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.
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"""
44from __future__ import annotations
46import os
47import shutil
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
58from .spec import Failed
60BLUE = "\033[36m"
61RESET = "\033[0m"
64def _bin(name: str, env_var: str) -> str:
65 """Resolve a uv executable, honouring an override.
67 Args:
68 name: ``uv`` or ``uvx``.
69 env_var: The override variable, e.g. ``RHIZA_UV_BIN``.
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
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.
81 Args:
82 argv: The full argument vector.
83 cwd: Working directory.
84 env: Extra environment variables, merged over the current environment.
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
109def uv(*args: str, cwd: Path, check: bool = True, env: Mapping[str, str] | None = None) -> int:
110 """Run uv itself.
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.
118 Returns:
119 The exit status.
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
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``.
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.
153 Returns:
154 The exit status.
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
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``.
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.
192 Returns:
193 The exit status.
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
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.
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.
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``.
231 Returns:
232 The exit status.
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
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.
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.
251 Args:
252 name: The executable.
253 *args: Its arguments.
254 cwd: Working directory.
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 ""