#!/usr/bin/env python3 """ Generate strict, queryable BPMN 2.0 files from processes.yaml (EV-008). USAGE python3 generate_bpmn.py [output_dir] One file per procedure, in bpmn/. Rule identifiers are carried in extensionElements under the project namespace, so a rule can be traced to every procedure that enforces it with a single XPath expression: //bpmn:process[.//pr:rule='TN-002']/@id Diagram interchange is emitted with a simple left-to-right layout so the files open directly in any BPMN editor. Layout is cosmetic and never authoritative. """ import os import sys from xml.sax.saxutils import escape import yaml HERE = os.path.dirname(os.path.abspath(__file__)) SRC = os.path.join(HERE, "processes.yaml") OUTDIR = sys.argv[1] if len(sys.argv) > 1 else os.path.join(HERE, "bpmn") BPMN = "http://www.omg.org/spec/BPMN/20100524/MODEL" BPMNDI = "http://www.omg.org/spec/BPMN/20100524/DI" DI = "http://www.omg.org/spec/DD/20100524/DI" DC = "http://www.omg.org/spec/DD/20100524/DC" TAG = { "start": "startEvent", "end": "endEvent", "userTask": "userTask", "scriptTask": "scriptTask", "callActivity": "callActivity", "gateway": "exclusiveGateway", } W = {"start": 36, "end": 36, "gateway": 50} H = {"start": 36, "end": 36, "gateway": 50} DEFAULT_W, DEFAULT_H = 150, 70 COL, ROW = 200, 130 def selfcheck(doc, rule_ids): """A procedure must be a connected graph, and every rule it cites must exist.""" problems = [] ids = {p["id"] for p in doc["processes"]} for p in doc["processes"]: nodes = {n["id"] for n in p["flow"]} for f in p["flows"]: for side in ("from", "to"): if f[side] not in nodes: problems.append("%s: flow references unknown node %s" % (p["id"], f[side])) targets = {f["to"] for f in p["flows"]} sources = {f["from"] for f in p["flows"]} for n in p["flow"]: if n["type"] == "start" and n["id"] in targets: problems.append("%s: start event %s has an incoming flow" % (p["id"], n["id"])) if n["type"] == "end" and n["id"] in sources: problems.append("%s: end event %s has an outgoing flow" % (p["id"], n["id"])) if n["type"] not in ("start", "end") and n["id"] not in targets: problems.append("%s: node %s is unreachable" % (p["id"], n["id"])) if n["type"] != "end" and n["id"] not in sources: problems.append("%s: node %s is a dead end" % (p["id"], n["id"])) if n["type"] == "callActivity" and n.get("calls") not in ids: problems.append("%s: %s calls unknown procedure %s" % (p["id"], n["id"], n.get("calls"))) for r in n.get("rules") or []: if rule_ids and r not in rule_ids: problems.append("%s/%s: unknown rule %s" % (p["id"], n["id"], r)) return problems def layout(proc): """Assign a column by longest path from start, a row to disambiguate siblings.""" succ = {} for f in proc["flows"]: succ.setdefault(f["from"], []).append(f["to"]) starts = [n["id"] for n in proc["flow"] if n["type"] == "start"] depth = {} for s in starts: stack = [(s, 0)] seen = set() while stack: node, d = stack.pop() if (node, d) in seen: continue seen.add((node, d)) if depth.get(node, -1) < d: depth[node] = d for nxt in succ.get(node, []): if d < 60: stack.append((nxt, d + 1)) used = {} pos = {} for n in proc["flow"]: c = depth.get(n["id"], 0) r = used.get(c, 0) used[c] = r + 1 pos[n["id"]] = (60 + c * COL, 80 + r * ROW) return pos def build(proc, ns): pid = proc["id"] o = [] a = o.append a('') a('' % (BPMN, BPMNDI, DI, DC, ns, pid, ns)) a(' ' % (pid, escape(proc["name"]))) doc = "%s | Scope: %s | Trigger: %s" % ( proc["name"], proc.get("scope", ""), proc.get("trigger", "")) a(' %s' % escape(doc)) incoming, outgoing = {}, {} for i, f in enumerate(proc["flows"], start=1): fid = "flow_%d" % i f["_id"] = fid outgoing.setdefault(f["from"], []).append(fid) incoming.setdefault(f["to"], []).append(fid) for n in proc["flow"]: tag = TAG[n["type"]] attrs = 'id="%s" name="%s"' % (n["id"], escape(n["name"])) if n["type"] == "callActivity": attrs += ' calledElement="%s"' % n["calls"] a(' ' % (tag, attrs)) rules = n.get("rules") or [] if rules: a(' ') a(' ') for r in rules: a(' %s' % r) a(' ') a(' ') for fid in incoming.get(n["id"], []): a(' %s' % fid) for fid in outgoing.get(n["id"], []): a(' %s' % fid) a(' ' % tag) for f in proc["flows"]: nm = ' name="%s"' % escape(f["condition"]) if f.get("condition") else "" a(' ' % (f["_id"], nm, f["from"], f["to"])) a(' ') pos = layout(proc) a(' ' % pid) a(' ' % (pid, pid)) for n in proc["flow"]: x, y = pos[n["id"]] w, h = W.get(n["type"], DEFAULT_W), H.get(n["type"], DEFAULT_H) a(' ' % (n["id"], n["id"])) a(' ' % (x, y, w, h)) a(' ') for f in proc["flows"]: sx, sy = pos[f["from"]] tx, ty = pos[f["to"]] sw = W.get(next(n["type"] for n in proc["flow"] if n["id"] == f["from"]), DEFAULT_W) sh = H.get(next(n["type"] for n in proc["flow"] if n["id"] == f["from"]), DEFAULT_H) th = H.get(next(n["type"] for n in proc["flow"] if n["id"] == f["to"]), DEFAULT_H) a(' ' % (f["_id"], f["_id"])) a(' ' % (sx + sw, sy + sh // 2)) a(' ' % (tx, ty + th // 2)) a(' ') a(' ') a(' ') a('') return "\n".join(o) + "\n" def main(): doc = yaml.safe_load(open(SRC, encoding="utf-8")) rule_ids = set() rb = os.path.join(HERE, "rules.yaml") if os.path.exists(rb): rule_ids = {r["id"] for r in yaml.safe_load(open(rb, encoding="utf-8"))["rules"]} problems = selfcheck(doc, rule_ids) if problems: print("SELF-CHECK FAILED") for p in problems: print(" " + p) sys.exit(1) os.makedirs(OUTDIR, exist_ok=True) ns = doc["meta"]["namespace"] for proc in doc["processes"]: path = os.path.join(OUTDIR, "%s.bpmn" % proc["id"]) open(path, "w", encoding="utf-8").write(build(proc, ns)) n_rules = len({r for n in proc["flow"] for r in (n.get("rules") or [])}) print(" %-4s %-46s %2d nodes, %2d flows, %2d rules" % (proc["id"], proc["name"], len(proc["flow"]), len(proc["flows"]), n_rules)) print("self-check passed: %d procedures, graphs connected, every rule known" % len(doc["processes"])) if __name__ == "__main__": main()