Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add a uv backend #762

Merged
merged 1 commit into from
Feb 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
henryiii marked this conversation as resolved.
Show resolved Hide resolved
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
Loading