EU-Utility/plugins/plugin_marketplace.py

555 lines
19 KiB
Python

"""
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