from __future__ import annotations
import itertools
import re
import typing as typ
QUOTED: typ.Final = re.compile(r"'(?:[^']|'')*'")
MASK: typ.Final = "\x00"
QUOTED_MASK: typ.Final = re.compile(MASK)
RUNNING_STATUS: typ.Final = frozenset({"success()", "always()", "!cancelled()"})
MATRIX_EQUALS: typ.Final = re.compile(r"matrix\.([\w-]+) == '([^']*)'")
PULL_REQUEST_EVENT: typ.Final = frozenset(
{
"github.event_name == 'pull_request'",
"github.event_name == 'pull_request_target'",
}
)
TRUNK_CONTEXT: typ.Final = frozenset(
{"github.ref == 'refs/heads/main'", "github.event_name == 'push'"}
)
Row = dict[str, str]
def conjuncts(condition: object) -> list[str] | None:
text = str(condition).strip()
if text.startswith("${{") and text.endswith("}}"):
text = text[3:-2]
literals = iter(QUOTED.findall(text))
masked = QUOTED.sub(MASK, text)
if "||" in masked:
return None
return [
QUOTED_MASK.sub(lambda _: next(literals), " ".join(part.split()))
for part in masked.split("&&")
]
def may_run(
job: dict[str, typ.Any],
step: dict[str, typ.Any],
context: frozenset[str] = PULL_REQUEST_EVENT,
) -> bool:
parts = _conjuncts_of(job.get("if"), step.get("if"))
if parts is None:
return False
pairs, others = _split_matrix(parts)
if not all(_holds(part, context) for part in others):
return False
return any(_satisfies(row, pairs) for row in matrix_rows(job))
def _conjuncts_of(*conditions: object) -> list[str] | None:
parts: list[str] = []
for condition in filter(_is_conditional, conditions):
found = conjuncts(condition)
if found is None:
return None
parts += found
return parts
def _is_conditional(condition: object) -> bool:
return condition is not None and condition is not True
def _split_matrix(parts: list[str]) -> tuple[list[tuple[str, str]], list[str]]:
found = [(part, MATRIX_EQUALS.fullmatch(part)) for part in parts]
pairs = [match.groups() for _, match in found if match is not None]
return pairs, [part for part, match in found if match is None]
def _holds(part: str, context: frozenset[str]) -> bool:
return part in RUNNING_STATUS or part in context
def matrix_rows(job: dict[str, typ.Any]) -> list[Row]:
matrix = (job.get("strategy") or {}).get("matrix")
if not isinstance(matrix, dict):
return [{}]
axes = _axes(matrix)
legs = _crossed(axes, _entries(matrix, "exclude"))
for entry in _entries(matrix, "include"):
_include(legs, entry, set(axes))
return legs or [{}]
def _axes(matrix: dict[str, typ.Any]) -> dict[str, list[str]]:
return {
str(key): [str(value) for value in values]
for key, values in matrix.items()
if _is_axis(key, values)
}
def _is_axis(key: object, values: object) -> bool:
return key not in {"include", "exclude"} and isinstance(values, list)
def _entries(matrix: dict[str, typ.Any], key: str) -> list[Row]:
return [_as_row(entry) for entry in matrix.get(key) or []]
def _crossed(axes: dict[str, list[str]], excluded: list[Row]) -> list[Row]:
combos = (dict(zip(axes, combo)) for combo in itertools.product(*axes.values()))
return [leg for leg in combos if not _excluded(leg, excluded)]
def _excluded(leg: Row, excluded: list[Row]) -> bool:
return any(_matches(leg, entry) for entry in excluded)
def _as_row(entry: object) -> Row:
return {str(key): str(value) for key, value in dict(entry).items()}
def _matches(leg: Row, entry: Row) -> bool:
return _satisfies(leg, entry.items())
def _satisfies(leg: Row, pairs: typ.Iterable[tuple[str, str]]) -> bool:
return all(leg.get(key) == value for key, value in pairs)
def _include(legs: list[Row], entry: Row, original: set[str]) -> None:
shared = [(key, value) for key, value in entry.items() if key in original]
fits = [leg for leg in legs if original and _satisfies(leg, shared)]
for leg in fits:
leg.update(entry)
if not fits:
legs.append(dict(entry))