From f04a04e63304c1faad879dd82f1d6945189a6cbf Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Fri, 16 Feb 2024 17:44:17 -0500 Subject: [PATCH] feat: add a uv backend Signed-off-by: Henry Schreiner --- docs/usage.rst | 22 ++++++++++++++++++++-- nox/_options.py | 12 ++++++------ nox/sessions.py | 18 ++++++++---------- nox/virtualenv.py | 39 ++++++++++++++++++++++++++------------- pyproject.toml | 3 +++ requirements-test.txt | 1 + tests/test_sessions.py | 32 ++++++++++++++++++++++++++++++++ tests/test_virtualenv.py | 21 +++++++++++++++++---- 8 files changed, 113 insertions(+), 35 deletions(-) diff --git a/docs/usage.rst b/docs/usage.rst index bc237610..dc1e03d3 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -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 `` or ``--default-venv-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 `` or ``--default-venv-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 `` @ .`` 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 `` or ``--force-venv-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 `` or ``--force-venv-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 diff --git a/nox/_options.py b/nox/_options.py index 39ebd4c5..7c4b89e8 100644 --- a/nox/_options.py +++ b/nox/_options.py @@ -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", @@ -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", diff --git a/nox/sessions.py b/nox/sessions.py index 02e1e69f..f29e6bfc 100644 --- a/nox/sessions.py +++ b/nox/sessions.py @@ -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, @@ -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"}: @@ -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'," diff --git a/nox/virtualenv.py b/nox/virtualenv.py index 431e8f27..d11db749 100644 --- a/nox/virtualenv.py +++ b/nox/virtualenv.py @@ -312,6 +312,7 @@ class VirtualEnv(ProcessEnv): """ is_sandboxed = True + allowed_globals = ("uv",) def __init__( self, @@ -319,7 +320,7 @@ def __init__( interpreter: str | None = None, reuse_existing: bool = False, *, - venv: bool = False, + venv_backend: str = "virtualenv", venv_params: Any = None, ): self.location_name = location @@ -327,7 +328,7 @@ def __init__( 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}) @@ -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.""" @@ -474,10 +479,18 @@ 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) @@ -485,7 +498,7 @@ def create(self) -> bool: 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) diff --git a/pyproject.toml b/pyproject.toml index e7cda194..f3dc7264 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/requirements-test.txt b/requirements-test.txt index 9886e7a0..eb05f07f 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -6,4 +6,5 @@ pytest-cov sphinx>=3.0 sphinx-autobuild sphinx-tabs +uv; python_version>='3.8' witchhazel diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 1926a332..4b831460 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -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): @@ -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 @@ -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 @@ -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): diff --git a/tests/test_virtualenv.py b/tests/test_virtualenv.py index 1578c58a..b5cf49a9 100644 --- a/tests/test_virtualenv.py +++ b/tests/test_virtualenv.py @@ -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__ @@ -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.") @@ -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. @@ -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. @@ -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()