From 8f33d1c247fadc0682c4026d55e0fbfe7b02c423 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Mon, 15 Apr 2024 01:26:15 -0400 Subject: [PATCH] fix: ensure 'uv' always works in a uv venv (#818) * fix: ensure 'uv' always works in a uv venv Signed-off-by: Henry Schreiner * fix: always use simplest form if on PATH Signed-off-by: Henry Schreiner * tests: verify correction works Signed-off-by: Henry Schreiner * tests: monkeypatch lower down for new test Signed-off-by: Henry Schreiner * fix: handle corner case of user instalilng uv into a uv environment Signed-off-by: Henry Schreiner * tests: add tests for installed UV Signed-off-by: Henry Schreiner --------- Signed-off-by: Henry Schreiner --- nox/sessions.py | 14 +++++++++-- nox/virtualenv.py | 18 +++++++++----- tests/test_sessions.py | 53 +++++++++++++++++++++++++++++++++++++++- tests/test_virtualenv.py | 18 ++++++++------ 4 files changed, 86 insertions(+), 17 deletions(-) diff --git a/nox/sessions.py b/nox/sessions.py index 756b09b6..4f5d2805 100644 --- a/nox/sessions.py +++ b/nox/sessions.py @@ -21,6 +21,7 @@ import os import pathlib import re +import shutil import subprocess import sys import unicodedata @@ -43,7 +44,7 @@ from nox._decorators import Func from nox.logger import logger from nox.popen import DEFAULT_INTERRUPT_TIMEOUT, DEFAULT_TERMINATE_TIMEOUT -from nox.virtualenv import UV, CondaEnv, PassthroughEnv, ProcessEnv, VirtualEnv +from nox.virtualenv import CondaEnv, PassthroughEnv, ProcessEnv, VirtualEnv if TYPE_CHECKING: from typing import IO @@ -556,6 +557,15 @@ def _run( if callable(args[0]): return self._run_func(args[0], args[1:]) # type: ignore[unreachable] + # Using `"uv"` when `uv` is the backend is guaranteed to work, even if it was co-installed with nox. + if ( + self.virtualenv.venv_backend == "uv" + and args[0] == "uv" + and nox.virtualenv.UV != "uv" + and shutil.which("uv", path=self.bin) is None # Session uv takes priority + ): + args = (nox.virtualenv.UV, *args[1:]) + # Combine the env argument with our virtualenv's env vars. if include_outer_env: overlay_env = env or {} @@ -769,7 +779,7 @@ def install( silent = True if isinstance(venv, VirtualEnv) and venv.venv_backend == "uv": - cmd = [UV, "pip", "install"] + cmd = ["uv", "pip", "install"] else: cmd = ["python", "-m", "pip", "install"] self._run( diff --git a/nox/virtualenv.py b/nox/virtualenv.py index 90ee0850..adf60333 100644 --- a/nox/virtualenv.py +++ b/nox/virtualenv.py @@ -24,6 +24,7 @@ import subprocess import sys from collections.abc import Callable, Mapping +from pathlib import Path from socket import gethostbyname from typing import Any, ClassVar @@ -45,18 +46,23 @@ def find_uv() -> tuple[bool, str]: + uv_on_path = shutil.which("uv") + # Look for uv in Nox's environment, to handle `pipx install nox[uv]`. with contextlib.suppress(ImportError, FileNotFoundError): from uv import find_uv_bin - return True, find_uv_bin() + uv_bin = find_uv_bin() - # Fall back to PATH. - uv = shutil.which("uv") - if uv is not None: - return True, uv + # If the returned value is the same as calling "uv" already, don't + # expand (simpler logging) + if uv_on_path and Path(uv_bin).samefile(uv_on_path): + return True, "uv" - return False, "uv" + return True, uv_bin + + # Fall back to PATH. + return uv_on_path is not None, "uv" HAS_UV, UV = find_uv() diff --git a/tests/test_sessions.py b/tests/test_sessions.py index c5295f51..90b0caf6 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -869,7 +869,7 @@ class SessionNoSlots(nox.sessions.Session): with mock.patch.object(session, "_run", autospec=True) as run: session.install("requests", "urllib3", silent=False) run.assert_called_once_with( - nox.virtualenv.UV, + "uv", "pip", "install", "requests", @@ -877,6 +877,57 @@ class SessionNoSlots(nox.sessions.Session): **_run_with_defaults(silent=False, external="error"), ) + def test_install_uv_command(self, monkeypatch): + runner = nox.sessions.SessionRunner( + name="test", + signatures=["test"], + func=mock.sentinel.func, + global_config=_options.options.namespace(posargs=[]), + manifest=mock.create_autospec(nox.manifest.Manifest), + ) + runner.venv = mock.create_autospec(nox.virtualenv.VirtualEnv) + runner.venv.env = {} + runner.venv.venv_backend = "uv" + + class SessionNoSlots(nox.sessions.Session): + pass + + session = SessionNoSlots(runner=runner) + + monkeypatch.setattr(nox.virtualenv, "UV", "/some/uv") + monkeypatch.setattr(shutil, "which", lambda x, path=None: None) + + with mock.patch.object(nox.command, "run", autospec=True) as run: + session.install("requests", "urllib3", silent=False) + run.assert_called_once() + + ((call_args,), _) = run.call_args + assert call_args == ( + "/some/uv", + "pip", + "install", + "requests", + "urllib3", + ) + + # user installs uv in the session venv + monkeypatch.setattr( + shutil, "which", lambda x, path="": path + "/uv" if x == "uv" else None + ) + + with mock.patch.object(nox.command, "run", autospec=True) as run: + session.install("requests", "urllib3", silent=False) + run.assert_called_once() + + ((call_args,), _) = run.call_args + assert call_args == ( + "uv", + "pip", + "install", + "requests", + "urllib3", + ) + def test___slots__(self): session, _ = self.make_session_and_runner() with pytest.raises(AttributeError): diff --git a/tests/test_virtualenv.py b/tests/test_virtualenv.py index f1dfb82d..ec26de3b 100644 --- a/tests/test_virtualenv.py +++ b/tests/test_virtualenv.py @@ -21,6 +21,7 @@ import subprocess import sys import types +from pathlib import Path from textwrap import dedent from typing import NamedTuple from unittest import mock @@ -602,26 +603,27 @@ def test_create_reuse_uv_environment(make_one): @pytest.mark.parametrize( - ["which_result", "find_uv_bin_result", "expected"], + ["which_result", "find_uv_bin_result", "found", "path"], [ - ("/usr/bin/uv", UV_IN_PIPX_VENV, (True, UV_IN_PIPX_VENV)), - ("/usr/bin/uv", FileNotFoundError, (True, "/usr/bin/uv")), - (None, UV_IN_PIPX_VENV, (True, UV_IN_PIPX_VENV)), - (None, FileNotFoundError, (False, "uv")), + ("/usr/bin/uv", UV_IN_PIPX_VENV, True, UV_IN_PIPX_VENV), + ("/usr/bin/uv", FileNotFoundError, True, "uv"), + (None, UV_IN_PIPX_VENV, True, UV_IN_PIPX_VENV), + (None, FileNotFoundError, False, "uv"), ], -) # fmt: skip -def test_find_uv(monkeypatch, which_result, find_uv_bin_result, expected): +) +def test_find_uv(monkeypatch, which_result, find_uv_bin_result, found, path): def find_uv_bin(): if find_uv_bin_result is FileNotFoundError: raise FileNotFoundError return find_uv_bin_result monkeypatch.setattr(shutil, "which", lambda _: which_result) + monkeypatch.setattr(Path, "samefile", lambda a, b: a == b) monkeypatch.setitem( sys.modules, "uv", types.SimpleNamespace(find_uv_bin=find_uv_bin) ) - assert nox.virtualenv.find_uv() == expected + assert nox.virtualenv.find_uv() == (found, path) def test_create_reuse_venv_environment(make_one, monkeypatch):