forked from b.peck/BAT
265 lines
10 KiB
Python
265 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""Functional-parity check between an original Ignition project and a converted copy.
|
|
|
|
Renaming is supposed to be behaviour-preserving. This compares what the two
|
|
projects DO, ignoring what things are called: every view, every component, every
|
|
binding, every event/message handler, every embedded view and style reference is
|
|
fingerprinted with names normalised (case, punctuation, snake/camel folded) so a
|
|
pure rename cancels out and a DROPPED FEATURE does not.
|
|
|
|
Usage: python3 parity.py <original_project> <converted_project>
|
|
"""
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
from collections import Counter
|
|
|
|
PERSP = "com.inductiveautomation.perspective"
|
|
EMBED = {"ia.display.view", "ia.display.flex-repeater"}
|
|
DIFFS = []
|
|
|
|
|
|
def diff(scope, msg):
|
|
DIFFS.append((scope, msg))
|
|
|
|
|
|
def norm(s):
|
|
"""Fold a name to bare lowercase alphanumerics so renames cancel out."""
|
|
return re.sub(r"[^a-z0-9]", "", str(s).lower())
|
|
|
|
|
|
def segs(path):
|
|
return [p for p in str(path).replace("\\", "/").split("/") if p]
|
|
|
|
|
|
def common_prefix_len(paths):
|
|
"""How many leading segments every resource shares -- i.e. the namespace.
|
|
|
|
Never eats the final segment, so a single-resource project still has a name.
|
|
"""
|
|
lists = [segs(p) for p in paths if segs(p)]
|
|
if not lists:
|
|
return 0
|
|
n = 0
|
|
while True:
|
|
if any(len(l) <= n + 1 for l in lists):
|
|
break
|
|
first = lists[0][n]
|
|
if any(l[n] != first for l in lists):
|
|
break
|
|
n += 1
|
|
return n
|
|
|
|
|
|
def key_of(path, strip):
|
|
"""Namespace-stripped identity: folds case, punctuation and re-foldering."""
|
|
return norm("".join(segs(path)[strip:]))
|
|
|
|
|
|
def collect(base, payload):
|
|
out = {}
|
|
if not os.path.isdir(base):
|
|
return out
|
|
for dp, _d, fs in os.walk(base):
|
|
if payload in fs:
|
|
rel = os.path.relpath(dp, base).replace(os.sep, "/")
|
|
if rel != ".":
|
|
out[rel] = os.path.join(dp, payload)
|
|
return out
|
|
|
|
|
|
def walk(node, visit, path="root"):
|
|
if not isinstance(node, dict):
|
|
return
|
|
visit(node, path)
|
|
for i, c in enumerate(node.get("children") or []):
|
|
n = (c.get("meta") or {}).get("name", "?") if isinstance(c, dict) else "?"
|
|
walk(c, visit, "%s/%s[%d]" % (path, n, i))
|
|
|
|
|
|
def view_fingerprint(vfile, embed_key):
|
|
"""A behaviour fingerprint for one view, insensitive to naming."""
|
|
view = json.load(open(vfile, encoding="utf-8"))
|
|
fp = {
|
|
"types": Counter(),
|
|
"names": Counter(),
|
|
"bindings": Counter(),
|
|
"transforms": Counter(),
|
|
"events": Counter(),
|
|
"handlers": Counter(),
|
|
"embeds": Counter(),
|
|
"stylerefs": 0,
|
|
"params": set(),
|
|
"components": 0,
|
|
}
|
|
|
|
def record_props(node, owner):
|
|
for key, cfg in (node.get("propConfig") or {}).items():
|
|
b = (cfg or {}).get("binding")
|
|
if not b:
|
|
continue
|
|
# the bound property is the feature; its binding type is how it is fed
|
|
fp["bindings"][(norm(key), b.get("type"))] += 1
|
|
for tr in (b.get("transforms") or []):
|
|
fp["transforms"][tr.get("type")] += 1
|
|
scripts = node.get("scripts") or {}
|
|
for ev in (scripts.get("customMethods") or []):
|
|
fp["events"][("method", norm(ev.get("name")))] += 1
|
|
for mh in (scripts.get("messageHandlers") or []):
|
|
fp["handlers"][norm((mh.get("messageType") or "").split(".")[-1])] += 1
|
|
for comp in (scripts.get("extensionFunctions") or {}):
|
|
fp["events"][("ext", norm(comp))] += 1
|
|
events = node.get("events") or {}
|
|
for domain, acts in events.items():
|
|
if isinstance(acts, dict):
|
|
for act in acts:
|
|
fp["events"][(norm(domain), norm(act))] += 1
|
|
|
|
record_props(view, "view")
|
|
for k in (view.get("propConfig") or {}):
|
|
if k.startswith("params."):
|
|
fp["params"].add(norm(k.split(".")[1].split("[")[0]))
|
|
for k in (view.get("params") or {}):
|
|
fp["params"].add(norm(k))
|
|
|
|
def visit(node, cpath):
|
|
if cpath != "root":
|
|
fp["components"] += 1
|
|
fp["types"][node.get("type")] += 1
|
|
nm = (node.get("meta") or {}).get("name")
|
|
if nm:
|
|
fp["names"][norm(nm)] += 1
|
|
record_props(node, cpath)
|
|
if node.get("type") in EMBED:
|
|
p = (node.get("props") or {}).get("path")
|
|
if isinstance(p, str) and p:
|
|
fp["embeds"][embed_key(p)] += 1
|
|
cls = ((node.get("props") or {}).get("style") or {}).get("classes")
|
|
if isinstance(cls, str):
|
|
fp["stylerefs"] += len([c for c in cls.split() if c])
|
|
walk(view.get("root") or {}, visit)
|
|
return fp
|
|
|
|
|
|
def compare_counter(scope, label, a, b):
|
|
lost = a - b
|
|
gained = b - a
|
|
for k, n in sorted(lost.items(), key=lambda kv: str(kv[0])):
|
|
diff(scope, "LOST %s %s x%d" % (label, k, n))
|
|
for k, n in sorted(gained.items(), key=lambda kv: str(kv[0])):
|
|
diff(scope, "ADDED %s %s x%d" % (label, k, n))
|
|
|
|
|
|
def main():
|
|
old_p, new_p = os.path.abspath(sys.argv[1]), os.path.abspath(sys.argv[2])
|
|
|
|
old_views = collect(os.path.join(old_p, PERSP, "views"), "view.json")
|
|
new_views = collect(os.path.join(new_p, PERSP, "views"), "view.json")
|
|
old_styles = collect(os.path.join(old_p, PERSP, "style-classes"), "style.json")
|
|
new_styles = collect(os.path.join(new_p, PERSP, "style-classes"), "style.json")
|
|
|
|
# Strip each project's own namespace so a pure re-namespacing cancels out.
|
|
ov_strip = common_prefix_len(old_views)
|
|
nv_strip = common_prefix_len(new_views)
|
|
os_strip = common_prefix_len(old_styles)
|
|
ns_strip = common_prefix_len(new_styles)
|
|
|
|
def index(d, strip):
|
|
out = {}
|
|
for k, v in d.items():
|
|
out.setdefault(key_of(k, strip), []).append((k, v))
|
|
return out
|
|
|
|
oi, ni = index(old_views, ov_strip), index(new_views, nv_strip)
|
|
for key in sorted(set(oi) - set(ni)):
|
|
diff("views", "MISSING view '%s' (no counterpart for %s)"
|
|
% (key, oi[key][0][0]))
|
|
for key in sorted(set(ni) - set(oi)):
|
|
diff("views", "EXTRA view '%s' (%s)" % (key, ni[key][0][0]))
|
|
|
|
# Style classes legitimately change shape (Grade/A -> grade-a, and
|
|
# E-STYLE-HIER re-folders them), so identity matching would be noise.
|
|
# What must not change is that none disappeared -- and verify_refs.py
|
|
# separately proves every remaining reference still resolves.
|
|
if len(old_styles) != len(new_styles):
|
|
diff("styles", "style class count %d -> %d" % (len(old_styles), len(new_styles)))
|
|
okeys = {key_of(k, os_strip) for k in old_styles}
|
|
nkeys = {key_of(k, ns_strip) for k in new_styles}
|
|
for k in sorted(okeys - nkeys):
|
|
diff("styles", "no class resembling '%s' in the new project" % k)
|
|
|
|
# ---- per-view behaviour ---------------------------------------------
|
|
for key in sorted(set(oi) & set(ni)):
|
|
ofp = view_fingerprint(oi[key][0][1], lambda p: key_of(p, ov_strip))
|
|
nfp = view_fingerprint(ni[key][0][1], lambda p: key_of(p, nv_strip))
|
|
scope = "view %s" % key
|
|
if ofp["components"] != nfp["components"]:
|
|
diff(scope, "component count %d -> %d"
|
|
% (ofp["components"], nfp["components"]))
|
|
if ofp["stylerefs"] != nfp["stylerefs"]:
|
|
diff(scope, "style class references %d -> %d"
|
|
% (ofp["stylerefs"], nfp["stylerefs"]))
|
|
for label in ("types", "names", "bindings", "transforms",
|
|
"events", "handlers", "embeds"):
|
|
compare_counter(scope, label[:-1] if label.endswith("s") else label,
|
|
ofp[label], nfp[label])
|
|
lost_params = ofp["params"] - nfp["params"]
|
|
if lost_params:
|
|
diff(scope, "params no longer declared: %s" % ", ".join(sorted(lost_params)))
|
|
|
|
# ---- library surface -------------------------------------------------
|
|
def lib(root):
|
|
out = {}
|
|
base = os.path.join(root, "ignition", "script-python")
|
|
for dp, _d, fs in os.walk(base):
|
|
if "code.py" not in fs:
|
|
continue
|
|
mod = os.path.basename(dp)
|
|
src = open(os.path.join(dp, "code.py"), encoding="utf-8",
|
|
errors="replace").read()
|
|
fns = {}
|
|
for m in re.finditer(r"^def\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]*)\)",
|
|
src, re.M):
|
|
args = [a.strip().split("=")[0].strip()
|
|
for a in m.group(2).split(",") if a.strip()]
|
|
fns[norm(m.group(1))] = len(args)
|
|
out[norm(mod)] = fns
|
|
return out
|
|
|
|
ol, nl = lib(old_p), lib(new_p)
|
|
for mod in sorted(set(ol) - set(nl)):
|
|
diff("library", "MISSING module %s" % mod)
|
|
for mod in sorted(set(ol) & set(nl)):
|
|
for fn in sorted(set(ol[mod]) - set(nl[mod])):
|
|
diff("library", "MISSING function %s.%s" % (mod, fn))
|
|
for fn in sorted(set(ol[mod]) & set(nl[mod])):
|
|
if ol[mod][fn] != nl[mod][fn]:
|
|
diff("library", "arity change %s.%s: %d -> %d args"
|
|
% (mod, fn, ol[mod][fn], nl[mod][fn]))
|
|
|
|
# ---- pages -----------------------------------------------------------
|
|
def pages(root):
|
|
p = os.path.join(root, PERSP, "page-config", "config.json")
|
|
if not os.path.exists(p):
|
|
return {}
|
|
return (json.load(open(p)) or {}).get("pages") or {}
|
|
op, np_ = pages(old_p), pages(new_p)
|
|
if len(op) != len(np_):
|
|
diff("pages", "page count %d -> %d" % (len(op), len(np_)))
|
|
ot = {key_of(v.get("viewPath", ""), ov_strip) for v in op.values()}
|
|
nt = {key_of(v.get("viewPath", ""), nv_strip) for v in np_.values()}
|
|
for k in sorted(ot - nt):
|
|
diff("pages", "no page maps to view %s any more" % k)
|
|
|
|
for scope, msg in DIFFS:
|
|
print("DIFF %-28s %s" % (scope, msg))
|
|
print("parity: %d view(s) vs %d, %d style(s) vs %d, %d difference(s)"
|
|
% (len(old_views), len(new_views), len(old_styles), len(new_styles),
|
|
len(DIFFS)))
|
|
return 1 if DIFFS else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|