Repository: theacodes/nox (python)

Issue:
Allow specifying command-line parameters in `noxfile`
Related #133, #40, #135.

Noxfiles should be able to specify *some* of the command-line args in the noxfile, for example:

```python
import nox

nox.options.envdir = ".cache"
nox.options.sessions = ["tests", "lint"]

...
```

Command-line args should *override* these "defaults" in the noxfile.

Pull request description:
 Add nox.options which allows specifying command-line configuration in the Noxfile
Closes #141

@dhermes, @lukesneeringer I would appreciate a critical eye here. I'm not 100% sold on the way I did this internally.

Patch:
diff --git a/docs/config.rst b/docs/config.rst
index 7e1dc8c..b37b56e 100644
--- a/docs/config.rst
+++ b/docs/config.rst
@@ -110,3 +110,42 @@ Nox will call your session functions with a :class:`Session` object. You use thi
 .. autoclass:: Session
     :members:
     :undoc-members:
+
+
+Modifying Nox's behavior in the Noxfile
+---------------------------------------
+
+Nox has various :doc:`command line arguments <usage>` that can be used to modify its behavior. Some of these can also be specified in the Noxfile using ``nox.options``. For example, if you wanted to store Nox's virtualenvs in a different directory without needing to pass it into ``nox`` every time:
+
+.. code-block:: python
+
+    import nox
+
+    nox.options.envdir = ".cache"
+
+    @nox.session
+    def tests(session):
+        ...
+
+Or, if you wanted to provide a set of sessions that are run by default:
+
+.. code-block:: python
+
+    import nox
+
+    nox.options.sessions = ["lint", "tests-3.6"]
+
+    ...
+
+The following options can be specified in the Noxfile:
+
+* ``nox.options.envdir`` is equivalent to specifying :ref:`--envdir <opt-envdir>`.
+* ``nox.options.sessions`` is equivalent to specifying :ref:`-s or --sessions <opt-sessions-and-keywords>`.
+* ``nox.options.keywords`` is equivalent to specifying :ref:`-k or --keywords <opt-sessions-and-keywords>`.
+* ``nox.options.reuse_existing_virtualenvs`` is equivalent to specifying :ref:`--reuse-existing-virtualenvs <opt-reuse-existing-virtualenvs>`. You can force this off by specifying ``--no-reuse-existing-virtualenvs`` during invocation.
+* ``nox.options.stop_on_first_error`` is equivalent to specifying :ref:`--stop-on-first-error <opt-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 <opt-error-on-missing-interpreters>`. You can force this off by specifying ``--no-error-on-missing-interpreters`` during invocation.
+* ``nox.options.report`` is equivalent to specifying :ref:`--report <opt-report>`.
+
+
+When invoking ``nox``, any options specified on the command line take precedence over the options specified in the Noxfile. If either ``--sessions`` or ``--keywords`` is specified on the command line, *both* options specified in the Noxfile will be ignored.
diff --git a/docs/usage.rst b/docs/usage.rst
index e71c462..cb90c35 100644
--- a/docs/usage.rst
+++ b/docs/usage.rst
@@ -35,6 +35,8 @@ You can run every session by just executing `nox` without any arguments::
 The order that sessions are executed is the order that they appear in the Noxfile.
 
 
+.. _opt-sessions-and-keywords:
+
 Specifying one or more sessions
 -------------------------------
 
@@ -76,6 +78,8 @@ Then running ``nox --session tests`` will actually run all parametrized versions
     nox --session "tests(django='2.0')"
 
 
+.. _opt-reuse-existing-virtualenvs:
+
 Re-using virtualenvs
 --------------------
 
@@ -85,6 +89,10 @@ By default nox deletes and recreates virtualenvs every time it is run. This is u
     nox --reuse-existing-virtualenvs
 
 
+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``.
+
+.. _opt-stop-on-first-error:
+
 Stopping if any session fails
 -----------------------------
 
@@ -92,6 +100,9 @@ By default nox will continue to run all sessions even if one fails. You can use
 
     nox --stop-on-first-error
 
+If the Noxfile sets ``nox.options.stop_on_first_error``, you can override the Noxfile setting from the command line by using ``--no-stop-on-first-error``.
+
+.. _opt-error-on-missing-interpreters:
 
 Failing sessions when the interpreter is missing
 ------------------------------------------------
@@ -100,6 +111,7 @@ By default, Nox will skip sessions where the Python interpreter can't be found.
 
     nox --error-on-missing-interpreters
 
+If the Noxfile sets ``nox.options.error_on_missing_interpreters``, you can override the Noxfile setting from the command line by using ``--no-error-on-missing-interpreters``.
 
 Specifying a different configuration file
 -----------------------------------------
@@ -110,6 +122,8 @@ If for some reason your noxfile is not named *noxfile.py*, you can use ``--noxfi
     nox -f something.py
 
 
+.. _opt-envdir:
+
 Storing virtualenvs in a different directory
 --------------------------------------------
 
@@ -136,6 +150,16 @@ However, this will never output colorful logs::
     nox --nocolor
 
 
+.. _opt-report:
+
+Outputting a machine-readable report
+------------------------------------
+
+You can output a report in ``json`` format by specifying ``--report``::
+
+    nox --report status.json
+
+
 Windows
 -------
 
diff --git a/nox/__init__.py b/nox/__init__.py
index 1e1c238..6b01b75 100644
--- a/nox/__init__.py
+++ b/nox/__init__.py
@@ -12,7 +12,8 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+from nox._options import options
 from nox._parametrize import parametrize_decorator as parametrize
 from nox.registry import session_decorator as session
 
-__all__ = ["parametrize", "session"]
+__all__ = ["parametrize", "session", "options"]
diff --git a/nox/__main__.py b/nox/__main__.py
index a2fff43..a4f99a2 100644
--- a/nox/__main__.py
+++ b/nox/__main__.py
@@ -30,22 +30,67 @@ from nox import workflow
 from nox.logger import setup_logging
 
 
+def _default_with_off_flag(current, default, off_flag):
+    """Helper method for merging command line args and noxfile config.
+
+    Returns False if off_flag is set, otherwise, returns the default value if
+    set, otherwise, returns the current value.
+    """
+    return (default or current) and not off_flag
+
+
 class GlobalConfig:
     def __init__(self, args):
         self.noxfile = args.noxfile
-        self.envdir = os.path.abspath(args.envdir)
+        self.envdir = args.envdir
         self.sessions = args.sessions
         self.keywords = args.keywords
         self.list_sessions = args.list_sessions
         self.reuse_existing_virtualenvs = args.reuse_existing_virtualenvs
+        self.no_reuse_existing_virtualenvs = args.no_reuse_existing_virtualenvs
         self.stop_on_first_error = args.stop_on_first_error
+        self.no_stop_on_first_error = args.no_stop_on_first_error
         self.error_on_missing_interpreters = args.error_on_missing_interpreters
+        self.no_error_on_missing_interpreters = args.no_error_on_missing_interpreters
         self.posargs = args.posargs
         self.report = args.report
 
         if self.posargs and self.posargs[0] == "--":
             self.posargs.pop(0)
 
+    def merge_from_options(self, options):
+        """Update the config from the Noxfile-specified options.
+
+        The options function as "defaults" for the most part, with some small
+        caveats documented in the body of the function.
+
+        Args:
+            options (nox._options.options): The options set in the Noxfile.
+        """
+        # If *either* sessions or keywords are specified on the command line,
+        # ignore *both* sessions and keywords in Noxfile.
+        if not self.sessions and not self.keywords:
+            self.sessions = options.sessions
+            self.keywords = options.keywords
+
+        self.envdir = self.envdir or options.envdir or ".nox"
+        self.reuse_existing_virtualenvs = _default_with_off_flag(
+            self.reuse_existing_virtualenvs,
+            options.reuse_existing_virtualenvs,
+            self.no_reuse_existing_virtualenvs,
+        )
+        self.stop_on_first_error = _default_with_off_flag(
+            self.stop_on_first_error,
+            options.stop_on_first_error,
+            self.no_stop_on_first_error,
+        )
+        self.error_on_missing_interpreters = _default_with_off_flag(
+            self.error_on_missing_interpreters,
+            options.error_on_missing_interpreters,
+            self.no_error_on_missing_interpreters,
+        )
+        self.report = self.report or options.report
+
 
 def main():
     parser = argparse.ArgumentParser(
@@ -101,6 +146,11 @@ def main():
         action="store_true",
         help="Re-use existing virtualenvs instead of recreating them.",
     )
+    secondary.add_argument(
+        "--no-reuse-existing-virtualenvs",
+        action="store_true",
+        help="Disables --reuse-existing-virtualenvs if it is enabled in the Noxfile.",
+    )
 
     secondary.add_argument(
         "-f",
@@ -110,7 +160,8 @@ def main():
     )
 
     secondary.add_argument(
-        "--envdir", default=".nox", help="Directory where nox will store virtualenvs."
+        "--envdir",
+        help="Directory where nox will store virtualenvs, this is .nox by default.",
     )
 
     secondary.add_argument(
@@ -119,15 +170,25 @@ def main():
         action="store_true",
         help="Stop after the first error.",
     )
+    secondary.add_argument(
+        "--no-stop-on-first-error",
+        action="store_true",
+        help="Disables --stop-on-first-error if it is enabled in the Noxfile.",
+    )
 
     secondary.add_argument(
         "--error-on-missing-interpreters",
         action="store_true",
         help="Error instead of skip if an interpreter can not be located.",
     )
+    secondary.add_argument(
+        "--no-error-on-missing-interpreters",
+        action="store_true",
+        help="Disables --error-on-missing-interpreters if it is enabled in the Noxfile.",
+    )
 
     secondary.add_argument(
-        "--report", default=None, help="Output a report of all sessions."
+        "--report", help="Output a report of all sessions to the given filename."
     )
 
     secondary.add_argument(
@@ -141,7 +202,7 @@ def main():
         "--forcecolor",
         default=False,
         action="store_true",
-        help=("Force color output, even if stdout is not an interactive " "terminal."),
+        help="Force color output, even if stdout is not an interactive terminal.",
     )
 
     args = parser.parse_args()
@@ -163,6 +224,7 @@ def main():
         global_config=global_config,
         workflow=(
             tasks.load_nox_module,
+            tasks.merge_noxfile_options,
             tasks.discover_manifest,
             tasks.filter_manifest,
             tasks.honor_list_request,
diff --git a/nox/_options.py b/nox/_options.py
new file mode 100644
index 0000000..7856fa8
--- /dev/null
+++ b/nox/_options.py
@@ -0,0 +1,32 @@
+# Copyright 2018 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.
+
+
+class options:
+    """Options that are configurable in the Noxfile.
+
+    By setting properties on ``nox.options`` you can specify command line
+    arguments in your Noxfile. If an argument is specified in both the Noxfile
+    and on the command line, the command line arguments take precedence.
+
+    See :doc:`usage` for more details on these settings and their effect.
+    """
+
+    sessions = None
+    keywords = None
+    envdir = None
+    reuse_existing_virtualenvs = False
+    stop_on_first_error = False
+    error_on_missing_interpreters = False
+    report = None
diff --git a/nox/tasks.py b/nox/tasks.py
index c213c53..ab38fc6 100644
--- a/nox/tasks.py
+++ b/nox/tasks.py
@@ -17,6 +17,7 @@ import io
 import json
 import os
 
+from nox import _options
 from nox import registry
 from nox.logger import logger
 from nox.manifest import Manifest
@@ -53,6 +54,18 @@ def load_nox_module(global_config):
         return 2
 
 
+def merge_noxfile_options(module, global_config):
+    """Merges any modifications made to ``nox.options`` by the Noxfile module
+    into global_config.
+
+    Args:
+        module (module): The Noxfile module.
+        global_config (~nox.main.GlobalConfig): The global configuration.
+    """
+    global_config.merge_from_options(_options.options)
+    return module
+
+
 def discover_manifest(module, global_config):
     """Discover all session functions in the noxfile module.
 


Test patch:
diff --git a/tests/test_main.py b/tests/test_main.py
index 9e901d0..9283622 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -23,6 +23,7 @@ import pytest
 
 import nox
 import nox.__main__
+import nox._options
 import nox.registry
 import nox.sessions
 
@@ -31,35 +32,99 @@ RESOURCES = os.path.join(os.path.dirname(__file__), "resources")
 VERSION = pkg_resources.get_distribution("nox").version
 
 
-def test_global_config_constructor():
-    args = argparse.Namespace(
-        noxfile="noxfile",
-        envdir="dir",
-        sessions=["1", "2"],
-        keywords="red and blue",
-        list_sessions=False,
-        reuse_existing_virtualenvs=True,
-        stop_on_first_error=False,
-        error_on_missing_interpreters=True,
-        posargs=["a", "b", "c"],
-        report=None,
-    )
-
-    config = nox.__main__.GlobalConfig(args)
-
-    assert config.noxfile == "noxfile"
-    assert config.envdir == os.path.abspath("dir")
-    assert config.sessions == ["1", "2"]
-    assert config.keywords == "red and blue"
-    assert config.list_sessions is False
-    assert config.reuse_existing_virtualenvs is True
-    assert config.stop_on_first_error is False
-    assert config.error_on_missing_interpreters is True
-    assert config.posargs == ["a", "b", "c"]
-
-    args.posargs = ["--", "a", "b", "c"]
-    config = nox.__main__.GlobalConfig(args)
-    assert config.posargs == ["a", "b", "c"]
+class TestGlobalConfig:
+    def make_args(self):
+        return argparse.Namespace(
+            noxfile="noxfile",
+            envdir="dir",
+            sessions=["1", "2"],
+            keywords="red and blue",
+            list_sessions=False,
+            reuse_existing_virtualenvs=False,
+            no_reuse_existing_virtualenvs=False,
+            stop_on_first_error=False,
+            no_stop_on_first_error=False,
+            error_on_missing_interpreters=False,
+            no_error_on_missing_interpreters=False,
+            posargs=["a", "b", "c"],
+            report=None,
+        )
+
+    def test_constructor(self):
+        args = self.make_args()
+        config = nox.__main__.GlobalConfig(args)
+
+        assert config.noxfile == "noxfile"
+        assert config.envdir == "dir"
+        assert config.sessions == ["1", "2"]
+        assert config.keywords == "red and blue"
+        assert config.list_sessions is False
+        assert config.reuse_existing_virtualenvs is False
+        assert config.no_reuse_existing_virtualenvs is False
+        assert config.stop_on_first_error is False
+        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.posargs == ["a", "b", "c"]
+
+        args.posargs = ["--", "a", "b", "c"]
+        config = nox.__main__.GlobalConfig(args)
+        assert config.posargs == ["a", "b", "c"]
+
+    def test_merge_from_options_no_changes(self):
+        args = self.make_args()
+        config = nox.__main__.GlobalConfig(args)
+        original_values = vars(config).copy()
+        options = nox._options.options()
+
+        config.merge_from_options(options)
+
+        assert vars(config) == original_values
+
+    def test_merge_from_options_options_by_default(self):
+        args = self.make_args()
+        args.sessions = None
+        args.keywords = None
+        config = nox.__main__.GlobalConfig(args)
+        original_values = vars(config).copy()
+
+        options = nox._options.options()
+        options.sessions = ["1", "2"]
+        options.keywords = "one"
+        options.reuse_existing_virtualenvs = True
+        options.stop_on_first_error = True
+        options.error_on_missing_interpreters = True
+        options.report = "output.json"
+
+        config.merge_from_options(options)
+
+        assert vars(config) != original_values
+        assert config.sessions == ["1", "2"]
+        assert config.keywords == "one"
+        assert config.reuse_existing_virtualenvs is True
+        assert config.stop_on_first_error is True
+        assert config.error_on_missing_interpreters is True
+        assert config.report == "output.json"
+
+    def test_merge_from_options_args_precendence(self):
+        args = self.make_args()
+        args.sessions = ["1", "2"]
+        args.no_reuse_existing_virtualenvs = True
+        args.no_stop_on_first_error = True
+        args.no_error_on_missing_interpreters = True
+        args.report = "output.json"
+        config = nox.__main__.GlobalConfig(args)
+        original_values = vars(config).copy()
+
+        options = nox._options.options()
+        options.keywords = "one"
+        options.reuse_existing_virtualenvs = True
+        options.stop_on_first_error = True
+        options.error_on_missing_interpreters = True
+
+        config.merge_from_options(options)
+
+        assert vars(config) == original_values
 
 
 def test_main_no_args(monkeypatch):
@@ -78,7 +143,6 @@ def test_main_no_args(monkeypatch):
         # Verify that the config looks correct.
         config = execute.call_args[1]["global_config"]
         assert config.noxfile == "noxfile.py"
-        assert config.envdir.endswith(".nox")
         assert config.sessions is None
         assert config.reuse_existing_virtualenvs is False
         assert config.stop_on_first_error is False