Source code for wolfhece._plugin_loader

"""Companion plugin loader.

Discovers, validates, and loads companion plugins from a plugins directory.

Plugin layout
-------------
Each plugin lives in its own subdirectory containing at least two files:

``plugin.toml``
    Machine-readable metadata (required).  See the schema below.

``companion.py``
    Python module that defines (and exports) the companion class (required).

Example directory tree::

    wolfhece/data/plugins/
    ├── _template/                    ← ignored (starts with ``_``)
    │   ├── plugin.toml
    │   └── companion.py
    └── my_feature/
        ├── plugin.toml
        └── companion.py

``plugin.toml`` schema
----------------------
.. code-block:: toml

    [plugin]
    name          = "my_feature"          # unique slug (required)
    display_name  = "My Feature"          # human-readable label (defaults to name)
    version       = "1.0.0"              # semver string (required)
    author        = "Jane Doe"           # (required)
    email         = "jane@example.com"   # optional
    description   = "Short description." # optional
    entry_class   = "MyFeatureCompanion" # companion class in companion.py (required)
    created       = "2026-01-15"         # optional ISO date
    updated       = "2026-05-22"         # optional ISO date

    [compatibility]
    requires_wolfhece = ">=2.2"          # optional, informational only
    min_python        = "3.11"            # optional "major.minor" constraint

.. warning::

    Loading a plugin executes arbitrary Python code from ``companion.py``.
    Only load plugins from **trusted sources**.
"""
from __future__ import annotations

import importlib.util
import logging
import sys
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from wolfhece._menu_companion_abc import AbstractCompanion

[docs] _logger = logging.getLogger(__name__)
#: Default directory where user-runtime plugins are stored.
[docs] PLUGINS_DIR: Path = Path(__file__).parent / 'data' / 'plugins'
#: Directory that ships with the wolfhece package and is always trusted. #: Companion plugins distributed here are treated as built-in by the trust store.
[docs] BUILTIN_PLUGINS_DIR: Path = Path(__file__).parent / '_builtin_plugins'
[docs] _MANIFEST_FILENAME = 'plugin.toml'
[docs] _COMPANION_FILENAME = 'companion.py'
[docs] _MODULE_PREFIX = '_wolfhece_plugin_'
# --------------------------------------------------------------------------- # Data classes # --------------------------------------------------------------------------- @dataclass
[docs] class PluginManifest: """Parsed metadata from ``plugin.toml``."""
[docs] name: str #: unique slug, e.g. ``"my_feature"``
[docs] display_name: str #: human-readable label
[docs] version: str #: semver string
[docs] author: str
[docs] entry_class: str #: name of the companion class in ``companion.py``
[docs] email: str = ''
[docs] description: str = ''
[docs] created: str = ''
[docs] updated: str = ''
[docs] requires_wolfhece: str = ''
[docs] min_python: str = ''
@classmethod
[docs] def from_dict(cls, data: dict) -> 'PluginManifest': """Build from a parsed ``plugin.toml`` dict.""" p = data.get('plugin', {}) c = data.get('compatibility', {}) return cls( name=p['name'], display_name=p.get('display_name', p['name']), version=p['version'], author=p['author'], entry_class=p['entry_class'], email=p.get('email', ''), description=p.get('description', ''), created=p.get('created', ''), updated=p.get('updated', ''), requires_wolfhece=c.get('requires_wolfhece', ''), min_python=c.get('min_python', ''), )
@dataclass
[docs] class PluginInfo: """A discovered plugin — manifest, resolved class, and runtime status."""
[docs] manifest: PluginManifest
[docs] path: Path
#: ``None`` until successfully loaded, or when loading failed.
[docs] companion_class: type | None = field(default=None, repr=False)
#: Whether the plugin is currently enabled by the user.
[docs] enabled: bool = True
#: Non-empty string describes the error when loading failed.
[docs] load_error: str = ''
@property
[docs] def loaded(self) -> bool: """``True`` if the companion class was successfully imported.""" return self.companion_class is not None
@property
[docs] def name(self) -> str: return self.manifest.name
@property
[docs] def display_name(self) -> str: return self.manifest.display_name
def __str__(self) -> str: status = 'OK' if self.loaded else f'ERROR: {self.load_error}' return ( f"PluginInfo(name={self.name!r}, version={self.manifest.version!r}, " f"enabled={self.enabled}, status={status})" )
[docs] class PluginValidationError(ValueError): """Raised when a plugin directory does not pass validation.""" def __init__(self, path: Path, errors: list[str]) -> None: lines = '\n'.join(f' \u2022 {e}' for e in errors) super().__init__( f"Plugin at '{path}' has {len(errors)} validation error(s):\n{lines}" )
[docs] self.path = path
[docs] self.errors: list[str] = errors
# --------------------------------------------------------------------------- # Validation # ---------------------------------------------------------------------------
[docs] def validate_plugin_dir(plugin_dir: Path) -> list[str]: """Return a list of validation error strings (empty list = OK). Checks: * The path is an existing directory. * ``plugin.toml`` exists and is valid TOML. * Required keys ``name``, ``version``, ``author``, ``entry_class`` are present. * ``name`` is a slug-like identifier (only letters, digits, ``-``, ``_``). * ``min_python`` constraint is satisfied by the running interpreter (if set). * ``companion.py`` exists. This function never raises — all problems are returned as strings. """ errors: list[str] = [] if not plugin_dir.is_dir(): errors.append(f"Not a directory: {plugin_dir}") return errors # -- manifest ------------------------------------------------------------ manifest_path = plugin_dir / _MANIFEST_FILENAME if not manifest_path.exists(): errors.append(f"Missing required file '{_MANIFEST_FILENAME}'") else: try: with open(manifest_path, 'rb') as fh: data = tomllib.load(fh) except Exception as exc: errors.append(f"Cannot parse '{_MANIFEST_FILENAME}': {exc}") data = {} if data: p = data.get('plugin', {}) for key in ('name', 'version', 'author', 'entry_class'): if not p.get(key): errors.append(f"[plugin] missing required key: '{key}'") name = p.get('name', '') if name and not name.replace('-', '_').isidentifier(): errors.append( f"[plugin].name must be a slug-like identifier (got '{name}')" ) min_py = data.get('compatibility', {}).get('min_python', '') if min_py: try: req = tuple(int(x) for x in min_py.split('.')) if sys.version_info[:len(req)] < req: running = ( f"{sys.version_info.major}.{sys.version_info.minor}" ) errors.append( f"Requires Python \u2265 {min_py} (running {running})" ) except ValueError: errors.append( f"Invalid [compatibility].min_python value: '{min_py}'" ) # -- companion module ---------------------------------------------------- if not (plugin_dir / _COMPANION_FILENAME).exists(): errors.append(f"Missing required file '{_COMPANION_FILENAME}'") return errors
# --------------------------------------------------------------------------- # Loading # ---------------------------------------------------------------------------
[docs] def load_plugin(plugin_dir: Path) -> PluginInfo: """Load and validate one plugin from *plugin_dir*. :param plugin_dir: Path to the plugin subdirectory. :raises PluginValidationError: if the directory fails validation. :raises ImportError: if ``companion.py`` cannot be imported. :raises AttributeError: if the entry class is not found in the module. :raises TypeError: if the entry class does not subclass :class:`~wolfhece._menu_companion_abc.AbstractCompanion`. :return: A fully populated :class:`PluginInfo` with ``companion_class`` set. """ from wolfhece._menu_companion_abc import AbstractCompanion plugin_dir = Path(plugin_dir).resolve() errors = validate_plugin_dir(plugin_dir) if errors: raise PluginValidationError(plugin_dir, errors) # Parse manifest with open(plugin_dir / _MANIFEST_FILENAME, 'rb') as fh: raw = tomllib.load(fh) manifest = PluginManifest.from_dict(raw) # Import (or retrieve cached) companion module module_name = f'{_MODULE_PREFIX}{manifest.name}' if module_name not in sys.modules: companion_path = plugin_dir / _COMPANION_FILENAME spec = importlib.util.spec_from_file_location(module_name, companion_path) if spec is None or spec.loader is None: raise ImportError(f"Cannot create module spec for '{companion_path}'") module = importlib.util.module_from_spec(spec) sys.modules[module_name] = module try: spec.loader.exec_module(module) # type: ignore[union-attr] except Exception: del sys.modules[module_name] raise else: module = sys.modules[module_name] # Resolve and validate entry class cls = getattr(module, manifest.entry_class, None) if cls is None: sys.modules.pop(module_name, None) raise AttributeError( f"'{_COMPANION_FILENAME}' does not define '{manifest.entry_class}'" ) if not (isinstance(cls, type) and issubclass(cls, AbstractCompanion)): sys.modules.pop(module_name, None) raise TypeError( f"'{manifest.entry_class}' must be a subclass of AbstractCompanion " f"(got {cls!r})" ) info = PluginInfo(manifest=manifest, path=plugin_dir, companion_class=cls) _logger.debug( "Loaded plugin '%s' v%s from '%s'", manifest.name, manifest.version, plugin_dir, ) return info
[docs] def discover_plugins( directory: Path | str | None = None, ) -> list[PluginInfo]: """Discover all plugin directories under *directory*. Skips subdirectories whose name starts with ``_`` or ``.`` (template / draft folders) so they can coexist safely with live plugins. Invalid or failing plugins are **not** raised — they are returned as :class:`PluginInfo` objects with :attr:`~PluginInfo.load_error` set and :attr:`~PluginInfo.companion_class` = ``None``. When two valid plugins share the same *name*, the first one (alphabetical) wins and the duplicate is logged then skipped. :param directory: Root plugins directory. Defaults to ``wolfhece/data/plugins``. :return: List of :class:`PluginInfo` objects, one per discovered subdirectory (failures included). """ if directory is None: directory = PLUGINS_DIR directory = Path(directory).resolve() if not directory.is_dir(): try: directory.mkdir(parents=True, exist_ok=True) except Exception as exc: _logger.warning( "Plugins directory does not exist and could not be created '%s': %s", directory, exc, ) return [] plugins: list[PluginInfo] = [] seen: set[str] = set() for sub in sorted(directory.iterdir()): if not sub.is_dir() or sub.name.startswith(('_', '.')): continue try: info = load_plugin(sub) except PluginValidationError as exc: _logger.warning("Plugin validation failed (%s):\n%s", sub.name, exc) info = _make_error_info(sub, str(exc)) except Exception as exc: _logger.warning("Failed to load plugin '%s': %s", sub.name, exc) info = _make_error_info(sub, str(exc)) if info.name in seen: _logger.warning( "Duplicate plugin name '%s' — skipping '%s'", info.name, sub ) continue seen.add(info.name) plugins.append(info) _logger.debug("Discovered %d plugin(s) in '%s'", len(plugins), directory) return plugins
# --------------------------------------------------------------------------- # Helpers # ---------------------------------------------------------------------------
[docs] def _make_error_info(sub: Path, error_msg: str) -> PluginInfo: """Build a placeholder PluginInfo for a plugin that failed to load.""" return PluginInfo( manifest=PluginManifest( name=sub.name, display_name=sub.name, version='?', author='?', entry_class='', ), path=sub, load_error=error_msg, )
[docs] def discover_all_plugins( user_directory: Path | str | None = None, ) -> list[PluginInfo]: """Discover both built-in and user plugins, returning a combined list. Built-in plugins (from :data:`BUILTIN_PLUGINS_DIR`) are listed first, followed by user plugins (from *user_directory* or :data:`PLUGINS_DIR`). Duplicate plugin names (same slug in both directories) are resolved in favour of the built-in version — the user plugin is skipped with a warning. :param user_directory: Directory to scan for user plugins. Defaults to :data:`PLUGINS_DIR`. :return: Combined list of :class:`PluginInfo` objects. """ builtin = discover_plugins(BUILTIN_PLUGINS_DIR) builtin_names = {info.name for info in builtin} user = discover_plugins(user_directory) for info in user: if info.name in builtin_names: _logger.warning( "User plugin '%s' shadows a built-in plugin — skipping user copy.", info.name, ) user_unique = [info for info in user if info.name not in builtin_names] return builtin + user_unique