forked from b.peck/BAT
exchange work
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user