""" PluginMarketplace Plugin - Browse and Install Community Plugins Provides a browsable marketplace for discovering, installing, and managing community-contributed plugins for EU-Utility. """ import os import json import urllib.request import urllib.error import zipfile import shutil import hashlib from pathlib import Path from typing import Optional, Dict, Any, List, Callable from dataclasses import dataclass, asdict from enum import Enum from datetime import datetime from core.base_plugin import BasePlugin class PluginStatus(Enum): """Installation status of a marketplace plugin.""" NOT_INSTALLED = "not_installed" INSTALLED = "installed" UPDATE_AVAILABLE = "update_available" INSTALLING = "installing" ERROR = "error" @dataclass class MarketplacePlugin: """Represents a plugin available in the marketplace.""" id: str name: str description: str version: str author: str author_url: Optional[str] = None download_url: str = "" icon_url: Optional[str] = None screenshots: List[str] = None tags: List[str] = None category: str = "general" license: str = "MIT" downloads: int = 0 rating: float = 0.0 rating_count: int = 0 release_date: Optional[str] = None last_updated: Optional[str] = None dependencies: List[str] = None min_app_version: str = "1.0.0" checksum: str = "" def __post_init__(self): if self.screenshots is None: self.screenshots = [] if self.tags is None: self.tags = [] if self.dependencies is None: self.dependencies = [] @dataclass class InstalledPluginInfo: """Information about an installed plugin.""" id: str name: str version: str installed_version: str install_date: str install_path: str enabled: bool = True auto_update: bool = False class PluginMarketplacePlugin(BasePlugin): """ Plugin marketplace browser and installer. Features: - Browse plugins by category, rating, popularity - Search plugins by name, tag, or author - One-click install/uninstall - Automatic update checking for installed plugins - Plugin ratings and reviews - Dependency resolution """ name = "plugin_marketplace" description = "Browse and install community plugins" version = "1.0.0" author = "EU-Utility" DEFAULT_CONFIG = { "marketplace_url": "https://marketplace.eu-utility.app/api", "cache_duration_minutes": 60, "plugins_dir": "plugins", "temp_dir": "data/temp/marketplace", "auto_check_updates": True, "check_interval_hours": 24, } def __init__(self): super().__init__() self._config = self.DEFAULT_CONFIG.copy() self._cache: Dict[str, Any] = {} self._cache_timestamp: Optional[datetime] = None self._installed_plugins: Dict[str, InstalledPluginInfo] = {} self._listeners: List[Callable] = [] self._data_dir = Path("data") self._data_dir.mkdir(exist_ok=True) self._load_installed_plugins() def on_start(self) -> None: """Start the marketplace service.""" print(f"[{self.name}] Starting marketplace...") # Ensure directories exist Path(self._config["temp_dir"]).mkdir(parents=True, exist_ok=True) Path(self._config["plugins_dir"]).mkdir(exist_ok=True) # Check for updates to installed plugins if self._config["auto_check_updates"]: self.check_installed_updates() def on_stop(self) -> None: """Stop the marketplace service.""" print(f"[{self.name}] Stopping marketplace...") self._save_installed_plugins() # Cache Management def _is_cache_valid(self) -> bool: """Check if the cache is still valid.""" if not self._cache_timestamp: return False elapsed = (datetime.now() - self._cache_timestamp).total_seconds() / 60 return elapsed < self._config["cache_duration_minutes"] def _update_cache(self, data: Dict[str, Any]) -> None: """Update the cache with new data.""" self._cache = data self._cache_timestamp = datetime.now() def clear_cache(self) -> None: """Clear the marketplace cache.""" self._cache = {} self._cache_timestamp = None print(f"[{self.name}] Cache cleared") # API Methods def fetch_plugins(self, force_refresh: bool = False) -> List[MarketplacePlugin]: """ Fetch all plugins from the marketplace. Args: force_refresh: Ignore cache and fetch fresh data Returns: List of marketplace plugins """ if not force_refresh and self._is_cache_valid() and "plugins" in self._cache: return [MarketplacePlugin(**p) for p in self._cache["plugins"]] try: url = f"{self._config['marketplace_url']}/plugins" req = urllib.request.Request(url, headers={"User-Agent": "EU-Utility/Marketplace"}) with urllib.request.urlopen(req, timeout=30) as response: data = json.loads(response.read().decode('utf-8')) plugins = [MarketplacePlugin(**p) for p in data.get("plugins", [])] self._update_cache({"plugins": [asdict(p) for p in plugins]}) print(f"[{self.name}] Fetched {len(plugins)} plugins") return plugins except Exception as e: print(f"[{self.name}] Failed to fetch plugins: {e}") return [] def search_plugins(self, query: str, category: Optional[str] = None) -> List[MarketplacePlugin]: """ Search for plugins by query string. Args: query: Search query category: Optional category filter Returns: List of matching plugins """ plugins = self.fetch_plugins() query_lower = query.lower() results = [] for plugin in plugins: # Check category filter if category and plugin.category != category: continue # Search in name, description, tags, and author if (query_lower in plugin.name.lower() or query_lower in plugin.description.lower() or query_lower in plugin.author.lower() or any(query_lower in tag.lower() for tag in plugin.tags)): results.append(plugin) # Sort by relevance (downloads + rating) results.sort(key=lambda p: (p.downloads * 0.5 + p.rating * p.rating_count * 10), reverse=True) return results def get_plugin_by_id(self, plugin_id: str) -> Optional[MarketplacePlugin]: """Get a specific plugin by ID.""" plugins = self.fetch_plugins() for plugin in plugins: if plugin.id == plugin_id: return plugin return None def get_categories(self) -> List[str]: """Get list of available categories.""" plugins = self.fetch_plugins() categories = set(p.category for p in plugins) return sorted(list(categories)) def get_featured_plugins(self, limit: int = 10) -> List[MarketplacePlugin]: """Get featured/popular plugins.""" plugins = self.fetch_plugins() # Sort by downloads and rating plugins.sort(key=lambda p: (p.downloads, p.rating), reverse=True) return plugins[:limit] def get_new_plugins(self, limit: int = 10) -> List[MarketplacePlugin]: """Get newest plugins.""" plugins = self.fetch_plugins() # Sort by release date (newest first) plugins.sort(key=lambda p: p.release_date or "", reverse=True) return plugins[:limit] # Installation Management def get_plugin_status(self, plugin_id: str) -> PluginStatus: """Get installation status of a plugin.""" if plugin_id not in self._installed_plugins: return PluginStatus.NOT_INSTALLED installed = self._installed_plugins[plugin_id] marketplace_plugin = self.get_plugin_by_id(plugin_id) if not marketplace_plugin: return PluginStatus.ERROR if marketplace_plugin.version != installed.installed_version: return PluginStatus.UPDATE_AVAILABLE return PluginStatus.INSTALLED def install_plugin(self, plugin_id: str, auto_enable: bool = True) -> bool: """ Install a plugin from the marketplace. Args: plugin_id: ID of the plugin to install auto_enable: Whether to enable the plugin after installation Returns: True if installation successful """ plugin = self.get_plugin_by_id(plugin_id) if not plugin: print(f"[{self.name}] Plugin not found: {plugin_id}") return False print(f"[{self.name}] Installing {plugin.name} v{plugin.version}...") try: # Check dependencies if not self._check_dependencies(plugin): return False # Download plugin temp_dir = Path(self._config["temp_dir"]) temp_dir.mkdir(parents=True, exist_ok=True) download_path = temp_dir / f"{plugin.id}_{plugin.version}.zip" req = urllib.request.Request( plugin.download_url, headers={"User-Agent": "EU-Utility/Marketplace"} ) with urllib.request.urlopen(req, timeout=120) as response: with open(download_path, 'wb') as f: f.write(response.read()) # Verify checksum if plugin.checksum and not self._verify_checksum(download_path, plugin.checksum): print(f"[{self.name}] Checksum verification failed") download_path.unlink(missing_ok=True) return False # Extract to plugins directory plugins_dir = Path(self._config["plugins_dir"]) extract_dir = plugins_dir / plugin.id if extract_dir.exists(): shutil.rmtree(extract_dir) with zipfile.ZipFile(download_path, 'r') as zip_ref: zip_ref.extractall(extract_dir) # Record installation installed_info = InstalledPluginInfo( id=plugin.id, name=plugin.name, version=plugin.version, installed_version=plugin.version, install_date=datetime.now().isoformat(), install_path=str(extract_dir), enabled=auto_enable, ) self._installed_plugins[plugin_id] = installed_info self._save_installed_plugins() # Cleanup download_path.unlink(missing_ok=True) print(f"[{self.name}] ✓ Installed {plugin.name}") self._notify_listeners("installed", plugin_id) return True except Exception as e: print(f"[{self.name}] Installation failed: {e}") return False def uninstall_plugin(self, plugin_id: str) -> bool: """ Uninstall a plugin. Args: plugin_id: ID of the plugin to uninstall Returns: True if uninstallation successful """ if plugin_id not in self._installed_plugins: print(f"[{self.name}] Plugin not installed: {plugin_id}") return False try: installed = self._installed_plugins[plugin_id] install_path = Path(installed.install_path) if install_path.exists(): shutil.rmtree(install_path) del self._installed_plugins[plugin_id] self._save_installed_plugins() print(f"[{self.name}] ✓ Uninstalled {installed.name}") self._notify_listeners("uninstalled", plugin_id) return True except Exception as e: print(f"[{self.name}] Uninstall failed: {e}") return False def update_plugin(self, plugin_id: str) -> bool: """ Update an installed plugin to the latest version. Args: plugin_id: ID of the plugin to update Returns: True if update successful """ status = self.get_plugin_status(plugin_id) if status != PluginStatus.UPDATE_AVAILABLE: print(f"[{self.name}] No update available for {plugin_id}") return False # Uninstall old version if not self.uninstall_plugin(plugin_id): return False # Install new version return self.install_plugin(plugin_id) def check_installed_updates(self) -> List[MarketplacePlugin]: """ Check for updates to installed plugins. Returns: List of plugins with available updates """ updates = [] for plugin_id in self._installed_plugins: status = self.get_plugin_status(plugin_id) if status == PluginStatus.UPDATE_AVAILABLE: plugin = self.get_plugin_by_id(plugin_id) if plugin: updates.append(plugin) if updates: print(f"[{self.name}] {len(updates)} update(s) available") for plugin in updates: installed = self._installed_plugins[plugin.id] print(f" - {plugin.name}: {installed.installed_version} → {plugin.version}") return updates def update_all(self) -> Dict[str, bool]: """ Update all installed plugins. Returns: Dictionary mapping plugin IDs to success status """ updates = self.check_installed_updates() results = {} for plugin in updates: results[plugin.id] = self.update_plugin(plugin.id) return results def enable_plugin(self, plugin_id: str) -> bool: """Enable an installed plugin.""" if plugin_id in self._installed_plugins: self._installed_plugins[plugin_id].enabled = True self._save_installed_plugins() return True return False def disable_plugin(self, plugin_id: str) -> bool: """Disable an installed plugin.""" if plugin_id in self._installed_plugins: self._installed_plugins[plugin_id].enabled = False self._save_installed_plugins() return True return False # Private Methods def _check_dependencies(self, plugin: MarketplacePlugin) -> bool: """Check if all dependencies are satisfied.""" for dep in plugin.dependencies: if dep not in self._installed_plugins: print(f"[{self.name}] Missing dependency: {dep}") return False return True def _verify_checksum(self, file_path: Path, expected_checksum: str) -> bool: """Verify file checksum (SHA256).""" sha256 = hashlib.sha256() with open(file_path, 'rb') as f: for chunk in iter(lambda: f.read(8192), b''): sha256.update(chunk) return sha256.hexdigest().lower() == expected_checksum.lower() def _load_installed_plugins(self) -> None: """Load installed plugin registry.""" registry_file = self._data_dir / "installed_plugins.json" if registry_file.exists(): try: with open(registry_file) as f: data = json.load(f) self._installed_plugins = { k: InstalledPluginInfo(**v) for k, v in data.items() } except Exception as e: print(f"[{self.name}] Failed to load registry: {e}") def _save_installed_plugins(self) -> None: """Save installed plugin registry.""" registry_file = self._data_dir / "installed_plugins.json" try: with open(registry_file, 'w') as f: data = {k: asdict(v) for k, v in self._installed_plugins.items()} json.dump(data, f, indent=2) except Exception as e: print(f"[{self.name}] Failed to save registry: {e}") def _notify_listeners(self, event: str, plugin_id: str) -> None: """Notify event listeners.""" for listener in self._listeners: try: listener(event, plugin_id) except Exception as e: print(f"[{self.name}] Listener error: {e}") # Public API def add_listener(self, callback: Callable[[str, str], None]) -> None: """Add an event listener. Events: 'installed', 'uninstalled', 'updated'.""" self._listeners.append(callback) def remove_listener(self, callback: Callable) -> None: """Remove an event listener.""" if callback in self._listeners: self._listeners.remove(callback) def get_installed_plugins(self) -> List[InstalledPluginInfo]: """Get list of installed plugins.""" return list(self._installed_plugins.values()) def get_installed_plugin(self, plugin_id: str) -> Optional[InstalledPluginInfo]: """Get info about a specific installed plugin.""" return self._installed_plugins.get(plugin_id) def submit_rating(self, plugin_id: str, rating: int, review: Optional[str] = None) -> bool: """ Submit a rating for a plugin. Args: plugin_id: Plugin ID rating: Rating from 1-5 review: Optional review text Returns: True if submission successful """ try: url = f"{self._config['marketplace_url']}/plugins/{plugin_id}/rate" data = { "rating": max(1, min(5, rating)), "review": review, } req = urllib.request.Request( url, data=json.dumps(data).encode('utf-8'), headers={ "Content-Type": "application/json", "User-Agent": "EU-Utility/Marketplace", }, method="POST", ) with urllib.request.urlopen(req, timeout=30) as response: return response.status == 200 except Exception as e: print(f"[{self.name}] Failed to submit rating: {e}") return False