Source code for swcstudio.plugins.loader

"""Dynamic plugin loader for swcstudio."""

from __future__ import annotations

import importlib
import os
from types import ModuleType
from typing import Any, Iterable

from .contracts import PluginManifest, plugin_manifest_to_dict
from .registry import register_plugin_manifest, register_plugin_method

_LOADED_MODULES: set[str] = set()


[docs] class PluginRegistrar: """Registrar object passed to external plugins during registration.""" def __init__(self, plugin_id: str) -> None: self.plugin_id = plugin_id self._registered: list[dict[str, str]] = [] def register_method(self, feature_key: str, method_name: str, func) -> None: register_plugin_method(self.plugin_id, feature_key, method_name, func) self._registered.append( { "feature_key": feature_key, "method_name": method_name, } ) def registered_methods(self) -> list[dict[str, str]]: return list(self._registered)
def _manifest_from_module(module: ModuleType) -> PluginManifest: if hasattr(module, "get_plugin_manifest"): raw = module.get_plugin_manifest() # type: ignore[attr-defined] elif hasattr(module, "PLUGIN_MANIFEST"): raw = getattr(module, "PLUGIN_MANIFEST") else: raise ValueError( f"Plugin module '{module.__name__}' must define PLUGIN_MANIFEST or get_plugin_manifest()." ) if not isinstance(raw, dict): raise ValueError( f"Plugin module '{module.__name__}' returned invalid manifest type; expected dict." ) manifest = register_plugin_manifest(raw) return manifest def _register_from_plugin_methods_attr(module: ModuleType, registrar: PluginRegistrar) -> int: if not hasattr(module, "PLUGIN_METHODS"): return 0 raw = getattr(module, "PLUGIN_METHODS") count = 0 if isinstance(raw, dict): for feature_key, methods in raw.items(): if not isinstance(methods, dict): raise ValueError( f"PLUGIN_METHODS['{feature_key}'] must be dict[method_name -> callable]." ) for method_name, func in methods.items(): registrar.register_method(str(feature_key), str(method_name), func) count += 1 return count if isinstance(raw, (list, tuple)): for row in raw: if not isinstance(row, dict): raise ValueError("PLUGIN_METHODS list items must be dictionaries.") feature_key = str(row.get("feature_key", "")).strip() method_name = str(row.get("method_name", "")).strip() func = row.get("func") if not feature_key or not method_name or not callable(func): raise ValueError( "PLUGIN_METHODS item must contain feature_key, method_name, and callable func." ) registrar.register_method(feature_key, method_name, func) count += 1 return count raise ValueError("PLUGIN_METHODS must be dict or list.")
[docs] def load_plugin_module(module_name: str, *, force_reload: bool = False) -> dict[str, Any]: """Load one plugin module and register its methods. Expected plugin contract: 1) PLUGIN_MANIFEST dict (or get_plugin_manifest()) 2) register_plugin(registrar) function OR PLUGIN_METHODS attribute """ mod_name = str(module_name).strip() if not mod_name: raise ValueError("module_name cannot be empty.") if mod_name in _LOADED_MODULES and not force_reload: return {"ok": True, "module": mod_name, "status": "already_loaded"} module = importlib.import_module(mod_name) if force_reload: module = importlib.reload(module) manifest = _manifest_from_module(module) registrar = PluginRegistrar(manifest.plugin_id) count = 0 if hasattr(module, "register_plugin"): module.register_plugin(registrar) # type: ignore[attr-defined] count += len(registrar.registered_methods()) else: count += _register_from_plugin_methods_attr(module, registrar) _LOADED_MODULES.add(mod_name) return { "ok": True, "module": mod_name, "plugin": plugin_manifest_to_dict(manifest), "registered_method_count": count, "registered_methods": registrar.registered_methods(), "status": "loaded", }
[docs] def load_plugins(modules: Iterable[str]) -> list[dict[str, Any]]: """Load multiple plugin modules and return per-module results.""" out: list[dict[str, Any]] = [] for module_name in modules: try: out.append(load_plugin_module(module_name)) except Exception as exc: # noqa: BLE001 out.append( { "ok": False, "module": str(module_name), "error": str(exc), } ) return out
[docs] def autoload_plugins_from_environment( env_var: str = "SWCSTUDIO_PLUGINS", ) -> list[dict[str, Any]]: """Autoload plugin modules from comma-separated environment variable.""" raw = str(os.environ.get(env_var, "")).strip() if not raw and env_var == "SWCSTUDIO_PLUGINS": raw = str(os.environ.get("SWCTOOLS_PLUGINS", "")).strip() if not raw: return [] mods = [m.strip() for m in raw.split(",") if m.strip()] return load_plugins(mods)