diff --git a/packages/plugin_sdk/hms_plugin_sdk/__init__.py b/packages/plugin_sdk/hms_plugin_sdk/__init__.py index bf234d6..24210ff 100644 --- a/packages/plugin_sdk/hms_plugin_sdk/__init__.py +++ b/packages/plugin_sdk/hms_plugin_sdk/__init__.py @@ -1,5 +1,11 @@ -"""hms_plugin_sdk – Plugin-API, Manifest, Validierung (PLAN.md §14).""" +"""hms_plugin_sdk – Plugin-API, Manifest, Validierung, Lifecycle (PLAN.md §14).""" +from hms_plugin_sdk.lifecycle import ( + InvalidTransitionError, + LifecycleState, + PluginLifecycleManager, + PluginRecord, +) from hms_plugin_sdk.manifest import ( PluginKind, load_manifest, @@ -12,4 +18,8 @@ __all__ = [ "load_manifest", "validate_manifest", "validate_plugin_zip", + "LifecycleState", + "PluginRecord", + "PluginLifecycleManager", + "InvalidTransitionError", ] diff --git a/packages/plugin_sdk/hms_plugin_sdk/lifecycle.py b/packages/plugin_sdk/hms_plugin_sdk/lifecycle.py new file mode 100644 index 0000000..24e6873 --- /dev/null +++ b/packages/plugin_sdk/hms_plugin_sdk/lifecycle.py @@ -0,0 +1,227 @@ +"""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 diff --git a/tests/unit/test_plugin_lifecycle.py b/tests/unit/test_plugin_lifecycle.py new file mode 100644 index 0000000..85a4f40 --- /dev/null +++ b/tests/unit/test_plugin_lifecycle.py @@ -0,0 +1,228 @@ +"""Unit-Tests Plugin-Lifecycle (PLAN.md §14.5, §14.6, §26.3, §29.2).""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from hms_plugin_sdk import ( + InvalidTransitionError, + LifecycleState, + PluginLifecycleManager, +) + +REPO = Path(__file__).resolve().parents[2] +EXAMPLES = REPO / "plugins" / "examples" + + +def _valid_plugin_dir(tmp_path: Path) -> Path: + """Kopiert das gültige Passthrough-Beispiel in ein Test-Verzeichnis.""" + target = tmp_path / "plugins" + target.mkdir() + for item in EXAMPLES.rglob("*"): + if item.is_file(): + rel = item.relative_to(EXAMPLES) + dest = target / rel + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(item.read_bytes()) + return target + + +# ---------- Discovery & Validierung (§14.5, §29.2) ---------- + + +def test_discover_validates_example_plugins(tmp_path: Path) -> None: + """Externe Beispielplugins werden ohne Kernänderung entdeckt und + validiert (§29.2 / Gate 3 Vorbereitung).""" + plugin_dir = _valid_plugin_dir(tmp_path) + mgr = PluginLifecycleManager() + errors = mgr.discover(plugin_dir) + assert errors == [] + by_id = {r.plugin_id: r for r in mgr.all()} + assert "com.hms.fx.example_passthrough" in by_id + assert "com.hms.fx.gaussian_blur" in by_id + assert all(r.state is LifecycleState.VALIDATED for r in by_id.values()) + + +def test_discover_quarantines_invalid_manifest(tmp_path: Path) -> None: + """Ungültiges Manifest → QUARANTINED mit dokumentierter Ursache (§3.5).""" + plugin_dir = _valid_plugin_dir(tmp_path) + broken = plugin_dir / "com.broken.plugin" + broken.mkdir() + (broken / "plugin.json").write_text( + json.dumps({"schema_version": 99, "id": "com.broken.plugin"}), + encoding="utf-8", + ) + mgr = PluginLifecycleManager() + errors = mgr.discover(plugin_dir) + record = mgr.get("com.broken.plugin") + assert record is not None + assert record.state is LifecycleState.QUARANTINED + assert record.last_error # Ursache sichtbar, nicht geschluckt (§33) + assert any("com.broken.plugin" in e for e in errors) + # die gültigen Plugins bleiben unbeeinflusst + assert mgr.get("com.hms.fx.example_passthrough").state is LifecycleState.VALIDATED + + +def test_discover_missing_dir_reports_error(tmp_path: Path) -> None: + mgr = PluginLifecycleManager() + errors = mgr.discover(tmp_path / "gibts-nicht") + assert errors and "fehlt" in errors[0] + + +# ---------- Übergänge (§14.5) ---------- + + +@pytest.fixture() +def manager_with_plugin(tmp_path: Path) -> PluginLifecycleManager: + mgr = PluginLifecycleManager() + mgr.discover(_valid_plugin_dir(tmp_path)) + return mgr + + +def _advance_to( + mgr: PluginLifecycleManager, pid: str, *states: LifecycleState +) -> None: + """Führt ein Plugin schrittweise durch die angegebenen Zustände.""" + for state in states: + mgr.advance(pid, state) + + +_ACTIVE_CHAIN = ( + LifecycleState.INSTALLED, + LifecycleState.ENABLED, + LifecycleState.COMPILED, + LifecycleState.ACTIVE, +) + + +def test_full_lifecycle_happy_path(manager_with_plugin: PluginLifecycleManager) -> None: + """discovered→validated→installed→enabled→compiled→active (§14.5).""" + mgr = manager_with_plugin + pid = "com.hms.fx.example_passthrough" + _advance_to(mgr, pid, *_ACTIVE_CHAIN) + record = mgr.get(pid) + assert record.state is LifecycleState.ACTIVE + assert record.last_error is None + assert mgr.active_plugins() == [record] + + +def test_skip_transition_rejected(manager_with_plugin: PluginLifecycleManager) -> None: + """Sprünge im Graph sind Fehler: validated → active illegal.""" + mgr = manager_with_plugin + with pytest.raises(InvalidTransitionError, match="nicht erlaubt"): + mgr.advance("com.hms.fx.example_passthrough", LifecycleState.ACTIVE) + + +def test_backward_transition_rejected(manager_with_plugin: PluginLifecycleManager) -> None: + """Rücksprünge sind ebenfalls illegal (active → enabled).""" + mgr = manager_with_plugin + pid = "com.hms.fx.example_passthrough" + _advance_to(mgr, pid, *_ACTIVE_CHAIN) + with pytest.raises(InvalidTransitionError): + mgr.advance(pid, LifecycleState.ENABLED) + + +def test_disable_and_reenable_cycle(manager_with_plugin: PluginLifecycleManager) -> None: + """active → disabled → enabled: Live-Betrieb bleibt steuerbar (§17.7).""" + mgr = manager_with_plugin + pid = "com.hms.fx.example_passthrough" + _advance_to(mgr, pid, *_ACTIVE_CHAIN) + mgr.advance(pid, LifecycleState.DISABLED) + mgr.advance(pid, LifecycleState.ENABLED) # Re-Enable: Rücksprung in Betrieb + assert mgr.get(pid).state is LifecycleState.ENABLED + + +def test_unknown_plugin_rejected() -> None: + mgr = PluginLifecycleManager() + with pytest.raises(KeyError): + mgr.advance("gibts-nicht", LifecycleState.INSTALLED) + + +def test_quarantine_from_active_documents_reason( + manager_with_plugin: PluginLifecycleManager, +) -> None: + """Fehlerfall im Betrieb: Quarantäne mit Ursache, Projekt bleibt + nutzbar (§3.5, §12.2 Bypass).""" + mgr = manager_with_plugin + pid = "com.hms.fx.gaussian_blur" + _advance_to(mgr, pid, *_ACTIVE_CHAIN) + mgr.quarantine(pid, "shader compile failed") + record = mgr.get(pid) + assert record.state is LifecycleState.QUARANTINED + assert record.last_error == "shader compile failed" + assert mgr.by_state(LifecycleState.QUARANTINED) == [record] + # quarantined hat keine Folgezustände: Neustart nur über Neuinstallation + with pytest.raises(InvalidTransitionError): + mgr.advance(pid, LifecycleState.ENABLED) + + +# ---------- Show-Lock (§26.3) ---------- + + +def test_show_lock_blocks_installation(manager_with_plugin: PluginLifecycleManager) -> None: + """§26.3: Plugininstallation im Show-Lock gesperrt.""" + mgr = manager_with_plugin + mgr.set_show_lock(True) + with pytest.raises(InvalidTransitionError, match="Show-Lock"): + mgr.advance("com.hms.fx.example_passthrough", LifecycleState.INSTALLED) + + +def test_show_lock_allows_enable_of_installed(manager_with_plugin: PluginLifecycleManager) -> None: + """§17.7/§26.3: Bereits installiertes Plugin darf im Live-Betrieb + aktiviert werden (Layerparameter und Livefunktionen).""" + mgr = manager_with_plugin + pid = "com.hms.fx.example_passthrough" + mgr.advance(pid, LifecycleState.INSTALLED) + mgr.set_show_lock(True) + _advance_to( + mgr, + pid, + LifecycleState.ENABLED, + LifecycleState.COMPILED, + LifecycleState.ACTIVE, + ) + assert mgr.get(pid).state is LifecycleState.ACTIVE + + +def test_show_lock_blocks_version_update(tmp_path: Path) -> None: + """§26.3: Versionswechsel (= Update) im Show-Lock gesperrt.""" + plugin_dir = _valid_plugin_dir(tmp_path) + mgr = PluginLifecycleManager() + mgr.discover(plugin_dir) + mgr.set_show_lock(True) + # gleiche Plugin-ID mit neuer Version einschleusen + manifest_path = plugin_dir / "com.hms.fx.example_passthrough" / "plugin.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["version"] = "2.0.0" + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + with pytest.raises(InvalidTransitionError, match="Versionswechsel"): + mgr.discover(plugin_dir) + + +def test_version_update_without_lock_starts_new_cycle(tmp_path: Path) -> None: + """Update ohne Show-Lock: Plugin startet einen neuen Zyklus in DISCOVERED.""" + plugin_dir = _valid_plugin_dir(tmp_path) + mgr = PluginLifecycleManager() + mgr.discover(plugin_dir) + mgr.advance("com.hms.fx.example_passthrough", LifecycleState.INSTALLED) + manifest_path = plugin_dir / "com.hms.fx.example_passthrough" / "plugin.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["version"] = "2.0.0" + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + mgr.discover(plugin_dir) # Re-Scan erkennt neue Version + record = mgr.get("com.hms.fx.example_passthrough") + assert record.version == "2.0.0" + assert record.state is LifecycleState.VALIDATED # neuer Zyklus + + +# ---------- Backend-Kompatibilität (§14.5, §12.6) ---------- + + +def test_compatible_backends_intersection(manager_with_plugin: PluginLifecycleManager) -> None: + """Nur Backends mit Entrypoint UND supported_backends gelten (§12.6).""" + mgr = manager_with_plugin + backends = mgr.compatible_backends("com.hms.fx.example_passthrough") + assert backends == frozenset({"d3d11", "gl", "gles"}) + assert mgr.compatible_backends("gibts-nicht") == frozenset()