Files
willtools-demo/tools/wpf_smoke.py
T
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
History-free snapshot of the private development repository at 77a82eb.
Code, docs and the public catalogue only: no plant or job knowledge
layers, no client, job or producer names (tools/scrub.py clean against
the widened term list; a second word-level grep clean). The intro deck is
left out because its slide images cannot be checked for job drawings.
2026-09-09 08:35:15 -05:00

213 lines
9.1 KiB
Python

#!/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 <script.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: <repr of the statement, first 80 chars> + ' -> ' +
# 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 <file>` 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())