204 lines
7.4 KiB
Python
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),
|
|
}
|