Coverage for src/rhiza_task/uv.py: 100%
60 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-10-13 02:27 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-10-13 02:27 +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 python: str | None = None,
178 uv_args: Sequence[str] = (),
179 check: bool = True,
180 env: Mapping[str, str] | None = None,
181) -> int:
182 """Run a tool against the project environment via ``uv run --with``.
184 Args:
185 tool: The executable, e.g. ``pytest``.
186 *args: Arguments for the tool.
187 cwd: Working directory.
188 withs: Packages to inject, e.g. ``("pytest", "pytest-cov")``.
189 no_project: Pass ``--no-project``, for a tool that must not see the project
190 environment. marimo.mk's ``marimo`` target is the one case.
191 python: Interpreter to run under, as ``uvx``'s parameter of the same name already
192 does. Usually omitted -- a tool run against the project environment takes that
193 environment's interpreter, which is the point of this form. ``update`` is the
194 exception: the scripts it drives are a *different* project's, pinned by that
195 project to one version, so the interpreter is theirs to name rather than the
196 target repository's.
197 uv_args: Flags for ``uv run`` itself, placed before the tool. ``test-lowest`` is the
198 only caller, and what it passes is why this is one parameter rather than three:
199 ``--isolated``, ``--resolution lowest-direct`` and the repository's
200 ``uv_sync_args`` are a resolver mode plus the environment it is asked about, and
201 none of them means anything without the others. ``no_project`` and ``python``
202 stay named because each is a single decision with a single spelling -- a caller
203 wanting one of those should keep using them rather than spelling the flag here.
204 check: Raise on non-zero rather than returning the status.
205 env: Extra environment variables.
207 Returns:
208 The exit status.
210 Raises:
211 Failed: When ``check`` and the tool exited non-zero.
212 """
213 argv = [_bin("uv", "RHIZA_UV_BIN"), "run", *uv_args]
214 if python:
215 argv += ["--python", python]
216 if no_project:
217 argv.append("--no-project")
218 for w in withs:
219 argv += ["--with", w]
220 argv += [tool, *args]
221 code = _run(argv, cwd, env)
222 if check and code:
223 raise Failed(code, f"{tool} failed")
224 return code
227def tool(
228 name: str,
229 *args: str,
230 cwd: Path,
231 check: bool = True,
232 env: Mapping[str, str] | None = None,
233) -> int:
234 """Run a toolchain binary that is expected to be on PATH.
236 The Rust and Go layers' engines, which uv neither provisions nor knows about: cargo,
237 rustup, go, and the binaries ``cargo-tools`` and ``go-tools`` install. Nothing is
238 injected and nothing is isolated -- that is what makes it different from :func:`uvx`,
239 not an oversight.
241 Args:
242 name: The executable, or an absolute path to one.
243 *args: Its arguments.
244 cwd: Working directory.
245 check: Raise on non-zero rather than returning the status.
246 env: Extra environment variables, e.g. ``RUSTDOCFLAGS``.
248 Returns:
249 The exit status.
251 Raises:
252 Failed: When ``check`` and the tool exited non-zero.
253 """
254 code = _run([shutil.which(name) or name, *args], cwd, env)
255 if check and code:
256 raise Failed(code, f"{Path(name).name} {args[0] if args else ''} failed".strip())
257 return code
260def capture(name: str, *args: str, cwd: Path) -> str:
261 """Run a tool and return its stdout, for the one recipe that needs a value back.
263 go.mk's licence gate is that recipe: ``go-licenses check ./... --ignore "$(go list -m)"``
264 -- without the module's own path, go-licenses walks the project's own packages and fails
265 a freshly synced project for having no LICENSE of its own. Found by rhiza's e2e suite
266 rather than by a dry run, which is why it is carried over rather than rediscovered.
268 Args:
269 name: The executable.
270 *args: Its arguments.
271 cwd: Working directory.
273 Returns:
274 Stripped stdout, or an empty string when the tool failed or is absent.
275 """
276 try:
277 result = subprocess.run( # noqa: S603 # nosec B603
278 [shutil.which(name) or name, *args],
279 cwd=cwd,
280 capture_output=True,
281 text=True,
282 check=False,
283 )
284 except OSError:
285 return ""
286 return result.stdout.strip() if result.returncode == 0 else ""