mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-10-31 04:01:59 +01:00
Add comprehensive tests for plugin management and IPC functionality
- Introduced tests for the plugin catalogue installation and update flows in `test_plugin_catalogue_install.py`. - Added unit tests for the GitHub Releases plugin client in `test_plugin_github_releases.py`. - Implemented IPC tests for the plugin manager Unix socket protocol in `test_plugin_ipc.py`. - Created lifecycle and installation tests for the plugin manager in `test_plugin_manager_lifecycle.py`. - Added tests for plugin manifest parsing in `test_plugin_manifest.py`. - Developed tests for plugin filesystem layout and state management in `test_plugin_storage.py`.
This commit is contained in:
1 parent
bd0c340856
commit
26560fd50a
30 files changed
+5742
-1
No files matched your search
@@ -0,0 +1,532 @@
|
||||
"""Plugin lifecycle orchestration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import shutil
|
||||
|
||||
from .catalogue import CatalogueClient, CatalogueError, DEFAULT_CATALOGUE_URL
|
||||
from .github_releases import (
|
||||
GitHubReleaseClient,
|
||||
GitHubReleasesError,
|
||||
make_download_temp_dir,
|
||||
normalize_version,
|
||||
)
|
||||
from .manifest import ManifestError, load_manifest_from_wheel
|
||||
from .runtime import PluginRuntime, PluginState
|
||||
from .storage import PluginStorage
|
||||
|
||||
logger = logging.getLogger("PluginManager")
|
||||
|
||||
|
||||
class PluginManagerError(Exception):
|
||||
"""Base error for plugin manager operations."""
|
||||
|
||||
def __init__(self, message: str, code: int = 400):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
class PluginManager:
|
||||
"""High-level install / enable / start / stop / uninstall API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
storage: PluginStorage,
|
||||
runtime: Optional[PluginRuntime] = None,
|
||||
*,
|
||||
catalogue_client: Optional[CatalogueClient] = None,
|
||||
github_client: Optional[GitHubReleaseClient] = None,
|
||||
catalogue_url: Optional[str] = None,
|
||||
):
|
||||
self.storage = storage
|
||||
self.runtime = runtime or PluginRuntime(storage)
|
||||
self._lock = threading.RLock()
|
||||
self.catalogue = catalogue_client or CatalogueClient(catalogue_url or DEFAULT_CATALOGUE_URL)
|
||||
self.github = github_client or GitHubReleaseClient()
|
||||
|
||||
def start(self) -> None:
|
||||
"""Load state and start enabled service plugins."""
|
||||
self.storage.ensure_root()
|
||||
self.runtime.start_supervisor()
|
||||
for plugin_id in self.storage.list_plugin_ids():
|
||||
state = self.storage.read_state(plugin_id) or {}
|
||||
if not state.get("enabled", False):
|
||||
self.runtime._runtime_state[plugin_id] = PluginState.DISABLED
|
||||
continue
|
||||
manifest = self.storage.load_current_manifest(plugin_id)
|
||||
if manifest is None or manifest.runtime is None:
|
||||
self.runtime._runtime_state[plugin_id] = PluginState.STOPPED
|
||||
continue
|
||||
try:
|
||||
self.runtime.start(plugin_id)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to start enabled plugin %s: %s", plugin_id, exc)
|
||||
self.runtime._runtime_state[plugin_id] = PluginState.FAILED
|
||||
|
||||
def stop_all(self) -> None:
|
||||
for plugin_id in list(self.storage.list_plugin_ids()):
|
||||
try:
|
||||
self.runtime.stop(plugin_id)
|
||||
except Exception as exc:
|
||||
logger.debug("stop_all %s: %s", plugin_id, exc)
|
||||
self.runtime.stop_supervisor()
|
||||
|
||||
def list_plugins(self) -> list[dict[str, Any]]:
|
||||
result = []
|
||||
for plugin_id in self.storage.list_plugin_ids():
|
||||
try:
|
||||
result.append(self.runtime.status_dict(plugin_id))
|
||||
except KeyError:
|
||||
continue
|
||||
return result
|
||||
|
||||
def status(self, plugin_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
return self.runtime.status_dict(plugin_id)
|
||||
except KeyError as exc:
|
||||
raise PluginManagerError(f"plugin not found: {plugin_id}", 404) from exc
|
||||
|
||||
def install(self, wheel_path: str | Path) -> dict[str, Any]:
|
||||
wheel_path = Path(wheel_path)
|
||||
try:
|
||||
manifest = load_manifest_from_wheel(wheel_path)
|
||||
except ManifestError as exc:
|
||||
raise PluginManagerError(str(exc), 400) from exc
|
||||
with self._lock:
|
||||
try:
|
||||
info = self.runtime.install_wheel(wheel_path, manifest)
|
||||
except Exception as exc:
|
||||
raise PluginManagerError(f"install failed: {exc}", 500) from exc
|
||||
logger.info("Installed plugin %s@%s", info["id"], info["version"])
|
||||
# Return full status
|
||||
return self.status(info["id"])
|
||||
|
||||
def enable(self, plugin_id: str) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
state = self.storage.read_state(plugin_id)
|
||||
if state is None:
|
||||
raise PluginManagerError(f"plugin not found: {plugin_id}", 404)
|
||||
state = dict(state)
|
||||
state["enabled"] = True
|
||||
self.storage.write_state(plugin_id, state)
|
||||
manifest = self.storage.load_current_manifest(plugin_id)
|
||||
if manifest and manifest.runtime is not None:
|
||||
try:
|
||||
self.runtime.start(plugin_id)
|
||||
except Exception as exc:
|
||||
raise PluginManagerError(f"enable start failed: {exc}", 500) from exc
|
||||
else:
|
||||
self.runtime._runtime_state[plugin_id] = PluginState.STOPPED
|
||||
return self.status(plugin_id)
|
||||
|
||||
def disable(self, plugin_id: str) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
state = self.storage.read_state(plugin_id)
|
||||
if state is None:
|
||||
raise PluginManagerError(f"plugin not found: {plugin_id}", 404)
|
||||
try:
|
||||
self.runtime.stop(plugin_id, mark_disabled=True)
|
||||
except Exception as exc:
|
||||
logger.warning("disable stop error for %s: %s", plugin_id, exc)
|
||||
state = dict(state)
|
||||
state["enabled"] = False
|
||||
self.storage.write_state(plugin_id, state)
|
||||
self.runtime._runtime_state[plugin_id] = PluginState.DISABLED
|
||||
return self.status(plugin_id)
|
||||
|
||||
def start_plugin(self, plugin_id: str) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
state = self.storage.read_state(plugin_id)
|
||||
if state is None:
|
||||
raise PluginManagerError(f"plugin not found: {plugin_id}", 404)
|
||||
if not state.get("enabled", False):
|
||||
raise PluginManagerError(
|
||||
f"plugin is disabled; enable it before starting: {plugin_id}", 409
|
||||
)
|
||||
try:
|
||||
self.runtime.start(plugin_id)
|
||||
except Exception as exc:
|
||||
raise PluginManagerError(f"start failed: {exc}", 500) from exc
|
||||
return self.status(plugin_id)
|
||||
|
||||
def stop_plugin(self, plugin_id: str) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
state = self.storage.read_state(plugin_id)
|
||||
if state is None:
|
||||
raise PluginManagerError(f"plugin not found: {plugin_id}", 404)
|
||||
try:
|
||||
self.runtime.stop(plugin_id)
|
||||
except Exception as exc:
|
||||
raise PluginManagerError(f"stop failed: {exc}", 500) from exc
|
||||
return self.status(plugin_id)
|
||||
|
||||
def restart_plugin(self, plugin_id: str) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
state = self.storage.read_state(plugin_id)
|
||||
if state is None:
|
||||
raise PluginManagerError(f"plugin not found: {plugin_id}", 404)
|
||||
if not state.get("enabled", False):
|
||||
raise PluginManagerError(
|
||||
f"plugin is disabled; enable it before restarting: {plugin_id}", 409
|
||||
)
|
||||
try:
|
||||
self.runtime.restart(plugin_id)
|
||||
except Exception as exc:
|
||||
raise PluginManagerError(f"restart failed: {exc}", 500) from exc
|
||||
return self.status(plugin_id)
|
||||
|
||||
def logs(self, plugin_id: str, tail: int = 200) -> dict[str, Any]:
|
||||
state = self.storage.read_state(plugin_id)
|
||||
if state is None:
|
||||
raise PluginManagerError(f"plugin not found: {plugin_id}", 404)
|
||||
lines = self.storage.tail_log(plugin_id, lines=tail)
|
||||
return {"id": plugin_id, "lines": lines, "tail": tail}
|
||||
|
||||
def get_config(self, plugin_id: str) -> dict[str, Any]:
|
||||
"""Return plugin config for the editor.
|
||||
|
||||
``config`` is the value to show in the UI: saved data/config.json if
|
||||
present, otherwise plugin-declared defaults (manifest / config.default.json).
|
||||
``saved`` is only what is on disk; ``defaults`` is the plugin template.
|
||||
"""
|
||||
state = self.storage.read_state(plugin_id)
|
||||
if state is None:
|
||||
raise PluginManagerError(f"plugin not found: {plugin_id}", 404)
|
||||
paths = self.storage.paths_for(plugin_id)
|
||||
paths.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
saved = self.storage.read_config(plugin_id)
|
||||
except ValueError as exc:
|
||||
raise PluginManagerError(str(exc), 500) from exc
|
||||
defaults = self.runtime.resolve_config_defaults(plugin_id)
|
||||
exists = self.storage.config_path(plugin_id).is_file()
|
||||
# Editor payload: prefer saved file; fall back to defaults when empty/missing
|
||||
if exists and saved:
|
||||
config = saved
|
||||
elif exists and not saved and not defaults:
|
||||
config = {}
|
||||
elif not exists:
|
||||
config = dict(defaults)
|
||||
else:
|
||||
# Empty saved file — still show defaults as a helpful starting point
|
||||
config = dict(defaults) if defaults else saved
|
||||
return {
|
||||
"id": plugin_id,
|
||||
"path": str(self.storage.config_path(plugin_id)),
|
||||
"exists": exists,
|
||||
"defaults": defaults,
|
||||
"saved": saved,
|
||||
"config": config,
|
||||
}
|
||||
|
||||
def set_config(
|
||||
self,
|
||||
plugin_id: str,
|
||||
config: dict[str, Any],
|
||||
*,
|
||||
restart: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Write plugin-owned data/config.json. Optionally restart a service plugin."""
|
||||
state = self.storage.read_state(plugin_id)
|
||||
if state is None:
|
||||
raise PluginManagerError(f"plugin not found: {plugin_id}", 404)
|
||||
if not isinstance(config, dict):
|
||||
raise PluginManagerError("config must be a JSON object", 400)
|
||||
# Soft size guard — prevents accidental huge payloads
|
||||
try:
|
||||
encoded = json.dumps(config)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise PluginManagerError(f"config is not JSON-serializable: {exc}", 400) from exc
|
||||
if len(encoded.encode("utf-8")) > 256 * 1024:
|
||||
raise PluginManagerError("config exceeds 256 KiB limit", 413)
|
||||
|
||||
with self._lock:
|
||||
try:
|
||||
self.storage.write_config(plugin_id, config)
|
||||
except ValueError as exc:
|
||||
raise PluginManagerError(str(exc), 400) from exc
|
||||
|
||||
restarted = False
|
||||
if restart and state.get("enabled", False):
|
||||
manifest = self.storage.load_current_manifest(plugin_id)
|
||||
if manifest and manifest.runtime is not None:
|
||||
try:
|
||||
self.runtime.restart(plugin_id)
|
||||
restarted = True
|
||||
except Exception as exc:
|
||||
raise PluginManagerError(
|
||||
f"config saved but restart failed: {exc}", 500
|
||||
) from exc
|
||||
|
||||
result = self.get_config(plugin_id)
|
||||
result["restarted"] = restarted
|
||||
return result
|
||||
|
||||
def uninstall(self, plugin_id: str, *, delete_data: bool = False) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
state = self.storage.read_state(plugin_id)
|
||||
if state is None:
|
||||
raise PluginManagerError(f"plugin not found: {plugin_id}", 404)
|
||||
try:
|
||||
self.runtime.stop(plugin_id, mark_disabled=True)
|
||||
except Exception as exc:
|
||||
logger.warning("uninstall stop error for %s: %s", plugin_id, exc)
|
||||
self.storage.remove_release_code(plugin_id, keep_data=not delete_data)
|
||||
self.runtime._runtime_state.pop(plugin_id, None)
|
||||
self.runtime._crash_times.pop(plugin_id, None)
|
||||
self.runtime._exit_codes.pop(plugin_id, None)
|
||||
return {
|
||||
"id": plugin_id,
|
||||
"uninstalled": True,
|
||||
"data_deleted": bool(delete_data),
|
||||
}
|
||||
|
||||
def _version_gt(self, left: str, right: str) -> bool:
|
||||
"""Return True if left > right using packaging.version when available."""
|
||||
try:
|
||||
from packaging.version import Version
|
||||
|
||||
return Version(normalize_version(left)) > Version(normalize_version(right))
|
||||
except Exception:
|
||||
return normalize_version(left) > normalize_version(right)
|
||||
|
||||
def _download_dir(self) -> Path:
|
||||
return self.storage.root / ".download"
|
||||
|
||||
def list_catalogue(self, *, force_refresh: bool = False) -> dict[str, Any]:
|
||||
"""Fetch catalogue entries and annotate with install/update info."""
|
||||
try:
|
||||
catalogue = self.catalogue.fetch(force_refresh=force_refresh)
|
||||
except CatalogueError as exc:
|
||||
raise PluginManagerError(str(exc), int(exc.code)) from exc
|
||||
|
||||
plugins_out: list[dict[str, Any]] = []
|
||||
for entry in catalogue.plugins:
|
||||
installed_state = self.storage.read_state(entry.id)
|
||||
installed = installed_state is not None
|
||||
installed_version = (
|
||||
str(installed_state.get("version") or "") if installed_state else None
|
||||
)
|
||||
row: dict[str, Any] = {
|
||||
**entry.to_dict(),
|
||||
"installed": installed,
|
||||
"installedVersion": installed_version if installed else None,
|
||||
}
|
||||
# Enrich with latest stable when possible (cached); failures are non-fatal per row
|
||||
latest_version = None
|
||||
update_available = False
|
||||
try:
|
||||
latest = self.github.latest_stable(entry.repository, force_refresh=force_refresh)
|
||||
if latest is not None:
|
||||
latest_version = latest.version
|
||||
if installed and installed_version:
|
||||
update_available = self._version_gt(latest_version, installed_version)
|
||||
except GitHubReleasesError as exc:
|
||||
row["releasesError"] = str(exc)
|
||||
row["latestVersion"] = latest_version
|
||||
row["updateAvailable"] = bool(update_available)
|
||||
plugins_out.append(row)
|
||||
return {"schema": catalogue.schema, "plugins": plugins_out}
|
||||
|
||||
def install_from_catalogue(
|
||||
self,
|
||||
plugin_id: str,
|
||||
*,
|
||||
version: Optional[str] = None,
|
||||
force_refresh: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Download catalogue plugin wheel and install via existing installer."""
|
||||
try:
|
||||
entry = self.catalogue.get_plugin(plugin_id, force_refresh=force_refresh)
|
||||
except CatalogueError as exc:
|
||||
raise PluginManagerError(str(exc), int(exc.code)) from exc
|
||||
|
||||
download_root = self._download_dir()
|
||||
staging = make_download_temp_dir(download_root)
|
||||
try:
|
||||
try:
|
||||
release, wheel_path = self.github.download_latest_wheel(
|
||||
entry.repository,
|
||||
staging,
|
||||
version=version,
|
||||
force_refresh=force_refresh,
|
||||
)
|
||||
except GitHubReleasesError as exc:
|
||||
raise PluginManagerError(str(exc), int(exc.code)) from exc
|
||||
|
||||
try:
|
||||
manifest = load_manifest_from_wheel(wheel_path)
|
||||
except ManifestError as exc:
|
||||
raise PluginManagerError(str(exc), 400) from exc
|
||||
|
||||
if manifest.id != entry.id:
|
||||
raise PluginManagerError(
|
||||
f"manifest id {manifest.id!r} does not match catalogue id {entry.id!r}",
|
||||
400,
|
||||
)
|
||||
|
||||
with self._lock:
|
||||
try:
|
||||
info = self.runtime.install_wheel(wheel_path, manifest)
|
||||
except Exception as exc:
|
||||
raise PluginManagerError(f"install failed: {exc}", 500) from exc
|
||||
# Record catalogue provenance (repository locked at install time)
|
||||
st = self.storage.read_state(info["id"]) or {}
|
||||
st = dict(st)
|
||||
st["version"] = info["version"]
|
||||
st["source"] = "catalogue"
|
||||
st["repository"] = entry.repository
|
||||
# Keep enabled flag from install_wheel; enable next
|
||||
self.storage.write_state(info["id"], st)
|
||||
|
||||
# Catalogue installs are enabled by default
|
||||
return self.enable(info["id"])
|
||||
finally:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
|
||||
def check_update(self, plugin_id: str, *, force_refresh: bool = False) -> dict[str, Any]:
|
||||
state = self.storage.read_state(plugin_id)
|
||||
if state is None:
|
||||
raise PluginManagerError(f"plugin not found: {plugin_id}", 404)
|
||||
installed_version = str(state.get("version") or "")
|
||||
repository = state.get("repository")
|
||||
if not repository:
|
||||
return {
|
||||
"id": plugin_id,
|
||||
"installedVersion": installed_version or None,
|
||||
"latestVersion": None,
|
||||
"updateAvailable": False,
|
||||
"repository": None,
|
||||
"updatesAvailable": False,
|
||||
"reason": "repository unknown (local install); update check unavailable",
|
||||
}
|
||||
|
||||
try:
|
||||
latest = self.github.latest_stable(str(repository), force_refresh=force_refresh)
|
||||
except GitHubReleasesError as exc:
|
||||
raise PluginManagerError(str(exc), int(exc.code)) from exc
|
||||
|
||||
latest_version = latest.version if latest else None
|
||||
update_available = bool(
|
||||
latest_version
|
||||
and installed_version
|
||||
and self._version_gt(latest_version, installed_version)
|
||||
)
|
||||
notes = latest.body if latest and update_available else None
|
||||
return {
|
||||
"id": plugin_id,
|
||||
"installedVersion": installed_version or None,
|
||||
"latestVersion": latest_version,
|
||||
"updateAvailable": update_available,
|
||||
"repository": str(repository),
|
||||
"releaseNotes": notes,
|
||||
"releaseUrl": latest.html_url if latest else None,
|
||||
"releaseTag": latest.tag if latest else None,
|
||||
}
|
||||
|
||||
def update_plugin(
|
||||
self,
|
||||
plugin_id: str,
|
||||
*,
|
||||
version: Optional[str] = None,
|
||||
force_refresh: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Download a newer (or pinned) release and reinstall via existing installer."""
|
||||
state = self.storage.read_state(plugin_id)
|
||||
if state is None:
|
||||
raise PluginManagerError(f"plugin not found: {plugin_id}", 404)
|
||||
repository = state.get("repository")
|
||||
if not repository:
|
||||
raise PluginManagerError(
|
||||
"update unavailable: plugin has no repository metadata "
|
||||
"(reinstall from catalogue or set repository)",
|
||||
400,
|
||||
)
|
||||
repository = str(repository)
|
||||
installed_version = str(state.get("version") or "")
|
||||
was_enabled = bool(state.get("enabled", False))
|
||||
|
||||
# Resolve target release
|
||||
try:
|
||||
if version:
|
||||
release = self.github.find_release(repository, version, force_refresh=force_refresh)
|
||||
else:
|
||||
release = self.github.latest_stable(repository, force_refresh=force_refresh)
|
||||
if release is None:
|
||||
raise PluginManagerError(f"no stable releases found for {repository}", 404)
|
||||
if installed_version and not self._version_gt(release.version, installed_version):
|
||||
# Already up to date — return status without changing
|
||||
status = self.status(plugin_id)
|
||||
status["updateAvailable"] = False
|
||||
status["latestVersion"] = release.version
|
||||
status["updated"] = False
|
||||
return status
|
||||
except GitHubReleasesError as exc:
|
||||
raise PluginManagerError(str(exc), int(exc.code)) from exc
|
||||
|
||||
download_root = self._download_dir()
|
||||
staging = make_download_temp_dir(download_root)
|
||||
try:
|
||||
try:
|
||||
wheel_path = self.github.download_wheel(release, staging)
|
||||
except GitHubReleasesError as exc:
|
||||
raise PluginManagerError(str(exc), int(exc.code)) from exc
|
||||
|
||||
try:
|
||||
manifest = load_manifest_from_wheel(wheel_path)
|
||||
except ManifestError as exc:
|
||||
raise PluginManagerError(str(exc), 400) from exc
|
||||
if manifest.id != plugin_id:
|
||||
raise PluginManagerError(
|
||||
f"manifest id {manifest.id!r} does not match installed id {plugin_id!r}",
|
||||
400,
|
||||
)
|
||||
|
||||
with self._lock:
|
||||
# Stop running process before swapping release
|
||||
try:
|
||||
self.runtime.stop(plugin_id)
|
||||
except Exception as exc:
|
||||
logger.debug("update stop %s: %s", plugin_id, exc)
|
||||
try:
|
||||
info = self.runtime.install_wheel(wheel_path, manifest)
|
||||
except Exception as exc:
|
||||
raise PluginManagerError(f"update install failed: {exc}", 500) from exc
|
||||
st = self.storage.read_state(info["id"]) or {}
|
||||
st = dict(st)
|
||||
st["version"] = info["version"]
|
||||
st["source"] = st.get("source") or "catalogue"
|
||||
st["repository"] = repository # never silently retarget
|
||||
st["enabled"] = was_enabled
|
||||
self.storage.write_state(info["id"], st)
|
||||
|
||||
if was_enabled:
|
||||
return self.enable(plugin_id)
|
||||
result = self.status(plugin_id)
|
||||
result["updated"] = True
|
||||
result["latestVersion"] = release.version
|
||||
return result
|
||||
finally:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
|
||||
def ui_root_for(self, plugin_id: str) -> Optional[Path]:
|
||||
"""Return the release directory for an enabled UI application plugin."""
|
||||
state = self.storage.read_state(plugin_id)
|
||||
if state is None or not state.get("enabled", False):
|
||||
return None
|
||||
manifest = self.storage.load_current_manifest(plugin_id)
|
||||
if manifest is None or manifest.ui is None:
|
||||
return None
|
||||
paths = self.storage.paths_for(plugin_id)
|
||||
if paths.current_link.exists() or paths.current_link.is_symlink():
|
||||
return paths.current_link.resolve()
|
||||
version = state.get("version")
|
||||
if version:
|
||||
return paths.release_dir(str(version))
|
||||
return None
|
||||
Reference in new issue
Block a user