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/_options.py b/nox/_options.py index 1096c4ef..a198b9ac 100644 --- a/nox/_options.py +++ b/nox/_options.py @@ -26,6 +26,7 @@ from nox import _option_set from nox.tasks import discover_manifest, filter_manifest, load_nox_module +from nox.virtualenv import ALL_VENVS if sys.version_info < (3, 8): from typing_extensions import Literal @@ -423,10 +424,9 @@ def _tag_completer( merge_func=_default_venv_backend_merge_func, help=( "Virtual environment backend to use by default for Nox sessions, this is" - " ``'virtualenv'`` by default but any of ``('uv, 'virtualenv'," - " 'conda', 'mamba', 'venv')`` are accepted." + " ``'virtualenv'`` by default but any of ``{list(ALL_VENVS)!r}`` are accepted." ), - choices=["none", "virtualenv", "conda", "mamba", "venv", "uv"], + choices=list(ALL_VENVS), ), _option_set.Option( "force_venv_backend", @@ -438,10 +438,9 @@ def _tag_completer( help=( "Virtual environment backend to force-use for all Nox sessions in this run," " overriding any other venv backend declared in the Noxfile and ignoring" - " the default backend. Any of ``('uv', 'virtualenv', 'conda', 'mamba'," - " 'venv')`` are accepted." + " the default backend. Any of ``{list(ALL_VENVS)!r}`` are accepted." ), - choices=["none", "virtualenv", "conda", "mamba", "venv", "uv"], + choices=list(ALL_VENVS), ), _option_set.Option( "no_venv", diff --git a/nox/sessions.py b/nox/sessions.py index 1ecacf04..2728b1fa 100644 --- a/nox/sessions.py +++ b/nox/sessions.py @@ -38,6 +38,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 @@ -761,38 +762,42 @@ def envdir(self) -> str: return _normalize_path(self.global_config.envdir, self.friendly_name) def _create_venv(self) -> None: - backend = ( + reuse_existing = self.reuse_existing_venv() + + backends = ( self.global_config.force_venv_backend or self.func.venv_backend or self.global_config.default_venv_backend - ) + or "virtualenv" + ).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: - self.venv = PassthroughEnv() - return - - reuse_existing = self.reuse_existing_venv() - - if backend is None or backend in {"virtualenv", "venv", "uv"}: - self.venv = VirtualEnv( - self.envdir, - interpreter=self.func.python, # type: ignore[arg-type] - reuse_existing=reuse_existing, - venv_backend=backend or "virtualenv", - venv_params=self.func.venv_params, - ) - elif backend in {"conda", "mamba"}: - self.venv = CondaEnv( + self.venv = nox.virtualenv.ALL_VENVS["none"]() + else: + self.venv = nox.virtualenv.ALL_VENVS[backend]( self.envdir, - interpreter=self.func.python, # type: ignore[arg-type] + interpreter=self.func.python, reuse_existing=reuse_existing, venv_params=self.func.venv_params, - conda_cmd=backend, - ) - else: - raise ValueError( - "Expected venv_backend one of ('virtualenv', 'conda', 'mamba'," - f" 'venv'), but got '{backend}'." ) self.venv.create() diff --git a/nox/virtualenv.py b/nox/virtualenv.py index 1895f841..6f4b6099 100644 --- a/nox/virtualenv.py +++ b/nox/virtualenv.py @@ -14,14 +14,16 @@ from __future__ import annotations +import abc import contextlib +import functools import os import platform import re import shutil import subprocess import sys -from collections.abc import Mapping +from collections.abc import Callable, Mapping from socket import gethostbyname from typing import Any, ClassVar @@ -43,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 @@ -84,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: @@ -169,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. @@ -532,3 +543,22 @@ def create(self) -> bool: nox.command.run(cmd, silent=True, log=nox.options.verbose or False) return True + + +ALL_VENVS: dict[str, Callable[..., ProcessEnv]] = { + "conda": functools.partial(CondaEnv, conda_cmd="conda"), + "mamba": functools.partial(CondaEnv, conda_cmd="mamba"), + "virtualenv": functools.partial(VirtualEnv, venv_backend="virtualenv"), + "venv": functools.partial(VirtualEnv, venv_backend="venv"), + "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 3b8c53d6..98478a60 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() @@ -969,6 +969,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..6af872bc 100644 --- a/tests/test_virtualenv.py +++ b/tests/test_virtualenv.py @@ -113,24 +113,23 @@ 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() - with pytest.raises(NotImplementedError): - penv.create() + with pytest.raises(TypeError): + nox.virtualenv.ProcessEnv() def test_invalid_venv_create(make_one):