Files
BAT/exchangeResources/exchange_lint.py
2026-09-15 13:06:45 -05:00

1244 lines
52 KiB
Python
Executable File

#!/usr/bin/env python3
"""exchange_lint.py — conformance checker for the Ignition Exchange Resources Style Guide.
Tests whether an Ignition project on disk (8.1 project folder layout) follows the
naming, structure, and storage conventions in "Exchange Resources Style Guide"
v1.1.0, and whether it is ready to upload to the Exchange.
Every finding carries a rule id (E-...) so it can be traced back to a section of
the guide; run with --list-rules to print the full table.
Severity follows the guide's own language:
FAIL the guide says "must", or the rule protects importability into someone
else's project (resource namespacing, session/client-tag storage).
warn a naming convention -- the guide "encourages" these but states they are
not required. --strict promotes every warning to a failure.
Targets: a project folder, any folder containing projects (a gateway's
projects/ directory, an unpacked gateway backup, a source repo), or a
project-export .zip. The resource name is inferred from the namespace the
project actually uses, so no per-project configuration is needed.
Usage:
python3 exchange_lint.py # scan the current directory
python3 exchange_lint.py /path/to/MyProject
python3 exchange_lint.py MyResource.zip --strict
python3 exchange_lint.py /gateway/projects # every project found
python3 exchange_lint.py --list-rules
Exit status: 0 clean, 1 findings, 2 bad invocation.
Stdlib only; no third-party dependencies.
"""
import argparse
import ast
import io
import json
import os
import re
import sys
import tokenize
PERSPECTIVE = "com.inductiveautomation.perspective"
VISION = "com.inductiveautomation.vision"
# ---------------------------------------------------------------- rule table
RULES = [
# id, section, description
("E-PROJ-NAME", "Projects", "project name lowercase, dashes, URL-friendly, no version suffix"),
("E-PROJ-TITLE", "Projects", "project title human-friendly Title Case"),
("E-PROJ-DESC", "Upload", "project description filled in (feeds the Exchange overview)"),
("E-NS-ROOT", "Resource Structure", "every resource under Exchange/<ResourceName>/ or exchange/<resource-name>/"),
("E-NS-CASE", "Resource Structure", "correct case of the namespace root for the resource type"),
("E-NS-NAME", "Resource Structure", "second path segment is the resource name"),
("E-VIEW-NAME", "Perspective", "view names Title Case or PascalCase"),
("E-COMP-NAME", "Perspective", "component names PascalCase"),
("E-PROP-NAME", "Perspective", "custom/param property names camelCase"),
("E-PAGE-NAME", "Perspective", "page names lowercase with dashes"),
("E-PAGE-ROOT", "Perspective", "page '/' claims the root URL of any importing project"),
("E-STYLE-NAME", "Perspective", "style class names lowercase with dashes"),
("E-STYLE-HIER", "Perspective", "style class placement mirrors the views that use it"),
("E-MSG-NAME", "Perspective", "message handlers namespaced exchange.resourceName.handlerName"),
("E-STORE-SESS", "Variable Storage", "no Perspective session custom properties (blocks clean import)"),
("E-STORE-CTAG", "Variable Storage", "no Vision client tags (blocks clean import)"),
("E-STORE-GLOB", "Variable Storage", "getGlobals() keyed ['exchange']['resourceName']"),
("E-TAG-NAME", "Tags", "tag folders/names PascalCase or Title Case under Exchange/<Resource>"),
("E-VIS-NAME", "Vision", "window/template names Title Case or PascalCase"),
("E-PY-TABS", "Code", "Python indented with tabs, not spaces"),
("E-PY-LIB", "Code", "project library packages lowercase, single words"),
("E-PY-FUNC", "Code", "function names camelCase"),
("E-PY-CLASS", "Code", "class names PascalCase"),
("E-PY-VAR", "Code", "variable names camelCase"),
("E-PY-DOC", "Code", "public functions/classes carry a docstring"),
("E-PY-DOCFMT", "Code", "docstring summary ends with . ? or !"),
("E-PY-COMMENT", "Code", "comments start with '# ' (hash then space)"),
("E-PY-PARSE", "Code", "source parses (checks below it are skipped otherwise)"),
("E-LOG-NAME", "Loggers", "logger names exchange.resourceName[...].PascalCaseName"),
("E-DB-TABLE", "Database", "table names ex_<abbrev>_snake_case"),
("E-DB-COL", "Database", "column names snake_case"),
("E-DB-ID", "Database", "every table has an 'id' primary key"),
("E-DB-FK", "Database", "foreign key columns named <foreign_table>_id"),
("E-NQ-NAME", "Named Queries", "named query names Title Case or PascalCase"),
("E-NQ-PARAM", "Named Queries", "named query parameters camelCase"),
("E-WEB-NAME", "WebDev", "WebDev sources lowercase with dashes"),
("E-I18N-KEY", "Translation", "translation keys 'word' or '_translation_phrase'"),
("E-UP-DOCS", "Upload", "installation/README documentation present in the resource"),
]
RULE_SECTIONS = {r[0]: r[1] for r in RULES}
VALID_RULES = set(RULE_SECTIONS)
# Resource types and the case their namespace root uses, per the guide's
# Project Browser screenshots (uppercase Exchange/ for designer resources that
# are themselves capitalised; lowercase exchange/ for URL- and code-facing ones).
UPPER = "Exchange"
LOWER = "exchange"
# ------------------------------------------------------------ name predicates
RX_PASCAL = re.compile(r"^[A-Z][A-Za-z0-9]*$")
RX_CAMEL = re.compile(r"^[a-z][A-Za-z0-9]*$")
RX_KEBAB = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
RX_SNAKE = re.compile(r"^[a-z][a-z0-9_]*$")
RX_CONST = re.compile(r"^[A-Z][A-Z0-9_]*$")
RX_PROJECT = re.compile(r"^[a-z0-9]+([-_][a-z0-9]+)*$")
RX_VERSION_SUFFIX = re.compile(r"[-_. ]v?\d+([._]\d+)*$", re.IGNORECASE)
# Title Case tolerates lowercase joining words in non-initial position.
SMALL_WORDS = {"a", "an", "and", "as", "at", "by", "for", "in", "of", "on",
"or", "the", "to", "vs", "with"}
def is_pascal(s):
return bool(RX_PASCAL.match(s))
def is_camel(s):
return bool(RX_CAMEL.match(s))
def is_kebab(s):
return bool(RX_KEBAB.match(s))
def is_title(s):
words = s.split(" ")
if len(words) < 2 or any(not w for w in words):
return False
for i, w in enumerate(words):
if RX_PASCAL.match(w):
continue
if i > 0 and w.lower() in SMALL_WORDS:
continue
return False
return True
def is_title_or_pascal(s):
"""The guide lets top level resources be either Title Case or PascalCase."""
return is_pascal(s) or is_title(s)
def norm(s):
"""Fold a name to bare lowercase alphanumerics for cross-style comparison."""
return re.sub(r"[^a-z0-9]", "", (s or "").lower())
def pascalize(s):
return "".join(p[:1].upper() + p[1:] for p in re.split(r"[-_ ]+", s or "") if p)
def kebabize(s):
s = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "-", s or "")
return re.sub(r"[-_ ]+", "-", s).lower()
# ------------------------------------------------------------------- findings
class Report(object):
def __init__(self, ignore=None, strict=False):
self.findings = [] # (severity, rule, where, message)
self.ignore = ignore or set()
self.strict = strict
self.checked = set()
def _add(self, sev, rule, where, msg):
self.checked.add(rule)
if rule in self.ignore:
return
if sev == "warn" and self.strict:
sev = "FAIL"
self.findings.append((sev, rule, where, msg))
def fail(self, rule, where, msg):
self._add("FAIL", rule, where, msg)
def warn(self, rule, where, msg):
self._add("warn", rule, where, msg)
def touch(self, *rules):
"""Record that a rule ran even though it produced no finding."""
self.checked.update(rules)
@property
def fails(self):
return [f for f in self.findings if f[0] == "FAIL"]
@property
def warns(self):
return [f for f in self.findings if f[0] == "warn"]
# --------------------------------------------------------------- file helpers
def load_json(rep, path, where):
try:
with open(path) as f:
return json.load(f)
except Exception as e:
rep.fail("E-PY-PARSE", where, "invalid JSON: %s" % e)
return None
def read_text(path):
with open(path, "r", encoding="utf-8", errors="replace") as f:
return f.read()
def find_payload_dirs(root, payload):
"""Yield path-segment tuples for every directory under root holding payload."""
out = []
if not os.path.isdir(root):
return out
for dirpath, _dirs, files in os.walk(root):
if payload in files:
rel = os.path.relpath(dirpath, root)
if rel != ".":
out.append(tuple(rel.split(os.sep)))
return sorted(out)
def find_resource_dirs(root):
"""Yield path-segment tuples for every leaf directory holding a resource.json."""
out = []
if not os.path.isdir(root):
return out
for dirpath, _dirs, files in os.walk(root):
if "resource.json" in files:
rel = os.path.relpath(dirpath, root)
if rel != ".":
out.append(tuple(rel.split(os.sep)))
return sorted(out)
def walk_components(node, visit, path="root"):
"""Depth-first over a Perspective view component tree; visit(node, path)."""
if not isinstance(node, dict):
return
visit(node, path)
for i, child in enumerate(node.get("children") or []):
name = "?"
if isinstance(child, dict):
name = (child.get("meta") or {}).get("name", "?")
walk_components(child, visit, "%s/%s[%d]" % (path, name, i))
# ------------------------------------------------------------ namespace check
def check_namespace(rep, kind, parts, where, want_case, resource, name_style):
"""Check <Exchange|exchange>/<ResourceName>/... on one resource path.
parts path segments below the resource-type root
want_case UPPER or LOWER -- the expected spelling of the first segment
name_style "title-pascal" or "kebab" -- expected style of the resource segment
Returns the segments whose names still need style checking: those below
the namespace when it is correct, or below the wrong root when it is not.
"""
if len(parts) < 2:
rep.fail("E-NS-ROOT", where,
"%s is not inside a %s/<resource>/ folder (imports will collide "
"with the target project)" % (kind, want_case))
return list(parts)
root, name = parts[0], parts[1]
if norm(root) != "exchange":
rep.fail("E-NS-ROOT", where,
"%s namespace root is %r, expected %r" % (kind, root, want_case))
# Keep checking the names below the (wrong) root so a project that has
# not been namespaced yet still gets its naming findings.
return list(parts[1:])
rep.touch("E-NS-ROOT")
if root != want_case:
rep.warn("E-NS-CASE", where,
"namespace root %r should be %r for %s" % (root, want_case, kind))
else:
rep.touch("E-NS-CASE")
if resource and norm(name) != norm(resource):
rep.warn("E-NS-NAME", where,
"resource folder %r does not match resource name %r" % (name, resource))
else:
rep.touch("E-NS-NAME")
if name_style == "kebab" and not is_kebab(name):
rep.warn("E-NS-NAME", where,
"resource folder %r should be lowercase-with-dashes" % name)
elif name_style == "title-pascal" and not is_title_or_pascal(name):
rep.warn("E-NS-NAME", where,
"resource folder %r should be Title Case or PascalCase" % name)
elif name_style == "camel" and not is_camel(name):
rep.warn("E-NS-NAME", where,
"resource folder %r should be lowercase or camelCase "
"(exchange.resourceName.scriptName)" % name)
return parts[2:]
# ------------------------------------------------------------------- sections
def check_project(rep, proj_dir, proj_name):
"""Projects: name, title, description."""
where = "project.json"
if not RX_PROJECT.match(proj_name):
rep.fail("E-PROJ-NAME", where,
"project name %r must be lowercase letters, numbers, hyphens and "
"underscores only" % proj_name)
elif RX_VERSION_SUFFIX.search(proj_name):
rep.warn("E-PROJ-NAME", where,
"project name %r looks version-suffixed; the Exchange manages "
"versions on upload" % proj_name)
else:
rep.touch("E-PROJ-NAME")
pj = os.path.join(proj_dir, "project.json")
if not os.path.exists(pj):
rep.warn("E-PROJ-TITLE", where, "no project.json found")
return
meta = load_json(rep, pj, where) or {}
title = (meta.get("title") or "").strip()
if not title:
rep.warn("E-PROJ-TITLE", where, "project title is empty")
elif not (is_title(title) or is_pascal(title)):
rep.warn("E-PROJ-TITLE", where,
"project title %r should be human-friendly Title Case" % title)
else:
rep.touch("E-PROJ-TITLE")
if not (meta.get("description") or "").strip():
rep.warn("E-PROJ-DESC", where,
"project description is empty; the Exchange builds the resource "
"readme from it")
else:
rep.touch("E-PROJ-DESC")
def check_perspective(rep, proj_dir, resource):
"""Perspective: views, components, properties, pages, style classes."""
views_root = os.path.join(proj_dir, PERSPECTIVE, "views")
styles_root = os.path.join(proj_dir, PERSPECTIVE, "style-classes")
view_paths = find_payload_dirs(views_root, "view.json")
style_paths = find_payload_dirs(styles_root, "style.json")
# ---- views -----------------------------------------------------------
for parts in view_paths:
where = "views/" + "/".join(parts)
tail = check_namespace(rep, "view", parts, where, UPPER, resource, "title-pascal")
for seg in (tail if tail is not None else []):
if not is_title_or_pascal(seg):
rep.warn("E-VIEW-NAME", where,
"view path segment %r should be Title Case or PascalCase" % seg)
else:
rep.touch("E-VIEW-NAME")
check_view_file(rep, os.path.join(views_root, *parts), where, resource)
# ---- style classes ---------------------------------------------------
for parts in style_paths:
where = "style-classes/" + "/".join(parts)
tail = check_namespace(rep, "style class", parts, where, LOWER, resource, "kebab")
for seg in (tail if tail is not None else []):
if not is_kebab(seg):
rep.warn("E-STYLE-NAME", where,
"style class segment %r should be lowercase-with-dashes" % seg)
else:
rep.touch("E-STYLE-NAME")
check_style_hierarchy(rep, views_root, view_paths, style_paths)
# ---- pages -----------------------------------------------------------
pc_path = os.path.join(proj_dir, PERSPECTIVE, "page-config", "config.json")
if os.path.exists(pc_path):
pc = load_json(rep, pc_path, "page-config/config.json") or {}
for url in (pc.get("pages") or {}):
where = "page-config/config.json"
if url == "/":
rep.warn("E-PAGE-ROOT", where,
"page '/' takes over the root URL of any project this "
"resource is imported into")
continue
rep.touch("E-PAGE-ROOT")
for seg in [s for s in url.split("/") if s]:
if seg.startswith(":"): # path parameter
continue
if not is_kebab(seg):
rep.warn("E-PAGE-NAME", where,
"page url segment %r should be lowercase-with-dashes" % seg)
else:
rep.touch("E-PAGE-NAME")
# ---- session properties ---------------------------------------------
sp_path = os.path.join(proj_dir, PERSPECTIVE, "session-props", "props.json")
if os.path.exists(sp_path):
sp = load_json(rep, sp_path, "session-props/props.json") or {}
custom = sp.get("custom") or {}
leaves = _leaf_paths(custom)
if leaves:
rep.fail("E-STORE-SESS", "session-props/props.json",
"%d custom session propert%s (%s%s) -- the guide directs "
"resources to use view properties and system.util.getGlobals() "
"instead, so importing does not merge into the host project's "
"session props"
% (len(leaves), "y" if len(leaves) == 1 else "ies",
", ".join(leaves[:3]), ", ..." if len(leaves) > 3 else ""))
else:
rep.touch("E-STORE-SESS")
def _leaf_paths(node, prefix=""):
"""Flatten a nested property dict to dotted leaf paths."""
out = []
if isinstance(node, dict) and node:
for k in sorted(node):
out.extend(_leaf_paths(node[k], "%s.%s" % (prefix, k) if prefix else k))
elif prefix:
out.append(prefix)
return out
def check_view_file(rep, view_dir, where, resource):
"""Component names, custom/param property names, message handlers, globals."""
vfile = os.path.join(view_dir, "view.json")
view = load_json(rep, vfile, where)
if view is None:
return
raw = read_text(vfile)
for group in ("params", "custom"):
for key in (view.get(group) or {}):
if not is_camel(key):
rep.warn("E-PROP-NAME", where,
"view %s property %r should be camelCase" % (group, key))
else:
rep.touch("E-PROP-NAME")
for key in (view.get("propConfig") or {}):
if key.startswith(("params.", "custom.")):
leaf = key.split(".", 1)[1].split(".")[0].split("[")[0]
if leaf and not is_camel(leaf):
rep.warn("E-PROP-NAME", where,
"view property %r should be camelCase" % key)
else:
rep.touch("E-PROP-NAME")
def visit(node, cpath):
name = (node.get("meta") or {}).get("name")
if cpath == "root" or not name:
return
if not is_pascal(name):
rep.warn("E-COMP-NAME", where, "component %r should be PascalCase" % name)
else:
rep.touch("E-COMP-NAME")
for key in (node.get("custom") or {}):
if not is_camel(key):
rep.warn("E-PROP-NAME", where,
"custom property %r on %r should be camelCase" % (key, name))
else:
rep.touch("E-PROP-NAME")
walk_components(view.get("root") or {}, visit)
check_message_names(rep, raw, where, resource)
check_globals(rep, raw, where, resource)
def check_message_names(rep, text, where, resource):
"""Message handler / sendMessage types namespaced to the resource."""
seen = set()
for rx in (r'"messageType"\s*:\s*"([^"]+)"',
r'sendMessage\(\s*[\'"]([^\'"]+)[\'"]',
r'messageType\s*=\s*[\'"]([^\'"]+)[\'"]'):
for m in re.finditer(rx, text):
seen.add(m.group(1))
for mt in sorted(seen):
parts = mt.split(".")
if len(parts) >= 3 and parts[0] == "exchange" and norm(parts[1]) == norm(resource):
rep.touch("E-MSG-NAME")
continue
rep.warn("E-MSG-NAME", where,
"message type %r should be exchange.%s.handlerName to avoid "
"collisions" % (mt, resource[:1].lower() + resource[1:]))
def check_globals(rep, text, where, resource):
"""getGlobals() must be keyed ['exchange']['resourceName']."""
for m in re.finditer(r"getGlobals\(\)\s*((?:\[\s*[\'\"][^\'\"]+[\'\"]\s*\])*)", text):
keys = re.findall(r"[\'\"]([^\'\"]+)[\'\"]", m.group(1))
if len(keys) >= 2 and keys[0] == "exchange" and norm(keys[1]) == norm(resource):
rep.touch("E-STORE-GLOB")
else:
rep.warn("E-STORE-GLOB", where,
"getGlobals()%s should be keyed ['exchange']['%s']"
% ("".join("[%r]" % k for k in keys) or "[...]",
resource[:1].lower() + resource[1:]))
def check_style_hierarchy(rep, views_root, view_paths, style_paths):
"""A class used by exactly one view belongs in a folder named for that view."""
if not style_paths:
return
known = {"/".join(p) for p in style_paths}
users = {}
for parts in view_paths:
vfile = os.path.join(views_root, *parts, "view.json")
if not os.path.exists(vfile):
continue
view = load_json(rep, vfile, "views/" + "/".join(parts))
if view is None:
continue
view_name = parts[-1]
def visit(node, _cpath):
classes = ((node.get("props") or {}).get("style") or {}).get("classes")
if isinstance(classes, str):
for cls in classes.split():
if cls in known:
users.setdefault(cls, set()).add(view_name)
walk_components(view.get("root") or {}, visit)
for cls, vnames in sorted(users.items()):
segs = cls.split("/")
view_segs = {norm(s) for s in segs}
if len(vnames) == 1:
vn = next(iter(vnames))
if norm(vn) not in view_segs:
rep.warn("E-STYLE-HIER", "style-classes/" + cls,
"used only by view %r; place it in a folder named for that "
"view" % vn)
else:
rep.touch("E-STYLE-HIER")
else:
shared = view_segs & {norm(v) for v in vnames}
if shared:
rep.warn("E-STYLE-HIER", "style-classes/" + cls,
"used by %d views (%s) but nested under a view-specific "
"folder; move it to the topmost folder covering them all"
% (len(vnames), ", ".join(sorted(vnames)[:3])))
else:
rep.touch("E-STYLE-HIER")
def check_vision(rep, proj_dir, resource):
"""Vision: windows, templates, and the client-tag prohibition."""
vis = os.path.join(proj_dir, VISION)
if not os.path.isdir(vis):
return
ctags = os.path.join(vis, "client-tags")
if os.path.isdir(ctags) and os.listdir(ctags):
rep.fail("E-STORE-CTAG", "%s/client-tags" % VISION,
"Vision client tags present -- the guide directs resources away "
"from client tags so they import cleanly into another project")
else:
rep.touch("E-STORE-CTAG")
for sub, kind in (("windows", "window"), ("templates", "template")):
root = os.path.join(vis, sub)
for parts in find_resource_dirs(root):
where = "%s/%s/%s" % (VISION, sub, "/".join(parts))
tail = check_namespace(rep, kind, parts, where, UPPER, resource, "title-pascal")
for seg in (tail if tail is not None else []):
if not is_title_or_pascal(seg):
rep.warn("E-VIS-NAME", where,
"%s name %r should be Title Case or PascalCase" % (kind, seg))
else:
rep.touch("E-VIS-NAME")
def check_scripts(rep, proj_dir, resource):
"""Code: library layout, indentation, names, docstrings, comments, loggers."""
sp_root = os.path.join(proj_dir, "ignition", "script-python")
if not os.path.isdir(sp_root):
return
for dirpath, _dirs, files in os.walk(sp_root):
if "code.py" not in files:
continue
parts = tuple(os.path.relpath(dirpath, sp_root).split(os.sep))
where = "script-python/" + "/".join(parts)
if norm(parts[0]) == "exchange":
tail = check_namespace(rep, "script package", parts, where, LOWER,
resource, "camel")
tail = tail if tail is not None else []
else:
rep.fail("E-NS-ROOT", where,
"project library package %r is not under exchange/<resource>/; "
"importing would land scripts in the host project's namespace"
% parts[0])
tail = list(parts[1:])
for seg in tail:
if not is_camel(seg):
rep.warn("E-PY-LIB", where,
"library package/script %r should be lowercase, a single "
"word where possible, camelCase otherwise" % seg)
else:
rep.touch("E-PY-LIB")
check_python_file(rep, os.path.join(dirpath, "code.py"), where + "/code.py",
resource)
def check_python_file(rep, path, where, resource):
src = read_text(path)
check_py_indentation(rep, src, where)
check_py_comments(rep, src, where)
check_globals(rep, src, where, resource)
check_message_names(rep, src, where, resource)
check_loggers(rep, src, where, resource)
try:
tree = ast.parse(src)
except SyntaxError as e:
rep.warn("E-PY-PARSE", where,
"could not parse (line %s): %s -- name and docstring checks skipped"
% (e.lineno, e.msg))
return
rep.touch("E-PY-PARSE")
check_py_names(rep, tree, where)
def check_py_indentation(rep, src, where):
"""Tabs, not spaces. Uses the tokenizer so continuation lines are not counted."""
space_lines, tab_lines = [], []
try:
for tok in tokenize.generate_tokens(io.StringIO(src).readline):
if tok.type == tokenize.INDENT:
(tab_lines if "\t" in tok.string else space_lines).append(tok.start[0])
except (tokenize.TokenError, IndentationError, SyntaxError):
for i, line in enumerate(src.splitlines(), 1):
if line.startswith("\t"):
tab_lines.append(i)
elif line.startswith(" "):
space_lines.append(i)
if space_lines:
preview = ", ".join(str(n) for n in space_lines[:5])
rep.warn("E-PY-TABS", where,
"%d space-indented block%s (line%s %s%s); Ignition's editor inserts "
"tabs" % (len(space_lines), "" if len(space_lines) == 1 else "s",
"" if len(space_lines) == 1 else "s", preview,
", ..." if len(space_lines) > 5 else ""))
elif tab_lines:
rep.touch("E-PY-TABS")
def check_py_comments(rep, src, where):
"""Comments start with '#' then a space."""
bad = []
try:
for tok in tokenize.generate_tokens(io.StringIO(src).readline):
if tok.type != tokenize.COMMENT:
continue
body = tok.string
if body in ("#", "#!"):
continue
if body.startswith("#!") and tok.start[0] == 1:
continue
if re.match(r"^#[-=#*]{2,}", body): # separator banners
continue
if not body.startswith("# "):
bad.append(tok.start[0])
except (tokenize.TokenError, IndentationError, SyntaxError):
return
if bad:
rep.warn("E-PY-COMMENT", where,
"%d comment%s missing the space after '#' (line%s %s%s)"
% (len(bad), "" if len(bad) == 1 else "s",
"" if len(bad) == 1 else "s",
", ".join(str(n) for n in bad[:5]),
", ..." if len(bad) > 5 else ""))
else:
rep.touch("E-PY-COMMENT")
def check_py_names(rep, tree, where):
"""Function/class/variable names and docstrings."""
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef):
if not is_pascal(node.name.lstrip("_")):
rep.warn("E-PY-CLASS", where,
"class %r (line %d) should be PascalCase" % (node.name, node.lineno))
else:
rep.touch("E-PY-CLASS")
check_docstring(rep, node, where, "class")
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
bare = node.name.strip("_")
dunder = node.name.startswith("__") and node.name.endswith("__")
if not dunder and bare and not is_camel(bare):
rep.warn("E-PY-FUNC", where,
"function %r (line %d) should be camelCase (the guide "
"diverges from PEP-8 here)" % (node.name, node.lineno))
else:
rep.touch("E-PY-FUNC")
if not dunder:
check_docstring(rep, node, where, "function")
for arg in _all_args(node):
_check_var(rep, arg.arg, arg.lineno, where, "argument")
elif isinstance(node, ast.Assign):
for tgt in node.targets:
for name, lineno in _assigned_names(tgt):
_check_var(rep, name, lineno, where, "variable")
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
_check_var(rep, node.target.id, node.lineno, where, "variable")
def _all_args(node):
a = node.args
out = list(getattr(a, "posonlyargs", [])) + list(a.args) + list(a.kwonlyargs)
if a.vararg:
out.append(a.vararg)
if a.kwarg:
out.append(a.kwarg)
return out
def _assigned_names(tgt):
if isinstance(tgt, ast.Name):
yield tgt.id, tgt.lineno
elif isinstance(tgt, (ast.Tuple, ast.List)):
for el in tgt.elts:
for pair in _assigned_names(el):
yield pair
def _check_var(rep, name, lineno, where, kind):
bare = name.strip("_")
if not bare or bare == "self" or bare == "cls":
return
# Module constants fall back to PEP-8, which the guide names as the default
# for anything it does not cover.
if RX_CONST.match(bare):
rep.touch("E-PY-VAR")
return
if is_camel(bare):
rep.touch("E-PY-VAR")
return
rep.warn("E-PY-VAR", where,
"%s %r (line %d) should be camelCase" % (kind, name, lineno))
def check_docstring(rep, node, where, kind):
doc = ast.get_docstring(node, clean=False)
if node.name.startswith("_"):
return
if not doc or not doc.strip():
rep.warn("E-PY-DOC", where,
"%s %r (line %d) has no docstring; Ignition 8.1.32+ surfaces these "
"in autocomplete" % (kind, node.name, node.lineno))
return
rep.touch("E-PY-DOC")
summary = doc.strip().splitlines()[0].strip()
if summary and summary[-1] not in ".?!":
rep.warn("E-PY-DOCFMT", where,
"%s %r (line %d) docstring summary should end with . ? or !"
% (kind, node.name, node.lineno))
else:
rep.touch("E-PY-DOCFMT")
def check_loggers(rep, src, where, resource):
"""exchange.resourceName[.path].PascalCaseName"""
want = resource[:1].lower() + resource[1:]
for m in re.finditer(r"getLogger\(\s*[\'\"]([^\'\"]+)[\'\"]", src):
name = m.group(1)
parts = name.split(".")
ok = (len(parts) >= 3 and parts[0] == "exchange"
and norm(parts[1]) == norm(resource)
and all(is_camel(p) for p in parts[2:-1])
and is_pascal(parts[-1]))
if ok:
rep.touch("E-LOG-NAME")
else:
rep.warn("E-LOG-NAME", where,
"logger %r should be exchange.%s[.path].LoggerName with a "
"PascalCase final element" % (name, want))
def check_named_queries(rep, proj_dir, resource):
root = os.path.join(proj_dir, "ignition", "named-query")
for parts in find_resource_dirs(root):
where = "named-query/" + "/".join(parts)
tail = check_namespace(rep, "named query", parts, where, UPPER, resource,
"title-pascal")
for seg in (tail if tail is not None else []):
if not is_title_or_pascal(seg):
rep.warn("E-NQ-NAME", where,
"named query %r should be Title Case or PascalCase" % seg)
else:
rep.touch("E-NQ-NAME")
qdir = os.path.join(root, *parts)
for fn in sorted(os.listdir(qdir)):
if not fn.endswith(".json") or fn == "resource.json":
continue
data = load_json(rep, os.path.join(qdir, fn), where)
for p in ((data or {}).get("parameters") or []):
pname = p.get("name") if isinstance(p, dict) else p
if isinstance(pname, str) and not is_camel(pname):
rep.warn("E-NQ-PARAM", where,
"query parameter %r should be camelCase" % pname)
elif isinstance(pname, str):
rep.touch("E-NQ-PARAM")
def check_other_resources(rep, proj_dir, resource):
"""Alarm pipelines, SFCs, transaction groups, reports, WebDev, translations."""
ign = os.path.join(proj_dir, "ignition")
upper_kinds = {
"alarm-pipeline": "alarm pipeline", "alarm-pipelines": "alarm pipeline",
"sfc": "SFC", "sfcs": "SFC",
"transaction-group": "transaction group", "transaction-groups": "transaction group",
"report": "report", "reports": "report",
}
if os.path.isdir(ign):
for entry in sorted(os.listdir(ign)):
kind = upper_kinds.get(entry)
if not kind:
continue
for parts in find_resource_dirs(os.path.join(ign, entry)):
where = "%s/%s" % (entry, "/".join(parts))
tail = check_namespace(rep, kind, parts, where, UPPER, resource,
"title-pascal")
for seg in (tail if tail is not None else []):
if not is_title_or_pascal(seg):
rep.warn("E-VIEW-NAME", where,
"%s name %r should be Title Case or PascalCase"
% (kind, seg))
for webroot in (os.path.join(proj_dir, "com.inductiveautomation.webdev"),
os.path.join(ign, "webdev")):
if not os.path.isdir(webroot):
continue
base = os.path.join(webroot, "resources")
base = base if os.path.isdir(base) else webroot
for parts in find_resource_dirs(base):
where = "webdev/" + "/".join(parts)
tail = check_namespace(rep, "WebDev source", parts, where, LOWER,
resource, "kebab")
for seg in (tail if tail is not None else []):
stem = seg.rsplit(".", 1)[0]
if not is_kebab(stem):
rep.warn("E-WEB-NAME", where,
"WebDev source %r should be lowercase-with-dashes" % seg)
else:
rep.touch("E-WEB-NAME")
check_translations(rep, proj_dir)
def check_translations(rep, proj_dir):
for dirpath, _dirs, files in os.walk(proj_dir):
for fn in files:
if "locale" not in fn and "translation" not in fn.lower():
continue
if not fn.endswith(".json"):
continue
where = os.path.relpath(os.path.join(dirpath, fn), proj_dir)
data = load_json(rep, os.path.join(dirpath, fn), where)
keys = list(data) if isinstance(data, dict) else []
for key in keys:
if not isinstance(key, str):
continue
ok = (re.match(r"^[A-Za-z0-9]+$", key)
or re.match(r"^_[a-z0-9]+(_[a-z0-9]+)*$", key))
if ok:
rep.touch("E-I18N-KEY")
else:
rep.warn("E-I18N-KEY", where,
"translation key %r should be a single word, or "
"_underscore_separated_phrase" % key)
def check_tags(rep, tag_files, resource):
"""Tag export JSON: Exchange/<Resource>/... with PascalCase or Title Case names."""
for tf in tag_files:
where = os.path.basename(tf)
data = load_json(rep, tf, where)
if data is None:
continue
def walk(node, trail):
if not isinstance(node, dict):
return
name = node.get("name")
here = trail + [name] if name else trail
if name:
if not is_title_or_pascal(name):
rep.warn("E-TAG-NAME", where,
"tag/folder %r should be PascalCase or Title Case"
% "/".join(here))
else:
rep.touch("E-TAG-NAME")
for child in (node.get("tags") or []):
walk(child, here)
roots = data.get("tags") or []
top = [t.get("name") for t in roots if isinstance(t, dict)]
if not any(norm(n) == "exchange" for n in top if n):
rep.fail("E-NS-ROOT", where,
"tag export root is %s; tags must live under Exchange/%s/"
% (top or "(empty)", resource))
for t in roots:
walk(t, [])
def check_database(rep, sql_files, resource):
"""CREATE TABLE statements: ex_ prefix, snake_case, id PK, *_id foreign keys."""
abbrev_hint = "".join(w[0] for w in re.findall(r"[A-Z][a-z]*", resource)).lower()
for sf in sql_files:
where = os.path.basename(sf)
sql = read_text(sf)
for m in re.finditer(
r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`\"\[]?([A-Za-z0-9_.]+)[`\"\]]?\s*\((.*?)\)\s*;",
sql, re.IGNORECASE | re.DOTALL):
table = m.group(1).split(".")[-1]
body = m.group(2)
if not RX_SNAKE.match(table):
rep.warn("E-DB-TABLE", where,
"table %r should be snake_case" % table)
elif not table.startswith("ex_"):
rep.warn("E-DB-TABLE", where,
"table %r should start with 'ex_' (e.g. ex_%s_%s) to avoid "
"collisions in an existing schema"
% (table, abbrev_hint or "abbrev", table))
else:
rep.touch("E-DB-TABLE")
cols = []
for line in body.splitlines():
line = line.strip().rstrip(",")
cm = re.match(r"^[`\"\[]?([A-Za-z0-9_]+)[`\"\]]?\s+[A-Za-z]", line)
if not cm:
continue
kw = cm.group(1).upper()
if kw in ("PRIMARY", "FOREIGN", "UNIQUE", "KEY", "INDEX", "CONSTRAINT", "CHECK"):
continue
cols.append(cm.group(1))
for col in cols:
if not RX_SNAKE.match(col):
rep.warn("E-DB-COL", where,
"column %s.%s should be snake_case" % (table, col))
else:
rep.touch("E-DB-COL")
if "id" not in cols:
rep.warn("E-DB-ID", where,
"table %r has no 'id' column; the first auto-increment "
"primary key should be named 'id'" % table)
else:
rep.touch("E-DB-ID")
for fkm in re.finditer(
r"FOREIGN\s+KEY\s*\(\s*[`\"\[]?([A-Za-z0-9_]+)[`\"\]]?\s*\)\s*"
r"REFERENCES\s+[`\"\[]?([A-Za-z0-9_]+)",
body, re.IGNORECASE):
col, ref = fkm.group(1), fkm.group(2).split(".")[-1]
if col != "%s_id" % ref:
rep.warn("E-DB-FK", where,
"foreign key %s.%s references %s; name it %s_id"
% (table, col, ref, ref))
else:
rep.touch("E-DB-FK")
def check_upload_readiness(rep, proj_dir, extra_roots=()):
"""Documentation the Exchange upload form and the guide's checklist expect."""
bases = [proj_dir] + [r for r in extra_roots if r]
for base in bases:
if not os.path.isdir(base):
continue
for fn in os.listdir(base):
if re.match(r"^(readme|install|installation|docs?|instructions)",
fn, re.IGNORECASE):
rep.touch("E-UP-DOCS")
return
rep.warn("E-UP-DOCS", "(resource root)",
"no README / installation document found; the Exchange upload form "
"asks for custom installation instructions")
# ------------------------------------------------------------------ discovery
# Markers specific enough not to match a gateway's config/resources tree, which
# also carries com.inductiveautomation.* folders but is not a project.
PROJECT_MARKERS = (
"project.json",
os.path.join(PERSPECTIVE, "views"),
os.path.join(PERSPECTIVE, "session-props"),
os.path.join(PERSPECTIVE, "page-config"),
os.path.join(VISION, "windows"),
os.path.join(VISION, "templates"),
os.path.join("ignition", "script-python"),
)
MAX_SCAN_DEPTH = 6
SKIP_DIRS = {".git", ".svn", "__pycache__", "node_modules", ".resources",
".venv", "venv", ".idea", ".vscode"}
def looks_like_project(d):
"""True if d is the root of an Ignition 8.x project folder."""
if not os.path.isdir(d):
return False
return any(os.path.exists(os.path.join(d, m)) for m in PROJECT_MARKERS)
def discover_projects(target):
"""Return project directories under target.
Accepts a project folder, any directory containing one or more projects
(a gateway's projects/ dir, an unpacked gateway backup, a repo), or a
project-export .zip. Nested projects are not searched once a project root
is found, so a projects/ dir yields its children, not their subfolders.
"""
target = os.path.abspath(target)
if os.path.isfile(target):
if target.lower().endswith(".zip"):
return discover_projects(unpack_zip(target))
raise SystemExit("not a project folder or .zip export: %s" % target)
if not os.path.isdir(target):
raise SystemExit("no such path: %s" % target)
if looks_like_project(target):
return [target]
found = []
base_depth = target.rstrip(os.sep).count(os.sep)
for dirpath, dirs, _files in os.walk(target):
dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".")]
if dirpath.count(os.sep) - base_depth >= MAX_SCAN_DEPTH:
dirs[:] = []
continue
for d in list(dirs):
cand = os.path.join(dirpath, d)
if looks_like_project(cand):
found.append(cand)
dirs.remove(d)
return sorted(found)
def unpack_zip(path):
"""Extract a project-export zip to a temp dir and return the project root."""
import tempfile
import zipfile
dest = tempfile.mkdtemp(prefix="exchange_lint_")
with zipfile.ZipFile(path) as zf:
for member in zf.namelist():
# refuse absolute paths and traversal in an untrusted archive
norm_m = os.path.normpath(member)
if os.path.isabs(norm_m) or norm_m.startswith(".."):
raise SystemExit("unsafe path in archive: %s" % member)
zf.extractall(dest)
entries = [os.path.join(dest, e) for e in os.listdir(dest)]
if len(entries) == 1 and os.path.isdir(entries[0]):
return entries[0]
return dest
def discover_aux(proj_dir, pattern_fn, limit=200):
"""Collect auxiliary files (tag exports, .sql dumps) shipped beside a project."""
out = []
roots = [proj_dir, os.path.dirname(proj_dir.rstrip(os.sep))]
seen = set()
for base in roots:
if not os.path.isdir(base) or base in seen:
continue
seen.add(base)
for dirpath, dirs, files in os.walk(base):
dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".")]
for fn in sorted(files):
p = os.path.join(dirpath, fn)
if p not in out and pattern_fn(p):
out.append(p)
if len(out) >= limit:
return out
return out
def is_tag_export(path):
if not path.lower().endswith(".json"):
return False
if os.path.basename(path) in ("project.json", "resource.json", "view.json",
"style.json", "props.json", "config.json"):
return False
try:
with open(path) as f:
head = f.read(4096)
except OSError:
return False
return '"tags"' in head and ('"tagType"' in head or '"valueSource"' in head
or '"tagGroup"' in head)
def is_sql_file(path):
return path.lower().endswith(".sql")
def infer_resource(proj_dir, proj_name):
"""Best guess at the resource name from the view namespace, else the project."""
for sub, payload in ((os.path.join(PERSPECTIVE, "views"), "view.json"),
(os.path.join(VISION, "windows"), "resource.json")):
paths = find_payload_dirs(os.path.join(proj_dir, sub), payload)
if not paths:
continue
tops = {}
for p in paths:
key = p[1] if len(p) > 1 and norm(p[0]) == "exchange" else p[0]
tops[key] = tops.get(key, 0) + 1
return max(tops, key=lambda k: tops[k])
return pascalize(proj_name)
# ---------------------------------------------------------------------- main
def render(rep, proj_name, proj_dir, resource, as_json, show_header=True,
max_per_rule=8):
if as_json:
return {
"project": proj_name,
"path": proj_dir,
"resource": resource,
"failures": len(rep.fails),
"warnings": len(rep.warns),
"rules_exercised": sorted(rep.checked),
"findings": [{"severity": s, "rule": r, "section": RULE_SECTIONS.get(r, "?"),
"where": w, "message": m} for s, r, w, m in rep.findings],
}
if show_header:
print("Ignition Exchange Style Guide v1.1.0 — conformance report")
print("\nproject: %s resource: %s" % (proj_name, resource))
print("path: %s" % proj_dir)
if not rep.findings:
print(" No findings.")
sections = []
for _rid, sec, _desc in RULES:
if sec not in sections:
sections.append(sec)
for sec in sections:
rows = [f for f in rep.findings if RULE_SECTIONS.get(f[1]) == sec]
if not rows:
continue
print("\n %s" % sec)
rows.sort(key=lambda f: (f[0] != "FAIL", f[1], f[2]))
shown = {}
for sev, rule, where, msg in rows:
n = shown.get(rule, 0)
if max_per_rule and n >= max_per_rule:
shown[rule] = n + 1
continue
shown[rule] = n + 1
print(" %-4s %-13s %s: %s" % (sev, rule, where, msg))
for rule, n in shown.items():
if max_per_rule and n > max_per_rule:
print(" %-4s %-13s ... and %d more (-v for all)"
% ("", rule, n - max_per_rule))
print("\n summary: %d failure(s), %d warning(s); %d of %d rules exercised"
% (len(rep.fails), len(rep.warns), len(rep.checked), len(RULES)))
return None
def lint_project(proj_dir, resource=None, tags=None, sql=None, ignore=None,
strict=False):
"""Run every check against one project directory and return its Report."""
proj_name = os.path.basename(proj_dir.rstrip(os.sep))
resource = resource or infer_resource(proj_dir, proj_name)
tags = tags if tags is not None else discover_aux(proj_dir, is_tag_export)
sql = sql if sql is not None else discover_aux(proj_dir, is_sql_file)
rep = Report(ignore=ignore, strict=strict)
check_project(rep, proj_dir, proj_name)
check_perspective(rep, proj_dir, resource)
check_vision(rep, proj_dir, resource)
check_scripts(rep, proj_dir, resource)
check_named_queries(rep, proj_dir, resource)
check_other_resources(rep, proj_dir, resource)
check_tags(rep, tags, resource)
check_database(rep, sql, resource)
check_upload_readiness(rep, proj_dir,
[os.path.dirname(proj_dir.rstrip(os.sep))])
return rep, proj_name, resource
def main(argv=None):
ap = argparse.ArgumentParser(
description="Check an Ignition project against the Ignition Exchange "
"Resources Style Guide (v1.1.0).",
epilog="TARGET may be a project folder, a folder containing projects "
"(a gateway projects/ dir, an unpacked gateway backup, a repo), "
"or a project-export .zip. Defaults to the current directory.")
ap.add_argument("target", nargs="*", default=None,
help="project folder, containing folder, or .zip export")
ap.add_argument("--resource", help="Exchange resource name (default: inferred "
"from the resource namespace)")
ap.add_argument("--tags", nargs="*", default=None,
help="tag export .json files (default: auto-discovered)")
ap.add_argument("--sql", nargs="*", default=None,
help=".sql files to check (default: auto-discovered)")
ap.add_argument("--strict", action="store_true", help="treat warnings as failures")
ap.add_argument("--ignore", default="", help="comma-separated rule ids to suppress")
ap.add_argument("--json", action="store_true", dest="as_json",
help="machine-readable output")
ap.add_argument("--list-rules", action="store_true",
help="print the rule table and exit")
ap.add_argument("-v", "--verbose", action="store_true",
help="list every occurrence instead of collapsing per rule")
ap.add_argument("--max-per-rule", type=int, default=8,
help="occurrences shown per rule before collapsing (default 8)")
args = ap.parse_args(argv)
if args.list_rules:
width = max(len(r[0]) for r in RULES)
for rid, sec, desc in RULES:
print("%-*s %-20s %s" % (width, rid, sec, desc))
return 0
ignore = {r.strip() for r in args.ignore.split(",") if r.strip()}
unknown = ignore - VALID_RULES
if unknown:
print("unknown rule id(s) in --ignore: %s" % ", ".join(sorted(unknown)),
file=sys.stderr)
return 2
targets = args.target or [os.getcwd()]
projects = []
try:
for t in targets:
for p in discover_projects(t):
if p not in projects:
projects.append(p)
except SystemExit as e:
print(e, file=sys.stderr)
return 2
if not projects:
print("no Ignition project found under: %s" % ", ".join(targets),
file=sys.stderr)
return 2
if args.resource and len(projects) > 1:
print("--resource applies to a single project; %d found" % len(projects),
file=sys.stderr)
return 2
results, total_fails = [], 0
for i, proj_dir in enumerate(projects):
rep, proj_name, resource = lint_project(
proj_dir, resource=args.resource, tags=args.tags, sql=args.sql,
ignore=ignore, strict=args.strict)
total_fails += len(rep.fails)
payload = render(rep, proj_name, proj_dir, resource, args.as_json,
show_header=(i == 0),
max_per_rule=0 if args.verbose else args.max_per_rule)
if payload is not None:
results.append(payload)
if args.as_json:
print(json.dumps({"projects": results,
"failures": total_fails}, indent=2))
elif len(projects) > 1:
print("\n%d project(s) checked, %d total failure(s)"
% (len(projects), total_fails))
return 1 if total_fails else 0
if __name__ == "__main__":
sys.exit(main())