ReMe/reme/plugin.py

134 lines
5.5 KiB
Python

"""Load explicitly enabled plugins into one application's config and registry.
New plugins expose a package-only ``reme.plugins`` entry point and declare two
optional mappings in ``plugin.yaml``: ``backends`` and ``application_defaults``.
Python ``Plugin`` descriptors remain supported as a compatibility boundary.
"""
from __future__ import annotations
from collections.abc import Iterable, Mapping
from dataclasses import dataclass, field
from importlib import import_module
from importlib.metadata import EntryPoint
from typing import Any
from .components.base_component import ComponentMixin
from .components.component_registry import ComponentRegistry, create_application_registry
from .config import deep_merge_config, expand_env_vars
from .entry_point import PLUGIN_ENTRY_POINT_GROUP, find_entry_points, load_entry_point, unique_entry_point
from .plugin_manifest import load_package_manifest
@dataclass(frozen=True)
class Backend:
"""One named component, step, or job backend contributed by a plugin."""
name: str
implementation: type[ComponentMixin]
@dataclass(frozen=True)
class Plugin:
"""Legacy Python descriptor accepted during the plugin manifest migration."""
name: str
backends: tuple[Backend, ...] = ()
config: Mapping[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class PluginRuntime:
"""Application config and registry after applying enabled plugins."""
config: dict[str, Any]
registry: ComponentRegistry
def _load_backend(target: str, *, plugin_name: str) -> type[ComponentMixin]:
"""Import one ``module:class`` backend target from a plugin manifest."""
module_name, separator, attribute = target.partition(":")
if not separator or not module_name or not attribute or ":" in attribute:
raise ValueError(f"Plugin '{plugin_name}' has invalid backend target: {target!r}")
value: Any = import_module(module_name)
for part in attribute.split("."):
value = getattr(value, part)
if not isinstance(value, type) or not issubclass(value, ComponentMixin):
raise TypeError(f"Plugin '{plugin_name}' backend '{target}' is not a ComponentMixin class")
return value
def _load_manifest_plugin(name: str, package: str) -> Plugin:
"""Load ``plugin.yaml`` from an entry point's package."""
manifest = load_package_manifest(package, plugin_name=name)
backends = tuple(
Backend(backend_name, _load_backend(target, plugin_name=name))
for backend_name, target in manifest.backends.items()
)
return Plugin(name=name, backends=backends, config=manifest.application_defaults)
def _load_plugin(name: str, entry: EntryPoint) -> Plugin:
"""Load a package manifest, falling back to the legacy descriptor form."""
if ":" not in entry.value:
# Package-only targets use plugin.yaml; package:object targets are the
# legacy Python descriptor contract.
from .components.component_registry import R
with R.preserve(allow_mutation=True):
return _load_manifest_plugin(name, entry.value)
plugin = load_entry_point(entry, invoke=True)
if not isinstance(plugin, Plugin):
raise TypeError(f"Plugin entry point '{name}' did not return reme.plugin.Plugin")
return plugin
class PluginManager:
"""Resolve enabled plugins and apply their contributions to one application."""
def __init__(self, plugins: Iterable[Plugin] = ()) -> None:
self.plugins = tuple(plugins)
@classmethod
def discover(cls, specs: Iterable[str]) -> "PluginManager":
"""Load explicitly enabled plugins by entry-point name."""
plugins: list[Plugin] = []
seen: set[str] = set()
for name in specs:
if not isinstance(name, str):
raise TypeError(f"Invalid plugin name: {name!r}")
if not name:
raise ValueError("Plugin name cannot be empty")
if name in seen:
raise ValueError(f"Plugin '{name}' is enabled more than once")
entries = find_entry_points(PLUGIN_ENTRY_POINT_GROUP, name)
entry = unique_entry_point(entries, name, provider="Plugin")
if entry is None:
raise ValueError(f"Plugin '{name}' is not installed")
plugin = _load_plugin(name, entry)
if plugin.name != name:
raise ValueError(f"Plugin entry point '{name}' returned plugin '{plugin.name}'")
plugins.append(plugin)
seen.add(name)
return cls(plugins)
def merge_config(self, application_config: Mapping[str, Any]) -> dict[str, Any]:
"""Place plugin application defaults below the user's resolved config."""
merged: dict[str, Any] = {}
for plugin in self.plugins:
merged = deep_merge_config(merged, expand_env_vars(plugin.config))
return deep_merge_config(merged, application_config)
def register(self, registry: ComponentRegistry) -> None:
"""Register every backend into an application-local registry."""
for plugin in self.plugins:
for backend in plugin.backends:
registry.add(backend.name, backend.implementation, owner=plugin.name)
def resolve_plugin_runtime(application_config: Mapping[str, Any]) -> PluginRuntime:
"""Build one local registry, with user config overriding plugin application defaults."""
manager = PluginManager.discover(application_config.get("plugins") or ())
registry = create_application_registry()
manager.register(registry)
return PluginRuntime(config=manager.merge_config(application_config), registry=registry)