#!/usr/bin/env python3 """Offline verification harness for a built .plugin bundle. Runs without a phone. Usage: python3 tools/verify_bundle.py """ from __future__ import annotations import ast import json import sys import traceback import types from dataclasses import dataclass from enum import Enum, auto from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent PLUGIN_JSON = REPO_ROOT / "plugin.json" _FAILURES = [] def check(name: str, condition: bool, detail: str = "") -> None: if condition: print(f"PASS {name}") else: message = f"FAIL {name}" if detail: message += f": {detail}" print(message, file=sys.stderr) _FAILURES.append(name) class HookStrategy(Enum): DEFAULT = auto() MODIFY = auto() CANCEL = auto() MODIFY_FINAL = auto() @dataclass class HookResult: strategy: HookStrategy = HookStrategy.DEFAULT params: object = None LOG_LINES: list[str] = [] class BasePlugin: def __init__(self): self._settings = {} def get_setting(self, key, default=None): return self._settings.get(key, default) def set_setting(self, key, value): self._settings[key] = value def add_on_send_message_hook(self): pass def on_plugin_load(self): pass def on_plugin_unload(self): pass def create_settings(self): return [] def fake_log(*args, **kwargs): LOG_LINES.append(" ".join(str(a) for a in args)) class _SettingsRow: def __init__(self, **kwargs): self.__dict__.update(kwargs) def install_fakes() -> None: base_plugin_mod = types.ModuleType("base_plugin") base_plugin_mod.BasePlugin = BasePlugin base_plugin_mod.HookResult = HookResult base_plugin_mod.HookStrategy = HookStrategy sys.modules["base_plugin"] = base_plugin_mod android_utils_mod = types.ModuleType("android_utils") android_utils_mod.log = fake_log sys.modules["android_utils"] = android_utils_mod ui_mod = types.ModuleType("ui") ui_mod.__path__ = [] sys.modules["ui"] = ui_mod ui_settings_mod = types.ModuleType("ui.settings") for cls_name in ("Header", "Input", "Text", "Switch"): setattr(ui_settings_mod, cls_name, type(cls_name, (_SettingsRow,), {})) sys.modules["ui.settings"] = ui_settings_mod ui_mod.settings = ui_settings_mod class SimpleParams: def __init__(self, message): self.message = message KEY_TO_DUNDER = { "id": "__id__", "name": "__name__", "description": "__description__", "author": "__author__", "version": "__version__", "icon": "__icon__", "app_version": "__app_version__", "sdk_version": "__sdk_version__", "requirements": "__requirements__", } def expected_dunders(meta: dict) -> dict: result = {} for key, dunder in KEY_TO_DUNDER.items(): if key not in meta: continue value = meta[key] if key in ("description", "icon") and value == "": continue if key == "requirements" and value == []: continue result[dunder] = value return result def main(argv=None) -> int: argv = sys.argv[1:] if argv is None else argv if len(argv) != 1: print("usage: tools/verify_bundle.py ", file=sys.stderr) return 1 bundle_path = Path(argv[0]) if not bundle_path.is_file(): print(f"error: bundle not found at {bundle_path}", file=sys.stderr) return 1 install_fakes() source = bundle_path.read_text(encoding="utf-8") modules_before = set(sys.modules) ns = {"__name__": "hello_bundle"} exec(compile(source, str(bundle_path), "exec"), ns) new_modules = set(sys.modules) - modules_before meta = json.loads(PLUGIN_JSON.read_text(encoding="utf-8")) expected = expected_dunders(meta) mismatches = [ f"{dunder}: expected {value!r}, got {ns.get(dunder)!r}" for dunder, value in expected.items() if ns.get(dunder) != value ] check("metadata constants match plugin.json", not mismatches, "; ".join(mismatches)) tree = ast.parse(source, filename=str(bundle_path)) top_level_assigned = { target.id for node in tree.body if isinstance(node, ast.Assign) for target in node.targets if isinstance(target, ast.Name) } check( "metadata exposed as top-level literal assignments (AST)", set(expected) <= top_level_assigned, f"missing: {set(expected) - top_level_assigned}", ) plugin_classes = [ value for value in ns.values() if isinstance(value, type) and issubclass(value, BasePlugin) and value is not BasePlugin ] check( "exactly one BasePlugin subclass defined", len(plugin_classes) == 1, f"found {len(plugin_classes)}", ) plugin_cls = plugin_classes[0] if len(plugin_classes) == 1 else None if plugin_cls is not None: check( "plugin class __module__ matches executed namespace", plugin_cls.__module__ == ns["__name__"], f"__module__={plugin_cls.__module__!r} != {ns['__name__']!r}", ) plugin = plugin_cls() LOG_LINES.clear() plugin.on_plugin_load() check( "on_plugin_load emits load log line", any("hello_bundle loaded" in line for line in LOG_LINES), f"log lines: {LOG_LINES}", ) params = SimpleParams(".hello alice smith") result = plugin.on_send_message_hook(account=0, params=params) check( "'.hello alice smith' renders greeting", params.message == "Hello, Alice Smith!", f"got {params.message!r}", ) check( "on_send_message_hook returns MODIFY strategy", result.strategy == HookStrategy.MODIFY, f"got {result.strategy}", ) plugin.set_setting("template", "{nope}") params2 = SimpleParams(".hello someone") plugin.on_send_message_hook(account=0, params=params2) check( "bad template produces 'Bad template:' message", params2.message.startswith("Bad template:"), f"got {params2.message!r}", ) # main.py imports `render`/`TemplateError` by name, so they live directly # in the executed namespace — call render() directly to inspect the raised # traceback (proves linecache registration for bundled, non-file modules). render = ns.get("render") template_error = ns.get("TemplateError") traceback_text = "" if render is not None and template_error is not None: try: render("{nope}", "x") except template_error: traceback_text = traceback.format_exc() check( "render() traceback names src/greeting/template.py", "src/greeting/template.py" in traceback_text, f"traceback:\n{traceback_text}", ) check( "traceback includes the raising source line text", "unsupported placeholder" in traceback_text, f"traceback:\n{traceback_text}", ) check( "sys.modules gained no entry for bundled module names", not ({"greeting", "textutil", "main"} & new_modules), f"leaked: {({'greeting', 'textutil', 'main'} & new_modules)}", ) if _FAILURES: print(f"\n{len(_FAILURES)} check(s) failed: {', '.join(_FAILURES)}", file=sys.stderr) return 1 print("\nall checks passed") return 0 if __name__ == "__main__": sys.exit(main())