Skip to content

Commit d862350

Browse files
authored
feat: venv backend fallback (#787)
* refactor: pull out env selection to dict Signed-off-by: Henry Schreiner <henryschreineriii@gmail.com> * feat: support fallback for nox/mamba/conda Signed-off-by: Henry Schreiner <henryschreineriii@gmail.com> --------- Signed-off-by: Henry Schreiner <henryschreineriii@gmail.com>
1 parent 1c6af24 commit d862350

6 files changed

Lines changed: 113 additions & 41 deletions

File tree

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/_options.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626

2727
from nox import _option_set
2828
from nox.tasks import discover_manifest, filter_manifest, load_nox_module
29+
from nox.virtualenv import ALL_VENVS
2930

3031
if sys.version_info < (3, 8):
3132
from typing_extensions import Literal
@@ -423,10 +424,9 @@ def _tag_completer(
423424
merge_func=_default_venv_backend_merge_func,
424425
help=(
425426
"Virtual environment backend to use by default for Nox sessions, this is"
426-
" ``'virtualenv'`` by default but any of ``('uv, 'virtualenv',"
427-
" 'conda', 'mamba', 'venv')`` are accepted."
427+
" ``'virtualenv'`` by default but any of ``{list(ALL_VENVS)!r}`` are accepted."
428428
),
429-
choices=["none", "virtualenv", "conda", "mamba", "venv", "uv"],
429+
choices=list(ALL_VENVS),
430430
),
431431
_option_set.Option(
432432
"force_venv_backend",
@@ -438,10 +438,9 @@ def _tag_completer(
438438
help=(
439439
"Virtual environment backend to force-use for all Nox sessions in this run,"
440440
" overriding any other venv backend declared in the Noxfile and ignoring"
441-
" the default backend. Any of ``('uv', 'virtualenv', 'conda', 'mamba',"
442-
" 'venv')`` are accepted."
441+
" the default backend. Any of ``{list(ALL_VENVS)!r}`` are accepted."
443442
),
444-
choices=["none", "virtualenv", "conda", "mamba", "venv", "uv"],
443+
choices=list(ALL_VENVS),
445444
),
446445
_option_set.Option(
447446
"no_venv",

nox/sessions.py

Lines changed: 29 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
)
3939

4040
import nox.command
41+
import nox.virtualenv
4142
from nox._decorators import Func
4243
from nox.logger import logger
4344
from nox.virtualenv import CondaEnv, PassthroughEnv, ProcessEnv, VirtualEnv
@@ -761,38 +762,42 @@ def envdir(self) -> str:
761762
return _normalize_path(self.global_config.envdir, self.friendly_name)
762763

763764
def _create_venv(self) -> None:
764-
backend = (
765+
reuse_existing = self.reuse_existing_venv()
766+
767+
backends = (
765768
self.global_config.force_venv_backend
766769
or self.func.venv_backend
767770
or self.global_config.default_venv_backend
768-
)
771+
or "virtualenv"
772+
).split("|")
773+
774+
# Support fallback backends
775+
for bk in backends:
776+
if bk not in nox.virtualenv.ALL_VENVS:
777+
msg = f"Expected venv_backend one of {list(nox.virtualenv.ALL_VENVS)!r}, but got {bk!r}."
778+
raise ValueError(msg)
779+
780+
for bk in backends[:-1]:
781+
if bk not in nox.virtualenv.OPTIONAL_VENVS:
782+
msg = f"Only optional backends ({list(nox.virtualenv.OPTIONAL_VENVS)!r}) may have a fallback, {bk!r} is not optional."
783+
raise ValueError(msg)
784+
785+
for bk in backends:
786+
if nox.virtualenv.OPTIONAL_VENVS.get(bk, True):
787+
backend = bk
788+
break
789+
else:
790+
msg = f"No backends present, looked for {backends!r}."
791+
raise ValueError(msg)
769792

770793
if backend == "none" or self.func.python is False:
771-
self.venv = PassthroughEnv()
772-
return
773-
774-
reuse_existing = self.reuse_existing_venv()
775-
776-
if backend is None or backend in {"virtualenv", "venv", "uv"}:
777-
self.venv = VirtualEnv(
778-
self.envdir,
779-
interpreter=self.func.python, # type: ignore[arg-type]
780-
reuse_existing=reuse_existing,
781-
venv_backend=backend or "virtualenv",
782-
venv_params=self.func.venv_params,
783-
)
784-
elif backend in {"conda", "mamba"}:
785-
self.venv = CondaEnv(
794+
self.venv = nox.virtualenv.ALL_VENVS["none"]()
795+
else:
796+
self.venv = nox.virtualenv.ALL_VENVS[backend](
786797
self.envdir,
787-
interpreter=self.func.python, # type: ignore[arg-type]
798+
interpreter=self.func.python,
788799
reuse_existing=reuse_existing,
789800
venv_params=self.func.venv_params,
790-
conda_cmd=backend,
791-
)
792-
else:
793-
raise ValueError(
794-
"Expected venv_backend one of ('virtualenv', 'conda', 'mamba',"
795-
f" 'venv'), but got '{backend}'."
796801
)
797802

798803
self.venv.create()

nox/virtualenv.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,16 @@
1414

1515
from __future__ import annotations
1616

17+
import abc
1718
import contextlib
19+
import functools
1820
import os
1921
import platform
2022
import re
2123
import shutil
2224
import subprocess
2325
import sys
24-
from collections.abc import Mapping
26+
from collections.abc import Callable, Mapping
2527
from socket import gethostbyname
2628
from typing import Any, ClassVar
2729

@@ -43,7 +45,7 @@ def __init__(self, interpreter: str) -> None:
4345
self.interpreter = interpreter
4446

4547

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

4951
location: str
@@ -84,8 +86,12 @@ def bin(self) -> str:
8486
raise ValueError("The environment does not have a bin directory.")
8587
return paths[0]
8688

89+
@abc.abstractmethod
8790
def create(self) -> bool:
88-
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+
"""
8995

9096

9197
def locate_via_py(version: str) -> str | None:
@@ -169,6 +175,11 @@ def is_offline() -> bool:
169175
"""As of now this is only used in conda_install"""
170176
return CondaEnv.is_offline() # pragma: no cover
171177

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+
172183

173184
class CondaEnv(ProcessEnv):
174185
"""Conda environment management class.
@@ -532,3 +543,22 @@ def create(self) -> bool:
532543
nox.command.run(cmd, silent=True, log=nox.options.verbose or False)
533544

534545
return True
546+
547+
548+
ALL_VENVS: dict[str, Callable[..., ProcessEnv]] = {
549+
"conda": functools.partial(CondaEnv, conda_cmd="conda"),
550+
"mamba": functools.partial(CondaEnv, conda_cmd="mamba"),
551+
"virtualenv": functools.partial(VirtualEnv, venv_backend="virtualenv"),
552+
"venv": functools.partial(VirtualEnv, venv_backend="venv"),
553+
"uv": functools.partial(VirtualEnv, venv_backend="uv"),
554+
"none": PassthroughEnv,
555+
}
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()
@@ -969,6 +969,44 @@ def test__create_venv_unexpected_venv_backend(self):
969969
with pytest.raises(ValueError, match="venv_backend"):
970970
runner._create_venv()
971971

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

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)