""" Plugin discovery, loading and dispatch. Scans ``backend/plugins/*.py``, imports each one, and collects any module-level ``Plugin`` instance. Reloading is supported at runtime so you can edit a plugin and press "Reload" in the Extensions tab without restarting the server. A plugin that fails to import does not take the dashboard down - the error is recorded and shown in the UI next to the plugin that caused it. """ from __future__ import annotations import asyncio import importlib import importlib.util import sys import traceback from pathlib import Path from .plugin_api import Context, Plugin, invoke PLUGIN_DIR = Path(__file__).resolve().parent / "plugins" class Registry: def __init__(self, hub, config: dict): self.hub = hub self.config = config self.bridge = None self.plugins: dict[str, Plugin] = {} self.errors: list[dict] = [] self._tasks: dict[str, asyncio.Task] = {} self._storage: dict[str, dict] = {} def attach(self, bridge) -> None: self.bridge = bridge # -- loading ------------------------------------------------------------ async def load(self) -> dict: """Import every plugin file. Safe to call repeatedly.""" await self._stop_tasks() self.plugins.clear() self.errors.clear() PLUGIN_DIR.mkdir(parents=True, exist_ok=True) init = PLUGIN_DIR / "__init__.py" if not init.exists(): init.write_text("", encoding="utf-8") # A leading underscore marks a file as private: __init__.py and the # _template.py starting point are both skipped. for path in sorted(PLUGIN_DIR.glob("*.py")): if path.name.startswith("_"): continue self._load_file(path) for plugin in self.plugins.values(): plugin.storage = self._storage.setdefault(plugin.id, {}) await self._start_plugin(plugin) summary = { "loaded": sorted(self.plugins), "errors": list(self.errors), "count": len(self.plugins), } if self.errors: await self.hub.emit("warn", "plugins", f"{len(self.plugins)} plugin(s) loaded, {len(self.errors)} failed") else: await self.hub.emit("info", "plugins", f"{len(self.plugins)} plugin(s) loaded") return summary def _load_file(self, path: Path) -> None: module_name = f"backend.plugins.{path.stem}" try: # Always build a fresh module from the file rather than calling # importlib.reload. reload() requires the parent package to be in # sys.modules and re-runs stale bytecode paths; executing the file # afresh is both simpler and a true reload of what is on disk. spec = importlib.util.spec_from_file_location(module_name, path) if spec is None or spec.loader is None: raise ImportError(f"Cannot build import spec for {path.name}") module = importlib.util.module_from_spec(spec) sys.modules[module_name] = module spec.loader.exec_module(module) found = [v for v in vars(module).values() if isinstance(v, Plugin)] if not found: self.errors.append({ "file": path.name, "error": "No Plugin instance found at module level", "trace": "", }) return for plugin in found: if plugin.id in self.plugins: self.errors.append({ "file": path.name, "error": f"Duplicate plugin id '{plugin.id}' - ignoring this one", "trace": "", }) continue self.plugins[plugin.id] = plugin except Exception as exc: self.errors.append({ "file": path.name, "error": f"{type(exc).__name__}: {exc}", "trace": traceback.format_exc(limit=6), }) sys.modules.pop(module_name, None) async def _start_plugin(self, plugin: Plugin) -> None: ctx = self.context(plugin) if plugin._on_start: try: await invoke(plugin._on_start, ctx) except Exception as exc: self.errors.append({ "file": plugin.id, "error": f"on_start failed: {exc}", "trace": traceback.format_exc(limit=6), }) if plugin._on_tick: self._tasks[plugin.id] = asyncio.create_task( self._tick_loop(plugin), name=f"plugin-{plugin.id}" ) async def _tick_loop(self, plugin: Plugin) -> None: ctx = self.context(plugin) failures = 0 while True: try: await asyncio.sleep(plugin._tick_interval) await invoke(plugin._on_tick, ctx) failures = 0 except asyncio.CancelledError: raise except Exception as exc: failures += 1 await self.hub.emit("error", f"plugin:{plugin.id}", f"Tick failed: {exc}") if failures >= 5: await self.hub.emit("warn", f"plugin:{plugin.id}", "Tick disabled after 5 consecutive failures") return await asyncio.sleep(2.0) async def _stop_tasks(self) -> None: for task in self._tasks.values(): task.cancel() for task in list(self._tasks.values()): try: await task except (asyncio.CancelledError, Exception): pass self._tasks.clear() async def shutdown(self) -> None: await self._stop_tasks() # -- dispatch ----------------------------------------------------------- def context(self, plugin: Plugin) -> Context: return Context( bridge=self.bridge, hub=self.hub, config=self.config, storage=self._storage.setdefault(plugin.id, {}), plugin_id=plugin.id, ) async def dispatch(self, plugin_id: str, control_key: str, value=None) -> dict: plugin = self.plugins.get(plugin_id) if plugin is None: return {"ok": False, "message": f"No plugin '{plugin_id}'"} control = plugin.find(control_key) if control is None or control.handler is None: return {"ok": False, "message": f"No control '{control_key}' in '{plugin_id}'"} try: result = await invoke(control.handler, self.context(plugin), value) except Exception as exc: await self.hub.emit("error", f"plugin:{plugin_id}", f"{control_key}: {exc}") return {"ok": False, "message": f"{type(exc).__name__}: {exc}", "detail": traceback.format_exc(limit=4)} if isinstance(result, dict) and "ok" in result: return result return {"ok": True, "message": str(result) if result is not None else "Done"} # -- introspection ------------------------------------------------------ def manifest(self) -> dict: return { "plugins": sorted((p.manifest() for p in self.plugins.values()), key=lambda m: (m["order"], m["name"])), "errors": list(self.errors), "directory": str(PLUGIN_DIR), }