Repository: wntrblm/nox
Pull request #955 (2025-04-30 14:46:40)

fix: uv version is now uv self version, support UV
We don't support uv 0.7. This fixes it and also supports the UV environment variable, which wasn't something UV had before. (This fixes nested uv calls, uv sets UV when it calls anything)



Patch:
diff --git a/nox/virtualenv.py b/nox/virtualenv.py
index 3b97ab1..b4b1a83 100644
--- a/nox/virtualenv.py
+++ b/nox/virtualenv.py
@@ -82,33 +82,39 @@ _BLACKLISTED_ENV_VARS = frozenset(
 
 
 def find_uv() -> tuple[bool, str, version.Version]:
-    uv_on_path = shutil.which("uv")
+    uv_name = os.environ.get("UV", None)
+    uv_on_path = shutil.which(uv_name or "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
+    if uv_name is None:
+        with contextlib.suppress(ImportError, FileNotFoundError):
+            from uv import find_uv_bin
 
-        uv_bin = find_uv_bin()
+            uv_bin = find_uv_bin()
 
-        uv_vers = uv_version(uv_bin)
-        if uv_vers > version.Version("0"):
-            # 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", uv_vers
+            uv_vers = uv_version(uv_bin)
+            if uv_vers > version.Version("0"):
+                # 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", uv_vers
 
-            return True, uv_bin, uv_vers
+                return True, uv_bin, uv_vers
 
     # Fall back to PATH.
-    uv_vers = uv_version("uv")
-    return uv_on_path is not None and uv_vers > version.Version("0"), "uv", uv_vers
+    uv_vers = uv_version(uv_name or "uv")
+    return (
+        uv_on_path is not None and uv_vers > version.Version("0"),
+        uv_name or "uv",
+        uv_vers,
+    )
 
 
 def uv_version(uv_bin: str) -> version.Version:
     """Returns uv's version defaulting to 0.0 if uv is not available"""
     try:
         ret = subprocess.run(
-            [uv_bin, "version", "--output-format", "json"],
+            [uv_bin, "self", "version", "--output-format", "json"],
             check=False,
             text=True,
             capture_output=True,
@@ -118,8 +124,19 @@ def uv_version(uv_bin: str) -> version.Version:
         logger.info("uv binary not found.")
         return version.Version("0.0")
 
+    if ret.returncode == 2:
+        # uv < 0.7
+        ret = subprocess.run(
+            [uv_bin, "version", "--output-format", "json"],
+            check=False,
+            text=True,
+            capture_output=True,
+            encoding="utf-8",
+        )
+
     if ret.returncode == 0 and ret.stdout:
         return version.Version(json.loads(ret.stdout).get("version"))
+
     logger.info("Failed to establish uv's version.")
     return version.Version("0.0")
 


Test patch:
diff --git a/tests/test_virtualenv.py b/tests/test_virtualenv.py
index d8223fb..f2d532b 100644
--- a/tests/test_virtualenv.py
+++ b/tests/test_virtualenv.py
@@ -722,21 +722,151 @@ UV_IN_PIPX_VENV = "/home/user/.local/pipx/venvs/nox/bin/uv"
 
 
 @pytest.mark.parametrize(
-    ("which_result", "find_uv_bin_result", "found", "path", "vers", "vers_rc"),
+    (
+        "uv_env",
+        "which_result",
+        "find_uv_bin_result",
+        "found",
+        "path",
+        "vers",
+        "vers_rc",
+    ),
     [
-        ("/usr/bin/uv", UV_IN_PIPX_VENV, True, UV_IN_PIPX_VENV, "0.5.0", 0),
-        ("/usr/bin/uv", UV_IN_PIPX_VENV, True, UV_IN_PIPX_VENV, "0.6.0", 0),
-        ("/usr/bin/uv", UV_IN_PIPX_VENV, False, "uv", "0.0.0", 0),
-        ("/usr/bin/uv", UV_IN_PIPX_VENV, False, "uv", "0.6.0", 1),
-        ("/usr/bin/uv", None, True, "uv", "0.6.0", 0),
-        ("/usr/bin/uv", None, False, "uv", "0.0.0", 0),
-        ("/usr/bin/uv", None, False, "uv", "0.6.0", 1),
-        (None, UV_IN_PIPX_VENV, True, UV_IN_PIPX_VENV, "0.5.0", 0),
-        (None, None, False, "uv", "0.5.0", 0),
+        pytest.param(
+            None,
+            "/usr/bin/uv",
+            UV_IN_PIPX_VENV,
+            True,
+            UV_IN_PIPX_VENV,
+            "0.5.0",
+            0,
+            id="pkg_pipx_uv_0.5",
+        ),
+        pytest.param(
+            None,
+            "/usr/bin/uv",
+            UV_IN_PIPX_VENV,
+            True,
+            UV_IN_PIPX_VENV,
+            "0.6.0",
+            0,
+            id="pkg_pipx_uv_0.6",
+        ),
+        pytest.param(
+            "custom",
+            "/usr/bin/uv",
+            UV_IN_PIPX_VENV,
+            True,
+            "custom",
+            "0.6.0",
+            0,
+            id="UV_pkg_pipx_uv_0.6",
+        ),
+        pytest.param(
+            None,
+            "/usr/bin/uv",
+            UV_IN_PIPX_VENV,
+            True,
+            UV_IN_PIPX_VENV,
+            "0.7.0",
+            0,
+            id="pkg_pipx_uv_0.7",
+        ),
+        pytest.param(
+            "custom",
+            "/usr/bin/uv",
+            UV_IN_PIPX_VENV,
+            True,
+            "custom",
+            "0.7.0",
+            0,
+            id="UV_pkg_pipx_uv_0.7",
+        ),
+        pytest.param(
+            None,
+            "/usr/bin/uv",
+            UV_IN_PIPX_VENV,
+            False,
+            "uv",
+            "0.0.0",
+            0,
+            id="pkg_system_uv_0.0",
+        ),
+        pytest.param(
+            None,
+            "/usr/bin/uv",
+            UV_IN_PIPX_VENV,
+            False,
+            "uv",
+            "0.6.0",
+            1,
+            id="pkg_system_uv_0.6_broken",
+        ),
+        pytest.param(
+            None,
+            "/usr/bin/uv",
+            UV_IN_PIPX_VENV,
+            False,
+            "uv",
+            "0.7.0",
+            1,
+            id="pkg_system_uv_0.7_broken",
+        ),
+        pytest.param(
+            None, "/usr/bin/uv", None, True, "uv", "0.7.0", 0, id="system_uv_0.7"
+        ),
+        pytest.param(
+            None, "/usr/bin/uv", None, True, "uv", "0.6.0", 0, id="system_uv_0.6"
+        ),
+        pytest.param(
+            None, "/usr/bin/uv", None, False, "uv", "0.0.0", 0, id="system_uv_0.0"
+        ),
+        pytest.param(
+            None,
+            "/usr/bin/uv",
+            None,
+            False,
+            "uv",
+            "0.6.0",
+            1,
+            id="system_uv_0.6_broken",
+        ),
+        pytest.param(
+            None,
+            "/usr/bin/uv",
+            None,
+            False,
+            "uv",
+            "0.7.0",
+            1,
+            id="system_uv_0.7_broken",
+        ),
+        pytest.param(
+            None,
+            None,
+            UV_IN_PIPX_VENV,
+            True,
+            UV_IN_PIPX_VENV,
+            "0.5.0",
+            0,
+            id="pipx_uv_0.5",
+        ),
+        pytest.param(
+            None,
+            None,
+            UV_IN_PIPX_VENV,
+            True,
+            UV_IN_PIPX_VENV,
+            "0.7.0",
+            0,
+            id="pipx_uv_0.7",
+        ),
+        pytest.param(None, None, None, False, "uv", "0.5.0", 0, id="no_uv_0.5"),
     ],
 )
 def test_find_uv(
     monkeypatch: pytest.MonkeyPatch,
+    uv_env: str | None,
     which_result: str | None,
     find_uv_bin_result: str | None,
     found: bool,
@@ -744,21 +874,32 @@ def test_find_uv(
     vers: str,
     vers_rc: int,
 ) -> None:
+    monkeypatch.delenv("UV", raising=False)
+    if uv_env:
+        monkeypatch.setenv("UV", uv_env)
+
     def find_uv_bin() -> str:
         if find_uv_bin_result:
             return find_uv_bin_result
         raise FileNotFoundError()
 
-    def mock_run(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str]:
+    def mock_run(*args: Any, **kwargs: object) -> subprocess.CompletedProcess[str]:
+        if version.Version(vers) < version.Version("0.7.0") and "self" in args[0]:
+            return subprocess.CompletedProcess(
+                args=args[0],
+                returncode=2,
+            )
         return subprocess.CompletedProcess(
-            args=["uv", "version", "--output-format", "json"],
+            args=args[0],
             stdout=f'{{"version": "{vers}", "commit_info": null}}',
             returncode=vers_rc,
         )
 
     monkeypatch.setattr(subprocess, "run", mock_run)
 
-    monkeypatch.setattr(shutil, "which", lambda _: which_result)
+    monkeypatch.setattr(
+        shutil, "which", lambda x: which_result if x.endswith("uv") else uv_env
+    )
     monkeypatch.setattr(Path, "samefile", lambda a, b: a == b)
     monkeypatch.setitem(
         sys.modules, "uv", types.SimpleNamespace(find_uv_bin=find_uv_bin)
@@ -787,7 +928,7 @@ def test_uv_version(
 ) -> None:
     def mock_run(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str]:
         return subprocess.CompletedProcess(
-            args=["uv", "version", "--output-format", "json"],
+            args=["uv", "self", "version", "--output-format", "json"],
             stdout=stdout,
             returncode=return_code,
         )