From 0b8b9621d0201a5213256c5002ed25c5e35d4c42 Mon Sep 17 00:00:00 2001 From: Marius Gedminas Date: Fri, 20 Nov 2020 16:43:58 +0200 Subject: [PATCH] Initial, read-only support for GitHub Actions See #22. --- CHANGES.rst | 5 +- src/check_python_versions/cli.py | 2 + src/check_python_versions/sources/all.py | 2 + src/check_python_versions/sources/base.py | 2 +- src/check_python_versions/sources/github.py | 77 +++++++++++++ tests/sources/test_github.py | 115 ++++++++++++++++++++ tests/test_cli.py | 28 ++--- 7 files changed, 214 insertions(+), 17 deletions(-) create mode 100644 src/check_python_versions/sources/github.py create mode 100644 tests/sources/test_github.py diff --git a/CHANGES.rst b/CHANGES.rst index 4755f82..10dc469 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,10 +1,11 @@ Changelog ========= -0.16.2 (unreleased) +0.17.0 (unreleased) ------------------- -- Nothing changed yet. +- Initial supprot for GitHub Actions (`issue 22 + `_). 0.16.1 (2020-11-08) diff --git a/src/check_python_versions/cli.py b/src/check_python_versions/cli.py index 5b0ed5a..d4b3ff5 100644 --- a/src/check_python_versions/cli.py +++ b/src/check_python_versions/cli.py @@ -255,6 +255,8 @@ def update_versions( replacements: ReplacementDict = {} for source in ALL_SOURCES: + if source.update is None: + continue if only and source.filename not in only: continue pathname = os.path.join(where, source.filename) diff --git a/src/check_python_versions/sources/all.py b/src/check_python_versions/sources/all.py index 43700fe..698a02d 100644 --- a/src/check_python_versions/sources/all.py +++ b/src/check_python_versions/sources/all.py @@ -3,6 +3,7 @@ from .setup_py import SetupClassifiers, SetupPythonRequires from .tox import Tox from .travis import Travis +from .github import GitHubActions # The order here is only mildly important: it's used for presentation. @@ -13,6 +14,7 @@ SetupPythonRequires, Tox, Travis, + GitHubActions, Appveyor, Manylinux, ] diff --git a/src/check_python_versions/sources/base.py b/src/check_python_versions/sources/base.py index 0e28900..e0f1f9b 100644 --- a/src/check_python_versions/sources/base.py +++ b/src/check_python_versions/sources/base.py @@ -13,4 +13,4 @@ class Source(typing.NamedTuple): title: str filename: str extract: ExtractorFn - update: UpdaterFn + update: Optional[UpdaterFn] = None diff --git a/src/check_python_versions/sources/github.py b/src/check_python_versions/sources/github.py new file mode 100644 index 0000000..f6fea2e --- /dev/null +++ b/src/check_python_versions/sources/github.py @@ -0,0 +1,77 @@ +""" +Support for GitHub Actions. + +GitHub Actions are very flexible, so this code is going to make some +simplifying assumptions: + +- your workflow is in .github/workflows/tests.yml +- you use a matrix strategy + - on 'python-version' that contains python versions, or + - on 'config' that contains lists of [python_version, tox_env] +""" + +from typing import List, Union + +import yaml + +from .base import Source +from ..utils import FileOrFilename, open_file +from ..versions import SortedVersionList, Version + + +GHA_WORKFLOW_FILE = '.github/workflows/tests.yml' + + +def get_gha_python_versions( + filename: FileOrFilename = GHA_WORKFLOW_FILE, +) -> SortedVersionList: + """Extract supported Python versions from a GitHub workflow.""" + with open_file(filename) as fp: + conf = yaml.safe_load(fp) + + versions: List[Version] = [] + for job_name, job in conf.get('jobs', {}).items(): + matrix = job.get('strategy', {}).get('matrix', {}) + if 'python-version' in matrix: + versions.extend( + e for e in map(parse_gh_ver, matrix['python-version']) if e) + if 'config' in matrix: + versions.extend( + parse_gh_ver(c[0]) + for c in matrix['config'] + if isinstance(c, list) + ) + + return sorted(set(versions)) + + +def parse_gh_ver(v: Union[str, float]) -> Version: + """Parse Python versions used for actions/setup-python@v2. + + This format is not fully well documented. There's support for + specifying things like + + - "3.x" (latest minor in Python 3.x; currently 3.9) + - "3.7" (latest bugfix in Python 3.7) + - "3.7.2" (specific version to be downloaded and installed) + - "pypy2"/"pypy3" + + https://github.com/actions/python-versions/blob/main/versions-manifest.json + contains a list of supported CPython versions that can be downloaded + and installed; this includes prereleases, but doesn't include PyPy. + """ + v = str(v) + if v.startswith('pypy3'): + return Version.from_string('PyPy3') + elif v.startswith('pypy2'): + return Version.from_string('PyPy') + else: + return Version.from_string(v) + + +GitHubActions = Source( + title=GHA_WORKFLOW_FILE, + filename=GHA_WORKFLOW_FILE, + extract=get_gha_python_versions, + update=None, +) diff --git a/tests/sources/test_github.py b/tests/sources/test_github.py new file mode 100644 index 0000000..99c1275 --- /dev/null +++ b/tests/sources/test_github.py @@ -0,0 +1,115 @@ +import textwrap +from io import StringIO +from typing import List + +import pytest + +from check_python_versions.sources.github import ( + get_gha_python_versions, + parse_gh_ver, +) +from check_python_versions.versions import Version + + +def v(versions: List[str]) -> List[Version]: + return [Version.from_string(v) for v in versions] + + +def test_get_gha_python_versions(): + tests_yml = StringIO(textwrap.dedent("""\ + name: Python package + on: [push] + jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [2.7, 3.5, 3.6, 3.7, 3.8] + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pytest + pip install -r requirements.txt + - name: Test with pytest + run: | + pytest + """)) + tests_yml.name = '.github/workflows/tests.yml' + assert get_gha_python_versions(tests_yml) == v([ + '2.7', '3.5', '3.6', '3.7', '3.8', + ]) + + +def test_get_gha_python_versions_zopefoundation(): + tests_yml = StringIO(textwrap.dedent("""\ + name: tests + on: + push: + branches: [ master ] + pull_request: + schedule: + - cron: '0 12 * * 0' # run once a week on Sunday + jobs: + build: + strategy: + matrix: + config: + # [Python version, tox env] + - ["3.8", "lint"] + - ["2.7", "py27"] + - ["3.5", "py35"] + - ["3.6", "py36"] + - ["3.7", "py37"] + - ["3.8", "py38"] + - ["3.9", "py39"] + - ["pypy2", "pypy"] + - ["pypy3", "pypy3"] + - ["3.8", "coverage"] + + runs-on: ubuntu-latest + name: ${{ matrix.config[1] }} + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.config[0] }} + - ... + """)) + tests_yml.name = '.github/workflows/tests.yml' + assert get_gha_python_versions(tests_yml) == v([ + '2.7', '3.5', '3.6', '3.7', '3.8', '3.9', 'PyPy', 'PyPy3', + ]) + + +def test_get_gha_python_versions_no_version_matrix(): + tests_yml = StringIO(textwrap.dedent("""\ + name: Python package + on: [push] + jobs: + build: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - ... + """)) + tests_yml.name = '.github/workflows/tests.yml' + assert get_gha_python_versions(tests_yml) == [] + + +@pytest.mark.parametrize('s, expected', [ + (3.6, '3.6'), + ('3.7', '3.7'), + ('pypy2', 'PyPy'), + ('pypy3', 'PyPy3'), +]) +def test_parse_gh_ver(s, expected): + assert parse_gh_ver(s) == Version.from_string(expected) diff --git a/tests/test_cli.py b/tests/test_cli.py index e7eaf69..d83d6e6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -104,7 +104,7 @@ def test_check_unknown(tmp_path, capsys): """)) assert cpv.check_versions(tmp_path) is True assert capsys.readouterr().out == textwrap.dedent("""\ - setup.py says: (empty) + setup.py says: (empty) """) @@ -122,7 +122,7 @@ def test_check_minimal(tmp_path, capsys): """)) assert cpv.check_versions(tmp_path) is True assert capsys.readouterr().out == textwrap.dedent("""\ - setup.py says: 2.7, 3.6 + setup.py says: 2.7, 3.6 """) @@ -145,8 +145,8 @@ def test_check_mismatch(tmp_path, capsys): """)) assert cpv.check_versions(tmp_path) is False assert capsys.readouterr().out == textwrap.dedent("""\ - setup.py says: 2.7, 3.6 - tox.ini says: 2.7 + setup.py says: 2.7, 3.6 + tox.ini says: 2.7 """) @@ -164,8 +164,8 @@ def test_check_expectation(tmp_path, capsys): """)) assert not cpv.check_versions(tmp_path, expect=v(['2.7', '3.6', '3.7'])) assert capsys.readouterr().out == textwrap.dedent("""\ - setup.py says: 2.7, 3.6 - expected: 2.7, 3.6, 3.7 + setup.py says: 2.7, 3.6 + expected: 2.7, 3.6, 3.7 """) @@ -188,7 +188,7 @@ def test_check_only(tmp_path, capsys): """)) assert cpv.check_versions(tmp_path, only='tox.ini') assert capsys.readouterr().out == textwrap.dedent("""\ - tox.ini says: 2.7 + tox.ini says: 2.7 """) @@ -525,8 +525,8 @@ def test_main_only(monkeypatch, capsys, tmp_path): assert ( capsys.readouterr().out + '\n' ).replace(str(tmp_path) + os.path.sep, 'tmp/') == textwrap.dedent("""\ - setup.py says: 2.7, 3.6 - tox.ini says: 2.7, 3.6 + setup.py says: 2.7, 3.6 + tox.ini says: 2.7, 3.6 """) @@ -556,8 +556,8 @@ def test_main_multiple(monkeypatch, capsys, tmp_path): ).replace(str(tmp_path) + os.path.sep, 'tmp/') == textwrap.dedent("""\ tmp/a: - setup.py says: 2.7, 3.6 - expected: 3.6, 3.7 + setup.py says: 2.7, 3.6 + expected: 3.6, 3.7 tmp/b: @@ -640,7 +640,7 @@ def test_main_update(monkeypatch, capsys, tmp_path): Write changes to tmp/setup.py? [y/N] - setup.py says: 2.7, 3.6, 3.7, 3.8 + setup.py says: 2.7, 3.6, 3.7, 3.8 """) assert (tmp_path / "setup.py").read_text() == textwrap.dedent("""\ from setuptools import setup @@ -693,7 +693,7 @@ def test_main_update_rejected(monkeypatch, capsys, tmp_path): Write changes to tmp/setup.py? [y/N] - setup.py says: 2.7, 3.6 + setup.py says: 2.7, 3.6 """) assert (tmp_path / "setup.py").read_text() == textwrap.dedent("""\ from setuptools import setup @@ -779,7 +779,7 @@ def test_main_update_dry_run(monkeypatch, capsys, tmp_path): .expandtabs() .replace(' \n', '\n\n') ) == textwrap.dedent("""\ - setup.py says: 2.7, 3.6, 3.7, 3.8 + setup.py says: 2.7, 3.6, 3.7, 3.8 """) assert (tmp_path / "setup.py").read_text() == textwrap.dedent("""\ from setuptools import setup