diff --git a/.config/dictionary.txt b/.config/dictionary.txt index 382f8ed..7c816a8 100644 --- a/.config/dictionary.txt +++ b/.config/dictionary.txt @@ -13,3 +13,4 @@ minmax mkdocs pyenv ssbarnea +pypa diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 43538de..7e89416 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,6 +40,8 @@ repos: hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] + additional_dependencies: + - pytest - repo: https://github.com/psf/black rev: 24.8.0 hooks: @@ -53,6 +55,7 @@ repos: args: [--strict] additional_dependencies: - actions-toolkit + - pytest - repo: https://github.com/pycqa/pylint rev: v3.2.6 hooks: @@ -61,3 +64,4 @@ repos: - --output-format=colorized additional_dependencies: - actions-toolkit + - pytest diff --git a/.vscode/settings.json b/.vscode/settings.json index b7368ca..f9d5c38 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,5 @@ { - "python.formatting.provider": "black" + "python.formatting.provider": "black", + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.formatOnSave": true } diff --git a/cspell.config.yaml b/cspell.config.yaml index a5c9cce..464da56 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -10,4 +10,5 @@ dictionaries: - words - python ignorePaths: + - .vscode/settings.json - cspell.config.yaml diff --git a/entrypoint.py b/entrypoint.py index 66cfd7d..efd2893 100755 --- a/entrypoint.py +++ b/entrypoint.py @@ -1,8 +1,11 @@ #!env python3 """Action body.""" + import json import os import re +from pathlib import Path +from typing import Any from actions_toolkit import core @@ -22,14 +25,18 @@ IMPLICIT_SKIP_EXPLODE = "0" -def sort_human(l: list[str]) -> list[str]: +def sort_human(data: list[str]) -> list[str]: """Sort a list using human logic, so 'py39' comes before 'py311'.""" + def convert(text: str) -> str | float: return float(text) if text.isdigit() else text - def alphanum(key): - return [convert(c) for c in re.split("([-+]?[0-9]*\\.?[0-9]*)", key)] - l.sort(key=alphanum) - return l + + def alphanumeric(key: str) -> list[str | float]: + return [convert(c) for c in re.split(r"([-+]?\d*\\.?\d*)", key)] + + data.sort(key=alphanumeric) + return data + def add_job(result: dict[str, dict[str, str]], name: str, data: dict[str, str]) -> None: """Adds a new job to the list of generated jobs.""" @@ -40,6 +47,34 @@ def add_job(result: dict[str, dict[str, str]], name: str, data: dict[str, str]) result[name] = data +def get_platforms() -> list[str]: + """Retrieve effective list of platforms.""" + platforms = [] + for v in core.get_input("platforms", required=False).split(","): + platform, run_on = v.split(":") if ":" in v else (v, None) + if not platform: + continue + if run_on: + core.debug( + f"Add platform '{platform}' with run_on={run_on} to known platforms", + ) + PLATFORM_MAP[platform] = run_on + platforms.append(platform) + return platforms + + +def produce_output(output: dict[str, Any]) -> None: + """Produce the output.""" + if "TEST_GITHUB_OUTPUT_JSON" in os.environ: + with Path(os.environ["TEST_GITHUB_OUTPUT_JSON"]).open( + "w", + encoding="utf-8", + ) as f: + json.dump(output, f) + for key, value in output.items(): + core.set_output(key, value) + + # loop list staring with given item # pylint: disable=too-many-locals,too-many-branches def main() -> None: # noqa: C901,PLR0912 @@ -50,12 +85,14 @@ def main() -> None: # noqa: C901,PLR0912 core.info(f"Env var {k}={v}") try: other_names = core.get_input("other_names", required=False).split("\n") - platforms = core.get_input("platforms", required=False).split(",") + platforms = get_platforms() + min_python = core.get_input("min_python") or IMPLICIT_MIN_PYTHON max_python = core.get_input("max_python") or IMPLICIT_MAX_PYTHON default_python = core.get_input("default_python") or IMPLICIT_DEFAULT_PYTHON skip_explode = int(core.get_input("skip_explode") or IMPLICIT_SKIP_EXPLODE) strategies = {} + for platform in PLATFORM_MAP: strategies[platform] = core.get_input(platform, required=False) @@ -102,7 +139,7 @@ def main() -> None: # noqa: C901,PLR0912 if not skip_explode: for platform in platforms: for i, python in enumerate(python_names): - py_name = re.sub(r"[^0-9]", "", python.strip(".")) + py_name = re.sub(r"\D", "", python.strip(".")) suffix = "" if platform == IMPLICIT_PLATFORM else f"-{platform}" if strategies[platform] == "minmax" and ( i not in (0, python_flavours - 1) @@ -129,8 +166,8 @@ def main() -> None: # noqa: C901,PLR0912 core.info( f"Matrix jobs ordered by their name: {json.dumps(matrix_include, indent=2)}", ) - - core.set_output("matrix", {"include": matrix_include}) + output = {"matrix": {"include": matrix_include}} + produce_output(output) # pylint: disable=broad-exception-caught except Exception as exc: # noqa: BLE001 @@ -138,17 +175,4 @@ def main() -> None: # noqa: C901,PLR0912 if __name__ == "__main__": - # only used for local testing, emulating use from github actions - if os.getenv("GITHUB_ACTIONS") is None: - os.environ["INPUT_DEFAULT_PYTHON"] = "3.10" - os.environ["INPUT_LINUX"] = "full" - os.environ["INPUT_MACOS"] = "minmax" - os.environ["INPUT_MAX_PYTHON"] = "3.13" - os.environ["INPUT_MIN_PYTHON"] = "3.8" - os.environ["INPUT_OTHER_NAMES"] = ( - "lint\npkg\npy313-devel\nall-macos:tox -e unit;tox -e integration" - ) - os.environ["INPUT_PLATFORMS"] = "linux,macos" # macos and windows - os.environ["INPUT_SKIP_EXPLODE"] = "0" - os.environ["INPUT_WINDOWS"] = "minmax" main() diff --git a/pyproject.toml b/pyproject.toml index d2f28da..839d457 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -161,10 +161,15 @@ lint.ignore = [ "INP001", # "is part of an implicit namespace package", all false positives "PLW2901", # PLW2901: Redefined loop variable "RET504", # Unnecessary variable assignment before `return` statement + "S603", # https://github.com/astral-sh/ruff/issues/4045 + # temporary disabled until we fix them: ] lint.select = ["ALL"] +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = ["SLF001", "S101", "FBT001"] + [tool.ruff.lint.pydocstyle] convention = "google" diff --git a/tests/test_action.py b/tests/test_action.py index 89e3562..e114a40 100644 --- a/tests/test_action.py +++ b/tests/test_action.py @@ -1,9 +1,69 @@ """Tests for github action.""" +import json +import os import sys +import tempfile from subprocess import run +import pytest -def test_foo() -> None: + +@pytest.mark.parametrize( + ("passed_env", "expected"), + [ + pytest.param( + { + "INPUT_DEFAULT_PYTHON": "3.8", + "INPUT_LINUX": "full", + "INPUT_MACOS": "minmax", + "INPUT_MAX_PYTHON": "3.8", + "INPUT_MIN_PYTHON": "3.8", + "INPUT_OTHER_NAMES": "all-macos:tox -e unit;tox -e integration", + "INPUT_PLATFORMS": "linux", + "INPUT_SKIP_EXPLODE": "1", + "INPUT_WINDOWS": "minmax", + }, + { + "matrix": { + "include": [ + { + "command": "tox -e unit", + "command2": "tox -e integration", + "name": "all-macos", + "os": "macos-13", + "python_version": "3.8", + }, + ], + }, + }, + id="1", + ), + ], +) +def test_action(passed_env: dict[str, str], expected: dict[str, str]) -> None: """Sample test.""" - run([sys.executable, "entrypoint.py"], check=True, shell=False) # noqa: S603 + with tempfile.NamedTemporaryFile(delete=False) as temp_file: + env = { + **os.environ.copy(), + **passed_env, + "TEST_GITHUB_OUTPUT_JSON": temp_file.name, + } + + result = run( + [sys.executable, "entrypoint.py"], + text=True, + shell=False, + check=True, + capture_output=True, + env=env, + ) + assert result.returncode == 0 + temp_file.seek(0) + effective = temp_file.read().decode("utf-8") + data = json.loads(effective) + assert isinstance(data, dict), data + assert len(data) == 1 + assert "matrix" in data + assert data == expected + # TestCase().assertDictEqual(data, expected)