Repository: wntrblm/nox
Pull request #818 (2024-04-12 16:31:56)

fix: ensure 'uv' always works in a uv venv
It's necessary sometimes to use the backend tool (pip, uv, conda, mamba, micromamba) inside a virtual environment. We already have a bit of special casing for it, making sure it's not considered "external". This PR just makes sure that `.run("uv", ...)` is always supported if you are using the uv backend. All the other backends are already gaurenteed to be on the path if they are active, so no change is needed (yet, anyway - this mechanism could also enable using `pip` from outside instead of always installing it, might be worth investigating in the future).

Followup to and generalizes #795.


henryiii: Added handling for one more corner case: if a user installs uv into a uv environment, they'd probably expect the new uv to be used after they installed it, just like pip is for pip environments. Tested locally with:

```python
@nox.session(venv_backend="uv")
def uv(session):
    session.install("uv")
    session.install("pip")
```
cjolowicz: Can you add a source code comment to explain this edge case?

It would also be good to have a comment for the `Path.samefile` call in `find_uv`, and to add both branches to the test matrix in `test_find_uv`.
henryiii: Added comments and added to tests.

Patch:
diff --git a/nox/sessions.py b/nox/sessions.py
index 756b09b..4f5d280 100644
--- a/nox/sessions.py
+++ b/nox/sessions.py
@@ -21,6 +21,7 @@ import hashlib
 import os
 import pathlib
 import re
+import shutil
 import subprocess
 import sys
 import unicodedata
@@ -43,7 +44,7 @@ import nox.virtualenv
 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 @@ class Session:
         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 @@ class Session:
             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 90ee085..adf6033 100644
--- a/nox/virtualenv.py
+++ b/nox/virtualenv.py
@@ -24,6 +24,7 @@ import shutil
 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 @@ _SYSTEM = platform.system()
 
 
 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()


Test patch:
diff --git a/tests/test_sessions.py b/tests/test_sessions.py
index c5295f5..90b0caf 100644
--- a/tests/test_sessions.py
+++ b/tests/test_sessions.py
@@ -869,7 +869,7 @@ class TestSession:
         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 TestSession:
                 **_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 f1dfb82..ec26de3 100644
--- a/tests/test_virtualenv.py
+++ b/tests/test_virtualenv.py
@@ -21,6 +21,7 @@ import shutil
 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 @@ UV_IN_PIPX_VENV = "/home/user/.local/pipx/venvs/nox/bin/uv"
 
 
 @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):