b4c4f214d3
- Zustandsgraph discovered->validated->installed->enabled->compiled->active mit quarantined/incompatible/disabled; nur definierte Kanten - discover(): scannt Plugin-Verzeichnis, validiert Manifest + Shader, quarantiniert Ungueltiges mit dokumentierter Ursache (kein stiller Fehler, §33); Projekt bleibt nutzbar (§3.5) - Show-Lock (§26.3): Installation/Update gesperrt; Aktivieren bereits installierter Plugins erlaubt (§17.7 Live); Versionswechsel geblockt - Update ohne Lock: neuer Zyklus ab VALIDATED - Backend-Kompatibilitaet: Schnittmenge entrypoints & supported_backends - 14 Unit-Tests (Discovery, Happy Path, Sprung-/Ruecksprung-Verbot, Quarantaene mit Ursache, Show-Lock-Blockaden, Versionsupdate, Backends) - Gesamtsuite 311 gruen, Ruff gruen
228 lines
8.6 KiB
Python
228 lines
8.6 KiB
Python
"""Plugin-Lifecycle-Manager (PLAN.md §14.5, §14.6, §26.3).
|
||
|
||
Lebenszyklus:
|
||
|
||
discovered → validated → installed → enabled → compiled → active
|
||
↘ quarantined / incompatible
|
||
|
||
Regeln:
|
||
- Übergänge nur entlang definierter Kanten; Sprünge sind Fehler
|
||
- Validierung umfasst Manifest-Schema, API-Kompatibilität,
|
||
Pfadsicherheit, Backend-/Shader-Prüfung (§14.5)
|
||
- ein fehlerhaftes Plugin wird quarantiniert, ohne ein Projekt unbrauchbar
|
||
zu machen (§3.5); der Effekt wird überbrückt (bypass_on_error)
|
||
- Show-Lock (§26.3): Installieren/Updaten ist gesperrt; Enable/Disable
|
||
und Parameter bleiben erlaubt (§17.7 Live-Modus)
|
||
- Doppelte Plugin-IDs sind Fehler, keine stillen Überschreibungen
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass, field
|
||
from enum import StrEnum
|
||
from pathlib import Path
|
||
|
||
from hms_plugin_sdk.manifest import load_manifest
|
||
|
||
|
||
class LifecycleState(StrEnum):
|
||
"""Zustände gemäß §14.5."""
|
||
|
||
DISCOVERED = "discovered"
|
||
VALIDATED = "validated"
|
||
INSTALLED = "installed"
|
||
ENABLED = "enabled"
|
||
COMPILED = "compiled"
|
||
ACTIVE = "active"
|
||
QUARANTINED = "quarantined"
|
||
INCOMPATIBLE = "incompatible"
|
||
DISABLED = "disabled"
|
||
|
||
|
||
# Erlaubte Übergänge (§14.5-Lebenszyklusgraph)
|
||
_TRANSITIONS: dict[LifecycleState, frozenset[LifecycleState]] = {
|
||
LifecycleState.DISCOVERED: frozenset(
|
||
{LifecycleState.VALIDATED, LifecycleState.INCOMPATIBLE, LifecycleState.QUARANTINED}
|
||
),
|
||
LifecycleState.VALIDATED: frozenset(
|
||
{LifecycleState.INSTALLED, LifecycleState.INCOMPATIBLE, LifecycleState.QUARANTINED}
|
||
),
|
||
LifecycleState.INSTALLED: frozenset(
|
||
{LifecycleState.ENABLED, LifecycleState.DISABLED, LifecycleState.QUARANTINED}
|
||
),
|
||
LifecycleState.ENABLED: frozenset(
|
||
{LifecycleState.COMPILED, LifecycleState.DISABLED, LifecycleState.QUARANTINED}
|
||
),
|
||
LifecycleState.COMPILED: frozenset(
|
||
{LifecycleState.ACTIVE, LifecycleState.QUARANTINED, LifecycleState.DISABLED}
|
||
),
|
||
LifecycleState.ACTIVE: frozenset(
|
||
{LifecycleState.DISABLED, LifecycleState.QUARANTINED}
|
||
),
|
||
LifecycleState.DISABLED: frozenset(
|
||
{LifecycleState.ENABLED, LifecycleState.QUARANTINED}
|
||
),
|
||
LifecycleState.QUARANTINED: frozenset(), # manuelle Entfernung/Neuinstallation
|
||
LifecycleState.INCOMPATIBLE: frozenset(),
|
||
}
|
||
|
||
|
||
class InvalidTransitionError(Exception):
|
||
"""Unerlaubter Zustandsübergang im Lebenszyklus."""
|
||
|
||
|
||
@dataclass
|
||
class PluginRecord:
|
||
"""Ein Plugin im Lifecycle-Manager."""
|
||
|
||
plugin_id: str
|
||
version: str
|
||
state: LifecycleState = LifecycleState.DISCOVERED
|
||
package_hash: str | None = None
|
||
last_error: str | None = None
|
||
manifest: dict = field(default_factory=dict)
|
||
|
||
|
||
class PluginLifecycleManager:
|
||
"""Verwaltet den Lebenszyklus aller installierten Plugins (§14.5).
|
||
|
||
- discover(): Verzeichnis scannen, Manifest laden, Manifest-Validierung
|
||
- advance(): zustandsgeprüfter Übergang
|
||
- quarantine(): Fehlerfall mit Ursache (§3.5, §14.5)
|
||
- Show-Lock: install_validate/enable_new blockiert Strukturänderungen
|
||
(§26.3); Aktivieren bereits installierter Plugins bleibt erlaubt
|
||
"""
|
||
|
||
def __init__(self, show_lock: bool = False) -> None:
|
||
self._plugins: dict[str, PluginRecord] = {}
|
||
self._show_lock = show_lock
|
||
|
||
@property
|
||
def show_lock(self) -> bool:
|
||
return self._show_lock
|
||
|
||
def set_show_lock(self, enabled: bool) -> None:
|
||
"""§26.3: Show-Lock verhindert Plugininstallation/-update."""
|
||
self._show_lock = enabled
|
||
|
||
# ---------- Discovery & Validierung (§14.5) ----------
|
||
|
||
def discover(self, plugin_dir: Path) -> list[str]:
|
||
"""Scannt ein Verzeichnis mit Plugin-Ordnern.
|
||
|
||
Lädt plugin.json, validiert es (inkl. Shader-Existenz) und
|
||
überführt jedes Plugin in VALIDATED oder INCOMPATIBLE/QUARANTINED.
|
||
Rückgabe: Liste diagnostizierter Fehler (leer = alles sauber).
|
||
"""
|
||
errors: list[str] = []
|
||
if not plugin_dir.is_dir():
|
||
return [f"Plugin-Verzeichnis fehlt: {plugin_dir}"]
|
||
for child in sorted(plugin_dir.iterdir()):
|
||
if not child.is_dir():
|
||
continue
|
||
manifest_path = child / "plugin.json"
|
||
if not manifest_path.is_file():
|
||
continue # kein Plugin-Ordner (z. B. .git)
|
||
manifest, validation_errors = load_manifest(child)
|
||
if validation_errors:
|
||
pid = manifest.get("id") or child.name
|
||
errors.extend(f"{pid}: {e}" for e in validation_errors)
|
||
record = PluginRecord(
|
||
plugin_id=pid,
|
||
version=str(manifest.get("version", "0.0.0")),
|
||
state=LifecycleState.QUARANTINED,
|
||
last_error="; ".join(validation_errors),
|
||
manifest=manifest,
|
||
)
|
||
self._upsert(record)
|
||
continue
|
||
pid = manifest["id"]
|
||
record = PluginRecord(
|
||
plugin_id=pid,
|
||
version=manifest["version"],
|
||
state=LifecycleState.VALIDATED,
|
||
manifest=manifest,
|
||
)
|
||
self._upsert(record)
|
||
return errors
|
||
|
||
# ---------- Übergänge (§14.5) ----------
|
||
|
||
def advance(self, plugin_id: str, new_state: LifecycleState) -> LifecycleState:
|
||
"""Führt einen Lifecycle-Übergang aus; wirft bei illegaler Kante.
|
||
|
||
Show-Lock (§26.3): Übergänge, die Installation/Update bedeuten
|
||
(INSTALLED von DISCOVERED/VALIDATED), sind gesperrt. Aktivieren
|
||
bereits installierter Plugins (ENABLED/COMPILED/ACTIVE) bleibt
|
||
erlaubt (§17.7 Live: Layerparameter und Livefunktionen nutzbar).
|
||
"""
|
||
record = self._plugins.get(plugin_id)
|
||
if record is None:
|
||
raise KeyError(f"unbekanntes Plugin {plugin_id!r}")
|
||
current = record.state
|
||
if new_state not in _TRANSITIONS[current]:
|
||
raise InvalidTransitionError(
|
||
f"{plugin_id}: Übergang {current.value} → {new_state.value} nicht erlaubt"
|
||
)
|
||
if (
|
||
self._show_lock
|
||
and new_state is LifecycleState.INSTALLED
|
||
and current in (LifecycleState.DISCOVERED, LifecycleState.VALIDATED)
|
||
):
|
||
raise InvalidTransitionError(
|
||
f"{plugin_id}: Installation im Show-Lock gesperrt (§26.3)"
|
||
)
|
||
record.state = new_state
|
||
record.last_error = None
|
||
return record.state
|
||
|
||
def quarantine(self, plugin_id: str, reason: str) -> None:
|
||
"""Fehlerfall: Plugin überbrücken, Projekt bleibt nutzbar (§3.5)."""
|
||
record = self._plugins.get(plugin_id)
|
||
if record is None:
|
||
raise KeyError(f"unbekanntes Plugin {plugin_id!r}")
|
||
record.state = LifecycleState.QUARANTINED
|
||
record.last_error = reason
|
||
|
||
# ---------- Abfragen ----------
|
||
|
||
def get(self, plugin_id: str) -> PluginRecord | None:
|
||
return self._plugins.get(plugin_id)
|
||
|
||
def by_state(self, state: LifecycleState) -> list[PluginRecord]:
|
||
return [r for r in self._plugins.values() if r.state is state]
|
||
|
||
def active_plugins(self) -> list[PluginRecord]:
|
||
return self.by_state(LifecycleState.ACTIVE)
|
||
|
||
def all(self) -> list[PluginRecord]:
|
||
return list(self._plugins.values())
|
||
|
||
def compatible_backends(self, plugin_id: str) -> frozenset[str]:
|
||
"""Gemeinsame Backends: deklariert im Manifest und lokal verfügbar."""
|
||
record = self._plugins.get(plugin_id)
|
||
if record is None:
|
||
return frozenset()
|
||
declared = set(record.manifest.get("entrypoints", {}))
|
||
supported = set(
|
||
record.manifest.get("capabilities", {}).get("supported_backends", [])
|
||
)
|
||
return frozenset(declared & supported)
|
||
|
||
# ---------- Interna ----------
|
||
|
||
def _upsert(self, record: PluginRecord) -> None:
|
||
existing = self._plugins.get(record.plugin_id)
|
||
if existing is not None and existing.version != record.version:
|
||
# Versionskonflikt: neuer Stand gewinnt nur ohne Show-Lock.
|
||
# Ein Update startet einen neuen Zyklus: discover() hat das
|
||
# neue Manifest bereits validiert (§14.5), also ist VALIDATED
|
||
# der korrekte Zustand – eine erneute Installation ist nötig,
|
||
# der alte INSTALLED/ACTIVE-Status gilt nicht mehr.
|
||
if self._show_lock:
|
||
raise InvalidTransitionError(
|
||
f"{record.plugin_id}: Versionswechsel {existing.version} → "
|
||
f"{record.version} im Show-Lock gesperrt (§26.3)"
|
||
)
|
||
self._plugins[record.plugin_id] = record
|