|
| 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