Release standalone PPT template workflow 0.5.0
This commit is contained in:
@@ -0,0 +1,430 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Inspect, export style or bind one local presentation template. Standard library only."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from copy import deepcopy
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
ID = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
||||
VERSION = re.compile(
|
||||
r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
|
||||
r"(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)"
|
||||
r"(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?"
|
||||
r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$"
|
||||
)
|
||||
COLOR = re.compile(r"^#[0-9A-Fa-f]{6}$")
|
||||
PAINT_SELECTOR = re.compile(r"^#[A-Za-z_][A-Za-z0-9_.:-]*$")
|
||||
ROUTES = ("native", "image-rebuild")
|
||||
|
||||
|
||||
class InputError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def require(condition: bool, message: str) -> None:
|
||||
if not condition:
|
||||
raise InputError(message)
|
||||
|
||||
|
||||
def string(value: object, label: str) -> str:
|
||||
require(isinstance(value, str) and bool(value.strip()), f"{label}: expected non-empty string")
|
||||
return value
|
||||
|
||||
|
||||
def identifier(value: object, label: str) -> str:
|
||||
value = string(value, label)
|
||||
require(bool(ID.fullmatch(value)), f"{label}: expected lowercase hyphenated ID")
|
||||
return value
|
||||
|
||||
|
||||
def strings(value: object, label: str, *, nonempty: bool = False) -> list[str]:
|
||||
require(isinstance(value, list), f"{label}: expected string array")
|
||||
for entry in value:
|
||||
string(entry, label)
|
||||
require(len(value) == len(set(value)), f"{label}: duplicate entries")
|
||||
require(not nonempty or bool(value), f"{label}: must not be empty")
|
||||
return value
|
||||
|
||||
|
||||
def read_json(path: Path, schemas: tuple[int, ...] | None = (1, 2)) -> dict:
|
||||
require(path.is_file(), f"Missing JSON file: {path}")
|
||||
|
||||
def unique_keys(pairs):
|
||||
result = {}
|
||||
for key, value in pairs:
|
||||
require(key not in result, f"{path.name}: duplicate JSON key {key}")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
def invalid_constant(value):
|
||||
raise InputError(f"{path.name}: invalid JSON constant {value}")
|
||||
|
||||
value = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_keys,
|
||||
parse_constant=invalid_constant)
|
||||
require(isinstance(value, dict), f"{path.name}: expected object")
|
||||
if schemas is not None:
|
||||
require(type(value.get("schema_version")) is int and value["schema_version"] in schemas,
|
||||
f"{path.name}: unsupported schema_version")
|
||||
return value
|
||||
|
||||
|
||||
def local_path(root: Path, value: object, label: str) -> Path:
|
||||
raw = string(value, label)
|
||||
relative = PurePosixPath(raw)
|
||||
require(not relative.is_absolute() and ".." not in relative.parts
|
||||
and "\\" not in raw and ":" not in raw and raw != ".",
|
||||
f"{label}: expected package-relative POSIX path")
|
||||
path = (root / raw).resolve()
|
||||
require(path.is_relative_to(root.resolve()), f"{label}: path escapes package")
|
||||
return path
|
||||
|
||||
|
||||
def file_path(root: Path, value: object, label: str) -> Path:
|
||||
path = local_path(root, value, label)
|
||||
require(path.is_file(), f"{label}: missing file {path}")
|
||||
return path
|
||||
|
||||
|
||||
def load_paint_contract(root: Path, asset: dict, role: str) -> tuple[Path, dict]:
|
||||
label = f"asset {role}.paint_contract"
|
||||
asset_path = file_path(root, asset.get("path"), f"asset {role}.path")
|
||||
require(asset_path.suffix.lower() == ".svg", f"{label}: only SVG assets support paint contracts")
|
||||
path = file_path(root, asset.get("paint_contract"), label)
|
||||
value = read_json(path, (1,))
|
||||
string(value.get("asset_id"), f"{label}.asset_id")
|
||||
require(value.get("default_policy") == "preserve", f"{label}.default_policy: expected preserve")
|
||||
rules = value.get("rules")
|
||||
require(isinstance(rules, list), f"{label}.rules: expected array")
|
||||
ids, channels = set(), set()
|
||||
for rule in rules:
|
||||
require(isinstance(rule, dict), f"{label}.rule: expected object")
|
||||
rule_id = string(rule.get("id"), f"{label}.rule.id")
|
||||
require(rule_id not in ids, f"{label}: duplicate rule ID {rule_id}")
|
||||
ids.add(rule_id)
|
||||
selector = string(rule.get("selector"), f"{label}.rule.selector")
|
||||
require(bool(PAINT_SELECTOR.fullmatch(selector)),
|
||||
f"{label}.rule.selector: expected #stable-ID")
|
||||
channel = rule.get("channel")
|
||||
require(channel in ("fill", "stroke"), f"{label}.rule.channel: expected fill or stroke")
|
||||
key = (selector, channel)
|
||||
require(key not in channels, f"{label}: duplicate selector/channel {selector}/{channel}")
|
||||
channels.add(key)
|
||||
policy = rule.get("policy")
|
||||
require(policy in ("palette", "fixed", "structural"), f"{label}.rule.policy: unknown policy")
|
||||
if policy == "palette":
|
||||
string(rule.get("token"), f"{label}.rule.token")
|
||||
else:
|
||||
require("token" not in rule, f"{label}.rule.token: only palette rules have tokens")
|
||||
literal = string(rule.get("value"), f"{label}.rule.value")
|
||||
if policy == "structural":
|
||||
require(literal == "none", f"{label}.rule.value: structural requires none")
|
||||
return path, value
|
||||
|
||||
|
||||
def load_theme(root: Path, entry: dict, theme_id: str) -> tuple[Path, dict]:
|
||||
path = file_path(root, entry.get("theme"), f"theme {theme_id}.theme")
|
||||
value = read_json(path, (1,))
|
||||
require(value.get("theme_id") == theme_id, f"theme {theme_id}: theme_id mismatch")
|
||||
palette = value.get("palette")
|
||||
require(isinstance(palette, dict) and bool(palette), "palette: expected non-empty object")
|
||||
for index, color in palette.items():
|
||||
string(index, "palette index")
|
||||
require(isinstance(color, str) and bool(COLOR.fullmatch(color)),
|
||||
f"palette {index}: expected #RRGGBB")
|
||||
assets = value.get("assets")
|
||||
require(isinstance(assets, dict), "assets: expected object")
|
||||
contracts = {}
|
||||
for role, asset in assets.items():
|
||||
string(role, "asset role")
|
||||
require(isinstance(asset, dict), f"asset {role}: expected object")
|
||||
file_path(root, asset.get("path"), f"asset {role}.path")
|
||||
if "paint_contract" in asset:
|
||||
_, contract = load_paint_contract(root, asset, role)
|
||||
contracts[role] = {(rule["selector"], rule["channel"]): rule for rule in contract["rules"]}
|
||||
bindings = value.get("asset_bindings")
|
||||
require(isinstance(bindings, list), "asset_bindings: expected array")
|
||||
bound_channels = set()
|
||||
for binding in bindings:
|
||||
require(isinstance(binding, dict), "asset binding: expected object")
|
||||
role = string(binding.get("asset"), "binding.asset")
|
||||
require(role in assets, f"Unknown asset role: {role}")
|
||||
targets = binding.get("targets")
|
||||
require(isinstance(targets, list) and bool(targets), "binding.targets: expected non-empty array")
|
||||
for target in targets:
|
||||
require(isinstance(target, dict), "binding target: expected object")
|
||||
string(target.get("selector"), "target.selector")
|
||||
require(target.get("channel") in ("fill", "stroke"), "target.channel: expected fill or stroke")
|
||||
index = string(target.get("palette_index"), "target.palette_index")
|
||||
require(index in palette, f"Unknown palette index: {index}")
|
||||
if role in contracts:
|
||||
key = (target["selector"], target["channel"])
|
||||
rule = contracts[role].get(key)
|
||||
require(rule is not None and rule["policy"] == "palette",
|
||||
f"asset {role}: target must match a palette paint rule: {key[0]}/{key[1]}")
|
||||
bound = (role, *key)
|
||||
require(bound not in bound_channels,
|
||||
f"asset {role}: duplicate palette binding {key[0]}/{key[1]}")
|
||||
bound_channels.add(bound)
|
||||
for role, rules in contracts.items():
|
||||
for (selector, channel), rule in rules.items():
|
||||
if rule["policy"] == "palette":
|
||||
require((role, selector, channel) in bound_channels,
|
||||
f"asset {role}: missing palette binding {selector}/{channel}")
|
||||
return path, value
|
||||
|
||||
|
||||
def load_manifest(path: Path) -> dict:
|
||||
value = read_json(path)
|
||||
identifier(value.get("template_id"), "template_id")
|
||||
require(bool(VERSION.fullmatch(string(value.get("version"), "version"))), "version: expected SemVer")
|
||||
for key in ("name", "description"):
|
||||
string(value.get(key), key)
|
||||
require(value.get("status") in ("proposal", "approved", "released", "deprecated"), "Invalid status")
|
||||
file_path(path.parent, value.get("spec"), "spec")
|
||||
require("structure" in value, "Missing structure field")
|
||||
if value["structure"] is not None:
|
||||
file_path(path.parent, value["structure"], "structure")
|
||||
v2 = value["schema_version"] == 2
|
||||
if v2:
|
||||
read_json(file_path(path.parent, value.get("style"), "style"), None)
|
||||
variants_key = "themes" if v2 else "modes"
|
||||
variants = value.get(variants_key)
|
||||
require(isinstance(variants, dict) and bool(variants), f"{variants_key}: expected non-empty object")
|
||||
for variant, entry in variants.items():
|
||||
identifier(variant, f"{variants_key} ID")
|
||||
require(isinstance(entry, dict), f"{variant}: expected object")
|
||||
string(entry.get("name"), f"{variant}.name")
|
||||
for key in ("workspace", "native_template"):
|
||||
require(key in entry, f"{variant}: missing {key}")
|
||||
if entry[key] is not None or (v2 and key == "workspace"):
|
||||
local_path(path.parent, entry[key], f"{variant}.{key}")
|
||||
if v2:
|
||||
load_theme(path.parent, entry, variant)
|
||||
layouts = value.get("layouts")
|
||||
require(isinstance(layouts, list) and bool(layouts), "layouts: expected non-empty array")
|
||||
seen = set()
|
||||
for entry in layouts:
|
||||
require(isinstance(entry, dict), "layout: expected object")
|
||||
key = identifier(entry.get("id"), "layout ID")
|
||||
require(key not in seen, f"Duplicate layout: {key}")
|
||||
seen.add(key)
|
||||
string(entry.get("name"), f"layout {key}.name")
|
||||
strings(entry.get("slots"), f"layout {key}.slots")
|
||||
routes = strings(value.get("routes"), "routes", nonempty=True)
|
||||
require(all(route in ROUTES for route in routes), "Unsupported route")
|
||||
policy = value.get("editable_policy")
|
||||
require(isinstance(policy, dict), "editable_policy: expected object")
|
||||
for key in ("native", "image_allowed", "disclose"):
|
||||
strings(policy.get(key), f"editable_policy.{key}")
|
||||
return value
|
||||
|
||||
|
||||
def summary(path: Path, value: dict) -> dict:
|
||||
# Preserve extension metadata for review; only declared paths are interpreted.
|
||||
return {**deepcopy(value), "manifest": str(path),
|
||||
"spec": str(file_path(path.parent, value["spec"], "spec"))}
|
||||
|
||||
|
||||
def digest(path: Path) -> str:
|
||||
with path.open("rb") as stream:
|
||||
checksum = hashlib.sha256()
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
checksum.update(chunk)
|
||||
return checksum.hexdigest()
|
||||
|
||||
|
||||
def selected_variant(value: dict, args: argparse.Namespace) -> tuple[str, dict]:
|
||||
v2 = value["schema_version"] == 2
|
||||
key, other = ("theme", "mode") if v2 else ("mode", "theme")
|
||||
require(getattr(args, other, None) is None, f"v{value['schema_version']} uses --{key}, not --{other}")
|
||||
selected = getattr(args, key, None)
|
||||
require(selected is not None, f"v{value['schema_version']} requires --{key}")
|
||||
variants = value["themes" if v2 else "modes"]
|
||||
require(selected in variants, f"Unknown {key}: {selected}")
|
||||
return selected, variants[selected]
|
||||
|
||||
|
||||
def selected_layout(value: dict, layout: str | None) -> dict | None:
|
||||
if layout is None:
|
||||
return None
|
||||
result = next((entry for entry in value["layouts"] if entry["id"] == layout), None)
|
||||
require(result is not None, f"Unknown layout: {layout}")
|
||||
return result
|
||||
|
||||
|
||||
def workspace_inputs(root: Path, workspace: Path) -> set[Path]:
|
||||
inputs = set()
|
||||
# Exports are derived evidence. Hash the adapter's actual authoring surfaces.
|
||||
for folder in ("templates", "images", "icons"):
|
||||
source = workspace / folder
|
||||
if not source.exists():
|
||||
continue
|
||||
require(source.resolve().is_relative_to(root), f"{folder}: path escapes package")
|
||||
for candidate in source.rglob("*"):
|
||||
resolved = candidate.resolve()
|
||||
require(resolved.is_relative_to(root), f"{candidate.name}: path escapes package")
|
||||
if resolved.is_file():
|
||||
inputs.add(resolved)
|
||||
return inputs
|
||||
|
||||
|
||||
def input_hashes(root: Path, inputs: set[Path]) -> list[dict]:
|
||||
return [{"path": str(item.relative_to(root)), "sha256": digest(item)} for item in sorted(inputs)]
|
||||
|
||||
|
||||
def style_inputs(root: Path, value: dict, variant: dict, selected: str) -> tuple[Path, dict, Path, dict, set[Path]]:
|
||||
style_path = file_path(root, value["style"], "style")
|
||||
style = read_json(style_path, None)
|
||||
theme_path, theme = load_theme(root, variant, selected)
|
||||
inputs = {style_path, theme_path}
|
||||
theme = deepcopy(theme)
|
||||
for role, asset in theme["assets"].items():
|
||||
asset_path = file_path(root, asset["path"], f"asset {role}.path")
|
||||
inputs.add(asset_path)
|
||||
if "paint_contract" in asset:
|
||||
contract_path, contract = load_paint_contract(root, asset, role)
|
||||
inputs.add(contract_path)
|
||||
asset["paint_contract"] = str(contract_path)
|
||||
asset["paint_contract_data"] = contract
|
||||
asset["path"] = str(asset_path)
|
||||
return style_path, style, theme_path, theme, inputs
|
||||
|
||||
|
||||
def export_style(path: Path, value: dict, args: argparse.Namespace) -> dict:
|
||||
require(value["schema_version"] == 2, "export-style requires a v2 manifest")
|
||||
selected, variant = selected_variant(value, args)
|
||||
layout = selected_layout(value, args.layout)
|
||||
root = path.parent
|
||||
style_path, style, theme_path, theme, inputs = style_inputs(root, value, variant, selected)
|
||||
spec = file_path(root, value["spec"], "spec")
|
||||
structure = file_path(root, value["structure"], "structure") if value["structure"] is not None else None
|
||||
workspace = local_path(root, variant["workspace"], "workspace")
|
||||
native = local_path(root, variant["native_template"], "native_template") if variant["native_template"] is not None else None
|
||||
inputs.update({path, spec})
|
||||
if structure is not None:
|
||||
inputs.add(structure)
|
||||
inputs.update(workspace_inputs(root, workspace))
|
||||
if native is not None and native.is_file():
|
||||
inputs.add(native)
|
||||
return {"schema_version": 2, "template_id": value["template_id"], "version": value["version"],
|
||||
"status": value["status"], "production_ready": value["status"] == "released",
|
||||
"manifest": str(path), "manifest_sha256": digest(path), "theme": selected,
|
||||
"layout": args.layout, "layout_definition": deepcopy(layout),
|
||||
"spec": str(spec), "structure": str(structure) if structure is not None else None,
|
||||
"workspace": str(workspace), "native_template": str(native) if native is not None else None,
|
||||
"style_path": str(style_path), "theme_path": str(theme_path),
|
||||
"style": style, "theme_data": theme, "editable_policy": deepcopy(value["editable_policy"]),
|
||||
"inputs": input_hashes(root, inputs),
|
||||
"verification_scope": "style review and alignment only; no PPT creation, selector execution or release acceptance"}
|
||||
|
||||
|
||||
def resolve(path: Path, value: dict, args: argparse.Namespace) -> dict:
|
||||
require(value["status"] == "released", f"NOT_RELEASED: status={value['status']}; inspect or export-style only")
|
||||
selected, variant = selected_variant(value, args)
|
||||
require(args.route in value["routes"], f"Route unavailable: {args.route}")
|
||||
selected_layout(value, args.layout)
|
||||
root = path.parent
|
||||
structure = file_path(root, value["structure"], "structure")
|
||||
spec = file_path(root, value["spec"], "spec")
|
||||
workspace = local_path(root, variant["workspace"], "workspace")
|
||||
require(workspace.is_dir(), f"Missing workspace: {workspace}")
|
||||
file_path(workspace, "templates/design_spec.md", "workspace specification")
|
||||
require(bool(list((workspace / "templates").glob("*.svg"))), "Workspace has no SVG prototypes")
|
||||
native = None
|
||||
v2 = value["schema_version"] == 2
|
||||
if variant["native_template"] is not None or not v2:
|
||||
native = file_path(root, variant["native_template"], "native_template")
|
||||
require(native.suffix.lower() == ".pptx", "native_template must be .pptx")
|
||||
require("exports" not in native.relative_to(root).parts,
|
||||
"native_template cannot use an exports review artifact")
|
||||
with zipfile.ZipFile(native) as package:
|
||||
require({"[Content_Types].xml", "ppt/presentation.xml", "ppt/_rels/presentation.xml.rels"}
|
||||
<= set(package.namelist()), "Incomplete PPTX ZIP members")
|
||||
inputs = {path, structure, spec} | workspace_inputs(root, workspace)
|
||||
if native is not None:
|
||||
inputs.add(native)
|
||||
result = {"schema_version": value["schema_version"], "template_id": value["template_id"],
|
||||
"version": value["version"], "manifest": str(path), "manifest_sha256": digest(path),
|
||||
"theme" if v2 else "mode": selected, "layout": args.layout, "route": args.route,
|
||||
"workflow": args.workflow, "workspace": str(workspace),
|
||||
"native_template": str(native) if native is not None else None,
|
||||
"spec": str(spec), "structure": str(structure),
|
||||
"verification_scope": "metadata, local paths, input hashes, workspace shape and declared PPTX ZIP members only"}
|
||||
if v2:
|
||||
style_path, _, theme_path, _, theme_files = style_inputs(root, value, variant, selected)
|
||||
inputs.update(theme_files)
|
||||
result.update(status=value["status"], production_ready=True,
|
||||
style_path=str(style_path), theme_path=str(theme_path))
|
||||
result["inputs"] = input_hashes(root, inputs)
|
||||
return result
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
listing = sub.add_parser("list")
|
||||
listing.add_argument("--catalog", type=Path, required=True)
|
||||
for command in ("inspect", "resolve", "export-style"):
|
||||
item = sub.add_parser(command)
|
||||
item.add_argument("--manifest", type=Path, required=True, action="append")
|
||||
if command != "inspect":
|
||||
selection = item.add_mutually_exclusive_group(required=True)
|
||||
selection.add_argument("--mode", action="append")
|
||||
selection.add_argument("--theme", action="append")
|
||||
item.add_argument("--layout", action="append")
|
||||
item.add_argument("--out", type=Path)
|
||||
if command == "resolve":
|
||||
item.add_argument("--route", choices=ROUTES, required=True)
|
||||
item.add_argument("--workflow", choices=("interactive-page", "batch"), required=True)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
if hasattr(args, "manifest"):
|
||||
require(len(args.manifest) == 1, "Only one --manifest may be active")
|
||||
args.manifest = args.manifest[0]
|
||||
# Reject repeated selectors instead of argparse's usual last-value-wins behavior.
|
||||
for key in ("mode", "theme", "layout"):
|
||||
selections = getattr(args, key, None)
|
||||
if selections is not None:
|
||||
require(len(selections) == 1, f"Only one --{key} may be active")
|
||||
setattr(args, key, selections[0])
|
||||
if args.command == "list":
|
||||
catalog = args.catalog.resolve()
|
||||
entries = read_json(catalog).get("templates")
|
||||
require(isinstance(entries, list), "templates: expected array")
|
||||
output, seen = [], set()
|
||||
for entry in entries:
|
||||
require(isinstance(entry, dict), "catalog entry: expected object")
|
||||
path = file_path(catalog.parent, entry.get("manifest"), "manifest")
|
||||
value = load_manifest(path)
|
||||
require(value["template_id"] not in seen, f"Duplicate template: {value['template_id']}")
|
||||
seen.add(value["template_id"])
|
||||
output.append(summary(path, value))
|
||||
else:
|
||||
path = args.manifest.resolve()
|
||||
value = load_manifest(path)
|
||||
if args.command == "inspect":
|
||||
output = summary(path, value)
|
||||
elif args.command == "export-style":
|
||||
output = export_style(path, value, args)
|
||||
else:
|
||||
output = resolve(path, value, args)
|
||||
encoded = json.dumps(output, ensure_ascii=False, indent=2) + "\n"
|
||||
if args.command in ("resolve", "export-style") and args.out is not None:
|
||||
with args.out.open("x", encoding="utf-8", newline="\n") as stream:
|
||||
stream.write(encoded)
|
||||
print(encoded, end="")
|
||||
return 0
|
||||
except (InputError, OSError, ValueError, zipfile.BadZipFile) as error:
|
||||
print(json.dumps({"error": str(error)}, ensure_ascii=False), file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user