Skip to content

Commit 0b15c49

Browse files
authored
Use human sorting for job names (#39)
* Use human sorting for job names * Refactor testing
1 parent 0b6dba2 commit 0b15c49

File tree

7 files changed

+146
-24
lines changed

7 files changed

+146
-24
lines changed

.config/dictionary.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ minmax
1313
mkdocs
1414
pyenv
1515
ssbarnea
16+
pypa

.pre-commit-config.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ repos:
4040
hooks:
4141
- id: ruff
4242
args: [--fix, --exit-non-zero-on-fix]
43+
additional_dependencies:
44+
- pytest
4345
- repo: https://github.com/psf/black
4446
rev: 24.8.0
4547
hooks:
@@ -53,6 +55,7 @@ repos:
5355
args: [--strict]
5456
additional_dependencies:
5557
- actions-toolkit
58+
- pytest
5659
- repo: https://github.com/pycqa/pylint
5760
rev: v3.2.6
5861
hooks:
@@ -61,3 +64,4 @@ repos:
6164
- --output-format=colorized
6265
additional_dependencies:
6366
- actions-toolkit
67+
- pytest

.vscode/settings.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
{
2-
"python.formatting.provider": "black"
2+
"python.formatting.provider": "black",
3+
"editor.defaultFormatter": "charliermarsh.ruff",
4+
"editor.formatOnSave": true
35
}

cspell.config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ dictionaries:
1010
- words
1111
- python
1212
ignorePaths:
13+
- .vscode/settings.json
1314
- cspell.config.yaml

entrypoint.py

Lines changed: 64 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
#!env python3
22
"""Action body."""
3+
34
import json
45
import os
56
import re
7+
from pathlib import Path
8+
from typing import Any
69

710
from actions_toolkit import core
811

@@ -22,6 +25,19 @@
2225
IMPLICIT_SKIP_EXPLODE = "0"
2326

2427

28+
def sort_human(data: list[str]) -> list[str]:
29+
"""Sort a list using human logic, so 'py39' comes before 'py311'."""
30+
31+
def convert(text: str) -> str | float:
32+
return float(text) if text.isdigit() else text
33+
34+
def alphanumeric(key: str) -> list[str | float]:
35+
return [convert(c) for c in re.split(r"([-+]?\d*\\.?\d*)", key)]
36+
37+
data.sort(key=alphanumeric)
38+
return data
39+
40+
2541
def add_job(result: dict[str, dict[str, str]], name: str, data: dict[str, str]) -> None:
2642
"""Adds a new job to the list of generated jobs."""
2743
if name in result:
@@ -31,22 +47,54 @@ def add_job(result: dict[str, dict[str, str]], name: str, data: dict[str, str])
3147
result[name] = data
3248

3349

50+
def get_platforms() -> list[str]:
51+
"""Retrieve effective list of platforms."""
52+
platforms = []
53+
for v in core.get_input("platforms", required=False).split(","):
54+
platform, run_on = v.split(":") if ":" in v else (v, None)
55+
if not platform:
56+
continue
57+
if run_on:
58+
core.debug(
59+
f"Add platform '{platform}' with run_on={run_on} to known platforms",
60+
)
61+
PLATFORM_MAP[platform] = run_on
62+
platforms.append(platform)
63+
return platforms
64+
65+
66+
def produce_output(output: dict[str, Any]) -> None:
67+
"""Produce the output."""
68+
if "TEST_GITHUB_OUTPUT_JSON" in os.environ:
69+
with Path(os.environ["TEST_GITHUB_OUTPUT_JSON"]).open(
70+
"w",
71+
encoding="utf-8",
72+
) as f:
73+
json.dump(output, f)
74+
for key, value in output.items():
75+
core.set_output(key, value)
76+
77+
3478
# loop list staring with given item
3579
# pylint: disable=too-many-locals,too-many-branches
36-
def main() -> None: # noqa: C901,PLR0912
80+
def main() -> None: # noqa: C901,PLR0912,PLR0915
3781
"""Main."""
3882
# print all env vars starting with INPUT_
3983
for k, v in os.environ.items():
4084
if k.startswith("INPUT_"):
4185
core.info(f"Env var {k}={v}")
4286
try:
4387
other_names = core.get_input("other_names", required=False).split("\n")
44-
platforms = core.get_input("platforms", required=False).split(",")
88+
platforms = get_platforms()
89+
core.info(f"Effective platforms: {platforms}")
90+
core.info(f"Platform map: {PLATFORM_MAP}")
91+
4592
min_python = core.get_input("min_python") or IMPLICIT_MIN_PYTHON
4693
max_python = core.get_input("max_python") or IMPLICIT_MAX_PYTHON
4794
default_python = core.get_input("default_python") or IMPLICIT_DEFAULT_PYTHON
4895
skip_explode = int(core.get_input("skip_explode") or IMPLICIT_SKIP_EXPLODE)
4996
strategies = {}
97+
5098
for platform in PLATFORM_MAP:
5199
strategies[platform] = core.get_input(platform, required=False)
52100

@@ -60,7 +108,15 @@ def main() -> None: # noqa: C901,PLR0912
60108
KNOWN_PYTHONS.index(min_python) : (KNOWN_PYTHONS.index(max_python) + 1)
61109
]
62110
python_flavours = len(python_names)
63-
core.debug("...")
111+
112+
def sort_key(s: str) -> tuple[int, str]:
113+
"""Sorts longer strings first."""
114+
return -len(s), s
115+
116+
# we put longer names first in order to pick the most specific platforms
117+
platform_names_sorted = sorted(PLATFORM_MAP.keys(), key=sort_key)
118+
core.info(f"Known platforms sorted: {platform_names_sorted}")
119+
64120
for line in other_names:
65121
name, _ = line.split(":", 1) if ":" in line else (line, f"tox -e {line}")
66122
commands = _.split(";")
@@ -70,7 +126,7 @@ def main() -> None: # noqa: C901,PLR0912
70126
if match:
71127
py_version = match.groups()[0]
72128
env_python = f"{py_version[0]}.{py_version[1:]}"
73-
for platform_name in PLATFORM_MAP:
129+
for platform_name in platform_names_sorted:
74130
if platform_name in name:
75131
break
76132
else:
@@ -93,7 +149,7 @@ def main() -> None: # noqa: C901,PLR0912
93149
if not skip_explode:
94150
for platform in platforms:
95151
for i, python in enumerate(python_names):
96-
py_name = re.sub(r"[^0-9]", "", python.strip("."))
152+
py_name = re.sub(r"\D", "", python.strip("."))
97153
suffix = "" if platform == IMPLICIT_PLATFORM else f"-{platform}"
98154
if strategies[platform] == "minmax" and (
99155
i not in (0, python_flavours - 1)
@@ -111,7 +167,7 @@ def main() -> None: # noqa: C901,PLR0912
111167
)
112168

113169
core.info(f"Generated {len(result)} matrix entries.")
114-
names = sorted(result.keys())
170+
names = sort_human(list(result.keys()))
115171
core.info(f"Job names: {', '.join(names)}")
116172
matrix_include = []
117173
matrix_include = [
@@ -120,26 +176,13 @@ def main() -> None: # noqa: C901,PLR0912
120176
core.info(
121177
f"Matrix jobs ordered by their name: {json.dumps(matrix_include, indent=2)}",
122178
)
123-
124-
core.set_output("matrix", {"include": matrix_include})
179+
output = {"matrix": {"include": matrix_include}}
180+
produce_output(output)
125181

126182
# pylint: disable=broad-exception-caught
127183
except Exception as exc: # noqa: BLE001
128184
core.set_failed(f"Action failed due to {exc}")
129185

130186

131187
if __name__ == "__main__":
132-
# only used for local testing, emulating use from github actions
133-
if os.getenv("GITHUB_ACTIONS") is None:
134-
os.environ["INPUT_DEFAULT_PYTHON"] = "3.10"
135-
os.environ["INPUT_LINUX"] = "full"
136-
os.environ["INPUT_MACOS"] = "minmax"
137-
os.environ["INPUT_MAX_PYTHON"] = "3.13"
138-
os.environ["INPUT_MIN_PYTHON"] = "3.8"
139-
os.environ["INPUT_OTHER_NAMES"] = (
140-
"lint\npkg\npy313-devel\nall-macos:tox -e unit;tox -e integration"
141-
)
142-
os.environ["INPUT_PLATFORMS"] = "linux,macos" # macos and windows
143-
os.environ["INPUT_SKIP_EXPLODE"] = "0"
144-
os.environ["INPUT_WINDOWS"] = "minmax"
145188
main()

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,10 +161,15 @@ lint.ignore = [
161161
"INP001", # "is part of an implicit namespace package", all false positives
162162
"PLW2901", # PLW2901: Redefined loop variable
163163
"RET504", # Unnecessary variable assignment before `return` statement
164+
"S603", # https://github.com/astral-sh/ruff/issues/4045
165+
164166
# temporary disabled until we fix them:
165167
]
166168
lint.select = ["ALL"]
167169

170+
[tool.ruff.lint.per-file-ignores]
171+
"tests/**/*.py" = ["SLF001", "S101", "FBT001"]
172+
168173
[tool.ruff.lint.pydocstyle]
169174
convention = "google"
170175

tests/test_action.py

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,75 @@
11
"""Tests for github action."""
22

3+
import json
4+
import os
35
import sys
6+
import tempfile
47
from subprocess import run
58

9+
import pytest
610

7-
def test_foo() -> None:
11+
12+
@pytest.mark.parametrize(
13+
("passed_env", "expected"),
14+
[
15+
pytest.param(
16+
{
17+
"INPUT_DEFAULT_PYTHON": "3.8",
18+
"INPUT_LINUX": "full",
19+
"INPUT_MACOS": "minmax",
20+
"INPUT_MAX_PYTHON": "3.8",
21+
"INPUT_MIN_PYTHON": "3.8",
22+
"INPUT_OTHER_NAMES": "z\nall-linux-arm64:tox -e unit;tox -e integration",
23+
"INPUT_PLATFORMS": "linux-arm64:ubuntu-24.04-arm64-2core",
24+
"INPUT_SKIP_EXPLODE": "1",
25+
"INPUT_WINDOWS": "minmax",
26+
},
27+
{
28+
"matrix": {
29+
"include": [
30+
{
31+
"command": "tox -e unit",
32+
"command2": "tox -e integration",
33+
"name": "all-linux-arm64",
34+
"os": "ubuntu-24.04-arm64-2core",
35+
"python_version": "3.8",
36+
},
37+
{
38+
"command": "tox -e z",
39+
"name": "z",
40+
"os": "ubuntu-24.04",
41+
"python_version": "3.8",
42+
},
43+
],
44+
},
45+
},
46+
id="1",
47+
),
48+
],
49+
)
50+
def test_action(passed_env: dict[str, str], expected: dict[str, str]) -> None:
851
"""Sample test."""
9-
run([sys.executable, "entrypoint.py"], check=True, shell=False) # noqa: S603
52+
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
53+
env = {
54+
**os.environ.copy(),
55+
**passed_env,
56+
"TEST_GITHUB_OUTPUT_JSON": temp_file.name,
57+
}
58+
59+
result = run(
60+
[sys.executable, "entrypoint.py"],
61+
text=True,
62+
shell=False,
63+
check=True,
64+
capture_output=True,
65+
env=env,
66+
)
67+
assert result.returncode == 0
68+
temp_file.seek(0)
69+
effective = temp_file.read().decode("utf-8")
70+
data = json.loads(effective)
71+
assert isinstance(data, dict), data
72+
assert len(data) == 1
73+
assert "matrix" in data
74+
assert data == expected
75+
# TestCase().assertDictEqual(data, expected)

0 commit comments

Comments
 (0)