Spaces:
Running
Running
File size: 7,852 Bytes
e70c280 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 | from __future__ import annotations
import importlib
import importlib.util
import logging
import shutil
import subprocess
import sys
from pathlib import Path
from typing import TYPE_CHECKING, Optional
from src.core.plugins.catalog import PluginManifestCatalog
from src.core.plugins.entrypoints import PluginEntryPointResolver
from src.core.plugins.manifest import PluginManifest
if TYPE_CHECKING:
from src.core.plugins.context import PluginContext
logger = logging.getLogger("brainapi.plugins")
class PluginLoader:
def __init__(
self,
plugins_dir: Path,
context: "PluginContext",
manifest_catalog: Optional[PluginManifestCatalog] = None,
entry_point_resolver: Optional[PluginEntryPointResolver] = None,
):
self.plugins_dir = plugins_dir
self.context = context
self._loaded: dict[str, PluginManifest] = {}
self._manifest_catalog = manifest_catalog or PluginManifestCatalog(
plugins_dir=plugins_dir,
logger_override=logger,
)
self._entry_point_resolver = entry_point_resolver or PluginEntryPointResolver()
def discover(self) -> list[PluginManifest]:
if not self.plugins_dir.exists():
logger.info("Plugins directory '%s' does not exist, skipping discovery", self.plugins_dir)
return []
manifests = self._manifest_catalog.discover(validate=True)
manifests.sort(key=lambda m: m.priority)
return manifests
def install_dependencies(self, manifest: PluginManifest) -> None:
if not manifest.pip_dependencies:
return
logger.info("Installing pip dependencies for plugin '%s': %s", manifest.name, manifest.pip_dependencies)
cmd = self._build_install_cmd(manifest.pip_dependencies)
try:
subprocess.check_call(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
except subprocess.CalledProcessError as exc:
logger.error(
"Failed to install dependencies for plugin '%s': %s",
manifest.name,
exc.stderr.decode() if exc.stderr else str(exc),
)
raise
@staticmethod
def _build_install_cmd(packages: list[str]) -> list[str]:
if shutil.which("uv"):
return ["uv", "pip", "install", "--quiet", *packages]
return [sys.executable, "-m", "pip", "install", "--quiet", *packages]
def load(self, manifest: PluginManifest) -> bool:
if manifest.name in self._loaded:
logger.debug("Plugin '%s' already loaded, skipping", manifest.name)
return True
plugin_dir = manifest.plugin_dir
if plugin_dir is None:
logger.error("Plugin '%s' has no plugin_dir set", manifest.name)
return False
try:
self.install_dependencies(manifest)
except Exception:
return False
plugin_dir_str = str(plugin_dir)
original_sys_path = list(sys.path)
sys.path[:] = [
plugin_dir_str,
*[
entry
for entry in original_sys_path
if entry != plugin_dir_str and not self._is_plugin_path(entry)
],
]
self._clear_local_plugin_namespaces()
importlib.invalidate_caches()
module_name = self._entry_point_resolver.resolve(
entry_point=manifest.entry_point,
plugin_dir=plugin_dir,
)
entrypoint_path = self._entry_point_resolver.resolve_path(
entry_point=manifest.entry_point,
plugin_dir=plugin_dir,
)
unique_module_name = self._module_name_for_manifest(manifest, module_name)
try:
module = self._import_entrypoint(
unique_module_name=unique_module_name,
entrypoint_path=entrypoint_path,
)
except Exception as exc:
logger.error("Failed to import plugin '%s' (module: %s): %s", manifest.name, module_name, exc)
self._cleanup_plugin_imports(plugin_dir_str, original_sys_path)
return False
register_fn = getattr(module, "register", None)
if register_fn is None or not callable(register_fn):
logger.error("Plugin '%s' has no callable 'register' function in '%s'", manifest.name, module_name)
self._cleanup_plugin_imports(plugin_dir_str, original_sys_path)
return False
try:
register_fn(self.context)
self._loaded[manifest.name] = manifest
logger.info("Plugin '%s' v%s loaded successfully", manifest.name, manifest.version)
self._cleanup_plugin_imports(plugin_dir_str, original_sys_path)
return True
except Exception as exc:
logger.error("Plugin '%s' register() raised an exception: %s", manifest.name, exc, exc_info=True)
self._cleanup_plugin_imports(plugin_dir_str, original_sys_path)
return False
@staticmethod
def _is_plugin_path(path_entry: str) -> bool:
try:
return "plugins" in Path(path_entry).parts
except (TypeError, ValueError):
return False
def _cleanup_plugin_imports(
self, plugin_dir: str, original_sys_path: list[str]
) -> None:
sys.path[:] = original_sys_path
self._clear_local_plugin_namespaces()
sys.path_importer_cache.pop(plugin_dir, None)
importlib.invalidate_caches()
def _clear_local_plugin_namespaces(self) -> None:
namespace_roots = (
"routes",
"controllers",
"adapters",
"constants",
"agents",
"workers",
"prompts",
)
for module_name in list(sys.modules.keys()):
if any(
module_name == namespace_root
or module_name.startswith(f"{namespace_root}.")
for namespace_root in namespace_roots
):
sys.modules.pop(module_name, None)
@staticmethod
def _safe_module_token(value: str) -> str:
return "".join(ch if ch.isalnum() else "_" for ch in value)
def _module_name_for_manifest(self, manifest: PluginManifest, module_name: str) -> str:
plugin_token = self._safe_module_token(manifest.name)
module_token = self._safe_module_token(module_name)
return f"brainapi_plugin_{plugin_token}__{module_token}"
def _import_entrypoint(
self,
unique_module_name: str,
entrypoint_path: Path,
):
if not entrypoint_path.exists():
raise ModuleNotFoundError(f"Entrypoint file not found: {entrypoint_path}")
spec = importlib.util.spec_from_file_location(unique_module_name, entrypoint_path)
if spec is None or spec.loader is None:
raise ImportError(f"Unable to create module spec for '{entrypoint_path}'")
module = importlib.util.module_from_spec(spec)
sys.modules[unique_module_name] = module
spec.loader.exec_module(module)
return module
def load_all(self) -> dict[str, bool]:
manifests = self.discover()
results: dict[str, bool] = {}
for manifest in manifests:
try:
results[manifest.name] = self.load(manifest)
except Exception as exc:
logger.error("Unexpected error loading plugin '%s': %s", manifest.name, exc, exc_info=True)
results[manifest.name] = False
loaded_count = sum(1 for v in results.values() if v)
total = len(results)
if total > 0:
logger.info("Loaded %d/%d plugins", loaded_count, total)
return results
@property
def loaded_plugins(self) -> dict[str, PluginManifest]:
return dict(self._loaded)
|