Skip to content

Use human sorting for job names #39

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Aug 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
Loading