exchange work
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
---
|
||||
name: ignition-exchange-convert
|
||||
description: Convert an existing Ignition project into a publishable Exchange resource — re-namespacing views/styles/scripts, migrating Perspective session properties to view properties, renaming Python to the guide's conventions — and prove the conversion did not break anything. Use when asked to make a project Exchange-ready, de-brand a project for publication, or refactor one out of session-scoped state. Bundles verify_refs.py and parity.py, which catch the two failure modes a conformance checker cannot see: dangling references and silently dropped features. Not for merely checking conformance (use ignition-exchange-conformance) or uploading (use ignition-exchange-publish).
|
||||
---
|
||||
|
||||
# Converting a project into an Exchange resource
|
||||
|
||||
A conversion is a large mechanical rename plus one genuine refactor. It is
|
||||
**very easy to produce a project that is perfectly named and completely broken** —
|
||||
so the verification gates below are not optional garnish, they are the point.
|
||||
|
||||
Check the target shape first with
|
||||
[`ignition-exchange-conformance`](../ignition-exchange-conformance/SKILL.md);
|
||||
this skill is what to do about the findings.
|
||||
|
||||
## Work on a copy
|
||||
|
||||
Never convert in place. Copy the project to its new name, leave the original
|
||||
untouched, and diff against it at the end. The original is your parity baseline —
|
||||
without it you cannot prove you did not drop a feature.
|
||||
|
||||
Ignition project folder name **is** the project name, so the copy's directory
|
||||
must already be the new lowercase-dashed name.
|
||||
|
||||
## Order matters
|
||||
|
||||
Do it in this order. Step 2 invalidates bindings that step 3 would otherwise
|
||||
have to rewrite twice.
|
||||
|
||||
1. **Delete what must not ship.** Vision `client-tags/`, scratch views, dev-only
|
||||
resources.
|
||||
2. **Session properties → view properties**, while paths are still the familiar
|
||||
old ones. Verify the app still works before touching namespaces.
|
||||
3. **Re-namespace** views, style classes, scripts, page config — one mechanical
|
||||
sweep.
|
||||
4. **Python pass** — tabs, camelCase, docstrings, loggers.
|
||||
5. **Content sweep** — branding, internal names, placeholder text.
|
||||
6. **Verify** (gates below), then package.
|
||||
|
||||
## Step 2 is the real refactor
|
||||
|
||||
Everything else is search-and-replace. This one changes how the app works.
|
||||
|
||||
The guide forbids session custom properties in an Exchange resource because
|
||||
importing merges them into the host project's session props. Replace with:
|
||||
|
||||
- **State lives on the root view** as `custom` properties.
|
||||
- **Flows down** to child views as `params` (the guide is explicit: `params` =
|
||||
public/configuration, `custom` = internal).
|
||||
- **Flows up** as page-scoped messages handled on the root view, named
|
||||
`exchange.resourceName.handlerName`.
|
||||
|
||||
**Bindings must stay reactive.** Do not park binding-read state in
|
||||
`system.util.getGlobals()` — globals do not trigger Perspective binding updates.
|
||||
Globals are only for non-reactive cross-session data, and most resources need
|
||||
none.
|
||||
|
||||
Before rewriting a binding, check **what it actually depended on**. A bidirectional
|
||||
binding that writes a session prop does not necessarily re-trigger the data fetch;
|
||||
if the fetch keyed off a `refreshToken`, then an explicit Apply button was already
|
||||
the commit point and your message-based version preserves behaviour exactly. Read
|
||||
the dependency graph rather than assuming.
|
||||
|
||||
## Jython 2.7 constraints
|
||||
|
||||
Gateway-scoped code is Jython 2.7. Violations are silent until they reach the gateway.
|
||||
|
||||
- **No f-strings.** `%` formatting only.
|
||||
- No `typing`, `statistics`, `zoneinfo`.
|
||||
- Java exceptions bypass `except Exception:` — use bare `except:` with
|
||||
`sys.exc_info()` in gateway-facing defensive code.
|
||||
- **Scripts embedded in `view.json`** are JSON strings that must be tab-indented:
|
||||
the first character of every line in the block is a tab.
|
||||
|
||||
## Python renames
|
||||
|
||||
The guide wants camelCase functions and variables (deliberately not PEP-8) and
|
||||
tab indentation. Two traps:
|
||||
|
||||
- **Rename through the tokenizer, not regex.** A blind replace will corrupt
|
||||
string literals and attribute names that happen to match (`lt.tm_wday`).
|
||||
- **Do not rename data-dictionary keys.** Keys inside dicts returned to view
|
||||
bindings are a wire format between the scripts and every binding that reads
|
||||
them. They are not covered by any rule and renaming them is a large, risky
|
||||
diff for no conformance gain.
|
||||
|
||||
Tab conversion applies to **indentation** only — bracket continuation lines
|
||||
starting with spaces are fine and normal. Verify with the tokenizer's INDENT
|
||||
tokens, not by grepping for leading spaces.
|
||||
|
||||
## Verification gates
|
||||
|
||||
Run all four. Each catches something the others cannot.
|
||||
|
||||
### 1 · Conformance
|
||||
|
||||
```bash
|
||||
python3 skills/ignition-exchange-conformance/exchange_lint.py <new-project>
|
||||
```
|
||||
Target: **0 failures**. Warnings are a judgement call.
|
||||
|
||||
### 2 · Referential integrity
|
||||
|
||||
```bash
|
||||
python3 skills/ignition-exchange-convert/verify_refs.py <new-project> --old <OldNamespace>
|
||||
```
|
||||
|
||||
Resolves every embedded view path, style-class reference, `view.params.X` /
|
||||
`view.custom.X`, and every call into the project library against the functions
|
||||
that actually exist. Also flags leftover old-namespace strings, surviving
|
||||
`session.custom` reads, untabbed script transforms, f-strings, and `resource.json`
|
||||
drift. Target: **0 breakages**.
|
||||
|
||||
With ~40 function renames, a missed call site is the single most likely defect,
|
||||
and nothing else finds it.
|
||||
|
||||
### 3 · Feature parity
|
||||
|
||||
```bash
|
||||
python3 skills/ignition-exchange-convert/parity.py <original-project> <new-project>
|
||||
```
|
||||
|
||||
Fingerprints every view — component types and names, bindings by target property
|
||||
and type, script transforms, event and message handlers, embedded views, declared
|
||||
params — with names normalised for case, punctuation and snake/camel. **A pure
|
||||
rename cancels out; a dropped feature does not.**
|
||||
|
||||
Target: **zero `LOST` or `MISSING` lines.** `ADDED` lines are expected — the
|
||||
session-props refactor legitimately adds params and handlers. Read the diff, do
|
||||
not just count it.
|
||||
|
||||
### 4 · Behaviour
|
||||
|
||||
If the project has a test suite over its pure modules, run it against the
|
||||
converted library. Point the package name at the new location and alias
|
||||
camelCase functions **and their keyword arguments** back to the original names —
|
||||
a renamed kwarg (`higher_is_worse` → `higherIsWorse`) breaks callers that pass it
|
||||
by name, which no static check catches.
|
||||
|
||||
Compare against the **original project's** result, not against zero failures. A
|
||||
pre-existing failure staying pre-existing is a pass.
|
||||
|
||||
### Then: load it on a gateway
|
||||
|
||||
Static analysis cannot prove a screen renders. Load the project and open it.
|
||||
A clean project load proves resources parse; only a browser proves components
|
||||
render.
|
||||
|
||||
## De-branding
|
||||
|
||||
Conformance checkers read names, never label text. Sweep user-visible strings
|
||||
explicitly — `props.text`, `title`, `placeholder`, `tooltip` — for company names,
|
||||
project codenames, contest or demo references, internal hostnames, ticket numbers.
|
||||
Strip the text rather than deleting the component, so layout and parity are
|
||||
unchanged.
|
||||
|
||||
## Testing a minimum-version claim
|
||||
|
||||
To claim a floor lower than the gateway you built on, load the project on that
|
||||
version — **against a copy of the files**. An older gateway rewrites every
|
||||
`resource.json` with `lastModification` metadata and leaves root-owned
|
||||
`.resources/` artifacts behind that your user account cannot delete (clean up
|
||||
with a throwaway container rather than sudo).
|
||||
|
||||
A clean load proves resources parse. It does **not** prove screens render — a
|
||||
component property introduced in a later version fails at render time, not load
|
||||
time. Open the client.
|
||||
|
||||
## Reporting
|
||||
|
||||
Report what the gates actually said, with numbers. A conversion reported
|
||||
accurately with two known gaps is worth far more than one claimed clean. If a
|
||||
gate is a false positive, say which and why — the tooling is not infallible and
|
||||
both bundled scripts have known blind spots documented in their docstrings.
|
||||
|
||||
## Provenance
|
||||
|
||||
Procedure and both scripts derived from a full conversion of a 20-view / 32-style
|
||||
/ 3-module Perspective project (2026-09-15), verified on Ignition 8.1.20 and
|
||||
8.3.7. The gate thresholds are the ones that actually caught defects during it.
|
||||
@@ -0,0 +1,264 @@
|
||||
#!/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())
|
||||
@@ -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