# -*- coding: utf-8 -*- """Every extension .py with a non-ASCII byte must declare its encoding. This is a test because it already cost a live failure. On 2026-09-03, after a Revit restart, pressing Panel Analysis threw Revit encountered a non-ASCII character '\\xe2' in file ... PanelAnalysis.pushbutton/script.py -- an IronPython 2.7 SyntaxError, raised at compile time, before a line of the button ran. The file carried deliberate typography in `u'...'` labels and comments (the prime in f'c, subscripts, the sqrt sign, an em-dash, a Chinese note) but no `# -*- coding: utf-8 -*-` cookie, and IronPython 2.7 refuses a source file that mixes non-ASCII bytes with no declared encoding (PEP 263). CPython 3 -- what this test runs on, and what `tools/rvt.py` runs on -- defaults to UTF-8 and never raises this, so the trap is invisible on the host and only appears inside Revit. That asymmetry is exactly why it needs a guard: DSV4F, a branch merge, or anyone pasting a nice-looking dash into a comment reintroduces it silently, and the next person to feel it is the owner pressing a button. The engine that matters here is IronPython 2.7, so the scan covers the files it loads -- everything under the extension except the host-only `tests/` tree (those run on CPython 3, where the cookie is not required). """ import os import re # tests/ -> WillTools.extension (mirrors test_button_tooltips._EXT). _EXT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) _TESTS_DIR = os.path.dirname(os.path.abspath(__file__)) # PEP 263: the cookie is honoured only on the first OR second line, and must # match `coding[:=]\s*([-\w.]+)`. We check the first two lines the same way # IronPython does, so a cookie on line 3 (too late) still counts as missing. _COOKIE = re.compile(rb'coding[:=]') def _extension_py_files(): """Every .py IronPython 2.7 loads from the extension -- tests excluded.""" for root, dirs, files in os.walk(_EXT): # Do not descend into the host-only tests tree. if os.path.abspath(root) == _TESTS_DIR: dirs[:] = [] continue for name in files: if name.endswith('.py'): yield os.path.join(root, name) def _has_non_ascii(data): return any(byte >= 0x80 for byte in bytearray(data)) def _declares_encoding(data): first_two = b'\n'.join(data.split(b'\n')[:2]) return bool(_COOKIE.search(first_two)) def _first_non_ascii_line(data): for lineno, line in enumerate(data.split(b'\n'), 1): if _has_non_ascii(line): return lineno return None def test_the_walk_found_scripts(): """A scan that silently matched nothing is not a passing guard. If the walk stops finding the button scripts -- a renamed container, a walk that no longer descends -- this test must fail rather than pass by vacuously scanning an empty set. """ found = list(_extension_py_files()) assert found, 'the extension .py walk found nothing -- the scan is broken' assert any(p.endswith('script.py') for p in found), ( 'the walk found no button script.py -- it stopped descending; fix the ' 'walk before trusting a green run here') def test_non_ascii_files_declare_an_encoding(): """No IronPython-loaded .py may carry a non-ASCII byte without a cookie.""" offenders = [] for path in _extension_py_files(): with open(path, 'rb') as handle: data = handle.read() if _has_non_ascii(data) and not _declares_encoding(data): offenders.append((os.path.relpath(path, _EXT), _first_non_ascii_line(data))) assert not offenders, ( 'these files carry a non-ASCII byte but declare no encoding, so ' "IronPython 2.7 throws a SyntaxError at compile time (\"non-ASCII " 'character ... but no encoding declared\") the moment Revit loads ' 'them. Add `# -*- coding: utf-8 -*-` as the first line:\n' + '\n'.join(' {0} (first non-ASCII on line {1})'.format(p, ln) for p, ln in offenders))