Repository: theacodes/nox
Pull request #153 (2018-11-02 19:05:49)

Add --install-only flag to install dependencies without running anything
The main use is for downloading dependencies for later offline use (with `--reuse-existing-virtualenvs`). A typical session where this would be useful will have the form:

```python
@nox.session
def tests(session):
    session.install(...)
    session.run("spam", ...)
```

dhermes: ISTM this could be enabled via `posargs` and some helpful documentation. I'm a bit worried about having "flag soup", i.e. too many flags makes it harder to understand the interface.
stsewd: Here are the docs about an example using posargs https://nox.thea.codes/en/stable/tutorial.html?highlight=posargs#passing-arguments-into-sessions
bmccutchon: I'm aware of the posargs solution. I'm of the opinion that this shouldn't require modifications to the noxfile to work.
theacodes: I'm wondering about thoughts on two things:

1. Would breakpointing (#143) satisfy this usecase?
2. Would inverting this to `--install-only` be a more easy to swallow proposal?
bmccutchon: It seems like breakpointing is an interactive thing, whereas I'd like this to also work in an automated test environment. If people prefer `--install-only`, I'm fine with that.
theacodes: Curious as to @lukesneeringer @stsewd and @dhermes' thoughts on the new proposal.
dhermes: I am :+1: on `--install-only` and don't think breakpointing makes much sense to solve this.
stsewd: @theacodes `--install-only` looks like a good approach.
theacodes: Cool, @bmccutchon we have consensus, do you mind updating this pull to implement `--install-only`?
bmccutchon: Will do.

On Wed, Nov 7, 2018 at 1:11 PM Thea Flowers <notifications@github.com>
wrote:

> Cool, @bmccutchon <https://github.com/bmccutchon> we have consensus, do
> you mind updating this pull to implement --install-only?
>
> —
> You are receiving this because you were mentioned.
>
>
> Reply to this email directly, view it on GitHub
> <https://github.com/theacodes/nox/pull/153#issuecomment-436778847>, or mute
> the thread
> <https://github.com/notifications/unsubscribe-auth/AIRSTdwWgdXXwnzspTnbBcGQARTqtkHCks5us0yWgaJpZM4YMNwT>
> .
>

bmccutchon: It seems like the `--install-only` flag would only skip `run` commands. In particular:

* I don't see much reason for it to skip `chdir`,
* Skipping `notify` seems bad, since the notified session could have more `install` directives,
* Skipping `log` seems undesirable and not very useful, and
* Skipping `error` or `skip` seems like a bad idea.
theacodes: seems reasonable

On Wed, Nov 7, 2018 at 3:31 PM Brian McCutchon <notifications@github.com>
wrote:

> It seems like the --install-only flag would only skip run commands. In
> particular:
>
>    - I don't see much reason for it to skip chdir,
>    - Skipping notify seems bad, since the notified session could have
>    more install directives,
>    - Skipping log seems undesirable and not very useful, and
>    - Skipping error or skip seems like a bad idea.
>
> —
> You are receiving this because you were mentioned.
>
>
> Reply to this email directly, view it on GitHub
> <https://github.com/theacodes/nox/pull/153#issuecomment-436816485>, or mute
> the thread
> <https://github.com/notifications/unsubscribe-auth/AAPUc0QoXlZSFUkI48PPGV6WjSMuoGw6ks5us206gaJpZM4YMNwT>
> .
>

bmccutchon: I made the change.
stsewd: Seems like something weird happened with the git history, there are some merged commits.
theacodes: Yeah, you may need to rebase onto my master branch and force push.
bmccutchon: The history should be fixed now.

Patch:
diff --git a/docs/usage.rst b/docs/usage.rst
index 6d51c81..08d1432 100644
--- a/docs/usage.rst
+++ b/docs/usage.rst
@@ -143,6 +143,42 @@ By default nox stores virtualenvs in ``./.nox``, however, you can change this us
     nox --envdir /tmp/.
 
 
+Skipping everything but install commands
+----------------------------------------
+
+There are a couple of cases where it makes sense to have Nox only run ``install`` commands, such as preparing an environment for offline testing or re-creating the same virtulenvs used for testing. You can use ``--install-only`` to skip ``run`` commands.
+
+For example, given this Noxfile:
+
+.. code-block:: python
+
+    @nox.session
+    def tests(session):
+        session.install("pytest")
+        session.install(".")
+        session.run("pytest")
+
+
+Running:
+
+.. code-block:: bash
+
+    nox --install-only
+
+
+Would run both ``install`` commands, but skip the ``run`` command::
+
+.. code-block:: plaintext
+
+
+    nox > Running session tests
+    nox > Creating virtualenv using python3.7 in ./.nox/tests
+    nox > pip install --upgrade pytest
+    nox > pip install --upgrade .
+    nox > Skipping pytest run, as --install-only is set.
+    nox > Session tests was successful.
+
+
 Controlling color output
 ------------------------
 
diff --git a/nox/__main__.py b/nox/__main__.py
index 041e41b..d56cc41 100644
--- a/nox/__main__.py
+++ b/nox/__main__.py
@@ -54,6 +54,7 @@ class GlobalConfig:
         self.no_error_on_missing_interpreters = args.no_error_on_missing_interpreters
         self.error_on_external_run = args.error_on_external_run
         self.no_error_on_external_run = args.no_error_on_external_run
+        self.install_only = args.install_only
         self.posargs = args.posargs
         self.report = args.report
 
@@ -205,6 +206,12 @@ def main():
         help="Disables --error-on-external-run if it is enabled in the Noxfile.",
     )
 
+    secondary.add_argument(
+        "--install-only",
+        action="store_true",
+        help="Skip session.run invocations in the Noxfile.",
+    )
+
     secondary.add_argument(
         "--report", help="Output a report of all sessions to the given filename."
     )
diff --git a/nox/_options.py b/nox/_options.py
index c22af87..bef4fc3 100644
--- a/nox/_options.py
+++ b/nox/_options.py
@@ -30,4 +30,5 @@ class options:
     stop_on_first_error = False
     error_on_missing_interpreters = False
     error_on_external_run = False
+    install_only = False
     report = None
diff --git a/nox/sessions.py b/nox/sessions.py
index a3b0c54..37ab0f3 100644
--- a/nox/sessions.py
+++ b/nox/sessions.py
@@ -83,7 +83,7 @@ class Session:
     def __dict__(self):
         """Attribute dictionary for object inspection.
 
-        This is needed because ``__slots__`` turns of ``__dict__`` by
+        This is needed because ``__slots__`` turns off ``__dict__`` by
         default. Unlike a typical object, modifying the result of this
         dictionary won't allow modification of the instance.
         """
@@ -177,6 +177,14 @@ class Session:
         if not args:
             raise ValueError("At least one argument required to run().")
 
+        if self._runner.global_config.install_only:
+            logger.info("Skipping {} run, as --install-only is set.".format(args[0]))
+            return
+
+        return self._run(*args, env=env, **kwargs)
+
+    def _run(self, *args, env=None, **kwargs):
+        """Like run(), except that it runs even if --install-only is provided."""
         # Legacy support - run a function given.
         if callable(args[0]):
             return self._run_func(args[0], args[1:], kwargs)
@@ -235,7 +243,7 @@ class Session:
         if "silent" not in kwargs:
             kwargs["silent"] = True
 
-        self.run("pip", "install", "--upgrade", *args, external="error", **kwargs)
+        self._run("pip", "install", "--upgrade", *args, external="error", **kwargs)
 
     def notify(self, target):
         """Place the given session at the end of the queue.


Test patch:
diff --git a/tests/test_main.py b/tests/test_main.py
index 42ab088..97978d0 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -48,6 +48,7 @@ class TestGlobalConfig:
             no_error_on_missing_interpreters=False,
             error_on_external_run=False,
             no_error_on_external_run=True,
+            install_only=False,
             posargs=["a", "b", "c"],
             report=None,
         )
@@ -67,6 +68,7 @@ class TestGlobalConfig:
         assert config.no_stop_on_first_error is False
         assert config.error_on_missing_interpreters is False
         assert config.no_error_on_missing_interpreters is False
+        assert config.install_only is False
         assert config.posargs == ["a", "b", "c"]
 
         args.posargs = ["--", "a", "b", "c"]
@@ -96,6 +98,7 @@ class TestGlobalConfig:
         options.reuse_existing_virtualenvs = True
         options.stop_on_first_error = True
         options.error_on_missing_interpreters = True
+        options.install_only = True
         options.report = "output.json"
 
         config.merge_from_options(options)
@@ -108,7 +111,7 @@ class TestGlobalConfig:
         assert config.error_on_missing_interpreters is True
         assert config.report == "output.json"
 
-    def test_merge_from_options_args_precendence(self):
+    def test_merge_from_options_args_precedence(self):
         args = self.make_args()
         args.sessions = ["1", "2"]
         args.no_reuse_existing_virtualenvs = True
@@ -123,6 +126,7 @@ class TestGlobalConfig:
         options.reuse_existing_virtualenvs = True
         options.stop_on_first_error = True
         options.error_on_missing_interpreters = True
+        options.install_only = True
 
         config.merge_from_options(options)
 
diff --git a/tests/test_sessions.py b/tests/test_sessions.py
index 7b4c514..58dcd53 100644
--- a/tests/test_sessions.py
+++ b/tests/test_sessions.py
@@ -31,7 +31,7 @@ import nox.virtualenv
 def test__normalize_path():
     envdir = "envdir"
     normalize = nox.sessions._normalize_path
-    assert normalize(envdir, u"hello") == os.path.join("envdir", "hello")
+    assert normalize(envdir, "hello") == os.path.join("envdir", "hello")
     assert normalize(envdir, b"hello") == os.path.join("envdir", "hello")
     assert normalize(envdir, "hello(world)") == os.path.join("envdir", "hello-world")
     assert normalize(envdir, "hello(world, meep)") == os.path.join(
@@ -63,7 +63,9 @@ class TestSession:
             signatures=["test"],
             func=func,
             global_config=argparse.Namespace(
-                posargs=mock.sentinel.posargs, error_on_external_run=False
+                posargs=mock.sentinel.posargs,
+                error_on_external_run=False,
+                install_only=False,
             ),
             manifest=mock.create_autospec(nox.manifest.Manifest),
         )
@@ -112,6 +114,34 @@ class TestSession:
         with pytest.raises(nox.command.CommandFailed):
             assert session.run(raise_value_error)
 
+    def test_run_install_only(self, caplog):
+        caplog.set_level(logging.INFO)
+        session, runner = self.make_session_and_runner()
+        runner.global_config.install_only = True
+
+        with mock.patch.object(nox.command, "run") as run:
+            session.run("spam", "eggs")
+
+        run.assert_not_called()
+
+        assert "install-only" in caplog.text
+
+    def test_run_install_only_should_install(self):
+        session, runner = self.make_session_and_runner()
+        runner.global_config.install_only = True
+
+        with mock.patch.object(nox.command, "run") as run:
+            session.install("spam")
+            session.run("spam", "eggs")
+
+        run.assert_called_once_with(
+            ("pip", "install", "--upgrade", "spam"),
+            env=mock.ANY,
+            external=mock.ANY,
+            path=mock.ANY,
+            silent=mock.ANY,
+        )
+
     def test_run_success(self):
         session, _ = self.make_session_and_runner()
         result = session.run(sys.executable, "-c", "print(123)")
@@ -187,7 +217,7 @@ class TestSession:
 
         session = SessionNoSlots(runner=runner)
 
-        with mock.patch.object(session, "run", autospec=True) as run:
+        with mock.patch.object(session, "_run", autospec=True) as run:
             session.install("requests", "urllib3")
             run.assert_called_once_with(
                 "pip",
@@ -215,7 +245,7 @@ class TestSession:
 
         session = SessionNoSlots(runner=runner)
 
-        with mock.patch.object(session, "run", autospec=True) as run:
+        with mock.patch.object(session, "_run", autospec=True) as run:
             session.install("requests", "urllib3", silent=False)
             run.assert_called_once_with(
                 "pip",