Source code for pymixef.plugins

"""Typed, deterministic registries for PyMixEF extension points."""

from __future__ import annotations

from collections.abc import Callable, Iterator, Mapping
from dataclasses import dataclass
from importlib import metadata
from threading import RLock
from typing import Any, Generic, Protocol, TypeVar, cast, runtime_checkable

from .errors import PluginError

T = TypeVar("T")


[docs] @dataclass(frozen=True, slots=True) class PluginInfo: """Metadata retained for one registered implementation.""" name: str implementation: Any version: str | None = None source: str = "runtime"
[docs] class Registry(Generic[T]): """Thread-safe registry with normalized names and explicit replacement.""" def __init__(self, kind: str) -> None: self.kind = kind self._entries: dict[str, PluginInfo] = {} self._lock = RLock()
[docs] @staticmethod def normalize(name: str) -> str: key = name.strip().lower().replace("_", "-") if not key: raise PluginError("Plugin names cannot be empty.", code="PLUGIN-NAME-001") return key
[docs] def register( self, name: str, implementation: T | None = None, *, replace: bool = False, version: str | None = None, source: str = "runtime", ) -> T | Callable[[T], T]: """Register an implementation, directly or as a decorator.""" def add(value: T) -> T: key = self.normalize(name) with self._lock: if key in self._entries and not replace: raise PluginError( f"{self.kind.title()} plugin {key!r} is already registered.", code="PLUGIN-DUPLICATE-001", details={"kind": self.kind, "name": key}, ) self._entries[key] = PluginInfo(key, value, version, source) return value return add if implementation is None else add(implementation)
[docs] def unregister(self, name: str) -> T: """Remove and return a runtime registration.""" key = self.normalize(name) with self._lock: try: return cast(T, self._entries.pop(key).implementation) except KeyError as exc: raise PluginError( f"Unknown {self.kind} plugin {key!r}.", code="PLUGIN-NOT-FOUND-001", ) from exc
[docs] def get(self, name: str) -> T: """Resolve an implementation by normalized name.""" key = self.normalize(name) with self._lock: try: return cast(T, self._entries[key].implementation) except KeyError as exc: raise PluginError( f"Unknown {self.kind} plugin {key!r}.", code="PLUGIN-NOT-FOUND-001", details={"available": list(self.names())}, ) from exc
[docs] def info(self, name: str) -> PluginInfo: """Return immutable registration metadata.""" key = self.normalize(name) with self._lock: try: return self._entries[key] except KeyError as exc: raise PluginError( f"Unknown {self.kind} plugin {key!r}.", code="PLUGIN-NOT-FOUND-001", ) from exc
[docs] def names(self) -> tuple[str, ...]: """Return deterministic registered names.""" with self._lock: return tuple(sorted(self._entries))
[docs] def snapshot(self) -> Mapping[str, PluginInfo]: """Return a detached, sorted registry snapshot.""" with self._lock: return {name: self._entries[name] for name in sorted(self._entries)}
def __contains__(self, name: object) -> bool: return isinstance(name, str) and self.normalize(name) in self._entries def __iter__(self) -> Iterator[str]: return iter(self.names())
[docs] @runtime_checkable class CovariancePlugin(Protocol): """Minimal covariance plugin contract."""
[docs] def covariance(self, parameters: Any, **context: Any) -> Any: """Construct a covariance matrix."""
[docs] def validate(self, matrix: Any, **context: Any) -> Any: """Validate a covariance matrix."""
[docs] def simulate(self, parameters: Any, **context: Any) -> Any: """Simulate a zero-mean draw."""
[docs] @runtime_checkable class EstimatorPlugin(Protocol): """Minimal estimator extension contract."""
[docs] def fit(self, model: Any, data: Any, **controls: Any) -> Any: """Fit a compiled model."""
FAMILY_REGISTRY: Registry[Any] = Registry("family") LINK_REGISTRY: Registry[Any] = Registry("link") COVARIANCE_REGISTRY: Registry[Any] = Registry("covariance") ESTIMATOR_REGISTRY: Registry[Any] = Registry("estimator") DIAGNOSTIC_REGISTRY: Registry[Any] = Registry("diagnostic") EXPORTER_REGISTRY: Registry[Any] = Registry("exporter") ODE_SOLVER_REGISTRY: Registry[Any] = Registry("ode-solver")
[docs] def discover_plugins(group: str = "pymixef.plugins") -> tuple[str, ...]: """Load package entry points in deterministic name order. An entry point may be a zero-argument registration function or an import whose module-level code performs registration. """ loaded: list[str] = [] selected = metadata.entry_points().select(group=group) for entry_point in sorted(selected, key=lambda item: item.name): try: target = entry_point.load() if callable(target): target() except Exception as exc: raise PluginError( f"Failed to load plugin entry point {entry_point.name!r}.", code="PLUGIN-LOAD-001", details={"entry_point": entry_point.name, "group": group}, ) from exc loaded.append(entry_point.name) return tuple(loaded)
__all__ = [ "COVARIANCE_REGISTRY", "DIAGNOSTIC_REGISTRY", "ESTIMATOR_REGISTRY", "EXPORTER_REGISTRY", "FAMILY_REGISTRY", "LINK_REGISTRY", "ODE_SOLVER_REGISTRY", "CovariancePlugin", "EstimatorPlugin", "PluginInfo", "Registry", "discover_plugins", ]