2026-08-13 16:23:18 +04:00

204 lines
7.4 KiB
Python

"""
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),
}