#!/usr/bin/env python3 """wpf_smoke -- exec a button's System / System.Windows imports inside Revit. A wrong WPF namespace is invisible to every host gate: host CPython cannot import System.Windows at all (no .NET), so a button whose `from System.Windows.Media import StretchDirection` (the type lives in System.Windows.Controls) passes every host test and dies at import the first time the owner clicks it. The bridge runs IronPython INSIDE Revit, where the .NET assemblies exist, so the check is cheap: collect the script's System.* import statements, ship them through tools/rvt.py as one snippet, and require zero import errors. That is the only place they can be checked. python tools/wpf_smoke.py [--port N] Host CPython 3 only -- this file never runs inside Revit (the SNIPPET it builds is IronPython 2.7). Verdicts: OK (exit 0) every collected import loaded FAIL (exit 1) at least one import raised; one line per error COULD NOT JUDGE (exit 2) the bridge could not run the check -- never OK """ from __future__ import annotations import argparse import os import re # Every complete import statement whose FIRST line names the System # namespace. Anchored to the start of a (possibly indented) line so comment # prose and string bodies are ignored; `System` must be followed by a dot, # whitespace or end of line so a longer identifier like `Systematically` is # not matched. Parenthesised continuations are followed separately. _IMPORT_START = re.compile(r'^\s*from System(\.|\s)|^\s*import System(\.|\s|$)') # The snippet head. The four references cover the WPF surface (System.Xaml # holds System.Windows.Markup); the rest of System.* resolves through the # assemblies IronPython already has. An AddReference failure is swallowed: a # missing assembly is not itself the answer, the import below either finds # the name or raises. _REFERENCE_HEAD = ( 'import clr\n' "for _r in ('PresentationFramework','PresentationCore','WindowsBase'," "'System.Xaml'):\n" ' try:\n' ' clr.AddReference(_r)\n' ' except Exception:\n' ' pass\n' '_errors = []\n' '_n = 0\n' ) # How much of a failing statement is echoed in its error line. _ERR_PREFIX_LEN = 80 def collect_system_imports(source: str) -> list: """Every complete ``System.*`` import statement in `source`, one per entry. A statement starts at a line matching `_IMPORT_START`; a parenthesised multi-line import is followed to the line that balances its parentheses (counting ``(`` and ``)``). Each physical line is stripped and the parts joined with a space, so the result is one valid one-line statement -- exactly the shape IronPython's ``exec`` expects when each entry runs on its own inside the snippet. """ lines = source.splitlines() out = [] i = 0 n = len(lines) while i < n: line = lines[i] if not _IMPORT_START.match(line): i += 1 continue parts = [line.strip()] depth = line.count('(') - line.count(')') i += 1 while depth > 0 and i < n: nxt = lines[i] # A line that itself starts a System import cannot be a # continuation: the current parenthesised import is broken # (never closed), so stop here and let the outer loop collect # the next statement normally rather than swallowing it. if _IMPORT_START.match(nxt): break depth += nxt.count('(') - nxt.count(')') parts.append(nxt.strip()) i += 1 collapsed = ' '.join(parts) # Whitespace-normalise the collapsed one-liner: no space after `(`, # no space before `)`, and no runs of spaces elsewhere (a `(` or # `)` alone on its own physical line would otherwise leave a # padding space around it). collapsed = re.sub(r'\(\s+', '(', collapsed) collapsed = re.sub(r'\s+\)', ')', collapsed) collapsed = re.sub(r'\s+', ' ', collapsed).strip() out.append(collapsed) return out def build_snippet(statements: list) -> str: """The IronPython 2.7 snippet that loads each statement inside Revit. Each statement is exec'd in its own try/except so one bad namespace -- the bug this tool exists for -- is collected as an error instead of killing the snippet and leaving the rest un-checked. The final `result` line is the contract /exec reads to send data back (startup.py picks it out of the namespace after running the snippet). The snippet is IronPython 2.7: no f-strings, no pathlib; the formatting happens on the HOST side below. """ parts = [_REFERENCE_HEAD] for stmt in statements: # The error line is THREE concatenated literals in the emitted # snippet: + ' -> ' + # str(_e). The statement text is single-quoted by repr(); ' -> ' # is its own separate literal, never folded into the statement # string, so the emitted line stays valid Python. parts.append( '_n += 1\n' 'try:\n' ' exec({0})\n' 'except Exception as _e:\n' ' _errors.append({1} + \' -> \' + str(_e))' .format(repr(stmt), repr(stmt[:_ERR_PREFIX_LEN]))) parts.append("result = {'import_statements_checked': _n, 'errors': _errors}") return '\n'.join(parts) + '\n' def _bridge_call(code, port=None): """Send the snippet through tools/rvt.py's /exec -- the same path that `rvt.py exec ` takes, so the answer carries everything rvt guards (correlation, the timeout latch, the reload tripwire) for free. `port` is honoured the way every other bridge tool honours it: set WILLTOOLS_BRIDGE_PORT in the environment BEFORE the call, because rvt reads that variable per request, not at import. rvt is imported here rather than at module scope so importing this module never touches the network and never needs `tools/` on sys.path. """ if port is not None: os.environ['WILLTOOLS_BRIDGE_PORT'] = str(port) import rvt return rvt.call('/exec', 'POST', {'code': code}) def judge(script: str, port=None): """Run the whole smoke for one script file. Returns (exit_code, lines-to-print): 0 for OK, 1 for FAIL, 2 for COULD NOT JUDGE. A refusal, a transport failure or an /exec that did not run are all COULD NOT JUDGE -- "the bridge did not answer" is never reported as "the imports are fine". """ try: with open(script, 'r', encoding='utf-8') as fh: source = fh.read() except (OSError, UnicodeDecodeError) as exc: return 2, ['COULD NOT JUDGE -- cannot read {0}: {1}' .format(script, exc)] statements = collect_system_imports(source) snippet = build_snippet(statements) try: res = _bridge_call(snippet, port=port) except SystemExit as exc: # rvt.py refused or could not reach the bridge and already printed # why on stderr; the verdict must still say it was not judged. return 2, ['COULD NOT JUDGE -- the bridge call did not complete ' '(exit {0}); rvt.py printed why on stderr' .format(exc.code)] except Exception as exc: # pragma: no cover - defensive only return 2, ['COULD NOT JUDGE -- the bridge call raised {0}: {1}' .format(type(exc).__name__, exc)] if not isinstance(res, dict): return 2, ['COULD NOT JUDGE -- unexpected bridge answer: {0!r}' .format(res)] if res.get('refused'): return 2, ['COULD NOT JUDGE -- the bridge refused to run the check: ' '{0}'.format(res.get('error') or 'refused')] if not res.get('ok'): return 2, ['COULD NOT JUDGE -- the snippet did not run inside Revit: ' '{0}'.format(res.get('error') or 'exec failed')] result = res.get('result') if not isinstance(result, dict): return 2, ['COULD NOT JUDGE -- /exec returned no usable result'] errors = result.get('errors') checked = result.get('import_statements_checked') if not isinstance(errors, list) or not isinstance(checked, int): return 2, ['COULD NOT JUDGE -- /exec result had an unexpected shape'] if errors: head = 'FAIL {0} -- {1} import error(s):'.format(script, len(errors)) return 1, [head] + ['{0}'.format(e) for e in errors] return 0, ['OK {0} -- {1} WPF import statements load inside Revit' .format(script, checked)] def main(argv=None) -> int: ap = argparse.ArgumentParser( prog='wpf_smoke', description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument('script', help='path to the button script.py to smoke') ap.add_argument('--port', type=int, default=None, help='bridge port (default: WILLTOOLS_BRIDGE_PORT, ' 'else 48884)') args = ap.parse_args(argv) code, lines = judge(args.script, port=args.port) for line in lines: print(line) return code if __name__ == '__main__': raise SystemExit(main())