Files
2026-09-07 21:29:48 +01:00

817 lines
33 KiB
Python

"""Plugin lifecycle orchestration."""
from __future__ import annotations
import json
import logging
import os
import shutil
import stat
import tempfile
import threading
import time
from contextlib import contextmanager
from functools import wraps
from pathlib import Path
from typing import Any, Optional
from .catalogue import DEFAULT_CATALOGUE_URL, MAX_WHEEL_BYTES, CatalogueClient, CatalogueError
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")
PROGRESS_MAX_LINES = 500
PROGRESS_MAX_LOGS = 32
class OperationProgress:
"""Bounded output log of a plugin's last install or update."""
def __init__(self, plugin_id: str, operation: str):
self.plugin_id = plugin_id
self.operation = operation
self.state = "running"
self.error: Optional[str] = None
self.started = time.time()
self.finished: Optional[float] = None
self._lines: list[str] = []
self._dropped = 0 # cursors count every line ever appended
self._lock = threading.Lock()
def append(self, line: str) -> None:
with self._lock:
self._lines.append(line)
excess = len(self._lines) - PROGRESS_MAX_LINES
if excess > 0:
del self._lines[:excess]
self._dropped += excess
def finish(self, error: Optional[str] = None) -> None:
with self._lock:
self.state = "error" if error else "complete"
self.error = error
self.finished = time.time()
def snapshot(self, since: int = 0) -> dict[str, Any]:
with self._lock:
start = max(0, min(int(since) - self._dropped, len(self._lines)))
return {
"id": self.plugin_id,
"operation": self.operation,
"state": self.state,
"error": self.error,
"lines": list(self._lines[start:]),
"next": self._dropped + len(self._lines),
"started": self.started,
"finished": self.finished,
}
def _reported(operation: str):
"""Record the operation's installer output and outcome for /plugins/progress."""
def decorate(fn):
@wraps(fn)
def wrapped(self, plugin_id, *args, **kwargs):
progress = self._start_progress(plugin_id, operation)
previous = self.runtime.on_output
self.runtime.on_output = progress.append
try:
result = fn(self, plugin_id, *args, **kwargs)
except Exception as exc:
progress.finish(str(exc))
raise
finally:
self.runtime.on_output = previous
if progress.finished is None:
progress.finish()
return result
return wrapped
return decorate
def _serialized_plugin(fn):
"""Keep a complete lifecycle transaction exclusive, without queuing callers."""
@wraps(fn)
def wrapped(self, plugin_id, *args, **kwargs):
with self._operation(plugin_id):
return fn(self, plugin_id, *args, **kwargs)
return wrapped
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._operations: dict[str, tuple[int, int]] = {}
self._progress: dict[str, OperationProgress] = {}
self.catalogue = catalogue_client or CatalogueClient(catalogue_url or DEFAULT_CATALOGUE_URL)
self.github = github_client or GitHubReleaseClient()
@contextmanager
def _operation(self, plugin_id: str):
owner = threading.get_ident()
with self._lock:
active = self._operations.get(plugin_id)
if active and active[0] != owner:
raise PluginManagerError(f"plugin operation already in progress: {plugin_id}", 409)
self._operations[plugin_id] = (owner, active[1] + 1 if active else 1)
try:
yield
finally:
with self._lock:
depth = self._operations[plugin_id][1]
if depth == 1:
del self._operations[plugin_id]
else:
self._operations[plugin_id] = (owner, depth - 1)
def _start_progress(self, plugin_id: str, operation: str) -> OperationProgress:
progress = OperationProgress(plugin_id, operation)
with self._lock:
self._progress[plugin_id] = progress
finished = sorted(
(p for p in self._progress.values() if p.state != "running"),
key=lambda p: p.finished or 0,
)
for old in finished[: max(0, len(self._progress) - PROGRESS_MAX_LOGS)]:
del self._progress[old.plugin_id]
return progress
def progress(self, plugin_id: str, since: int = 0) -> dict[str, Any]:
with self._lock:
progress = self._progress.get(plugin_id)
if progress is None:
return {
"id": plugin_id,
"operation": None,
"state": "idle",
"error": None,
"lines": [],
"next": 0,
"started": None,
"finished": None,
}
return progress.snapshot(since)
@contextmanager
def stage_upload(self, wheel_path: str):
"""Own a bounded regular-file copy before IPC acknowledges processing.
Read through one opened descriptor, so unlinking an HTTP upload cannot
invalidate an in-progress copy. Never consume the caller's path later.
"""
root = self.storage.root / ".ipc-staging"
root.mkdir(parents=True, exist_ok=True)
limit = MAX_WHEEL_BYTES
deadline = time.monotonic() + 30
with tempfile.TemporaryDirectory(prefix="install-", dir=root) as directory:
source = Path(wheel_path)
staged = Path(directory) / source.name
fd = os.open(source, os.O_RDONLY | os.O_NONBLOCK)
with os.fdopen(fd, "rb") as incoming:
metadata = os.fstat(incoming.fileno())
if not stat.S_ISREG(metadata.st_mode):
raise PluginManagerError("wheel must be a regular file", 400)
if metadata.st_size > limit:
raise PluginManagerError("wheel exceeds 100 MiB limit", 413)
total = 0
with staged.open("wb") as outgoing:
while chunk := incoming.read(1024 * 1024):
total += len(chunk)
if total > limit:
raise PluginManagerError("wheel exceeds 100 MiB limit", 413)
if time.monotonic() > deadline:
raise PluginManagerError("wheel staging deadline exceeded", 504)
outgoing.write(chunk)
yield staged
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._operation(manifest.id):
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"])
@_serialized_plugin
def enable(self, plugin_id: str) -> dict[str, Any]:
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)
@_serialized_plugin
def disable(self, plugin_id: str) -> dict[str, Any]:
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)
@_serialized_plugin
def start_plugin(self, plugin_id: str) -> dict[str, Any]:
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)
@_serialized_plugin
def stop_plugin(self, plugin_id: str) -> dict[str, Any]:
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)
@_serialized_plugin
def restart_plugin(self, plugin_id: str) -> dict[str, Any]:
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,
}
@_serialized_plugin
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)
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 get_runtime(self, plugin_id: str) -> dict[str, Any]:
"""Return plugin runtime snapshot from data/runtime.json for UIs."""
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:
runtime = self.storage.read_runtime(plugin_id)
except ValueError as exc:
raise PluginManagerError(str(exc), 500) from exc
path = self.storage.runtime_path(plugin_id)
return {
"id": plugin_id,
"path": str(path),
"exists": path.is_file(),
"runtime": runtime,
}
@_serialized_plugin
def uninstall(self, plugin_id: str, *, delete_data: 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)
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,
}
# Schema 2 carries the openHop-approved version directly. Schema 1
# remains readable for compatibility with custom legacy catalogues.
latest_version = entry.version
update_available = False
if entry.has_approved_wheel:
if installed and installed_version and latest_version:
update_available = self._version_gt(latest_version, installed_version)
else:
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}
@_serialized_plugin
@_reported("install")
def install_from_catalogue(
self,
plugin_id: str,
*,
version: Optional[str] = None,
force_refresh: bool = False,
) -> dict[str, Any]:
"""Download an approved catalogue wheel and install it."""
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:
if entry.has_approved_wheel:
if version and normalize_version(version) != normalize_version(entry.version or ""):
raise PluginManagerError(
f"version {version!r} is not approved for {entry.id}; "
f"approved version is {entry.version}",
400,
)
try:
wheel_path = self.catalogue.download_wheel(entry, staging)
except CatalogueError as exc:
raise PluginManagerError(str(exc), int(exc.code)) from exc
else:
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,
)
if entry.has_approved_wheel and manifest.version != entry.version:
raise PluginManagerError(
f"manifest version {manifest.version!r} does not match approved "
f"catalogue version {entry.version!r}",
400,
)
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 state.get("source") == "catalogue":
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
if entry.has_approved_wheel:
latest_version = entry.version
update_available = bool(
latest_version
and installed_version
and self._version_gt(latest_version, installed_version)
)
return {
"id": plugin_id,
"installedVersion": installed_version or None,
"latestVersion": latest_version,
"updateAvailable": update_available,
"repository": entry.repository,
"releaseNotes": None,
"releaseUrl": None,
"releaseTag": None,
}
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,
}
@_serialized_plugin
@_reported("update")
def update_plugin(
self,
plugin_id: str,
*,
version: Optional[str] = None,
force_refresh: bool = False,
) -> dict[str, Any]:
"""Install the newer approved catalogue version when available."""
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))
approved_entry = None
release = None
if state.get("source") == "catalogue":
try:
candidate = self.catalogue.get_plugin(plugin_id, force_refresh=force_refresh)
except CatalogueError as exc:
raise PluginManagerError(str(exc), int(exc.code)) from exc
if candidate.has_approved_wheel:
approved_entry = candidate
if version and normalize_version(version) != normalize_version(
approved_entry.version or ""
):
raise PluginManagerError(
f"version {version!r} is not approved for {plugin_id}; "
f"approved version is {approved_entry.version}",
400,
)
if installed_version and not self._version_gt(
approved_entry.version or "", installed_version
):
status = self.status(plugin_id)
status["updateAvailable"] = False
status["latestVersion"] = approved_entry.version
status["updated"] = False
return status
if approved_entry is None:
# Legacy schema-1 catalogues continue to resolve GitHub Releases.
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
):
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:
if approved_entry is not None:
try:
wheel_path = self.catalogue.download_wheel(approved_entry, staging)
except CatalogueError as exc:
raise PluginManagerError(str(exc), int(exc.code)) from exc
target_version = approved_entry.version
else:
if release is None:
raise PluginManagerError("could not resolve plugin release", 500)
try:
wheel_path = self.github.download_wheel(release, staging)
except GitHubReleasesError as exc:
raise PluginManagerError(str(exc), int(exc.code)) from exc
target_version = release.version
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,
)
if approved_entry is not None and manifest.version != approved_entry.version:
raise PluginManagerError(
f"manifest version {manifest.version!r} does not match approved "
f"catalogue version {approved_entry.version!r}",
400,
)
# 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"] = target_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