Files

207 lines
7.9 KiB
Python
Raw Permalink Normal View History

2026-08-05 09:14:34 +02:00
#!/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('<?xml version="1.0" encoding="UTF-8"?>')
a('<bpmn:definitions xmlns:bpmn="%s" xmlns:bpmndi="%s" xmlns:di="%s" '
'xmlns:dc="%s" xmlns:pr="%s" id="defs_%s" targetNamespace="%s" '
'exporter="generate_bpmn.py">' % (BPMN, BPMNDI, DI, DC, ns, pid, ns))
a(' <bpmn:process id="%s" name="%s" isExecutable="false">'
% (pid, escape(proc["name"])))
doc = "%s | Scope: %s | Trigger: %s" % (
proc["name"], proc.get("scope", ""), proc.get("trigger", ""))
a(' <bpmn:documentation>%s</bpmn:documentation>' % 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(' <bpmn:%s %s>' % (tag, attrs))
rules = n.get("rules") or []
if rules:
a(' <bpmn:extensionElements>')
a(' <pr:rules>')
for r in rules:
a(' <pr:rule>%s</pr:rule>' % r)
a(' </pr:rules>')
a(' </bpmn:extensionElements>')
for fid in incoming.get(n["id"], []):
a(' <bpmn:incoming>%s</bpmn:incoming>' % fid)
for fid in outgoing.get(n["id"], []):
a(' <bpmn:outgoing>%s</bpmn:outgoing>' % fid)
a(' </bpmn:%s>' % tag)
for f in proc["flows"]:
nm = ' name="%s"' % escape(f["condition"]) if f.get("condition") else ""
a(' <bpmn:sequenceFlow id="%s"%s sourceRef="%s" targetRef="%s" />'
% (f["_id"], nm, f["from"], f["to"]))
a(' </bpmn:process>')
pos = layout(proc)
a(' <bpmndi:BPMNDiagram id="diagram_%s">' % pid)
a(' <bpmndi:BPMNPlane id="plane_%s" bpmnElement="%s">' % (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(' <bpmndi:BPMNShape id="shape_%s" bpmnElement="%s">' % (n["id"], n["id"]))
a(' <dc:Bounds x="%d" y="%d" width="%d" height="%d" />' % (x, y, w, h))
a(' </bpmndi:BPMNShape>')
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(' <bpmndi:BPMNEdge id="edge_%s" bpmnElement="%s">' % (f["_id"], f["_id"]))
a(' <di:waypoint x="%d" y="%d" />' % (sx + sw, sy + sh // 2))
a(' <di:waypoint x="%d" y="%d" />' % (tx, ty + th // 2))
a(' </bpmndi:BPMNEdge>')
a(' </bpmndi:BPMNPlane>')
a(' </bpmndi:BPMNDiagram>')
a('</bpmn:definitions>')
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()