Repository: wntrblm/nox (python)

Issue:
nox fails if uv in PATH is a pyenv shim
### Current Behavior

[pyenv]() uses shim scripts that live in a directory in PATH. These scripts either trigger the executable for the loaded python version or raise an error if the script is not installed in the "activated" python version.

This leads to broken nox builds, since nox automatically uses `uv` if it finds it in your PATH, even if the uv is a shim script. It would be nice to detect this because venv builds run fine. I worked around this by ufing `-fb venv`.

### Expected Behavior

Nox somehow infers that the uv in the user's PATH is broken and doesn't use it.

### Steps To Reproduce

```
$ pyenv global 3.12.8
$ pip install uv
$ pyenv global 3.13.1
$ nox
nox > Running session tests
nox > Re-using existing virtual environment at .nox/tests.
nox > uv pip install coverage
nox > Command uv pip install coverage failed with exit code 127:
pyenv: uv: command not found

The `uv' command exists in these Python versions:
  3.12.8

Note: See 'pyenv help global' for tips on allowing both
      python2 and python3 to be found.
```

### Environment

```Markdown
- OS: MacOS
- Python: Any two python versions installed via PyEnv with uv installed via pip in one environment
- Nox: 2024.10.9
```

### Anything else?

_No response_

Pull request description:
fix: support broken uv (via pyenv)
Fix #919 by moving the `uv` version check up to the discovery phase, and skip discovery if the version step returns an error code. If we know what the minimum version of uv we support is, we could add that here.

Patch:
diff --git a/nox/_option_set.py b/nox/_option_set.py
index 23a9331..873a701 100644
--- a/nox/_option_set.py
+++ b/nox/_option_set.py
@@ -30,7 +30,7 @@ import attrs
 import attrs.validators as av
 
 if TYPE_CHECKING:
-    from collections.abc import Callable, Iterable
+    from collections.abc import Callable, Iterable, Sequence
 
 __all__ = [
     "ArgumentError",
@@ -50,7 +50,7 @@ av_opt_str = av.optional(av.instance_of(str))
 av_opt_list_str = av.optional(
     av.deep_iterable(
         member_validator=av.instance_of(str),
-        iterable_validator=attrs.validators.instance_of(list),
+        iterable_validator=av.not_(av.instance_of(str)),
     )
 )
 av_bool = av.instance_of(bool)
@@ -63,16 +63,16 @@ class NoxOptions:
     error_on_external_run: bool = attrs.field(validator=av_bool)
     error_on_missing_interpreters: bool = attrs.field(validator=av_bool)
     force_venv_backend: None | str = attrs.field(validator=av_opt_str)
-    keywords: None | list[str] = attrs.field(validator=av_opt_list_str)
-    pythons: None | list[str] = attrs.field(validator=av_opt_list_str)
+    keywords: None | Sequence[str] = attrs.field(validator=av_opt_list_str)
+    pythons: None | Sequence[str] = attrs.field(validator=av_opt_list_str)
     report: None | str = attrs.field(validator=av_opt_str)
     reuse_existing_virtualenvs: bool = attrs.field(validator=av_bool)
     reuse_venv: None | Literal["no", "yes", "never", "always"] = attrs.field(
         validator=av.optional(av.in_(["no", "yes", "never", "always"]))
     )
-    sessions: None | list[str] = attrs.field(validator=av_opt_list_str)
+    sessions: None | Sequence[str] = attrs.field(validator=av_opt_list_str)
     stop_on_first_error: bool = attrs.field(validator=av_bool)
-    tags: None | list[str] = attrs.field(validator=av_opt_list_str)
+    tags: None | Sequence[str] = attrs.field(validator=av_opt_list_str)
     verbose: bool = attrs.field(validator=av_bool)
 
 
diff --git a/nox/virtualenv.py b/nox/virtualenv.py
index bb29e60..3b97ab1 100644
--- a/nox/virtualenv.py
+++ b/nox/virtualenv.py
@@ -44,7 +44,7 @@ __all__ = [
     "HAS_UV",
     "OPTIONAL_VENVS",
     "UV",
-    "UV_PYTHON_SUPPORT",
+    "UV_VERSION",
     "CondaEnv",
     "InterpreterNotFound",
     "PassthroughEnv",
@@ -81,7 +81,7 @@ _BLACKLISTED_ENV_VARS = frozenset(
 )
 
 
-def find_uv() -> tuple[bool, str]:
+def find_uv() -> tuple[bool, str, version.Version]:
     uv_on_path = shutil.which("uv")
 
     # Look for uv in Nox's environment, to handle `pipx install nox[uv]`.
@@ -90,22 +90,25 @@ def find_uv() -> tuple[bool, str]:
 
         uv_bin = find_uv_bin()
 
-        # 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_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
+            return True, uv_bin, uv_vers
 
     # Fall back to PATH.
-    return uv_on_path is not None, "uv"
+    uv_vers = uv_version("uv")
+    return uv_on_path is not None and uv_vers > version.Version("0"), "uv", uv_vers
 
 
-def uv_version() -> version.Version:
+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, "version", "--output-format", "json"],
+            [uv_bin, "version", "--output-format", "json"],
             check=False,
             text=True,
             capture_output=True,
@@ -130,10 +133,7 @@ def uv_install_python(python_version: str) -> bool:
     return ret.returncode == 0
 
 
-HAS_UV, UV = find_uv()
-# supported since uv 0.3 but 0.4.16 is the first version that doesn't cause
-# issues for nox with pypy/cpython confusion
-UV_PYTHON_SUPPORT = uv_version() >= version.Version("0.4.16")
+HAS_UV, UV, UV_VERSION = find_uv()
 
 
 class InterpreterNotFound(OSError):
@@ -612,8 +612,12 @@ class VirtualEnv(ProcessEnv):
             self._resolved = cleaned_interpreter
             return self._resolved
 
+        # Supported since uv 0.3 but 0.4.16 is the first version that doesn't cause
+        # issues for nox with pypy/cpython confusion
         if (
-            self.venv_backend == "uv" and HAS_UV and UV_PYTHON_SUPPORT
+            self.venv_backend == "uv"
+            and HAS_UV
+            and version.Version("0.4.16") <= UV_VERSION
         ):  # pragma: nocover
             uv_python_success = uv_install_python(cleaned_interpreter)
             if uv_python_success:


Test patch:
diff --git a/tests/test__option_set.py b/tests/test__option_set.py
index 3eb5b15..3035d84 100644
--- a/tests/test__option_set.py
+++ b/tests/test__option_set.py
@@ -132,3 +132,25 @@ class TestOptionSet:
 
         expected_tags = {f"tag{n}" for n in range(1, 8)}
         assert expected_tags == set(actual_tags_from_file)
+
+    def test_validation_options(self) -> None:
+        options = _option_set.NoxOptions(
+            default_venv_backend=None,
+            envdir=None,
+            error_on_external_run=False,
+            error_on_missing_interpreters=False,
+            force_venv_backend=None,
+            keywords=None,
+            pythons=None,
+            report=None,
+            reuse_existing_virtualenvs=False,
+            reuse_venv=None,
+            sessions=None,
+            stop_on_first_error=False,
+            tags=None,
+            verbose=False,
+        )
+        options.sessions = ["testytest"]
+        options.sessions = ("testytest",)
+        with pytest.raises(ValueError):  # noqa: PT011
+            options.sessions = "testytest"
diff --git a/tests/test_virtualenv.py b/tests/test_virtualenv.py
index dcc620a..d8223fb 100644
--- a/tests/test_virtualenv.py
+++ b/tests/test_virtualenv.py
@@ -722,12 +722,17 @@ UV_IN_PIPX_VENV = "/home/user/.local/pipx/venvs/nox/bin/uv"
 
 
 @pytest.mark.parametrize(
-    ("which_result", "find_uv_bin_result", "found", "path"),
+    ("which_result", "find_uv_bin_result", "found", "path", "vers", "vers_rc"),
     [
-        ("/usr/bin/uv", UV_IN_PIPX_VENV, True, UV_IN_PIPX_VENV),
-        ("/usr/bin/uv", None, True, "uv"),
-        (None, UV_IN_PIPX_VENV, True, UV_IN_PIPX_VENV),
-        (None, None, False, "uv"),
+        ("/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),
     ],
 )
 def test_find_uv(
@@ -736,19 +741,34 @@ def test_find_uv(
     find_uv_bin_result: str | None,
     found: bool,
     path: str,
+    vers: str,
+    vers_rc: int,
 ) -> None:
     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]:
+        return subprocess.CompletedProcess(
+            args=["uv", "version", "--output-format", "json"],
+            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(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() == (found, path)
+    assert nox.virtualenv.find_uv() == (
+        found,
+        path,
+        version.Version(vers if vers_rc == 0 else "0"),
+    )
 
 
 @pytest.mark.parametrize(
@@ -773,7 +793,9 @@ def test_uv_version(
         )
 
     monkeypatch.setattr(subprocess, "run", mock_run)
-    assert nox.virtualenv.uv_version() == version.Version(expected_result)
+    assert nox.virtualenv.uv_version(nox.virtualenv.UV) == version.Version(
+        expected_result
+    )
 
 
 def test_uv_version_no_uv(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -781,7 +803,7 @@ def test_uv_version_no_uv(monkeypatch: pytest.MonkeyPatch) -> None:
         raise FileNotFoundError
 
     monkeypatch.setattr(subprocess, "run", mock_exception)
-    assert nox.virtualenv.uv_version() == version.Version("0.0")
+    assert nox.virtualenv.uv_version(nox.virtualenv.UV) == version.Version("0.0")
 
 
 @pytest.mark.parametrize(
@@ -1064,7 +1086,7 @@ def test__resolved_interpreter_windows_pyexe_fails(
 
 
 @mock.patch("nox.virtualenv._PLATFORM", new="win32")
-@mock.patch("nox.virtualenv.UV_PYTHON_SUPPORT", new=False)
+@mock.patch("nox.virtualenv.UV_VERSION", new=version.Version("0.3"))
 def test__resolved_interpreter_windows_path_and_version(
     make_one: Callable[..., tuple[VirtualEnv, Path]],
     patch_sysfind: Callable[..., None],
@@ -1093,7 +1115,7 @@ def test__resolved_interpreter_windows_path_and_version(
 @pytest.mark.parametrize("sysfind_result", [r"c:\python37-x64\python.exe", None])
 @pytest.mark.parametrize("sysexec_result", ["3.7.3\\n", RAISE_ERROR])
 @mock.patch("nox.virtualenv._PLATFORM", new="win32")
-@mock.patch("nox.virtualenv.UV_PYTHON_SUPPORT", new=False)
+@mock.patch("nox.virtualenv.UV_VERSION", new=version.Version("0.3"))
 def test__resolved_interpreter_windows_path_and_version_fails(
     input_: str,
     sysfind_result: None | str,