mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* feat(ci): enforce reusable workload plans * fix(ci): skip incompatible convergence upgrades
113 lines
3.8 KiB
Python
113 lines
3.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Render strict text templates owned by GitHub automation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
|
|
TEMPLATES_ROOT = Path(__file__).resolve().parents[1] / "templates"
|
|
ALLOWED_SUFFIXES = {".md", ".txt"}
|
|
PLACEHOLDER = re.compile(r"{{\s*([a-z][a-z0-9_]*)\s*}}")
|
|
|
|
|
|
class TemplateError(ValueError):
|
|
pass
|
|
|
|
|
|
def resolve_template(path_value: str) -> Path:
|
|
relative = Path(path_value)
|
|
if relative.is_absolute():
|
|
raise TemplateError("template path must be relative to .github/templates")
|
|
path = (TEMPLATES_ROOT / relative).resolve()
|
|
try:
|
|
path.relative_to(TEMPLATES_ROOT.resolve())
|
|
except ValueError as error:
|
|
raise TemplateError("template path must stay within .github/templates") from error
|
|
if path.suffix not in ALLOWED_SUFFIXES:
|
|
raise TemplateError("template must use a .md or .txt extension")
|
|
if not path.is_file():
|
|
raise TemplateError(f"template does not exist: {path_value}")
|
|
return path
|
|
|
|
|
|
def template_parameters(source: str) -> set[str]:
|
|
parameters = set(PLACEHOLDER.findall(source))
|
|
remainder = PLACEHOLDER.sub("", source)
|
|
if "{{" in remainder or "}}" in remainder:
|
|
raise TemplateError("template contains an invalid placeholder")
|
|
return parameters
|
|
|
|
|
|
def render_template(path_value: str, params: dict[str, str]) -> str:
|
|
source = resolve_template(path_value).read_text(encoding="utf-8")
|
|
expected = template_parameters(source)
|
|
missing = sorted(expected - params.keys())
|
|
unknown = sorted(params.keys() - expected)
|
|
if missing:
|
|
raise TemplateError(f"missing template parameters: {', '.join(missing)}")
|
|
if unknown:
|
|
raise TemplateError(f"unknown template parameters: {', '.join(unknown)}")
|
|
return PLACEHOLDER.sub(lambda match: params[match.group(1)], source)
|
|
|
|
|
|
def parse_args(argv: list[str]) -> tuple[str, dict[str, str], Path | None]:
|
|
if not argv or argv[0] in {"-h", "--help"}:
|
|
print(
|
|
"usage: template.py <path> [--params-<name> <value>]... [--output <path>]",
|
|
file=sys.stdout,
|
|
)
|
|
raise SystemExit(0)
|
|
|
|
path_value = argv[0]
|
|
params: dict[str, str] = {}
|
|
output: Path | None = None
|
|
index = 1
|
|
while index < len(argv):
|
|
argument = argv[index]
|
|
if "=" in argument:
|
|
option, value = argument.split("=", 1)
|
|
consumed = 1
|
|
else:
|
|
option = argument
|
|
if index + 1 >= len(argv):
|
|
raise TemplateError(f"missing value for {option}")
|
|
value = argv[index + 1]
|
|
consumed = 2
|
|
|
|
if option == "--output":
|
|
if output is not None:
|
|
raise TemplateError("--output may only be provided once")
|
|
output = Path(value)
|
|
elif option.startswith("--params-"):
|
|
name = option.removeprefix("--params-").replace("-", "_")
|
|
if not re.fullmatch(r"[a-z][a-z0-9_]*", name):
|
|
raise TemplateError(f"invalid template parameter option: {option}")
|
|
if name in params:
|
|
raise TemplateError(f"duplicate template parameter: {name}")
|
|
params[name] = value
|
|
else:
|
|
raise TemplateError(f"unknown option: {option}")
|
|
index += consumed
|
|
return path_value, params, output
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
try:
|
|
path_value, params, output = parse_args(sys.argv[1:] if argv is None else argv)
|
|
rendered = render_template(path_value, params)
|
|
if output is None:
|
|
sys.stdout.write(rendered)
|
|
else:
|
|
output.write_text(rendered, encoding="utf-8")
|
|
return 0
|
|
except TemplateError as error:
|
|
print(f"template error: {error}", file=sys.stderr)
|
|
return 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|