diff --git a/docs/usage.rst b/docs/usage.rst index f3bf4fd3..098609f2 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 dcdcee2b..8f66b1aa 100644 --- a/nox/_options.py +++ b/nox/_options.py @@ -327,6 +327,14 @@ def _session_completer( 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 eb6d8b5b..616de130 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 @@ 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 @@ def make_session( 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)): diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 20f27f66..a3631511 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 session_func(): 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())