Repository: wntrblm/nox
Pull request #609 (2022-04-30 14:42:31)

feat: allow defining a python version list for GHA action (alt)
This is another way to implement the feature discussed in #601.

It hopefully addresses any shortcomings in the implementation except for the yaml list which is still not possible but has been replaced with a simple enough comma-separated list.

It now supports any python version supported by `actions/setup-python@v3` with up-to 20 versions installed.
The python validation/helper script which is currently inlined in the action might benefit from being a standalone script allowing for proper testing. I can do this if maintainers want to pursue this further.

One thing discovered while working on this is that mixing `actions/setup-python` & `wntrblm/nox` actions might not be that safe.
e.g. let's say that for some reason that I need `pypy-3.9-nightly` because of a bug in `pypy-3.9`:
```
    # this breaks cpython3.9 testing (overridden by pypy-3.9-nightly)
    - uses: wntrblm/nox
    - uses: actions/setup-python@v3
      with:
        python-version: "pypy-3.9-nightly"
```
```
    # this is just no-op (pypy-3.9-nightly overridden by wntrblm/nox)
    - uses: actions/setup-python@v3
      with:
        python-version: "pypy-3.9-nightly"
    - uses: wntrblm/nox
```
```
    # we currently need to do this
    - uses: wntrblm/nox
    - uses: actions/setup-python@v3
      with:
        python-version: "pypy-3.9-nightly"
    - uses: actions/setup-python@v3
      with:
        python-version: "3.9"
```
```
    # with this PR 
    - uses: wntrblm/nox
      with:
        python-versions: "3.7, 3.8, 3.9, 3.10, pypy-3.8, pypy-3.9-nightly"
```

closes #601

mayeut: thanks for the review, I'm a bit short on time until this week-end so I will address most remarks then.
In the meantime, #606 is probably easier to review and included here (https://github.com/wntrblm/nox/pull/609#discussion_r862752916 is applicable to #606 so maybe we should start there ?)
mayeut: > Am I correct in thinking that the additional validation logic here is purely a result of avoiding the `"['3.6', '3.7', '3.8'...]"` syntax?
>
> If so I don't think the increased complexity is worth it and I'd actually be happier with a string list syntax. Sorry that I've flip flopped on this but I didn't appreciate how much additional complexity would be required

The validation/helper logic is not only there to avoid the `"['3.6', '3.7', '3.8'...]"` syntax. That's only a small aspect of what's being achieved in this PR.

The main goal with this alternative to the implementation of #601 is for the `wntrblm/nox` action to be able to install any python version that can be installed with `actions/python` correctly. The list of python version that can be installed with the action in #601 is hardcoded meaning it will require some updates every now & then (e.g. when python 3.11 is released or python 3.7 EOL, ...).

The implementation here comes with a small increase in complexity but is in my opinion more usable and I feel that there will be less maintenance on the action in the long run.
FollowTheProcess: > The implementation here comes with a small increase in complexity but is in my opinion more usable and I feel that there will be less maintenance on the action in the long run.

So this one is intended to replace #601 ?
mayeut: > So this one is intended to replace https://github.com/wntrblm/nox/pull/601 ?

short answer: yes.
longer answer: I'm keeping #601 opened for now if maintainers think this added complexity is really too much to handle. #601 might be an acceptable middle ground.
FollowTheProcess: Sorry for the long delay, been a bit of a busy period! This is looking really good and thanks @mayeut for all the effort you've put in so far. It would be good to get a couple other maintainers to weigh in with any thoughts/comments (@wntrblm/nox) but this looks good to me 👍🏻 
theacodes: LGTM
mayeut: Thanks for the feedback. I rebased on main to fix the conflict.

Patch:
diff --git a/.github/action_helper.py b/.github/action_helper.py
new file mode 100644
index 0000000..569d10b
--- /dev/null
+++ b/.github/action_helper.py
@@ -0,0 +1,74 @@
+import sys
+
+
+def filter_version(version: str) -> str:
+    """return python 'major.minor'"""
+
+    # remove interpreter prefix
+    if version.startswith("pypy-"):
+        version_ = version[5:]
+    elif version.startswith("pypy"):
+        version_ = version[4:]
+    else:
+        version_ = version
+
+    # remove extra specifier e.g. "3.11-dev" => "3.11"
+    version_ = version_.split("-")[0]
+
+    version_parts = version_.split(".")
+    if len(version_parts) < 2:
+        raise ValueError(f"invalid version: {version}")
+    if not version_parts[0].isdigit():
+        raise ValueError(f"invalid major python version: {version}")
+    if not version_parts[1].isdigit():
+        raise ValueError(f"invalid minor python version: {version}")
+    return ".".join(version_parts[:2])
+
+
+def setup_action(input_: str) -> None:
+    versions = [version.strip() for version in input_.split(",") if version.strip()]
+
+    pypy_versions = [version for version in versions if version.startswith("pypy")]
+    pypy_versions_filtered = [filter_version(version) for version in pypy_versions]
+    if len(pypy_versions) != len(set(pypy_versions_filtered)):
+        raise ValueError(
+            "multiple versions specified for the same 'major.minor' PyPy interpreter:"
+            f" {pypy_versions}"
+        )
+
+    cpython_versions = [version for version in versions if version not in pypy_versions]
+    cpython_versions_filtered = [
+        filter_version(version) for version in cpython_versions
+    ]
+    if len(cpython_versions) != len(set(cpython_versions_filtered)):
+        raise ValueError(
+            "multiple versions specified for the same 'major.minor' CPython"
+            f" interpreter: {cpython_versions}"
+        )
+
+    # cpython shall be installed last because
+    # other interpreters also define pythonX.Y symlinks.
+    versions = pypy_versions + cpython_versions
+
+    # we want to install python 3.10 last to ease nox set-up
+    if "3.10" in cpython_versions_filtered:
+        index = cpython_versions_filtered.index("3.10")
+        index = versions.index(cpython_versions[index])
+        cpython_310 = versions.pop(index)
+        versions.append(cpython_310)
+    else:
+        # add this to install nox
+        versions.append("3.10")
+
+    if len(versions) > 20:
+        raise ValueError(f"too many interpreters to install: {len(versions)} > 20")
+
+    print(f"::set-output name=interpreter_count::{len(versions)}")
+    for i, version in enumerate(versions):
+        print(f"::set-output name=interpreter_{i}::{version}")
+
+
+if __name__ == "__main__":
+    if len(sys.argv) != 2:
+        raise AssertionError(f"invalid arguments: {sys.argv}")
+    setup_action(sys.argv[1])
diff --git a/.github/workflows/action.yml b/.github/workflows/action.yml
index 89d6e52..00215d2 100644
--- a/.github/workflows/action.yml
+++ b/.github/workflows/action.yml
@@ -20,3 +20,11 @@ jobs:
       - uses: actions/checkout@v3
       - uses: ./
       - run: nox --non-interactive --error-on-missing-interpreter --session github_actions_default_tests
+  action-all-tests:
+    runs-on: windows-latest
+    steps:
+      - uses: actions/checkout@v3
+      - uses: ./
+        with:
+          python-versions: "2.7.18, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9, 3.10, 3.11-dev, pypy-2.7, pypy-3.7, pypy-3.8, pypy-3.9-v7.3.9"
+      - run: nox --non-interactive --error-on-missing-interpreter --session github_actions_all_tests
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index a4b5bd9..7a8e3d8 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -29,12 +29,14 @@ repos:
     hooks:
       - id: isort
         args: ["-a", "from __future__ import annotations"]
+        exclude: ^.github/action_helper.py$
 
   - repo: https://github.com/asottile/pyupgrade
     rev: v2.32.0
     hooks:
       - id: pyupgrade
         args: [--py37-plus]
+        exclude: ^.github/action_helper.py$
 
   - repo: https://github.com/tox-dev/pyproject-fmt
     rev: "0.3.3"
diff --git a/action.yml b/action.yml
index deb2cb2..9e4443a 100644
--- a/action.yml
+++ b/action.yml
@@ -1,5 +1,10 @@
 name: Setup Nox
-description: 'Prepares all python versions for nox'
+description: "Prepares all python versions for nox"
+inputs:
+  python-versions:
+    description: "comma-separated list of python versions to install"
+    required: false
+    default: "3.7, 3.8, 3.9, 3.10, pypy-3.7, pypy-3.8, pypy-3.9"
 branding:
   icon: package
   color: blue
@@ -7,28 +12,91 @@ branding:
 runs:
   using: composite
   steps:
+    - name: "Validate input"
+      id: helper
+      run: ${{ runner.os == 'Windows' && 'python' || 'python3' }} '${{ github.action_path }}/.github/action_helper.py' '${{ inputs.python-versions }}'
+      shell: bash
+
     - uses: actions/setup-python@v4
       with:
-        python-version: "pypy-3.7"
+        python-version: "${{ steps.helper.outputs.interpreter_0 }}"
+      if: ${{ steps.helper.outputs.interpreter_count > 0 }}
     - uses: actions/setup-python@v4
       with:
-        python-version: "pypy-3.8"
+        python-version: "${{ steps.helper.outputs.interpreter_1 }}"
+      if: ${{ steps.helper.outputs.interpreter_count > 1 }}
     - uses: actions/setup-python@v4
       with:
-        python-version: "pypy-3.9"
-
+        python-version: "${{ steps.helper.outputs.interpreter_2 }}"
+      if: ${{ steps.helper.outputs.interpreter_count > 2 }}
+    - uses: actions/setup-python@v4
+      with:
+        python-version: "${{ steps.helper.outputs.interpreter_3 }}"
+      if: ${{ steps.helper.outputs.interpreter_count > 3 }}
+    - uses: actions/setup-python@v4
+      with:
+        python-version: "${{ steps.helper.outputs.interpreter_4 }}"
+      if: ${{ steps.helper.outputs.interpreter_count > 4 }}
+    - uses: actions/setup-python@v4
+      with:
+        python-version: "${{ steps.helper.outputs.interpreter_5 }}"
+      if: ${{ steps.helper.outputs.interpreter_count > 5 }}
+    - uses: actions/setup-python@v4
+      with:
+        python-version: "${{ steps.helper.outputs.interpreter_6 }}"
+      if: ${{ steps.helper.outputs.interpreter_count > 6 }}
+    - uses: actions/setup-python@v4
+      with:
+        python-version: "${{ steps.helper.outputs.interpreter_7 }}"
+      if: ${{ steps.helper.outputs.interpreter_count > 7 }}
+    - uses: actions/setup-python@v4
+      with:
+        python-version: "${{ steps.helper.outputs.interpreter_8 }}"
+      if: ${{ steps.helper.outputs.interpreter_count > 8 }}
+    - uses: actions/setup-python@v4
+      with:
+        python-version: "${{ steps.helper.outputs.interpreter_9 }}"
+      if: ${{ steps.helper.outputs.interpreter_count > 9 }}
+    - uses: actions/setup-python@v4
+      with:
+        python-version: "${{ steps.helper.outputs.interpreter_10 }}"
+      if: ${{ steps.helper.outputs.interpreter_count > 10 }}
+    - uses: actions/setup-python@v4
+      with:
+        python-version: "${{ steps.helper.outputs.interpreter_11 }}"
+      if: ${{ steps.helper.outputs.interpreter_count > 11 }}
+    - uses: actions/setup-python@v4
+      with:
+        python-version: "${{ steps.helper.outputs.interpreter_12 }}"
+      if: ${{ steps.helper.outputs.interpreter_count > 12 }}
+    - uses: actions/setup-python@v4
+      with:
+        python-version: "${{ steps.helper.outputs.interpreter_13 }}"
+      if: ${{ steps.helper.outputs.interpreter_count > 13 }}
+    - uses: actions/setup-python@v4
+      with:
+        python-version: "${{ steps.helper.outputs.interpreter_14 }}"
+      if: ${{ steps.helper.outputs.interpreter_count > 14 }}
+    - uses: actions/setup-python@v4
+      with:
+        python-version: "${{ steps.helper.outputs.interpreter_15 }}"
+      if: ${{ steps.helper.outputs.interpreter_count > 15 }}
     - uses: actions/setup-python@v4
       with:
-        python-version: "3.7"
+        python-version: "${{ steps.helper.outputs.interpreter_16 }}"
+      if: ${{ steps.helper.outputs.interpreter_count > 16 }}
     - uses: actions/setup-python@v4
       with:
-        python-version: "3.8"
+        python-version: "${{ steps.helper.outputs.interpreter_17 }}"
+      if: ${{ steps.helper.outputs.interpreter_count > 17 }}
     - uses: actions/setup-python@v4
       with:
-        python-version: "3.9"
+        python-version: "${{ steps.helper.outputs.interpreter_18 }}"
+      if: ${{ steps.helper.outputs.interpreter_count > 18 }}
     - uses: actions/setup-python@v4
       with:
-        python-version: "3.10"
+        python-version: "${{ steps.helper.outputs.interpreter_19 }}"
+      if: ${{ steps.helper.outputs.interpreter_count > 19 }}
 
     - name: "Install nox"
       # --python "$(which python)" => always use the last setup-python version to install nox.
diff --git a/docs/tutorial.rst b/docs/tutorial.rst
index 83bc190..4f9271d 100644
--- a/docs/tutorial.rst
+++ b/docs/tutorial.rst
@@ -29,7 +29,23 @@ Either way, Nox is usually installed *globally*, similar to ``tox``, ``pip``, an
 
 If you're interested in running ``nox`` within `docker`_, you can use the `thekevjames/nox images`_ on DockerHub which contain builds for all ``nox`` versions and all supported ``python`` versions. Nox is also supported via ``pipx run nox`` in the `manylinux images`_.
 
-If you want to run ``nox`` within `GitHub Actions`_, you can use the ``wntrblm/nox`` action, which installs the latest ``nox`` and makes available all active CPython and PyPY versions provided by the GitHub Actions environment. You can safely combine this with with ``setup-python`` for past end-of-life or development versions of Python, as well.
+If you want to run ``nox`` within `GitHub Actions`_, you can use the ``wntrblm/nox`` action, which installs the latest ``nox`` and makes available all active CPython and PyPY versions provided by the GitHub Actions environment:
+
+.. code-block:: yaml
+
+    # setup nox with all active CPython and PyPY versions provided by
+    # the GitHub Actions environment i.e.
+    # python-versions: "3.7, 3.8, 3.9, 3.10, pypy-3.7, pypy-3.8, pypy-3.9"
+    - uses: wntrblm/nox
+
+    # setup nox only for a given list of python versions
+    # Limitations:
+    # - Version specifiers shall be supported by actions/setup-python
+    # - You can specify up-to 20 versions
+    # - There can only be one "major.minor" per interpreter i.e. "3.7.0, 3.7.1" is invalid
+    - uses: wntrblm/nox
+      with:
+          python-versions: "2.7, 3.5, 3.11-dev, pypy-3.9"
 
 .. _pip: https://pip.readthedocs.org
 .. _user site: https://packaging.python.org/tutorials/installing-packages/#installing-to-the-user-site
diff --git a/noxfile.py b/noxfile.py
index 44e0d77..154a100 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -19,6 +19,7 @@ import functools
 import os
 import platform
 import shutil
+import sys
 
 import nox
 
@@ -132,4 +133,28 @@ def _check_python_version(session: nox.Session) -> None:
 @nox.session(python=["3.7", "3.8", "3.9", "3.10", "pypy3.7", "pypy3.8", "pypy3.9"])
 def github_actions_default_tests(session: nox.Session) -> None:
     """Check default versions installed by the nox GHA Action"""
+    assert sys.version_info[:2] == (3, 10)
+    _check_python_version(session)
+
+
+# The following sessions are only to be run in CI to check the nox GHA action
+@nox.session(
+    python=[
+        "2.7",
+        "3.4",
+        "3.5",
+        "3.6",
+        "3.7",
+        "3.8",
+        "3.9",
+        "3.10",
+        "3.11",
+        "pypy2.7",
+        "pypy3.7",
+        "pypy3.8",
+        "pypy3.9",
+    ]
+)
+def github_actions_all_tests(session: nox.Session) -> None:
+    """Check all versions installed by the nox GHA Action"""
     _check_python_version(session)


Test patch:
diff --git a/tests/test_action_helper.py b/tests/test_action_helper.py
new file mode 100644
index 0000000..07451cc
--- /dev/null
+++ b/tests/test_action_helper.py
@@ -0,0 +1,117 @@
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+import pytest
+
+GITHUB_FOLDER = Path(__file__).resolve().parent.parent / ".github"
+sys.path.insert(0, str(GITHUB_FOLDER))
+from action_helper import filter_version, setup_action  # noqa: E402
+
+VALID_VERSIONS = {
+    "2.7.18": "2.7",
+    "3.9-dev": "3.9",
+    "3.10": "3.10",
+    "3.11.0.beta1": "3.11",
+    "pypy-3.7": "3.7",
+    "pypy-3.8-v7.3.9": "3.8",
+    "pypy-3.9": "3.9",
+    "pypy3.10": "3.10",
+}
+
+
+@pytest.mark.parametrize("version", VALID_VERSIONS.keys())
+def test_filter_version(version):
+    assert filter_version(version) == VALID_VERSIONS[version]
+
+
+def test_filter_version_invalid():
+    with pytest.raises(ValueError, match=r"invalid version: 3"):
+        filter_version("3")
+
+
+def test_filter_version_invalid_major():
+    with pytest.raises(ValueError, match=r"invalid major python version: x.0"):
+        filter_version("x.0")
+
+
+def test_filter_version_invalid_minor():
+    with pytest.raises(ValueError, match=r"invalid minor python version: 3.x"):
+        filter_version("3.x")
+
+
+VALID_VERSION_LISTS = {
+    "3.7, 3.8, 3.9, 3.10, pypy-3.7, pypy-3.8, pypy-3.9": [
+        "::set-output name=interpreter_count::7",
+        "::set-output name=interpreter_0::pypy-3.7",
+        "::set-output name=interpreter_1::pypy-3.8",
+        "::set-output name=interpreter_2::pypy-3.9",
+        "::set-output name=interpreter_3::3.7",
+        "::set-output name=interpreter_4::3.8",
+        "::set-output name=interpreter_5::3.9",
+        "::set-output name=interpreter_6::3.10",
+    ],
+    "": [
+        "::set-output name=interpreter_count::1",
+        "::set-output name=interpreter_0::3.10",
+    ],
+    "3.10.4": [
+        "::set-output name=interpreter_count::1",
+        "::set-output name=interpreter_0::3.10.4",
+    ],
+    "3.9-dev,pypy3.9-nightly": [
+        "::set-output name=interpreter_count::3",
+        "::set-output name=interpreter_0::pypy3.9-nightly",
+        "::set-output name=interpreter_1::3.9-dev",
+        "::set-output name=interpreter_2::3.10",
+    ],
+    "3.10, 3.9, 3.8": [
+        "::set-output name=interpreter_count::3",
+        "::set-output name=interpreter_0::3.9",
+        "::set-output name=interpreter_1::3.8",
+        "::set-output name=interpreter_2::3.10",
+    ],
+    ",".join(f"3.{minor}" for minor in range(20)): [
+        "::set-output name=interpreter_count::20"
+    ]
+    + [
+        f"::set-output name=interpreter_{i}::3.{minor}"
+        for i, minor in enumerate(minor_ for minor_ in range(20) if minor_ != 10)
+    ]
+    + ["::set-output name=interpreter_19::3.10"],
+}
+
+
+@pytest.mark.parametrize("version_list", VALID_VERSION_LISTS.keys())
+def test_setup_action(capsys, version_list):
+    setup_action(version_list)
+    captured = capsys.readouterr()
+    lines = captured.out.splitlines()
+    assert lines == VALID_VERSION_LISTS[version_list]
+
+
+def test_setup_action_multiple_pypy():
+    with pytest.raises(
+        ValueError,
+        match=(
+            r"multiple versions specified for the same 'major.minor' PyPy interpreter"
+        ),
+    ):
+        setup_action("pypy3.9, pypy-3.9-v7.3.9")
+
+
+def test_setup_action_multiple_cpython():
+    with pytest.raises(
+        ValueError,
+        match=(
+            r"multiple versions specified for the same 'major.minor' CPython"
+            r" interpreter"
+        ),
+    ):
+        setup_action("3.10, 3.10.4")
+
+
+def test_setup_action_too_many_interpreters():
+    with pytest.raises(ValueError, match=r"too many interpreters to install: 21 > 20"):
+        setup_action(",".join(f"3.{minor}" for minor in range(21)))