Skip to content

Commit

Permalink
feat: add a uv backend (#762)
Browse files Browse the repository at this point in the history
Signed-off-by: Henry Schreiner <[email protected]>
  • Loading branch information
henryiii authored Feb 23, 2024
1 parent 78a2612 commit 1fe4437
Show file tree
Hide file tree
Showing 8 changed files with 113 additions and 35 deletions.
22 changes: 20 additions & 2 deletions docs/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -126,25 +126,43 @@ Then running ``nox --session tests`` will actually run all parametrized versions
Changing the sessions default backend
-------------------------------------

By default Nox uses ``virtualenv`` as the virtual environment backend for the sessions, but it also supports ``conda``, ``mamba``, and ``venv`` as well as no backend (passthrough to whatever python environment Nox is running on). You can change the default behaviour by using ``-db <backend>`` or ``--default-venv-backend <backend>``. Supported names are ``('none', 'virtualenv', 'conda', 'mamba', 'venv')``.
By default Nox uses ``virtualenv`` as the virtual environment backend for the sessions, but it also supports ``uv``, ``conda``, ``mamba``, and ``venv`` as well as no backend (passthrough to whatever python environment Nox is running on). You can change the default behaviour by using ``-db <backend>`` or ``--default-venv-backend <backend>``. Supported names are ``('none', 'uv', 'virtualenv', 'conda', 'mamba', 'venv')``.

.. code-block:: console
nox -db conda
nox --default-venv-backend conda
.. note::

The ``uv``, ``conda``, and ``mamba`` backends require their respective
programs be pre-installed. ``uv`` is distributed as a Python package
and can be installed with the ``nox[uv]`` extra.

You can also set this option in the Noxfile with ``nox.options.default_venv_backend``. In case both are provided, the commandline argument takes precedence.

Note that using this option does not change the backend for sessions where ``venv_backend`` is explicitly set.

.. warning::

The ``uv`` backend does not install anything by default, including ``pip``,
as ``uv pip`` is used to install programs instead. If you need to manually
interact with pip, you should install it with ``session.install("pip")``.

.. warning::

Currently the ``uv`` backend requires the ``<program name> @ .`` syntax to
install a local folder in non-editable mode; it does not (yet) compute the
name from the install process like pip does if the name is omitted. Editable
installs do not require a name.


.. _opt-force-venv-backend:

Forcing the sessions backend
----------------------------

You might work in a different environment than a project's default continuous integration settings, and might wish to get a quick way to execute the same tasks but on a different venv backend. For this purpose, you can temporarily force the backend used by **all** sessions in the current Nox execution by using ``-fb <backend>`` or ``--force-venv-backend <backend>``. No exceptions are made, the backend will be forced for all sessions run whatever the other options values and Noxfile configuration. Supported names are ``('none', 'virtualenv', 'conda', 'venv')``.
You might work in a different environment than a project's default continuous integration settings, and might wish to get a quick way to execute the same tasks but on a different venv backend. For this purpose, you can temporarily force the backend used by **all** sessions in the current Nox execution by using ``-fb <backend>`` or ``--force-venv-backend <backend>``. No exceptions are made, the backend will be forced for all sessions run whatever the other options values and Noxfile configuration. Supported names are ``('none', 'uv', 'virtualenv', 'conda', 'mamba', 'venv')``.

.. code-block:: console
Expand Down
12 changes: 6 additions & 6 deletions nox/_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,10 +383,10 @@ 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 ``('virtualenv', 'conda', 'mamba',"
" 'venv')`` are accepted."
" ``'virtualenv'`` by default but any of ``('uv, 'virtualenv',"
" 'conda', 'mamba', 'venv')`` are accepted."
),
choices=["none", "virtualenv", "conda", "mamba", "venv"],
choices=["none", "virtualenv", "conda", "mamba", "venv", "uv"],
),
_option_set.Option(
"force_venv_backend",
Expand All @@ -398,10 +398,10 @@ 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 ``('virtualenv', 'conda', 'mamba', 'venv')``"
" are accepted."
" the default backend. Any of ``('uv', 'virtualenv', 'conda', 'mamba',"
" 'venv')`` are accepted."
),
choices=["none", "virtualenv", "conda", "mamba", "venv"],
choices=["none", "virtualenv", "conda", "mamba", "venv", "uv"],
),
_option_set.Option(
"no_venv",
Expand Down
18 changes: 8 additions & 10 deletions nox/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -650,7 +650,12 @@ def install(self, *args: str, **kwargs: Any) -> None:
if "silent" not in kwargs:
kwargs["silent"] = True

self._run("python", "-m", "pip", "install", *args, external="error", **kwargs)
if isinstance(venv, VirtualEnv) and venv.venv_backend == "uv":
self._run("uv", "pip", "install", *args, external="error", **kwargs)
else:
self._run(
"python", "-m", "pip", "install", *args, external="error", **kwargs
)

def notify(
self,
Expand Down Expand Up @@ -766,11 +771,12 @@ def _create_venv(self) -> None:
self.func.reuse_venv or self.global_config.reuse_existing_virtualenvs
)

if backend is None or backend == "virtualenv":
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"}:
Expand All @@ -781,14 +787,6 @@ def _create_venv(self) -> None:
venv_params=self.func.venv_params,
conda_cmd=backend,
)
elif backend == "venv":
self.venv = VirtualEnv(
self.envdir,
interpreter=self.func.python, # type: ignore[arg-type]
reuse_existing=reuse_existing,
venv=True,
venv_params=self.func.venv_params,
)
else:
raise ValueError(
"Expected venv_backend one of ('virtualenv', 'conda', 'mamba',"
Expand Down
39 changes: 26 additions & 13 deletions nox/virtualenv.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,22 +312,23 @@ class VirtualEnv(ProcessEnv):
"""

is_sandboxed = True
allowed_globals = ("uv",)

def __init__(
self,
location: str,
interpreter: str | None = None,
reuse_existing: bool = False,
*,
venv: bool = False,
venv_backend: str = "virtualenv",
venv_params: Any = None,
):
self.location_name = location
self.location = os.path.abspath(location)
self.interpreter = interpreter
self._resolved: None | str | InterpreterNotFound = None
self.reuse_existing = reuse_existing
self.venv_or_virtualenv = "venv" if venv else "virtualenv"
self.venv_backend = venv_backend
self.venv_params = venv_params or []
super().__init__(env={"VIRTUAL_ENV": self.location})

Expand All @@ -349,17 +350,21 @@ def _clean_location(self) -> bool:

def _check_reused_environment_type(self) -> bool:
"""Check if reused environment type is the same."""
path = os.path.join(self.location, "pyvenv.cfg")
if not os.path.isfile(path):
try:
with open(os.path.join(self.location, "pyvenv.cfg")) as fp:
parts = (x.partition("=") for x in fp if "=" in x)
config = {k.strip(): v.strip() for k, _, v in parts}
if "uv" in config or "gourgeist" in config:
old_env = "uv"
elif "virtualenv" in config:
old_env = "virtualenv"
else:
old_env = "venv"
except FileNotFoundError: # pragma: no cover
# virtualenv < 20.0 does not create pyvenv.cfg
old_env = "virtualenv"
else:
pattern = re.compile("virtualenv[ \t]*=")
with open(path) as fp:
old_env = (
"virtualenv" if any(pattern.match(line) for line in fp) else "venv"
)
return old_env == self.venv_or_virtualenv

return old_env == self.venv_backend

def _check_reused_environment_interpreter(self) -> bool:
"""Check if reused environment interpreter is the same."""
Expand Down Expand Up @@ -474,18 +479,26 @@ def create(self) -> bool:

return False

if self.venv_or_virtualenv == "virtualenv":
if self.venv_backend == "virtualenv":
cmd = [sys.executable, "-m", "virtualenv", self.location]
if self.interpreter:
cmd.extend(["-p", self._resolved_interpreter])
elif self.venv_backend == "uv":
cmd = [
"uv",
"venv",
"-p",
self._resolved_interpreter if self.interpreter else sys.executable,
self.location,
]
else:
cmd = [self._resolved_interpreter, "-m", "venv", self.location]
cmd.extend(self.venv_params)

resolved_interpreter_name = os.path.basename(self._resolved_interpreter)

logger.info(
f"Creating virtual environment ({self.venv_or_virtualenv}) using"
f"Creating virtual environment ({self.venv_backend}) using"
f" {resolved_interpreter_name} in {self.location_name}"
)
nox.command.run(cmd, silent=True, log=nox.options.verbose or False)
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ tox_to_nox = [
"jinja2",
"tox",
]
uv = [
"uv",
]
[project.urls]
bug-tracker = "https://github.com/wntrblm/nox/issues"
documentation = "https://nox.thea.codes"
Expand Down
1 change: 1 addition & 0 deletions requirements-test.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ pytest-cov
sphinx>=3.0
sphinx-autobuild
sphinx-tabs
uv; python_version>='3.8'
witchhazel
32 changes: 32 additions & 0 deletions tests/test_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ def make_session_and_runner(self):
runner.venv = mock.create_autospec(nox.virtualenv.VirtualEnv)
runner.venv.env = {}
runner.venv.bin_paths = ["/no/bin/for/you"]
runner.venv.venv_backend = "venv"
return nox.sessions.Session(runner=runner), runner

def test_create_tmp(self):
Expand Down Expand Up @@ -633,6 +634,7 @@ def test_install(self):
)
runner.venv = mock.create_autospec(nox.virtualenv.VirtualEnv)
runner.venv.env = {}
runner.venv.venv_backend = "venv"

class SessionNoSlots(nox.sessions.Session):
pass
Expand Down Expand Up @@ -662,6 +664,7 @@ def test_install_non_default_kwargs(self):
)
runner.venv = mock.create_autospec(nox.virtualenv.VirtualEnv)
runner.venv.env = {}
runner.venv.venv_backend = "venv"

class SessionNoSlots(nox.sessions.Session):
pass
Expand Down Expand Up @@ -798,6 +801,35 @@ def test_session_venv_reused_with_no_install(self, no_install, reused, run_calle

assert run.called is run_called

def test_install_uv(self):
runner = nox.sessions.SessionRunner(
name="test",
signatures=["test"],
func=mock.sentinel.func,
global_config=_options.options.namespace(posargs=[]),
manifest=mock.create_autospec(nox.manifest.Manifest),
)
runner.venv = mock.create_autospec(nox.virtualenv.VirtualEnv)
runner.venv.env = {}
runner.venv.venv_backend = "uv"

class SessionNoSlots(nox.sessions.Session):
pass

session = SessionNoSlots(runner=runner)

with mock.patch.object(session, "_run", autospec=True) as run:
session.install("requests", "urllib3", silent=False)
run.assert_called_once_with(
"uv",
"pip",
"install",
"requests",
"urllib3",
silent=False,
external="error",
)

def test___slots__(self):
session, _ = self.make_session_and_runner()
with pytest.raises(AttributeError):
Expand Down
21 changes: 17 additions & 4 deletions tests/test_virtualenv.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@

IS_WINDOWS = nox.virtualenv._SYSTEM == "Windows"
HAS_CONDA = shutil.which("conda") is not None
HAS_UV = shutil.which("uv") is not None
RAISE_ERROR = "RAISE_ERROR"
VIRTUALENV_VERSION = virtualenv.__version__

Expand Down Expand Up @@ -240,12 +241,24 @@ def test_condaenv_detection(make_conda):
assert path_regex.search(output).group("env_dir") == dir_.strpath


@pytest.mark.skipif(not HAS_UV, reason="Missing uv command.")
def test_uv_creation(make_one):
venv, _ = make_one(venv_backend="uv")
assert venv.location
assert venv.interpreter is None
assert venv.reuse_existing is False
assert venv.venv_backend == "uv"

venv.create()
assert venv._check_reused_environment_type()


def test_constructor_defaults(make_one):
venv, _ = make_one()
assert venv.location
assert venv.interpreter is None
assert venv.reuse_existing is False
assert venv.venv_or_virtualenv == "virtualenv"
assert venv.venv_backend == "virtualenv"


@pytest.mark.skipif(IS_WINDOWS, reason="Not testing multiple interpreters on Windows.")
Expand Down Expand Up @@ -417,7 +430,7 @@ def test_create_reuse_stale_venv_environment(make_one):

@enable_staleness_check
def test_create_reuse_stale_virtualenv_environment(make_one):
venv, location = make_one(reuse_existing=True, venv=True)
venv, location = make_one(reuse_existing=True, venv_backend="venv")
venv.create()

# Drop a virtualenv-style pyvenv.cfg into the environment.
Expand All @@ -442,7 +455,7 @@ def test_create_reuse_stale_virtualenv_environment(make_one):

@enable_staleness_check
def test_create_reuse_venv_environment(make_one):
venv, location = make_one(reuse_existing=True, venv=True)
venv, location = make_one(reuse_existing=True, venv_backend="venv")
venv.create()

# Place a spurious occurrence of "virtualenv" in the pyvenv.cfg.
Expand Down Expand Up @@ -516,7 +529,7 @@ def test_create_reuse_python2_environment(make_one):


def test_create_venv_backend(make_one):
venv, dir_ = make_one(venv=True)
venv, dir_ = make_one(venv_backend="venv")
venv.create()


Expand Down

0 comments on commit 1fe4437

Please sign in to comment.