Skip to content

Commit 928b76d

Browse files
committed
feat: support fallback for nox/mamba/conda
Signed-off-by: Henry Schreiner <henryschreineriii@gmail.com>
1 parent 86d7895 commit 928b76d

File tree

5 files changed

+87
-15
lines changed

5 files changed

+87
-15
lines changed

docs/usage.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ Note that using this option does not change the backend for sessions where ``ven
163163
name from the install process like pip does if the name is omitted. Editable
164164
installs do not require a name.
165165

166+
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.
166167

167168
.. _opt-force-venv-backend:
168169

nox/sessions.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -760,15 +760,30 @@ def envdir(self) -> str:
760760
def _create_venv(self) -> None:
761761
reuse_existing = self.reuse_existing_venv()
762762

763-
backend = (
763+
backends = (
764764
self.global_config.force_venv_backend
765765
or self.func.venv_backend
766766
or self.global_config.default_venv_backend
767767
or "virtualenv"
768-
)
769-
770-
if backend not in nox.virtualenv.ALL_VENVS:
771-
msg = f"Expected venv_backend one of {list(nox.virtualenv.ALL_VENVS)!r}, but got {backend!r}."
768+
).split("|")
769+
770+
# Support fallback backends
771+
for bk in backends:
772+
if bk not in nox.virtualenv.ALL_VENVS:
773+
msg = f"Expected venv_backend one of {list(nox.virtualenv.ALL_VENVS)!r}, but got {bk!r}."
774+
raise ValueError(msg)
775+
776+
for bk in backends[:-1]:
777+
if bk not in nox.virtualenv.OPTIONAL_VENVS:
778+
msg = f"Only optional backends ({list(nox.virtualenv.OPTIONAL_VENVS)!r}) may have a fallback, {bk!r} is not optional."
779+
raise ValueError(msg)
780+
781+
for bk in backends:
782+
if nox.virtualenv.OPTIONAL_VENVS.get(bk, True):
783+
backend = bk
784+
break
785+
else:
786+
msg = f"No backends present, looked for {backends!r}."
772787
raise ValueError(msg)
773788

774789
if backend == "none" or self.func.python is False:

nox/virtualenv.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from __future__ import annotations
1616

17+
import abc
1718
import contextlib
1819
import functools
1920
import os
@@ -44,7 +45,7 @@ def __init__(self, interpreter: str) -> None:
4445
self.interpreter = interpreter
4546

4647

47-
class ProcessEnv:
48+
class ProcessEnv(abc.ABC):
4849
"""An environment with a 'bin' directory and a set of 'env' vars."""
4950

5051
location: str
@@ -85,8 +86,12 @@ def bin(self) -> str:
8586
raise ValueError("The environment does not have a bin directory.")
8687
return paths[0]
8788

89+
@abc.abstractmethod
8890
def create(self) -> bool:
89-
raise NotImplementedError("ProcessEnv.create should be overwritten in subclass")
91+
"""Create a new environment.
92+
93+
Returns True if the environment is new, and False if it was reused.
94+
"""
9095

9196

9297
def locate_via_py(version: str) -> str | None:
@@ -170,6 +175,11 @@ def is_offline() -> bool:
170175
"""As of now this is only used in conda_install"""
171176
return CondaEnv.is_offline() # pragma: no cover
172177

178+
def create(self) -> bool:
179+
"""Does nothing, since this is an existing environment. Always returns
180+
False since it's always reused."""
181+
return False
182+
173183

174184
class CondaEnv(ProcessEnv):
175185
"""Conda environment management class.
@@ -543,3 +553,12 @@ def create(self) -> bool:
543553
"uv": functools.partial(VirtualEnv, venv_backend="uv"),
544554
"none": PassthroughEnv,
545555
}
556+
557+
# Any environment in this dict could be missing, and is only available if the
558+
# value is True. If an environment is always available, it should not be in this
559+
# dict. "virtualenv" is not considered optional since it's a dependency of nox.
560+
OPTIONAL_VENVS = {
561+
"conda": shutil.which("conda") is not None,
562+
"mamba": shutil.which("mamba") is not None,
563+
"uv": shutil.which("uv") is not None,
564+
}

tests/test_sessions.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ def test_run_external_not_a_virtualenv(self):
309309
# Non-virtualenv sessions should always allow external programs.
310310
session, runner = self.make_session_and_runner()
311311

312-
runner.venv = nox.virtualenv.ProcessEnv()
312+
runner.venv = nox.virtualenv.PassthroughEnv()
313313

314314
with mock.patch("nox.command.run", autospec=True) as run:
315315
session.run(sys.executable, "--version")
@@ -402,7 +402,7 @@ def test_run_shutdown_process_timeouts(
402402
):
403403
session, runner = self.make_session_and_runner()
404404

405-
runner.venv = nox.virtualenv.ProcessEnv()
405+
runner.venv = nox.virtualenv.PassthroughEnv()
406406

407407
subp_popen_instance = mock.Mock()
408408
subp_popen_instance.communicate.side_effect = KeyboardInterrupt()
@@ -963,6 +963,44 @@ def test__create_venv_unexpected_venv_backend(self):
963963
with pytest.raises(ValueError, match="venv_backend"):
964964
runner._create_venv()
965965

966+
@pytest.mark.parametrize(
967+
"venv_backend",
968+
["uv|virtualenv", "conda|virtualenv", "mamba|conda|venv"],
969+
)
970+
def test_fallback_venv(self, venv_backend, monkeypatch):
971+
runner = self.make_runner()
972+
runner.func.venv_backend = venv_backend
973+
monkeypatch.setattr(
974+
nox.virtualenv,
975+
"OPTIONAL_VENVS",
976+
{"uv": False, "conda": False, "mamba": False},
977+
)
978+
with mock.patch("nox.virtualenv.VirtualEnv.create", autospec=True):
979+
runner._create_venv()
980+
assert runner.venv.venv_backend == venv_backend.split("|")[-1]
981+
982+
@pytest.mark.parametrize(
983+
"venv_backend",
984+
[
985+
"uv|virtualenv|unknown",
986+
"conda|unknown|virtualenv",
987+
"virtualenv|venv",
988+
"conda|mamba",
989+
],
990+
)
991+
def test_invalid_fallback_venv(self, venv_backend, monkeypatch):
992+
runner = self.make_runner()
993+
runner.func.venv_backend = venv_backend
994+
monkeypatch.setattr(
995+
nox.virtualenv,
996+
"OPTIONAL_VENVS",
997+
{"uv": False, "conda": False, "mamba": False},
998+
)
999+
with mock.patch(
1000+
"nox.virtualenv.VirtualEnv.create", autospec=True
1001+
), pytest.raises(ValueError):
1002+
runner._create_venv()
1003+
9661004
@pytest.mark.parametrize(
9671005
("reuse_venv", "reuse_venv_func", "should_reuse"),
9681006
[

tests/test_virtualenv.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -113,24 +113,23 @@ def special_run(cmd, *args, **kwargs):
113113

114114

115115
def test_process_env_constructor():
116-
penv = nox.virtualenv.ProcessEnv()
116+
penv = nox.virtualenv.PassthroughEnv()
117117
assert not penv.bin_paths
118118
with pytest.raises(
119119
ValueError, match=r"^The environment does not have a bin directory\.$"
120120
):
121121
print(penv.bin)
122122

123-
penv = nox.virtualenv.ProcessEnv(env={"SIGIL": "123"})
123+
penv = nox.virtualenv.PassthroughEnv(env={"SIGIL": "123"})
124124
assert penv.env["SIGIL"] == "123"
125125

126-
penv = nox.virtualenv.ProcessEnv(bin_paths=["/bin"])
126+
penv = nox.virtualenv.PassthroughEnv(bin_paths=["/bin"])
127127
assert penv.bin == "/bin"
128128

129129

130130
def test_process_env_create():
131-
penv = nox.virtualenv.ProcessEnv()
132-
with pytest.raises(NotImplementedError):
133-
penv.create()
131+
with pytest.raises(TypeError):
132+
nox.virtualenv.ProcessEnv()
134133

135134

136135
def test_invalid_venv_create(make_one):

0 commit comments

Comments
 (0)