feat: Auto-install plugin dependencies when enabling plugins
NEW FEATURES:
1. Plugin Dependency Declaration (BasePlugin):
- Added 'dependencies' class attribute
- Format: {'pip': ['package1', 'package2'], 'optional': {...}}
- Plugins can declare required pip packages
2. Plugin Dependency Manager (core/plugin_dependency_manager.py):
- Checks if declared dependencies are installed
- Installs missing packages via pip
- Tracks installation status
- Shows progress dialog during installation
3. Settings Dialog Integration:
- When enabling a plugin with dependencies, shows dialog
- Lists missing dependencies
- Asks user if they want to install
- Shows progress bar during installation
- Handles installation failures gracefully
4. Example: Game Reader Test plugin:
- Declares dependencies: pillow, numpy
- Optional: easyocr, pytesseract, paddleocr
- When enabled, prompts to install if missing
WORKFLOW:
1. User enables a plugin in Settings → Plugins
2. System checks if plugin has dependencies
3. If dependencies missing, shows dialog
4. User clicks Yes to install
5. Progress dialog shows installation progress
6. Plugin loads after dependencies installed
This eliminates manual pip install steps for plugins!
This commit is contained in:
parent
6931c4b039
commit
706a5710a9
|
|
@ -1303,7 +1303,7 @@ class OverlayWindow(QMainWindow):
|
|||
return tab
|
||||
|
||||
def _save_settings(self, dialog):
|
||||
"""Save plugin settings."""
|
||||
"""Save plugin settings with dependency checking."""
|
||||
if not self.plugin_manager:
|
||||
dialog.accept()
|
||||
return
|
||||
|
|
@ -1312,13 +1312,79 @@ class OverlayWindow(QMainWindow):
|
|||
self._refresh_ui()
|
||||
self.setStyleSheet(get_global_stylesheet())
|
||||
|
||||
# Save plugin enable/disable
|
||||
# Check for newly enabled plugins with dependencies
|
||||
from core.plugin_dependency_manager import get_dependency_manager
|
||||
dep_manager = get_dependency_manager()
|
||||
|
||||
plugins_to_enable = []
|
||||
for plugin_id, cb in self.settings_checkboxes.items():
|
||||
if cb.isChecked():
|
||||
# Check if this is a newly enabled plugin
|
||||
if not self.plugin_manager.is_plugin_enabled(plugin_id):
|
||||
plugins_to_enable.append(plugin_id)
|
||||
self.plugin_manager.enable_plugin(plugin_id)
|
||||
else:
|
||||
self.plugin_manager.disable_plugin(plugin_id)
|
||||
|
||||
# Check dependencies for newly enabled plugins
|
||||
for plugin_id in plugins_to_enable:
|
||||
# Get plugin class
|
||||
all_plugins = self.plugin_manager.get_all_discovered_plugins()
|
||||
if plugin_id in all_plugins:
|
||||
plugin_class = all_plugins[plugin_id]
|
||||
if dep_manager.has_dependencies(plugin_class):
|
||||
installed, missing = dep_manager.check_all_dependencies(plugin_class)
|
||||
if missing:
|
||||
# Show dependency dialog
|
||||
dep_text = dep_manager.get_missing_dependencies_text(plugin_class)
|
||||
from PyQt6.QtWidgets import QMessageBox
|
||||
|
||||
msg_box = QMessageBox(self)
|
||||
msg_box.setWindowTitle("Install Dependencies?")
|
||||
msg_box.setText(f"Plugin '{plugin_class.name}' requires additional dependencies:\n\n{dep_text}")
|
||||
msg_box.setInformativeText("Would you like to install them now?")
|
||||
msg_box.setStandardButtons(
|
||||
QMessageBox.StandardButton.Yes |
|
||||
QMessageBox.StandardButton.No
|
||||
)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.Yes)
|
||||
|
||||
reply = msg_box.exec()
|
||||
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
# Install dependencies
|
||||
from PyQt6.QtWidgets import QProgressDialog
|
||||
from PyQt6.QtCore import Qt
|
||||
|
||||
progress = QProgressDialog(
|
||||
f"Installing dependencies for {plugin_class.name}...",
|
||||
"Cancel",
|
||||
0,
|
||||
len(missing),
|
||||
self
|
||||
)
|
||||
progress.setWindowModality(Qt.WindowModality.WindowModal)
|
||||
progress.setWindowTitle("Installing Dependencies")
|
||||
|
||||
current = 0
|
||||
for dep in missing:
|
||||
if progress.wasCanceled():
|
||||
break
|
||||
|
||||
progress.setLabelText(f"Installing {dep.name}...")
|
||||
progress.setValue(current)
|
||||
|
||||
success, msg = dep_manager.install_dependency(dep.name)
|
||||
if not success:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Installation Failed",
|
||||
f"Failed to install {dep.name}:\n{msg}"
|
||||
)
|
||||
current += 1
|
||||
|
||||
progress.setValue(len(missing))
|
||||
|
||||
# Reload plugins
|
||||
self._reload_plugins()
|
||||
dialog.accept()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,185 @@
|
|||
"""
|
||||
EU-Utility - Plugin Dependency Manager
|
||||
|
||||
Manages plugin dependencies and auto-installs them when needed.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import subprocess
|
||||
import importlib
|
||||
from typing import Dict, List, Tuple, Optional, Any
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
|
||||
@dataclass
|
||||
class DependencyCheck:
|
||||
"""Result of a dependency check."""
|
||||
name: str
|
||||
required: bool
|
||||
installed: bool
|
||||
version: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class PluginDependencyManager:
|
||||
"""Manages plugin dependencies with auto-install capability."""
|
||||
|
||||
def __init__(self, plugin_manager=None):
|
||||
self.plugin_manager = plugin_manager
|
||||
self.install_status_file = Path("config/dependency_installs.json")
|
||||
self._install_status = self._load_install_status()
|
||||
|
||||
def _load_install_status(self) -> Dict:
|
||||
"""Load status of previously installed dependencies."""
|
||||
if self.install_status_file.exists():
|
||||
try:
|
||||
with open(self.install_status_file, 'r') as f:
|
||||
return json.load(f)
|
||||
except:
|
||||
pass
|
||||
return {}
|
||||
|
||||
def _save_install_status(self):
|
||||
"""Save status of installed dependencies."""
|
||||
try:
|
||||
self.install_status_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.install_status_file, 'w') as f:
|
||||
json.dump(self._install_status, f, indent=2)
|
||||
except Exception as e:
|
||||
print(f"[DependencyManager] Failed to save status: {e}")
|
||||
|
||||
def check_dependency(self, package_name: str) -> DependencyCheck:
|
||||
"""Check if a pip package is installed."""
|
||||
# Handle version specifiers (e.g., "package>=1.0")
|
||||
base_name = package_name.split('[')[0].split('>')[0].split('<')[0].split('=')[0].strip()
|
||||
|
||||
try:
|
||||
module = importlib.import_module(base_name.replace('-', '_'))
|
||||
version = getattr(module, '__version__', 'unknown')
|
||||
return DependencyCheck(
|
||||
name=package_name,
|
||||
required=True,
|
||||
installed=True,
|
||||
version=version
|
||||
)
|
||||
except ImportError:
|
||||
return DependencyCheck(
|
||||
name=package_name,
|
||||
required=True,
|
||||
installed=False
|
||||
)
|
||||
except Exception as e:
|
||||
return DependencyCheck(
|
||||
name=package_name,
|
||||
required=True,
|
||||
installed=False,
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
def check_all_dependencies(self, plugin_class) -> Tuple[List[DependencyCheck], List[DependencyCheck]]:
|
||||
"""Check all dependencies for a plugin.
|
||||
|
||||
Returns:
|
||||
Tuple of (installed_deps, missing_deps)
|
||||
"""
|
||||
deps = getattr(plugin_class, 'dependencies', {})
|
||||
pip_deps = deps.get('pip', [])
|
||||
|
||||
installed = []
|
||||
missing = []
|
||||
|
||||
for dep in pip_deps:
|
||||
check = self.check_dependency(dep)
|
||||
if check.installed:
|
||||
installed.append(check)
|
||||
else:
|
||||
missing.append(check)
|
||||
|
||||
return installed, missing
|
||||
|
||||
def install_dependency(self, package_name: str) -> Tuple[bool, str]:
|
||||
"""Install a pip package.
|
||||
|
||||
Returns:
|
||||
(success, message)
|
||||
"""
|
||||
try:
|
||||
print(f"[DependencyManager] Installing {package_name}...")
|
||||
result = subprocess.run(
|
||||
[sys.executable, '-m', 'pip', 'install', package_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
self._install_status[package_name] = {
|
||||
'installed': True,
|
||||
'output': result.stdout[-500:] if len(result.stdout) > 500 else result.stdout
|
||||
}
|
||||
self._save_install_status()
|
||||
return True, f"Successfully installed {package_name}"
|
||||
else:
|
||||
return False, f"Failed to install {package_name}: {result.stderr}"
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, f"Installation of {package_name} timed out"
|
||||
except Exception as e:
|
||||
return False, f"Error installing {package_name}: {e}"
|
||||
|
||||
def install_all_missing(self, plugin_class, progress_callback=None) -> Tuple[bool, List[str]]:
|
||||
"""Install all missing dependencies for a plugin.
|
||||
|
||||
Returns:
|
||||
(all_successful, messages)
|
||||
"""
|
||||
_, missing = self.check_all_dependencies(plugin_class)
|
||||
|
||||
if not missing:
|
||||
return True, ["All dependencies already installed"]
|
||||
|
||||
messages = []
|
||||
all_success = True
|
||||
|
||||
for dep in missing:
|
||||
if progress_callback:
|
||||
progress_callback(f"Installing {dep.name}...")
|
||||
|
||||
success, msg = self.install_dependency(dep.name)
|
||||
messages.append(msg)
|
||||
|
||||
if not success:
|
||||
all_success = False
|
||||
|
||||
return all_success, messages
|
||||
|
||||
def get_missing_dependencies_text(self, plugin_class) -> str:
|
||||
"""Get a formatted text of missing dependencies."""
|
||||
_, missing = self.check_all_dependencies(plugin_class)
|
||||
|
||||
if not missing:
|
||||
return "All dependencies are installed."
|
||||
|
||||
lines = [f"Missing dependencies ({len(missing)}):"]
|
||||
for dep in missing:
|
||||
lines.append(f" • {dep.name}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def has_dependencies(self, plugin_class) -> bool:
|
||||
"""Check if a plugin has declared dependencies."""
|
||||
deps = getattr(plugin_class, 'dependencies', {})
|
||||
return bool(deps.get('pip', []))
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_dep_manager = None
|
||||
|
||||
def get_dependency_manager(plugin_manager=None) -> PluginDependencyManager:
|
||||
"""Get the plugin dependency manager singleton."""
|
||||
global _dep_manager
|
||||
if _dep_manager is None:
|
||||
_dep_manager = PluginDependencyManager(plugin_manager)
|
||||
return _dep_manager
|
||||
|
|
@ -28,6 +28,10 @@ class BasePlugin(ABC):
|
|||
hotkey: Optional[str] = None # e.g., "ctrl+shift+n"
|
||||
enabled: bool = True
|
||||
|
||||
# Dependencies - override in subclass
|
||||
# Format: {'pip': ['package1', 'package2>=1.0'], 'optional': {'package3': 'description'}}
|
||||
dependencies: Dict[str, Any] = {}
|
||||
|
||||
def __init__(self, overlay_window: 'OverlayWindow', config: Dict[str, Any]):
|
||||
self.overlay = overlay_window
|
||||
self.config = config
|
||||
|
|
|
|||
|
|
@ -133,6 +133,16 @@ class GameReaderTestPlugin(BasePlugin):
|
|||
author = "EU-Utility"
|
||||
description = "Debug tool for testing OCR and screen reading"
|
||||
|
||||
# Dependencies for OCR functionality
|
||||
dependencies = {
|
||||
'pip': ['pillow', 'numpy'],
|
||||
'optional': {
|
||||
'easyocr': 'Best OCR accuracy, auto-downloads models',
|
||||
'pytesseract': 'Alternative OCR engine',
|
||||
'paddleocr': 'Advanced OCR with layout detection'
|
||||
}
|
||||
}
|
||||
|
||||
def __init__(self, overlay_window, config):
|
||||
super().__init__(overlay_window, config)
|
||||
self.test_history = []
|
||||
|
|
|
|||
Loading…
Reference in New Issue