Repository: wntrblm/nox (python)

Issue:
Allow running a session with a different Python interpreter
### How would this feature be useful?

I normally run `nox -s test` to run my tests using the default Python version (the one nox is installed under, I guess?) This is my most common activity, and I'd like it to be straightforward. However, I sometimes want to run the tests for a specific Python version. I can do this by parametrising the Python version for the test session, but if I do that, `nox -s test` now means "run the test session for *all* Python versions.

Furthermore, I don't want to have to hard-code the list of Python versions. Doing that means I need to update the noxfile every time a new Python version comes out.

### Describe the solution you'd like

If a session does not have the Python version parametrised, `nox -s session --use-python 3.9` would run the given session with Python 3.9. Having `-P` as a short form for `--use-python` would be useful.

### Describe alternatives you've considered

* Using `@session(python=None, "3.8", "3.9", "3.10")` looks like it might work, as it creates a session with no suffix. But that still runs all of the suffixed sessions, rather than just running the one session.
* `--force-pythons` sounds like it would work, but it doesn't, it just says "Python version selection caused no sessions to be selected".

### Anything else?

_No response_

Pull request description:
Allow `--force-python` on unparametrized sessions
Also adds the shorthand `-P` for the `--force-python` option.

Closes #623 

Patch:
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 10cbe2a..5ba9d16 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -5,3 +5,7 @@ updates:
     directory: "/"
     schedule:
       interval: weekly
+    groups:
+      actions:
+        patterns:
+          - "*"
diff --git a/.github/workflows/action.yml b/.github/workflows/action.yml
index a7d9827..65b15a6 100644
--- a/.github/workflows/action.yml
+++ b/.github/workflows/action.yml
@@ -9,6 +9,8 @@ on:
       - ".github/workflows/action.yml"
       - ".github/action_helper.py"
       - "action.yml"
+  workflow_dispatch:
+    # allow manual runs on branches without a PR
 env:
   FORCE_COLOR: "1"
 jobs:
@@ -34,10 +36,10 @@ jobs:
     runs-on: windows-latest
     steps:
       - uses: actions/checkout@v4
-      - uses: actions/setup-python@v2
+      - uses: actions/setup-python@v5
         with:
           python-version: 3.9
       - uses: ./
         with:
-          python-versions: "3.4, 3.5, 3.6, 3.7, 3.8, 3.9, 3.10, 3.11, 3.12, pypy-2.7, pypy-3.7, pypy-3.8, pypy-3.9-v7.3.9"
+          python-versions: "3.4, 3.5, 3.6, 3.7, 3.8, 3.9, 3.10, 3.11, 3.12, pypy-2.7, pypy-3.7, pypy-3.8, pypy-3.9, pypy-3.10"
       - run: nox --non-interactive --error-on-missing-interpreter --session github_actions_all_tests
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5be101a..86a17a3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -3,6 +3,8 @@ on:
   push:
     branches: [main]
   pull_request:
+  workflow_dispatch:
+    # allow manual runs on branches without a PR
 
 env:
   FORCE_COLOR: "1"
@@ -21,11 +23,11 @@ jobs:
     steps:
       - uses: actions/checkout@v4
       - name: Set up Python ${{ matrix.python-version }}
-        uses: actions/setup-python@v4
+        uses: actions/setup-python@v5
         with:
           python-version: ${{ matrix.python-version }}
       - name: Set up Miniconda
-        uses: conda-incubator/setup-miniconda@v2
+        uses: conda-incubator/setup-miniconda@v3
         with:
           auto-update-conda: true
           python-version: ${{ matrix.python-version }}
@@ -42,7 +44,7 @@ jobs:
     steps:
       - uses: actions/checkout@v4
       - name: Set up Python 3.9
-        uses: actions/setup-python@v4
+        uses: actions/setup-python@v5
         with:
           python-version: 3.9
       - name: Install Nox-under-test
@@ -55,7 +57,7 @@ jobs:
     steps:
       - uses: actions/checkout@v4
       - name: Set up Python 3.9
-        uses: actions/setup-python@v4
+        uses: actions/setup-python@v5
         with:
           python-version: 3.9
       - name: Install Nox-under-test
diff --git a/action.yml b/action.yml
index 6b23016..f0392ba 100644
--- a/action.yml
+++ b/action.yml
@@ -4,7 +4,7 @@ inputs:
   python-versions:
     description: "comma-separated list of python versions to install"
     required: false
-    default: "3.7, 3.8, 3.9, 3.10, 3.11, 3.12, pypy-3.7, pypy-3.8, pypy-3.9"
+    default: "3.8, 3.9, 3.10, 3.11, 3.12, pypy-3.9, pypy-3.10"
 branding:
   icon: package
   color: blue
@@ -12,7 +12,7 @@ branding:
 runs:
   using: composite
   steps:
-    - uses: actions/setup-python@v4
+    - uses: actions/setup-python@v5
       id: localpython
       with:
         python-version: "3.7 - 3.12"
@@ -24,7 +24,7 @@ runs:
         '${{ steps.localpython.outputs.python-path }}' '${{ github.action_path }}/.github/action_helper.py' '${{ inputs.python-versions }}' >>${GITHUB_OUTPUT}
       shell: bash
 
-    - uses: actions/setup-python@v4
+    - uses: actions/setup-python@v5
       id: allpython
       with:
         python-version: "${{ join(fromJSON(steps.helper.outputs.interpreters), '\n') }}"
diff --git a/nox/_options.py b/nox/_options.py
index 24987b4..e23cfe7 100644
--- a/nox/_options.py
+++ b/nox/_options.py
@@ -433,6 +433,7 @@ options.add_options(
     ),
     _option_set.Option(
         "force_pythons",
+        "-P",
         "--force-pythons",
         "--force-python",
         group=options.groups["python"],
@@ -441,6 +442,7 @@ options.add_options(
         help=(
             "Run sessions with the given interpreters instead of those listed in the"
             " Noxfile. This is a shorthand for ``--python=X.Y --extra-python=X.Y``."
+            " It will also work on sessions that don't have any interpreter parametrized."
         ),
         finalizer_func=_force_pythons_finalizer,
     ),
diff --git a/nox/_parametrize.py b/nox/_parametrize.py
index 8b5d55c..ad83b53 100644
--- a/nox/_parametrize.py
+++ b/nox/_parametrize.py
@@ -78,7 +78,7 @@ class Param:
         elif isinstance(other, dict):
             return dict(zip(self.arg_names, self.args)) == other
 
-        raise NotImplementedError
+        return NotImplemented
 
 
 def _apply_param_specs(param_specs: Iterable[Param], f: Any) -> Any:
diff --git a/nox/manifest.py b/nox/manifest.py
index 1eff952..fa9f797 100644
--- a/nox/manifest.py
+++ b/nox/manifest.py
@@ -230,6 +230,10 @@ class Manifest:
                 # Otherwise, add the extra specified python.
                 assert isinstance(func.python, str)
                 func.python = _unique_list(func.python, *extra_pythons)
+            elif not func.python and self._config.force_pythons:
+                # If a python is forced by the user, but the underlying function
+                # has no version parametrised, add it as sole occupant to func.python
+                func.python = _unique_list(*extra_pythons)
 
         # If the func has the python attribute set to a list, we'll need
         # to expand them.
diff --git a/noxfile.py b/noxfile.py
index 3ebed4b..7fac569 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -132,15 +132,13 @@ def _check_python_version(session: nox.Session) -> None:
 
 @nox.session(
     python=[
-        "3.7",
         "3.8",
         "3.9",
         "3.10",
         "3.11",
         "3.12",
-        "pypy3.7",
-        "pypy3.8",
         "pypy3.9",
+        "pypy3.10",
     ]
 )
 def github_actions_default_tests(session: nox.Session) -> None:
@@ -161,6 +159,7 @@ def github_actions_default_tests(session: nox.Session) -> None:
         "pypy3.7",
         "pypy3.8",
         "pypy3.9",
+        "pypy3.10",
     ]
 )
 def github_actions_all_tests(session: nox.Session) -> None:
diff --git a/pyproject.toml b/pyproject.toml
index 23b9e49..585efc2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -45,7 +45,7 @@ dependencies = [
   'importlib-metadata; python_version < "3.8"',
   "packaging>=20.9",
   'typing-extensions>=3.7.4; python_version < "3.8"',
-  "virtualenv>=14",
+  "virtualenv>=20.14.1",
 ]
 [project.optional-dependencies]
 tox_to_nox = [


Test patch:
diff --git a/requirements-conda-test.txt b/requirements-conda-test.txt
index 156a932..8dde50e 100644
--- a/requirements-conda-test.txt
+++ b/requirements-conda-test.txt
@@ -3,4 +3,4 @@ colorlog >=2.6.1,<7.0.0
 jinja2
 pytest
 tox<4.0.0
-virtualenv >=14.0.0
+virtualenv >=20.14.1
diff --git a/tests/test__parametrize.py b/tests/test__parametrize.py
index 1364087..41ea423 100644
--- a/tests/test__parametrize.py
+++ b/tests/test__parametrize.py
@@ -35,8 +35,7 @@ def test_param_eq(param, other, expected):
 
 
 def test_param_eq_fail():
-    with pytest.raises(NotImplementedError):
-        _parametrize.Param() == "a"  # noqa: B015
+    assert _parametrize.Param() != "a"
 
 
 def test_parametrize_decorator_one():
diff --git a/tests/test_manifest.py b/tests/test_manifest.py
index 5d87bfd..5452288 100644
--- a/tests/test_manifest.py
+++ b/tests/test_manifest.py
@@ -49,6 +49,7 @@ def create_mock_config():
     cfg.force_venv_backend = None
     cfg.default_venv_backend = None
     cfg.extra_pythons = None
+    cfg.force_pythons = None
     cfg.posargs = []
     return cfg
 
@@ -287,6 +288,41 @@ def test_extra_pythons(python, extra_pythons, expected):
     assert expected == [session.func.python for session in manifest._all_sessions]
 
 
+@pytest.mark.parametrize(
+    "python,force_pythons,expected",
+    [
+        (None, [], [None]),
+        (None, ["3.8"], ["3.8"]),
+        (None, ["3.8", "3.9"], ["3.8", "3.9"]),
+        (False, [], [False]),
+        (False, ["3.8"], ["3.8"]),
+        (False, ["3.8", "3.9"], ["3.8", "3.9"]),
+        ("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_force_pythons(python, force_pythons, expected):
+    cfg = create_mock_config()
+    cfg.force_pythons = force_pythons
+    cfg.extra_pythons = force_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())