Repository: theacodes/nox (python)

Issue:
Add an option to skip install commands
**How would this feature be useful?**
Frequently you just want to re-run a particular session in a reused virtual environment as quickly as possible (e.g for unit tests, docs or linting). This is possible with `--reuse-existing-virtualenvs`, but `session.install()` still takes a second or two, which is not a lot, but still slower than just running `pytest` directly. Therefore, it would be useful to have an option for skipping `session.install()`, especially when executing sessions locally in a development loop.

**Describe the solution you'd like**
Add an inverse of the `--install-only` command.

**Describe alternatives you've considered**
* Use [_invoke_](https://www.pyinvoke.org/) within a manually created virtual environment (this is what I've been doing).
* Just use a corresponding Python interpreter directly, e.g. `.nox/<session_name>/bin/python -m pytest`.
* Accept the 1-2 second overhead, and take your time to sip on your favourite beverage :)

Pull request:
Add option --no-install to skip install commands in reused environments
Add option `--no-install` to skip the re-installation of packages when a virtualenv is reused.

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.

Closes #394
Supercedes #411 

Co-authored-by: @jamandbees


Patch:
diff --git a/docs/usage.rst b/docs/usage.rst
index 5567551..8103cb2 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 <https://pip.pypa.io/en/stable/reference/pip_install/#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 <https://pip.pypa.io/en/stable/cli/pip_install/#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 <nox.sessions.Session.install>`
+- :func:`session.conda_install <nox.sessions.Session.conda_install>`
+- :func:`session.run_always <nox.sessions.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 4fbd8f2..770b843 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 @@ options.add_options(
         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 @@ options.add_options(
         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 52f647a..6ed894a 100644
--- a/nox/sessions.py
+++ b/nox/sessions.py
@@ -272,8 +272,16 @@ class Session:
     ) -> 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 @@ class Session:
             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 @@ class Session:
         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 @@ class Session:
 
         .. _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 @@ class Session:
         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 cd231b3..f8ff176 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 @@ class CondaEnv(ProcessEnv):
             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 @@ class VirtualEnv(ProcessEnv):
                     self.location_name
                 )
             )
+
+            self._reused = True
+
             return False
 
         if self.venv_or_virtualenv == "virtualenv":


Test patch:
diff --git a/tests/test_main.py b/tests/test_main.py
index 90120c3..3539581 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 150c15e..93b9b15 100644
--- a/tests/test_sessions.py
+++ b/tests/test_sessions.py
@@ -310,6 +310,25 @@ class TestSession:
 
         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 TestSession:
                 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 @@ class TestSession:
         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 58cf0b6..1ff8b43 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()