218 lines
8.8 KiB
Python
218 lines
8.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Referential-integrity check for a migrated Ignition project.
|
|
|
|
The style checker tests NAMES. This tests that everything still POINTS somewhere
|
|
real after a namespace + function rename: embedded views, style classes, view
|
|
params, and every call into the project library. A migration can be perfectly
|
|
named and completely broken; this is the half that catches that.
|
|
|
|
Usage: python3 verify_refs.py <project_dir> [--old OldNamespace]
|
|
"""
|
|
import ast
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
PERSP = "com.inductiveautomation.perspective"
|
|
EMBED = {"ia.display.view", "ia.display.flex-repeater"}
|
|
FAILS = []
|
|
|
|
|
|
def fail(where, msg):
|
|
FAILS.append((where, msg))
|
|
|
|
|
|
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 main():
|
|
proj = os.path.abspath(sys.argv[1])
|
|
# The project's previous namespace, e.g. "MyCompany". Any surviving mention
|
|
# of it in the converted project is a missed rename.
|
|
old = ""
|
|
if "--old" in sys.argv:
|
|
old = sys.argv[sys.argv.index("--old") + 1]
|
|
|
|
views_root = os.path.join(proj, PERSP, "views")
|
|
styles_root = os.path.join(proj, PERSP, "style-classes")
|
|
sp_root = os.path.join(proj, "ignition", "script-python")
|
|
|
|
def collect(base, payload):
|
|
out = set()
|
|
for dp, _d, fs in os.walk(base):
|
|
if payload in fs:
|
|
r = os.path.relpath(dp, base)
|
|
if r != ".":
|
|
out.add(r.replace(os.sep, "/"))
|
|
return out
|
|
|
|
views = collect(views_root, "view.json")
|
|
styles = collect(styles_root, "style.json")
|
|
|
|
# ---- project library: package path -> {function names} ----------------
|
|
lib = {}
|
|
for dp, _d, fs in os.walk(sp_root):
|
|
if "code.py" not in fs:
|
|
continue
|
|
pkg = os.path.relpath(dp, sp_root).replace(os.sep, ".")
|
|
src = open(os.path.join(dp, "code.py"), encoding="utf-8",
|
|
errors="replace").read()
|
|
try:
|
|
tree = ast.parse(src)
|
|
lib[pkg] = {n.name for n in tree.body
|
|
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))}
|
|
except SyntaxError as e:
|
|
fail(pkg + "/code.py", "does not parse: line %s %s" % (e.lineno, e.msg))
|
|
lib[pkg] = set()
|
|
if re.search(r"""(^|[^A-Za-z0-9_'"])[fF]["']""", src):
|
|
fail(pkg + "/code.py", "f-string present (Jython 2.7 cannot parse)")
|
|
|
|
lib_roots = {p.split(".")[0] for p in lib}
|
|
|
|
# ---- per view ---------------------------------------------------------
|
|
for vp in sorted(views):
|
|
vfile = os.path.join(views_root, vp.replace("/", os.sep), "view.json")
|
|
where = "views/" + vp
|
|
raw = open(vfile, encoding="utf-8", errors="replace").read()
|
|
try:
|
|
view = json.loads(raw)
|
|
except Exception as e:
|
|
fail(where, "invalid JSON: %s" % e)
|
|
continue
|
|
|
|
if old and old in raw:
|
|
hits = len(re.findall(re.escape(old), raw))
|
|
fail(where, "%d leftover reference(s) to old namespace %r" % (hits, old))
|
|
if "session.custom" in raw:
|
|
fail(where, "still reads session.custom (Exchange resources must not)")
|
|
|
|
declared = set((view.get("params") or {}).keys())
|
|
declared_custom = set((view.get("custom") or {}).keys())
|
|
for k in (view.get("propConfig") or {}):
|
|
if k.startswith("params."):
|
|
declared.add(k.split(".")[1].split("[")[0])
|
|
elif k.startswith("custom."):
|
|
declared_custom.add(k.split(".")[1].split("[")[0])
|
|
|
|
def visit(node, cpath):
|
|
if node.get("type") in EMBED:
|
|
p = (node.get("props") or {}).get("path")
|
|
bound = "props.path" in (node.get("propConfig") or {})
|
|
if isinstance(p, str) and p:
|
|
if p not in views:
|
|
fail(where, "%s embeds missing view %r" % (cpath, p))
|
|
elif not bound:
|
|
pass
|
|
cls = ((node.get("props") or {}).get("style") or {}).get("classes")
|
|
if isinstance(cls, str):
|
|
for c in cls.split():
|
|
if c and c not in styles:
|
|
fail(where, "%s references missing style class %r" % (cpath, c))
|
|
walk(view.get("root") or {}, visit)
|
|
|
|
# bindings: params/custom resolution, tab indent, library calls
|
|
def each_cfg():
|
|
for k, v in (view.get("propConfig") or {}).items():
|
|
yield "view." + k, v
|
|
acc = []
|
|
walk(view.get("root") or {},
|
|
lambda n, p: [acc.append(("%s %s" % (p, k), v))
|
|
for k, v in (n.get("propConfig") or {}).items()])
|
|
for it in acc:
|
|
yield it
|
|
|
|
for owner, cfg in each_cfg():
|
|
b = (cfg or {}).get("binding")
|
|
if not b:
|
|
continue
|
|
blobs = [json.dumps(b.get("config") or {})]
|
|
for tr in b.get("transforms") or []:
|
|
code = tr.get("code", "")
|
|
blobs.append(code)
|
|
if tr.get("type") == "script" and code and not code.startswith(("\t", "\n")):
|
|
fail(where, "%s: script transform not tab-indented" % owner)
|
|
blob = " ".join(blobs)
|
|
for m in re.finditer(r"view\.params\.([A-Za-z0-9_]+)", blob):
|
|
if m.group(1) not in declared:
|
|
fail(where, "%s: undeclared view param %r" % (owner, m.group(1)))
|
|
for m in re.finditer(r"view\.custom\.([A-Za-z0-9_]+)", blob):
|
|
if m.group(1) not in declared_custom:
|
|
fail(where, "%s: undeclared view custom prop %r" % (owner, m.group(1)))
|
|
|
|
check_lib_calls(raw, where, lib, lib_roots)
|
|
|
|
# ---- library calling itself ------------------------------------------
|
|
for dp, _d, fs in os.walk(sp_root):
|
|
if "code.py" not in fs:
|
|
continue
|
|
p = os.path.join(dp, "code.py")
|
|
rel = os.path.relpath(p, proj)
|
|
src = open(p, encoding="utf-8", errors="replace").read()
|
|
if old and old in src:
|
|
fail(rel, "leftover reference to old namespace %r" % old)
|
|
check_lib_calls(src, rel, lib, lib_roots)
|
|
|
|
# ---- resource.json integrity -----------------------------------------
|
|
for dp, ds, fs in os.walk(proj):
|
|
if ".resources" in ds:
|
|
fail(os.path.relpath(dp, proj), "authored .resources directory")
|
|
ds.remove(".resources")
|
|
if "resource.json" not in fs:
|
|
continue
|
|
rel = os.path.relpath(dp, proj)
|
|
try:
|
|
res = json.load(open(os.path.join(dp, "resource.json")))
|
|
except Exception as e:
|
|
fail(rel, "invalid resource.json: %s" % e)
|
|
continue
|
|
declared = res.get("files") or []
|
|
payloads = [f for f in fs if f != "resource.json"]
|
|
for f in declared:
|
|
if f not in payloads:
|
|
fail(rel, "resource.json declares missing file %r" % f)
|
|
for f in payloads:
|
|
if f not in declared:
|
|
fail(rel, "undeclared payload file %r" % f)
|
|
want = "A" if ("script-python" in rel or "global-props" in rel) else "G"
|
|
if res.get("scope") != want:
|
|
fail(rel, "scope %r, expected %r" % (res.get("scope"), want))
|
|
|
|
for where, msg in FAILS:
|
|
print("BROKEN %s: %s" % (where, msg))
|
|
print("refs: %d view(s), %d style class(es), %d library module(s), %d breakage(s)"
|
|
% (len(views), len(styles), len(lib), len(FAILS)))
|
|
return 1 if FAILS else 0
|
|
|
|
|
|
def check_lib_calls(text, where, lib, lib_roots):
|
|
"""Every <root>.<pkg>.<fn> reference must resolve to a real function."""
|
|
for root in lib_roots:
|
|
# (?<![.\w]) so a property path like session.custom.Foo.bar is not
|
|
# mistaken for a call into a library package named Foo.
|
|
for m in re.finditer(r"(?<![.\w])" + re.escape(root)
|
|
+ r"((?:\.[A-Za-z_][A-Za-z0-9_]*)+)", text):
|
|
parts = m.group(1).lstrip(".").split(".")
|
|
for split in range(len(parts) - 1, 0, -1):
|
|
pkg = ".".join([root] + parts[:split])
|
|
fn = parts[split]
|
|
if pkg in lib:
|
|
if fn not in lib[pkg]:
|
|
fail(where, "calls %s.%s which does not exist in %s/code.py"
|
|
% (pkg, fn, pkg.replace(".", "/")))
|
|
break
|
|
# No prefix resolves to a library module, so this is not a library
|
|
# reference at all. The same dotted namespace is used for message
|
|
# types and popup ids (exchange.<resource>.<handler>), which are
|
|
# strings, not code -- flagging those would be a false positive.
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|