Files
mobcat40-ComfyUI-PromptChain/scripts/validate_templates.py
T
chrmc40 9fd6498ba6 Publish snapshot: v1->v2 character port + accumulated fixes
- Tag-builder: port ~1,477 v1 characters into the v2 chip format so the
  character picker shows 1,486 characters (was 9); 1,390 carry rich per-chip
  descriptions restructured from each character's own bio. Adds
  scripts/port_v1_characters_to_v2.py.
- Ideogram: rewrite caption to the real Ideogram-4 schema + broaden refusal
  detection; sampler updates.
- Pose-studio, regional-detailer, attention-couple: assorted fixes.
- Add validate_templates / test_regions / test_crop_windows scripts.
2026-06-28 20:49:04 -07:00

155 lines
5.8 KiB
Python

#!/usr/bin/env python3
"""Structural validator for PromptChain workflow templates.
Templates are the contract surface between every model architecture and the
regional/depth/pose nodes — a dangling link or bad slot index silently breaks a
template in the picker. This catches those before they ship; run it on /deploy.
It validates STRUCTURE, not render semantics (it can't tell a depth ControlNet
is wired to the wrong stream — only a render does). Checks:
- valid JSON, required top-level fields
- scope.type is one of the known kinds, with the fields that kind needs
- every node has a string `type`
- every connection references existing node indices and non-negative slots
- anchorConnections reference existing nodes/slots
- template `id` is unique across the directory
- duplicate (name + scope) pairs are reported (two indistinguishable entries)
Exit code 0 = clean, 1 = at least one error. Warnings never fail the build.
"""
from __future__ import annotations
import json
import os
import sys
from collections import defaultdict
TEMPLATES_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"data", "templates")
SCOPE_TYPES = {"architecture", "family", "version", "model"}
SCOPE_REQUIRED = {
"architecture": ["architecture"],
"family": ["architecture", "family"],
"version": ["model_hash"],
"model": ["model_name"], # display_name is accepted as an alias below
}
def _validate(path: str, errors: list, warnings: list, ids: dict, signatures: dict) -> None:
name = os.path.basename(path)
try:
with open(path, "r", encoding="utf-8") as f:
tpl = json.load(f)
except (OSError, json.JSONDecodeError) as e:
errors.append(f"{name}: not valid JSON — {e}")
return
if not isinstance(tpl, dict):
errors.append(f"{name}: top level must be an object")
return
if not tpl.get("name"):
errors.append(f"{name}: missing 'name'")
tid = tpl.get("id")
if not tid:
errors.append(f"{name}: missing 'id'")
elif tid in ids:
errors.append(f"{name}: duplicate id '{tid}' (also in {ids[tid]})")
else:
ids[tid] = name
scope = tpl.get("scope")
if not isinstance(scope, dict):
errors.append(f"{name}: missing/invalid 'scope'")
else:
st = scope.get("type", "architecture")
if st not in SCOPE_TYPES:
errors.append(f"{name}: scope.type '{st}' not one of {sorted(SCOPE_TYPES)}")
else:
req = SCOPE_REQUIRED.get(st, [])
for key in req:
if key == "model_name" and (scope.get("model_name") or scope.get("display_name")):
continue
if not scope.get(key):
errors.append(f"{name}: scope.type '{st}' requires '{key}'")
nodes = tpl.get("nodes")
if not isinstance(nodes, list) or not nodes:
errors.append(f"{name}: 'nodes' must be a non-empty array")
nodes = []
for i, node in enumerate(nodes):
if not isinstance(node, dict) or not isinstance(node.get("type"), str) or not node.get("type"):
errors.append(f"{name}: node[{i}] missing a string 'type'")
n = len(nodes)
def _check_link(label: str, link: dict, has_from: bool) -> None:
if not isinstance(link, dict):
errors.append(f"{name}: {label} is not an object")
return
if has_from:
fi = link.get("from_node_idx")
if not isinstance(fi, int) or not (0 <= fi < n):
errors.append(f"{name}: {label} from_node_idx {fi} out of range (0..{n - 1})")
for slot_key in ("from_slot", "to_slot"):
sv = link.get(slot_key)
if not isinstance(sv, int) or sv < 0:
errors.append(f"{name}: {label} {slot_key} {sv!r} must be a non-negative int")
ti = link.get("to_node_idx")
if not isinstance(ti, int) or not (0 <= ti < n):
errors.append(f"{name}: {label} to_node_idx {ti} out of range (0..{n - 1})")
conns = tpl.get("connections", [])
if not isinstance(conns, list):
errors.append(f"{name}: 'connections' must be an array")
else:
for j, link in enumerate(conns):
_check_link(f"connections[{j}]", link, has_from=True)
anchors = tpl.get("anchorConnections", [])
if isinstance(anchors, list):
for j, link in enumerate(anchors):
_check_link(f"anchorConnections[{j}]", link, has_from=False)
# Two templates the matcher can't tell apart (same name + identical scope)
# both surface for the same model — usually an authoring mistake.
if isinstance(scope, dict) and tpl.get("name"):
sig = (tpl.get("name"), json.dumps(scope, sort_keys=True))
signatures[sig].append(name)
def main() -> int:
if not os.path.isdir(TEMPLATES_DIR):
print(f"templates dir not found: {TEMPLATES_DIR}")
return 1
errors: list = []
warnings: list = []
ids: dict = {}
signatures: dict = defaultdict(list)
files = sorted(f for f in os.listdir(TEMPLATES_DIR)
if f.endswith(".json") and not f.startswith("_"))
for f in files:
_validate(os.path.join(TEMPLATES_DIR, f), errors, warnings, ids, signatures)
for sig, names in signatures.items():
if len(names) > 1:
warnings.append(f"same name+scope on {len(names)} templates: {', '.join(names)} "
f"(name={sig[0]!r})")
print(f"validated {len(files)} templates")
for w in warnings:
print(f" WARN {w}")
for e in errors:
print(f" ERROR {e}")
if errors:
print(f"\n{len(errors)} error(s)")
return 1
print("OK — no structural errors" + (f" ({len(warnings)} warning(s))" if warnings else ""))
return 0
if __name__ == "__main__":
sys.exit(main())