Repository: theacodes/nox
Pull request #304 (2020-03-28 13:18:24)

Add ability to specify which Python versions to run with from the command line
Implements #95 

theacodes: This looks great! I'm just waiting for Travis to finish running and I'll merge if all checks out. :)
theacodes: Ah it passed, it just didn't get reported: https://travis-ci.org/github/theacodes/nox/builds/668067405

Patch:
diff --git a/.gitignore b/.gitignore
index 93a7840..98979c4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -73,3 +73,6 @@ target/
 
 # pyenv
 .python-version
+
+# PyCharm
+.idea/
diff --git a/docs/config.rst b/docs/config.rst
index 4bf4390..e2a8338 100644
--- a/docs/config.rst
+++ b/docs/config.rst
@@ -274,7 +274,7 @@ If you only want to run one of the parametrized sessions, see :ref:`running_para
 Giving friendly names to parametrized sessions
 ----------------------------------------------
 
-The automatically generated names for parametrized sessions, such as ``tests(django='1.9', database='postgres')``, can be long and unwieldy to work with even with using :ref:`keyword filtering <opt-sessions-and-keywords>`. You can give parametrized sessions custom IDs to help in this scenario. These two examples are equivalent:
+The automatically generated names for parametrized sessions, such as ``tests(django='1.9', database='postgres')``, can be long and unwieldy to work with even with using :ref:`keyword filtering <opt-sessions-pythons-and-keywords>`. You can give parametrized sessions custom IDs to help in this scenario. These two examples are equivalent:
 
 .. code-block:: python
 
@@ -372,8 +372,9 @@ Or, if you wanted to provide a set of sessions that are run by default:
 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.sessions`` is equivalent to specifying :ref:`-s or --sessions <opt-sessions-pythons-and-keywords>`.
+* ``nox.options.pythons`` is equivalent to specifying :ref:`-p or --pythons <opt-sessions-pythons-and-keywords>`.
+* ``nox.options.keywords`` is equivalent to specifying :ref:`-k or --keywords <opt-sessions-pythons-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.
diff --git a/docs/usage.rst b/docs/usage.rst
index edb6d39..6f0e341 100644
--- a/docs/usage.rst
+++ b/docs/usage.rst
@@ -45,7 +45,7 @@ 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:
+.. _opt-sessions-pythons-and-keywords:
 
 Specifying one or more sessions
 -------------------------------
@@ -67,6 +67,13 @@ You can also use the ``NOXSESSION`` environment variable:
 
 Nox will run these sessions in the same order they are specified.
 
+If you have a :ref:`configured session's virtualenv <virtualenv config>`, you can choose to run only sessions with given Python versions:
+
+.. code-block:: console
+
+    nox --python 3.8
+    nox -p 3.7 3.8
+
 You can also use `pytest-style keywords`_ to filter test sessions:
 
 .. code-block:: console
diff --git a/nox/_options.py b/nox/_options.py
index c7ebd4d..168890a 100644
--- a/nox/_options.py
+++ b/nox/_options.py
@@ -39,21 +39,21 @@ options.add_group(
 )
 
 
-def _sessions_and_keywords_merge_func(
+def _session_filters_merge_func(
     key: str, command_args: argparse.Namespace, noxfile_args: argparse.Namespace
 ) -> List[str]:
-    """Only return the Noxfile value for sessions/keywords if neither sessions
-    or keywords are specified on the command-line.
+    """Only return the Noxfile value for sessions/pythons/keywords if neither sessions,
+    pythons or keywords are specified on the command-line.
 
     Args:
-        key (str): This function is used for both the "sessions" and "keywords"
+        key (str): This function is used for the "sessions", "pythons" and "keywords"
             options, this allows using ``funtools.partial`` to pass the
             same function for both options.
         command_args (_option_set.Namespace): The options specified on the
             command-line.
-        noxfile_Args (_option_set.Namespace): The options specified in the
+        noxfile_args (_option_set.Namespace): The options specified in the
             Noxfile."""
-    if not command_args.sessions and not command_args.keywords:
+    if not any((command_args.sessions, command_args.pythons, command_args.keywords)):
         return getattr(noxfile_args, key)
     return getattr(command_args, key)
 
@@ -176,18 +176,29 @@ options.add_options(
         "--session",
         group="primary",
         noxfile=True,
-        merge_func=functools.partial(_sessions_and_keywords_merge_func, "sessions"),
+        merge_func=functools.partial(_session_filters_merge_func, "sessions"),
         nargs="*",
         default=_sessions_default,
         help="Which sessions to run. By default, all sessions will run.",
         completer=_session_completer,
     ),
+    _option_set.Option(
+        "pythons",
+        "-p",
+        "--pythons",
+        "--python",
+        group="primary",
+        noxfile=True,
+        merge_func=functools.partial(_session_filters_merge_func, "pythons"),
+        nargs="*",
+        help="Only run sessions that use the given python interpreter versions.",
+    ),
     _option_set.Option(
         "keywords",
         "-k",
         "--keywords",
         noxfile=True,
-        merge_func=functools.partial(_sessions_and_keywords_merge_func, "keywords"),
+        merge_func=functools.partial(_session_filters_merge_func, "keywords"),
         help="Only run sessions that match the given expression.",
     ),
     _option_set.Option(
diff --git a/nox/manifest.py b/nox/manifest.py
index 16ce01a..d90ba34 100644
--- a/nox/manifest.py
+++ b/nox/manifest.py
@@ -15,7 +15,7 @@
 import argparse
 import collections.abc
 import itertools
-from typing import Any, Iterable, Iterator, List, Mapping, Set, Tuple, Union
+from typing import Any, Iterable, Iterator, List, Mapping, Sequence, Set, Tuple, Union
 
 from nox._decorators import Call, Func
 from nox.sessions import Session, SessionRunner
@@ -132,6 +132,16 @@ class Manifest:
         if missing_sessions:
             raise KeyError("Sessions not found: {}".format(", ".join(missing_sessions)))
 
+    def filter_by_python_interpreter(self, specified_pythons: Sequence[str]) -> None:
+        """Filter sessions in the queue based on the user-specified
+        python interpreter versions.
+
+        Args:
+            specified_pythons (Sequence[str]): A list of specified
+                python interpreter versions.
+        """
+        self._queue = [x for x in self._queue if x.func.python in specified_pythons]
+
     def filter_by_keywords(self, keywords: str) -> None:
         """Filter sessions using pytest-like keyword expressions.
 
diff --git a/nox/tasks.py b/nox/tasks.py
index 41f1fce..3cbbf3e 100644
--- a/nox/tasks.py
+++ b/nox/tasks.py
@@ -125,6 +125,12 @@ def filter_manifest(
             logger.error(exc.args[0])
             return 3
 
+    # Filter by python interpreter versions.
+    # This function never errors, but may cause an empty list of sessions
+    # (which is an error condition later).
+    if global_config.pythons:
+        manifest.filter_by_python_interpreter(global_config.pythons)
+
     # Filter by keywords.
     # This function never errors, but may cause an empty list of sessions
     # (which is an error condition later).


Test patch:
diff --git a/tests/test_manifest.py b/tests/test_manifest.py
index cd29a5e..9864191 100644
--- a/tests/test_manifest.py
+++ b/tests/test_manifest.py
@@ -137,6 +137,16 @@ def test_filter_by_name_not_found():
         manifest.filter_by_name(("baz",))
 
 
+def test_filter_by_python_interpreter():
+    sessions = create_mock_sessions()
+    manifest = Manifest(sessions, mock.sentinel.CONFIG)
+    manifest["foo"].func.python = "3.8"
+    manifest["bar"].func.python = "3.7"
+    manifest.filter_by_python_interpreter(("3.8",))
+    assert "foo" in manifest
+    assert "bar" not in manifest
+
+
 def test_filter_by_keyword():
     sessions = create_mock_sessions()
     manifest = Manifest(sessions, mock.sentinel.CONFIG)
diff --git a/tests/test_tasks.py b/tests/test_tasks.py
index fbff29b..e914313 100644
--- a/tests/test_tasks.py
+++ b/tests/test_tasks.py
@@ -35,6 +35,13 @@ def session_func():
 session_func.python = None
 
 
+def session_func_with_python():
+    pass
+
+
+session_func_with_python.python = "3.8"
+
+
 def test_load_nox_module():
     config = _options.options.namespace(noxfile=os.path.join(RESOURCES, "noxfile.py"))
     noxfile_module = tasks.load_nox_module(config)
@@ -91,7 +98,7 @@ def test_discover_session_functions_decorator():
 
 
 def test_filter_manifest():
-    config = _options.options.namespace(sessions=(), keywords=())
+    config = _options.options.namespace(sessions=(), pythons=(), keywords=())
     manifest = Manifest({"foo": session_func, "bar": session_func}, config)
     return_value = tasks.filter_manifest(manifest, config)
     assert return_value is manifest
@@ -99,14 +106,25 @@ def test_filter_manifest():
 
 
 def test_filter_manifest_not_found():
-    config = _options.options.namespace(sessions=("baz",), keywords=())
+    config = _options.options.namespace(sessions=("baz",), pythons=(), keywords=())
     manifest = Manifest({"foo": session_func, "bar": session_func}, config)
     return_value = tasks.filter_manifest(manifest, config)
     assert return_value == 3
 
 
+def test_filter_manifest_pythons():
+    config = _options.options.namespace(sessions=(), pythons=("3.8",), keywords=())
+    manifest = Manifest(
+        {"foo": session_func_with_python, "bar": session_func, "baz": session_func},
+        config,
+    )
+    return_value = tasks.filter_manifest(manifest, config)
+    assert return_value is manifest
+    assert len(manifest) == 1
+
+
 def test_filter_manifest_keywords():
-    config = _options.options.namespace(sessions=(), keywords="foo or bar")
+    config = _options.options.namespace(sessions=(), pythons=(), keywords="foo or bar")
     manifest = Manifest(
         {"foo": session_func, "bar": session_func, "baz": session_func}, config
     )