WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m14s
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.
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user