Repository: theacodes/nox (python)

Issue:
Run session with an unregistered interpreter 
```python

@nox.session(python=['3.5', '3.6'])
def test():
    ...
```

```bash
$ nox -s test-3.7
nox> Warning: running session with custom interpreter
...
```

Raised in https://github.com/theacodes/nox/issues/95

Pull request description:
feat: support users specifying an undeclared parametrization of python
Closes #158 

This adds coverage for `--extra-python` which allows for adding additional pythons for running python interpreters, not specified in the noxfile.

Patch:
diff --git a/docs/usage.rst b/docs/usage.rst
index f3bf4fd..098609f 100644
--- a/docs/usage.rst
+++ b/docs/usage.rst
@@ -159,6 +159,28 @@ 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``.
 
+.. _opt-running-extra-pythons:
+
+Running additional Python versions
+----------------------------------
+In addition to Nox supporting executing single sessions, it also supports runnings python versions that aren't specified using ``--extra-pythons``.
+
+.. code-block:: console
+
+    nox --extra-pythons 3.8 3.9
+
+This will, in addition to specified python versions in the Noxfile, also create sessions for the specified versions.
+
+This option can be combined with ``--python`` to replace, instead of appending, the Python interpreter for a given session::
+
+    nox --python 3.10 --extra-python 3.10 -s lint
+
+Also, you can specify ``python`` in place of a specific version. This will run the session
+using the ``python`` specified for the current ``PATH``::
+
+    nox --python python --extra-python python -s lint
+
+
 .. _opt-stop-on-first-error:
 
 Stopping if any session fails
diff --git a/nox/_options.py b/nox/_options.py
index dcdcee2..8f66b1a 100644
--- a/nox/_options.py
+++ b/nox/_options.py
@@ -327,6 +327,14 @@ options.add_options(
         group=options.groups["secondary"],
         help="Directory where nox will store virtualenvs, this is ``.nox`` by default.",
     ),
+    _option_set.Option(
+        "extra_pythons",
+        "--extra-pythons",
+        "--extra-python",
+        group=options.groups["secondary"],
+        nargs="*",
+        help="Additionally, run sessions using the given python interpreter versions.",
+    ),
     *_option_set.make_flag_pair(
         "stop_on_first_error",
         ("-x", "--stop-on-first-error"),
diff --git a/nox/manifest.py b/nox/manifest.py
index eb6d8b5..616de13 100644
--- a/nox/manifest.py
+++ b/nox/manifest.py
@@ -15,6 +15,7 @@
 import argparse
 import collections.abc
 import itertools
+from collections import OrderedDict
 from typing import Any, Iterable, Iterator, List, Mapping, Sequence, Set, Tuple, Union
 
 from nox._decorators import Call, Func
@@ -23,6 +24,11 @@ from nox.sessions import Session, SessionRunner
 WARN_PYTHONS_IGNORED = "python_ignored"
 
 
+def _unique_list(*args: str) -> List[str]:
+    """Return a list without duplicates, while preserving order."""
+    return list(OrderedDict.fromkeys(args))
+
+
 class Manifest:
     """Session manifest.
 
@@ -184,6 +190,21 @@ class Manifest:
             func.should_warn[WARN_PYTHONS_IGNORED] = func.python
             func.python = False
 
+        if self._config.extra_pythons:
+            # If extra python is provided, expand the func.python list to
+            # include additional python interpreters
+            extra_pythons = self._config.extra_pythons  # type: List[str]
+            if isinstance(func.python, (list, tuple, set)):
+                func.python = _unique_list(*func.python, *extra_pythons)
+            elif not multi and func.python:
+                # If this is multi, but there is only a single interpreter, it
+                # is the reentrant case. The extra_python interpreter shouldn't
+                # be added in that case. If func.python is False, the session
+                # has no backend; if None, it uses the same interpreter as Nox.
+                # Otherwise, add the extra specified python.
+                assert isinstance(func.python, str)
+                func.python = _unique_list(func.python, *extra_pythons)
+
         # If the func has the python attribute set to a list, we'll need
         # to expand them.
         if isinstance(func.python, (list, tuple, set)):


Test patch:
diff --git a/tests/test_manifest.py b/tests/test_manifest.py
index 20f27f6..a363151 100644
--- a/tests/test_manifest.py
+++ b/tests/test_manifest.py
@@ -37,6 +37,7 @@ def create_mock_config():
     cfg = mock.sentinel.CONFIG
     cfg.force_venv_backend = None
     cfg.default_venv_backend = None
+    cfg.extra_pythons = None
     return cfg
 
 
@@ -203,6 +204,42 @@ def test_add_session_multiple_pythons():
     assert len(manifest) == 2
 
 
+@pytest.mark.parametrize(
+    "python,extra_pythons,expected",
+    [
+        (None, [], [None]),
+        (None, ["3.8"], [None]),
+        (None, ["3.8", "3.9"], [None]),
+        (False, [], [False]),
+        (False, ["3.8"], [False]),
+        (False, ["3.8", "3.9"], [False]),
+        ("3.5", [], ["3.5"]),
+        ("3.5", ["3.8"], ["3.5", "3.8"]),
+        ("3.5", ["3.8", "3.9"], ["3.5", "3.8", "3.9"]),
+        (["3.5", "3.9"], [], ["3.5", "3.9"]),
+        (["3.5", "3.9"], ["3.8"], ["3.5", "3.9", "3.8"]),
+        (["3.5", "3.9"], ["3.8", "3.4"], ["3.5", "3.9", "3.8", "3.4"]),
+        (["3.5", "3.9"], ["3.5", "3.9"], ["3.5", "3.9"]),
+    ],
+)
+def test_extra_pythons(python, extra_pythons, expected):
+    cfg = mock.sentinel.CONFIG
+    cfg.force_venv_backend = None
+    cfg.default_venv_backend = None
+    cfg.extra_pythons = extra_pythons
+
+    manifest = Manifest({}, cfg)
+
+    def session_func():
+        pass
+
+    func = Func(session_func, python=python)
+    for session in manifest.make_session("my_session", func):
+        manifest.add_session(session)
+
+    assert expected == [session.func.python for session in manifest._all_sessions]
+
+
 def test_add_session_parametrized():
     manifest = Manifest({}, create_mock_config())