Skip to content

Commit

Permalink
feat: venv backend fallback (#787)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
henryiii committed Feb 29, 2024
1 parent 1c6af24 commit d862350
Show file tree
Hide file tree
Showing 6 changed files with 113 additions and 41 deletions.
1 change: 1 addition & 0 deletions docs/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
11 changes: 5 additions & 6 deletions nox/_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
53 changes: 29 additions & 24 deletions nox/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
36 changes: 33 additions & 3 deletions nox/virtualenv.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
}
42 changes: 40 additions & 2 deletions tests/test_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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"),
[
Expand Down
11 changes: 5 additions & 6 deletions tests/test_virtualenv.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down

0 comments on commit d862350

Please sign in to comment.