- driver/registry.py never actually defined register_driver_type, so the plugin-system's 'driver plugin registered successfully' scenario was unreachable in production (only simulated via test monkeypatch). Added a real implementation wired into the existing driver factory registry. - PluginRegistry.discover() only caught PluginValidationError/ DuplicatePluginError, so a driver-kind manifest that failed wiring (DriverRegistryUnavailableError/PluginTargetResolutionError) aborted the whole scan, silently skipping co-located tool/skill manifests. Now caught and skipped per manifest instead. openspec: plugin-system capability, archived change cloud-runtime
259 lines
9.2 KiB
Python
259 lines
9.2 KiB
Python
"""Plugin manifest and registry: discover and wire drivers/tools/skills.
|
|
|
|
Capability: ``plugin-system``.
|
|
|
|
Two discovery paths (Python ``importlib.metadata`` entry points in group
|
|
``device_agent_runtime.plugins``, and a local ``plugin.json`` file scan) feed
|
|
into one validation + registration path. Only ``driver``-kind plugins are
|
|
concretely wired (into ``driver/registry.py``'s extension point); ``tool``- and
|
|
``skill``-kind manifests are accepted and stored as ``wired=False``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import importlib.metadata
|
|
import json
|
|
import logging
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any, Literal
|
|
|
|
if TYPE_CHECKING:
|
|
from cloud.store import CloudStore
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
ENTRY_POINT_GROUP = "device_agent_runtime.plugins"
|
|
PLUGIN_MANIFEST_FILENAME = "plugin.json"
|
|
EntryPointKind = Literal["driver", "tool", "skill"]
|
|
_VALID_ENTRY_POINT_KINDS = ("driver", "tool", "skill")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PluginManifest:
|
|
"""A plugin's declaration: who it is, what kind it is, where it lives."""
|
|
|
|
name: str
|
|
version: str
|
|
entry_point_kind: EntryPointKind
|
|
target: str
|
|
|
|
def __post_init__(self) -> None:
|
|
if not self.name:
|
|
raise PluginValidationError("plugin manifest 'name' is required")
|
|
if not self.version:
|
|
raise PluginValidationError("plugin manifest 'version' is required")
|
|
if not self.target:
|
|
raise PluginValidationError("plugin manifest 'target' is required")
|
|
if self.entry_point_kind not in _VALID_ENTRY_POINT_KINDS:
|
|
raise PluginValidationError(
|
|
f"plugin manifest 'entry_point_kind' must be one of "
|
|
f"{_VALID_ENTRY_POINT_KINDS}, got {self.entry_point_kind!r}"
|
|
)
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"name": self.name,
|
|
"version": self.version,
|
|
"entry_point_kind": self.entry_point_kind,
|
|
"target": self.target,
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: dict[str, Any]) -> "PluginManifest":
|
|
return cls(
|
|
name=str(data["name"]),
|
|
version=str(data["version"]),
|
|
entry_point_kind=str(data["entry_point_kind"]), # type: ignore[arg-type]
|
|
target=str(data["target"]),
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class DiscoveryResult:
|
|
"""The outcome of running both discovery scans."""
|
|
|
|
registered: list[PluginManifest] = field(default_factory=list)
|
|
errors: list[str] = field(default_factory=list)
|
|
|
|
|
|
class PluginValidationError(ValueError):
|
|
"""Raised when a plugin manifest fails schema validation."""
|
|
|
|
|
|
class DuplicatePluginError(ValueError):
|
|
"""Raised when a plugin name is already registered."""
|
|
|
|
|
|
class DriverRegistryUnavailableError(RuntimeError):
|
|
"""Raised when ``driver.registry.register_driver_type`` is not importable."""
|
|
|
|
|
|
class PluginTargetResolutionError(RuntimeError):
|
|
"""Raised when a plugin target cannot be resolved to a callable."""
|
|
|
|
|
|
# Exception types `register()` can raise for a single bad manifest (validation
|
|
# failure, name conflict, or driver-wiring failure). `discover()` catches only
|
|
# these so one bad manifest is skipped/logged, per spec's "malformed manifest
|
|
# file is skipped, not fatal" resilience intent -- anything else propagates as
|
|
# a genuine programmer error.
|
|
_PLUGIN_REGISTRATION_FAILURES = (
|
|
PluginValidationError,
|
|
DuplicatePluginError,
|
|
DriverRegistryUnavailableError,
|
|
PluginTargetResolutionError,
|
|
)
|
|
|
|
|
|
class PluginRegistry:
|
|
"""Validates, persists, and wires plugins discovered or submitted directly."""
|
|
|
|
def __init__(self, store: "CloudStore") -> None:
|
|
self.store = store
|
|
|
|
def register(self, manifest: PluginManifest) -> PluginManifest:
|
|
"""Validate, persist, and (for drivers) wire a plugin manifest."""
|
|
if self.store.get_plugin(manifest.name) is not None:
|
|
raise DuplicatePluginError(
|
|
f"a plugin named {manifest.name!r} is already registered"
|
|
)
|
|
wired = False
|
|
if manifest.entry_point_kind == "driver":
|
|
wired = self._wire_driver(manifest)
|
|
# tool/skill-kind: stored with wired=False, no other resolution attempted.
|
|
self.store.save_plugin(manifest, wired=wired)
|
|
if not wired:
|
|
logger.info(
|
|
"plugin %r registered but not wired to any execution path",
|
|
manifest.name,
|
|
)
|
|
return manifest
|
|
|
|
def list(self) -> list[tuple[PluginManifest, bool]]:
|
|
return self.store.list_plugins()
|
|
|
|
def discover(self, scan_path: str | Path | None = None) -> DiscoveryResult:
|
|
result = DiscoveryResult()
|
|
for manifest in self.discover_entry_points():
|
|
try:
|
|
self.register(manifest)
|
|
result.registered.append(manifest)
|
|
except _PLUGIN_REGISTRATION_FAILURES as exc:
|
|
result.errors.append(
|
|
f"entry-point plugin {manifest.name!r}: {exc}"
|
|
)
|
|
if scan_path is not None:
|
|
for manifest in self.discover_manifest_files(scan_path):
|
|
try:
|
|
self.register(manifest)
|
|
result.registered.append(manifest)
|
|
except _PLUGIN_REGISTRATION_FAILURES as exc:
|
|
result.errors.append(
|
|
f"manifest-file plugin {manifest.name!r}: {exc}"
|
|
)
|
|
return result
|
|
|
|
def discover_entry_points(self) -> list[PluginManifest]:
|
|
manifests: list[PluginManifest] = []
|
|
try:
|
|
entry_points = importlib.metadata.entry_points(group=ENTRY_POINT_GROUP)
|
|
except TypeError:
|
|
# Older Pythons: entry_points() returns a dict-like view.
|
|
all_eps = importlib.metadata.entry_points()
|
|
entry_points = all_eps.get(ENTRY_POINT_GROUP, []) # type: ignore[union-attr]
|
|
for entry_point in entry_points:
|
|
try:
|
|
loaded = entry_point.load()
|
|
except Exception as exc: # pragma: no cover - exercised via fake eps in tests
|
|
logger.warning("entry point %r failed to load: %s", entry_point.name, exc)
|
|
continue
|
|
manifest = _coerce_to_manifest(loaded)
|
|
if manifest is not None:
|
|
manifests.append(manifest)
|
|
return manifests
|
|
|
|
def discover_manifest_files(self, scan_path: str | Path) -> list[PluginManifest]:
|
|
root = Path(scan_path)
|
|
manifests: list[PluginManifest] = []
|
|
if not root.exists():
|
|
return manifests
|
|
for path in sorted(root.rglob(PLUGIN_MANIFEST_FILENAME)):
|
|
try:
|
|
raw = path.read_text(encoding="utf-8")
|
|
data = json.loads(raw)
|
|
manifest = PluginManifest.from_dict(data)
|
|
except (OSError, ValueError, KeyError, json.JSONDecodeError) as exc:
|
|
logger.warning("malformed plugin manifest %s: %s", path, exc)
|
|
continue
|
|
manifests.append(manifest)
|
|
return manifests
|
|
|
|
def _wire_driver(self, manifest: PluginManifest) -> bool:
|
|
register_fn = _resolve_driver_register_function()
|
|
if register_fn is None:
|
|
raise DriverRegistryUnavailableError(
|
|
"driver.registry.register_driver_type is not importable; "
|
|
"the driver-registry extension point must be available to "
|
|
"register driver-kind plugins"
|
|
)
|
|
builder = _resolve_target(manifest.target)
|
|
register_fn(manifest.name, builder)
|
|
return True
|
|
|
|
|
|
def _resolve_driver_register_function():
|
|
"""Return ``driver.registry.register_driver_type`` if importable, else None."""
|
|
try:
|
|
module = importlib.import_module("driver.registry")
|
|
except ImportError:
|
|
return None
|
|
fn = getattr(module, "register_driver_type", None)
|
|
if not callable(fn):
|
|
return None
|
|
return fn
|
|
|
|
|
|
def _resolve_target(target: str):
|
|
"""Resolve ``module.sub:attribute`` to a callable."""
|
|
if ":" not in target:
|
|
raise PluginTargetResolutionError(
|
|
f"plugin target {target!r} must be 'module.path:attribute'"
|
|
)
|
|
module_path, _, attribute = target.partition(":")
|
|
if not module_path or not attribute:
|
|
raise PluginTargetResolutionError(
|
|
f"plugin target {target!r} must be 'module.path:attribute'"
|
|
)
|
|
try:
|
|
module = importlib.import_module(module_path)
|
|
except ImportError as exc:
|
|
raise PluginTargetResolutionError(
|
|
f"could not import plugin target module {module_path!r}: {exc}"
|
|
) from exc
|
|
obj = getattr(module, attribute, None)
|
|
if obj is None or not callable(obj):
|
|
raise PluginTargetResolutionError(
|
|
f"plugin target {target!r} did not resolve to a callable"
|
|
)
|
|
return obj
|
|
|
|
|
|
def _coerce_to_manifest(loaded: Any) -> PluginManifest | None:
|
|
if isinstance(loaded, PluginManifest):
|
|
return loaded
|
|
if isinstance(loaded, dict):
|
|
try:
|
|
return PluginManifest.from_dict(loaded)
|
|
except (KeyError, ValueError):
|
|
return None
|
|
if callable(loaded):
|
|
try:
|
|
result = loaded()
|
|
except Exception: # pragma: no cover - defensive
|
|
return None
|
|
return _coerce_to_manifest(result)
|
|
return None
|