summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--plugins/plugin_manager.py40
-rw-r--r--ui/main_window.py49
-rw-r--r--ui/windows/settings_window.py12
3 files changed, 99 insertions, 2 deletions
diff --git a/plugins/plugin_manager.py b/plugins/plugin_manager.py
index 36ae71a..5cefc2e 100644
--- a/plugins/plugin_manager.py
+++ b/plugins/plugin_manager.py
@@ -10,12 +10,14 @@ per-plugin state separately via get_save_state / apply_save_state.
from __future__ import annotations
+import importlib.metadata
import importlib.util
import json
import os
+import re
import sys
import traceback
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from typing import Dict, List, Optional
from plugins.base_plugin import LabPlugin, PluginContext
@@ -35,9 +37,35 @@ class PluginManifest:
description: str = ""
author: str = ""
entry_point: str = "plugin.Plugin" # "module.ClassName" relative to plugin dir
+ requires: List[str] = field(default_factory=list) # pip-style reqs, e.g. "opencv-python>=4.8.0"
plugin_dir: str = ""
+def _dist_name(requirement: str) -> str:
+ """Extract the distribution name from a requirement string, e.g.
+ "opencv-python>=4.8.0" -> "opencv-python"."""
+ return re.split(r"[<>=!~\[; ]", requirement.strip(), maxsplit=1)[0]
+
+
+def missing_requirements(requires: List[str]) -> List[str]:
+ """Return the subset of `requires` whose distribution isn't installed.
+
+ Checked by distribution name via importlib.metadata (matches what pip
+ installed it as), not by import name — those differ for packages like
+ opencv-python (imports as cv2) or pyserial (imports as serial).
+ """
+ missing = []
+ for req in requires:
+ name = _dist_name(req)
+ if not name:
+ continue
+ try:
+ importlib.metadata.version(name)
+ except importlib.metadata.PackageNotFoundError:
+ missing.append(req)
+ return missing
+
+
# ── Manager ───────────────────────────────────────────────────────────────────
class PluginManager:
@@ -94,6 +122,7 @@ class PluginManager:
description = data.get("description", ""),
author = data.get("author", ""),
entry_point = data.get("entry_point", "plugin.Plugin"),
+ requires = data.get("requires", []),
plugin_dir = plugin_dir,
)
self._manifests[m.plugin_id] = m
@@ -146,6 +175,11 @@ class PluginManager:
print(f"[PluginManager] No manifest for '{plugin_id}'")
return None
+ missing = missing_requirements(manifest.requires)
+ if missing:
+ print(f"[Plugin] '{plugin_id}' missing dependencies: {', '.join(missing)}")
+ return None
+
module_name, class_name = manifest.entry_point.rsplit(".", 1)
module_file = os.path.join(
manifest.plugin_dir, *module_name.split("/")
@@ -218,6 +252,10 @@ class PluginManager:
def get_manifests(self) -> List[PluginManifest]:
return list(self._manifests.values())
+ def get_missing_dependencies(self, plugin_id: str) -> List[str]:
+ manifest = self._manifests.get(plugin_id)
+ return missing_requirements(manifest.requires) if manifest else []
+
def get_loaded(self) -> List[LabPlugin]:
return list(self._loaded.values())
diff --git a/ui/main_window.py b/ui/main_window.py
index ca4274c..d585a83 100644
--- a/ui/main_window.py
+++ b/ui/main_window.py
@@ -303,11 +303,60 @@ class MainWindow(QMainWindow):
def plugin_enable(self, plugin_id: str):
"""Called by SettingsWindow when user enables a plugin."""
+ missing = self._plugin_mgr.get_missing_dependencies(plugin_id)
+ if missing:
+ from PyQt6.QtWidgets import QMessageBox
+ reply = QMessageBox.question(
+ self, "Missing Plugin Dependencies",
+ f"This plugin needs packages that aren't installed:\n\n"
+ f" {', '.join(missing)}\n\n"
+ f"Install them now with pip?",
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
+ QMessageBox.StandardButton.No,
+ )
+ if reply == QMessageBox.StandardButton.Yes:
+ if self._pip_install(missing):
+ self._enable_plugin_now(plugin_id)
+ else:
+ self._enable_plugin_now(plugin_id)
+ if self._win_settings:
+ self._win_settings.sync_plugin_button(plugin_id)
+
+ def _enable_plugin_now(self, plugin_id: str):
ctx = self._make_plugin_context()
plugin = self._plugin_mgr.enable(plugin_id, ctx)
if plugin:
self._install_plugin(plugin)
+ def _pip_install(self, requirements: list) -> bool:
+ """Blocking `pip install` of the given requirement strings.
+ Returns True on success; shows a result dialog either way."""
+ import subprocess
+ import sys
+ from PyQt6.QtWidgets import QMessageBox
+
+ QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
+ try:
+ result = subprocess.run(
+ [sys.executable, "-m", "pip", "install", *requirements],
+ capture_output=True, text=True,
+ )
+ finally:
+ QApplication.restoreOverrideCursor()
+
+ if result.returncode == 0:
+ QMessageBox.information(
+ self, "Install Complete",
+ f"Installed: {', '.join(requirements)}"
+ )
+ return True
+ QMessageBox.critical(
+ self, "Install Failed",
+ f"pip install failed for: {', '.join(requirements)}\n\n"
+ f"{result.stderr.strip()[-1500:]}"
+ )
+ return False
+
def plugin_disable(self, plugin_id: str):
"""Called by SettingsWindow when user disables a plugin."""
self._uninstall_plugin(plugin_id)
diff --git a/ui/windows/settings_window.py b/ui/windows/settings_window.py
index e24da7f..290d9e0 100644
--- a/ui/windows/settings_window.py
+++ b/ui/windows/settings_window.py
@@ -51,6 +51,7 @@ class SettingsWindow(QWidget):
self.registry = registry
self.engine = engine
self._plugin_mgr = plugin_manager
+ self._plugin_buttons: dict = {} # plugin_id -> QPushButton
self.cfg = dict(self._defaults)
if current:
self.cfg.update(current)
@@ -243,6 +244,7 @@ class SettingsWindow(QWidget):
toggle.clicked.connect(
lambda _, pid=manifest.plugin_id, btn=toggle: self._toggle_plugin(pid, btn)
)
+ self._plugin_buttons[manifest.plugin_id] = toggle
hdr.addWidget(toggle)
cl.addLayout(hdr)
@@ -271,8 +273,16 @@ class SettingsWindow(QWidget):
self.plugin_disable_requested.emit(plugin_id)
btn.setText("Enable")
else:
+ # Don't flip to "Disable" yet — enabling can fail (missing
+ # dependencies, bad plugin code). main_window confirms the
+ # real outcome via sync_plugin_button() once enable() returns.
self.plugin_enable_requested.emit(plugin_id)
- btn.setText("Disable")
+
+ def sync_plugin_button(self, plugin_id: str):
+ """Refresh one plugin's toggle button to match its actual enabled state."""
+ btn = self._plugin_buttons.get(plugin_id)
+ if btn is not None and self._plugin_mgr is not None:
+ btn.setText("Disable" if self._plugin_mgr.is_enabled(plugin_id) else "Enable")
# ── Actions ───────────────────────────────────────────────────────────