From d1bdf0940eda7213dc2bd6407cf7107dfc986672 Mon Sep 17 00:00:00 2001 From: Matthieu Darbois Date: Thu, 16 Jun 2022 19:26:47 +0200 Subject: [PATCH] feat: allow defining a python version list for GHA action (#609) --- .github/action_helper.py | 74 ++++++++++++++++++++++ .github/workflows/action.yml | 8 +++ .pre-commit-config.yaml | 2 + action.yml | 86 ++++++++++++++++++++++--- docs/tutorial.rst | 18 +++++- noxfile.py | 25 ++++++++ tests/test_action_helper.py | 117 +++++++++++++++++++++++++++++++++++ 7 files changed, 320 insertions(+), 10 deletions(-) create mode 100644 .github/action_helper.py create mode 100644 tests/test_action_helper.py diff --git a/.github/action_helper.py b/.github/action_helper.py new file mode 100644 index 00000000..569d10bc --- /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 89d6e526..00215d29 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 a4b5bd93..7a8e3d8d 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 deb2cb2b..9e4443ac 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 83bc1909..4f9271d3 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 44e0d778..154a1002 100644 --- a/noxfile.py +++ b/noxfile.py @@ -19,6 +19,7 @@ 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) diff --git a/tests/test_action_helper.py b/tests/test_action_helper.py new file mode 100644 index 00000000..07451cc1 --- /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)))