From 56c7d56f656c032a98ad17a0924893c338a842e6 Mon Sep 17 00:00:00 2001 From: Claudio Jolowicz Date: Tue, 1 Jun 2021 12:15:58 +0200 Subject: [PATCH] Add option --no-install to skip install commands in reused environments (#432) Use either of these to reuse a virtualenv without re-installing packages: ``` nox -R nox --reuse-existing-virtualenvs --no-install ``` The `--no-install` option causes the following session methods to return early: - `session.install` - `session.conda_install` - `session.run_always` This option has no effect if the virtualenv is not being reused. Co-authored-by: Jam * Add option --no-install * Add test for VirtualEnv._reused * Add test for CondaEnv._reused * Add {Virtual,Conda,Process}Env._reused * Add test for session.install with --no-install * Skip session.install when --no-install is given * Add test for session.conda_install with --no-install * Skip session.conda_install when --no-install is given * Add test for session.run_always with --no-install * Skip session.run_always when --no-install is given * Add test for short option -R * Add short option -R for `--reuse-existing-virtualenvs --no-install` * Document the --no-install and -R options * Update broken link to pip documentation * Clarify documentation of session.run_always --- docs/usage.rst | 17 ++++++++++- nox/_options.py | 31 ++++++++++++++++++++ nox/sessions.py | 29 ++++++++++++++++-- nox/virtualenv.py | 7 +++++ tests/test_main.py | 9 ++++++ tests/test_sessions.py | 63 ++++++++++++++++++++++++++++++++++++++++ tests/test_virtualenv.py | 4 +++ 7 files changed, 156 insertions(+), 4 deletions(-) diff --git a/docs/usage.rst b/docs/usage.rst index 55675510..8103cb2a 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -149,7 +149,7 @@ Finally note that the ``--no-venv`` flag is a shortcut for ``--force-venv-backen 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 re-use 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 re-use the virtualenvs between runs. Use ``-r`` or ``--reuse-existing-virtualenvs``: .. code-block:: console @@ -159,6 +159,21 @@ By default, Nox deletes and recreates virtualenvs every time it is run. This is 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``. +Additionally, you can skip the re-installation of packages when a virtualenv is reused. Use ``-R`` or ``--reuse-existing-virtualenvs --no-install``: + +.. code-block:: console + + nox -R + nox --reuse-existing-virtualenvs --no-install + +The ``--no-install`` option causes the following session methods to return early: + +- :func:`session.install ` +- :func:`session.conda_install ` +- :func:`session.run_always ` + +This option has no effect if the virtualenv is not being reused. + .. _opt-running-extra-pythons: Running additional Python versions diff --git a/nox/_options.py b/nox/_options.py index 4fbd8f20..770b8438 100644 --- a/nox/_options.py +++ b/nox/_options.py @@ -157,6 +157,13 @@ def _force_pythons_finalizer( return value +def _R_finalizer(value: bool, args: argparse.Namespace) -> bool: + """Propagate -R to --reuse-existing-virtualenvs and --no-install.""" + if value: + args.reuse_existing_virtualenvs = args.no_install = value + return value + + def _posargs_finalizer( value: Sequence[Any], args: argparse.Namespace ) -> Union[Sequence[Any], List[Any]]: @@ -320,6 +327,18 @@ def _session_completer( group=options.groups["secondary"], help="Re-use existing virtualenvs instead of recreating them.", ), + _option_set.Option( + "R", + "-R", + default=False, + group=options.groups["secondary"], + action="store_true", + help=( + "Re-use existing virtualenvs and skip package re-installation." + " This is an alias for '--reuse-existing-virtualenvs --no-install'." + ), + finalizer_func=_R_finalizer, + ), _option_set.Option( "noxfile", "-f", @@ -384,6 +403,18 @@ def _session_completer( action="store_true", help="Skip session.run invocations in the Noxfile.", ), + _option_set.Option( + "no_install", + "--no-install", + default=False, + group=options.groups["secondary"], + action="store_true", + help=( + "Skip invocations of session methods for installing packages" + " (session.install, session.conda_install, session.run_always)" + " when a virtualenv is being reused." + ), + ), _option_set.Option( "report", "--report", diff --git a/nox/sessions.py b/nox/sessions.py index 52f647a1..6ed894a6 100644 --- a/nox/sessions.py +++ b/nox/sessions.py @@ -272,8 +272,16 @@ def run_always( ) -> Optional[Any]: """Run a command **always**. - This is a variant of :meth:`run` that runs in all cases, including in - the presence of ``--install-only``. + This is a variant of :meth:`run` that runs even in the presence of + ``--install-only``. This method returns early if ``--no-install`` is + specified and the virtualenv is being reused. + + Here are some cases where this method is useful: + + - You need to install packages using a command other than ``pip + install`` or ``conda install``. + - You need to run a command as a prerequisite of package installation, + such as building a package or compiling a binary extension. :param env: A dictionary of environment variables to expose to the command. By default, all environment variables are passed. @@ -290,6 +298,13 @@ def run_always( do not have a virtualenv. :type external: bool """ + if ( + self._runner.global_config.no_install + and self._runner.venv is not None + and self._runner.venv._reused + ): + return None + if not args: raise ValueError("At least one argument required to run_always().") @@ -368,6 +383,9 @@ def conda_install( if not args: raise ValueError("At least one argument required to install().") + if self._runner.global_config.no_install and venv._reused: + return None + # Escape args that should be (conda-specific; pip install does not need this) args = _dblquote_pkg_install_args(args) @@ -417,8 +435,10 @@ def install(self, *args: str, **kwargs: Any) -> None: .. _pip: https://pip.readthedocs.org """ + venv = self._runner.venv + if not isinstance( - self._runner.venv, (CondaEnv, VirtualEnv, PassthroughEnv) + venv, (CondaEnv, VirtualEnv, PassthroughEnv) ): # pragma: no cover raise ValueError( "A session without a virtualenv can not install dependencies." @@ -426,6 +446,9 @@ def install(self, *args: str, **kwargs: Any) -> None: if not args: raise ValueError("At least one argument required to install().") + if self._runner.global_config.no_install and venv._reused: + return None + if "silent" not in kwargs: kwargs["silent"] = True diff --git a/nox/virtualenv.py b/nox/virtualenv.py index cd231b32..f8ff176c 100644 --- a/nox/virtualenv.py +++ b/nox/virtualenv.py @@ -54,6 +54,7 @@ class ProcessEnv: def __init__(self, bin_paths: None = None, env: Mapping[str, str] = None) -> None: self._bin_paths = bin_paths self.env = os.environ.copy() + self._reused = False if env is not None: self.env.update(env) @@ -224,6 +225,9 @@ def create(self) -> bool: logger.debug( "Re-using existing conda env at {}.".format(self.location_name) ) + + self._reused = True + return False cmd = ["conda", "create", "--yes", "--prefix", self.location] @@ -423,6 +427,9 @@ def create(self) -> bool: self.location_name ) ) + + self._reused = True + return False if self.venv_or_virtualenv == "virtualenv": diff --git a/tests/test_main.py b/tests/test_main.py index 90120c37..35395819 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -587,3 +587,12 @@ def test_main_force_python(monkeypatch): nox.__main__.main() config = execute.call_args[1]["global_config"] assert config.pythons == config.extra_pythons == ["3.10"] + + +def test_main_reuse_existing_virtualenvs_no_install(monkeypatch): + monkeypatch.setattr(sys, "argv", ["nox", "-R"]) + 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 config.reuse_existing_virtualenvs and config.no_install diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 150c15ec..93b9b155 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -310,6 +310,25 @@ def test_run_always_install_only(self, caplog): assert session.run_always(operator.add, 23, 19) == 42 + @pytest.mark.parametrize( + ("no_install", "reused", "run_called"), + [ + (True, True, False), + (True, False, True), + (False, True, True), + (False, False, True), + ], + ) + def test_run_always_no_install(self, no_install, reused, run_called): + session, runner = self.make_session_and_runner() + runner.global_config.no_install = no_install + runner.venv._reused = reused + + with mock.patch.object(nox.command, "run") as run: + session.run_always("python", "-m", "pip", "install", "requests") + + assert run.called is run_called + def test_conda_install_bad_args(self): session, runner = self.make_session_and_runner() runner.venv = mock.create_autospec(nox.virtualenv.CondaEnv) @@ -380,6 +399,31 @@ class SessionNoSlots(nox.sessions.Session): external="error", ) + @pytest.mark.parametrize( + ("no_install", "reused", "run_called"), + [ + (True, True, False), + (True, False, True), + (False, True, True), + (False, False, True), + ], + ) + def test_conda_venv_reused_with_no_install(self, no_install, reused, run_called): + session, runner = self.make_session_and_runner() + + runner.venv = mock.create_autospec(nox.virtualenv.CondaEnv) + runner.venv.location = "/path/to/conda/env" + runner.venv.env = {} + runner.venv.is_offline = lambda: True + + runner.global_config.no_install = no_install + runner.venv._reused = reused + + with mock.patch.object(nox.command, "run") as run: + session.conda_install("baked beans", "eggs", "spam") + + assert run.called is run_called + @pytest.mark.parametrize( "version_constraint", ["no", "yes", "already_dbl_quoted"], @@ -538,6 +582,25 @@ def test_skip_no_log(self): with pytest.raises(nox.sessions._SessionSkip): session.skip() + @pytest.mark.parametrize( + ("no_install", "reused", "run_called"), + [ + (True, True, False), + (True, False, True), + (False, True, True), + (False, False, True), + ], + ) + def test_session_venv_reused_with_no_install(self, no_install, reused, run_called): + session, runner = self.make_session_and_runner() + runner.global_config.no_install = no_install + runner.venv._reused = reused + + with mock.patch.object(nox.command, "run") as run: + session.install("eggs", "spam") + + assert run.called is run_called + 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 58cf0b6b..1ff8b436 100644 --- a/tests/test_virtualenv.py +++ b/tests/test_virtualenv.py @@ -180,6 +180,7 @@ def test_condaenv_create(make_conda): venv.reuse_existing = True venv.create() assert dir_.join("test.txt").check() + assert venv._reused @pytest.mark.skipif(not HAS_CONDA, reason="Missing conda command.") @@ -334,7 +335,10 @@ def test_create(monkeypatch, make_one): assert dir_.join("test.txt").check() venv.reuse_existing = True monkeypatch.setattr(nox.virtualenv.nox.command, "run", mock.MagicMock()) + venv.create() + + assert venv._reused assert dir_.join("test.txt").check()