Skip to content

Commit 1fe4437

Browse files
authored
feat: add a uv backend (#762)
Signed-off-by: Henry Schreiner <henryschreineriii@gmail.com>
1 parent 78a2612 commit 1fe4437

File tree

8 files changed

+113
-35
lines changed

8 files changed

+113
-35
lines changed

docs/usage.rst

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,25 +126,43 @@ Then running ``nox --session tests`` will actually run all parametrized versions
126126
Changing the sessions default backend
127127
-------------------------------------
128128

129-
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')``.
129+
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')``.
130130

131131
.. code-block:: console
132132
133133
nox -db conda
134134
nox --default-venv-backend conda
135135
136+
.. note::
137+
138+
The ``uv``, ``conda``, and ``mamba`` backends require their respective
139+
programs be pre-installed. ``uv`` is distributed as a Python package
140+
and can be installed with the ``nox[uv]`` extra.
136141

137142
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.
138143

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

146+
.. warning::
147+
148+
The ``uv`` backend does not install anything by default, including ``pip``,
149+
as ``uv pip`` is used to install programs instead. If you need to manually
150+
interact with pip, you should install it with ``session.install("pip")``.
151+
152+
.. warning::
153+
154+
Currently the ``uv`` backend requires the ``<program name> @ .`` syntax to
155+
install a local folder in non-editable mode; it does not (yet) compute the
156+
name from the install process like pip does if the name is omitted. Editable
157+
installs do not require a name.
158+
141159

142160
.. _opt-force-venv-backend:
143161

144162
Forcing the sessions backend
145163
----------------------------
146164

147-
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')``.
165+
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')``.
148166

149167
.. code-block:: console
150168

nox/_options.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -383,10 +383,10 @@ def _tag_completer(
383383
merge_func=_default_venv_backend_merge_func,
384384
help=(
385385
"Virtual environment backend to use by default for Nox sessions, this is"
386-
" ``'virtualenv'`` by default but any of ``('virtualenv', 'conda', 'mamba',"
387-
" 'venv')`` are accepted."
386+
" ``'virtualenv'`` by default but any of ``('uv, 'virtualenv',"
387+
" 'conda', 'mamba', 'venv')`` are accepted."
388388
),
389-
choices=["none", "virtualenv", "conda", "mamba", "venv"],
389+
choices=["none", "virtualenv", "conda", "mamba", "venv", "uv"],
390390
),
391391
_option_set.Option(
392392
"force_venv_backend",
@@ -398,10 +398,10 @@ def _tag_completer(
398398
help=(
399399
"Virtual environment backend to force-use for all Nox sessions in this run,"
400400
" overriding any other venv backend declared in the Noxfile and ignoring"
401-
" the default backend. Any of ``('virtualenv', 'conda', 'mamba', 'venv')``"
402-
" are accepted."
401+
" the default backend. Any of ``('uv', 'virtualenv', 'conda', 'mamba',"
402+
" 'venv')`` are accepted."
403403
),
404-
choices=["none", "virtualenv", "conda", "mamba", "venv"],
404+
choices=["none", "virtualenv", "conda", "mamba", "venv", "uv"],
405405
),
406406
_option_set.Option(
407407
"no_venv",

nox/sessions.py

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -650,7 +650,12 @@ def install(self, *args: str, **kwargs: Any) -> None:
650650
if "silent" not in kwargs:
651651
kwargs["silent"] = True
652652

653-
self._run("python", "-m", "pip", "install", *args, external="error", **kwargs)
653+
if isinstance(venv, VirtualEnv) and venv.venv_backend == "uv":
654+
self._run("uv", "pip", "install", *args, external="error", **kwargs)
655+
else:
656+
self._run(
657+
"python", "-m", "pip", "install", *args, external="error", **kwargs
658+
)
654659

655660
def notify(
656661
self,
@@ -766,11 +771,12 @@ def _create_venv(self) -> None:
766771
self.func.reuse_venv or self.global_config.reuse_existing_virtualenvs
767772
)
768773

769-
if backend is None or backend == "virtualenv":
774+
if backend is None or backend in {"virtualenv", "venv", "uv"}:
770775
self.venv = VirtualEnv(
771776
self.envdir,
772777
interpreter=self.func.python, # type: ignore[arg-type]
773778
reuse_existing=reuse_existing,
779+
venv_backend=backend or "virtualenv",
774780
venv_params=self.func.venv_params,
775781
)
776782
elif backend in {"conda", "mamba"}:
@@ -781,14 +787,6 @@ def _create_venv(self) -> None:
781787
venv_params=self.func.venv_params,
782788
conda_cmd=backend,
783789
)
784-
elif backend == "venv":
785-
self.venv = VirtualEnv(
786-
self.envdir,
787-
interpreter=self.func.python, # type: ignore[arg-type]
788-
reuse_existing=reuse_existing,
789-
venv=True,
790-
venv_params=self.func.venv_params,
791-
)
792790
else:
793791
raise ValueError(
794792
"Expected venv_backend one of ('virtualenv', 'conda', 'mamba',"

nox/virtualenv.py

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -312,22 +312,23 @@ class VirtualEnv(ProcessEnv):
312312
"""
313313

314314
is_sandboxed = True
315+
allowed_globals = ("uv",)
315316

316317
def __init__(
317318
self,
318319
location: str,
319320
interpreter: str | None = None,
320321
reuse_existing: bool = False,
321322
*,
322-
venv: bool = False,
323+
venv_backend: str = "virtualenv",
323324
venv_params: Any = None,
324325
):
325326
self.location_name = location
326327
self.location = os.path.abspath(location)
327328
self.interpreter = interpreter
328329
self._resolved: None | str | InterpreterNotFound = None
329330
self.reuse_existing = reuse_existing
330-
self.venv_or_virtualenv = "venv" if venv else "virtualenv"
331+
self.venv_backend = venv_backend
331332
self.venv_params = venv_params or []
332333
super().__init__(env={"VIRTUAL_ENV": self.location})
333334

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

350351
def _check_reused_environment_type(self) -> bool:
351352
"""Check if reused environment type is the same."""
352-
path = os.path.join(self.location, "pyvenv.cfg")
353-
if not os.path.isfile(path):
353+
try:
354+
with open(os.path.join(self.location, "pyvenv.cfg")) as fp:
355+
parts = (x.partition("=") for x in fp if "=" in x)
356+
config = {k.strip(): v.strip() for k, _, v in parts}
357+
if "uv" in config or "gourgeist" in config:
358+
old_env = "uv"
359+
elif "virtualenv" in config:
360+
old_env = "virtualenv"
361+
else:
362+
old_env = "venv"
363+
except FileNotFoundError: # pragma: no cover
354364
# virtualenv < 20.0 does not create pyvenv.cfg
355365
old_env = "virtualenv"
356-
else:
357-
pattern = re.compile("virtualenv[ \t]*=")
358-
with open(path) as fp:
359-
old_env = (
360-
"virtualenv" if any(pattern.match(line) for line in fp) else "venv"
361-
)
362-
return old_env == self.venv_or_virtualenv
366+
367+
return old_env == self.venv_backend
363368

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

475480
return False
476481

477-
if self.venv_or_virtualenv == "virtualenv":
482+
if self.venv_backend == "virtualenv":
478483
cmd = [sys.executable, "-m", "virtualenv", self.location]
479484
if self.interpreter:
480485
cmd.extend(["-p", self._resolved_interpreter])
486+
elif self.venv_backend == "uv":
487+
cmd = [
488+
"uv",
489+
"venv",
490+
"-p",
491+
self._resolved_interpreter if self.interpreter else sys.executable,
492+
self.location,
493+
]
481494
else:
482495
cmd = [self._resolved_interpreter, "-m", "venv", self.location]
483496
cmd.extend(self.venv_params)
484497

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

487500
logger.info(
488-
f"Creating virtual environment ({self.venv_or_virtualenv}) using"
501+
f"Creating virtual environment ({self.venv_backend}) using"
489502
f" {resolved_interpreter_name} in {self.location_name}"
490503
)
491504
nox.command.run(cmd, silent=True, log=nox.options.verbose or False)

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ tox_to_nox = [
5252
"jinja2",
5353
"tox",
5454
]
55+
uv = [
56+
"uv",
57+
]
5558
[project.urls]
5659
bug-tracker = "https://github.com/wntrblm/nox/issues"
5760
documentation = "https://nox.thea.codes"

requirements-test.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ pytest-cov
66
sphinx>=3.0
77
sphinx-autobuild
88
sphinx-tabs
9+
uv; python_version>='3.8'
910
witchhazel

tests/test_sessions.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ def make_session_and_runner(self):
7979
runner.venv = mock.create_autospec(nox.virtualenv.VirtualEnv)
8080
runner.venv.env = {}
8181
runner.venv.bin_paths = ["/no/bin/for/you"]
82+
runner.venv.venv_backend = "venv"
8283
return nox.sessions.Session(runner=runner), runner
8384

8485
def test_create_tmp(self):
@@ -633,6 +634,7 @@ def test_install(self):
633634
)
634635
runner.venv = mock.create_autospec(nox.virtualenv.VirtualEnv)
635636
runner.venv.env = {}
637+
runner.venv.venv_backend = "venv"
636638

637639
class SessionNoSlots(nox.sessions.Session):
638640
pass
@@ -662,6 +664,7 @@ def test_install_non_default_kwargs(self):
662664
)
663665
runner.venv = mock.create_autospec(nox.virtualenv.VirtualEnv)
664666
runner.venv.env = {}
667+
runner.venv.venv_backend = "venv"
665668

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

799802
assert run.called is run_called
800803

804+
def test_install_uv(self):
805+
runner = nox.sessions.SessionRunner(
806+
name="test",
807+
signatures=["test"],
808+
func=mock.sentinel.func,
809+
global_config=_options.options.namespace(posargs=[]),
810+
manifest=mock.create_autospec(nox.manifest.Manifest),
811+
)
812+
runner.venv = mock.create_autospec(nox.virtualenv.VirtualEnv)
813+
runner.venv.env = {}
814+
runner.venv.venv_backend = "uv"
815+
816+
class SessionNoSlots(nox.sessions.Session):
817+
pass
818+
819+
session = SessionNoSlots(runner=runner)
820+
821+
with mock.patch.object(session, "_run", autospec=True) as run:
822+
session.install("requests", "urllib3", silent=False)
823+
run.assert_called_once_with(
824+
"uv",
825+
"pip",
826+
"install",
827+
"requests",
828+
"urllib3",
829+
silent=False,
830+
external="error",
831+
)
832+
801833
def test___slots__(self):
802834
session, _ = self.make_session_and_runner()
803835
with pytest.raises(AttributeError):

tests/test_virtualenv.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131

3232
IS_WINDOWS = nox.virtualenv._SYSTEM == "Windows"
3333
HAS_CONDA = shutil.which("conda") is not None
34+
HAS_UV = shutil.which("uv") is not None
3435
RAISE_ERROR = "RAISE_ERROR"
3536
VIRTUALENV_VERSION = virtualenv.__version__
3637

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

242243

244+
@pytest.mark.skipif(not HAS_UV, reason="Missing uv command.")
245+
def test_uv_creation(make_one):
246+
venv, _ = make_one(venv_backend="uv")
247+
assert venv.location
248+
assert venv.interpreter is None
249+
assert venv.reuse_existing is False
250+
assert venv.venv_backend == "uv"
251+
252+
venv.create()
253+
assert venv._check_reused_environment_type()
254+
255+
243256
def test_constructor_defaults(make_one):
244257
venv, _ = make_one()
245258
assert venv.location
246259
assert venv.interpreter is None
247260
assert venv.reuse_existing is False
248-
assert venv.venv_or_virtualenv == "virtualenv"
261+
assert venv.venv_backend == "virtualenv"
249262

250263

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

418431
@enable_staleness_check
419432
def test_create_reuse_stale_virtualenv_environment(make_one):
420-
venv, location = make_one(reuse_existing=True, venv=True)
433+
venv, location = make_one(reuse_existing=True, venv_backend="venv")
421434
venv.create()
422435

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

443456
@enable_staleness_check
444457
def test_create_reuse_venv_environment(make_one):
445-
venv, location = make_one(reuse_existing=True, venv=True)
458+
venv, location = make_one(reuse_existing=True, venv_backend="venv")
446459
venv.create()
447460

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

517530

518531
def test_create_venv_backend(make_one):
519-
venv, dir_ = make_one(venv=True)
532+
venv, dir_ = make_one(venv_backend="venv")
520533
venv.create()
521534

522535

0 commit comments

Comments
 (0)