25592fa6c2
ci / test (push) Successful in 6m14s
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.
213 lines
9.1 KiB
Python
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())
|