refactor(cli/plugins): move plugin loading/deferred-platform/portable methods into PluginLoaderMixin (plugins_loader.py)
This commit is contained in:
+13
-504
@@ -14,9 +14,7 @@ import asyncio
|
||||
import builtins
|
||||
import contextvars
|
||||
import copy
|
||||
import hashlib
|
||||
import importlib.metadata
|
||||
import importlib.util
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
@@ -27,18 +25,12 @@ import sys
|
||||
import threading
|
||||
import time
|
||||
import types
|
||||
from contextlib import contextmanager, suppress
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Mapping, Optional, Set, Tuple, Union
|
||||
|
||||
from hermes_constants import (
|
||||
get_hermes_home,
|
||||
hermes_home_key,
|
||||
reset_hermes_home_override,
|
||||
set_hermes_home_override,
|
||||
)
|
||||
from hermes_constants import get_hermes_home, hermes_home_key
|
||||
from registration_lifecycle import replacement_coordinator
|
||||
from utils import env_var_enabled
|
||||
from hermes_cli.config import cfg_get, load_config_readonly
|
||||
@@ -48,11 +40,7 @@ from hermes_cli.plugin_capabilities import ( # noqa: F401 — re-exported
|
||||
VALID_CAPABILITY_IDS,
|
||||
plugin_capability_granted,
|
||||
)
|
||||
from hermes_cli.relay_plugin_cutover import (
|
||||
LEGACY_RELAY_PLUGIN_KEYS,
|
||||
RELAY_PLUGINS_CONFIG_ENV,
|
||||
legacy_relay_plugin_keys,
|
||||
)
|
||||
from hermes_cli.relay_plugin_cutover import RELAY_PLUGINS_CONFIG_ENV, legacy_relay_plugin_keys
|
||||
from hermes_cli.plugins_manifest import ( # noqa: F401 — re-exported
|
||||
parse_manifest_file,
|
||||
portable_plugin_manifest,
|
||||
@@ -85,6 +73,15 @@ from hermes_cli.plugins_discovery import ( # noqa: F401 — re-exported
|
||||
_select_entry_point_group,
|
||||
discover_entrypoint_manifests,
|
||||
)
|
||||
from hermes_cli.plugins_loader import ( # noqa: F401 — re-exported
|
||||
PluginLoaderMixin,
|
||||
_NS_PARENT,
|
||||
_MODULE_NAMESPACE_LOCK,
|
||||
_BARE_MODULE_SCOPE,
|
||||
_evict_modules,
|
||||
_serialized_replacement,
|
||||
_plugin_home_scope,
|
||||
)
|
||||
from hermes_cli.plugins_state import ( # noqa: F401 — re-exported
|
||||
_PLUGIN_SETTING_RESERVED_ROOTS,
|
||||
_PLUGIN_SETTING_SEGMENT_RE,
|
||||
@@ -317,38 +314,6 @@ _EVENT_EMIT_DEPTH_CAP = 8
|
||||
_EVENT_PENDING_CAP = 64
|
||||
_EVENT_WORKER_STOP = object()
|
||||
|
||||
_NS_PARENT = "hermes_plugins"
|
||||
_MODULE_NAMESPACE_LOCK = threading.RLock()
|
||||
_BARE_MODULE_SCOPE: Dict[str, str] = {}
|
||||
|
||||
|
||||
def _evict_modules(module_name: str) -> None:
|
||||
"""Drop ``module_name`` and every ``module_name.*`` submodule from ``sys.modules``."""
|
||||
prefix = f"{module_name}."
|
||||
for name in [n for n in sys.modules if n == module_name or n.startswith(prefix)]:
|
||||
del sys.modules[name]
|
||||
|
||||
|
||||
def _serialized_replacement(method):
|
||||
"""Make snapshot → write → lease attachment one atomic transaction."""
|
||||
@wraps(method)
|
||||
def wrapped(*args, **kwargs):
|
||||
with replacement_coordinator.transaction():
|
||||
return method(*args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _plugin_home_scope(home: Path):
|
||||
"""Bind discovery and loading to the manager's immutable Hermes home."""
|
||||
token = set_hermes_home_override(home)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
|
||||
|
||||
_env_enabled = env_var_enabled # imported by plugins/memory
|
||||
|
||||
|
||||
@@ -1801,7 +1766,7 @@ def _pre_tool_call_timeout_block() -> Dict[str, str]:
|
||||
return {"action": "block", "message": _PRE_TOOL_CALL_TIMEOUT_BLOCK_MESSAGE}
|
||||
|
||||
|
||||
class PluginManager:
|
||||
class PluginManager(PluginLoaderMixin):
|
||||
"""Central manager that discovers, loads, and invokes plugins."""
|
||||
|
||||
def __init__(self, scope_key: Optional[str] = None) -> None:
|
||||
@@ -2449,462 +2414,6 @@ class PluginManager:
|
||||
|
||||
# -- loading ---------------------------------------------------------------
|
||||
|
||||
def _platform_name_from_manifest(self, manifest: PluginManifest) -> str:
|
||||
"""Derive the platform name without importing the adapter: strip a trailing ``-platform``
|
||||
from the manifest name, else the directory basename (the bundled convention)."""
|
||||
name = manifest.name or ""
|
||||
if name.endswith("-platform"):
|
||||
return name[: -len("-platform")]
|
||||
if manifest.path:
|
||||
return Path(manifest.path).name
|
||||
return name
|
||||
|
||||
@_serialized_replacement
|
||||
def _register_deferred_platform(self, manifest: PluginManifest) -> None:
|
||||
"""Register a lazy loader for a bundled platform: the adapter imports only when the
|
||||
``platform_registry`` is first asked for it; a placeholder ``LoadedPlugin`` keeps it visible
|
||||
in ``hermes plugins list`` until then."""
|
||||
lookup_key = manifest.key or manifest.name
|
||||
platform_name = self._platform_name_from_manifest(manifest)
|
||||
|
||||
loaded = LoadedPlugin(manifest=manifest, enabled=True)
|
||||
loaded.deferred = True
|
||||
self._plugins[lookup_key] = loaded
|
||||
|
||||
try:
|
||||
from gateway.platform_registry import platform_registry
|
||||
|
||||
scope = self.scope_key
|
||||
|
||||
def _loader(_manifest: PluginManifest = manifest) -> None:
|
||||
# Lock before checking cancellation: if an unload won the race it restored the
|
||||
# predecessor and this loader must publish nothing; if loading won, unload waits
|
||||
# and disposes the completed set.
|
||||
with self._discovery_lock, _plugin_home_scope(self.home_path):
|
||||
if platform_registry.is_deferred_load_cancelled(
|
||||
platform_name, scope=scope
|
||||
):
|
||||
return
|
||||
self._load_plugin_scoped(_manifest)
|
||||
|
||||
previous = platform_registry.snapshot_registration(platform_name, scope=scope)
|
||||
platform_registry.register_deferred(platform_name, _loader, scope=scope)
|
||||
current = platform_registry.snapshot_registration(platform_name, scope=scope)
|
||||
if current[0] is None and current[1] is _loader:
|
||||
self._plugin_platform_names.add(platform_name)
|
||||
self._track_scoped_registration(
|
||||
manifest, "platform", platform_name, platform_registry, current, previous,
|
||||
finalize=lambda: self._remove_platform_name_if_unowned(platform_name),
|
||||
)
|
||||
logger.debug(
|
||||
"Registered deferred platform loader: %s (plugin=%s)",
|
||||
platform_name,
|
||||
lookup_key,
|
||||
)
|
||||
except Exception:
|
||||
# Fall back to eager loading so the platform is never silently lost.
|
||||
logger.debug(
|
||||
"Deferred platform registration failed for '%s'; eager-loading",
|
||||
lookup_key,
|
||||
exc_info=True,
|
||||
)
|
||||
self._load_plugin(manifest)
|
||||
return
|
||||
|
||||
self._register_deferred_platform_tools(manifest, loaded)
|
||||
|
||||
def _register_deferred_platform_tools(
|
||||
self, manifest: PluginManifest, loaded: LoadedPlugin
|
||||
) -> None:
|
||||
"""Register a deferred platform's *client* tools without its adapter.
|
||||
|
||||
Deferring the plugin would otherwise defer its outbound tools too, so in CLI/TUI processes
|
||||
(which never materialize platforms) they would be missing from ``hermes tools`` and dropped
|
||||
from ``platform_toolsets``. Opt-in is explicit via ``provides_tools``; tools live in a
|
||||
``tools`` submodule so ``__init__`` stays import-light.
|
||||
"""
|
||||
if not manifest.provides_tools:
|
||||
return
|
||||
|
||||
lookup_key = manifest.key or manifest.name
|
||||
plugin_dir = Path(manifest.path) if manifest.path else None
|
||||
if plugin_dir is None or not (plugin_dir / "tools.py").is_file():
|
||||
# Declared but undeliverable — staying quiet reproduces the very symptom this fixes.
|
||||
logger.warning(
|
||||
"Plugin '%s' declares provides_tools %s but has no tools.py; "
|
||||
"those tools will not be available in CLI/TUI sessions.",
|
||||
lookup_key,
|
||||
list(manifest.provides_tools),
|
||||
)
|
||||
return
|
||||
|
||||
before = set(self._plugin_tool_names) # lets the failure path credit partial registrations
|
||||
try:
|
||||
module = self._load_directory_module(manifest)
|
||||
# Record the module even if nothing registers: the package body has run, so
|
||||
# materializing the adapter later must reuse it rather than execute it twice.
|
||||
loaded.module = module
|
||||
self._predeclared_modules[lookup_key] = module
|
||||
|
||||
tools_module = importlib.import_module(f"{module.__name__}.tools")
|
||||
register_tools = getattr(tools_module, "register_tools", None)
|
||||
if register_tools is None:
|
||||
logger.warning(
|
||||
"Plugin '%s' declares provides_tools %s but its tools.py "
|
||||
"has no register_tools(ctx); those tools will not be "
|
||||
"available in CLI/TUI sessions.",
|
||||
lookup_key,
|
||||
list(manifest.provides_tools),
|
||||
)
|
||||
return
|
||||
|
||||
register_tools(PluginContext(manifest, self))
|
||||
registered = [t for t in self._plugin_tool_names if t not in before]
|
||||
|
||||
loaded.tools_registered = registered
|
||||
self._predeclared_tools[lookup_key] = registered
|
||||
logger.debug(
|
||||
"Deferred platform '%s': pre-registered %d client tool(s) %s",
|
||||
lookup_key,
|
||||
len(registered),
|
||||
registered,
|
||||
)
|
||||
except Exception as exc:
|
||||
# Tools registered before the raise are live: credit them or `hermes plugins list`
|
||||
# under-reports (and _load_plugin's later diff would miss them too).
|
||||
partial = [t for t in self._plugin_tool_names if t not in before]
|
||||
if partial:
|
||||
loaded.tools_registered = partial
|
||||
self._predeclared_tools[lookup_key] = partial
|
||||
|
||||
# Never break discovery (the platform stays deferred), but a broken tools.py IS the
|
||||
# symptom, so warn — and say where it failed, which is what the operator needs first.
|
||||
declared = len(manifest.provides_tools)
|
||||
if not partial:
|
||||
scope = f"before registering any of its {declared} declared tool(s)"
|
||||
elif len(partial) >= declared:
|
||||
scope = f"after registering all {declared} declared tool(s)"
|
||||
else:
|
||||
scope = f"after registering {len(partial)} of {declared} declared tool(s)"
|
||||
logger.warning(
|
||||
"Plugin '%s': client-tool pre-registration failed %s (%s).%s",
|
||||
lookup_key,
|
||||
scope,
|
||||
exc,
|
||||
"" if len(partial) >= declared else
|
||||
" The remainder will be missing from CLI/TUI sessions.",
|
||||
exc_info=_PLUGINS_DEBUG,
|
||||
)
|
||||
|
||||
def _warn_python_dependencies(self, manifest: PluginManifest) -> None:
|
||||
"""Warn about missing declared pip dependencies with an install hint — NEVER auto-install."""
|
||||
deps = manifest.python_dependencies
|
||||
if not deps:
|
||||
return
|
||||
key = manifest.key or manifest.name
|
||||
missing: List[str] = []
|
||||
for req in deps:
|
||||
# Best-effort presence probe on the distribution name.
|
||||
dist = re.split(r"[<>=!~\[;\s]", req, maxsplit=1)[0].strip()
|
||||
if not dist:
|
||||
continue
|
||||
try:
|
||||
importlib.metadata.version(dist)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
missing.append(req)
|
||||
except Exception:
|
||||
continue
|
||||
if missing:
|
||||
logger.warning(
|
||||
"Plugin %s declares Python dependencies that are not "
|
||||
"installed: %s. Hermes does not install plugin dependencies "
|
||||
"automatically; install them yourself, e.g.: pip install %s",
|
||||
key, ", ".join(missing),
|
||||
" ".join(f"'{m}'" for m in missing),
|
||||
)
|
||||
else:
|
||||
logger.debug("Plugin %s python_dependencies satisfied: %s", key, ", ".join(deps))
|
||||
|
||||
def _validate_plugin_config_schema(self, manifest: PluginManifest) -> None:
|
||||
"""Warn (never block) on plugins.entries.<id> settings that violate config_schema."""
|
||||
if not manifest.config_schema:
|
||||
return
|
||||
plugin_id = manifest.key or manifest.name
|
||||
settings: Mapping[str, Any] = {}
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
cfg = load_config() or {}
|
||||
entries = (cfg.get("plugins") or {}).get("entries") or {}
|
||||
entry = entries.get(plugin_id) if isinstance(entries, Mapping) else None
|
||||
raw = entry.get("settings") if isinstance(entry, Mapping) else None
|
||||
if not isinstance(raw, Mapping):
|
||||
# Migration fallback mirroring ctx.get_config.
|
||||
raw = entry.get("config") if isinstance(entry, Mapping) else None
|
||||
settings = raw if isinstance(raw, Mapping) else {}
|
||||
except Exception:
|
||||
settings = {}
|
||||
for warning in validate_config_schema(
|
||||
plugin_id, manifest.config_schema, settings
|
||||
):
|
||||
logger.warning("Plugin %s config: %s", plugin_id, warning)
|
||||
|
||||
def _load_plugin(self, manifest: PluginManifest) -> None:
|
||||
"""Import a plugin module and call its ``register(ctx)`` function."""
|
||||
with self._discovery_lock, _plugin_home_scope(self.home_path):
|
||||
self._load_plugin_scoped(manifest)
|
||||
|
||||
def _load_plugin_scoped(self, manifest: PluginManifest) -> None:
|
||||
"""Load one plugin with the manager's home bound as current."""
|
||||
loaded = LoadedPlugin(manifest=manifest)
|
||||
logger.debug(
|
||||
"Loading plugin '%s' (source=%s, kind=%s, path=%s)",
|
||||
manifest.key or manifest.name, manifest.source, manifest.kind, manifest.path,
|
||||
)
|
||||
|
||||
if manifest.portable:
|
||||
self._load_portable_plugin(manifest, loaded)
|
||||
return
|
||||
|
||||
registration_start = len(self._registration_order)
|
||||
plugin_key = manifest.key or manifest.name
|
||||
_module_name = self._policy_module_name(manifest)
|
||||
self._track_tool_override_policy(manifest, _module_name)
|
||||
try:
|
||||
# Reuse a deferred platform's already-imported package so its body doesn't run twice.
|
||||
preloaded = self._predeclared_modules.pop(plugin_key, None)
|
||||
if preloaded is not None:
|
||||
module = preloaded
|
||||
elif manifest.source in {"user", "project", "bundled"}:
|
||||
module = self._load_directory_module(manifest, module_name=_module_name)
|
||||
else:
|
||||
module = self._load_entrypoint_module(manifest)
|
||||
|
||||
loaded.module = module
|
||||
register_fn = getattr(module, "register", None)
|
||||
if register_fn is None:
|
||||
loaded.error = "no register() function"
|
||||
logger.warning("Plugin '%s' has no register() function", manifest.name)
|
||||
else:
|
||||
register_fn(PluginContext(manifest, self))
|
||||
self._attribute_registrations(loaded, plugin_key, registration_start)
|
||||
loaded.enabled = True
|
||||
|
||||
except Exception as exc:
|
||||
owned = [
|
||||
registration
|
||||
for registration in self._registration_order
|
||||
if registration.plugin_key == plugin_key
|
||||
]
|
||||
self._dispose_registrations(owned)
|
||||
self._forget_registrations(owned)
|
||||
loaded.error = str(exc)
|
||||
# register() may have subscribed before raising; a failed plugin must leave no callable
|
||||
# reachable from later event dispatch.
|
||||
self._remove_plugin_subscriptions(plugin_key)
|
||||
logger.warning(
|
||||
"Failed to load plugin '%s': %s",
|
||||
manifest.name, exc, exc_info=_PLUGINS_DEBUG,
|
||||
)
|
||||
# The failure path swept this plugin's whole ledger (not just the registration_start slice),
|
||||
# so discovery-time pre-registrations are gone too.
|
||||
if not loaded.enabled:
|
||||
self._predeclared_tools.pop(plugin_key, None)
|
||||
self._plugins[manifest.key or manifest.name] = loaded
|
||||
|
||||
def _track_tool_override_policy(self, manifest: PluginManifest, module_name: str) -> None:
|
||||
"""Install the plugin's tool-override policy in tools.registry as a ledger-owned lease."""
|
||||
from tools.registry import registry as _registry
|
||||
|
||||
with replacement_coordinator.transaction():
|
||||
previous_policy = _registry.snapshot_plugin_override_policy(
|
||||
module_name, scope=self.scope_key
|
||||
)
|
||||
current_policy = _registry.register_plugin_override_policy(
|
||||
module_name,
|
||||
PluginContext(manifest, self)._tool_override_allowed(""),
|
||||
scope=self.scope_key,
|
||||
)
|
||||
policy_lease = replacement_coordinator.acquire(
|
||||
("tool_override_policy", self.scope_key, module_name),
|
||||
current=current_policy,
|
||||
previous=previous_policy,
|
||||
restore=lambda replacement: _registry.restore_plugin_override_policy(
|
||||
module_name,
|
||||
current_policy,
|
||||
replacement,
|
||||
scope=self.scope_key,
|
||||
),
|
||||
)
|
||||
self._track_registration(
|
||||
manifest, "tool_override_policy", module_name, policy_lease.dispose,
|
||||
)
|
||||
|
||||
def _attribute_registrations(
|
||||
self, loaded: LoadedPlugin, plugin_key: str, registration_start: int
|
||||
) -> None:
|
||||
"""Fill ``loaded.*_registered`` from the ledger slice this plugin's register() produced."""
|
||||
registrations = [
|
||||
registration
|
||||
for registration in self._registration_order[registration_start:]
|
||||
if registration.plugin_key == plugin_key and registration.active
|
||||
]
|
||||
|
||||
def _keys(kind: str) -> List[str]:
|
||||
return [r.key for r in registrations if r.kind == kind]
|
||||
|
||||
# Discovery-time tools predate registration_start; credit them back or `hermes plugins
|
||||
# list` under-reports once the deferred adapter materializes.
|
||||
_predeclared = [
|
||||
t for t in self._predeclared_tools.pop(plugin_key, [])
|
||||
if t in self._plugin_tool_names
|
||||
]
|
||||
loaded.tools_registered = _predeclared + [
|
||||
key for key in _keys("tool") if key not in _predeclared
|
||||
]
|
||||
loaded.hooks_registered = _keys("hook")
|
||||
loaded.middleware_registered = _keys("middleware")
|
||||
loaded.commands_registered = _keys("command")
|
||||
logger.debug(
|
||||
" registered: %d tool(s), %d hook(s), %d middleware, %d slash command(s), %d CLI command(s)",
|
||||
len(loaded.tools_registered),
|
||||
len(loaded.hooks_registered),
|
||||
len(loaded.middleware_registered),
|
||||
len(loaded.commands_registered),
|
||||
sum(1 for c in self._cli_commands if c in _keys("cli_command")),
|
||||
)
|
||||
|
||||
def _load_portable_plugin(self, manifest: PluginManifest, loaded: LoadedPlugin) -> None:
|
||||
"""Load validated portable components without importing Python code."""
|
||||
|
||||
lookup_key = manifest.key or manifest.name
|
||||
try:
|
||||
from hermes_cli.agent_plugins import load_agent_plugin
|
||||
|
||||
package = load_agent_plugin(
|
||||
Path(manifest.path),
|
||||
get_hermes_home() / "plugin-data" / manifest.skill_namespace,
|
||||
)
|
||||
ctx = PluginContext(manifest, self)
|
||||
for diagnostic in package.diagnostics:
|
||||
logger.warning(
|
||||
"Agent Plugin '%s' [%s]: %s",
|
||||
lookup_key,
|
||||
diagnostic.scope,
|
||||
diagnostic.message,
|
||||
)
|
||||
for skill in package.skills:
|
||||
try:
|
||||
ctx.register_skill(
|
||||
skill.name,
|
||||
skill.skill_md,
|
||||
skill.description,
|
||||
skill.frontmatter,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Agent Plugin '%s' skill '%s' skipped: %s",
|
||||
lookup_key,
|
||||
skill.name,
|
||||
exc,
|
||||
)
|
||||
for server_name, config in package.mcp_servers.items():
|
||||
internal_name = f"{manifest.skill_namespace}__{server_name}"
|
||||
if internal_name in self._portable_mcp_servers:
|
||||
logger.warning(
|
||||
"Agent Plugin '%s' MCP server collision: %s",
|
||||
lookup_key,
|
||||
internal_name,
|
||||
)
|
||||
continue
|
||||
self._portable_mcp_servers[internal_name] = dict(config)
|
||||
loaded.enabled = True
|
||||
except Exception as exc:
|
||||
loaded.error = str(exc)
|
||||
logger.warning("Failed to load Agent Plugin '%s': %s", lookup_key, exc)
|
||||
self._plugins[lookup_key] = loaded
|
||||
|
||||
def _directory_module_name(self, manifest: PluginManifest) -> str:
|
||||
"""Return a profile-safe import namespace for a directory plugin."""
|
||||
key = manifest.key or manifest.name
|
||||
slug = key.replace("/", "__").replace("-", "_")
|
||||
bare_name = f"{_NS_PARENT}.{slug}"
|
||||
with _MODULE_NAMESPACE_LOCK:
|
||||
owner = _BARE_MODULE_SCOPE.get(bare_name)
|
||||
if owner is None:
|
||||
_BARE_MODULE_SCOPE[bare_name] = self.scope_key
|
||||
return bare_name
|
||||
if owner == self.scope_key:
|
||||
return bare_name
|
||||
digest = hashlib.sha256(self.scope_key.encode("utf-8")).hexdigest()[:12]
|
||||
return f"{bare_name}__home_{digest}"
|
||||
|
||||
def _policy_module_name(self, manifest: PluginManifest) -> str:
|
||||
"""Return the module prefix whose callbacks inherit plugin policy."""
|
||||
if manifest.source == "entrypoint" and manifest.path:
|
||||
module_name = str(manifest.path).partition(":")[0].strip()
|
||||
if module_name:
|
||||
return module_name
|
||||
return self._directory_module_name(manifest)
|
||||
|
||||
def _load_directory_module(
|
||||
self,
|
||||
manifest: PluginManifest,
|
||||
*,
|
||||
module_name: Optional[str] = None,
|
||||
) -> types.ModuleType:
|
||||
"""Import a directory plugin as ``hermes_plugins.<slug>`` (slug from ``manifest.key`` so
|
||||
``image_gen/openai`` cannot collide with ``tts/openai``)."""
|
||||
plugin_dir = Path(manifest.path) # type: ignore[arg-type]
|
||||
init_file = plugin_dir / "__init__.py"
|
||||
if not init_file.exists():
|
||||
raise FileNotFoundError(f"No __init__.py in {plugin_dir}")
|
||||
|
||||
if _NS_PARENT not in sys.modules:
|
||||
ns_pkg = types.ModuleType(_NS_PARENT)
|
||||
ns_pkg.__path__ = [] # type: ignore[attr-defined]
|
||||
ns_pkg.__package__ = _NS_PARENT
|
||||
sys.modules[_NS_PARENT] = ns_pkg
|
||||
|
||||
module_name = module_name or self._directory_module_name(manifest)
|
||||
|
||||
# Evict stale entries for this slug (same slug cached from another Hermes home, or an
|
||||
# earlier force reload). Replacing only sys.modules[module_name] is not enough: the plugin's
|
||||
# relative imports are cached as "module_name.sub" and resolve from sys.modules first, so a
|
||||
# stale submodule would keep serving the previous load's code/state.
|
||||
_evict_modules(module_name)
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
module_name,
|
||||
init_file,
|
||||
submodule_search_locations=[str(plugin_dir)],
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError(f"Cannot create module spec for {init_file}")
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
module.__package__ = module_name
|
||||
module.__path__ = [str(plugin_dir)] # type: ignore[attr-defined]
|
||||
sys.modules[module_name] = module
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
except BaseException:
|
||||
# Don't leave a half-initialized module (or its partially imported relative submodules)
|
||||
# cached — a retry or a same-slug plugin in another profile would inherit broken state.
|
||||
_evict_modules(module_name)
|
||||
raise
|
||||
return module
|
||||
|
||||
def _load_entrypoint_module(self, manifest: PluginManifest) -> types.ModuleType:
|
||||
"""Load a pip-installed plugin via its entry-point reference."""
|
||||
for ep in _select_entry_point_group(importlib.metadata.entry_points(), ENTRY_POINTS_GROUP):
|
||||
if ep.name == manifest.name:
|
||||
return ep.load()
|
||||
|
||||
raise ImportError(
|
||||
f"Entry point '{manifest.name}' not found in group '{ENTRY_POINTS_GROUP}'"
|
||||
)
|
||||
|
||||
# -- hook invocation ----------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
"""Plugin loading: directory/entry-point module import, deferred bundled platforms, portable
|
||||
packages, dependency/config-schema warnings. Mixed into :class:`hermes_cli.plugins.PluginManager`.
|
||||
|
||||
Origin-internal names (``PluginContext``, ``LoadedPlugin``, ``_PLUGINS_DEBUG`` …) are imported lazily
|
||||
through ``hermes_cli.plugins`` so tests that patch them on the origin keep working.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib
|
||||
import importlib.metadata
|
||||
import importlib.util
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import types
|
||||
from contextlib import contextmanager
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional
|
||||
|
||||
from hermes_constants import get_hermes_home, reset_hermes_home_override, set_hermes_home_override
|
||||
from registration_lifecycle import replacement_coordinator
|
||||
from hermes_cli.plugins_discovery import ENTRY_POINTS_GROUP, _select_entry_point_group
|
||||
from hermes_cli.plugins_manifest import PluginManifest, validate_config_schema
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from hermes_cli.plugins import LoadedPlugin
|
||||
|
||||
logger = logging.getLogger("hermes_cli.plugins")
|
||||
|
||||
|
||||
_NS_PARENT = "hermes_plugins"
|
||||
|
||||
|
||||
_MODULE_NAMESPACE_LOCK = threading.RLock()
|
||||
|
||||
|
||||
_BARE_MODULE_SCOPE: Dict[str, str] = {}
|
||||
|
||||
|
||||
def _evict_modules(module_name: str) -> None:
|
||||
"""Drop ``module_name`` and every ``module_name.*`` submodule from ``sys.modules``."""
|
||||
prefix = f"{module_name}."
|
||||
for name in [n for n in sys.modules if n == module_name or n.startswith(prefix)]:
|
||||
del sys.modules[name]
|
||||
|
||||
|
||||
def _serialized_replacement(method):
|
||||
"""Make snapshot → write → lease attachment one atomic transaction."""
|
||||
@wraps(method)
|
||||
def wrapped(*args, **kwargs):
|
||||
with replacement_coordinator.transaction():
|
||||
return method(*args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _plugin_home_scope(home: Path):
|
||||
"""Bind discovery and loading to the manager's immutable Hermes home."""
|
||||
token = set_hermes_home_override(home)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
|
||||
|
||||
class PluginLoaderMixin:
|
||||
def _platform_name_from_manifest(self, manifest: PluginManifest) -> str:
|
||||
"""Derive the platform name without importing the adapter: strip a trailing ``-platform``
|
||||
from the manifest name, else the directory basename (the bundled convention)."""
|
||||
name = manifest.name or ""
|
||||
if name.endswith("-platform"):
|
||||
return name[: -len("-platform")]
|
||||
if manifest.path:
|
||||
return Path(manifest.path).name
|
||||
return name
|
||||
|
||||
@_serialized_replacement
|
||||
def _register_deferred_platform(self, manifest: PluginManifest) -> None:
|
||||
"""Register a lazy loader for a bundled platform: the adapter imports only when the
|
||||
``platform_registry`` is first asked for it; a placeholder ``LoadedPlugin`` keeps it visible
|
||||
in ``hermes plugins list`` until then."""
|
||||
from hermes_cli.plugins import LoadedPlugin
|
||||
|
||||
lookup_key = manifest.key or manifest.name
|
||||
platform_name = self._platform_name_from_manifest(manifest)
|
||||
|
||||
loaded = LoadedPlugin(manifest=manifest, enabled=True)
|
||||
loaded.deferred = True
|
||||
self._plugins[lookup_key] = loaded
|
||||
|
||||
try:
|
||||
from gateway.platform_registry import platform_registry
|
||||
|
||||
scope = self.scope_key
|
||||
|
||||
def _loader(_manifest: PluginManifest = manifest) -> None:
|
||||
# Lock before checking cancellation: if an unload won the race it restored the
|
||||
# predecessor and this loader must publish nothing; if loading won, unload waits
|
||||
# and disposes the completed set.
|
||||
with self._discovery_lock, _plugin_home_scope(self.home_path):
|
||||
if platform_registry.is_deferred_load_cancelled(
|
||||
platform_name, scope=scope
|
||||
):
|
||||
return
|
||||
self._load_plugin_scoped(_manifest)
|
||||
|
||||
previous = platform_registry.snapshot_registration(platform_name, scope=scope)
|
||||
platform_registry.register_deferred(platform_name, _loader, scope=scope)
|
||||
current = platform_registry.snapshot_registration(platform_name, scope=scope)
|
||||
if current[0] is None and current[1] is _loader:
|
||||
self._plugin_platform_names.add(platform_name)
|
||||
self._track_scoped_registration(
|
||||
manifest, "platform", platform_name, platform_registry, current, previous,
|
||||
finalize=lambda: self._remove_platform_name_if_unowned(platform_name),
|
||||
)
|
||||
logger.debug(
|
||||
"Registered deferred platform loader: %s (plugin=%s)",
|
||||
platform_name,
|
||||
lookup_key,
|
||||
)
|
||||
except Exception:
|
||||
# Fall back to eager loading so the platform is never silently lost.
|
||||
logger.debug(
|
||||
"Deferred platform registration failed for '%s'; eager-loading",
|
||||
lookup_key,
|
||||
exc_info=True,
|
||||
)
|
||||
self._load_plugin(manifest)
|
||||
return
|
||||
|
||||
self._register_deferred_platform_tools(manifest, loaded)
|
||||
|
||||
def _register_deferred_platform_tools(
|
||||
self, manifest: PluginManifest, loaded: LoadedPlugin
|
||||
) -> None:
|
||||
"""Register a deferred platform's *client* tools without its adapter.
|
||||
|
||||
Deferring the plugin would otherwise defer its outbound tools too, so in CLI/TUI processes
|
||||
(which never materialize platforms) they would be missing from ``hermes tools`` and dropped
|
||||
from ``platform_toolsets``. Opt-in is explicit via ``provides_tools``; tools live in a
|
||||
``tools`` submodule so ``__init__`` stays import-light.
|
||||
"""
|
||||
from hermes_cli.plugins import PluginContext, _PLUGINS_DEBUG
|
||||
|
||||
if not manifest.provides_tools:
|
||||
return
|
||||
|
||||
lookup_key = manifest.key or manifest.name
|
||||
plugin_dir = Path(manifest.path) if manifest.path else None
|
||||
if plugin_dir is None or not (plugin_dir / "tools.py").is_file():
|
||||
# Declared but undeliverable — staying quiet reproduces the very symptom this fixes.
|
||||
logger.warning(
|
||||
"Plugin '%s' declares provides_tools %s but has no tools.py; "
|
||||
"those tools will not be available in CLI/TUI sessions.",
|
||||
lookup_key,
|
||||
list(manifest.provides_tools),
|
||||
)
|
||||
return
|
||||
|
||||
before = set(self._plugin_tool_names) # lets the failure path credit partial registrations
|
||||
try:
|
||||
module = self._load_directory_module(manifest)
|
||||
# Record the module even if nothing registers: the package body has run, so
|
||||
# materializing the adapter later must reuse it rather than execute it twice.
|
||||
loaded.module = module
|
||||
self._predeclared_modules[lookup_key] = module
|
||||
|
||||
tools_module = importlib.import_module(f"{module.__name__}.tools")
|
||||
register_tools = getattr(tools_module, "register_tools", None)
|
||||
if register_tools is None:
|
||||
logger.warning(
|
||||
"Plugin '%s' declares provides_tools %s but its tools.py "
|
||||
"has no register_tools(ctx); those tools will not be "
|
||||
"available in CLI/TUI sessions.",
|
||||
lookup_key,
|
||||
list(manifest.provides_tools),
|
||||
)
|
||||
return
|
||||
|
||||
register_tools(PluginContext(manifest, self))
|
||||
registered = [t for t in self._plugin_tool_names if t not in before]
|
||||
|
||||
loaded.tools_registered = registered
|
||||
self._predeclared_tools[lookup_key] = registered
|
||||
logger.debug(
|
||||
"Deferred platform '%s': pre-registered %d client tool(s) %s",
|
||||
lookup_key,
|
||||
len(registered),
|
||||
registered,
|
||||
)
|
||||
except Exception as exc:
|
||||
# Tools registered before the raise are live: credit them or `hermes plugins list`
|
||||
# under-reports (and _load_plugin's later diff would miss them too).
|
||||
partial = [t for t in self._plugin_tool_names if t not in before]
|
||||
if partial:
|
||||
loaded.tools_registered = partial
|
||||
self._predeclared_tools[lookup_key] = partial
|
||||
|
||||
# Never break discovery (the platform stays deferred), but a broken tools.py IS the
|
||||
# symptom, so warn — and say where it failed, which is what the operator needs first.
|
||||
declared = len(manifest.provides_tools)
|
||||
if not partial:
|
||||
scope = f"before registering any of its {declared} declared tool(s)"
|
||||
elif len(partial) >= declared:
|
||||
scope = f"after registering all {declared} declared tool(s)"
|
||||
else:
|
||||
scope = f"after registering {len(partial)} of {declared} declared tool(s)"
|
||||
logger.warning(
|
||||
"Plugin '%s': client-tool pre-registration failed %s (%s).%s",
|
||||
lookup_key,
|
||||
scope,
|
||||
exc,
|
||||
"" if len(partial) >= declared else
|
||||
" The remainder will be missing from CLI/TUI sessions.",
|
||||
exc_info=_PLUGINS_DEBUG,
|
||||
)
|
||||
|
||||
def _warn_python_dependencies(self, manifest: PluginManifest) -> None:
|
||||
"""Warn about missing declared pip dependencies with an install hint — NEVER auto-install."""
|
||||
deps = manifest.python_dependencies
|
||||
if not deps:
|
||||
return
|
||||
key = manifest.key or manifest.name
|
||||
missing: List[str] = []
|
||||
for req in deps:
|
||||
# Best-effort presence probe on the distribution name.
|
||||
dist = re.split(r"[<>=!~\[;\s]", req, maxsplit=1)[0].strip()
|
||||
if not dist:
|
||||
continue
|
||||
try:
|
||||
importlib.metadata.version(dist)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
missing.append(req)
|
||||
except Exception:
|
||||
continue
|
||||
if missing:
|
||||
logger.warning(
|
||||
"Plugin %s declares Python dependencies that are not "
|
||||
"installed: %s. Hermes does not install plugin dependencies "
|
||||
"automatically; install them yourself, e.g.: pip install %s",
|
||||
key, ", ".join(missing),
|
||||
" ".join(f"'{m}'" for m in missing),
|
||||
)
|
||||
else:
|
||||
logger.debug("Plugin %s python_dependencies satisfied: %s", key, ", ".join(deps))
|
||||
|
||||
def _validate_plugin_config_schema(self, manifest: PluginManifest) -> None:
|
||||
"""Warn (never block) on plugins.entries.<id> settings that violate config_schema."""
|
||||
if not manifest.config_schema:
|
||||
return
|
||||
plugin_id = manifest.key or manifest.name
|
||||
settings: Mapping[str, Any] = {}
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
cfg = load_config() or {}
|
||||
entries = (cfg.get("plugins") or {}).get("entries") or {}
|
||||
entry = entries.get(plugin_id) if isinstance(entries, Mapping) else None
|
||||
raw = entry.get("settings") if isinstance(entry, Mapping) else None
|
||||
if not isinstance(raw, Mapping):
|
||||
# Migration fallback mirroring ctx.get_config.
|
||||
raw = entry.get("config") if isinstance(entry, Mapping) else None
|
||||
settings = raw if isinstance(raw, Mapping) else {}
|
||||
except Exception:
|
||||
settings = {}
|
||||
for warning in validate_config_schema(
|
||||
plugin_id, manifest.config_schema, settings
|
||||
):
|
||||
logger.warning("Plugin %s config: %s", plugin_id, warning)
|
||||
|
||||
def _load_plugin(self, manifest: PluginManifest) -> None:
|
||||
"""Import a plugin module and call its ``register(ctx)`` function."""
|
||||
with self._discovery_lock, _plugin_home_scope(self.home_path):
|
||||
self._load_plugin_scoped(manifest)
|
||||
|
||||
def _load_plugin_scoped(self, manifest: PluginManifest) -> None:
|
||||
"""Load one plugin with the manager's home bound as current."""
|
||||
from hermes_cli.plugins import LoadedPlugin, PluginContext, _PLUGINS_DEBUG
|
||||
|
||||
loaded = LoadedPlugin(manifest=manifest)
|
||||
logger.debug(
|
||||
"Loading plugin '%s' (source=%s, kind=%s, path=%s)",
|
||||
manifest.key or manifest.name, manifest.source, manifest.kind, manifest.path,
|
||||
)
|
||||
|
||||
if manifest.portable:
|
||||
self._load_portable_plugin(manifest, loaded)
|
||||
return
|
||||
|
||||
registration_start = len(self._registration_order)
|
||||
plugin_key = manifest.key or manifest.name
|
||||
_module_name = self._policy_module_name(manifest)
|
||||
self._track_tool_override_policy(manifest, _module_name)
|
||||
try:
|
||||
# Reuse a deferred platform's already-imported package so its body doesn't run twice.
|
||||
preloaded = self._predeclared_modules.pop(plugin_key, None)
|
||||
if preloaded is not None:
|
||||
module = preloaded
|
||||
elif manifest.source in {"user", "project", "bundled"}:
|
||||
module = self._load_directory_module(manifest, module_name=_module_name)
|
||||
else:
|
||||
module = self._load_entrypoint_module(manifest)
|
||||
|
||||
loaded.module = module
|
||||
register_fn = getattr(module, "register", None)
|
||||
if register_fn is None:
|
||||
loaded.error = "no register() function"
|
||||
logger.warning("Plugin '%s' has no register() function", manifest.name)
|
||||
else:
|
||||
register_fn(PluginContext(manifest, self))
|
||||
self._attribute_registrations(loaded, plugin_key, registration_start)
|
||||
loaded.enabled = True
|
||||
|
||||
except Exception as exc:
|
||||
owned = [
|
||||
registration
|
||||
for registration in self._registration_order
|
||||
if registration.plugin_key == plugin_key
|
||||
]
|
||||
self._dispose_registrations(owned)
|
||||
self._forget_registrations(owned)
|
||||
loaded.error = str(exc)
|
||||
# register() may have subscribed before raising; a failed plugin must leave no callable
|
||||
# reachable from later event dispatch.
|
||||
self._remove_plugin_subscriptions(plugin_key)
|
||||
logger.warning(
|
||||
"Failed to load plugin '%s': %s",
|
||||
manifest.name, exc, exc_info=_PLUGINS_DEBUG,
|
||||
)
|
||||
# The failure path swept this plugin's whole ledger (not just the registration_start slice),
|
||||
# so discovery-time pre-registrations are gone too.
|
||||
if not loaded.enabled:
|
||||
self._predeclared_tools.pop(plugin_key, None)
|
||||
self._plugins[manifest.key or manifest.name] = loaded
|
||||
|
||||
def _track_tool_override_policy(self, manifest: PluginManifest, module_name: str) -> None:
|
||||
"""Install the plugin's tool-override policy in tools.registry as a ledger-owned lease."""
|
||||
from hermes_cli.plugins import PluginContext
|
||||
|
||||
from tools.registry import registry as _registry
|
||||
|
||||
with replacement_coordinator.transaction():
|
||||
previous_policy = _registry.snapshot_plugin_override_policy(
|
||||
module_name, scope=self.scope_key
|
||||
)
|
||||
current_policy = _registry.register_plugin_override_policy(
|
||||
module_name,
|
||||
PluginContext(manifest, self)._tool_override_allowed(""),
|
||||
scope=self.scope_key,
|
||||
)
|
||||
policy_lease = replacement_coordinator.acquire(
|
||||
("tool_override_policy", self.scope_key, module_name),
|
||||
current=current_policy,
|
||||
previous=previous_policy,
|
||||
restore=lambda replacement: _registry.restore_plugin_override_policy(
|
||||
module_name,
|
||||
current_policy,
|
||||
replacement,
|
||||
scope=self.scope_key,
|
||||
),
|
||||
)
|
||||
self._track_registration(
|
||||
manifest, "tool_override_policy", module_name, policy_lease.dispose,
|
||||
)
|
||||
|
||||
def _attribute_registrations(
|
||||
self, loaded: LoadedPlugin, plugin_key: str, registration_start: int
|
||||
) -> None:
|
||||
"""Fill ``loaded.*_registered`` from the ledger slice this plugin's register() produced."""
|
||||
registrations = [
|
||||
registration
|
||||
for registration in self._registration_order[registration_start:]
|
||||
if registration.plugin_key == plugin_key and registration.active
|
||||
]
|
||||
|
||||
def _keys(kind: str) -> List[str]:
|
||||
return [r.key for r in registrations if r.kind == kind]
|
||||
|
||||
# Discovery-time tools predate registration_start; credit them back or `hermes plugins
|
||||
# list` under-reports once the deferred adapter materializes.
|
||||
_predeclared = [
|
||||
t for t in self._predeclared_tools.pop(plugin_key, [])
|
||||
if t in self._plugin_tool_names
|
||||
]
|
||||
loaded.tools_registered = _predeclared + [
|
||||
key for key in _keys("tool") if key not in _predeclared
|
||||
]
|
||||
loaded.hooks_registered = _keys("hook")
|
||||
loaded.middleware_registered = _keys("middleware")
|
||||
loaded.commands_registered = _keys("command")
|
||||
logger.debug(
|
||||
" registered: %d tool(s), %d hook(s), %d middleware, %d slash command(s), %d CLI command(s)",
|
||||
len(loaded.tools_registered),
|
||||
len(loaded.hooks_registered),
|
||||
len(loaded.middleware_registered),
|
||||
len(loaded.commands_registered),
|
||||
sum(1 for c in self._cli_commands if c in _keys("cli_command")),
|
||||
)
|
||||
|
||||
def _load_portable_plugin(self, manifest: PluginManifest, loaded: LoadedPlugin) -> None:
|
||||
"""Load validated portable components without importing Python code."""
|
||||
from hermes_cli.plugins import PluginContext
|
||||
|
||||
lookup_key = manifest.key or manifest.name
|
||||
try:
|
||||
from hermes_cli.agent_plugins import load_agent_plugin
|
||||
|
||||
package = load_agent_plugin(
|
||||
Path(manifest.path),
|
||||
get_hermes_home() / "plugin-data" / manifest.skill_namespace,
|
||||
)
|
||||
ctx = PluginContext(manifest, self)
|
||||
for diagnostic in package.diagnostics:
|
||||
logger.warning(
|
||||
"Agent Plugin '%s' [%s]: %s",
|
||||
lookup_key,
|
||||
diagnostic.scope,
|
||||
diagnostic.message,
|
||||
)
|
||||
for skill in package.skills:
|
||||
try:
|
||||
ctx.register_skill(
|
||||
skill.name,
|
||||
skill.skill_md,
|
||||
skill.description,
|
||||
skill.frontmatter,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Agent Plugin '%s' skill '%s' skipped: %s",
|
||||
lookup_key,
|
||||
skill.name,
|
||||
exc,
|
||||
)
|
||||
for server_name, config in package.mcp_servers.items():
|
||||
internal_name = f"{manifest.skill_namespace}__{server_name}"
|
||||
if internal_name in self._portable_mcp_servers:
|
||||
logger.warning(
|
||||
"Agent Plugin '%s' MCP server collision: %s",
|
||||
lookup_key,
|
||||
internal_name,
|
||||
)
|
||||
continue
|
||||
self._portable_mcp_servers[internal_name] = dict(config)
|
||||
loaded.enabled = True
|
||||
except Exception as exc:
|
||||
loaded.error = str(exc)
|
||||
logger.warning("Failed to load Agent Plugin '%s': %s", lookup_key, exc)
|
||||
self._plugins[lookup_key] = loaded
|
||||
|
||||
def _directory_module_name(self, manifest: PluginManifest) -> str:
|
||||
"""Return a profile-safe import namespace for a directory plugin."""
|
||||
key = manifest.key or manifest.name
|
||||
slug = key.replace("/", "__").replace("-", "_")
|
||||
bare_name = f"{_NS_PARENT}.{slug}"
|
||||
with _MODULE_NAMESPACE_LOCK:
|
||||
owner = _BARE_MODULE_SCOPE.get(bare_name)
|
||||
if owner is None:
|
||||
_BARE_MODULE_SCOPE[bare_name] = self.scope_key
|
||||
return bare_name
|
||||
if owner == self.scope_key:
|
||||
return bare_name
|
||||
digest = hashlib.sha256(self.scope_key.encode("utf-8")).hexdigest()[:12]
|
||||
return f"{bare_name}__home_{digest}"
|
||||
|
||||
def _policy_module_name(self, manifest: PluginManifest) -> str:
|
||||
"""Return the module prefix whose callbacks inherit plugin policy."""
|
||||
if manifest.source == "entrypoint" and manifest.path:
|
||||
module_name = str(manifest.path).partition(":")[0].strip()
|
||||
if module_name:
|
||||
return module_name
|
||||
return self._directory_module_name(manifest)
|
||||
|
||||
def _load_directory_module(
|
||||
self,
|
||||
manifest: PluginManifest,
|
||||
*,
|
||||
module_name: Optional[str] = None,
|
||||
) -> types.ModuleType:
|
||||
"""Import a directory plugin as ``hermes_plugins.<slug>`` (slug from ``manifest.key`` so
|
||||
``image_gen/openai`` cannot collide with ``tts/openai``)."""
|
||||
plugin_dir = Path(manifest.path) # type: ignore[arg-type]
|
||||
init_file = plugin_dir / "__init__.py"
|
||||
if not init_file.exists():
|
||||
raise FileNotFoundError(f"No __init__.py in {plugin_dir}")
|
||||
|
||||
if _NS_PARENT not in sys.modules:
|
||||
ns_pkg = types.ModuleType(_NS_PARENT)
|
||||
ns_pkg.__path__ = [] # type: ignore[attr-defined]
|
||||
ns_pkg.__package__ = _NS_PARENT
|
||||
sys.modules[_NS_PARENT] = ns_pkg
|
||||
|
||||
module_name = module_name or self._directory_module_name(manifest)
|
||||
|
||||
# Evict stale entries for this slug (same slug cached from another Hermes home, or an
|
||||
# earlier force reload). Replacing only sys.modules[module_name] is not enough: the plugin's
|
||||
# relative imports are cached as "module_name.sub" and resolve from sys.modules first, so a
|
||||
# stale submodule would keep serving the previous load's code/state.
|
||||
_evict_modules(module_name)
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
module_name,
|
||||
init_file,
|
||||
submodule_search_locations=[str(plugin_dir)],
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError(f"Cannot create module spec for {init_file}")
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
module.__package__ = module_name
|
||||
module.__path__ = [str(plugin_dir)] # type: ignore[attr-defined]
|
||||
sys.modules[module_name] = module
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
except BaseException:
|
||||
# Don't leave a half-initialized module (or its partially imported relative submodules)
|
||||
# cached — a retry or a same-slug plugin in another profile would inherit broken state.
|
||||
_evict_modules(module_name)
|
||||
raise
|
||||
return module
|
||||
|
||||
def _load_entrypoint_module(self, manifest: PluginManifest) -> types.ModuleType:
|
||||
"""Load a pip-installed plugin via its entry-point reference."""
|
||||
for ep in _select_entry_point_group(importlib.metadata.entry_points(), ENTRY_POINTS_GROUP):
|
||||
if ep.name == manifest.name:
|
||||
return ep.load()
|
||||
|
||||
raise ImportError(
|
||||
f"Entry point '{manifest.name}' not found in group '{ENTRY_POINTS_GROUP}'"
|
||||
)
|
||||
Reference in New Issue
Block a user