diff --git a/docs/config.rst b/docs/config.rst index 9311df53..74bb7836 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -157,7 +157,7 @@ Use of :func:`session.install()` is deprecated without a virtualenv since it mod def tests(session): session.run("pip", "install", "nox") -You can also specify that the virtualenv should *always* be reused instead of recreated every time: +You can also specify that the virtualenv should *always* be reused instead of recreated every time unless ``--reuse-venv=never``: .. code-block:: python @@ -432,7 +432,8 @@ The following options can be specified in the Noxfile: * ``nox.options.tags`` is equivalent to specifying :ref:`-t or --tags `. * ``nox.options.default_venv_backend`` is equivalent to specifying :ref:`-db or --default-venv-backend `. * ``nox.options.force_venv_backend`` is equivalent to specifying :ref:`-fb or --force-venv-backend `. -* ``nox.options.reuse_existing_virtualenvs`` is equivalent to specifying :ref:`--reuse-existing-virtualenvs `. You can force this off by specifying ``--no-reuse-existing-virtualenvs`` during invocation. +* ``nox.options.reuse_venv`` is equivalent to specifying :ref:`--reuse-venv `. Preferred over using ``nox.options.reuse_existing_virtualenvs``. +* ``nox.options.reuse_existing_virtualenvs`` is equivalent to specifying :ref:`--reuse-existing-virtualenvs `. You can force this off by specifying ``--no-reuse-existing-virtualenvs`` during invocation. Alias of ``nox.options.reuse_venv=yes|no``. * ``nox.options.stop_on_first_error`` is equivalent to specifying :ref:`--stop-on-first-error `. You can force this off by specifying ``--no-stop-on-first-error`` during invocation. * ``nox.options.error_on_missing_interpreters`` is equivalent to specifying :ref:`--error-on-missing-interpreters `. You can force this off by specifying ``--no-error-on-missing-interpreters`` during invocation. * ``nox.options.error_on_external_run`` is equivalent to specifying :ref:`--error-on-external-run `. You can force this off by specifying ``--no-error-on-external-run`` during invocation. diff --git a/docs/usage.rst b/docs/usage.rst index dc1e03d3..6491dc0a 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -179,26 +179,40 @@ Finally note that the ``--no-venv`` flag is a shortcut for ``--force-venv-backen nox --no-venv .. _opt-reuse-existing-virtualenvs: +.. _opt-reuse-venv: Re-using virtualenvs -------------------- -By default, Nox deletes and recreates virtualenvs every time it is run. This is usually fine for most projects and continuous integration environments as `pip's caching `_ makes re-install rather quick. However, there are some situations where it is advantageous to reuse the virtualenvs between runs. Use ``-r`` or ``--reuse-existing-virtualenvs``: +By default, Nox deletes and recreates virtualenvs every time it is run. This is +usually fine for most projects and continuous integration environments as +`pip's caching `_ makes +re-install rather quick. However, there are some situations where it is +advantageous to reuse the virtualenvs between runs. Use ``-r`` or +``--reuse-existing-virtualenvs`` or for fine-grained control use +``--reuse-venv=yes|no|always|never``: .. code-block:: console nox -r nox --reuse-existing-virtualenvs - + nox --reuse-venv=yes # preferred If the Noxfile sets ``nox.options.reuse_existing_virtualenvs``, you can override the Noxfile setting from the command line by using ``--no-reuse-existing-virtualenvs``. +Similarly you can override ``nox.options.reuse_venvs`` from the Noxfile via the command line by using ``--reuse-venv=yes|no|always|never``. + +.. note:: -Additionally, you can skip the re-installation of packages when a virtualenv is reused. Use ``-R`` or ``--reuse-existing-virtualenvs --no-install``: + ``--reuse-existing-virtualenvs`` is a alias for ``--reuse-venv=yes`` and ``--no-reuse-existing-virtualenvs`` is an alias for ``--reuse-venv=no``. + +Additionally, you can skip the re-installation of packages when a virtualenv is reused. +Use ``-R`` or ``--reuse-existing-virtualenvs --no-install`` or ``--reuse-venv=yes --no-install``: .. code-block:: console nox -R nox --reuse-existing-virtualenvs --no-install + nox --reuse-venv=yes --no-install The ``--no-install`` option causes the following session methods to return early: @@ -206,7 +220,10 @@ The ``--no-install`` option causes the following session methods to return early - :func:`session.conda_install ` - :func:`session.run_install ` -This option has no effect if the virtualenv is not being reused. +The ``never`` and ``always`` options in ``--reuse-venv`` gives you more fine-grained control +as it ignores when a ``@nox.session`` has ``reuse_venv=True|False`` defined. + +These options have no effect if the virtualenv is not being reused. .. _opt-running-extra-pythons: diff --git a/nox/_options.py b/nox/_options.py index f6dd828d..cf9cbde9 100644 --- a/nox/_options.py +++ b/nox/_options.py @@ -27,6 +27,13 @@ from nox import _option_set from nox.tasks import discover_manifest, filter_manifest, load_nox_module +if sys.version_info < (3, 8): + from typing_extensions import Literal +else: + from typing import Literal + +ReuseVenvType = Literal["no", "yes", "never", "always"] + """All of Nox's configuration options.""" options = _option_set.OptionSet( @@ -146,6 +153,30 @@ def _envdir_merge_func( return command_args.envdir or noxfile_args.envdir or ".nox" +def _reuse_venv_merge_func( + command_args: argparse.Namespace, noxfile_args: argparse.Namespace +) -> ReuseVenvType: + """Merge reuse_venv from command args and Noxfile while maintaining + backwards compatibility with reuse_existing_virtualenvs. Default is "no". + + Args: + command_args (_option_set.Namespace): The options specified on the + command-line. + noxfile_Args (_option_set.Namespace): The options specified in the + Noxfile. + """ + # back-compat scenario with no_reuse_existing_virtualenvs/reuse_existing_virtualenvs + if command_args.no_reuse_existing_virtualenvs: + return "no" + if ( + command_args.reuse_existing_virtualenvs + or noxfile_args.reuse_existing_virtualenvs + ): + return "yes" + # regular option behavior + return command_args.reuse_venv or noxfile_args.reuse_venv or "no" + + def default_env_var_list_factory(env_var: str) -> Callable[[], list[str] | None]: """Looks at the env var to set the default value for a list of env vars. @@ -197,12 +228,22 @@ def _force_pythons_finalizer( def _R_finalizer(value: bool, args: argparse.Namespace) -> bool: - """Propagate -R to --reuse-existing-virtualenvs and --no-install.""" + """Propagate -R to --reuse-existing-virtualenvs and --no-install and --reuse-venv=yes.""" if value: + args.reuse_venv = "yes" args.reuse_existing_virtualenvs = args.no_install = value return value +def _reuse_existing_virtualenvs_finalizer( + value: bool, args: argparse.Namespace +) -> bool: + """Propagate --reuse-existing-virtualenvs to --reuse-venv=yes.""" + if value: + args.reuse_venv = "yes" + return value + + def _posargs_finalizer( value: Sequence[Any], args: argparse.Namespace ) -> Sequence[Any] | list[Any]: @@ -412,6 +453,18 @@ def _tag_completer( " creating a venv. This is an alias for '--force-venv-backend none'." ), ), + _option_set.Option( + "reuse_venv", + "--reuse-venv", + group=options.groups["environment"], + noxfile=True, + merge_func=_reuse_venv_merge_func, + help=( + "Controls existing virtualenvs recreation. This is ``'no'`` by" + " default, but any of ``('yes', 'no', 'always', 'never')`` are accepted." + ), + choices=["yes", "no", "always", "never"], + ), *_option_set.make_flag_pair( "reuse_existing_virtualenvs", ("-r", "--reuse-existing-virtualenvs"), @@ -420,7 +473,8 @@ def _tag_completer( "--no-reuse-existing-virtualenvs", ), group=options.groups["environment"], - help="Reuse existing virtualenvs instead of recreating them.", + help="This is an alias for '--reuse-venv=yes|no'.", + finalizer_func=_reuse_existing_virtualenvs_finalizer, ), _option_set.Option( "R", diff --git a/nox/sessions.py b/nox/sessions.py index f3c2540f..a98916f4 100644 --- a/nox/sessions.py +++ b/nox/sessions.py @@ -767,9 +767,7 @@ def _create_venv(self) -> None: self.venv = PassthroughEnv() return - reuse_existing = ( - self.func.reuse_venv or self.global_config.reuse_existing_virtualenvs - ) + reuse_existing = self.reuse_existing_venv() if backend is None or backend in {"virtualenv", "venv", "uv"}: self.venv = VirtualEnv( @@ -795,6 +793,51 @@ def _create_venv(self) -> None: self.venv.create() + def reuse_existing_venv(self) -> bool: + """ + Determines whether to reuse an existing virtual environment. + + The decision matrix is as follows: + + +--------------------------+-----------------+-------------+ + | global_config.reuse_venv | func.reuse_venv | Reuse venv? | + +==========================+=================+=============+ + | "always" | N/A | Yes | + +--------------------------+-----------------+-------------+ + | "never" | N/A | No | + +--------------------------+-----------------+-------------+ + | "yes" | True|None | Yes | + +--------------------------+-----------------+-------------+ + | "yes" | False | No | + +--------------------------+-----------------+-------------+ + | "no" | True | Yes | + +--------------------------+-----------------+-------------+ + | "no" | False|None | No | + +--------------------------+-----------------+-------------+ + + Summary + ~~~~~~~ + - "always" forces reuse regardless of `func.reuse_venv`. + - "never" forces recreation regardless of `func.reuse_venv`. + - "yes" and "no" respect `func.reuse_venv` being ``False`` or ``True`` respectively. + + Returns: + bool: True if the existing virtual environment should be reused, False otherwise. + """ + + return any( + ( + # "always" forces reuse regardless of func.reuse_venv + self.global_config.reuse_venv == "always", + # Respect func.reuse_venv when it's explicitly True, unless global_config is "never" + self.func.reuse_venv is True + and self.global_config.reuse_venv != "never", + # Delegate to reuse ("yes") when func.reuse_venv is not explicitly False + self.func.reuse_venv is not False + and self.global_config.reuse_venv == "yes", + ) + ) + def execute(self) -> Result: logger.warning(f"Running session {self.friendly_name}") diff --git a/tests/conftest.py b/tests/conftest.py index 52662964..160beae4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,23 @@ +# Copyright 2023 Alethea Katherine Flowers +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +import re +from pathlib import Path +from string import Template +from typing import Callable + import pytest @@ -6,3 +26,29 @@ def reset_color_envvars(monkeypatch): """Remove color-related envvars to fix test output""" monkeypatch.delenv("FORCE_COLOR", raising=False) monkeypatch.delenv("NO_COLOR", raising=False) + + +RESOURCES = Path(__file__).parent.joinpath("resources") + + +@pytest.fixture +def generate_noxfile_options(tmp_path: Path) -> Callable[..., str]: + """Generate noxfile.py with test and templated options. + + The options are enabled (if disabled) and the values are applied + if a matching format string is encountered with the option name. + """ + + def generate_noxfile(**option_mapping: str | bool) -> str: + path = Path(RESOURCES) / "noxfile_options.py" + text = path.read_text(encoding="utf8") + if option_mapping: + for opt, _val in option_mapping.items(): + # "uncomment" options with values provided + text = re.sub(rf"(# )?nox.options.{opt}", f"nox.options.{opt}", text) + text = Template(text).safe_substitute(**option_mapping) + path = tmp_path / "noxfile.py" + path.write_text(text) + return str(path) + + return generate_noxfile diff --git a/tests/resources/noxfile_options.py b/tests/resources/noxfile_options.py index b625cae5..6bd4e884 100644 --- a/tests/resources/noxfile_options.py +++ b/tests/resources/noxfile_options.py @@ -16,8 +16,9 @@ import nox -nox.options.reuse_existing_virtualenvs = True -# nox.options.error_on_missing_interpreters = {error_on_missing_interpreters} # used by tests +# nox.options.reuse_existing_virtualenvs = ${reuse_existing_virtualenvs} +# nox.options.reuse_venv = "${reuse_venv}" +# nox.options.error_on_missing_interpreters = ${error_on_missing_interpreters} nox.options.sessions = ["test"] diff --git a/tests/test_main.py b/tests/test_main.py index 158702f3..251432a3 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -61,6 +61,7 @@ def test_main_no_args(monkeypatch): assert config.sessions is None assert not config.no_venv assert not config.reuse_existing_virtualenvs + assert not config.reuse_venv assert not config.stop_on_first_error assert config.posargs == [] @@ -101,6 +102,7 @@ def test_main_long_form_args(): assert config.force_venv_backend == "none" assert config.no_venv is True assert config.reuse_existing_virtualenvs is True + assert config.reuse_venv == "yes" assert config.stop_on_first_error is True assert config.posargs == [] @@ -180,6 +182,7 @@ def test_main_short_form_args(monkeypatch): assert config.default_venv_backend == "venv" assert config.force_venv_backend == "conda" assert config.reuse_existing_virtualenvs is True + assert config.reuse_venv == "yes" def test_main_explicit_sessions(monkeypatch): @@ -466,7 +469,8 @@ def test_main_with_bad_session_names(run_nox, session): assert session in stderr -def test_main_noxfile_options(monkeypatch): +def test_main_noxfile_options(monkeypatch, generate_noxfile_options): + noxfile_path = generate_noxfile_options(reuse_existing_virtualenvs=True) monkeypatch.setattr( sys, "argv", @@ -476,7 +480,7 @@ def test_main_noxfile_options(monkeypatch): "-s", "test", "--noxfile", - os.path.join(RESOURCES, "noxfile_options.py"), + noxfile_path, ], ) @@ -491,9 +495,11 @@ def test_main_noxfile_options(monkeypatch): # Verify that the config looks correct. config = honor_list_request.call_args[1]["global_config"] assert config.reuse_existing_virtualenvs is True + assert config.reuse_venv == "yes" -def test_main_noxfile_options_disabled_by_flag(monkeypatch): +def test_main_noxfile_options_disabled_by_flag(monkeypatch, generate_noxfile_options): + noxfile_path = generate_noxfile_options(reuse_existing_virtualenvs=True) monkeypatch.setattr( sys, "argv", @@ -504,7 +510,7 @@ def test_main_noxfile_options_disabled_by_flag(monkeypatch): "test", "--no-reuse-existing-virtualenvs", "--noxfile", - os.path.join(RESOURCES, "noxfile_options.py"), + noxfile_path, ], ) @@ -519,13 +525,15 @@ def test_main_noxfile_options_disabled_by_flag(monkeypatch): # Verify that the config looks correct. config = honor_list_request.call_args[1]["global_config"] assert config.reuse_existing_virtualenvs is False + assert config.reuse_venv == "no" -def test_main_noxfile_options_sessions(monkeypatch): +def test_main_noxfile_options_sessions(monkeypatch, generate_noxfile_options): + noxfile_path = generate_noxfile_options(reuse_existing_virtualenvs=True) monkeypatch.setattr( sys, "argv", - ["nox", "-l", "--noxfile", os.path.join(RESOURCES, "noxfile_options.py")], + ["nox", "-l", "--noxfile", noxfile_path], ) with mock.patch("nox.tasks.honor_list_request") as honor_list_request: @@ -693,7 +701,11 @@ def test_main_reuse_existing_virtualenvs_no_install(monkeypatch): with mock.patch.object(sys, "exit"): nox.__main__.main() config = execute.call_args[1]["global_config"] - assert config.reuse_existing_virtualenvs and config.no_install + assert ( + config.reuse_existing_virtualenvs + and config.no_install + and config.reuse_venv == "yes" + ) @pytest.mark.parametrize( @@ -709,7 +721,7 @@ def test_main_reuse_existing_virtualenvs_no_install(monkeypatch): ) def test_main_noxfile_options_with_ci_override( monkeypatch, - tmp_path, + generate_noxfile_options, should_set_ci_env_var, noxfile_option_value, expected_final_value, @@ -723,16 +735,12 @@ def test_main_noxfile_options_with_ci_override( ) monkeypatch.setattr(nox, "options", nox._options.noxfile_options) - noxfile_path = Path(RESOURCES) / "noxfile_options.py" - if noxfile_option_value is not None: - # Temp noxfile with error_on_missing_interpreters set - noxfile_text = noxfile_path.read_text() - noxfile_text = noxfile_text.replace("# nox", "nox") - noxfile_text = noxfile_text.format( + if noxfile_option_value is None: + noxfile_path = generate_noxfile_options() + else: + noxfile_path = generate_noxfile_options( error_on_missing_interpreters=noxfile_option_value ) - noxfile_path = tmp_path / "noxfile.py" - noxfile_path.write_text(noxfile_text) monkeypatch.setattr( sys, @@ -747,3 +755,88 @@ def test_main_noxfile_options_with_ci_override( nox.__main__.main() config = honor_list_request.call_args[1]["global_config"] assert config.error_on_missing_interpreters == expected_final_value + + +@pytest.mark.parametrize( + "reuse_venv", + [ + "yes", + "no", + "always", + "never", + ], +) +def test_main_reuse_venv_cli_flags(monkeypatch, generate_noxfile_options, reuse_venv): + monkeypatch.setattr(sys, "argv", ["nox", "--reuse-venv", reuse_venv]) + with mock.patch("nox.workflow.execute", return_value=0) as execute: + with mock.patch.object(sys, "exit"): + nox.__main__.main() + config = execute.call_args[1]["global_config"] + assert ( + not config.reuse_existing_virtualenvs + ) # should remain unaffected in this case + assert config.reuse_venv == reuse_venv + + +@pytest.mark.parametrize( + ("reuse_venv", "reuse_existing_virtualenvs", "expected"), + [ + ("yes", None, "yes"), + ("yes", False, "yes"), + ("yes", True, "yes"), + ("yes", "--no-reuse-existing-virtualenvs", "no"), + ("yes", "--reuse-existing-virtualenvs", "yes"), + ("no", None, "no"), + ("no", False, "no"), + ("no", True, "yes"), + ("no", "--no-reuse-existing-virtualenvs", "no"), + ("no", "--reuse-existing-virtualenvs", "yes"), + ("always", None, "always"), + ("always", False, "always"), + ("always", True, "yes"), + ("always", "--no-reuse-existing-virtualenvs", "no"), + ("always", "--reuse-existing-virtualenvs", "yes"), + ("never", None, "never"), + ("never", False, "never"), + ("never", True, "yes"), + ("never", "--no-reuse-existing-virtualenvs", "no"), + ("never", "--reuse-existing-virtualenvs", "yes"), + ], +) +def test_main_noxfile_options_reuse_venv_compat_check( + monkeypatch, + generate_noxfile_options, + reuse_venv, + reuse_existing_virtualenvs, + expected, +): + cmd_args = ["nox", "-l"] + # CLI Compat Check + if isinstance(reuse_existing_virtualenvs, str): + cmd_args += [reuse_existing_virtualenvs] + + # Generate noxfile + if isinstance(reuse_existing_virtualenvs, bool): + # Non-CLI Compat Check + noxfile_path = generate_noxfile_options( + reuse_venv=reuse_venv, reuse_existing_virtualenvs=reuse_existing_virtualenvs + ) + else: + noxfile_path = generate_noxfile_options(reuse_venv=reuse_venv) + cmd_args += ["--noxfile", str(noxfile_path)] + + # Reset nox.options + monkeypatch.setattr( + nox._options, "noxfile_options", nox._options.options.noxfile_namespace() + ) + monkeypatch.setattr(nox, "options", nox._options.noxfile_options) + + # Execute + monkeypatch.setattr(sys, "argv", cmd_args) + with mock.patch( + "nox.tasks.honor_list_request", return_value=0 + ) as honor_list_request: + with mock.patch("sys.exit"): + nox.__main__.main() + config = honor_list_request.call_args[1]["global_config"] + assert config.reuse_venv == expected diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 4b831460..24ebd635 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -857,7 +857,7 @@ def make_runner(self): noxfile=os.path.join(os.getcwd(), "noxfile.py"), envdir="envdir", posargs=[], - reuse_existing_virtualenvs=False, + reuse_venv="no", error_on_missing_interpreters="CI" in os.environ, ), manifest=mock.create_autospec(nox.manifest.Manifest), @@ -960,10 +960,32 @@ def test__create_venv_options(self, create_method, venv_backend, expected_backen def test__create_venv_unexpected_venv_backend(self): runner = self.make_runner() runner.func.venv_backend = "somenewenvtool" - with pytest.raises(ValueError, match="venv_backend"): runner._create_venv() + @pytest.mark.parametrize( + ("reuse_venv", "reuse_venv_func", "should_reuse"), + [ + ("yes", None, True), + ("yes", False, False), + ("yes", True, True), + ("no", None, False), + ("no", False, False), + ("no", True, True), + ("always", None, True), + ("always", False, True), + ("always", True, True), + ("never", None, False), + ("never", False, False), + ("never", True, False), + ], + ) + def test__reuse_venv_outcome(self, reuse_venv, reuse_venv_func, should_reuse): + runner = self.make_runner() + runner.func.reuse_venv = reuse_venv_func + runner.global_config.reuse_venv = reuse_venv + assert runner.reuse_existing_venv() == should_reuse + def make_runner_with_mock_venv(self): runner = self.make_runner() runner._create_venv = mock.Mock() diff --git a/tests/test_tasks.py b/tests/test_tasks.py index fe3e0b5e..947239ed 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -320,7 +320,7 @@ def quux(): assert "Tag selection caused no sessions to be selected." in caplog.text -def test_merge_sessions_and_tags(reset_global_nox_options): +def test_merge_sessions_and_tags(reset_global_nox_options, generate_noxfile_options): @nox.session(tags=["foobar"]) def test(): pass @@ -329,8 +329,9 @@ def test(): def bar(): pass + noxfile_path = generate_noxfile_options(reuse_existing_virtualenvs=True) config = _options.options.namespace( - noxfile=os.path.join(RESOURCES, "noxfile_options.py"), + noxfile=noxfile_path, sessions=None, pythons=(), posargs=[],