diff options
| author | Christian Kolset <ckolset@colostate.edu> | 2026-07-29 14:21:40 -0600 |
|---|---|---|
| committer | Christian Kolset <ckolset@colostate.edu> | 2026-07-29 14:21:40 -0600 |
| commit | c98885f503a8a2eb4b691b41404231da8d2aeda5 (patch) | |
| tree | c48e40b1357e2857a98b4b28293aca643b688bb6 | |
| parent | 1d66acf29dd88acce5f3f7c5fee0c305147173a2 (diff) | |
| parent | 2298f779fdf7d828c1a84ca933b4eee9e8103212 (diff) | |
Merge branch 'feat/plugin-pip-install-prompt'
| -rw-r--r-- | plugins/plugin_manager.py | 40 | ||||
| -rw-r--r-- | ui/main_window.py | 49 | ||||
| -rw-r--r-- | ui/windows/settings_window.py | 12 |
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 ─────────────────────────────────────────────────────────── |
