Skip to content

Commit 8f33d1c

Browse files
authored
fix: ensure 'uv' always works in a uv venv (#818)
* fix: ensure 'uv' always works in a uv venv Signed-off-by: Henry Schreiner <[email protected]> * fix: always use simplest form if on PATH Signed-off-by: Henry Schreiner <[email protected]> * tests: verify correction works Signed-off-by: Henry Schreiner <[email protected]> * tests: monkeypatch lower down for new test Signed-off-by: Henry Schreiner <[email protected]> * fix: handle corner case of user instalilng uv into a uv environment Signed-off-by: Henry Schreiner <[email protected]> * tests: add tests for installed UV Signed-off-by: Henry Schreiner <[email protected]> --------- Signed-off-by: Henry Schreiner <[email protected]>
1 parent d6e1906 commit 8f33d1c

File tree

4 files changed

+86
-17
lines changed

4 files changed

+86
-17
lines changed

nox/sessions.py

+12-2
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import os
2222
import pathlib
2323
import re
24+
import shutil
2425
import subprocess
2526
import sys
2627
import unicodedata
@@ -43,7 +44,7 @@
4344
from nox._decorators import Func
4445
from nox.logger import logger
4546
from nox.popen import DEFAULT_INTERRUPT_TIMEOUT, DEFAULT_TERMINATE_TIMEOUT
46-
from nox.virtualenv import UV, CondaEnv, PassthroughEnv, ProcessEnv, VirtualEnv
47+
from nox.virtualenv import CondaEnv, PassthroughEnv, ProcessEnv, VirtualEnv
4748

4849
if TYPE_CHECKING:
4950
from typing import IO
@@ -556,6 +557,15 @@ def _run(
556557
if callable(args[0]):
557558
return self._run_func(args[0], args[1:]) # type: ignore[unreachable]
558559

560+
# Using `"uv"` when `uv` is the backend is guaranteed to work, even if it was co-installed with nox.
561+
if (
562+
self.virtualenv.venv_backend == "uv"
563+
and args[0] == "uv"
564+
and nox.virtualenv.UV != "uv"
565+
and shutil.which("uv", path=self.bin) is None # Session uv takes priority
566+
):
567+
args = (nox.virtualenv.UV, *args[1:])
568+
559569
# Combine the env argument with our virtualenv's env vars.
560570
if include_outer_env:
561571
overlay_env = env or {}
@@ -769,7 +779,7 @@ def install(
769779
silent = True
770780

771781
if isinstance(venv, VirtualEnv) and venv.venv_backend == "uv":
772-
cmd = [UV, "pip", "install"]
782+
cmd = ["uv", "pip", "install"]
773783
else:
774784
cmd = ["python", "-m", "pip", "install"]
775785
self._run(

nox/virtualenv.py

+12-6
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import subprocess
2525
import sys
2626
from collections.abc import Callable, Mapping
27+
from pathlib import Path
2728
from socket import gethostbyname
2829
from typing import Any, ClassVar
2930

@@ -45,18 +46,23 @@
4546

4647

4748
def find_uv() -> tuple[bool, str]:
49+
uv_on_path = shutil.which("uv")
50+
4851
# Look for uv in Nox's environment, to handle `pipx install nox[uv]`.
4952
with contextlib.suppress(ImportError, FileNotFoundError):
5053
from uv import find_uv_bin
5154

52-
return True, find_uv_bin()
55+
uv_bin = find_uv_bin()
5356

54-
# Fall back to PATH.
55-
uv = shutil.which("uv")
56-
if uv is not None:
57-
return True, uv
57+
# If the returned value is the same as calling "uv" already, don't
58+
# expand (simpler logging)
59+
if uv_on_path and Path(uv_bin).samefile(uv_on_path):
60+
return True, "uv"
5861

59-
return False, "uv"
62+
return True, uv_bin
63+
64+
# Fall back to PATH.
65+
return uv_on_path is not None, "uv"
6066

6167

6268
HAS_UV, UV = find_uv()

tests/test_sessions.py

+52-1
Original file line numberDiff line numberDiff line change
@@ -869,14 +869,65 @@ class SessionNoSlots(nox.sessions.Session):
869869
with mock.patch.object(session, "_run", autospec=True) as run:
870870
session.install("requests", "urllib3", silent=False)
871871
run.assert_called_once_with(
872-
nox.virtualenv.UV,
872+
"uv",
873873
"pip",
874874
"install",
875875
"requests",
876876
"urllib3",
877877
**_run_with_defaults(silent=False, external="error"),
878878
)
879879

880+
def test_install_uv_command(self, monkeypatch):
881+
runner = nox.sessions.SessionRunner(
882+
name="test",
883+
signatures=["test"],
884+
func=mock.sentinel.func,
885+
global_config=_options.options.namespace(posargs=[]),
886+
manifest=mock.create_autospec(nox.manifest.Manifest),
887+
)
888+
runner.venv = mock.create_autospec(nox.virtualenv.VirtualEnv)
889+
runner.venv.env = {}
890+
runner.venv.venv_backend = "uv"
891+
892+
class SessionNoSlots(nox.sessions.Session):
893+
pass
894+
895+
session = SessionNoSlots(runner=runner)
896+
897+
monkeypatch.setattr(nox.virtualenv, "UV", "/some/uv")
898+
monkeypatch.setattr(shutil, "which", lambda x, path=None: None)
899+
900+
with mock.patch.object(nox.command, "run", autospec=True) as run:
901+
session.install("requests", "urllib3", silent=False)
902+
run.assert_called_once()
903+
904+
((call_args,), _) = run.call_args
905+
assert call_args == (
906+
"/some/uv",
907+
"pip",
908+
"install",
909+
"requests",
910+
"urllib3",
911+
)
912+
913+
# user installs uv in the session venv
914+
monkeypatch.setattr(
915+
shutil, "which", lambda x, path="": path + "/uv" if x == "uv" else None
916+
)
917+
918+
with mock.patch.object(nox.command, "run", autospec=True) as run:
919+
session.install("requests", "urllib3", silent=False)
920+
run.assert_called_once()
921+
922+
((call_args,), _) = run.call_args
923+
assert call_args == (
924+
"uv",
925+
"pip",
926+
"install",
927+
"requests",
928+
"urllib3",
929+
)
930+
880931
def test___slots__(self):
881932
session, _ = self.make_session_and_runner()
882933
with pytest.raises(AttributeError):

tests/test_virtualenv.py

+10-8
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import subprocess
2222
import sys
2323
import types
24+
from pathlib import Path
2425
from textwrap import dedent
2526
from typing import NamedTuple
2627
from unittest import mock
@@ -602,26 +603,27 @@ def test_create_reuse_uv_environment(make_one):
602603

603604

604605
@pytest.mark.parametrize(
605-
["which_result", "find_uv_bin_result", "expected"],
606+
["which_result", "find_uv_bin_result", "found", "path"],
606607
[
607-
("/usr/bin/uv", UV_IN_PIPX_VENV, (True, UV_IN_PIPX_VENV)),
608-
("/usr/bin/uv", FileNotFoundError, (True, "/usr/bin/uv")),
609-
(None, UV_IN_PIPX_VENV, (True, UV_IN_PIPX_VENV)),
610-
(None, FileNotFoundError, (False, "uv")),
608+
("/usr/bin/uv", UV_IN_PIPX_VENV, True, UV_IN_PIPX_VENV),
609+
("/usr/bin/uv", FileNotFoundError, True, "uv"),
610+
(None, UV_IN_PIPX_VENV, True, UV_IN_PIPX_VENV),
611+
(None, FileNotFoundError, False, "uv"),
611612
],
612-
) # fmt: skip
613-
def test_find_uv(monkeypatch, which_result, find_uv_bin_result, expected):
613+
)
614+
def test_find_uv(monkeypatch, which_result, find_uv_bin_result, found, path):
614615
def find_uv_bin():
615616
if find_uv_bin_result is FileNotFoundError:
616617
raise FileNotFoundError
617618
return find_uv_bin_result
618619

619620
monkeypatch.setattr(shutil, "which", lambda _: which_result)
621+
monkeypatch.setattr(Path, "samefile", lambda a, b: a == b)
620622
monkeypatch.setitem(
621623
sys.modules, "uv", types.SimpleNamespace(find_uv_bin=find_uv_bin)
622624
)
623625

624-
assert nox.virtualenv.find_uv() == expected
626+
assert nox.virtualenv.find_uv() == (found, path)
625627

626628

627629
def test_create_reuse_venv_environment(make_one, monkeypatch):

0 commit comments

Comments
 (0)