Skip to content

Commit c26c837

Browse files
dmitratclaude
andcommitted
test(addon): static import-hygiene check across all bridge modules
AST-only test (no import, so it also covers the bpy-importing modules that plain unit tests can't load) asserting every `<stdlib>.<attr>` reference is backed by an import. Catches the exact class of bug that shipped the local-bake `re.sub` without `import re` — verified it flags the pre-fix bridge_operators. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 50f21f4 commit c26c837

1 file changed

Lines changed: 61 additions & 0 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from __future__ import annotations
2+
3+
import ast
4+
import pathlib
5+
import unittest
6+
7+
8+
PACKAGE_ROOT = pathlib.Path(__file__).resolve().parents[1] / "outwit_render_bridge"
9+
10+
# Stdlib modules the addon uses via ``<module>.<attr>``. A reference to any of these without a matching
11+
# import is a NameError the moment that code path runs — and because several addon modules ``import bpy``
12+
# they cannot be imported outside Blender, so a plain unit test never exercises them (this is exactly how
13+
# ``bridge_operators`` shipped a ``re.sub`` call with no ``import re``, crashing the local bake). This test
14+
# statically (AST-only, no import) verifies every such reference is backed by an import.
15+
_STDLIB_MODULES = {
16+
"re", "os", "json", "sys", "subprocess", "math", "time", "glob",
17+
"shutil", "tempfile", "pathlib", "uuid", "datetime", "collections",
18+
"itertools", "io", "zipfile",
19+
}
20+
21+
22+
def _imported_names(tree: ast.AST) -> set[str]:
23+
names: set[str] = set()
24+
for node in ast.walk(tree):
25+
if isinstance(node, ast.Import):
26+
for alias in node.names:
27+
names.add((alias.asname or alias.name).split(".")[0])
28+
elif isinstance(node, ast.ImportFrom):
29+
if node.module:
30+
names.add(node.module.split(".")[0])
31+
for alias in node.names:
32+
names.add(alias.asname or alias.name)
33+
return names
34+
35+
36+
def _stdlib_modules_used(tree: ast.AST) -> set[str]:
37+
used: set[str] = set()
38+
for node in ast.walk(tree):
39+
if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name):
40+
if node.value.id in _STDLIB_MODULES:
41+
used.add(node.value.id)
42+
return used
43+
44+
45+
class ModuleImportHygieneTests(unittest.TestCase):
46+
def test_every_stdlib_module_used_is_imported(self) -> None:
47+
modules = sorted(PACKAGE_ROOT.glob("*.py"))
48+
self.assertGreater(len(modules), 0, f"no addon modules found under {PACKAGE_ROOT}")
49+
50+
problems: list[str] = []
51+
for path in modules:
52+
tree = ast.parse(path.read_text(encoding="utf-8"), str(path))
53+
missing = _stdlib_modules_used(tree) - _imported_names(tree)
54+
for module in sorted(missing):
55+
problems.append(f"{path.name}: uses '{module}.' but never imports '{module}'")
56+
57+
self.assertEqual(problems, [], "missing stdlib imports:\n" + "\n".join(problems))
58+
59+
60+
if __name__ == "__main__":
61+
unittest.main()

0 commit comments

Comments
 (0)