Repository: wntrblm/nox (python)

Issue:
uv executable not found when installed with `pipx install nox[uv]`
### Current Behavior

The following exception is raised when a global `uv` installation is not available.

```python
ValueError: No backends present, looked for ['uv'].
```

https://github.com/wntrblm/nox/blob/4c8e89164d90718312e4819a48243797924f55a9/nox/sessions.py#L790

The underlying cause seems to be `shutil.which("uv")` not being able to find uv installed alongside nox.

https://github.com/wntrblm/nox/blob/4c8e89164d90718312e4819a48243797924f55a9/nox/virtualenv.py#L563

### Expected Behavior

Installing `nox[uv]` is sufficient to use uv as a backend, without relying on a global uv installation.

### Steps To Reproduce

1. Install `nox[uv]`
2. Configure `uv` as a venv backend, for example by setting `nox.options.default_venv_backend = "uv"` in the Noxfile
3. Run any session

### Environment

```Markdown
- OS: macOS
- Python: 3.12
- Nox: 2024.3.2
```


### Anything else?

_No response_

Pull request:
fix: look for uv next to python if it's not on PATH
Fixes #791 

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 3f4808f..ec34957 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -40,6 +40,7 @@ repos:
           - jinja2
           - packaging
           - importlib_metadata
+          - uv
 
   - repo: https://github.com/codespell-project/codespell
     rev: v2.2.6
diff --git a/nox/sessions.py b/nox/sessions.py
index 83c3fdb..432db0c 100644
--- a/nox/sessions.py
+++ b/nox/sessions.py
@@ -41,7 +41,7 @@ import nox.command
 import nox.virtualenv
 from nox._decorators import Func
 from nox.logger import logger
-from nox.virtualenv import CondaEnv, PassthroughEnv, ProcessEnv, VirtualEnv
+from nox.virtualenv import UV, CondaEnv, PassthroughEnv, ProcessEnv, VirtualEnv
 
 if TYPE_CHECKING:
     from nox.manifest import Manifest
@@ -664,7 +664,7 @@ class Session:
             kwargs["silent"] = True
 
         if isinstance(venv, VirtualEnv) and venv.venv_backend == "uv":
-            self._run("uv", "pip", "install", *args, external="error", **kwargs)
+            self._run(UV, "pip", "install", *args, external="error", **kwargs)
         else:
             self._run(
                 "python", "-m", "pip", "install", *args, external="error", **kwargs
diff --git a/nox/virtualenv.py b/nox/virtualenv.py
index 2258a04..64560b8 100644
--- a/nox/virtualenv.py
+++ b/nox/virtualenv.py
@@ -39,6 +39,24 @@ _BLACKLISTED_ENV_VARS = frozenset(
 _SYSTEM = platform.system()
 
 
+def find_uv() -> tuple[bool, str]:
+    # 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()
+
+    # Fall back to PATH.
+    uv = shutil.which("uv")
+    if uv is not None:
+        return True, uv
+
+    return False, "uv"
+
+
+HAS_UV, UV = find_uv()
+
+
 class InterpreterNotFound(OSError):
     def __init__(self, interpreter: str) -> None:
         super().__init__(f"Python interpreter {interpreter} not found")
@@ -340,7 +358,7 @@ class VirtualEnv(ProcessEnv):
     """
 
     is_sandboxed = True
-    allowed_globals = ("uv",)
+    allowed_globals = (UV,)
 
     def __init__(
         self,
@@ -539,7 +557,7 @@ class VirtualEnv(ProcessEnv):
                 cmd.extend(["-p", self._resolved_interpreter])
         elif self.venv_backend == "uv":
             cmd = [
-                "uv",
+                UV,
                 "venv",
                 "-p",
                 self._resolved_interpreter if self.interpreter else sys.executable,
@@ -579,5 +597,5 @@ ALL_VENVS: dict[str, Callable[..., ProcessEnv]] = {
 OPTIONAL_VENVS = {
     "conda": shutil.which("conda") is not None,
     "mamba": shutil.which("mamba") is not None,
-    "uv": shutil.which("uv") is not None,
+    "uv": HAS_UV,
 }
diff --git a/pyproject.toml b/pyproject.toml
index df7d531..38b6ec8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -53,7 +53,7 @@ tox_to_nox = [
   "tox",
 ]
 uv = [
-  "uv",
+  "uv>=0.1.6",
 ]
 [project.urls]
 bug-tracker = "https://github.com/wntrblm/nox/issues"


Test patch:
diff --git a/tests/test_sessions.py b/tests/test_sessions.py
index ef17bd5..e6006ea 100644
--- a/tests/test_sessions.py
+++ b/tests/test_sessions.py
@@ -829,7 +829,7 @@ class TestSession:
         with mock.patch.object(session, "_run", autospec=True) as run:
             session.install("requests", "urllib3", silent=False)
             run.assert_called_once_with(
-                "uv",
+                nox.virtualenv.UV,
                 "pip",
                 "install",
                 "requests",
diff --git a/tests/test_virtualenv.py b/tests/test_virtualenv.py
index 69dd6c5..12d41c5 100644
--- a/tests/test_virtualenv.py
+++ b/tests/test_virtualenv.py
@@ -20,6 +20,7 @@ import re
 import shutil
 import subprocess
 import sys
+import types
 from textwrap import dedent
 from typing import NamedTuple
 from unittest import mock
@@ -546,6 +547,32 @@ def test_create_reuse_uv_environment(make_one):
     assert reused
 
 
+UV_IN_PIPX_VENV = "/home/user/.local/pipx/venvs/nox/bin/uv"
+
+
+@pytest.mark.parametrize(
+    ["which_result", "find_uv_bin_result", "expected"],
+    [
+        ("/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")),
+    ],
+)  # fmt: skip
+def test_find_uv(monkeypatch, which_result, find_uv_bin_result, expected):
+    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.setitem(
+        sys.modules, "uv", types.SimpleNamespace(find_uv_bin=find_uv_bin)
+    )
+
+    assert nox.virtualenv.find_uv() == expected
+
+
 def test_create_reuse_venv_environment(make_one, monkeypatch):
     # Making the reuse requirement more strict
     monkeypatch.setenv("NOX_ENABLE_STALENESS_CHECK", "1")