ReMe/reme/plugin.py
jinliyl efcc2b34d1
Some checks are pending
CI / Documentation / Test and build documentation (push) Waiting to run
CI / Python quality / Pre-commit (push) Waiting to run
CI / Python tests / Unit Tests - py3.11 (push) Waiting to run
CI / Python tests / Unit Tests - py3.12 (push) Waiting to run
CI / Python tests / Unit Tests - py3.13 (push) Waiting to run
CI / Windows / CLI smoke - py3.11 (push) Waiting to run
Deploy / Documentation / Build documentation (push) Waiting to run
Deploy / Documentation / deploy (push) Blocked by required conditions
Security / CodeQL / Analyze javascript-typescript (push) Waiting to run
Security / CodeQL / Analyze python (push) Waiting to run
feat: simplify plugin setup and add management CLI (#485)
* feat: simplify plugin setup and add management CLI

* fix: isolate plugin CLI import side effects

* refactor: streamline plugin validation

* fix: route plugin CLI arguments independently

* fix: support standard plugin source layouts
2026-08-23 17:58:20 +08:00

137 lines
5.7 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 PluginManifest, 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}")
try:
value: Any = import_module(module_name)
for part in attribute.split("."):
value = getattr(value, part)
except (AttributeError, ImportError) as exc:
raise ValueError(f"Plugin '{plugin_name}' cannot load backend '{target}': {exc}") from exc
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 _plugin_from_manifest(name: str, manifest: PluginManifest) -> Plugin:
"""Convert a parsed manifest into one runtime plugin descriptor."""
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):
manifest = load_package_manifest(entry.value, plugin_name=name)
return _plugin_from_manifest(name, manifest)
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)