Skip to content

Commit

Permalink
Use human sorting for job names (#39)
Browse files Browse the repository at this point in the history
* Use human sorting for job names

* Refactor testing
  • Loading branch information
ssbarnea authored Aug 13, 2024
1 parent 0b6dba2 commit 0b15c49
Show file tree
Hide file tree
Showing 7 changed files with 146 additions and 24 deletions.
1 change: 1 addition & 0 deletions .config/dictionary.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ minmax
mkdocs
pyenv
ssbarnea
pypa
4 changes: 4 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -53,6 +55,7 @@ repos:
args: [--strict]
additional_dependencies:
- actions-toolkit
- pytest
- repo: https://github.com/pycqa/pylint
rev: v3.2.6
hooks:
Expand All @@ -61,3 +64,4 @@ repos:
- --output-format=colorized
additional_dependencies:
- actions-toolkit
- pytest
4 changes: 3 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
{
"python.formatting.provider": "black"
"python.formatting.provider": "black",
"editor.defaultFormatter": "charliermarsh.ruff",
"editor.formatOnSave": true
}
1 change: 1 addition & 0 deletions cspell.config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ dictionaries:
- words
- python
ignorePaths:
- .vscode/settings.json
- cspell.config.yaml
85 changes: 64 additions & 21 deletions entrypoint.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -22,6 +25,19 @@
IMPLICIT_SKIP_EXPLODE = "0"


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 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."""
if name in result:
Expand All @@ -31,22 +47,54 @@ 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
def main() -> None: # noqa: C901,PLR0912,PLR0915
"""Main."""
# print all env vars starting with INPUT_
for k, v in os.environ.items():
if k.startswith("INPUT_"):
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()
core.info(f"Effective platforms: {platforms}")
core.info(f"Platform map: {PLATFORM_MAP}")

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)

Expand All @@ -60,7 +108,15 @@ def main() -> None: # noqa: C901,PLR0912
KNOWN_PYTHONS.index(min_python) : (KNOWN_PYTHONS.index(max_python) + 1)
]
python_flavours = len(python_names)
core.debug("...")

def sort_key(s: str) -> tuple[int, str]:
"""Sorts longer strings first."""
return -len(s), s

# we put longer names first in order to pick the most specific platforms
platform_names_sorted = sorted(PLATFORM_MAP.keys(), key=sort_key)
core.info(f"Known platforms sorted: {platform_names_sorted}")

for line in other_names:
name, _ = line.split(":", 1) if ":" in line else (line, f"tox -e {line}")
commands = _.split(";")
Expand All @@ -70,7 +126,7 @@ def main() -> None: # noqa: C901,PLR0912
if match:
py_version = match.groups()[0]
env_python = f"{py_version[0]}.{py_version[1:]}"
for platform_name in PLATFORM_MAP:
for platform_name in platform_names_sorted:
if platform_name in name:
break
else:
Expand All @@ -93,7 +149,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)
Expand All @@ -111,7 +167,7 @@ def main() -> None: # noqa: C901,PLR0912
)

core.info(f"Generated {len(result)} matrix entries.")
names = sorted(result.keys())
names = sort_human(list(result.keys()))
core.info(f"Job names: {', '.join(names)}")
matrix_include = []
matrix_include = [
Expand All @@ -120,26 +176,13 @@ 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
core.set_failed(f"Action failed due to {exc}")


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()
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
70 changes: 68 additions & 2 deletions tests/test_action.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,75 @@
"""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": "z\nall-linux-arm64:tox -e unit;tox -e integration",
"INPUT_PLATFORMS": "linux-arm64:ubuntu-24.04-arm64-2core",
"INPUT_SKIP_EXPLODE": "1",
"INPUT_WINDOWS": "minmax",
},
{
"matrix": {
"include": [
{
"command": "tox -e unit",
"command2": "tox -e integration",
"name": "all-linux-arm64",
"os": "ubuntu-24.04-arm64-2core",
"python_version": "3.8",
},
{
"command": "tox -e z",
"name": "z",
"os": "ubuntu-24.04",
"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)

0 comments on commit 0b15c49

Please sign in to comment.