From c6b3672a495f7d4b7396e1fef46ee69c68c309ea Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 17 Mar 2026 21:28:37 -0400 Subject: [PATCH] feat: add plugin manager (load, validate, register plugins) --- fablednetmon/core/plugin_manager.py | 129 ++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 fablednetmon/core/plugin_manager.py diff --git a/fablednetmon/core/plugin_manager.py b/fablednetmon/core/plugin_manager.py new file mode 100644 index 0000000..6c4f885 --- /dev/null +++ b/fablednetmon/core/plugin_manager.py @@ -0,0 +1,129 @@ +# fablednetmon/core/plugin_manager.py +from __future__ import annotations +import importlib +import logging +import sys +from pathlib import Path +from typing import TYPE_CHECKING + +import yaml +from packaging.version import Version + +if TYPE_CHECKING: + from quart import Quart + +logger = logging.getLogger(__name__) + + +def load_plugins(app: "Quart") -> None: + """Import all enabled plugins and register their blueprints and scheduled tasks. + + For each plugin listed as enabled in config.yaml under 'plugins': + 1. Locate the plugin directory under PLUGIN_DIR + 2. Load and validate plugin.yaml (name must match dir, min_app_version check) + 3. Merge plugin.yaml config defaults with user overrides in app.config["PLUGINS"] + 4. Import the plugin Python package + 5. Validate required exports: setup() and get_scheduled_tasks() + 6. Call setup(app) + 7. Register blueprint at /plugins// if get_blueprint() exists + 8. Append get_scheduled_tasks() results to app._task_registry + """ + import fablednetmon + + plugin_dir = Path(app.config["PLUGIN_DIR"]) + plugins_cfg: dict = app.config["PLUGINS"] + + # Ensure plugin_dir is on sys.path so plugins are importable by name + plugin_dir_str = str(plugin_dir.resolve()) + if plugin_dir_str not in sys.path: + sys.path.insert(0, plugin_dir_str) + + for name, cfg in list(plugins_cfg.items()): + if not cfg.get("enabled", False): + continue + + plugin_path = plugin_dir / name + if not plugin_path.exists(): + logger.error("Plugin %r: directory %s not found, skipping", name, plugin_path) + continue + + # Load and validate plugin.yaml + yaml_path = plugin_path / "plugin.yaml" + if not yaml_path.exists(): + logger.error("Plugin %r: plugin.yaml not found, skipping", name) + continue + + try: + with yaml_path.open() as f: + meta = yaml.safe_load(f) or {} + except Exception: + logger.exception("Plugin %r: failed to read plugin.yaml, skipping", name) + continue + + if meta.get("name") != name: + logger.error( + "Plugin %r: plugin.yaml name=%r does not match directory name, skipping", + name, meta.get("name"), + ) + continue + + min_ver = meta.get("min_app_version") + if min_ver: + try: + if Version(fablednetmon.__version__) < Version(min_ver): + logger.error( + "Plugin %r: requires fablednetmon>=%s but running %s, skipping", + name, min_ver, fablednetmon.__version__, + ) + continue + except Exception: + logger.exception("Plugin %r: invalid min_app_version %r, skipping", name, min_ver) + continue + + # Merge plugin.yaml config defaults with user overrides (non-"enabled" keys) + defaults = meta.get("config", {}) + user_overrides = {k: v for k, v in cfg.items() if k != "enabled"} + plugins_cfg[name] = {**defaults, **user_overrides} + + # Import the plugin package + try: + module = importlib.import_module(name) + except ImportError: + logger.exception("Plugin %r: import failed, skipping", name) + continue + + # Validate required exports + if not hasattr(module, "setup"): + logger.error("Plugin %r: missing required export 'setup', skipping", name) + continue + if not hasattr(module, "get_scheduled_tasks"): + logger.error( + "Plugin %r: missing required export 'get_scheduled_tasks', skipping", name + ) + continue + + # Call setup + try: + module.setup(app) + except Exception: + logger.exception("Plugin %r: setup() raised, skipping", name) + continue + + # Register blueprint (optional) + if hasattr(module, "get_blueprint"): + try: + bp = module.get_blueprint() + app.register_blueprint(bp, url_prefix=f"/plugins/{name}") + logger.info("Plugin %r: blueprint registered at /plugins/%s/", name, name) + except Exception: + logger.exception("Plugin %r: get_blueprint() raised", name) + + # Register scheduled tasks + try: + tasks = module.get_scheduled_tasks() + app._task_registry.extend(tasks) + logger.info("Plugin %r: registered %d scheduled task(s)", name, len(tasks)) + except Exception: + logger.exception("Plugin %r: get_scheduled_tasks() raised", name) + + logger.info("Plugin %r loaded (v%s)", name, meta.get("version", "?"))