|
| 1 | +"""Register/unregister round-trip for this add-on, headless. Copy it with the template. |
| 2 | +
|
| 3 | + blender --background --factory-startup --python-exit-code 1 \ |
| 4 | + --python tests/check_register.py [-- --skip-unregister] |
| 5 | +
|
| 6 | +Loads the add-on package (the directory above tests/) under the module name |
| 7 | +Blender gives an installed extension, then: |
| 8 | +
|
| 9 | +1. register(): every bpy.types class the package defines must be registered, |
| 10 | + and register() must add something (exit 3 otherwise); |
| 11 | +2. unregister(): every one of those classes must be gone, and every property |
| 12 | + register() added to an ID type (Scene.my_addon = PointerProperty(...)) |
| 13 | + must be deleted again (exit 4 names what survived); |
| 14 | +3. register() and unregister() a second time: a non-symmetric pair usually |
| 15 | + fails here, on the re-register (exit 3). |
| 16 | +
|
| 17 | +Generic: it finds the classes and properties by comparing before and after, |
| 18 | +so it keeps working as you add operators, panels and property groups. |
| 19 | +--skip-unregister is the falsifier: it skips step 2's unregister and must |
| 20 | +exit 4. Exit 0 means the lifecycle is symmetric on this Blender. |
| 21 | +""" |
| 22 | +import importlib.util |
| 23 | +import inspect |
| 24 | +import os |
| 25 | +import sys |
| 26 | +import tomllib |
| 27 | + |
| 28 | +import bpy |
| 29 | + |
| 30 | +HERE = os.path.dirname(os.path.abspath(__file__)) |
| 31 | +PKG_DIR = os.path.dirname(HERE) |
| 32 | +ARGS = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] |
| 33 | +SKIP_UNREGISTER = "--skip-unregister" in ARGS |
| 34 | +BASES = (bpy.types.Operator, bpy.types.Panel, bpy.types.Menu, bpy.types.UIList, |
| 35 | + bpy.types.PropertyGroup, bpy.types.AddonPreferences, bpy.types.Header) |
| 36 | +ID_TYPES = (bpy.types.Scene, bpy.types.Object, bpy.types.Mesh, bpy.types.Material, |
| 37 | + bpy.types.WindowManager, bpy.types.World, bpy.types.Collection) |
| 38 | + |
| 39 | + |
| 40 | +def fail(msg, code): |
| 41 | + print(f"FAIL: {msg}", file=sys.stderr) |
| 42 | + return code |
| 43 | + |
| 44 | + |
| 45 | +def id_props(): |
| 46 | + return {(t.__name__, p) for t in ID_TYPES for p in t.bl_rna.properties.keys()} |
| 47 | + |
| 48 | + |
| 49 | +def package_classes(pkg_name): |
| 50 | + """bpy.types subclasses defined in the package or its submodules.""" |
| 51 | + found = set() |
| 52 | + for name, m in list(sys.modules.items()): |
| 53 | + if name != pkg_name and not name.startswith(pkg_name + "."): |
| 54 | + continue |
| 55 | + for _attr, obj in inspect.getmembers(m, inspect.isclass): |
| 56 | + if issubclass(obj, BASES) and obj not in BASES and ( |
| 57 | + obj.__module__ == pkg_name or obj.__module__.startswith(pkg_name + ".")): |
| 58 | + found.add(obj) |
| 59 | + return found |
| 60 | + |
| 61 | + |
| 62 | +def load_package(): |
| 63 | + ident = "addon_under_test" |
| 64 | + manifest = os.path.join(PKG_DIR, "blender_manifest.toml") |
| 65 | + if os.path.isfile(manifest): |
| 66 | + with open(manifest, "rb") as fh: |
| 67 | + ident = tomllib.load(fh).get("id", ident) |
| 68 | + name = f"bl_ext.user_default.{ident}" # what Blender names an installed extension |
| 69 | + for parent in ("bl_ext", "bl_ext.user_default"): |
| 70 | + sys.modules.setdefault(parent, type(sys)(parent)) |
| 71 | + spec = importlib.util.spec_from_file_location( |
| 72 | + name, os.path.join(PKG_DIR, "__init__.py"), submodule_search_locations=[PKG_DIR]) |
| 73 | + module = importlib.util.module_from_spec(spec) |
| 74 | + sys.modules[name] = module |
| 75 | + spec.loader.exec_module(module) |
| 76 | + return module |
| 77 | + |
| 78 | + |
| 79 | +def main(): |
| 80 | + bpy.ops.wm.read_factory_settings(use_empty=True) |
| 81 | + pkg = load_package() |
| 82 | + classes = package_classes(pkg.__name__) |
| 83 | + before = id_props() |
| 84 | + |
| 85 | + pkg.register() |
| 86 | + added = id_props() - before |
| 87 | + missing = sorted(c.__name__ for c in classes if not c.is_registered) |
| 88 | + if missing: |
| 89 | + return fail(f"classes not registered by register(): {missing}", 3) |
| 90 | + if not classes and not added: |
| 91 | + return fail("register() registered no class and added no property", 3) |
| 92 | + |
| 93 | + if not SKIP_UNREGISTER: |
| 94 | + pkg.unregister() |
| 95 | + left = sorted(c.__name__ for c in classes if c.is_registered) |
| 96 | + if left: |
| 97 | + return fail(f"classes still registered after unregister(): {left}", 4) |
| 98 | + stale = sorted(f"{t}.{p}" for t, p in (id_props() & added)) |
| 99 | + if stale: |
| 100 | + return fail(f"properties still bound after unregister(): {stale}", 4) |
| 101 | + |
| 102 | + try: |
| 103 | + pkg.register() |
| 104 | + pkg.unregister() |
| 105 | + except Exception as exc: # noqa: BLE001 -- any failure here is the asymmetry |
| 106 | + return fail(f"second register()/unregister() raised {type(exc).__name__}: {exc}", 3) |
| 107 | + |
| 108 | + print(f"OK: {len(classes)} classes and {len(added)} properties register and unregister " |
| 109 | + f"symmetrically on Blender {bpy.app.version_string}") |
| 110 | + return 0 |
| 111 | + |
| 112 | + |
| 113 | +sys.exit(main()) |
0 commit comments