bio-nexus-api / app /services /plugin_system.py
Samad14's picture
Upload app/services/plugin_system.py with huggingface_hub
5d77e0d verified
Raw History Blame Contribute Delete
6.23 kB
"""Plugin System (BioNexus 2.0, Component 20).
Extends the platform at runtime: lightweight Python plugins declare hook
callbacks that are dispatched by the PluginManager around engine validation,
exports and pipeline events. Plugins live in BIONEXUS_PLUGINS_DIR (default
app/plugins/) as plain .py files; each subclass of BioNexusPlugin is loaded,
registered and enabled. All plugin failures are contained (never break the
host operation).
"""
from __future__ import annotations
import importlib.util
import logging
import os
import threading
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
PLUGIN_DIR = os.environ.get("BIONEXUS_PLUGINS_DIR")
if not PLUGIN_DIR:
PLUGIN_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "plugins")
class BioNexusPlugin:
"""Override the hook methods you care about. `name`/`version` are required."""
name: str = "unnamed"
version: str = "0.0.0"
description: str = ""
#: lifecycle + pipeline hooks (all optional, all contained-safe)
def on_register(self) -> None:
pass
def on_disable(self) -> None:
pass
def before_validate(self, engine_name: str, report: dict, result: Any = None) -> list[dict]:
"""Return extra validation checks [{name, passed, detail}] appended to
the engine's native PASS/FAIL report. `result` is the parsed EngineResult."""
return []
def after_export(self, engine_name: str, payload: dict) -> dict | None:
"""Optionally decorate an exported artifact (returned dict is merged)."""
return None
def on_event(self, event: str, payload: dict) -> list[dict]:
"""Receive arbitrary platform events as a trace record."""
return []
class PluginManager:
def __init__(self, plugin_dir: str | None = None) -> None:
self.dir = plugin_dir or PLUGIN_DIR
self.plugins: dict[str, BioNexusPlugin] = {}
self.enabled: set[str] = set()
self.trace: list[dict] = []
self._lock = threading.Lock()
# --- discovery ----------------------------------------------------------
def load_dir(self, directory: str | None = None) -> list[str]:
"""Import every *.py plugin in the (default) plugin directory."""
scan_dir = directory or self.dir
loaded: list[str] = []
if not os.path.isdir(scan_dir):
return loaded
for path in sorted(Path(scan_dir).glob("*.py")):
if path.name.startswith("_"):
continue
before = set(self.plugins)
self._load_file(path)
if set(self.plugins) - before:
loaded.append(path.stem)
return loaded
def _load_file(self, path: Path) -> None:
try:
spec = importlib.util.spec_from_file_location(f"bionexus_plugin_{path.stem}", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
except Exception as e:
logger.warning("plugin %s import failed: %s", path.name, e)
return
added = False
for obj in vars(mod).values():
if isinstance(obj, type) and issubclass(obj, BioNexusPlugin) and obj is not BioNexusPlugin:
plugin = obj()
try:
plugin.on_register()
self.plugins[plugin.name] = plugin
self.enabled.add(plugin.name)
except Exception as e:
logger.warning("plugin %s register failed: %s", plugin.name, e)
def reload(self) -> list[str]:
with self._lock:
self.plugins = {}
self.enabled = set()
return self.load_dir()
# --- state --------------------------------------------------------------
def list_plugins(self) -> list[dict]:
with self._lock:
return [{"name": p.name, "version": p.version, "description": p.description,
"enabled": p.name in self.enabled}
for p in self.plugins.values()]
def set_enabled(self, name: str, enabled: bool) -> bool:
with self._lock:
if name not in self.plugins:
return False
if enabled:
self.enabled.add(name)
else:
self.enabled.discard(name)
try:
self.plugins[name].on_disable()
except Exception as e:
logger.warning("plugin %s on_disable failed: %s", name, e)
return True
# --- hooks --------------------------------------------------------------
def _active(self) -> list[BioNexusPlugin]:
with self._lock:
return [p for n, p in self.plugins.items() if n in self.enabled]
def before_validate(self, engine_name: str, report: dict, result: Any = None) -> list[dict]:
"""Collect extra checks from every active plugin, containing failures."""
extra: list[dict] = []
for p in self._active():
try:
extra.extend(p.before_validate(engine_name, report, result))
except Exception as e:
extra.append({"name": f"{p.name}:validate_error", "passed": False, "detail": str(e)})
return extra
def after_export(self, engine_name: str, payload: dict) -> dict:
merged = dict(payload)
for p in self._active():
try:
decor = p.after_export(engine_name, payload)
if isinstance(decor, dict):
merged.update(decor)
except Exception as e:
logger.warning("plugin %s.after_export failed: %s", p.name, e)
return merged
def run_event(self, event: str, payload: dict) -> list[dict]:
records: list[dict] = []
for p in self._active():
try:
records.extend(p.on_event(event, dict(payload)))
except Exception as e:
records.append({"plugin": p.name, "event": event, "error": str(e)})
self.trace.append({"event": event, "payload": payload, "records": records})
return records
plugin_manager = PluginManager()