diff --git a/docs/usage.rst b/docs/usage.rst index 81431dfb..e32808da 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -163,6 +163,7 @@ Note that using this option does not change the backend for sessions where ``ven name from the install process like pip does if the name is omitted. Editable installs do not require a name. +Backends that could be missing (``uv``, ``conda``, and ``mamba``) can have a fallback using ``|``, such as ``uv|virtualenv`` or ``mamba|conda``. This will use the first item that is available on the users system. .. _opt-force-venv-backend: diff --git a/nox/sessions.py b/nox/sessions.py index 44c7b446..3b0f72c0 100644 --- a/nox/sessions.py +++ b/nox/sessions.py @@ -760,15 +760,30 @@ def envdir(self) -> str: def _create_venv(self) -> None: reuse_existing = self.reuse_existing_venv() - backend = ( + backends = ( self.global_config.force_venv_backend or self.func.venv_backend or self.global_config.default_venv_backend or "virtualenv" - ) - - if backend not in nox.virtualenv.ALL_VENVS: - msg = f"Expected venv_backend one of {list(nox.virtualenv.ALL_VENVS)!r}, but got {backend!r}." + ).split("|") + + # Support fallback backends + for bk in backends: + if bk not in nox.virtualenv.ALL_VENVS: + msg = f"Expected venv_backend one of {list(nox.virtualenv.ALL_VENVS)!r}, but got {bk!r}." + raise ValueError(msg) + + for bk in backends[:-1]: + if bk not in nox.virtualenv.OPTIONAL_VENVS: + msg = f"Only optional backends ({list(nox.virtualenv.OPTIONAL_VENVS)!r}) may have a fallback, {bk!r} is not optional." + raise ValueError(msg) + + for bk in backends: + if nox.virtualenv.OPTIONAL_VENVS.get(bk, True): + backend = bk + break + else: + msg = f"No backends present, looked for {backends!r}." raise ValueError(msg) if backend == "none" or self.func.python is False: diff --git a/nox/virtualenv.py b/nox/virtualenv.py index 84844e59..6f4b6099 100644 --- a/nox/virtualenv.py +++ b/nox/virtualenv.py @@ -14,6 +14,7 @@ from __future__ import annotations +import abc import contextlib import functools import os @@ -44,7 +45,7 @@ def __init__(self, interpreter: str) -> None: self.interpreter = interpreter -class ProcessEnv: +class ProcessEnv(abc.ABC): """An environment with a 'bin' directory and a set of 'env' vars.""" location: str @@ -85,8 +86,12 @@ def bin(self) -> str: raise ValueError("The environment does not have a bin directory.") return paths[0] + @abc.abstractmethod def create(self) -> bool: - raise NotImplementedError("ProcessEnv.create should be overwritten in subclass") + """Create a new environment. + + Returns True if the environment is new, and False if it was reused. + """ def locate_via_py(version: str) -> str | None: @@ -170,6 +175,11 @@ def is_offline() -> bool: """As of now this is only used in conda_install""" return CondaEnv.is_offline() # pragma: no cover + def create(self) -> bool: + """Does nothing, since this is an existing environment. Always returns + False since it's always reused.""" + return False + class CondaEnv(ProcessEnv): """Conda environment management class. @@ -543,3 +553,12 @@ def create(self) -> bool: "uv": functools.partial(VirtualEnv, venv_backend="uv"), "none": PassthroughEnv, } + +# Any environment in this dict could be missing, and is only available if the +# value is True. If an environment is always available, it should not be in this +# dict. "virtualenv" is not considered optional since it's a dependency of nox. +OPTIONAL_VENVS = { + "conda": shutil.which("conda") is not None, + "mamba": shutil.which("mamba") is not None, + "uv": shutil.which("uv") is not None, +} diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 24ebd635..4e7a6b3a 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -309,7 +309,7 @@ def test_run_external_not_a_virtualenv(self): # Non-virtualenv sessions should always allow external programs. session, runner = self.make_session_and_runner() - runner.venv = nox.virtualenv.ProcessEnv() + runner.venv = nox.virtualenv.PassthroughEnv() with mock.patch("nox.command.run", autospec=True) as run: session.run(sys.executable, "--version") @@ -402,7 +402,7 @@ def test_run_shutdown_process_timeouts( ): session, runner = self.make_session_and_runner() - runner.venv = nox.virtualenv.ProcessEnv() + runner.venv = nox.virtualenv.PassthroughEnv() subp_popen_instance = mock.Mock() subp_popen_instance.communicate.side_effect = KeyboardInterrupt() @@ -963,6 +963,44 @@ def test__create_venv_unexpected_venv_backend(self): with pytest.raises(ValueError, match="venv_backend"): runner._create_venv() + @pytest.mark.parametrize( + "venv_backend", + ["uv|virtualenv", "conda|virtualenv", "mamba|conda|venv"], + ) + def test_fallback_venv(self, venv_backend, monkeypatch): + runner = self.make_runner() + runner.func.venv_backend = venv_backend + monkeypatch.setattr( + nox.virtualenv, + "OPTIONAL_VENVS", + {"uv": False, "conda": False, "mamba": False}, + ) + with mock.patch("nox.virtualenv.VirtualEnv.create", autospec=True): + runner._create_venv() + assert runner.venv.venv_backend == venv_backend.split("|")[-1] + + @pytest.mark.parametrize( + "venv_backend", + [ + "uv|virtualenv|unknown", + "conda|unknown|virtualenv", + "virtualenv|venv", + "conda|mamba", + ], + ) + def test_invalid_fallback_venv(self, venv_backend, monkeypatch): + runner = self.make_runner() + runner.func.venv_backend = venv_backend + monkeypatch.setattr( + nox.virtualenv, + "OPTIONAL_VENVS", + {"uv": False, "conda": False, "mamba": False}, + ) + with mock.patch( + "nox.virtualenv.VirtualEnv.create", autospec=True + ), pytest.raises(ValueError): + runner._create_venv() + @pytest.mark.parametrize( ("reuse_venv", "reuse_venv_func", "should_reuse"), [ diff --git a/tests/test_virtualenv.py b/tests/test_virtualenv.py index 708de0d0..5dbe0469 100644 --- a/tests/test_virtualenv.py +++ b/tests/test_virtualenv.py @@ -113,22 +113,22 @@ def special_run(cmd, *args, **kwargs): def test_process_env_constructor(): - penv = nox.virtualenv.ProcessEnv() + penv = nox.virtualenv.PassthroughEnv() assert not penv.bin_paths with pytest.raises( ValueError, match=r"^The environment does not have a bin directory\.$" ): print(penv.bin) - penv = nox.virtualenv.ProcessEnv(env={"SIGIL": "123"}) + penv = nox.virtualenv.PassthroughEnv(env={"SIGIL": "123"}) assert penv.env["SIGIL"] == "123" - penv = nox.virtualenv.ProcessEnv(bin_paths=["/bin"]) + penv = nox.virtualenv.PassthroughEnv(bin_paths=["/bin"]) assert penv.bin == "/bin" def test_process_env_create(): - penv = nox.virtualenv.ProcessEnv() + penv = nox.virtualenv.PassthroughEnv() with pytest.raises(NotImplementedError): penv.create()