from __future__ import annotations
import typing as typ
from pathlib import Path
import yaml
PULL_REQUEST_EVENTS: typ.Final = ("pull_request", "pull_request_target")
WORKFLOW_PREFIX: typ.Final = ".github/workflows/"
WORKFLOW_SUFFIXES: typ.Final = (".yml", ".yaml")
class StrictLoader(yaml.SafeLoader):
def construct_mapping(
self, node: yaml.MappingNode, deep: bool = False
) -> dict[typ.Hashable, typ.Any]:
seen: dict[typ.Hashable, yaml.Mark] = {}
for key_node, _ in node.value:
key = self.construct_object(key_node, deep=deep)
if key in seen:
raise yaml.constructor.ConstructorError(
"while constructing a mapping",
seen[key],
f"found duplicate key {key!r}",
key_node.start_mark,
)
seen[key] = key_node.start_mark
return super().construct_mapping(node, deep=deep)
def triggers(document: dict[typ.Any, typ.Any]) -> dict[str, typ.Any]:
return _event_map(_trigger_declaration(document))
def _trigger_declaration(document: dict[typ.Any, typ.Any]) -> object:
keys = [key for key in ("on", True) if key in document]
if len(keys) > 1:
msg = "the workflow declares its triggers under both `on` and `true`"
raise ValueError(msg)
return document[keys[0]] if keys else None
def _event_map(raw: object) -> dict[str, typ.Any]:
if isinstance(raw, dict):
return {str(event): config for event, config in raw.items()}
events = [raw] if isinstance(raw, str) else raw
if isinstance(events, list) and all(isinstance(name, str) for name in events):
return dict.fromkeys(events)
msg = f"unreadable trigger declaration: {raw!r}"
raise ValueError(msg)
class Workflow(typ.NamedTuple):
path: str
document: dict[typ.Any, typ.Any]
triggers: dict[str, typ.Any]
@classmethod
def parse(cls, path: str, text: str) -> Workflow:
document = yaml.load(text, Loader=StrictLoader)
if not isinstance(document, dict):
msg = f"{path} does not parse to a mapping"
raise TypeError(msg)
return cls(path, document, triggers(document))
@property
def on_pull_request(self) -> bool:
return any(event in self.triggers for event in PULL_REQUEST_EVENTS)
def jobs(self) -> list[tuple[str, dict[str, typ.Any]]]:
jobs = self.document.get("jobs")
pairs = jobs.items() if isinstance(jobs, dict) else ()
return [(str(name), job) for name, job in pairs if isinstance(job, dict)]
def steps(self) -> list[tuple[str, dict[str, typ.Any]]]:
return [
(name, step)
for name, job in self.jobs()
for step in job.get("steps") or []
if isinstance(step, dict)
]
def load_workflows(root: Path) -> list[Workflow]:
directory = root / WORKFLOW_PREFIX
found = [
Workflow.parse(
path.relative_to(root).as_posix(), path.read_text(encoding="utf-8")
)
for path in sorted(directory.iterdir())
if path.is_file() and path.suffix.lower() in WORKFLOW_SUFFIXES
]
assert found, f"no workflow parsed under {directory}"
return found