mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 18:45:53 +00:00
420 lines
16 KiB
Python
Executable File
420 lines
16 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Validate and render the model-selected visual release-note plan."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
MAX_CAPTURES = 3
|
|
MAX_STEPS = 12
|
|
ALLOWED_LOCATOR_KINDS = {"role", "text", "label", "testid"}
|
|
ALLOWED_ACTIONS = {"click", "wait"}
|
|
ALLOWED_ROLES = {
|
|
"button",
|
|
"checkbox",
|
|
"dialog",
|
|
"heading",
|
|
"link",
|
|
"menuitem",
|
|
"option",
|
|
"radio",
|
|
"row",
|
|
"tab",
|
|
}
|
|
ID_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
|
|
|
|
class PlanError(ValueError):
|
|
pass
|
|
|
|
|
|
def _text(value: Any, field: str, *, maximum: int, required: bool = True) -> str:
|
|
if not isinstance(value, str):
|
|
raise PlanError(f"{field} must be a string")
|
|
value = value.strip()
|
|
if required and not value:
|
|
raise PlanError(f"{field} must not be empty")
|
|
if len(value) > maximum:
|
|
raise PlanError(f"{field} must be {maximum} characters or fewer")
|
|
if any(ord(character) < 32 for character in value):
|
|
raise PlanError(f"{field} must be one line without control characters")
|
|
if ";" in value or "\u2014" in value:
|
|
raise PlanError(f"{field} must not contain semicolons or em dashes")
|
|
return value
|
|
|
|
|
|
def _public_text(value: Any, field: str, *, maximum: int, required: bool = True) -> str:
|
|
value = _text(value, field, maximum=maximum, required=required)
|
|
if any(character in value for character in ("[", "]", "<", ">", "|")):
|
|
raise PlanError(f"{field} must be plain text without Markdown or HTML delimiters")
|
|
return value
|
|
|
|
|
|
def _locator(value: Any, field: str) -> dict[str, Any]:
|
|
if not isinstance(value, dict):
|
|
raise PlanError(f"{field} must be an object")
|
|
allowed = {"kind", "value", "role", "name", "exact", "nth"}
|
|
unknown = set(value) - allowed
|
|
if unknown:
|
|
raise PlanError(f"{field} has unsupported fields: {', '.join(sorted(unknown))}")
|
|
kind = value.get("kind")
|
|
if kind not in ALLOWED_LOCATOR_KINDS:
|
|
raise PlanError(f"{field}.kind must be one of {', '.join(sorted(ALLOWED_LOCATOR_KINDS))}")
|
|
normalized: dict[str, Any] = {"kind": kind}
|
|
if kind == "role":
|
|
role = _text(value.get("role"), f"{field}.role", maximum=32)
|
|
if role not in ALLOWED_ROLES:
|
|
raise PlanError(f"{field}.role is not supported")
|
|
normalized["role"] = role
|
|
normalized["name"] = _text(value.get("name"), f"{field}.name", maximum=120)
|
|
else:
|
|
normalized["value"] = _text(value.get("value"), f"{field}.value", maximum=160)
|
|
exact = value.get("exact", True)
|
|
if not isinstance(exact, bool):
|
|
raise PlanError(f"{field}.exact must be a boolean")
|
|
normalized["exact"] = exact
|
|
nth = value.get("nth", 0)
|
|
if not isinstance(nth, int) or isinstance(nth, bool) or not 0 <= nth <= 20:
|
|
raise PlanError(f"{field}.nth must be an integer from 0 to 20")
|
|
normalized["nth"] = nth
|
|
return normalized
|
|
|
|
|
|
def _state(value: Any, field: str) -> dict[str, Any]:
|
|
if not isinstance(value, dict):
|
|
raise PlanError(f"{field} must be an object")
|
|
unknown = set(value) - {"route", "steps", "ready"}
|
|
if unknown:
|
|
raise PlanError(f"{field} has unsupported fields: {', '.join(sorted(unknown))}")
|
|
route = _text(value.get("route"), f"{field}.route", maximum=240)
|
|
if (
|
|
not route.startswith("/")
|
|
or route.startswith("//")
|
|
or "://" in route
|
|
or "\\" in route
|
|
):
|
|
raise PlanError(f"{field}.route must be a same-origin absolute path")
|
|
steps = value.get("steps", [])
|
|
if not isinstance(steps, list) or len(steps) > MAX_STEPS:
|
|
raise PlanError(f"{field}.steps must be a list with at most {MAX_STEPS} entries")
|
|
normalized_steps = []
|
|
for index, step in enumerate(steps):
|
|
step_field = f"{field}.steps[{index}]"
|
|
if not isinstance(step, dict):
|
|
raise PlanError(f"{step_field} must be an object")
|
|
unknown_step = set(step) - {"action", "locator"}
|
|
if unknown_step:
|
|
raise PlanError(
|
|
f"{step_field} has unsupported fields: {', '.join(sorted(unknown_step))}"
|
|
)
|
|
action = step.get("action")
|
|
if action not in ALLOWED_ACTIONS:
|
|
raise PlanError(f"{step_field}.action must be click or wait")
|
|
normalized_steps.append(
|
|
{"action": action, "locator": _locator(step.get("locator"), f"{step_field}.locator")}
|
|
)
|
|
if value.get("ready") is None:
|
|
raise PlanError(f"{field}.ready must identify visible content in the captured view")
|
|
return {
|
|
"route": route,
|
|
"steps": normalized_steps,
|
|
"ready": _locator(value["ready"], f"{field}.ready"),
|
|
}
|
|
|
|
|
|
def validate_plan(raw: Any) -> dict[str, Any]:
|
|
if not isinstance(raw, dict):
|
|
raise PlanError("visual plan must be a JSON object")
|
|
unknown = set(raw) - {"schema_version", "decision", "captures"}
|
|
if unknown:
|
|
raise PlanError(f"visual plan has unsupported fields: {', '.join(sorted(unknown))}")
|
|
if raw.get("schema_version") != 1:
|
|
raise PlanError("visual plan schema_version must be 1")
|
|
decision = _text(raw.get("decision"), "visual plan decision", maximum=500)
|
|
captures = raw.get("captures")
|
|
if not isinstance(captures, list) or len(captures) > MAX_CAPTURES:
|
|
raise PlanError(f"visual plan captures must be a list with at most {MAX_CAPTURES} entries")
|
|
|
|
normalized_captures = []
|
|
seen_ids: set[str] = set()
|
|
for index, capture in enumerate(captures):
|
|
field = f"captures[{index}]"
|
|
if not isinstance(capture, dict):
|
|
raise PlanError(f"{field} must be an object")
|
|
unknown_capture = set(capture) - {
|
|
"id",
|
|
"title",
|
|
"description",
|
|
"viewport",
|
|
"before",
|
|
"after",
|
|
}
|
|
if unknown_capture:
|
|
raise PlanError(
|
|
f"{field} has unsupported fields: {', '.join(sorted(unknown_capture))}"
|
|
)
|
|
capture_id = _text(capture.get("id"), f"{field}.id", maximum=48)
|
|
if not ID_PATTERN.fullmatch(capture_id):
|
|
raise PlanError(f"{field}.id must be lower-case words separated by hyphens")
|
|
if capture_id in seen_ids:
|
|
raise PlanError(f"duplicate capture id: {capture_id}")
|
|
seen_ids.add(capture_id)
|
|
|
|
viewport = capture.get("viewport")
|
|
if not isinstance(viewport, dict) or set(viewport) != {"width", "height"}:
|
|
raise PlanError(f"{field}.viewport must contain only width and height")
|
|
width = viewport.get("width")
|
|
height = viewport.get("height")
|
|
if not isinstance(width, int) or isinstance(width, bool) or not 320 <= width <= 1920:
|
|
raise PlanError(f"{field}.viewport.width must be an integer from 320 to 1920")
|
|
if not isinstance(height, int) or isinstance(height, bool) or not 568 <= height <= 1440:
|
|
raise PlanError(f"{field}.viewport.height must be an integer from 568 to 1440")
|
|
|
|
before = capture.get("before")
|
|
normalized_captures.append(
|
|
{
|
|
"id": capture_id,
|
|
"title": _public_text(capture.get("title"), f"{field}.title", maximum=90),
|
|
"description": _public_text(
|
|
capture.get("description", ""),
|
|
f"{field}.description",
|
|
maximum=240,
|
|
required=False,
|
|
),
|
|
"viewport": {"width": width, "height": height},
|
|
"before": None if before is None else _state(before, f"{field}.before"),
|
|
"after": _state(capture.get("after"), f"{field}.after"),
|
|
}
|
|
)
|
|
return {
|
|
"schema_version": 1,
|
|
"decision": decision,
|
|
"captures": normalized_captures,
|
|
}
|
|
|
|
|
|
def load_plan(path: str) -> dict[str, Any]:
|
|
if path == "-":
|
|
raw_text = sys.stdin.read()
|
|
else:
|
|
raw_text = Path(path).read_text(encoding="utf-8")
|
|
try:
|
|
raw = json.loads(raw_text)
|
|
except json.JSONDecodeError as exc:
|
|
raise PlanError(f"visual plan is not valid JSON: {exc}") from exc
|
|
return validate_plan(raw)
|
|
|
|
|
|
def asset_names(plan: dict[str, Any]) -> list[str]:
|
|
names: list[str] = []
|
|
for capture in plan["captures"]:
|
|
if capture["before"] is not None:
|
|
names.append(f"release-note-{capture['id']}-before.png")
|
|
names.append(f"release-note-{capture['id']}-now.png")
|
|
return names
|
|
|
|
|
|
def json_schema() -> dict[str, Any]:
|
|
locator_position = {
|
|
"exact": {"type": "boolean"},
|
|
"nth": {"type": "integer", "minimum": 0, "maximum": 20},
|
|
}
|
|
locator = {
|
|
"anyOf": [
|
|
{
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"required": ["kind", "role", "name", "exact", "nth"],
|
|
"properties": {
|
|
"kind": {"const": "role", "type": "string"},
|
|
"role": {"enum": sorted(ALLOWED_ROLES), "type": "string"},
|
|
"name": {"type": "string", "minLength": 1, "maxLength": 120},
|
|
**locator_position,
|
|
},
|
|
},
|
|
{
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"required": ["kind", "value", "exact", "nth"],
|
|
"properties": {
|
|
"kind": {
|
|
"enum": sorted(ALLOWED_LOCATOR_KINDS - {"role"}),
|
|
"type": "string",
|
|
},
|
|
"value": {"type": "string", "minLength": 1, "maxLength": 160},
|
|
**locator_position,
|
|
},
|
|
},
|
|
]
|
|
}
|
|
state = {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"required": ["route", "steps", "ready"],
|
|
"properties": {
|
|
"route": {"type": "string", "minLength": 1, "maxLength": 240},
|
|
"steps": {
|
|
"type": "array",
|
|
"maxItems": MAX_STEPS,
|
|
"items": {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"required": ["action", "locator"],
|
|
"properties": {
|
|
"action": {"enum": sorted(ALLOWED_ACTIONS)},
|
|
"locator": locator,
|
|
},
|
|
},
|
|
},
|
|
"ready": locator,
|
|
},
|
|
}
|
|
return {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"required": ["schema_version", "decision", "captures"],
|
|
"properties": {
|
|
"schema_version": {"const": 1, "type": "integer"},
|
|
"decision": {"type": "string", "minLength": 1, "maxLength": 500},
|
|
"captures": {
|
|
"type": "array",
|
|
"maxItems": MAX_CAPTURES,
|
|
"items": {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"required": [
|
|
"id",
|
|
"title",
|
|
"description",
|
|
"viewport",
|
|
"before",
|
|
"after",
|
|
],
|
|
"properties": {
|
|
"id": {
|
|
"type": "string",
|
|
"maxLength": 48,
|
|
"pattern": ID_PATTERN.pattern,
|
|
},
|
|
"title": {"type": "string", "minLength": 1, "maxLength": 90},
|
|
"description": {"type": "string", "maxLength": 240},
|
|
"viewport": {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"required": ["width", "height"],
|
|
"properties": {
|
|
"width": {
|
|
"type": "integer",
|
|
"minimum": 320,
|
|
"maximum": 1920,
|
|
},
|
|
"height": {
|
|
"type": "integer",
|
|
"minimum": 568,
|
|
"maximum": 1440,
|
|
},
|
|
},
|
|
},
|
|
"before": {"anyOf": [state, {"type": "null"}]},
|
|
"after": state,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def render_markdown(plan: dict[str, Any], repository: str, tag: str) -> str:
|
|
if not plan["captures"]:
|
|
return ""
|
|
base = f"https://github.com/{repository}/releases/download/{tag}"
|
|
lines = ["## See the difference", ""]
|
|
for capture in plan["captures"]:
|
|
lines.extend([f"### {capture['title']}", ""])
|
|
if capture["description"]:
|
|
lines.extend([capture["description"], ""])
|
|
now_name = f"release-note-{capture['id']}-now.png"
|
|
if capture["before"] is None:
|
|
lines.extend(
|
|
[f"![{capture['title']}]({base}/{now_name})", ""]
|
|
)
|
|
continue
|
|
before_name = f"release-note-{capture['id']}-before.png"
|
|
lines.extend(
|
|
[
|
|
"| Before | Now |",
|
|
"| --- | --- |",
|
|
(
|
|
f"| ![{capture['title']} before]({base}/{before_name}) "
|
|
f"| ![{capture['title']} now]({base}/{now_name}) |"
|
|
),
|
|
"",
|
|
]
|
|
)
|
|
return "\n".join(lines).rstrip() + "\n"
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
|
|
validate_parser = subparsers.add_parser("validate")
|
|
validate_parser.add_argument("--plan", required=True)
|
|
validate_parser.add_argument("--output")
|
|
|
|
count_parser = subparsers.add_parser("count")
|
|
count_parser.add_argument("--plan", required=True)
|
|
|
|
before_count_parser = subparsers.add_parser("before-count")
|
|
before_count_parser.add_argument("--plan", required=True)
|
|
|
|
subparsers.add_parser("schema")
|
|
|
|
assets_parser = subparsers.add_parser("assets")
|
|
assets_parser.add_argument("--plan", required=True)
|
|
|
|
render_parser = subparsers.add_parser("render")
|
|
render_parser.add_argument("--plan", required=True)
|
|
render_parser.add_argument("--repository", required=True)
|
|
render_parser.add_argument("--tag", required=True)
|
|
render_parser.add_argument("--output")
|
|
|
|
args = parser.parse_args()
|
|
try:
|
|
if args.command == "schema":
|
|
sys.stdout.write(json.dumps(json_schema(), separators=(",", ":")) + "\n")
|
|
return 0
|
|
plan = load_plan(args.plan)
|
|
if args.command == "validate":
|
|
output = json.dumps(plan, indent=2) + "\n"
|
|
elif args.command == "count":
|
|
output = f"{len(plan['captures'])}\n"
|
|
elif args.command == "before-count":
|
|
output = f"{sum(capture['before'] is not None for capture in plan['captures'])}\n"
|
|
elif args.command == "assets":
|
|
output = "".join(f"{name}\n" for name in asset_names(plan))
|
|
else:
|
|
if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", args.repository):
|
|
raise PlanError("repository must be in owner/name form")
|
|
if not re.fullmatch(r"v[0-9A-Za-z][0-9A-Za-z._-]*", args.tag):
|
|
raise PlanError("tag is not a safe release tag")
|
|
output = render_markdown(plan, args.repository, args.tag)
|
|
if getattr(args, "output", None):
|
|
Path(args.output).write_text(output, encoding="utf-8")
|
|
else:
|
|
sys.stdout.write(output)
|
|
return 0
|
|
except (OSError, PlanError) as exc:
|
|
print(f"release-note visuals: {exc}", file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|