cloud
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
"""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."""
|
||||
|
||||
|
||||
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 (PluginValidationError, DuplicatePluginError) 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 (PluginValidationError, DuplicatePluginError) 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
|
||||
Reference in New Issue
Block a user