"""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]
entry_class: str #: name of the companion class in ``companion.py``
[docs]
requires_wolfhece: 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
#: ``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.
#: Non-empty string describes the error when loading failed.
@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.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