Compare commits
No commits in common. "665d140b49bc8de64f54e3ef6b9bfe279688911e" and "70b7e9b237424c8492b10d836c48c31ca8935c86" have entirely different histories.
665d140b49
...
70b7e9b237
378
TESTING.md
378
TESTING.md
|
|
@ -1,378 +0,0 @@
|
|||
# EU-Utility Testing Guide
|
||||
|
||||
Complete testing instructions for EU-Utility Premium with Entropia Universe.
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Install Dependencies
|
||||
|
||||
```bash
|
||||
cd ~/EU-Utility
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
**Required packages:**
|
||||
- PyQt6 (GUI framework)
|
||||
- psutil (process monitoring)
|
||||
- pywin32 (Windows API) - Windows only
|
||||
- pillow (screenshots)
|
||||
|
||||
### 2. Configure Game Path
|
||||
|
||||
Edit the config file (created automatically on first run):
|
||||
|
||||
```bash
|
||||
# Windows default paths:
|
||||
# C:\Program Files (x86)\Entropia Universe\ClientLoader.exe
|
||||
# C:\Users\<username>\AppData\Local\Entropia Universe
|
||||
|
||||
# Or create manually:
|
||||
python -c "
|
||||
import json
|
||||
from pathlib import Path
|
||||
config = {
|
||||
'game_path': r'C:\\Program Files (x86)\\Entropia Universe',
|
||||
'overlay_enabled': True,
|
||||
'overlay_mode': 'overlay_toggle',
|
||||
'hotkey': 'ctrl+shift+b',
|
||||
'plugins_enabled': ['dashboard_widget'],
|
||||
}
|
||||
Path.home().joinpath('.eu-utility', 'config.json').write_text(json.dumps(config, indent=2))
|
||||
"
|
||||
```
|
||||
|
||||
### 3. Launch EU-Utility
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
**Command line options:**
|
||||
```bash
|
||||
python main.py --verbose # Debug logging
|
||||
python main.py --test # Test mode (no game required)
|
||||
python main.py --no-overlay # Headless mode
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎮 Testing With Entropia Universe
|
||||
|
||||
### Test 1: Basic Launch
|
||||
|
||||
**Steps:**
|
||||
1. Launch EU-Utility: `python main.py`
|
||||
2. Check console output - should show "EU-Utility Premium initialized"
|
||||
3. Look for system tray icon (small EU logo)
|
||||
4. Press `Ctrl+Shift+B` to toggle overlay
|
||||
|
||||
**Expected:** Overlay appears with dashboard widget
|
||||
|
||||
### Test 2: Game Detection
|
||||
|
||||
**Steps:**
|
||||
1. Launch Entropia Universe game client
|
||||
2. Wait 5-10 seconds
|
||||
3. Check EU-Utility console for "Game client detected"
|
||||
|
||||
**Expected:** Log shows process detection working
|
||||
|
||||
**Debug:** If not detected, check game path in config
|
||||
|
||||
### Test 3: Overlay Modes
|
||||
|
||||
Test each overlay mode in config:
|
||||
|
||||
**Mode: `overlay_toggle`** (default)
|
||||
- Press `Ctrl+Shift+B` to show/hide overlay
|
||||
- Should toggle instantly (<100ms)
|
||||
|
||||
**Mode: `overlay_game`**
|
||||
- Overlay auto-shows when EU window is focused
|
||||
- Overlay auto-hides when EU loses focus
|
||||
- Should be smooth (no lag)
|
||||
|
||||
**Mode: `overlay_temp`**
|
||||
- Press hotkey to show for 8 seconds
|
||||
- Auto-hides after duration
|
||||
|
||||
### Test 4: Dashboard Widget
|
||||
|
||||
**Steps:**
|
||||
1. Open overlay with hotkey
|
||||
2. Verify dashboard widget loads
|
||||
3. Check for player stats display
|
||||
|
||||
**Features to test:**
|
||||
- [ ] Player name display
|
||||
- [ ] Skill levels visible
|
||||
- [ ] Health/Energy bars
|
||||
- [ ] PED balance (if available)
|
||||
|
||||
### Test 5: Log File Parsing
|
||||
|
||||
**Steps:**
|
||||
1. In game, get some loot or skill gain
|
||||
2. Check EU-Utility console for events
|
||||
3. Look for: `[GameEvent] Loot: ...` or `[GameEvent] Skill: ...`
|
||||
|
||||
**Expected:** Real-time event detection
|
||||
|
||||
**Log locations:**
|
||||
```
|
||||
%LOCALAPPDATA%\Entropia Universe\chat.log
|
||||
%LOCALAPPDATA%\Entropia Universe\Entropia.log
|
||||
```
|
||||
|
||||
### Test 6: Performance Test
|
||||
|
||||
**Steps:**
|
||||
1. Run EU-Utility with game for 30 minutes
|
||||
2. Monitor CPU usage (should be <5%)
|
||||
3. Check for "Focus check took Xms" warnings
|
||||
|
||||
**Expected:**
|
||||
- No lag spikes
|
||||
- Smooth 60fps overlay
|
||||
- CPU usage minimal
|
||||
|
||||
**Debug:** If slow, check overlay_controller.py optimizations
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### Issue: "Game not detected"
|
||||
|
||||
**Check:**
|
||||
1. Game path in config is correct
|
||||
2. Game is actually running
|
||||
3. Try running EU-Utility as administrator
|
||||
|
||||
**Fix:**
|
||||
```python
|
||||
# Update config with correct path
|
||||
config['game_path'] = r'C:\Your\Actual\Path\To\Entropia Universe'
|
||||
```
|
||||
|
||||
### Issue: "Overlay not appearing"
|
||||
|
||||
**Check:**
|
||||
1. Hotkey is not conflicting with other apps
|
||||
2. Overlay mode is set correctly
|
||||
3. No Qt/OpenGL errors in console
|
||||
|
||||
**Fix:**
|
||||
```bash
|
||||
# Try test mode
|
||||
python main.py --test
|
||||
|
||||
# Or disable overlay temporarily
|
||||
python main.py --no-overlay
|
||||
```
|
||||
|
||||
### Issue: "App is slow/laggy"
|
||||
|
||||
**Check:**
|
||||
1. Focus check timing in logs
|
||||
2. CPU/memory usage
|
||||
3. Number of plugins loaded
|
||||
|
||||
**Fix:**
|
||||
- Reduce poll interval in config
|
||||
- Disable unused plugins
|
||||
- Check for window manager conflicts
|
||||
|
||||
### Issue: "Plugins not loading"
|
||||
|
||||
**Check:**
|
||||
1. Plugin files exist in `plugins/builtin/`
|
||||
2. plugin.json is valid JSON
|
||||
3. No import errors in console
|
||||
|
||||
**Fix:**
|
||||
```bash
|
||||
# Verify plugin structure
|
||||
ls plugins/builtin/dashboard_widget/
|
||||
# Should show: plugin.json, main.py
|
||||
|
||||
# Check for syntax errors
|
||||
python -m py_compile plugins/builtin/dashboard_widget/main.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Advanced Testing
|
||||
|
||||
### Test Plugin Development
|
||||
|
||||
Create a test plugin:
|
||||
|
||||
```python
|
||||
# plugins/user/test_plugin/main.py
|
||||
from premium.plugins.api import BasePlugin, PluginManifest
|
||||
|
||||
class TestPlugin(BasePlugin):
|
||||
manifest = PluginManifest(
|
||||
name="Test Plugin",
|
||||
version="1.0.0",
|
||||
author="Tester"
|
||||
)
|
||||
|
||||
def on_load(self):
|
||||
print("Test plugin loaded!")
|
||||
return True
|
||||
|
||||
def on_enable(self):
|
||||
print("Test plugin enabled!")
|
||||
|
||||
def on_disable(self):
|
||||
print("Test plugin disabled!")
|
||||
```
|
||||
|
||||
```json
|
||||
// plugins/user/test_plugin/plugin.json
|
||||
{
|
||||
"name": "Test Plugin",
|
||||
"version": "1.0.0",
|
||||
"author": "Tester",
|
||||
"main": "main.py",
|
||||
"entry_point": "TestPlugin"
|
||||
}
|
||||
```
|
||||
|
||||
### Test Event System
|
||||
|
||||
```python
|
||||
# Test event bus
|
||||
from premium.core.event_bus import EventBus, Event
|
||||
|
||||
bus = EventBus()
|
||||
|
||||
@bus.on("game.loot")
|
||||
def on_loot(event):
|
||||
print(f"Got loot: {event.data}")
|
||||
|
||||
bus.emit("game.loot", {"item": "Ammunition", "amount": 100})
|
||||
```
|
||||
|
||||
### Test State Management
|
||||
|
||||
```python
|
||||
# Test state store
|
||||
from premium.core.state.store import StateStore, ActionBase
|
||||
|
||||
class IncrementAction(ActionBase):
|
||||
type = "INCREMENT"
|
||||
|
||||
def counter_reducer(state, action):
|
||||
if action.type == "INCREMENT":
|
||||
return state + 1
|
||||
return state
|
||||
|
||||
store = StateStore(counter_reducer, initial_state=0)
|
||||
store.dispatch(IncrementAction())
|
||||
print(store.state) # 1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Performance Benchmarks
|
||||
|
||||
### Expected Performance
|
||||
|
||||
| Metric | Target | Acceptable |
|
||||
|--------|--------|------------|
|
||||
| Startup time | <3s | <5s |
|
||||
| Focus check | <10ms | <50ms |
|
||||
| Overlay toggle | <100ms | <200ms |
|
||||
| CPU usage | <3% | <10% |
|
||||
| Memory usage | <200MB | <500MB |
|
||||
| Log parsing | Real-time | <1s delay |
|
||||
|
||||
### Benchmark Script
|
||||
|
||||
```bash
|
||||
# Run performance test
|
||||
python -c "
|
||||
import time
|
||||
from premium.eu_integration.game_client import GameClient
|
||||
|
||||
start = time.time()
|
||||
client = GameClient()
|
||||
client.initialize()
|
||||
print(f'Init time: {time.time() - start:.2f}s')
|
||||
|
||||
# Test detection
|
||||
start = time.time()
|
||||
found = client.find_game_process()
|
||||
print(f'Detection time: {time.time() - start:.2f}s')
|
||||
print(f'Game found: {found}')
|
||||
"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Debug Mode
|
||||
|
||||
### Enable Debug Logging
|
||||
|
||||
```bash
|
||||
python main.py --verbose
|
||||
```
|
||||
|
||||
### Key Log Messages
|
||||
|
||||
| Message | Meaning |
|
||||
|---------|---------|
|
||||
| "Game client detected" | Process found |
|
||||
| "Window focused" | EU window active |
|
||||
| "Overlay shown/hidden" | Toggle working |
|
||||
| "Plugin loaded: X" | Plugin loaded |
|
||||
| "Focus check took Xms" | Performance warning |
|
||||
| "Event: game.loot" | Game event detected |
|
||||
|
||||
### Check Logs
|
||||
|
||||
```bash
|
||||
# Real-time log view
|
||||
tail -f ~/.eu-utility/logs/app.log
|
||||
|
||||
# Search for errors
|
||||
grep ERROR ~/.eu-utility/logs/app.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Final Checklist
|
||||
|
||||
Before considering ready for production:
|
||||
|
||||
- [ ] App launches without errors
|
||||
- [ ] Game detection works
|
||||
- [ ] Overlay appears with hotkey
|
||||
- [ ] Dashboard shows data
|
||||
- [ ] Log events are captured
|
||||
- [ ] Performance is smooth
|
||||
- [ ] No memory leaks (stable after 1 hour)
|
||||
- [ ] Plugins load/unload correctly
|
||||
- [ ] Settings save/load properly
|
||||
- [ ] Exits gracefully on close
|
||||
|
||||
---
|
||||
|
||||
## 🆘 Getting Help
|
||||
|
||||
If issues persist:
|
||||
|
||||
1. Check logs: `~/.eu-utility/logs/`
|
||||
2. Run tests: `python -m pytest tests/`
|
||||
3. Check config: `~/.eu-utility/config.json`
|
||||
4. Verify game running and accessible
|
||||
|
||||
**Debug info to collect:**
|
||||
- Console output
|
||||
- Log files
|
||||
- System specs (CPU, RAM, GPU)
|
||||
- Windows version
|
||||
- Game version
|
||||
297
main.py
297
main.py
|
|
@ -1,297 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
EU-Utility Premium - Main Entry Point
|
||||
======================================
|
||||
|
||||
Launch the EU-Utility premium overlay system.
|
||||
|
||||
Usage:
|
||||
python main.py # Start with default config
|
||||
python main.py --config path/to/config.json
|
||||
python main.py --no-overlay # Run without overlay (headless)
|
||||
python main.py --test # Run in test mode
|
||||
|
||||
For more information, see TESTING.md
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import logging
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
# Add the EU-Utility directory to path
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
|
||||
def setup_logging(verbose: bool = False) -> logging.Logger:
|
||||
"""Setup logging configuration."""
|
||||
level = logging.DEBUG if verbose else logging.INFO
|
||||
|
||||
logging.basicConfig(
|
||||
level=level,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout),
|
||||
]
|
||||
)
|
||||
|
||||
return logging.getLogger("EU-Utility")
|
||||
|
||||
|
||||
def load_config(config_path: Path) -> dict:
|
||||
"""Load configuration from file."""
|
||||
if not config_path.exists():
|
||||
# Create default config
|
||||
default_config = {
|
||||
"game_path": None,
|
||||
"overlay_enabled": True,
|
||||
"overlay_mode": "overlay_toggle",
|
||||
"hotkey": "ctrl+shift+b",
|
||||
"plugins": {
|
||||
"enabled": [],
|
||||
"disabled": []
|
||||
},
|
||||
"ui": {
|
||||
"theme": "dark",
|
||||
"opacity": 0.95,
|
||||
"scale": 1.0
|
||||
},
|
||||
"features": {
|
||||
"loot_tracker": True,
|
||||
"skill_tracker": True,
|
||||
"global_alerts": True,
|
||||
"analytics": True
|
||||
}
|
||||
}
|
||||
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(config_path, 'w') as f:
|
||||
json.dump(default_config, f, indent=2)
|
||||
|
||||
return default_config
|
||||
|
||||
with open(config_path, 'r') as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="EU-Utility Premium - Entropia Universe Overlay"
|
||||
)
|
||||
parser.add_argument(
|
||||
'--config',
|
||||
type=Path,
|
||||
default=Path.home() / '.eu-utility' / 'config.json',
|
||||
help='Path to configuration file'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--no-overlay',
|
||||
action='store_true',
|
||||
help='Run without overlay (headless mode)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--test',
|
||||
action='store_true',
|
||||
help='Run in test mode (no game required)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--verbose', '-v',
|
||||
action='store_true',
|
||||
help='Enable verbose logging'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--setup',
|
||||
action='store_true',
|
||||
help='Run first-time setup'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Setup logging
|
||||
logger = setup_logging(args.verbose)
|
||||
logger.info("=" * 50)
|
||||
logger.info("EU-Utility Premium Starting")
|
||||
logger.info("=" * 50)
|
||||
|
||||
# Load config
|
||||
config = load_config(args.config)
|
||||
logger.info(f"Loaded config from: {args.config}")
|
||||
|
||||
# Run setup if requested
|
||||
if args.setup:
|
||||
run_setup(config, args.config)
|
||||
return 0
|
||||
|
||||
# Check for game path
|
||||
if not config.get('game_path') and not args.test:
|
||||
logger.error("No game path configured. Run with --setup or edit config.json")
|
||||
print("\n" + "=" * 50)
|
||||
print("First-time setup required!")
|
||||
print("=" * 50)
|
||||
print("\nPlease run: python main.py --setup")
|
||||
print("\nOr manually edit the config file:")
|
||||
print(f" {args.config}")
|
||||
print("\nAnd set the game_path to your Entropia Universe installation.")
|
||||
print("=" * 50)
|
||||
return 1
|
||||
|
||||
# Import and start the application
|
||||
try:
|
||||
from premium import EUUtilityApp
|
||||
|
||||
logger.info("Initializing application...")
|
||||
app = EUUtilityApp(config_path=args.config)
|
||||
|
||||
# Override config with CLI args
|
||||
if args.no_overlay:
|
||||
config['overlay_enabled'] = False
|
||||
|
||||
if args.test:
|
||||
logger.info("Running in TEST mode")
|
||||
return run_test_mode(app, config)
|
||||
|
||||
# Normal operation
|
||||
logger.info("Starting application...")
|
||||
app.initialize()
|
||||
app.run()
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Interrupted by user")
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Fatal error")
|
||||
print(f"\nError: {e}")
|
||||
return 1
|
||||
|
||||
|
||||
def run_setup(config: dict, config_path: Path) -> None:
|
||||
"""Run first-time setup wizard."""
|
||||
print("\n" + "=" * 50)
|
||||
print("EU-Utility Setup Wizard")
|
||||
print("=" * 50 + "\n")
|
||||
|
||||
# Detect game path
|
||||
print("Looking for Entropia Universe installation...")
|
||||
|
||||
possible_paths = [
|
||||
Path("C:/Program Files (x86)/Entropia Universe"),
|
||||
Path("C:/Program Files/Entropia Universe"),
|
||||
Path.home() / "AppData" / "Local" / "Entropia Universe",
|
||||
]
|
||||
|
||||
detected = None
|
||||
for path in possible_paths:
|
||||
if path.exists():
|
||||
detected = path
|
||||
print(f" Found: {path}")
|
||||
break
|
||||
|
||||
if detected:
|
||||
use = input(f"\nUse this path? [Y/n]: ").strip().lower()
|
||||
if use in ('', 'y', 'yes'):
|
||||
config['game_path'] = str(detected)
|
||||
else:
|
||||
detected = None
|
||||
|
||||
if not detected:
|
||||
custom = input("Enter your Entropia Universe installation path: ").strip()
|
||||
if custom:
|
||||
config['game_path'] = custom
|
||||
|
||||
# Save config
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(config_path, 'w') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
|
||||
print(f"\nConfiguration saved to: {config_path}")
|
||||
print("\nYou can now run: python main.py")
|
||||
print("=" * 50)
|
||||
|
||||
|
||||
def run_test_mode(app, config: dict) -> int:
|
||||
"""Run in test mode without requiring game."""
|
||||
print("\n" + "=" * 50)
|
||||
print("EU-Utility TEST MODE")
|
||||
print("=" * 50 + "\n")
|
||||
|
||||
try:
|
||||
# Test imports
|
||||
print("✓ Checking imports...")
|
||||
from premium.plugins.api import PluginAPI, PluginManifest
|
||||
from premium.plugins.manager import PluginManager
|
||||
from premium.core.event_bus import EventBus
|
||||
from premium.core.state.store import StateStore
|
||||
print(" All imports successful")
|
||||
|
||||
# Test plugin system
|
||||
print("\n✓ Testing plugin system...")
|
||||
event_bus = EventBus()
|
||||
store = StateStore(reducer=lambda s, a: s or {}, initial_state={})
|
||||
|
||||
plugin_manager = PluginManager(
|
||||
plugin_dirs=[],
|
||||
data_dir=Path.home() / '.eu-utility' / 'data',
|
||||
event_bus=event_bus,
|
||||
state_store=store
|
||||
)
|
||||
print(" Plugin manager initialized")
|
||||
|
||||
# Test event bus
|
||||
print("\n✓ Testing event bus...")
|
||||
test_events = []
|
||||
|
||||
@event_bus.on('test')
|
||||
def on_test(event):
|
||||
test_events.append(event)
|
||||
|
||||
event_bus.emit('test', {'message': 'hello'})
|
||||
import time
|
||||
time.sleep(0.1)
|
||||
|
||||
if len(test_events) == 1:
|
||||
print(" Event bus working")
|
||||
else:
|
||||
print(" ⚠ Event bus may have issues")
|
||||
|
||||
# Test game client (simulated)
|
||||
print("\n✓ Testing game client integration...")
|
||||
from premium.eu_integration import GameClient
|
||||
|
||||
# Test with dummy path
|
||||
client = GameClient(
|
||||
game_path=Path.home(), # Dummy path for testing
|
||||
event_bus=event_bus
|
||||
)
|
||||
print(" Game client initialized (simulated mode)")
|
||||
|
||||
# Test widget system
|
||||
print("\n✓ Testing widget system...")
|
||||
from premium.widgets import Widget, WidgetConfig
|
||||
|
||||
class TestWidget(Widget):
|
||||
def create_ui(self, parent):
|
||||
return None # No UI in test mode
|
||||
|
||||
widget = TestWidget(WidgetConfig(name="Test"))
|
||||
print(" Widget system functional")
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print("All tests passed!")
|
||||
print("=" * 50)
|
||||
print("\nThe application is ready to use.")
|
||||
print("Run 'python main.py' to start with the actual game.")
|
||||
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n✗ Test failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
|
|
@ -1,322 +0,0 @@
|
|||
"""
|
||||
EU-Utility Premium - Dashboard Widget Plugin
|
||||
=============================================
|
||||
|
||||
A built-in plugin that provides a dashboard for player statistics.
|
||||
Shows skills, loot, and game status in a convenient overlay widget.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
# Import the plugin API
|
||||
from premium.plugins.api import (
|
||||
PluginAPI, PluginContext, PluginManifest, PermissionLevel
|
||||
)
|
||||
|
||||
# Manifest - REQUIRED
|
||||
manifest = PluginManifest(
|
||||
name="Player Dashboard",
|
||||
version="1.0.0",
|
||||
author="EU-Utility Team",
|
||||
description="Player statistics dashboard with skills, loot, and game status",
|
||||
entry_point="main.py",
|
||||
permissions={PermissionLevel.UI, PermissionLevel.FILE_READ},
|
||||
tags=["dashboard", "stats", "built-in"],
|
||||
)
|
||||
|
||||
|
||||
class DashboardPlugin(PluginAPI):
|
||||
"""Player statistics dashboard plugin.
|
||||
|
||||
This plugin displays:
|
||||
- Current character name and level
|
||||
- Skill gains (latest and session total)
|
||||
- Loot tracker (recent items and total value)
|
||||
- Game connection status
|
||||
"""
|
||||
|
||||
manifest = manifest
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._widget = None
|
||||
self._stats = {
|
||||
'character': None,
|
||||
'session_loot_value': 0.0,
|
||||
'session_loot_count': 0,
|
||||
'session_skills': [],
|
||||
'last_loot': None,
|
||||
'last_skill': None,
|
||||
}
|
||||
|
||||
def on_init(self, ctx: PluginContext) -> None:
|
||||
"""Called when plugin is initialized."""
|
||||
self.ctx = ctx
|
||||
ctx.logger.info("Dashboard plugin initialized")
|
||||
|
||||
# Load saved stats if any
|
||||
self._load_stats()
|
||||
|
||||
# Subscribe to events
|
||||
if ctx.event_bus:
|
||||
ctx.event_bus.subscribe('game.loot', self._on_loot)
|
||||
ctx.event_bus.subscribe('game.skill', self._on_skill)
|
||||
ctx.event_bus.subscribe('game.connected', self._on_connected)
|
||||
ctx.event_bus.subscribe('game.disconnected', self._on_disconnected)
|
||||
|
||||
def on_activate(self) -> None:
|
||||
"""Called when plugin is activated."""
|
||||
self.ctx.logger.info("Dashboard plugin activated")
|
||||
|
||||
def on_deactivate(self) -> None:
|
||||
"""Called when plugin is deactivated."""
|
||||
self._save_stats()
|
||||
|
||||
def on_shutdown(self) -> None:
|
||||
"""Called when plugin is being unloaded."""
|
||||
self._save_stats()
|
||||
|
||||
def create_widget(self) -> Optional[Any]:
|
||||
"""Create the dashboard UI widget."""
|
||||
try:
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QFrame, QPushButton, QGridLayout
|
||||
)
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtGui import QFont
|
||||
|
||||
# Main widget
|
||||
widget = QWidget()
|
||||
widget.setStyleSheet("""
|
||||
QWidget {
|
||||
background-color: #1e1e1e;
|
||||
color: #ffffff;
|
||||
font-family: 'Segoe UI', Arial, sans-serif;
|
||||
}
|
||||
QFrame {
|
||||
background-color: #2d2d2d;
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
}
|
||||
QLabel {
|
||||
color: #ffffff;
|
||||
}
|
||||
.stat-label {
|
||||
color: #888888;
|
||||
font-size: 11px;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
""")
|
||||
|
||||
layout = QVBoxLayout(widget)
|
||||
layout.setSpacing(15)
|
||||
layout.setContentsMargins(15, 15, 15, 15)
|
||||
|
||||
# Header
|
||||
header = QLabel("👤 Player Dashboard")
|
||||
font = QFont("Segoe UI", 14, QFont.Weight.Bold)
|
||||
header.setFont(font)
|
||||
layout.addWidget(header)
|
||||
|
||||
# Status section
|
||||
status_frame = QFrame()
|
||||
status_layout = QHBoxLayout(status_frame)
|
||||
|
||||
self.status_label = QLabel("● Disconnected")
|
||||
self.status_label.setStyleSheet("color: #f44336; font-weight: bold;")
|
||||
status_layout.addWidget(self.status_label)
|
||||
|
||||
self.char_label = QLabel("No character")
|
||||
status_layout.addStretch()
|
||||
status_layout.addWidget(self.char_label)
|
||||
|
||||
layout.addWidget(status_frame)
|
||||
|
||||
# Stats grid
|
||||
stats_frame = QFrame()
|
||||
stats_layout = QGridLayout(stats_frame)
|
||||
stats_layout.setSpacing(10)
|
||||
|
||||
# Session loot
|
||||
loot_title = QLabel("💰 Session Loot")
|
||||
loot_title.setStyleSheet("font-weight: bold;")
|
||||
stats_layout.addWidget(loot_title, 0, 0)
|
||||
|
||||
self.loot_value_label = QLabel("0.00 PED")
|
||||
self.loot_value_label.setStyleSheet("font-size: 18px; font-weight: bold; color: #4caf50;")
|
||||
stats_layout.addWidget(self.loot_value_label, 1, 0)
|
||||
|
||||
self.loot_count_label = QLabel("0 items")
|
||||
self.loot_count_label.setStyleSheet("color: #888888;")
|
||||
stats_layout.addWidget(self.loot_count_label, 2, 0)
|
||||
|
||||
# Skills
|
||||
skills_title = QLabel("📈 Skills")
|
||||
skills_title.setStyleSheet("font-weight: bold;")
|
||||
stats_layout.addWidget(skills_title, 0, 1)
|
||||
|
||||
self.skills_count_label = QLabel("0 gains")
|
||||
self.skills_count_label.setStyleSheet("font-size: 18px; font-weight: bold; color: #2196f3;")
|
||||
stats_layout.addWidget(self.skills_count_label, 1, 1)
|
||||
|
||||
self.last_skill_label = QLabel("-")
|
||||
self.last_skill_label.setStyleSheet("color: #888888;")
|
||||
stats_layout.addWidget(self.last_skill_label, 2, 1)
|
||||
|
||||
layout.addWidget(stats_frame)
|
||||
|
||||
# Recent activity
|
||||
activity_title = QLabel("📝 Recent Activity")
|
||||
activity_title.setStyleSheet("font-weight: bold; margin-top: 10px;")
|
||||
layout.addWidget(activity_title)
|
||||
|
||||
self.activity_label = QLabel("No activity yet...")
|
||||
self.activity_label.setWordWrap(True)
|
||||
self.activity_label.setStyleSheet("color: #aaaaaa; padding: 5px;")
|
||||
layout.addWidget(self.activity_label)
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
# Save reference for updates
|
||||
self._widget = widget
|
||||
self._update_display()
|
||||
|
||||
return widget
|
||||
|
||||
except ImportError:
|
||||
self.ctx.logger.warning("PyQt6 not available, cannot create widget")
|
||||
return None
|
||||
|
||||
# ========== Event Handlers ==========
|
||||
|
||||
def _on_loot(self, event: Dict[str, Any]) -> None:
|
||||
"""Handle loot event."""
|
||||
item = event.get('item', 'Unknown')
|
||||
value = event.get('value', 0.0)
|
||||
quantity = event.get('quantity', 1)
|
||||
|
||||
self._stats['session_loot_value'] += value
|
||||
self._stats['session_loot_count'] += quantity
|
||||
self._stats['last_loot'] = {
|
||||
'item': item,
|
||||
'value': value,
|
||||
'quantity': quantity,
|
||||
}
|
||||
|
||||
self.ctx.logger.info(f"Loot: {item} x{quantity} ({value:.2f} PED)")
|
||||
self._update_display()
|
||||
|
||||
def _on_skill(self, event: Dict[str, Any]) -> None:
|
||||
"""Handle skill gain event."""
|
||||
skill = event.get('skill', 'Unknown')
|
||||
gain = event.get('gain', 0.0)
|
||||
|
||||
self._stats['session_skills'].append({
|
||||
'skill': skill,
|
||||
'gain': gain,
|
||||
})
|
||||
self._stats['last_skill'] = {
|
||||
'skill': skill,
|
||||
'gain': gain,
|
||||
}
|
||||
|
||||
self.ctx.logger.info(f"Skill: {skill} +{gain:.4f}")
|
||||
self._update_display()
|
||||
|
||||
def _on_connected(self, event: Dict[str, Any]) -> None:
|
||||
"""Handle game connection."""
|
||||
self.ctx.logger.info("Game connected")
|
||||
self._update_status(True)
|
||||
|
||||
def _on_disconnected(self, event: Dict[str, Any]) -> None:
|
||||
"""Handle game disconnection."""
|
||||
self.ctx.logger.info("Game disconnected")
|
||||
self._update_status(False)
|
||||
|
||||
# ========== Display Updates ==========
|
||||
|
||||
def _update_display(self) -> None:
|
||||
"""Update the widget display."""
|
||||
if not self._widget:
|
||||
return
|
||||
|
||||
try:
|
||||
# Update loot stats
|
||||
self.loot_value_label.setText(
|
||||
f"{self._stats['session_loot_value']:.2f} PED"
|
||||
)
|
||||
self.loot_count_label.setText(
|
||||
f"{self._stats['session_loot_count']} items"
|
||||
)
|
||||
|
||||
# Update skill stats
|
||||
self.skills_count_label.setText(
|
||||
f"{len(self._stats['session_skills'])} gains"
|
||||
)
|
||||
|
||||
if self._stats['last_skill']:
|
||||
skill = self._stats['last_skill']
|
||||
self.last_skill_label.setText(
|
||||
f"{skill['skill']} +{skill['gain']:.4f}"
|
||||
)
|
||||
|
||||
# Update activity
|
||||
activity_text = ""
|
||||
if self._stats['last_loot']:
|
||||
loot = self._stats['last_loot']
|
||||
activity_text += f"Loot: {loot['item']} ({loot['value']:.2f} PED)\n"
|
||||
|
||||
if self._stats['last_skill']:
|
||||
skill = self._stats['last_skill']
|
||||
activity_text += f"Skill: {skill['skill']} +{skill['gain']:.4f}"
|
||||
|
||||
if activity_text:
|
||||
self.activity_label.setText(activity_text)
|
||||
|
||||
except Exception as e:
|
||||
self.ctx.logger.error(f"Error updating display: {e}")
|
||||
|
||||
def _update_status(self, connected: bool) -> None:
|
||||
"""Update connection status display."""
|
||||
if not self._widget:
|
||||
return
|
||||
|
||||
if connected:
|
||||
self.status_label.setText("● Connected")
|
||||
self.status_label.setStyleSheet("color: #4caf50; font-weight: bold;")
|
||||
else:
|
||||
self.status_label.setText("● Disconnected")
|
||||
self.status_label.setStyleSheet("color: #f44336; font-weight: bold;")
|
||||
|
||||
# ========== Persistence ==========
|
||||
|
||||
def _load_stats(self) -> None:
|
||||
"""Load stats from disk."""
|
||||
try:
|
||||
import json
|
||||
stats_path = self.ctx.data_dir / "session_stats.json"
|
||||
if stats_path.exists():
|
||||
with open(stats_path, 'r') as f:
|
||||
saved = json.load(f)
|
||||
self._stats.update(saved)
|
||||
except Exception as e:
|
||||
self.ctx.logger.error(f"Failed to load stats: {e}")
|
||||
|
||||
def _save_stats(self) -> None:
|
||||
"""Save stats to disk."""
|
||||
try:
|
||||
import json
|
||||
stats_path = self.ctx.data_dir / "session_stats.json"
|
||||
with open(stats_path, 'w') as f:
|
||||
json.dump(self._stats, f, indent=2)
|
||||
except Exception as e:
|
||||
self.ctx.logger.error(f"Failed to save stats: {e}")
|
||||
|
||||
|
||||
# Export the plugin class
|
||||
Plugin = DashboardPlugin
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"name": "Player Dashboard",
|
||||
"version": "1.0.0",
|
||||
"author": "EU-Utility Team",
|
||||
"description": "Player statistics dashboard with skills, loot, and game status",
|
||||
"entry_point": "main.py",
|
||||
"permissions": ["ui", "file_read"],
|
||||
"dependencies": {},
|
||||
"min_api_version": "3.0.0",
|
||||
"tags": ["dashboard", "stats", "built-in"],
|
||||
"icon": "📊"
|
||||
}
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
"""
|
||||
EU-Utility Premium
|
||||
==================
|
||||
|
||||
Enterprise-grade modular overlay system for Entropia Universe.
|
||||
|
||||
This package provides the premium/enterprise layer with:
|
||||
- Advanced plugin system with sandboxing
|
||||
- Redux-inspired state management
|
||||
- High-performance event bus
|
||||
- Widget system for dashboard
|
||||
- Entropia Universe game integration
|
||||
|
||||
Quick Start:
|
||||
from premium import EUUtilityApp
|
||||
|
||||
app = EUUtilityApp()
|
||||
app.run()
|
||||
|
||||
Modules:
|
||||
plugins - Plugin management system
|
||||
core - Core infrastructure (state, events)
|
||||
widgets - Dashboard widget system
|
||||
eu_integration - Entropia Universe integration
|
||||
"""
|
||||
|
||||
__version__ = "3.0.0"
|
||||
__author__ = "EU-Utility Team"
|
||||
|
||||
# Core exports
|
||||
from premium.core.state.store import StateStore, ActionBase, create_store, get_store
|
||||
from premium.core.event_bus import EventBus, Event, EventPriority
|
||||
from premium.plugins.api import (
|
||||
PluginAPI, PluginManifest, PluginContext, PluginInstance,
|
||||
PluginState, PermissionLevel,
|
||||
PluginError, PluginLoadError, PluginInitError
|
||||
)
|
||||
from premium.plugins.manager import PluginManager
|
||||
|
||||
# Widget system
|
||||
from premium.widgets.base import Widget, WidgetConfig
|
||||
from premium.widgets.dashboard import Dashboard
|
||||
|
||||
# EU Integration
|
||||
from premium.eu_integration.game_client import GameClient
|
||||
from premium.eu_integration.log_parser import LogParser
|
||||
from premium.eu_integration.events import GameEvent, LootEvent, SkillEvent
|
||||
|
||||
|
||||
class EUUtilityApp:
|
||||
"""Main application class for EU-Utility Premium.
|
||||
|
||||
This is the high-level API for the entire premium system.
|
||||
|
||||
Example:
|
||||
app = EUUtilityApp()
|
||||
app.initialize()
|
||||
app.run()
|
||||
"""
|
||||
|
||||
def __init__(self, config_path=None):
|
||||
self.config_path = config_path
|
||||
self.plugin_manager = None
|
||||
self.state_store = None
|
||||
self.event_bus = None
|
||||
self.game_client = None
|
||||
self.dashboard = None
|
||||
self._initialized = False
|
||||
|
||||
def initialize(self):
|
||||
"""Initialize all subsystems."""
|
||||
from pathlib import Path
|
||||
|
||||
# Create event bus
|
||||
self.event_bus = EventBus()
|
||||
|
||||
# Create state store
|
||||
self.state_store = StateStore(
|
||||
reducer=self._root_reducer,
|
||||
initial_state=self._get_initial_state()
|
||||
)
|
||||
|
||||
# Create plugin manager
|
||||
plugin_dirs = [
|
||||
Path(__file__).parent.parent / "plugins" / "builtin",
|
||||
Path(__file__).parent.parent / "plugins" / "user",
|
||||
]
|
||||
data_dir = Path.home() / ".eu-utility" / "data"
|
||||
|
||||
self.plugin_manager = PluginManager(
|
||||
plugin_dirs=plugin_dirs,
|
||||
data_dir=data_dir,
|
||||
event_bus=self.event_bus,
|
||||
state_store=self.state_store
|
||||
)
|
||||
|
||||
# Create game client
|
||||
self.game_client = GameClient(event_bus=self.event_bus)
|
||||
|
||||
self._initialized = True
|
||||
|
||||
def run(self):
|
||||
"""Run the application."""
|
||||
if not self._initialized:
|
||||
self.initialize()
|
||||
|
||||
# Discover and load plugins
|
||||
self.plugin_manager.discover_all()
|
||||
self.plugin_manager.load_all(auto_activate=True)
|
||||
|
||||
# Start game client
|
||||
self.game_client.start()
|
||||
|
||||
# Run main loop
|
||||
try:
|
||||
self._main_loop()
|
||||
except KeyboardInterrupt:
|
||||
self.shutdown()
|
||||
|
||||
def shutdown(self):
|
||||
"""Shutdown the application gracefully."""
|
||||
if self.plugin_manager:
|
||||
self.plugin_manager.shutdown()
|
||||
if self.game_client:
|
||||
self.game_client.stop()
|
||||
|
||||
def _root_reducer(self, state, action):
|
||||
"""Root state reducer."""
|
||||
# Default reducer - just returns state
|
||||
# Plugins can register their own reducers
|
||||
return state or {}
|
||||
|
||||
def _get_initial_state(self):
|
||||
"""Get initial application state."""
|
||||
return {
|
||||
'app': {
|
||||
'version': __version__,
|
||||
'initialized': False,
|
||||
'game_connected': False,
|
||||
},
|
||||
'player': {
|
||||
'name': None,
|
||||
'skills': {},
|
||||
'loot': [],
|
||||
},
|
||||
'plugins': {},
|
||||
}
|
||||
|
||||
def _main_loop(self):
|
||||
"""Main application loop."""
|
||||
import time
|
||||
while True:
|
||||
time.sleep(0.1)
|
||||
|
||||
|
||||
__all__ = [
|
||||
# Version
|
||||
'__version__',
|
||||
# Core classes
|
||||
'EUUtilityApp',
|
||||
# State management
|
||||
'StateStore', 'ActionBase', 'create_store', 'get_store',
|
||||
# Event system
|
||||
'EventBus', 'Event', 'EventPriority',
|
||||
# Plugin system
|
||||
'PluginManager', 'PluginAPI', 'PluginManifest', 'PluginContext',
|
||||
'PluginInstance', 'PluginState', 'PermissionLevel',
|
||||
'PluginError', 'PluginLoadError', 'PluginInitError',
|
||||
# Widgets
|
||||
'Widget', 'WidgetConfig', 'Dashboard',
|
||||
# EU Integration
|
||||
'GameClient', 'LogParser', 'GameEvent', 'LootEvent', 'SkillEvent',
|
||||
]
|
||||
|
|
@ -1,599 +0,0 @@
|
|||
"""
|
||||
EU-Utility Premium - Event Bus
|
||||
===============================
|
||||
|
||||
High-performance async event system for inter-plugin communication.
|
||||
|
||||
Features:
|
||||
- Async/await support
|
||||
- Event prioritization
|
||||
- Event filtering and routing
|
||||
- Buffered events for high-throughput scenarios
|
||||
- Type-safe event definitions
|
||||
|
||||
Example:
|
||||
from premium.core.event_bus import EventBus, Event, EventPriority
|
||||
|
||||
bus = EventBus()
|
||||
|
||||
# Subscribe to events
|
||||
@bus.on('game.loot')
|
||||
async def handle_loot(event: Event):
|
||||
print(f"Got loot: {event.data}")
|
||||
|
||||
# Emit events
|
||||
bus.emit('game.loot', {'item': 'Angel Scales', 'value': 150.50})
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import logging
|
||||
import time
|
||||
import traceback
|
||||
import weakref
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import defaultdict, deque
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum, auto
|
||||
from typing import (
|
||||
Any, Callable, Coroutine, Dict, List, Optional, Set, Type, TypeVar,
|
||||
Union, Generic, Protocol, runtime_checkable
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# EVENT PRIORITY
|
||||
# =============================================================================
|
||||
|
||||
class EventPriority(Enum):
|
||||
"""Priority levels for event handlers.
|
||||
|
||||
Higher priority handlers are called first.
|
||||
"""
|
||||
CRITICAL = 100 # System-critical events (error handling, shutdown)
|
||||
HIGH = 75 # Important game events (loot, globals)
|
||||
NORMAL = 50 # Standard events
|
||||
LOW = 25 # Background tasks
|
||||
BACKGROUND = 10 # Analytics, logging
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# EVENT CLASS
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class Event:
|
||||
"""Represents an event in the system.
|
||||
|
||||
Attributes:
|
||||
type: Event type/category
|
||||
data: Event payload data
|
||||
source: Source of the event (plugin_id or system)
|
||||
timestamp: When the event was created
|
||||
priority: Event priority
|
||||
id: Unique event identifier
|
||||
"""
|
||||
type: str
|
||||
data: Dict[str, Any] = field(default_factory=dict)
|
||||
source: str = "system"
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
priority: EventPriority = EventPriority.NORMAL
|
||||
id: str = field(default_factory=lambda: f"evt_{int(time.time()*1000)}_{id(Event) % 10000}")
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a value from event data."""
|
||||
return self.data.get(key, default)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert event to dictionary."""
|
||||
return {
|
||||
'id': self.id,
|
||||
'type': self.type,
|
||||
'data': self.data,
|
||||
'source': self.source,
|
||||
'timestamp': self.timestamp,
|
||||
'priority': self.priority.name,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
event_type: str,
|
||||
data: Dict[str, Any],
|
||||
source: str = "system",
|
||||
priority: EventPriority = EventPriority.NORMAL
|
||||
) -> Event:
|
||||
"""Create a new event."""
|
||||
return cls(
|
||||
type=event_type,
|
||||
data=data,
|
||||
source=source,
|
||||
priority=priority
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# EVENT FILTER
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class EventFilter:
|
||||
"""Filter for event subscription.
|
||||
|
||||
Allows subscribers to receive only events matching specific criteria.
|
||||
|
||||
Example:
|
||||
filter = EventFilter(
|
||||
event_types=['game.loot', 'game.skill'],
|
||||
sources=['game_client'],
|
||||
min_priority=EventPriority.HIGH
|
||||
)
|
||||
"""
|
||||
event_types: Optional[Set[str]] = None
|
||||
sources: Optional[Set[str]] = None
|
||||
min_priority: Optional[EventPriority] = None
|
||||
data_filter: Optional[Callable[[Dict[str, Any]], bool]] = None
|
||||
|
||||
def matches(self, event: Event) -> bool:
|
||||
"""Check if an event matches this filter."""
|
||||
if self.event_types and event.type not in self.event_types:
|
||||
return False
|
||||
|
||||
if self.sources and event.source not in self.sources:
|
||||
return False
|
||||
|
||||
if self.min_priority and event.priority.value < self.min_priority.value:
|
||||
return False
|
||||
|
||||
if self.data_filter and not self.data_filter(event.data):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SUBSCRIPTION
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class Subscription:
|
||||
"""Represents an event subscription."""
|
||||
id: str
|
||||
event_types: Set[str]
|
||||
callback: Callable[[Event], Any]
|
||||
filter: Optional[EventFilter]
|
||||
priority: EventPriority
|
||||
once: bool
|
||||
|
||||
def matches(self, event: Event) -> bool:
|
||||
"""Check if this subscription matches an event."""
|
||||
if self.event_types and event.type not in self.event_types:
|
||||
return False
|
||||
|
||||
if self.filter and not self.filter.matches(event):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# EVENT BUS
|
||||
# =============================================================================
|
||||
|
||||
HandlerType = Callable[[Event], Any]
|
||||
|
||||
|
||||
class EventBus:
|
||||
"""High-performance async event bus.
|
||||
|
||||
Features:
|
||||
- Async and sync handler support
|
||||
- Priority-based execution order
|
||||
- Event filtering
|
||||
- Buffered event queues
|
||||
- Wildcard subscriptions
|
||||
|
||||
Example:
|
||||
bus = EventBus()
|
||||
|
||||
# Sync handler
|
||||
@bus.on('game.loot')
|
||||
def handle_loot(event):
|
||||
print(f"Loot: {event.data}")
|
||||
|
||||
# Async handler
|
||||
@bus.on('game.global', priority=EventPriority.HIGH)
|
||||
async def handle_global(event):
|
||||
await notify_discord(event.data)
|
||||
|
||||
# Emit event
|
||||
bus.emit('game.loot', {'item': 'Uber Item', 'value': 1000})
|
||||
"""
|
||||
|
||||
def __init__(self, max_queue_size: int = 10000):
|
||||
"""Initialize event bus.
|
||||
|
||||
Args:
|
||||
max_queue_size: Maximum events in queue before dropping
|
||||
"""
|
||||
self._subscribers: Dict[str, Set[Subscription]] = defaultdict(set)
|
||||
self._wildcard_subscribers: Set[Subscription] = set()
|
||||
self._queue: deque = deque(maxlen=max_queue_size)
|
||||
self._logger = logging.getLogger("EventBus")
|
||||
self._sub_counter = 0
|
||||
self._running = False
|
||||
self._lock = asyncio.Lock()
|
||||
self._event_count = 0
|
||||
self._dropped_count = 0
|
||||
|
||||
# ========== Subscription Methods ==========
|
||||
|
||||
def on(
|
||||
self,
|
||||
event_type: Union[str, List[str]],
|
||||
priority: EventPriority = EventPriority.NORMAL,
|
||||
filter: Optional[EventFilter] = None,
|
||||
once: bool = False
|
||||
) -> Callable[[HandlerType], HandlerType]:
|
||||
"""Decorator to subscribe to events.
|
||||
|
||||
Args:
|
||||
event_type: Event type(s) to subscribe to
|
||||
priority: Handler priority
|
||||
filter: Optional event filter
|
||||
once: Remove after first matching event
|
||||
|
||||
Example:
|
||||
@bus.on('game.loot')
|
||||
def handle_loot(event):
|
||||
print(event.data)
|
||||
"""
|
||||
def decorator(handler: HandlerType) -> HandlerType:
|
||||
self.subscribe(event_type, handler, priority, filter, once)
|
||||
return handler
|
||||
return decorator
|
||||
|
||||
def subscribe(
|
||||
self,
|
||||
event_type: Union[str, List[str]],
|
||||
handler: HandlerType,
|
||||
priority: EventPriority = EventPriority.NORMAL,
|
||||
filter: Optional[EventFilter] = None,
|
||||
once: bool = False
|
||||
) -> str:
|
||||
"""Subscribe to events.
|
||||
|
||||
Args:
|
||||
event_type: Event type(s) to subscribe to, or '*' for all
|
||||
handler: Callback function
|
||||
priority: Handler priority
|
||||
filter: Optional event filter
|
||||
once: Remove after first matching event
|
||||
|
||||
Returns:
|
||||
Subscription ID
|
||||
"""
|
||||
self._sub_counter += 1
|
||||
sub_id = f"sub_{self._sub_counter}"
|
||||
|
||||
# Handle wildcard subscription
|
||||
if event_type == '*':
|
||||
sub = Subscription(
|
||||
id=sub_id,
|
||||
event_types=set(),
|
||||
callback=handler,
|
||||
filter=filter,
|
||||
priority=priority,
|
||||
once=once
|
||||
)
|
||||
self._wildcard_subscribers.add(sub)
|
||||
return sub_id
|
||||
|
||||
# Handle single or multiple event types
|
||||
if isinstance(event_type, str):
|
||||
event_types = {event_type}
|
||||
else:
|
||||
event_types = set(event_type)
|
||||
|
||||
sub = Subscription(
|
||||
id=sub_id,
|
||||
event_types=event_types,
|
||||
callback=handler,
|
||||
filter=filter,
|
||||
priority=priority,
|
||||
once=once
|
||||
)
|
||||
|
||||
for et in event_types:
|
||||
self._subscribers[et].add(sub)
|
||||
|
||||
return sub_id
|
||||
|
||||
def unsubscribe(self, subscription_id: str) -> bool:
|
||||
"""Unsubscribe from events.
|
||||
|
||||
Args:
|
||||
subscription_id: ID returned by subscribe()
|
||||
|
||||
Returns:
|
||||
True if subscription was found and removed
|
||||
"""
|
||||
# Check wildcard subscribers
|
||||
for sub in self._wildcard_subscribers:
|
||||
if sub.id == subscription_id:
|
||||
self._wildcard_subscribers.remove(sub)
|
||||
return True
|
||||
|
||||
# Check typed subscribers
|
||||
for event_type, subs in self._subscribers.items():
|
||||
for sub in list(subs):
|
||||
if sub.id == subscription_id:
|
||||
subs.remove(sub)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def once(
|
||||
self,
|
||||
event_type: str,
|
||||
handler: HandlerType,
|
||||
priority: EventPriority = EventPriority.NORMAL,
|
||||
timeout: Optional[float] = None
|
||||
) -> asyncio.Future:
|
||||
"""Subscribe to a single event.
|
||||
|
||||
Args:
|
||||
event_type: Event type to wait for
|
||||
handler: Optional handler callback
|
||||
priority: Handler priority
|
||||
timeout: Optional timeout in seconds
|
||||
|
||||
Returns:
|
||||
Future that resolves with the event
|
||||
"""
|
||||
future = asyncio.Future()
|
||||
|
||||
def wrapper(event: Event):
|
||||
if handler:
|
||||
handler(event)
|
||||
if not future.done():
|
||||
future.set_result(event)
|
||||
|
||||
self.subscribe(event_type, wrapper, priority, once=True)
|
||||
|
||||
if timeout:
|
||||
async def timeout_handler():
|
||||
await asyncio.sleep(timeout)
|
||||
if not future.done():
|
||||
future.set_exception(TimeoutError(f"Event {event_type} timed out"))
|
||||
|
||||
asyncio.create_task(timeout_handler())
|
||||
|
||||
return future
|
||||
|
||||
# ========== Event Emission ==========
|
||||
|
||||
def emit(
|
||||
self,
|
||||
event_type: str,
|
||||
data: Dict[str, Any],
|
||||
source: str = "system",
|
||||
priority: EventPriority = EventPriority.NORMAL
|
||||
) -> Event:
|
||||
"""Emit an event.
|
||||
|
||||
Args:
|
||||
event_type: Type of event
|
||||
data: Event data
|
||||
source: Event source
|
||||
priority: Event priority
|
||||
|
||||
Returns:
|
||||
The created event
|
||||
"""
|
||||
event = Event.create(
|
||||
event_type=event_type,
|
||||
data=data,
|
||||
source=source,
|
||||
priority=priority
|
||||
)
|
||||
|
||||
self._queue.append(event)
|
||||
self._event_count += 1
|
||||
|
||||
# Process immediately in async context
|
||||
if asyncio.get_event_loop().is_running():
|
||||
asyncio.create_task(self._process_event(event))
|
||||
|
||||
return event
|
||||
|
||||
async def emit_async(
|
||||
self,
|
||||
event_type: str,
|
||||
data: Dict[str, Any],
|
||||
source: str = "system",
|
||||
priority: EventPriority = EventPriority.NORMAL
|
||||
) -> Event:
|
||||
"""Emit an event asynchronously."""
|
||||
event = Event.create(
|
||||
event_type=event_type,
|
||||
data=data,
|
||||
source=source,
|
||||
priority=priority
|
||||
)
|
||||
|
||||
await self._process_event(event)
|
||||
return event
|
||||
|
||||
async def _process_event(self, event: Event) -> None:
|
||||
"""Process a single event."""
|
||||
# Collect all matching handlers
|
||||
handlers: List[tuple] = []
|
||||
|
||||
# Get handlers for specific event type
|
||||
for sub in self._subscribers.get(event.type, set()):
|
||||
if sub.matches(event):
|
||||
handlers.append((sub.priority.value, sub))
|
||||
|
||||
# Get wildcard handlers
|
||||
for sub in self._wildcard_subscribers:
|
||||
if sub.filter is None or sub.filter.matches(event):
|
||||
handlers.append((sub.priority.value, sub))
|
||||
|
||||
# Sort by priority (highest first)
|
||||
handlers.sort(key=lambda x: -x[0])
|
||||
|
||||
# Execute handlers
|
||||
once_subs = []
|
||||
|
||||
for _, sub in handlers:
|
||||
try:
|
||||
if inspect.iscoroutinefunction(sub.callback):
|
||||
await sub.callback(event)
|
||||
else:
|
||||
sub.callback(event)
|
||||
|
||||
if sub.once:
|
||||
once_subs.append(sub)
|
||||
|
||||
except Exception as e:
|
||||
self._logger.error(f"Error in event handler: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
# Remove once subscriptions
|
||||
for sub in once_subs:
|
||||
self.unsubscribe(sub.id)
|
||||
|
||||
# ========== Utility Methods ==========
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all subscriptions and queued events."""
|
||||
self._subscribers.clear()
|
||||
self._wildcard_subscribers.clear()
|
||||
self._queue.clear()
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""Get event bus statistics."""
|
||||
return {
|
||||
'total_events': self._event_count,
|
||||
'dropped_events': self._dropped_count,
|
||||
'queue_size': len(self._queue),
|
||||
'subscription_count': sum(
|
||||
len(subs) for subs in self._subscribers.values()
|
||||
),
|
||||
'wildcard_subscriptions': len(self._wildcard_subscribers),
|
||||
}
|
||||
|
||||
def wait_for(
|
||||
self,
|
||||
event_type: str,
|
||||
condition: Optional[Callable[[Event], bool]] = None,
|
||||
timeout: Optional[float] = None
|
||||
) -> asyncio.Future:
|
||||
"""Wait for a specific event.
|
||||
|
||||
Args:
|
||||
event_type: Event type to wait for
|
||||
condition: Optional condition function
|
||||
timeout: Optional timeout in seconds
|
||||
|
||||
Returns:
|
||||
Future that resolves with the matching event
|
||||
"""
|
||||
future = asyncio.Future()
|
||||
|
||||
def handler(event: Event):
|
||||
if condition and not condition(event):
|
||||
return
|
||||
if not future.done():
|
||||
future.set_result(event)
|
||||
return True # Unsubscribe
|
||||
|
||||
self.subscribe(event_type, handler, once=True)
|
||||
|
||||
if timeout:
|
||||
async def timeout_coro():
|
||||
await asyncio.sleep(timeout)
|
||||
if not future.done():
|
||||
future.set_exception(TimeoutError(f"Timeout waiting for {event_type}"))
|
||||
|
||||
asyncio.create_task(timeout_coro())
|
||||
|
||||
return future
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TYPED EVENTS
|
||||
# =============================================================================
|
||||
|
||||
class TypedEventBus(EventBus):
|
||||
"""Type-safe event bus with predefined event types."""
|
||||
|
||||
# Game events
|
||||
GAME_CONNECTED = "game.connected"
|
||||
GAME_DISCONNECTED = "game.disconnected"
|
||||
GAME_FOCUS_CHANGED = "game.focus_changed"
|
||||
|
||||
# Loot events
|
||||
LOOT_RECEIVED = "game.loot"
|
||||
GLOBAL_HOF = "game.global"
|
||||
|
||||
# Skill events
|
||||
SKILL_GAIN = "game.skill"
|
||||
PROFESSION_GAIN = "game.profession"
|
||||
|
||||
# Chat events
|
||||
CHAT_MESSAGE = "game.chat"
|
||||
|
||||
# System events
|
||||
PLUGIN_LOADED = "system.plugin.loaded"
|
||||
PLUGIN_UNLOADED = "system.plugin.unloaded"
|
||||
ERROR_OCCURRED = "system.error"
|
||||
|
||||
def emit_game_connected(self, character_name: str) -> Event:
|
||||
"""Emit game connected event."""
|
||||
return self.emit(self.GAME_CONNECTED, {
|
||||
'character_name': character_name
|
||||
})
|
||||
|
||||
def emit_loot(self, item: str, value: float, quantity: int = 1) -> Event:
|
||||
"""Emit loot received event."""
|
||||
return self.emit(self.LOOT_RECEIVED, {
|
||||
'item': item,
|
||||
'value': value,
|
||||
'quantity': quantity,
|
||||
'timestamp': time.time(),
|
||||
})
|
||||
|
||||
def emit_skill(self, skill_name: str, value: float, gain: float) -> Event:
|
||||
"""Emit skill gain event."""
|
||||
return self.emit(self.SKILL_GAIN, {
|
||||
'skill': skill_name,
|
||||
'value': value,
|
||||
'gain': gain,
|
||||
})
|
||||
|
||||
def emit_global(self, mob_name: str, value: float, player: str) -> Event:
|
||||
"""Emit global/HoF event."""
|
||||
return self.emit(self.GLOBAL_HOF, {
|
||||
'mob': mob_name,
|
||||
'value': value,
|
||||
'player': player,
|
||||
'is_hof': value >= 1000,
|
||||
})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# EXPORTS
|
||||
# =============================================================================
|
||||
|
||||
__all__ = [
|
||||
'EventPriority',
|
||||
'Event',
|
||||
'EventFilter',
|
||||
'Subscription',
|
||||
'EventBus',
|
||||
'TypedEventBus',
|
||||
]
|
||||
|
|
@ -1,711 +0,0 @@
|
|||
"""
|
||||
EU-Utility Premium - State Management
|
||||
======================================
|
||||
|
||||
Redux-inspired state store with:
|
||||
- Immutable state updates
|
||||
- Time-travel debugging
|
||||
- Selectors for derived state
|
||||
- Middleware support
|
||||
- State persistence
|
||||
|
||||
Example:
|
||||
from premium.core.state import StateStore, Action, Reducer
|
||||
|
||||
# Define actions
|
||||
class IncrementAction(Action):
|
||||
type = "INCREMENT"
|
||||
|
||||
# Define reducer
|
||||
def counter_reducer(state: int, action: Action) -> int:
|
||||
if action.type == "INCREMENT":
|
||||
return state + 1
|
||||
return state
|
||||
|
||||
# Create store
|
||||
store = StateStore(counter_reducer, initial_state=0)
|
||||
store.dispatch(IncrementAction())
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from datetime import datetime
|
||||
from enum import Enum, auto
|
||||
from pathlib import Path
|
||||
from typing import (
|
||||
Any, Callable, Dict, Generic, List, Optional, Set, TypeVar, Union,
|
||||
Protocol, runtime_checkable
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TYPE DEFINITIONS
|
||||
# =============================================================================
|
||||
|
||||
T = TypeVar('T')
|
||||
State = TypeVar('State')
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Action(Protocol):
|
||||
"""Protocol for actions."""
|
||||
type: str
|
||||
payload: Optional[Any] = None
|
||||
meta: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class ActionBase:
|
||||
"""Base class for actions."""
|
||||
type: str = "UNKNOWN"
|
||||
payload: Any = None
|
||||
meta: Optional[Dict[str, Any]] = None
|
||||
timestamp: datetime = field(default_factory=datetime.now)
|
||||
|
||||
def __init__(self, payload: Any = None, meta: Optional[Dict[str, Any]] = None):
|
||||
self.payload = payload
|
||||
self.meta = meta or {}
|
||||
self.timestamp = datetime.now()
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert action to dictionary."""
|
||||
return {
|
||||
'type': self.type,
|
||||
'payload': self.payload,
|
||||
'meta': self.meta,
|
||||
'timestamp': self.timestamp.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# Reducer type: (state, action) -> new_state
|
||||
Reducer = Callable[[State, Action], State]
|
||||
|
||||
# Selector type: state -> derived_value
|
||||
Selector = Callable[[State], T]
|
||||
|
||||
# Subscriber type: (new_state, old_state) -> None
|
||||
Subscriber = Callable[[State, State], None]
|
||||
|
||||
# Middleware type: store -> next -> action -> result
|
||||
Middleware = Callable[['StateStore', Callable[[Action], Any], Action], Any]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# BUILT-IN ACTIONS
|
||||
# =============================================================================
|
||||
|
||||
class StateResetAction(ActionBase):
|
||||
"""Reset state to initial value."""
|
||||
type = "@@STATE/RESET"
|
||||
|
||||
|
||||
class StateRestoreAction(ActionBase):
|
||||
"""Restore state from snapshot."""
|
||||
type = "@@STATE/RESTORE"
|
||||
|
||||
def __init__(self, state: State, meta: Optional[Dict[str, Any]] = None):
|
||||
super().__init__(payload=state, meta=meta)
|
||||
|
||||
|
||||
class StateBatchAction(ActionBase):
|
||||
"""Batch multiple actions."""
|
||||
type = "@@STATE/BATCH"
|
||||
|
||||
def __init__(self, actions: List[Action], meta: Optional[Dict[str, Any]] = None):
|
||||
super().__init__(payload=actions, meta=meta)
|
||||
|
||||
|
||||
class StateHydrateAction(ActionBase):
|
||||
"""Hydrate state from persisted data."""
|
||||
type = "@@STATE/HYDRATE"
|
||||
|
||||
def __init__(self, state: State, meta: Optional[Dict[str, Any]] = None):
|
||||
super().__init__(payload=state, meta=meta)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# STATE SNAPSHOT
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class StateSnapshot:
|
||||
"""Immutable snapshot of state at a point in time."""
|
||||
state: Any
|
||||
action: Optional[Action] = None
|
||||
timestamp: datetime = field(default_factory=datetime.now)
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert snapshot to dictionary."""
|
||||
return {
|
||||
'id': self.id,
|
||||
'state': self.state,
|
||||
'action': self.action.to_dict() if self.action else None,
|
||||
'timestamp': self.timestamp.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# STORE SLICE
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class StoreSlice(Generic[T]):
|
||||
"""A slice of the store with its own reducer and state."""
|
||||
name: str
|
||||
reducer: Reducer
|
||||
initial_state: T
|
||||
selectors: Dict[str, Selector] = field(default_factory=dict)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# COMBINED REDUCER
|
||||
# =============================================================================
|
||||
|
||||
def combine_reducers(reducers: Dict[str, Reducer]) -> Reducer:
|
||||
"""Combine multiple reducers into one.
|
||||
|
||||
Each reducer manages a slice of the state.
|
||||
|
||||
Args:
|
||||
reducers: Dict mapping slice names to reducers
|
||||
|
||||
Returns:
|
||||
Combined reducer function
|
||||
|
||||
Example:
|
||||
root_reducer = combine_reducers({
|
||||
'counter': counter_reducer,
|
||||
'todos': todos_reducer,
|
||||
})
|
||||
"""
|
||||
def combined(state: Dict[str, Any], action: Action) -> Dict[str, Any]:
|
||||
new_state = {}
|
||||
has_changed = False
|
||||
|
||||
for key, reducer in reducers.items():
|
||||
previous_state = state.get(key) if isinstance(state, dict) else None
|
||||
new_slice = reducer(previous_state, action)
|
||||
new_state[key] = new_slice
|
||||
|
||||
if new_slice is not previous_state:
|
||||
has_changed = True
|
||||
|
||||
return new_state if has_changed else state
|
||||
|
||||
return combined
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MIDDLEWARE
|
||||
# =============================================================================
|
||||
|
||||
def logging_middleware(store: StateStore, next: Callable[[Action], Any], action: Action) -> Any:
|
||||
"""Middleware that logs all actions."""
|
||||
print(f"[State] Action: {action.type}")
|
||||
result = next(action)
|
||||
print(f"[State] New state: {store.get_state()}")
|
||||
return result
|
||||
|
||||
|
||||
def thunk_middleware(store: StateStore, next: Callable[[Action], Any], action: Action) -> Any:
|
||||
"""Middleware that allows thunk actions (functions)."""
|
||||
if callable(action) and not isinstance(action, ActionBase):
|
||||
# It's a thunk - call it with dispatch and get_state
|
||||
return action(store.dispatch, store.get_state)
|
||||
return next(action)
|
||||
|
||||
|
||||
def persistence_middleware(
|
||||
storage_path: Path,
|
||||
debounce_ms: int = 1000
|
||||
) -> Middleware:
|
||||
"""Create middleware that persists state to disk.
|
||||
|
||||
Args:
|
||||
storage_path: Path to store state
|
||||
debounce_ms: Debounce time in milliseconds
|
||||
|
||||
Returns:
|
||||
Middleware function
|
||||
"""
|
||||
last_save = 0
|
||||
pending_save = False
|
||||
|
||||
def middleware(store: StateStore, next: Callable[[Action], Any], action: Action) -> Any:
|
||||
nonlocal last_save, pending_save
|
||||
|
||||
result = next(action)
|
||||
|
||||
# Debounce saves
|
||||
current_time = time.time() * 1000
|
||||
if current_time - last_save > debounce_ms:
|
||||
try:
|
||||
state = store.get_state()
|
||||
with open(storage_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(state, f, indent=2, default=str)
|
||||
last_save = current_time
|
||||
pending_save = False
|
||||
except Exception as e:
|
||||
logging.getLogger("StateStore").error(f"Failed to persist state: {e}")
|
||||
else:
|
||||
pending_save = True
|
||||
|
||||
return result
|
||||
|
||||
return middleware
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# STATE STORE
|
||||
# =============================================================================
|
||||
|
||||
class StateStore(Generic[State]):
|
||||
"""Redux-inspired state store with time-travel debugging.
|
||||
|
||||
Features:
|
||||
- Immutable state updates
|
||||
- Action history for debugging
|
||||
- State snapshots for time travel
|
||||
- Selectors for derived state
|
||||
- Middleware support
|
||||
- State persistence
|
||||
|
||||
Example:
|
||||
store = StateStore(
|
||||
reducer=root_reducer,
|
||||
initial_state={'count': 0},
|
||||
middleware=[logging_middleware]
|
||||
)
|
||||
|
||||
# Subscribe to changes
|
||||
unsubscribe = store.subscribe(lambda new, old: print(f"Changed: {old} -> {new}"))
|
||||
|
||||
# Dispatch actions
|
||||
store.dispatch(IncrementAction())
|
||||
|
||||
# Use selectors
|
||||
count = store.select(lambda state: state['count'])
|
||||
|
||||
# Time travel
|
||||
store.undo() # Undo last action
|
||||
store.jump_to_snapshot(0) # Jump to initial state
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
reducer: Reducer[State, Action],
|
||||
initial_state: Optional[State] = None,
|
||||
middleware: Optional[List[Middleware]] = None,
|
||||
max_history: int = 1000,
|
||||
enable_time_travel: bool = True
|
||||
):
|
||||
"""Initialize state store.
|
||||
|
||||
Args:
|
||||
reducer: Root reducer function
|
||||
initial_state: Initial state value
|
||||
middleware: List of middleware functions
|
||||
max_history: Maximum action history size
|
||||
enable_time_travel: Enable time-travel debugging
|
||||
"""
|
||||
self._reducer = reducer
|
||||
self._state: State = initial_state
|
||||
self._initial_state = copy.deepcopy(initial_state)
|
||||
self._middleware = middleware or []
|
||||
self._max_history = max_history
|
||||
self._enable_time_travel = enable_time_travel
|
||||
|
||||
# Subscribers
|
||||
self._subscribers: Dict[str, Subscriber] = {}
|
||||
self._subscriber_counter = 0
|
||||
|
||||
# History for time travel
|
||||
self._history: List[StateSnapshot] = []
|
||||
self._current_index = -1
|
||||
|
||||
# Lock for thread safety
|
||||
self._lock = threading.RLock()
|
||||
self._logger = logging.getLogger("StateStore")
|
||||
|
||||
# Create initial snapshot
|
||||
if enable_time_travel:
|
||||
self._add_snapshot(StateSnapshot(
|
||||
state=copy.deepcopy(self._state),
|
||||
action=None
|
||||
))
|
||||
|
||||
# ========== Core Methods ==========
|
||||
|
||||
def get_state(self) -> State:
|
||||
"""Get current state (immutable)."""
|
||||
with self._lock:
|
||||
return copy.deepcopy(self._state)
|
||||
|
||||
def dispatch(self, action: Action) -> Action:
|
||||
"""Dispatch an action to update state.
|
||||
|
||||
Args:
|
||||
action: Action to dispatch
|
||||
|
||||
Returns:
|
||||
The dispatched action
|
||||
"""
|
||||
# Apply middleware chain
|
||||
def dispatch_action(a: Action) -> Action:
|
||||
return self._apply_reducer(a)
|
||||
|
||||
# Build middleware chain
|
||||
chain = dispatch_action
|
||||
for mw in reversed(self._middleware):
|
||||
chain = lambda a, mw=mw, next=chain: mw(self, next, a)
|
||||
|
||||
return chain(action)
|
||||
|
||||
def _apply_reducer(self, action: Action) -> Action:
|
||||
"""Apply reducer and update state."""
|
||||
with self._lock:
|
||||
old_state = self._state
|
||||
new_state = self._reducer(copy.deepcopy(old_state), action)
|
||||
|
||||
# Only update if state changed
|
||||
if new_state is not old_state:
|
||||
self._state = new_state
|
||||
|
||||
# Add to history
|
||||
if self._enable_time_travel:
|
||||
# Remove any future states if we're not at the end
|
||||
if self._current_index < len(self._history) - 1:
|
||||
self._history = self._history[:self._current_index + 1]
|
||||
|
||||
self._add_snapshot(StateSnapshot(
|
||||
state=copy.deepcopy(new_state),
|
||||
action=action
|
||||
))
|
||||
|
||||
# Notify subscribers
|
||||
self._notify_subscribers(new_state, old_state)
|
||||
|
||||
return action
|
||||
|
||||
def _add_snapshot(self, snapshot: StateSnapshot) -> None:
|
||||
"""Add snapshot to history."""
|
||||
self._history.append(snapshot)
|
||||
self._current_index = len(self._history) - 1
|
||||
|
||||
# Trim history if needed
|
||||
if len(self._history) > self._max_history:
|
||||
self._history = self._history[-self._max_history:]
|
||||
self._current_index = len(self._history) - 1
|
||||
|
||||
# ========== Subscription ==========
|
||||
|
||||
def subscribe(self, callback: Subscriber, selector: Optional[Selector] = None) -> Callable[[], None]:
|
||||
"""Subscribe to state changes.
|
||||
|
||||
Args:
|
||||
callback: Function called when state changes
|
||||
selector: Optional selector to compare specific parts
|
||||
|
||||
Returns:
|
||||
Unsubscribe function
|
||||
"""
|
||||
with self._lock:
|
||||
self._subscriber_counter += 1
|
||||
sub_id = f"sub_{self._subscriber_counter}"
|
||||
|
||||
# Wrap callback with selector if provided
|
||||
if selector:
|
||||
last_value = selector(self._state)
|
||||
|
||||
def wrapped_callback(new_state: State, old_state: State) -> None:
|
||||
nonlocal last_value
|
||||
new_value = selector(new_state)
|
||||
if new_value != last_value:
|
||||
last_value = new_value
|
||||
callback(new_state, old_state)
|
||||
|
||||
self._subscribers[sub_id] = wrapped_callback
|
||||
else:
|
||||
self._subscribers[sub_id] = callback
|
||||
|
||||
def unsubscribe() -> None:
|
||||
with self._lock:
|
||||
self._subscribers.pop(sub_id, None)
|
||||
|
||||
return unsubscribe
|
||||
|
||||
def _notify_subscribers(self, new_state: State, old_state: State) -> None:
|
||||
"""Notify all subscribers of state change."""
|
||||
for callback in list(self._subscribers.values()):
|
||||
try:
|
||||
callback(new_state, old_state)
|
||||
except Exception as e:
|
||||
self._logger.error(f"Error in subscriber: {e}")
|
||||
|
||||
# ========== Selectors ==========
|
||||
|
||||
def select(self, selector: Selector[T]) -> T:
|
||||
"""Select a derived value from state.
|
||||
|
||||
Args:
|
||||
selector: Function that extracts value from state
|
||||
|
||||
Returns:
|
||||
Selected value
|
||||
"""
|
||||
with self._lock:
|
||||
return selector(copy.deepcopy(self._state))
|
||||
|
||||
def create_selector(self, *input_selectors: Selector, combiner: Callable) -> Selector:
|
||||
"""Create a memoized selector.
|
||||
|
||||
Args:
|
||||
input_selectors: Selectors that provide input values
|
||||
combiner: Function that combines inputs into output
|
||||
|
||||
Returns:
|
||||
Memoized selector function
|
||||
"""
|
||||
last_inputs = [None] * len(input_selectors)
|
||||
last_result = None
|
||||
|
||||
def memoized_selector(state: State) -> Any:
|
||||
nonlocal last_inputs, last_result
|
||||
|
||||
inputs = [s(state) for s in input_selectors]
|
||||
|
||||
# Check if inputs changed
|
||||
if inputs != last_inputs:
|
||||
last_inputs = inputs
|
||||
last_result = combiner(*inputs)
|
||||
|
||||
return last_result
|
||||
|
||||
return memoized_selector
|
||||
|
||||
# ========== Time Travel ==========
|
||||
|
||||
def get_history(self) -> List[StateSnapshot]:
|
||||
"""Get action history."""
|
||||
with self._lock:
|
||||
return self._history.copy()
|
||||
|
||||
def get_current_index(self) -> int:
|
||||
"""Get current position in history."""
|
||||
return self._current_index
|
||||
|
||||
def can_undo(self) -> bool:
|
||||
"""Check if undo is possible."""
|
||||
return self._current_index > 0
|
||||
|
||||
def can_redo(self) -> bool:
|
||||
"""Check if redo is possible."""
|
||||
return self._current_index < len(self._history) - 1
|
||||
|
||||
def undo(self) -> bool:
|
||||
"""Undo last action.
|
||||
|
||||
Returns:
|
||||
True if undo was performed
|
||||
"""
|
||||
if not self.can_undo():
|
||||
return False
|
||||
|
||||
with self._lock:
|
||||
self._current_index -= 1
|
||||
old_state = self._state
|
||||
self._state = copy.deepcopy(self._history[self._current_index].state)
|
||||
self._notify_subscribers(self._state, old_state)
|
||||
|
||||
return True
|
||||
|
||||
def redo(self) -> bool:
|
||||
"""Redo last undone action.
|
||||
|
||||
Returns:
|
||||
True if redo was performed
|
||||
"""
|
||||
if not self.can_redo():
|
||||
return False
|
||||
|
||||
with self._lock:
|
||||
self._current_index += 1
|
||||
old_state = self._state
|
||||
self._state = copy.deepcopy(self._history[self._current_index].state)
|
||||
self._notify_subscribers(self._state, old_state)
|
||||
|
||||
return True
|
||||
|
||||
def jump_to_snapshot(self, index: int) -> bool:
|
||||
"""Jump to a specific snapshot in history.
|
||||
|
||||
Args:
|
||||
index: Snapshot index
|
||||
|
||||
Returns:
|
||||
True if jump was successful
|
||||
"""
|
||||
if index < 0 or index >= len(self._history):
|
||||
return False
|
||||
|
||||
with self._lock:
|
||||
old_state = self._state
|
||||
self._current_index = index
|
||||
self._state = copy.deepcopy(self._history[index].state)
|
||||
self._notify_subscribers(self._state, old_state)
|
||||
|
||||
return True
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset to initial state."""
|
||||
self.dispatch(StateResetAction())
|
||||
|
||||
with self._lock:
|
||||
old_state = self._state
|
||||
self._state = copy.deepcopy(self._initial_state)
|
||||
|
||||
if self._enable_time_travel:
|
||||
self._history.clear()
|
||||
self._current_index = -1
|
||||
self._add_snapshot(StateSnapshot(
|
||||
state=copy.deepcopy(self._state),
|
||||
action=None
|
||||
))
|
||||
|
||||
self._notify_subscribers(self._state, old_state)
|
||||
|
||||
# ========== Persistence ==========
|
||||
|
||||
def save_to_disk(self, path: Path) -> bool:
|
||||
"""Save current state to disk.
|
||||
|
||||
Args:
|
||||
path: File path to save to
|
||||
|
||||
Returns:
|
||||
True if saved successfully
|
||||
"""
|
||||
try:
|
||||
with self._lock:
|
||||
state_data = {
|
||||
'state': self._state,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'history_count': len(self._history),
|
||||
}
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
json.dump(state_data, f, indent=2, default=str)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
self._logger.error(f"Failed to save state: {e}")
|
||||
return False
|
||||
|
||||
def load_from_disk(self, path: Path) -> bool:
|
||||
"""Load state from disk.
|
||||
|
||||
Args:
|
||||
path: File path to load from
|
||||
|
||||
Returns:
|
||||
True if loaded successfully
|
||||
"""
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
state = data.get('state')
|
||||
if state is not None:
|
||||
self.dispatch(StateHydrateAction(state))
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
self._logger.error(f"Failed to load state: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MODULE-LEVEL STORE (Application-wide state)
|
||||
# =============================================================================
|
||||
|
||||
_module_stores: Dict[str, StateStore] = {}
|
||||
|
||||
|
||||
def create_store(
|
||||
name: str,
|
||||
reducer: Reducer,
|
||||
initial_state: Optional[Any] = None,
|
||||
**kwargs
|
||||
) -> StateStore:
|
||||
"""Create or get a named store.
|
||||
|
||||
Args:
|
||||
name: Unique store name
|
||||
reducer: Reducer function
|
||||
initial_state: Initial state
|
||||
**kwargs: Additional arguments for StateStore
|
||||
|
||||
Returns:
|
||||
StateStore instance
|
||||
"""
|
||||
if name not in _module_stores:
|
||||
_module_stores[name] = StateStore(
|
||||
reducer=reducer,
|
||||
initial_state=initial_state,
|
||||
**kwargs
|
||||
)
|
||||
return _module_stores[name]
|
||||
|
||||
|
||||
def get_store(name: str) -> Optional[StateStore]:
|
||||
"""Get a named store.
|
||||
|
||||
Args:
|
||||
name: Store name
|
||||
|
||||
Returns:
|
||||
StateStore or None if not found
|
||||
"""
|
||||
return _module_stores.get(name)
|
||||
|
||||
|
||||
def remove_store(name: str) -> bool:
|
||||
"""Remove a named store.
|
||||
|
||||
Args:
|
||||
name: Store name
|
||||
|
||||
Returns:
|
||||
True if removed
|
||||
"""
|
||||
if name in _module_stores:
|
||||
del _module_stores[name]
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# EXPORTS
|
||||
# =============================================================================
|
||||
|
||||
__all__ = [
|
||||
# Types
|
||||
'Action', 'ActionBase', 'Reducer', 'Selector', 'Subscriber', 'Middleware',
|
||||
# Actions
|
||||
'StateResetAction', 'StateRestoreAction', 'StateBatchAction', 'StateHydrateAction',
|
||||
# Classes
|
||||
'StateSnapshot', 'StoreSlice', 'StateStore',
|
||||
# Functions
|
||||
'combine_reducers', 'create_store', 'get_store', 'remove_store',
|
||||
# Middleware
|
||||
'logging_middleware', 'thunk_middleware', 'persistence_middleware',
|
||||
]
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
"""
|
||||
EU-Utility Premium - Entropia Universe Integration
|
||||
===================================================
|
||||
|
||||
Modules for integrating with the Entropia Universe game client.
|
||||
|
||||
Example:
|
||||
from premium.eu_integration import GameClient
|
||||
|
||||
client = GameClient()
|
||||
client.start()
|
||||
|
||||
@client.on_loot
|
||||
def handle_loot(event):
|
||||
print(f"Got loot: {event.item}")
|
||||
"""
|
||||
|
||||
from .game_client import GameClient, GameState
|
||||
from .log_parser import LogParser, LogEvent
|
||||
from .window_tracker import WindowTracker
|
||||
from .events import GameEvent, LootEvent, SkillEvent, ChatEvent
|
||||
|
||||
__all__ = [
|
||||
'GameClient',
|
||||
'GameState',
|
||||
'LogParser',
|
||||
'LogEvent',
|
||||
'WindowTracker',
|
||||
'GameEvent',
|
||||
'LootEvent',
|
||||
'SkillEvent',
|
||||
'ChatEvent',
|
||||
]
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
"""
|
||||
Typed event definitions for Entropia Universe.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameEvent:
|
||||
"""Base game event."""
|
||||
timestamp: datetime
|
||||
type: str
|
||||
data: Dict[str, Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class LootEvent(GameEvent):
|
||||
"""Loot received event."""
|
||||
item: str
|
||||
quantity: int
|
||||
value: float
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: Dict[str, Any]) -> 'LootEvent':
|
||||
return cls(
|
||||
timestamp=datetime.now(),
|
||||
type='loot',
|
||||
data=data,
|
||||
item=data.get('item', 'Unknown'),
|
||||
quantity=data.get('quantity', 1),
|
||||
value=data.get('value', 0.0),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillEvent(GameEvent):
|
||||
"""Skill gain event."""
|
||||
skill: str
|
||||
gain: float
|
||||
new_value: Optional[float] = None
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: Dict[str, Any]) -> 'SkillEvent':
|
||||
return cls(
|
||||
timestamp=datetime.now(),
|
||||
type='skill',
|
||||
data=data,
|
||||
skill=data.get('skill', 'Unknown'),
|
||||
gain=data.get('gain', 0.0),
|
||||
new_value=data.get('new_value'),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GlobalEvent(GameEvent):
|
||||
"""Global/HoF event."""
|
||||
player: str
|
||||
mob: str
|
||||
item: str
|
||||
value: float
|
||||
is_hof: bool
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: Dict[str, Any]) -> 'GlobalEvent':
|
||||
return cls(
|
||||
timestamp=datetime.now(),
|
||||
type='global',
|
||||
data=data,
|
||||
player=data.get('player', 'Unknown'),
|
||||
mob=data.get('mob', 'Unknown'),
|
||||
item=data.get('item', 'Unknown'),
|
||||
value=data.get('value', 0.0),
|
||||
is_hof=data.get('is_hof', False),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatEvent(GameEvent):
|
||||
"""Chat message event."""
|
||||
channel: str
|
||||
player: str
|
||||
message: str
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: Dict[str, Any]) -> 'ChatEvent':
|
||||
return cls(
|
||||
timestamp=datetime.now(),
|
||||
type='chat',
|
||||
data=data,
|
||||
channel=data.get('channel', 'Unknown'),
|
||||
player=data.get('player', 'Unknown'),
|
||||
message=data.get('message', ''),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DamageEvent(GameEvent):
|
||||
"""Damage dealt/received event."""
|
||||
damage: float
|
||||
target: Optional[str] = None
|
||||
is_critical: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: Dict[str, Any], dealt: bool = True) -> 'DamageEvent':
|
||||
return cls(
|
||||
timestamp=datetime.now(),
|
||||
type='damage_dealt' if dealt else 'damage_received',
|
||||
data=data,
|
||||
damage=data.get('damage', 0.0),
|
||||
target=data.get('target'),
|
||||
is_critical=data.get('is_critical', False),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class KillEvent(GameEvent):
|
||||
"""Creature killed event."""
|
||||
creature: str
|
||||
experience: Optional[float] = None
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: Dict[str, Any]) -> 'KillEvent':
|
||||
return cls(
|
||||
timestamp=datetime.now(),
|
||||
type='kill',
|
||||
data=data,
|
||||
creature=data.get('creature', 'Unknown'),
|
||||
experience=data.get('experience'),
|
||||
)
|
||||
|
|
@ -1,344 +0,0 @@
|
|||
"""
|
||||
Entropia Universe Game Client Integration
|
||||
==========================================
|
||||
|
||||
Provides integration with the EU game client:
|
||||
- Process detection
|
||||
- Window tracking
|
||||
- Log file monitoring
|
||||
- Event emission
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import logging
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum, auto
|
||||
from pathlib import Path
|
||||
from typing import Optional, Callable, Dict, List, Any, Set
|
||||
|
||||
|
||||
class GameState(Enum):
|
||||
"""Game client connection state."""
|
||||
DISCONNECTED = auto()
|
||||
DETECTED = auto() # Process found
|
||||
CONNECTED = auto() # Log file accessible
|
||||
PLAYING = auto() # Character active
|
||||
|
||||
|
||||
@dataclass
|
||||
class CharacterInfo:
|
||||
"""Information about the current character."""
|
||||
name: Optional[str] = None
|
||||
level: Optional[int] = None
|
||||
profession: Optional[str] = None
|
||||
health: Optional[float] = None
|
||||
position: Optional[tuple] = None
|
||||
|
||||
|
||||
class GameClient:
|
||||
"""Main interface for Entropia Universe game client.
|
||||
|
||||
This class monitors the game client process, tracks window state,
|
||||
parses log files, and emits events for game actions.
|
||||
|
||||
Example:
|
||||
client = GameClient()
|
||||
|
||||
@client.on('loot')
|
||||
def handle_loot(event):
|
||||
print(f"Got: {event['item']}")
|
||||
|
||||
client.start()
|
||||
"""
|
||||
|
||||
# Default paths
|
||||
DEFAULT_INSTALL_PATHS = [
|
||||
Path("C:/Program Files (x86)/Entropia Universe"),
|
||||
Path("C:/Program Files/Entropia Universe"),
|
||||
Path.home() / "AppData" / "Local" / "Entropia Universe",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
game_path: Optional[Path] = None,
|
||||
event_bus: Optional[Any] = None,
|
||||
poll_interval: float = 1.0
|
||||
):
|
||||
"""Initialize game client.
|
||||
|
||||
Args:
|
||||
game_path: Path to EU installation (auto-detected if None)
|
||||
event_bus: Event bus for emitting events
|
||||
poll_interval: Seconds between process checks
|
||||
"""
|
||||
self.game_path = game_path
|
||||
self.event_bus = event_bus
|
||||
self.poll_interval = poll_interval
|
||||
|
||||
self._state = GameState.DISCONNECTED
|
||||
self._character = CharacterInfo()
|
||||
self._process_id: Optional[int] = None
|
||||
self._window_handle: Optional[int] = None
|
||||
self._log_path: Optional[Path] = None
|
||||
|
||||
self._running = False
|
||||
self._monitor_thread: Optional[threading.Thread] = None
|
||||
self._callbacks: Dict[str, List[Callable]] = {}
|
||||
self._logger = logging.getLogger("GameClient")
|
||||
|
||||
# Sub-components
|
||||
self._log_parser: Optional[Any] = None
|
||||
self._window_tracker: Optional[Any] = None
|
||||
|
||||
# ========== Properties ==========
|
||||
|
||||
@property
|
||||
def state(self) -> GameState:
|
||||
"""Current game connection state."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""Whether game client is connected."""
|
||||
return self._state in (GameState.CONNECTED, GameState.PLAYING)
|
||||
|
||||
@property
|
||||
def is_playing(self) -> bool:
|
||||
"""Whether character is active in game."""
|
||||
return self._state == GameState.PLAYING
|
||||
|
||||
@property
|
||||
def character(self) -> CharacterInfo:
|
||||
"""Current character information."""
|
||||
return self._character
|
||||
|
||||
# ========== Lifecycle ==========
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start monitoring the game client.
|
||||
|
||||
Returns:
|
||||
True if started successfully
|
||||
"""
|
||||
if self._running:
|
||||
return True
|
||||
|
||||
self._running = True
|
||||
|
||||
# Auto-detect game path if not provided
|
||||
if not self.game_path:
|
||||
self.game_path = self._detect_game_path()
|
||||
|
||||
if self.game_path:
|
||||
self._log_path = self.game_path / "chat.log"
|
||||
|
||||
# Start monitoring thread
|
||||
self._monitor_thread = threading.Thread(target=self._monitor_loop, daemon=True)
|
||||
self._monitor_thread.start()
|
||||
|
||||
self._logger.info("Game client monitor started")
|
||||
return True
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop monitoring the game client."""
|
||||
self._running = False
|
||||
|
||||
if self._monitor_thread:
|
||||
self._monitor_thread.join(timeout=2.0)
|
||||
|
||||
if self._log_parser:
|
||||
self._log_parser.stop()
|
||||
|
||||
self._logger.info("Game client monitor stopped")
|
||||
|
||||
# ========== Event Handling ==========
|
||||
|
||||
def on(self, event_type: str, callback: Callable) -> Callable:
|
||||
"""Register event callback.
|
||||
|
||||
Args:
|
||||
event_type: Event type ('loot', 'skill', 'global', etc.)
|
||||
callback: Function to call when event occurs
|
||||
|
||||
Returns:
|
||||
The callback function (for use as decorator)
|
||||
"""
|
||||
if event_type not in self._callbacks:
|
||||
self._callbacks[event_type] = []
|
||||
self._callbacks[event_type].append(callback)
|
||||
return callback
|
||||
|
||||
def off(self, event_type: str, callback: Callable) -> bool:
|
||||
"""Unregister event callback."""
|
||||
if event_type in self._callbacks:
|
||||
if callback in self._callbacks[event_type]:
|
||||
self._callbacks[event_type].remove(callback)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _emit(self, event_type: str, data: Dict[str, Any]) -> None:
|
||||
"""Emit event to callbacks and event bus."""
|
||||
# Call local callbacks
|
||||
for callback in self._callbacks.get(event_type, []):
|
||||
try:
|
||||
callback(data)
|
||||
except Exception as e:
|
||||
self._logger.error(f"Error in callback: {e}")
|
||||
|
||||
# Emit to event bus
|
||||
if self.event_bus:
|
||||
try:
|
||||
self.event_bus.emit(f"game.{event_type}", data, source="game_client")
|
||||
except Exception as e:
|
||||
self._logger.error(f"Error emitting to event bus: {e}")
|
||||
|
||||
# ========== Detection ==========
|
||||
|
||||
def _detect_game_path(self) -> Optional[Path]:
|
||||
"""Auto-detect EU installation path."""
|
||||
# Check default paths
|
||||
for path in self.DEFAULT_INSTALL_PATHS:
|
||||
if path.exists() and (path / "Entropia.exe").exists():
|
||||
self._logger.info(f"Found EU at: {path}")
|
||||
return path
|
||||
|
||||
# Check registry on Windows
|
||||
try:
|
||||
import winreg
|
||||
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE,
|
||||
r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall") as key:
|
||||
for i in range(winreg.QueryInfoKey(key)[0]):
|
||||
try:
|
||||
subkey_name = winreg.EnumKey(key, i)
|
||||
with winreg.OpenKey(key, subkey_name) as subkey:
|
||||
name = winreg.QueryValueEx(subkey, "DisplayName")[0]
|
||||
if "Entropia" in name:
|
||||
path = winreg.QueryValueEx(subkey, "InstallLocation")[0]
|
||||
return Path(path)
|
||||
except:
|
||||
continue
|
||||
except Exception as e:
|
||||
self._logger.debug(f"Registry search failed: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def _find_process(self) -> Optional[int]:
|
||||
"""Find EU game process ID."""
|
||||
try:
|
||||
import psutil
|
||||
for proc in psutil.process_iter(['pid', 'name']):
|
||||
try:
|
||||
if proc.info['name'] and 'entropia' in proc.info['name'].lower():
|
||||
return proc.info['pid']
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
continue
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Fallback: check if log file exists and is being written
|
||||
if self._log_path and self._log_path.exists():
|
||||
# Check if file was modified recently
|
||||
mtime = self._log_path.stat().st_mtime
|
||||
if time.time() - mtime < 60: # Modified in last minute
|
||||
return -1 # Unknown PID but likely running
|
||||
|
||||
return None
|
||||
|
||||
def _is_window_focused(self) -> bool:
|
||||
"""Check if EU window is focused."""
|
||||
try:
|
||||
import win32gui
|
||||
import win32process
|
||||
|
||||
hwnd = win32gui.GetForegroundWindow()
|
||||
_, pid = win32process.GetWindowThreadProcessId(hwnd)
|
||||
|
||||
return pid == self._process_id
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
# ========== Monitoring Loop ==========
|
||||
|
||||
def _monitor_loop(self) -> None:
|
||||
"""Main monitoring loop."""
|
||||
last_log_position = 0
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
# Check process
|
||||
pid = self._find_process()
|
||||
|
||||
if pid and self._state == GameState.DISCONNECTED:
|
||||
self._process_id = pid
|
||||
self._state = GameState.DETECTED
|
||||
self._emit('connected', {'pid': pid})
|
||||
self._start_log_parsing()
|
||||
|
||||
elif not pid and self._state != GameState.DISCONNECTED:
|
||||
self._state = GameState.DISCONNECTED
|
||||
self._process_id = None
|
||||
self._emit('disconnected', {})
|
||||
self._stop_log_parsing()
|
||||
|
||||
# Check window focus
|
||||
if self._state != GameState.DISCONNECTED:
|
||||
focused = self._is_window_focused()
|
||||
self._emit('focus_changed', {'focused': focused})
|
||||
|
||||
time.sleep(self.poll_interval)
|
||||
|
||||
except Exception as e:
|
||||
self._logger.error(f"Monitor error: {e}")
|
||||
time.sleep(self.poll_interval)
|
||||
|
||||
def _start_log_parsing(self) -> None:
|
||||
"""Start parsing log files."""
|
||||
if not self._log_path or not self._log_path.exists():
|
||||
return
|
||||
|
||||
from .log_parser import LogParser
|
||||
|
||||
self._log_parser = LogParser(self._log_path)
|
||||
|
||||
@self._log_parser.on_event
|
||||
def handle_log_event(event_type, data):
|
||||
self._handle_log_event(event_type, data)
|
||||
|
||||
self._log_parser.start()
|
||||
self._state = GameState.CONNECTED
|
||||
|
||||
def _stop_log_parsing(self) -> None:
|
||||
"""Stop parsing log files."""
|
||||
if self._log_parser:
|
||||
self._log_parser.stop()
|
||||
self._log_parser = None
|
||||
|
||||
def _handle_log_event(self, event_type: str, data: Dict[str, Any]) -> None:
|
||||
"""Handle events from log parser."""
|
||||
# Update character info
|
||||
if event_type == 'system_message' and 'character' in data:
|
||||
self._character.name = data.get('character')
|
||||
self._state = GameState.PLAYING
|
||||
|
||||
# Emit to listeners
|
||||
self._emit(event_type, data)
|
||||
|
||||
# ========== Public API ==========
|
||||
|
||||
def get_game_path(self) -> Optional[Path]:
|
||||
"""Get detected game path."""
|
||||
return self.game_path
|
||||
|
||||
def get_log_path(self) -> Optional[Path]:
|
||||
"""Get path to chat.log."""
|
||||
return self._log_path
|
||||
|
||||
def set_game_path(self, path: Path) -> None:
|
||||
"""Manually set game path."""
|
||||
self.game_path = Path(path)
|
||||
self._log_path = self.game_path / "chat.log"
|
||||
|
|
@ -1,323 +0,0 @@
|
|||
"""
|
||||
Log file parser for Entropia Universe chat.log
|
||||
===============================================
|
||||
|
||||
Parses EU chat.log file to extract:
|
||||
- Loot events
|
||||
- Skill gains
|
||||
- Chat messages
|
||||
- System messages
|
||||
- Globals/HoFs
|
||||
"""
|
||||
|
||||
import re
|
||||
import time
|
||||
import logging
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, Callable, Dict, List, Any, Pattern
|
||||
|
||||
|
||||
@dataclass
|
||||
class LogEvent:
|
||||
"""Represents a parsed log event."""
|
||||
timestamp: datetime
|
||||
type: str
|
||||
data: Dict[str, Any]
|
||||
raw: str
|
||||
|
||||
|
||||
class LogParser:
|
||||
"""Parser for Entropia Universe chat.log file.
|
||||
|
||||
Monitors the log file and emits events for interesting game actions.
|
||||
|
||||
Example:
|
||||
parser = LogParser(Path("C:/.../chat.log"))
|
||||
|
||||
@parser.on_event
|
||||
def handle(event_type, data):
|
||||
if event_type == 'loot':
|
||||
print(f"Loot: {data['item']}")
|
||||
|
||||
parser.start()
|
||||
"""
|
||||
|
||||
# Regex patterns for different event types
|
||||
PATTERNS = {
|
||||
# Loot: "2024-01-15 14:30:25 [System] [] [Player] You received Angel Scales x1 Value: 150 PED"
|
||||
'loot': re.compile(
|
||||
r'You received\s+(.+?)\s+x(\d+)\s+Value:\s+([\d.]+)'
|
||||
),
|
||||
|
||||
# Skill gain: "Skill increase: Rifle (Gain: 0.5234)"
|
||||
'skill': re.compile(
|
||||
r'Skill increase:\s+(.+?)\s+\(Gain:\s+([\d.]+)\)'
|
||||
),
|
||||
|
||||
# Global/HoF: "[Global] [Player] found something rare! Angel Scales (Uber)"
|
||||
'global': re.compile(
|
||||
r'\[Global\].*?found something rare!\s+(.+?)\s+\((\d+)\s*PED\)'
|
||||
),
|
||||
|
||||
# HoF (Hall of Fame): "[Hall of Fame] [Player] found something extraordinary!"
|
||||
'hof': re.compile(
|
||||
r'\[Hall of Fame\].*?found something extraordinary!\s+(.+?)\s+\((\d+)\s*PED\)'
|
||||
),
|
||||
|
||||
# Chat message: "[Trade] [PlayerName]: Message text"
|
||||
'chat': re.compile(
|
||||
r'\[(\w+)\]\s+\[(.+?)\]:\s+(.*)'
|
||||
),
|
||||
|
||||
# Damage dealt: "You inflicted 45.2 points of damage"
|
||||
'damage_dealt': re.compile(
|
||||
r'You inflicted\s+([\d.]+)\s+points of damage'
|
||||
),
|
||||
|
||||
# Damage received: "You took 12.3 points of damage"
|
||||
'damage_received': re.compile(
|
||||
r'You took\s+([\d.]+)\s+points of damage'
|
||||
),
|
||||
|
||||
# Enemy killed: "You killed [Creature Name]"
|
||||
'kill': re.compile(
|
||||
r'You killed\s+\[(.+?)\]'
|
||||
),
|
||||
|
||||
# Item crafted: "You have successfully crafted [Item Name]"
|
||||
'craft': re.compile(
|
||||
r'You have successfully crafted\s+\[(.+?)\]'
|
||||
),
|
||||
|
||||
# Mission completed: "Mission "Mission Name" completed!"
|
||||
'mission_complete': re.compile(
|
||||
r'Mission\s+"(.+?)"\s+completed!'
|
||||
),
|
||||
|
||||
# Experience gain: "You gained 0.2345 experience in your Rifle skill"
|
||||
'experience': re.compile(
|
||||
r'You gained\s+([\d.]+)\s+experience in your\s+(.+?)\s+skill'
|
||||
),
|
||||
|
||||
# Item looted (alt format): "You looted [Item Name]"
|
||||
'loot_alt': re.compile(
|
||||
r'You looted\s+\[(.+?)\]\s*(?:x(\d+))?'
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
log_path: Path,
|
||||
poll_interval: float = 0.5,
|
||||
encoding: str = 'utf-8'
|
||||
):
|
||||
"""Initialize log parser.
|
||||
|
||||
Args:
|
||||
log_path: Path to chat.log file
|
||||
poll_interval: Seconds between file checks
|
||||
encoding: File encoding
|
||||
"""
|
||||
self.log_path = Path(log_path)
|
||||
self.poll_interval = poll_interval
|
||||
self.encoding = encoding
|
||||
|
||||
self._running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._file_position = 0
|
||||
self._callbacks: List[Callable] = []
|
||||
self._logger = logging.getLogger("LogParser")
|
||||
|
||||
# Track last position to detect new content
|
||||
self._last_size = 0
|
||||
self._last_modified = 0
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start parsing log file.
|
||||
|
||||
Returns:
|
||||
True if started successfully
|
||||
"""
|
||||
if self._running:
|
||||
return True
|
||||
|
||||
if not self.log_path.exists():
|
||||
self._logger.error(f"Log file not found: {self.log_path}")
|
||||
return False
|
||||
|
||||
self._running = True
|
||||
|
||||
# Start at end of file (only read new content)
|
||||
try:
|
||||
self._last_size = self.log_path.stat().st_size
|
||||
self._file_position = self._last_size
|
||||
except Exception as e:
|
||||
self._logger.error(f"Failed to get file size: {e}")
|
||||
return False
|
||||
|
||||
self._thread = threading.Thread(target=self._parse_loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
self._logger.info(f"Started parsing {self.log_path}")
|
||||
return True
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop parsing log file."""
|
||||
self._running = False
|
||||
|
||||
if self._thread:
|
||||
self._thread.join(timeout=2.0)
|
||||
|
||||
self._logger.info("Stopped parsing")
|
||||
|
||||
def on_event(self, callback: Callable[[str, Dict[str, Any]], None]) -> Callable:
|
||||
"""Register event callback.
|
||||
|
||||
Args:
|
||||
callback: Function(event_type, data) called for each event
|
||||
|
||||
Returns:
|
||||
The callback function (for use as decorator)
|
||||
"""
|
||||
self._callbacks.append(callback)
|
||||
return callback
|
||||
|
||||
def _emit(self, event_type: str, data: Dict[str, Any]) -> None:
|
||||
"""Emit event to all callbacks."""
|
||||
for callback in self._callbacks:
|
||||
try:
|
||||
callback(event_type, data)
|
||||
except Exception as e:
|
||||
self._logger.error(f"Error in callback: {e}")
|
||||
|
||||
def _parse_loop(self) -> None:
|
||||
"""Main parsing loop."""
|
||||
while self._running:
|
||||
try:
|
||||
self._check_file()
|
||||
time.sleep(self.poll_interval)
|
||||
except Exception as e:
|
||||
self._logger.error(f"Parse error: {e}")
|
||||
time.sleep(self.poll_interval)
|
||||
|
||||
def _check_file(self) -> None:
|
||||
"""Check log file for new content."""
|
||||
try:
|
||||
stat = self.log_path.stat()
|
||||
current_size = stat.st_size
|
||||
current_modified = stat.st_mtime
|
||||
|
||||
# Check if file has new content
|
||||
if current_size == self._last_size:
|
||||
return
|
||||
|
||||
if current_size < self._last_size:
|
||||
# File was truncated or rotated, start from beginning
|
||||
self._file_position = 0
|
||||
|
||||
self._last_size = current_size
|
||||
self._last_modified = current_modified
|
||||
|
||||
# Read new content
|
||||
with open(self.log_path, 'r', encoding=self.encoding, errors='ignore') as f:
|
||||
f.seek(self._file_position)
|
||||
new_lines = f.readlines()
|
||||
self._file_position = f.tell()
|
||||
|
||||
# Parse each new line
|
||||
for line in new_lines:
|
||||
self._parse_line(line.strip())
|
||||
|
||||
except Exception as e:
|
||||
self._logger.error(f"Error checking file: {e}")
|
||||
|
||||
def _parse_line(self, line: str) -> Optional[LogEvent]:
|
||||
"""Parse a single log line."""
|
||||
if not line:
|
||||
return None
|
||||
|
||||
# Extract timestamp if present
|
||||
timestamp = datetime.now()
|
||||
ts_match = re.match(r'(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})', line)
|
||||
if ts_match:
|
||||
try:
|
||||
timestamp = datetime.strptime(ts_match.group(1), '%Y-%m-%d %H:%M:%S')
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Try each pattern
|
||||
for event_type, pattern in self.PATTERNS.items():
|
||||
match = pattern.search(line)
|
||||
if match:
|
||||
data = self._extract_data(event_type, match, line)
|
||||
event = LogEvent(
|
||||
timestamp=timestamp,
|
||||
type=event_type,
|
||||
data=data,
|
||||
raw=line
|
||||
)
|
||||
self._emit(event_type, data)
|
||||
return event
|
||||
|
||||
return None
|
||||
|
||||
def _extract_data(
|
||||
self,
|
||||
event_type: str,
|
||||
match: re.Match,
|
||||
raw_line: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Extract data from regex match."""
|
||||
groups = match.groups()
|
||||
|
||||
data = {
|
||||
'raw': raw_line,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
if event_type == 'loot':
|
||||
data['item'] = groups[0].strip()
|
||||
data['quantity'] = int(groups[1]) if len(groups) > 1 else 1
|
||||
data['value'] = float(groups[2]) if len(groups) > 2 else 0.0
|
||||
|
||||
elif event_type == 'skill':
|
||||
data['skill'] = groups[0].strip()
|
||||
data['gain'] = float(groups[1]) if len(groups) > 1 else 0.0
|
||||
|
||||
elif event_type in ('global', 'hof'):
|
||||
data['item'] = groups[0].strip() if groups else 'Unknown'
|
||||
data['value'] = float(groups[1]) if len(groups) > 1 else 0.0
|
||||
data['is_hof'] = event_type == 'hof'
|
||||
|
||||
elif event_type == 'chat':
|
||||
data['channel'] = groups[0] if groups else 'Unknown'
|
||||
data['player'] = groups[1] if len(groups) > 1 else 'Unknown'
|
||||
data['message'] = groups[2] if len(groups) > 2 else ''
|
||||
|
||||
elif event_type == 'damage_dealt':
|
||||
data['damage'] = float(groups[0]) if groups else 0.0
|
||||
|
||||
elif event_type == 'damage_received':
|
||||
data['damage'] = float(groups[0]) if groups else 0.0
|
||||
|
||||
elif event_type == 'kill':
|
||||
data['creature'] = groups[0] if groups else 'Unknown'
|
||||
|
||||
elif event_type == 'craft':
|
||||
data['item'] = groups[0] if groups else 'Unknown'
|
||||
|
||||
elif event_type == 'mission_complete':
|
||||
data['mission'] = groups[0] if groups else 'Unknown'
|
||||
|
||||
elif event_type == 'experience':
|
||||
data['amount'] = float(groups[0]) if groups else 0.0
|
||||
data['skill'] = groups[1].strip() if len(groups) > 1 else 'Unknown'
|
||||
|
||||
elif event_type == 'loot_alt':
|
||||
data['item'] = groups[0] if groups else 'Unknown'
|
||||
data['quantity'] = int(groups[1]) if len(groups) > 1 and groups[1] else 1
|
||||
|
||||
return data
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
"""
|
||||
Window tracker for monitoring Entropia Universe window state.
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
import threading
|
||||
from typing import Optional, Callable, List
|
||||
|
||||
|
||||
class WindowTracker:
|
||||
"""Tracks the EU game window state.
|
||||
|
||||
Monitors window focus, visibility, and position without
|
||||
interfering with the game.
|
||||
"""
|
||||
|
||||
def __init__(self, poll_interval: float = 0.5):
|
||||
"""Initialize window tracker.
|
||||
|
||||
Args:
|
||||
poll_interval: Seconds between window checks
|
||||
"""
|
||||
self.poll_interval = poll_interval
|
||||
|
||||
self._running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._callbacks: List[Callable] = []
|
||||
self._logger = logging.getLogger("WindowTracker")
|
||||
|
||||
self._is_focused = False
|
||||
self._window_handle: Optional[int] = None
|
||||
self._process_id: Optional[int] = None
|
||||
|
||||
def start(self, process_id: Optional[int] = None) -> bool:
|
||||
"""Start tracking window."""
|
||||
if self._running:
|
||||
return True
|
||||
|
||||
self._process_id = process_id
|
||||
self._running = True
|
||||
|
||||
self._thread = threading.Thread(target=self._track_loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
return True
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop tracking window."""
|
||||
self._running = False
|
||||
|
||||
if self._thread:
|
||||
self._thread.join(timeout=1.0)
|
||||
|
||||
def on_change(self, callback: Callable[[bool], None]) -> Callable:
|
||||
"""Register focus change callback."""
|
||||
self._callbacks.append(callback)
|
||||
return callback
|
||||
|
||||
def is_focused(self) -> bool:
|
||||
"""Check if game window is focused."""
|
||||
return self._is_focused
|
||||
|
||||
def _track_loop(self) -> None:
|
||||
"""Main tracking loop."""
|
||||
while self._running:
|
||||
try:
|
||||
focused = self._check_focus()
|
||||
|
||||
if focused != self._is_focused:
|
||||
self._is_focused = focused
|
||||
self._notify_change(focused)
|
||||
|
||||
time.sleep(self.poll_interval)
|
||||
|
||||
except Exception as e:
|
||||
self._logger.error(f"Tracking error: {e}")
|
||||
time.sleep(self.poll_interval)
|
||||
|
||||
def _check_focus(self) -> bool:
|
||||
"""Check if game window is focused."""
|
||||
try:
|
||||
import win32gui
|
||||
import win32process
|
||||
|
||||
hwnd = win32gui.GetForegroundWindow()
|
||||
|
||||
if self._process_id:
|
||||
_, pid = win32process.GetWindowThreadProcessId(hwnd)
|
||||
return pid == self._process_id
|
||||
else:
|
||||
# Check window title
|
||||
title = win32gui.GetWindowText(hwnd)
|
||||
return 'entropia' in title.lower()
|
||||
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
def _notify_change(self, focused: bool) -> None:
|
||||
"""Notify callbacks of focus change."""
|
||||
for callback in self._callbacks:
|
||||
try:
|
||||
callback(focused)
|
||||
except Exception as e:
|
||||
self._logger.error(f"Callback error: {e}")
|
||||
|
|
@ -1,465 +0,0 @@
|
|||
"""
|
||||
EU-Utility Premium - Plugin API
|
||||
================================
|
||||
|
||||
Plugin API surface for the plugin system.
|
||||
|
||||
This module defines the contracts that plugins must implement
|
||||
to be loaded by the PluginManager.
|
||||
|
||||
Example:
|
||||
from premium.plugins.api import PluginAPI, PluginManifest, PluginContext
|
||||
|
||||
class MyPlugin(PluginAPI):
|
||||
manifest = PluginManifest(
|
||||
name="My Plugin",
|
||||
version="1.0.0",
|
||||
author="Your Name"
|
||||
)
|
||||
|
||||
def on_init(self, ctx: PluginContext):
|
||||
self.ctx = ctx
|
||||
ctx.logger.info("Plugin initialized!")
|
||||
|
||||
def on_activate(self):
|
||||
self.ctx.logger.info("Plugin activated!")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum, auto
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Set, Type, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from PyQt6.QtWidgets import QWidget
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PERMISSION LEVELS
|
||||
# =============================================================================
|
||||
|
||||
class PermissionLevel(Enum):
|
||||
"""Permission levels for plugin sandboxing.
|
||||
|
||||
Plugins must declare which permissions they need.
|
||||
The user must approve these permissions before the plugin runs.
|
||||
"""
|
||||
FILE_READ = auto() # Read files from disk
|
||||
FILE_WRITE = auto() # Write files to disk
|
||||
NETWORK = auto() # Access network (API calls)
|
||||
UI = auto() # Create/manipulate UI widgets
|
||||
MEMORY = auto() # Access game memory (dangerous)
|
||||
PROCESS = auto() # Access other processes
|
||||
SYSTEM = auto() # System-level access (very dangerous)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PLUGIN STATE
|
||||
# =============================================================================
|
||||
|
||||
class PluginState(Enum):
|
||||
"""Lifecycle states for plugins."""
|
||||
DISCOVERED = auto() # Found but not loaded
|
||||
LOADING = auto() # Currently loading
|
||||
LOADED = auto() # Code loaded, not initialized
|
||||
INITIALIZING = auto() # Currently initializing
|
||||
INACTIVE = auto() # Initialized but not active
|
||||
ACTIVATING = auto() # Currently activating
|
||||
ACTIVE = auto() # Fully active and running
|
||||
DEACTIVATING = auto() # Currently deactivating
|
||||
UNLOADING = auto() # Currently unloading
|
||||
UNLOADED = auto() # Unloaded from memory
|
||||
ERROR = auto() # Error state
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PLUGIN MANIFEST
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class PluginManifest:
|
||||
"""Plugin manifest - metadata about a plugin.
|
||||
|
||||
This is loaded from plugin.json in the plugin directory.
|
||||
|
||||
Example plugin.json:
|
||||
{
|
||||
"name": "My Plugin",
|
||||
"version": "1.0.0",
|
||||
"author": "Your Name",
|
||||
"description": "Does cool things",
|
||||
"entry_point": "main.py",
|
||||
"permissions": ["file_read", "ui"],
|
||||
"dependencies": {
|
||||
"other_plugin": ">=1.0.0"
|
||||
}
|
||||
}
|
||||
"""
|
||||
name: str
|
||||
version: str
|
||||
author: str
|
||||
description: str = ""
|
||||
entry_point: str = "main.py"
|
||||
permissions: Set[PermissionLevel] = field(default_factory=set)
|
||||
dependencies: Dict[str, str] = field(default_factory=dict)
|
||||
min_api_version: str = "3.0.0"
|
||||
tags: List[str] = field(default_factory=list)
|
||||
homepage: str = ""
|
||||
icon: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, path: Path) -> PluginManifest:
|
||||
"""Load manifest from JSON file."""
|
||||
import json
|
||||
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Parse permissions
|
||||
permission_map = {
|
||||
'file_read': PermissionLevel.FILE_READ,
|
||||
'file_write': PermissionLevel.FILE_WRITE,
|
||||
'network': PermissionLevel.NETWORK,
|
||||
'ui': PermissionLevel.UI,
|
||||
'memory': PermissionLevel.MEMORY,
|
||||
'process': PermissionLevel.PROCESS,
|
||||
'system': PermissionLevel.SYSTEM,
|
||||
}
|
||||
|
||||
permissions = set()
|
||||
for perm_str in data.get('permissions', []):
|
||||
if perm_str in permission_map:
|
||||
permissions.add(permission_map[perm_str])
|
||||
|
||||
return cls(
|
||||
name=data['name'],
|
||||
version=data['version'],
|
||||
author=data.get('author', 'Unknown'),
|
||||
description=data.get('description', ''),
|
||||
entry_point=data.get('entry_point', 'main.py'),
|
||||
permissions=permissions,
|
||||
dependencies=data.get('dependencies', {}),
|
||||
min_api_version=data.get('min_api_version', '3.0.0'),
|
||||
tags=data.get('tags', []),
|
||||
homepage=data.get('homepage', ''),
|
||||
icon=data.get('icon', ''),
|
||||
)
|
||||
|
||||
def to_json(self, path: Path) -> None:
|
||||
"""Save manifest to JSON file."""
|
||||
import json
|
||||
|
||||
# Convert permissions back to strings
|
||||
permission_reverse_map = {
|
||||
PermissionLevel.FILE_READ: 'file_read',
|
||||
PermissionLevel.FILE_WRITE: 'file_write',
|
||||
PermissionLevel.NETWORK: 'network',
|
||||
PermissionLevel.UI: 'ui',
|
||||
PermissionLevel.MEMORY: 'memory',
|
||||
PermissionLevel.PROCESS: 'process',
|
||||
PermissionLevel.SYSTEM: 'system',
|
||||
}
|
||||
|
||||
data = {
|
||||
'name': self.name,
|
||||
'version': self.version,
|
||||
'author': self.author,
|
||||
'description': self.description,
|
||||
'entry_point': self.entry_point,
|
||||
'permissions': [permission_reverse_map[p] for p in self.permissions],
|
||||
'dependencies': self.dependencies,
|
||||
'min_api_version': self.min_api_version,
|
||||
'tags': self.tags,
|
||||
'homepage': self.homepage,
|
||||
'icon': self.icon,
|
||||
}
|
||||
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PLUGIN CONTEXT
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class PluginContext:
|
||||
"""Context passed to plugins during initialization.
|
||||
|
||||
This provides plugins with access to system resources while
|
||||
maintaining sandbox boundaries.
|
||||
|
||||
Attributes:
|
||||
plugin_id: Unique plugin identifier
|
||||
manifest: Plugin manifest
|
||||
data_dir: Directory for plugin data storage
|
||||
config: Plugin configuration dictionary
|
||||
logger: Logger instance for this plugin
|
||||
event_bus: Event bus for publishing/subscribing to events
|
||||
state_store: State store for accessing global state
|
||||
widget_api: API for creating UI widgets
|
||||
nexus_api: API for Entropia Nexus data
|
||||
permissions: Set of granted permissions
|
||||
"""
|
||||
plugin_id: str
|
||||
manifest: PluginManifest
|
||||
data_dir: Path
|
||||
config: Dict[str, Any]
|
||||
logger: logging.Logger
|
||||
event_bus: Optional[Any] = None
|
||||
state_store: Optional[Any] = None
|
||||
widget_api: Optional[Any] = None
|
||||
nexus_api: Optional[Any] = None
|
||||
permissions: Set[PermissionLevel] = field(default_factory=set)
|
||||
|
||||
def has_permission(self, permission: PermissionLevel) -> bool:
|
||||
"""Check if plugin has a specific permission."""
|
||||
return permission in self.permissions
|
||||
|
||||
def require_permission(self, permission: PermissionLevel) -> None:
|
||||
"""Require a permission or raise an error."""
|
||||
if not self.has_permission(permission):
|
||||
raise PluginPermissionError(
|
||||
f"Plugin '{self.manifest.name}' requires permission: {permission.name}"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PLUGIN INSTANCE
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class PluginInstance:
|
||||
"""Represents a loaded plugin instance.
|
||||
|
||||
Tracks the lifecycle state and metadata of a plugin.
|
||||
"""
|
||||
plugin_id: str
|
||||
manifest: PluginManifest
|
||||
state: PluginState = PluginState.DISCOVERED
|
||||
instance: Optional[PluginAPI] = None
|
||||
load_time: Optional[datetime] = None
|
||||
activate_time: Optional[datetime] = None
|
||||
error_message: Optional[str] = None
|
||||
error_traceback: Optional[str] = None
|
||||
|
||||
def is_active(self) -> bool:
|
||||
"""Check if plugin is currently active."""
|
||||
return self.state == PluginState.ACTIVE
|
||||
|
||||
def has_error(self) -> bool:
|
||||
"""Check if plugin is in error state."""
|
||||
return self.state == PluginState.ERROR
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
'plugin_id': self.plugin_id,
|
||||
'name': self.manifest.name,
|
||||
'version': self.manifest.version,
|
||||
'state': self.state.name,
|
||||
'is_active': self.is_active(),
|
||||
'has_error': self.has_error(),
|
||||
'load_time': self.load_time.isoformat() if self.load_time else None,
|
||||
'activate_time': self.activate_time.isoformat() if self.activate_time else None,
|
||||
'error_message': self.error_message,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PLUGIN ERRORS
|
||||
# =============================================================================
|
||||
|
||||
class PluginError(Exception):
|
||||
"""Base exception for plugin-related errors."""
|
||||
pass
|
||||
|
||||
|
||||
class PluginLoadError(PluginError):
|
||||
"""Error loading a plugin (invalid code, missing files, etc)."""
|
||||
pass
|
||||
|
||||
|
||||
class PluginInitError(PluginError):
|
||||
"""Error initializing a plugin."""
|
||||
pass
|
||||
|
||||
|
||||
class PluginPermissionError(PluginError):
|
||||
"""Plugin tried to use a permission it doesn't have."""
|
||||
pass
|
||||
|
||||
|
||||
class PluginDependencyError(PluginError):
|
||||
"""Error with plugin dependencies."""
|
||||
pass
|
||||
|
||||
|
||||
class PluginVersionError(PluginError):
|
||||
"""Error with plugin version compatibility."""
|
||||
pass
|
||||
|
||||
|
||||
class PluginAPIError(PluginError):
|
||||
"""Error with plugin API usage."""
|
||||
pass
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PLUGIN API BASE CLASS
|
||||
# =============================================================================
|
||||
|
||||
class PluginAPI(ABC):
|
||||
"""Base class for all plugins.
|
||||
|
||||
Plugins must inherit from this class and implement the lifecycle methods.
|
||||
|
||||
Example:
|
||||
class MyPlugin(PluginAPI):
|
||||
manifest = PluginManifest(
|
||||
name="My Plugin",
|
||||
version="1.0.0",
|
||||
author="Your Name"
|
||||
)
|
||||
|
||||
def on_init(self, ctx: PluginContext):
|
||||
self.ctx = ctx
|
||||
self.config = ctx.config
|
||||
|
||||
def on_activate(self):
|
||||
# Start doing work
|
||||
pass
|
||||
|
||||
def on_deactivate(self):
|
||||
# Stop doing work
|
||||
pass
|
||||
|
||||
def on_shutdown(self):
|
||||
# Cleanup resources
|
||||
pass
|
||||
|
||||
def create_widget(self) -> Optional[QWidget]:
|
||||
# Return UI widget for dashboard
|
||||
return None
|
||||
"""
|
||||
|
||||
# Must be defined by subclass
|
||||
manifest: PluginManifest
|
||||
|
||||
def __init__(self):
|
||||
self.ctx: Optional[PluginContext] = None
|
||||
self._initialized = False
|
||||
self._active = False
|
||||
|
||||
def _set_context(self, ctx: PluginContext) -> None:
|
||||
"""Set the plugin context (called by PluginManager)."""
|
||||
self.ctx = ctx
|
||||
|
||||
# ========== Lifecycle Methods ==========
|
||||
|
||||
@abstractmethod
|
||||
def on_init(self, ctx: PluginContext) -> None:
|
||||
"""Called when plugin is initialized.
|
||||
|
||||
Use this to set up initial state, load config, etc.
|
||||
Don't start any background work here - use on_activate for that.
|
||||
|
||||
Args:
|
||||
ctx: Plugin context with resources and configuration
|
||||
"""
|
||||
pass
|
||||
|
||||
def on_activate(self) -> None:
|
||||
"""Called when plugin is activated.
|
||||
|
||||
Start background tasks, register event handlers, etc.
|
||||
"""
|
||||
pass
|
||||
|
||||
def on_deactivate(self) -> None:
|
||||
"""Called when plugin is deactivated.
|
||||
|
||||
Stop background tasks, unregister event handlers.
|
||||
"""
|
||||
pass
|
||||
|
||||
def on_shutdown(self) -> None:
|
||||
"""Called when plugin is being unloaded.
|
||||
|
||||
Clean up all resources, save state, etc.
|
||||
"""
|
||||
pass
|
||||
|
||||
# ========== UI Methods ==========
|
||||
|
||||
def create_widget(self) -> Optional[Any]:
|
||||
"""Create a widget for the dashboard.
|
||||
|
||||
Returns:
|
||||
QWidget or None if plugin has no UI
|
||||
"""
|
||||
return None
|
||||
|
||||
def get_settings_widget(self) -> Optional[Any]:
|
||||
"""Create a settings widget.
|
||||
|
||||
Returns:
|
||||
QWidget or None if plugin has no settings
|
||||
"""
|
||||
return None
|
||||
|
||||
# ========== Utility Methods ==========
|
||||
|
||||
def log(self, level: str, message: str) -> None:
|
||||
"""Log a message through the plugin's logger."""
|
||||
if self.ctx and self.ctx.logger:
|
||||
getattr(self.ctx.logger, level.lower(), self.ctx.logger.info)(message)
|
||||
|
||||
def emit_event(self, event_type: str, data: Dict[str, Any]) -> None:
|
||||
"""Emit an event to the event bus."""
|
||||
if self.ctx and self.ctx.event_bus:
|
||||
self.ctx.event_bus.emit(event_type, data, source=self.ctx.plugin_id)
|
||||
|
||||
def save_config(self) -> bool:
|
||||
"""Save plugin configuration to disk."""
|
||||
if not self.ctx:
|
||||
return False
|
||||
|
||||
config_path = self.ctx.data_dir / "config.json"
|
||||
try:
|
||||
import json
|
||||
with open(config_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.ctx.config, f, indent=2)
|
||||
return True
|
||||
except Exception as e:
|
||||
self.log('error', f"Failed to save config: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# EXPORTS
|
||||
# =============================================================================
|
||||
|
||||
__all__ = [
|
||||
# Permissions
|
||||
'PermissionLevel',
|
||||
# State
|
||||
'PluginState',
|
||||
# Manifest
|
||||
'PluginManifest',
|
||||
# Context
|
||||
'PluginContext',
|
||||
# Instance
|
||||
'PluginInstance',
|
||||
# Errors
|
||||
'PluginError', 'PluginLoadError', 'PluginInitError',
|
||||
'PluginPermissionError', 'PluginDependencyError',
|
||||
'PluginVersionError', 'PluginAPIError',
|
||||
# Base class
|
||||
'PluginAPI',
|
||||
]
|
||||
|
|
@ -1,814 +0,0 @@
|
|||
"""
|
||||
EU-Utility Premium - Plugin Manager
|
||||
====================================
|
||||
|
||||
Enterprise-grade plugin manager with:
|
||||
- Sandboxed execution
|
||||
- Dynamic loading/unloading
|
||||
- Dependency resolution
|
||||
- Lifecycle management
|
||||
- Security validation
|
||||
|
||||
Example:
|
||||
manager = PluginManager()
|
||||
manager.discover_plugins(Path("./plugins"))
|
||||
manager.load_all()
|
||||
|
||||
# Activate specific plugin
|
||||
manager.activate_plugin("my_plugin")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Set, Type, Union
|
||||
from uuid import uuid4
|
||||
|
||||
from premium.plugins.api import (
|
||||
PluginAPI, PluginContext, PluginError, PluginInstance, PluginLoadError,
|
||||
PluginInitError, PluginManifest, PluginPermissionError, PluginState,
|
||||
PermissionLevel, PluginDependencyError, PluginVersionError
|
||||
)
|
||||
|
||||
|
||||
# API version for compatibility checking
|
||||
API_VERSION = "3.0.0"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PLUGIN SANDBOX
|
||||
# =============================================================================
|
||||
|
||||
class PluginSandbox:
|
||||
"""Sandbox for plugin execution.
|
||||
|
||||
Restricts plugin access based on permissions.
|
||||
"""
|
||||
|
||||
def __init__(self, plugin_id: str, permissions: Set[PermissionLevel]):
|
||||
self.plugin_id = plugin_id
|
||||
self.permissions = permissions
|
||||
self._original_open = open
|
||||
self._restricted_paths: Set[Path] = set()
|
||||
|
||||
def can_access_file(self, path: Path, mode: str = 'r') -> bool:
|
||||
"""Check if plugin can access a file."""
|
||||
if 'w' in mode or 'a' in mode or 'x' in mode:
|
||||
if PermissionLevel.FILE_WRITE not in self.permissions:
|
||||
return False
|
||||
|
||||
if PermissionLevel.FILE_READ not in self.permissions:
|
||||
return False
|
||||
|
||||
# Check restricted paths
|
||||
resolved = path.resolve()
|
||||
for restricted in self._restricted_paths:
|
||||
try:
|
||||
resolved.relative_to(restricted)
|
||||
return False
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
def can_access_network(self) -> bool:
|
||||
"""Check if plugin can access network."""
|
||||
return PermissionLevel.NETWORK in self.permissions
|
||||
|
||||
def can_access_ui(self) -> bool:
|
||||
"""Check if plugin can manipulate UI."""
|
||||
return PermissionLevel.UI in self.permissions
|
||||
|
||||
def can_access_memory(self) -> bool:
|
||||
"""Check if plugin can access memory (dangerous)."""
|
||||
return PermissionLevel.MEMORY in self.permissions
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PLUGIN LOADER
|
||||
# =============================================================================
|
||||
|
||||
class PluginLoader:
|
||||
"""Loads plugin modules with validation and security checks."""
|
||||
|
||||
def __init__(self, sandbox: Optional[PluginSandbox] = None):
|
||||
self.sandbox = sandbox
|
||||
self._loaded_modules: Dict[str, Any] = {}
|
||||
|
||||
def load_plugin_class(
|
||||
self,
|
||||
plugin_path: Path,
|
||||
manifest: PluginManifest
|
||||
) -> Type[PluginAPI]:
|
||||
"""Load a plugin class from a directory.
|
||||
|
||||
Args:
|
||||
plugin_path: Path to plugin directory
|
||||
manifest: Plugin manifest
|
||||
|
||||
Returns:
|
||||
PluginAPI subclass
|
||||
|
||||
Raises:
|
||||
PluginLoadError: If loading fails
|
||||
"""
|
||||
entry_file = plugin_path / manifest.entry_point
|
||||
|
||||
if not entry_file.exists():
|
||||
raise PluginLoadError(
|
||||
f"Entry point not found: {manifest.entry_point}"
|
||||
)
|
||||
|
||||
# Validate file hash for security
|
||||
file_hash = self._compute_hash(entry_file)
|
||||
|
||||
# Create unique module name
|
||||
module_name = f"premium_plugin_{manifest.name.lower().replace(' ', '_')}_{file_hash[:8]}"
|
||||
|
||||
try:
|
||||
# Load module
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
module_name, entry_file
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
raise PluginLoadError("Failed to create module spec")
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
|
||||
# Add to sys.modules temporarily
|
||||
sys.modules[module_name] = module
|
||||
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
except Exception as e:
|
||||
raise PluginLoadError(f"Failed to execute module: {e}")
|
||||
|
||||
# Find PluginAPI subclass
|
||||
plugin_class = None
|
||||
for attr_name in dir(module):
|
||||
attr = getattr(module, attr_name)
|
||||
if (
|
||||
isinstance(attr, type) and
|
||||
issubclass(attr, PluginAPI) and
|
||||
attr is not PluginAPI and
|
||||
not attr.__name__.startswith('Base')
|
||||
):
|
||||
plugin_class = attr
|
||||
break
|
||||
|
||||
if plugin_class is None:
|
||||
raise PluginLoadError(
|
||||
f"No PluginAPI subclass found in {manifest.entry_point}"
|
||||
)
|
||||
|
||||
# Validate manifest matches
|
||||
if not hasattr(plugin_class, 'manifest'):
|
||||
raise PluginLoadError("Plugin class missing manifest attribute")
|
||||
|
||||
return plugin_class
|
||||
|
||||
except PluginLoadError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise PluginLoadError(f"Unexpected error loading plugin: {e}")
|
||||
|
||||
def _compute_hash(self, file_path: Path) -> str:
|
||||
"""Compute SHA256 hash of a file."""
|
||||
h = hashlib.sha256()
|
||||
with open(file_path, 'rb') as f:
|
||||
for chunk in iter(lambda: f.read(8192), b''):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DEPENDENCY RESOLVER
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class DependencyNode:
|
||||
"""Node in dependency graph."""
|
||||
plugin_id: str
|
||||
manifest: PluginManifest
|
||||
dependencies: Set[str] = field(default_factory=set)
|
||||
dependents: Set[str] = field(default_factory=set)
|
||||
resolved: bool = False
|
||||
|
||||
|
||||
class DependencyResolver:
|
||||
"""Resolves plugin dependencies using topological sort."""
|
||||
|
||||
def __init__(self):
|
||||
self._nodes: Dict[str, DependencyNode] = {}
|
||||
|
||||
def add_plugin(self, plugin_id: str, manifest: PluginManifest) -> None:
|
||||
"""Add a plugin to the dependency graph."""
|
||||
if plugin_id not in self._nodes:
|
||||
self._nodes[plugin_id] = DependencyNode(
|
||||
plugin_id=plugin_id,
|
||||
manifest=manifest
|
||||
)
|
||||
|
||||
node = self._nodes[plugin_id]
|
||||
|
||||
# Add dependencies
|
||||
for dep_id in manifest.dependencies.keys():
|
||||
node.dependencies.add(dep_id)
|
||||
|
||||
if dep_id not in self._nodes:
|
||||
self._nodes[dep_id] = DependencyNode(
|
||||
plugin_id=dep_id,
|
||||
manifest=PluginManifest(
|
||||
name=dep_id,
|
||||
version="0.0.0",
|
||||
author="Unknown"
|
||||
)
|
||||
)
|
||||
|
||||
self._nodes[dep_id].dependents.add(plugin_id)
|
||||
|
||||
def resolve_load_order(self, plugin_ids: List[str]) -> List[str]:
|
||||
"""Resolve plugin load order using topological sort.
|
||||
|
||||
Returns:
|
||||
List of plugin IDs in load order
|
||||
|
||||
Raises:
|
||||
PluginDependencyError: If circular dependency detected
|
||||
"""
|
||||
order: List[str] = []
|
||||
visited: Set[str] = set()
|
||||
temp_mark: Set[str] = set()
|
||||
|
||||
def visit(node_id: str, path: List[str]) -> None:
|
||||
if node_id in temp_mark:
|
||||
cycle = " -> ".join(path + [node_id])
|
||||
raise PluginDependencyError(f"Circular dependency: {cycle}")
|
||||
|
||||
if node_id in visited:
|
||||
return
|
||||
|
||||
temp_mark.add(node_id)
|
||||
path.append(node_id)
|
||||
|
||||
node = self._nodes.get(node_id)
|
||||
if node:
|
||||
for dep_id in node.dependencies:
|
||||
visit(dep_id, path.copy())
|
||||
|
||||
temp_mark.remove(node_id)
|
||||
visited.add(node_id)
|
||||
order.append(node_id)
|
||||
|
||||
for plugin_id in plugin_ids:
|
||||
if plugin_id not in visited:
|
||||
visit(plugin_id, [])
|
||||
|
||||
return order
|
||||
|
||||
def get_dependents(self, plugin_id: str) -> Set[str]:
|
||||
"""Get all plugins that depend on a given plugin."""
|
||||
node = self._nodes.get(plugin_id)
|
||||
if node:
|
||||
return node.dependents.copy()
|
||||
return set()
|
||||
|
||||
def check_conflicts(self) -> List[str]:
|
||||
"""Check for version conflicts in dependencies."""
|
||||
conflicts = []
|
||||
# TODO: Implement version conflict checking
|
||||
return conflicts
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PLUGIN MANAGER
|
||||
# =============================================================================
|
||||
|
||||
class PluginManager:
|
||||
"""Manages plugin lifecycle with sandboxing and dependency resolution.
|
||||
|
||||
This is the main entry point for plugin management. It handles:
|
||||
- Discovery of plugins in directories
|
||||
- Loading and unloading
|
||||
- Dependency resolution
|
||||
- Lifecycle state management
|
||||
- Security sandboxing
|
||||
|
||||
Example:
|
||||
manager = PluginManager(
|
||||
plugin_dirs=[Path("./plugins"), Path("~/.eu-utility/plugins")],
|
||||
data_dir=Path("~/.eu-utility/data")
|
||||
)
|
||||
manager.discover_all()
|
||||
manager.load_all()
|
||||
|
||||
# Get active plugins
|
||||
for plugin_id, instance in manager.get_active_plugins().items():
|
||||
print(f"{plugin_id}: {instance.manifest.name}")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
plugin_dirs: Optional[List[Path]] = None,
|
||||
data_dir: Optional[Path] = None,
|
||||
event_bus: Optional[Any] = None,
|
||||
state_store: Optional[Any] = None,
|
||||
widget_api: Optional[Any] = None,
|
||||
nexus_api: Optional[Any] = None,
|
||||
max_workers: int = 4
|
||||
):
|
||||
"""Initialize plugin manager.
|
||||
|
||||
Args:
|
||||
plugin_dirs: Directories to search for plugins
|
||||
data_dir: Directory for plugin data storage
|
||||
event_bus: Event bus for plugin communication
|
||||
state_store: State store for plugins
|
||||
widget_api: Widget API for UI plugins
|
||||
nexus_api: Nexus API for Entropia data
|
||||
max_workers: Max worker threads for background tasks
|
||||
"""
|
||||
self.plugin_dirs = plugin_dirs or []
|
||||
self.data_dir = data_dir or Path.home() / ".eu-utility" / "data"
|
||||
self.event_bus = event_bus
|
||||
self.state_store = state_store
|
||||
self.widget_api = widget_api
|
||||
self.nexus_api = nexus_api
|
||||
|
||||
# Ensure data directory exists
|
||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Plugin storage
|
||||
self._instances: Dict[str, PluginInstance] = {}
|
||||
self._classes: Dict[str, Type[PluginAPI]] = {}
|
||||
self._paths: Dict[str, Path] = {}
|
||||
|
||||
# Support systems
|
||||
self._loader = PluginLoader()
|
||||
self._resolver = DependencyResolver()
|
||||
self._executor = ThreadPoolExecutor(max_workers=max_workers)
|
||||
self._logger = logging.getLogger("PluginManager")
|
||||
|
||||
# State
|
||||
self._discovered = False
|
||||
self._lock = threading.RLock()
|
||||
|
||||
# ========== Discovery ==========
|
||||
|
||||
def discover_plugins(self, directory: Path) -> List[str]:
|
||||
"""Discover plugins in a directory.
|
||||
|
||||
Args:
|
||||
directory: Directory to search for plugins
|
||||
|
||||
Returns:
|
||||
List of discovered plugin IDs
|
||||
"""
|
||||
discovered: List[str] = []
|
||||
|
||||
if not directory.exists():
|
||||
self._logger.warning(f"Plugin directory does not exist: {directory}")
|
||||
return discovered
|
||||
|
||||
for item in directory.iterdir():
|
||||
if not item.is_dir():
|
||||
continue
|
||||
|
||||
if item.name.startswith('.') or item.name.startswith('__'):
|
||||
continue
|
||||
|
||||
manifest_path = item / "plugin.json"
|
||||
if not manifest_path.exists():
|
||||
continue
|
||||
|
||||
try:
|
||||
manifest = PluginManifest.from_json(manifest_path)
|
||||
plugin_id = self._generate_plugin_id(manifest, item)
|
||||
|
||||
with self._lock:
|
||||
self._paths[plugin_id] = item
|
||||
self._resolver.add_plugin(plugin_id, manifest)
|
||||
|
||||
discovered.append(plugin_id)
|
||||
self._logger.debug(f"Discovered plugin: {manifest.name} ({plugin_id})")
|
||||
|
||||
except Exception as e:
|
||||
self._logger.error(f"Failed to load manifest from {item}: {e}")
|
||||
|
||||
return discovered
|
||||
|
||||
def discover_all(self) -> int:
|
||||
"""Discover plugins in all configured directories.
|
||||
|
||||
Returns:
|
||||
Total number of plugins discovered
|
||||
"""
|
||||
total = 0
|
||||
for directory in self.plugin_dirs:
|
||||
total += len(self.discover_plugins(directory))
|
||||
|
||||
self._discovered = True
|
||||
self._logger.info(f"Discovered {total} plugins")
|
||||
return total
|
||||
|
||||
# ========== Loading ==========
|
||||
|
||||
def load_plugin(self, plugin_id: str) -> bool:
|
||||
"""Load a plugin by ID.
|
||||
|
||||
Args:
|
||||
plugin_id: Unique plugin identifier
|
||||
|
||||
Returns:
|
||||
True if loaded successfully
|
||||
"""
|
||||
with self._lock:
|
||||
if plugin_id in self._instances:
|
||||
return True
|
||||
|
||||
path = self._paths.get(plugin_id)
|
||||
if not path:
|
||||
self._logger.error(f"Plugin not found: {plugin_id}")
|
||||
return False
|
||||
|
||||
manifest_path = path / "plugin.json"
|
||||
manifest = PluginManifest.from_json(manifest_path)
|
||||
|
||||
# Create instance record
|
||||
instance = PluginInstance(
|
||||
plugin_id=plugin_id,
|
||||
manifest=manifest,
|
||||
state=PluginState.LOADING
|
||||
)
|
||||
self._instances[plugin_id] = instance
|
||||
|
||||
try:
|
||||
# Load plugin class
|
||||
plugin_class = self._loader.load_plugin_class(path, manifest)
|
||||
self._classes[plugin_id] = plugin_class
|
||||
|
||||
with self._lock:
|
||||
instance.state = PluginState.LOADED
|
||||
instance.load_time = datetime.now()
|
||||
|
||||
self._logger.info(f"Loaded plugin: {manifest.name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
with self._lock:
|
||||
instance.state = PluginState.ERROR
|
||||
instance.error_message = str(e)
|
||||
instance.error_traceback = traceback.format_exc()
|
||||
|
||||
self._logger.error(f"Failed to load plugin {manifest.name}: {e}")
|
||||
return False
|
||||
|
||||
def load_all(self, auto_activate: bool = False) -> Dict[str, bool]:
|
||||
"""Load all discovered plugins.
|
||||
|
||||
Args:
|
||||
auto_activate: Automatically activate loaded plugins
|
||||
|
||||
Returns:
|
||||
Dict mapping plugin IDs to success status
|
||||
"""
|
||||
if not self._discovered:
|
||||
self.discover_all()
|
||||
|
||||
# Resolve load order
|
||||
plugin_ids = list(self._paths.keys())
|
||||
try:
|
||||
load_order = self._resolver.resolve_load_order(plugin_ids)
|
||||
except PluginDependencyError as e:
|
||||
self._logger.error(f"Dependency resolution failed: {e}")
|
||||
load_order = plugin_ids # Fall back to default order
|
||||
|
||||
# Load in order
|
||||
results: Dict[str, bool] = {}
|
||||
for plugin_id in load_order:
|
||||
results[plugin_id] = self.load_plugin(plugin_id)
|
||||
|
||||
if auto_activate and results[plugin_id]:
|
||||
self.activate_plugin(plugin_id)
|
||||
|
||||
return results
|
||||
|
||||
# ========== Initialization ==========
|
||||
|
||||
def init_plugin(self, plugin_id: str, config: Optional[Dict[str, Any]] = None) -> bool:
|
||||
"""Initialize a loaded plugin.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin ID
|
||||
config: Optional configuration override
|
||||
|
||||
Returns:
|
||||
True if initialized successfully
|
||||
"""
|
||||
with self._lock:
|
||||
instance = self._instances.get(plugin_id)
|
||||
if not instance:
|
||||
self._logger.error(f"Plugin not loaded: {plugin_id}")
|
||||
return False
|
||||
|
||||
if instance.state not in (PluginState.LOADED, PluginState.INACTIVE):
|
||||
self._logger.warning(f"Cannot initialize plugin in state: {instance.state}")
|
||||
return False
|
||||
|
||||
plugin_class = self._classes.get(plugin_id)
|
||||
if not plugin_class:
|
||||
self._logger.error(f"Plugin class not found: {plugin_id}")
|
||||
return False
|
||||
|
||||
instance.state = PluginState.INITIALIZING
|
||||
|
||||
try:
|
||||
# Create plugin directory for data
|
||||
plugin_data_dir = self.data_dir / plugin_id
|
||||
plugin_data_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Load saved config or use provided
|
||||
saved_config = self._load_plugin_config(plugin_id)
|
||||
if config:
|
||||
saved_config.update(config)
|
||||
|
||||
# Create logger
|
||||
logger = logging.getLogger(f"Plugin.{instance.manifest.name}")
|
||||
|
||||
# Create sandbox
|
||||
sandbox = PluginSandbox(plugin_id, instance.manifest.permissions)
|
||||
|
||||
# Create context
|
||||
ctx = PluginContext(
|
||||
plugin_id=plugin_id,
|
||||
manifest=instance.manifest,
|
||||
data_dir=plugin_data_dir,
|
||||
config=saved_config,
|
||||
logger=logger,
|
||||
event_bus=self.event_bus,
|
||||
state_store=self.state_store,
|
||||
widget_api=self.widget_api,
|
||||
nexus_api=self.nexus_api,
|
||||
permissions=instance.manifest.permissions
|
||||
)
|
||||
|
||||
# Create and initialize plugin instance
|
||||
plugin = plugin_class()
|
||||
plugin._set_context(ctx)
|
||||
plugin.on_init(ctx)
|
||||
|
||||
with self._lock:
|
||||
instance.instance = plugin
|
||||
instance.state = PluginState.INACTIVE
|
||||
|
||||
self._logger.info(f"Initialized plugin: {instance.manifest.name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
with self._lock:
|
||||
instance.state = PluginState.ERROR
|
||||
instance.error_message = str(e)
|
||||
instance.error_traceback = traceback.format_exc()
|
||||
|
||||
self._logger.error(f"Failed to initialize plugin {plugin_id}: {e}")
|
||||
return False
|
||||
|
||||
# ========== Activation ==========
|
||||
|
||||
def activate_plugin(self, plugin_id: str) -> bool:
|
||||
"""Activate an initialized plugin.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin ID
|
||||
|
||||
Returns:
|
||||
True if activated successfully
|
||||
"""
|
||||
with self._lock:
|
||||
instance = self._instances.get(plugin_id)
|
||||
if not instance:
|
||||
return False
|
||||
|
||||
if instance.state == PluginState.ACTIVE:
|
||||
return True
|
||||
|
||||
if instance.state != PluginState.INACTIVE:
|
||||
# Try to initialize first
|
||||
if instance.state == PluginState.LOADED:
|
||||
self.init_plugin(plugin_id)
|
||||
|
||||
if instance.state != PluginState.INACTIVE:
|
||||
return False
|
||||
|
||||
instance.state = PluginState.ACTIVATING
|
||||
plugin = instance.instance
|
||||
|
||||
try:
|
||||
plugin.on_activate()
|
||||
|
||||
with self._lock:
|
||||
instance.state = PluginState.ACTIVE
|
||||
instance.activate_time = datetime.now()
|
||||
|
||||
self._logger.info(f"Activated plugin: {instance.manifest.name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
with self._lock:
|
||||
instance.state = PluginState.ERROR
|
||||
instance.error_message = str(e)
|
||||
|
||||
self._logger.error(f"Failed to activate plugin {plugin_id}: {e}")
|
||||
return False
|
||||
|
||||
def deactivate_plugin(self, plugin_id: str) -> bool:
|
||||
"""Deactivate an active plugin.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin ID
|
||||
|
||||
Returns:
|
||||
True if deactivated successfully
|
||||
"""
|
||||
with self._lock:
|
||||
instance = self._instances.get(plugin_id)
|
||||
if not instance or instance.state != PluginState.ACTIVE:
|
||||
return False
|
||||
|
||||
instance.state = PluginState.DEACTIVATING
|
||||
plugin = instance.instance
|
||||
|
||||
try:
|
||||
plugin.on_deactivate()
|
||||
|
||||
with self._lock:
|
||||
instance.state = PluginState.INACTIVE
|
||||
|
||||
self._logger.info(f"Deactivated plugin: {instance.manifest.name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self._logger.error(f"Error deactivating plugin {plugin_id}: {e}")
|
||||
return False
|
||||
|
||||
# ========== Unloading ==========
|
||||
|
||||
def unload_plugin(self, plugin_id: str, force: bool = False) -> bool:
|
||||
"""Unload a plugin.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin ID
|
||||
force: Force unload even if dependents exist
|
||||
|
||||
Returns:
|
||||
True if unloaded successfully
|
||||
"""
|
||||
with self._lock:
|
||||
instance = self._instances.get(plugin_id)
|
||||
if not instance:
|
||||
return True
|
||||
|
||||
# Check dependents
|
||||
if not force:
|
||||
dependents = self._resolver.get_dependents(plugin_id)
|
||||
active_dependents = [
|
||||
d for d in dependents
|
||||
if d in self._instances and self._instances[d].is_active()
|
||||
]
|
||||
if active_dependents:
|
||||
self._logger.error(
|
||||
f"Cannot unload {plugin_id}: active dependents {active_dependents}"
|
||||
)
|
||||
return False
|
||||
|
||||
# Deactivate if active
|
||||
if instance.state == PluginState.ACTIVE:
|
||||
self.deactivate_plugin(plugin_id)
|
||||
|
||||
instance.state = PluginState.UNLOADING
|
||||
plugin = instance.instance
|
||||
|
||||
try:
|
||||
if plugin:
|
||||
# Save config before shutdown
|
||||
self._save_plugin_config(plugin_id, plugin.ctx.config)
|
||||
plugin.on_shutdown()
|
||||
|
||||
with self._lock:
|
||||
instance.state = PluginState.UNLOADED
|
||||
instance.instance = None
|
||||
del self._instances[plugin_id]
|
||||
del self._classes[plugin_id]
|
||||
|
||||
self._logger.info(f"Unloaded plugin: {instance.manifest.name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self._logger.error(f"Error unloading plugin {plugin_id}: {e}")
|
||||
return False
|
||||
|
||||
def unload_all(self) -> None:
|
||||
"""Unload all plugins in reverse dependency order."""
|
||||
# Get active plugins in reverse load order
|
||||
plugin_ids = list(self._instances.keys())
|
||||
|
||||
for plugin_id in reversed(plugin_ids):
|
||||
self.unload_plugin(plugin_id, force=True)
|
||||
|
||||
# ========== Queries ==========
|
||||
|
||||
def get_instance(self, plugin_id: str) -> Optional[PluginInstance]:
|
||||
"""Get plugin instance info."""
|
||||
return self._instances.get(plugin_id)
|
||||
|
||||
def get_plugin(self, plugin_id: str) -> Optional[PluginAPI]:
|
||||
"""Get active plugin instance."""
|
||||
instance = self._instances.get(plugin_id)
|
||||
if instance and instance.state == PluginState.ACTIVE:
|
||||
return instance.instance
|
||||
return None
|
||||
|
||||
def get_all_instances(self) -> Dict[str, PluginInstance]:
|
||||
"""Get all plugin instances."""
|
||||
return self._instances.copy()
|
||||
|
||||
def get_active_plugins(self) -> Dict[str, PluginInstance]:
|
||||
"""Get all active plugins."""
|
||||
return {
|
||||
k: v for k, v in self._instances.items()
|
||||
if v.state == PluginState.ACTIVE
|
||||
}
|
||||
|
||||
def get_plugin_ui(self, plugin_id: str) -> Optional[Any]:
|
||||
"""Get plugin's UI widget."""
|
||||
plugin = self.get_plugin(plugin_id)
|
||||
if plugin:
|
||||
return plugin.create_widget()
|
||||
return None
|
||||
|
||||
def get_plugin_states(self) -> Dict[str, PluginState]:
|
||||
"""Get state of all plugins."""
|
||||
return {
|
||||
k: v.state for k, v in self._instances.items()
|
||||
}
|
||||
|
||||
# ========== Configuration ==========
|
||||
|
||||
def _load_plugin_config(self, plugin_id: str) -> Dict[str, Any]:
|
||||
"""Load plugin configuration from disk."""
|
||||
config_path = self.data_dir / plugin_id / "config.json"
|
||||
if config_path.exists():
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
self._logger.error(f"Failed to load config for {plugin_id}: {e}")
|
||||
return {}
|
||||
|
||||
def _save_plugin_config(self, plugin_id: str, config: Dict[str, Any]) -> None:
|
||||
"""Save plugin configuration to disk."""
|
||||
config_path = self.data_dir / plugin_id / "config.json"
|
||||
try:
|
||||
with open(config_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
except Exception as e:
|
||||
self._logger.error(f"Failed to save config for {plugin_id}: {e}")
|
||||
|
||||
# ========== Utility ==========
|
||||
|
||||
def _generate_plugin_id(self, manifest: PluginManifest, path: Path) -> str:
|
||||
"""Generate unique plugin ID."""
|
||||
# Use path hash for uniqueness
|
||||
path_hash = hashlib.md5(str(path).encode()).hexdigest()[:8]
|
||||
name_slug = manifest.name.lower().replace(' ', '_').replace('-', '_')
|
||||
return f"{name_slug}_{path_hash}"
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Shutdown the plugin manager."""
|
||||
self.unload_all()
|
||||
self._executor.shutdown(wait=True)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# EXPORTS
|
||||
# =============================================================================
|
||||
|
||||
__all__ = [
|
||||
'PluginManager',
|
||||
'PluginLoader',
|
||||
'DependencyResolver',
|
||||
'PluginSandbox',
|
||||
]
|
||||
|
|
@ -1,378 +0,0 @@
|
|||
"""
|
||||
EU-Utility Premium - Widget System
|
||||
===================================
|
||||
|
||||
Dashboard widget system for creating plugin UIs.
|
||||
|
||||
Example:
|
||||
from premium.widgets import Widget, WidgetConfig
|
||||
|
||||
class MyWidget(Widget):
|
||||
def __init__(self):
|
||||
super().__init__(WidgetConfig(
|
||||
name="My Widget",
|
||||
icon="📊",
|
||||
size=(300, 200)
|
||||
))
|
||||
|
||||
def create_ui(self, parent):
|
||||
# Create and return your widget
|
||||
label = QLabel("Hello World", parent)
|
||||
return label
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum, auto
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# WIDGET CONFIG
|
||||
# =============================================================================
|
||||
|
||||
class WidgetSize(Enum):
|
||||
"""Standard widget sizes."""
|
||||
SMALL = (150, 100)
|
||||
MEDIUM = (300, 200)
|
||||
LARGE = (450, 300)
|
||||
WIDE = (600, 200)
|
||||
TALL = (300, 400)
|
||||
FULL = (600, 400)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WidgetConfig:
|
||||
"""Configuration for a widget.
|
||||
|
||||
Attributes:
|
||||
name: Display name of the widget
|
||||
icon: Emoji or icon name
|
||||
size: Widget size tuple (width, height)
|
||||
resizable: Whether widget can be resized
|
||||
collapsible: Whether widget can be collapsed
|
||||
refresh_interval: Auto-refresh interval in seconds (0 = disabled)
|
||||
settings: Widget-specific settings
|
||||
"""
|
||||
name: str = "Widget"
|
||||
icon: str = "📦"
|
||||
size: Tuple[int, int] = (300, 200)
|
||||
resizable: bool = True
|
||||
collapsible: bool = True
|
||||
refresh_interval: float = 0.0
|
||||
settings: Dict[str, Any] = field(default_factory=dict)
|
||||
category: str = "General"
|
||||
description: str = ""
|
||||
author: str = ""
|
||||
version: str = "1.0.0"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# WIDGET BASE CLASS
|
||||
# =============================================================================
|
||||
|
||||
class Widget(ABC):
|
||||
"""Base class for dashboard widgets.
|
||||
|
||||
Widgets are the UI components that plugins can create.
|
||||
They are displayed in the dashboard overlay.
|
||||
|
||||
Example:
|
||||
class StatsWidget(Widget):
|
||||
def __init__(self):
|
||||
super().__init__(WidgetConfig(
|
||||
name="Player Stats",
|
||||
icon="👤",
|
||||
size=WidgetSize.MEDIUM.value
|
||||
))
|
||||
|
||||
def create_ui(self, parent):
|
||||
self.widget = QWidget(parent)
|
||||
layout = QVBoxLayout(self.widget)
|
||||
|
||||
self.label = QLabel("Loading...")
|
||||
layout.addWidget(self.label)
|
||||
|
||||
return self.widget
|
||||
|
||||
def update(self, data):
|
||||
self.label.setText(f"Level: {data['level']}")
|
||||
"""
|
||||
|
||||
def __init__(self, config: WidgetConfig):
|
||||
self.config = config
|
||||
self._ui: Optional[Any] = None
|
||||
self._visible = True
|
||||
self._collapsed = False
|
||||
self._data: Dict[str, Any] = {}
|
||||
self._refresh_timer = None
|
||||
self._callbacks: Dict[str, List[Callable]] = {
|
||||
'update': [],
|
||||
'resize': [],
|
||||
'collapse': [],
|
||||
'close': [],
|
||||
}
|
||||
|
||||
# ========== Abstract Methods ==========
|
||||
|
||||
@abstractmethod
|
||||
def create_ui(self, parent: Any) -> Any:
|
||||
"""Create the widget UI.
|
||||
|
||||
Args:
|
||||
parent: Parent widget (QWidget)
|
||||
|
||||
Returns:
|
||||
The created widget
|
||||
"""
|
||||
pass
|
||||
|
||||
# ========== Lifecycle Methods ==========
|
||||
|
||||
def initialize(self, parent: Any) -> Any:
|
||||
"""Initialize the widget with a parent."""
|
||||
self._ui = self.create_ui(parent)
|
||||
|
||||
if self.config.refresh_interval > 0:
|
||||
self._start_refresh_timer()
|
||||
|
||||
return self._ui
|
||||
|
||||
def destroy(self) -> None:
|
||||
"""Destroy the widget and clean up resources."""
|
||||
self._stop_refresh_timer()
|
||||
self._emit('close')
|
||||
|
||||
if self._ui:
|
||||
self._ui.deleteLater()
|
||||
self._ui = None
|
||||
|
||||
# ========== Update Methods ==========
|
||||
|
||||
def update(self, data: Dict[str, Any]) -> None:
|
||||
"""Update widget with new data.
|
||||
|
||||
Override this to update your widget's display.
|
||||
"""
|
||||
self._data.update(data)
|
||||
self._emit('update', data)
|
||||
self.on_update(data)
|
||||
|
||||
def on_update(self, data: Dict[str, Any]) -> None:
|
||||
"""Called when widget receives new data.
|
||||
|
||||
Override this instead of update() for custom behavior.
|
||||
"""
|
||||
pass
|
||||
|
||||
def refresh(self) -> None:
|
||||
"""Manually trigger a refresh."""
|
||||
self.on_refresh()
|
||||
|
||||
def on_refresh(self) -> None:
|
||||
"""Called when widget should refresh its display."""
|
||||
pass
|
||||
|
||||
# ========== State Methods ==========
|
||||
|
||||
def show(self) -> None:
|
||||
"""Show the widget."""
|
||||
self._visible = True
|
||||
if self._ui:
|
||||
self._ui.show()
|
||||
|
||||
def hide(self) -> None:
|
||||
"""Hide the widget."""
|
||||
self._visible = False
|
||||
if self._ui:
|
||||
self._ui.hide()
|
||||
|
||||
def collapse(self) -> None:
|
||||
"""Collapse the widget."""
|
||||
self._collapsed = True
|
||||
self._emit('collapse', True)
|
||||
if self._ui:
|
||||
self._ui.setMaximumHeight(40)
|
||||
|
||||
def expand(self) -> None:
|
||||
"""Expand the widget."""
|
||||
self._collapsed = False
|
||||
self._emit('collapse', False)
|
||||
if self._ui:
|
||||
self._ui.setMaximumHeight(self.config.size[1])
|
||||
|
||||
def toggle_collapse(self) -> None:
|
||||
"""Toggle collapsed state."""
|
||||
if self._collapsed:
|
||||
self.expand()
|
||||
else:
|
||||
self.collapse()
|
||||
|
||||
# ========== Property Methods ==========
|
||||
|
||||
@property
|
||||
def visible(self) -> bool:
|
||||
"""Whether widget is visible."""
|
||||
return self._visible
|
||||
|
||||
@property
|
||||
def collapsed(self) -> bool:
|
||||
"""Whether widget is collapsed."""
|
||||
return self._collapsed
|
||||
|
||||
@property
|
||||
def data(self) -> Dict[str, Any]:
|
||||
"""Current widget data."""
|
||||
return self._data.copy()
|
||||
|
||||
@property
|
||||
def ui(self) -> Optional[Any]:
|
||||
"""The UI widget."""
|
||||
return self._ui
|
||||
|
||||
# ========== Event Handling ==========
|
||||
|
||||
def on(self, event: str, callback: Callable) -> Callable:
|
||||
"""Subscribe to a widget event."""
|
||||
if event in self._callbacks:
|
||||
self._callbacks[event].append(callback)
|
||||
return callback
|
||||
|
||||
def _emit(self, event: str, *args, **kwargs) -> None:
|
||||
"""Emit an event to subscribers."""
|
||||
for callback in self._callbacks.get(event, []):
|
||||
try:
|
||||
callback(*args, **kwargs)
|
||||
except Exception as e:
|
||||
print(f"Error in widget callback: {e}")
|
||||
|
||||
# ========== Timer Methods ==========
|
||||
|
||||
def _start_refresh_timer(self) -> None:
|
||||
"""Start auto-refresh timer."""
|
||||
if self.config.refresh_interval <= 0:
|
||||
return
|
||||
|
||||
try:
|
||||
from PyQt6.QtCore import QTimer
|
||||
self._refresh_timer = QTimer()
|
||||
self._refresh_timer.timeout.connect(self.refresh)
|
||||
self._refresh_timer.start(int(self.config.refresh_interval * 1000))
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
def _stop_refresh_timer(self) -> None:
|
||||
"""Stop auto-refresh timer."""
|
||||
if self._refresh_timer:
|
||||
self._refresh_timer.stop()
|
||||
self._refresh_timer = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DASHBOARD
|
||||
# =============================================================================
|
||||
|
||||
class Dashboard:
|
||||
"""Dashboard for managing widgets.
|
||||
|
||||
The dashboard is the container for all plugin widgets.
|
||||
It manages layout, visibility, and widget lifecycle.
|
||||
"""
|
||||
|
||||
def __init__(self, parent: Any = None):
|
||||
self.parent = parent
|
||||
self._widgets: Dict[str, Widget] = {}
|
||||
self._layout: Optional[Any] = None
|
||||
self._visible = False
|
||||
self._position: Tuple[int, int] = (100, 100)
|
||||
|
||||
def initialize(self, parent: Any) -> Any:
|
||||
"""Initialize the dashboard."""
|
||||
try:
|
||||
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QScrollArea
|
||||
|
||||
self.container = QWidget(parent)
|
||||
self.container.setWindowTitle("EU-Utility Dashboard")
|
||||
self.container.setGeometry(*self._position, 350, 600)
|
||||
|
||||
scroll = QScrollArea(self.container)
|
||||
scroll.setWidgetResizable(True)
|
||||
|
||||
self.widget_container = QWidget()
|
||||
self._layout = QVBoxLayout(self.widget_container)
|
||||
self._layout.setSpacing(10)
|
||||
self._layout.addStretch()
|
||||
|
||||
scroll.setWidget(self.widget_container)
|
||||
|
||||
layout = QVBoxLayout(self.container)
|
||||
layout.addWidget(scroll)
|
||||
|
||||
return self.container
|
||||
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
def add_widget(self, widget_id: str, widget: Widget) -> bool:
|
||||
"""Add a widget to the dashboard."""
|
||||
if widget_id in self._widgets:
|
||||
return False
|
||||
|
||||
self._widgets[widget_id] = widget
|
||||
|
||||
if self._layout:
|
||||
ui = widget.initialize(self.widget_container)
|
||||
if ui:
|
||||
# Insert before the stretch
|
||||
self._layout.insertWidget(self._layout.count() - 1, ui)
|
||||
|
||||
return True
|
||||
|
||||
def remove_widget(self, widget_id: str) -> bool:
|
||||
"""Remove a widget from the dashboard."""
|
||||
widget = self._widgets.pop(widget_id, None)
|
||||
if widget:
|
||||
widget.destroy()
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_widget(self, widget_id: str) -> Optional[Widget]:
|
||||
"""Get a widget by ID."""
|
||||
return self._widgets.get(widget_id)
|
||||
|
||||
def show(self) -> None:
|
||||
"""Show the dashboard."""
|
||||
self._visible = True
|
||||
if hasattr(self, 'container') and self.container:
|
||||
self.container.show()
|
||||
|
||||
def hide(self) -> None:
|
||||
"""Hide the dashboard."""
|
||||
self._visible = False
|
||||
if hasattr(self, 'container') and self.container:
|
||||
self.container.hide()
|
||||
|
||||
def toggle(self) -> None:
|
||||
"""Toggle dashboard visibility."""
|
||||
if self._visible:
|
||||
self.hide()
|
||||
else:
|
||||
self.show()
|
||||
|
||||
def get_all_widgets(self) -> Dict[str, Widget]:
|
||||
"""Get all widgets."""
|
||||
return self._widgets.copy()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# EXPORTS
|
||||
# =============================================================================
|
||||
|
||||
__all__ = [
|
||||
'WidgetSize',
|
||||
'WidgetConfig',
|
||||
'Widget',
|
||||
'Dashboard',
|
||||
]
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
"""
|
||||
EU-Utility Premium - Base Widget Components
|
||||
============================================
|
||||
|
||||
Base widget classes and components for building custom widgets.
|
||||
"""
|
||||
|
||||
from .dashboard_widget import DashboardWidget, WidgetHeader, WidgetContent
|
||||
from .metrics_card import MetricsCard, StatItem
|
||||
from .chart_widget import ChartWidget, ChartType
|
||||
|
||||
__all__ = [
|
||||
'DashboardWidget',
|
||||
'WidgetHeader',
|
||||
'WidgetContent',
|
||||
'MetricsCard',
|
||||
'StatItem',
|
||||
'ChartWidget',
|
||||
'ChartType',
|
||||
]
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
"""
|
||||
Chart widget for displaying data visualizations.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
try:
|
||||
from PyQt6.QtWidgets import QWidget, QVBoxLayout
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtGui import QPainter, QColor, QPen, QBrush, QFont
|
||||
HAS_QT = True
|
||||
except ImportError:
|
||||
HAS_QT = False
|
||||
QWidget = object
|
||||
|
||||
|
||||
class ChartType(Enum):
|
||||
LINE = "line"
|
||||
BAR = "bar"
|
||||
PIE = "pie"
|
||||
|
||||
|
||||
class ChartWidget(QWidget if HAS_QT else object):
|
||||
"""Simple chart widget."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
chart_type: ChartType = ChartType.LINE,
|
||||
title: str = "Chart",
|
||||
parent=None
|
||||
):
|
||||
if not HAS_QT:
|
||||
return
|
||||
super().__init__(parent)
|
||||
self.chart_type = chart_type
|
||||
self.title = title
|
||||
self._data: List[Dict[str, Any]] = []
|
||||
self._max_points = 50
|
||||
self._y_max = 100
|
||||
|
||||
self.setMinimumHeight(150)
|
||||
self.setStyleSheet("background-color: #2d2d2d; border-radius: 8px;")
|
||||
|
||||
def add_point(self, x: Any, y: float, label: Optional[str] = None):
|
||||
"""Add a data point."""
|
||||
self._data.append({'x': x, 'y': y, 'label': label})
|
||||
|
||||
# Limit points
|
||||
if len(self._data) > self._max_points:
|
||||
self._data = self._data[-self._max_points:]
|
||||
|
||||
# Update scale
|
||||
if y > self._y_max:
|
||||
self._y_max = y * 1.1
|
||||
|
||||
if HAS_QT:
|
||||
self.update()
|
||||
|
||||
def set_data(self, data: List[Dict[str, Any]]):
|
||||
"""Set all data points."""
|
||||
self._data = data
|
||||
if data:
|
||||
self._y_max = max(d['y'] for d in data) * 1.1
|
||||
if HAS_QT:
|
||||
self.update()
|
||||
|
||||
def clear(self):
|
||||
"""Clear all data."""
|
||||
self._data = []
|
||||
if HAS_QT:
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
if not HAS_QT or not self._data:
|
||||
return
|
||||
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
|
||||
width = self.width()
|
||||
height = self.height()
|
||||
padding = 40
|
||||
|
||||
chart_width = width - padding * 2
|
||||
chart_height = height - padding * 2
|
||||
|
||||
# Background
|
||||
painter.fillRect(self.rect(), QColor(45, 45, 45))
|
||||
|
||||
# Draw title
|
||||
painter.setPen(QColor(255, 255, 255))
|
||||
painter.setFont(QFont("Arial", 10, QFont.Weight.Bold))
|
||||
painter.drawText(10, 20, self.title)
|
||||
|
||||
if len(self._data) < 2:
|
||||
return
|
||||
|
||||
# Draw chart based on type
|
||||
if self.chart_type == ChartType.LINE:
|
||||
self._draw_line_chart(painter, padding, chart_width, chart_height)
|
||||
elif self.chart_type == ChartType.BAR:
|
||||
self._draw_bar_chart(painter, padding, chart_width, chart_height)
|
||||
|
||||
def _draw_line_chart(self, painter, padding, width, height):
|
||||
"""Draw a line chart."""
|
||||
if len(self._data) < 2:
|
||||
return
|
||||
|
||||
pen = QPen(QColor(76, 175, 80))
|
||||
pen.setWidth(2)
|
||||
painter.setPen(pen)
|
||||
|
||||
x_step = width / max(len(self._data) - 1, 1)
|
||||
|
||||
# Draw line
|
||||
points = []
|
||||
for i, point in enumerate(self._data):
|
||||
x = padding + i * x_step
|
||||
y = padding + height - (point['y'] / self._y_max * height)
|
||||
points.append((x, y))
|
||||
|
||||
for i in range(len(points) - 1):
|
||||
painter.drawLine(int(points[i][0]), int(points[i][1]),
|
||||
int(points[i+1][0]), int(points[i+1][1]))
|
||||
|
||||
# Draw points
|
||||
painter.setBrush(QColor(76, 175, 80))
|
||||
for x, y in points:
|
||||
painter.drawEllipse(int(x - 3), int(y - 3), 6, 6)
|
||||
|
||||
def _draw_bar_chart(self, painter, padding, width, height):
|
||||
"""Draw a bar chart."""
|
||||
bar_width = width / len(self._data) * 0.8
|
||||
gap = width / len(self._data) * 0.2
|
||||
|
||||
painter.setBrush(QColor(76, 175, 80))
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
|
||||
for i, point in enumerate(self._data):
|
||||
bar_height = point['y'] / self._y_max * height
|
||||
x = padding + i * (bar_width + gap) + gap / 2
|
||||
y = padding + height - bar_height
|
||||
|
||||
painter.drawRect(int(x), int(y), int(bar_width), int(bar_height))
|
||||
|
|
@ -1,194 +0,0 @@
|
|||
"""
|
||||
Base widget components for the dashboard.
|
||||
"""
|
||||
|
||||
from typing import Any, Optional, Callable
|
||||
|
||||
try:
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
||||
QFrame, QSizePolicy
|
||||
)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal
|
||||
from PyQt6.QtGui import QFont
|
||||
HAS_QT = True
|
||||
except ImportError:
|
||||
HAS_QT = False
|
||||
QWidget = object
|
||||
pyqtSignal = lambda *a, **k: None
|
||||
|
||||
|
||||
class WidgetHeader(QFrame if HAS_QT else object):
|
||||
"""Header component for widgets with title and controls."""
|
||||
|
||||
collapsed_changed = pyqtSignal(bool) if HAS_QT else None
|
||||
close_requested = pyqtSignal() if HAS_QT else None
|
||||
|
||||
def __init__(self, title: str = "Widget", icon: str = "📦", parent=None):
|
||||
if not HAS_QT:
|
||||
return
|
||||
super().__init__(parent)
|
||||
self._collapsed = False
|
||||
self._setup_ui(title, icon)
|
||||
|
||||
def _setup_ui(self, title: str, icon: str):
|
||||
self.setFrameShape(QFrame.Shape.StyledPanel)
|
||||
self.setStyleSheet("""
|
||||
WidgetHeader {
|
||||
background-color: #2d2d2d;
|
||||
border-radius: 8px 8px 0 0;
|
||||
padding: 8px;
|
||||
}
|
||||
""")
|
||||
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(10, 5, 10, 5)
|
||||
layout.setSpacing(10)
|
||||
|
||||
# Icon
|
||||
self.icon_label = QLabel(icon)
|
||||
self.icon_label.setStyleSheet("font-size: 16px;")
|
||||
layout.addWidget(self.icon_label)
|
||||
|
||||
# Title
|
||||
self.title_label = QLabel(title)
|
||||
font = QFont()
|
||||
font.setBold(True)
|
||||
self.title_label.setFont(font)
|
||||
self.title_label.setStyleSheet("color: #ffffff;")
|
||||
layout.addWidget(self.title_label)
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
# Collapse button
|
||||
self.collapse_btn = QPushButton("▼")
|
||||
self.collapse_btn.setFixedSize(24, 24)
|
||||
self.collapse_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #3d3d3d;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #4d4d4d;
|
||||
}
|
||||
""")
|
||||
self.collapse_btn.clicked.connect(self._toggle_collapse)
|
||||
layout.addWidget(self.collapse_btn)
|
||||
|
||||
# Close button
|
||||
self.close_btn = QPushButton("×")
|
||||
self.close_btn.setFixedSize(24, 24)
|
||||
self.close_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #3d3d3d;
|
||||
color: #ff6b6b;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #ff6b6b;
|
||||
color: #ffffff;
|
||||
}
|
||||
""")
|
||||
self.close_btn.clicked.connect(lambda: self.close_requested.emit())
|
||||
layout.addWidget(self.close_btn)
|
||||
|
||||
def _toggle_collapse(self):
|
||||
self._collapsed = not self._collapsed
|
||||
self.collapse_btn.setText("▶" if self._collapsed else "▼")
|
||||
self.collapsed_changed.emit(self._collapsed)
|
||||
|
||||
def set_collapsed(self, collapsed: bool):
|
||||
self._collapsed = collapsed
|
||||
self.collapse_btn.setText("▶" if collapsed else "▼")
|
||||
|
||||
|
||||
class WidgetContent(QFrame if HAS_QT else object):
|
||||
"""Content container for widgets."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
if not HAS_QT:
|
||||
return
|
||||
super().__init__(parent)
|
||||
self.setFrameShape(QFrame.Shape.StyledPanel)
|
||||
self.setStyleSheet("""
|
||||
WidgetContent {
|
||||
background-color: #1e1e1e;
|
||||
border-radius: 0 0 8px 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
""")
|
||||
|
||||
self._layout = QVBoxLayout(self)
|
||||
self._layout.setContentsMargins(10, 10, 10, 10)
|
||||
self._layout.setSpacing(10)
|
||||
|
||||
def add_widget(self, widget):
|
||||
if HAS_QT:
|
||||
self._layout.addWidget(widget)
|
||||
|
||||
def layout(self):
|
||||
return self._layout if HAS_QT else None
|
||||
|
||||
|
||||
class DashboardWidget(QFrame if HAS_QT else object):
|
||||
"""Complete dashboard widget with header and content."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
title: str = "Widget",
|
||||
icon: str = "📦",
|
||||
size: tuple = (300, 200),
|
||||
parent=None
|
||||
):
|
||||
if not HAS_QT:
|
||||
return
|
||||
super().__init__(parent)
|
||||
self._size = size
|
||||
self._setup_ui(title, icon)
|
||||
|
||||
def _setup_ui(self, title: str, icon: str):
|
||||
self.setStyleSheet("""
|
||||
DashboardWidget {
|
||||
background-color: transparent;
|
||||
border: 1px solid #3d3d3d;
|
||||
border-radius: 8px;
|
||||
}
|
||||
""")
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
# Header
|
||||
self.header = WidgetHeader(title, icon, self)
|
||||
self.header.collapsed_changed.connect(self._on_collapse_changed)
|
||||
layout.addWidget(self.header)
|
||||
|
||||
# Content
|
||||
self.content = WidgetContent(self)
|
||||
layout.addWidget(self.content)
|
||||
|
||||
self.setFixedSize(*self._size)
|
||||
|
||||
def _on_collapse_changed(self, collapsed: bool):
|
||||
self.content.setVisible(not collapsed)
|
||||
if collapsed:
|
||||
self.setFixedHeight(self.header.height())
|
||||
else:
|
||||
self.setFixedHeight(self._size[1])
|
||||
|
||||
def set_content_widget(self, widget: QWidget):
|
||||
"""Set the main content widget."""
|
||||
if HAS_QT:
|
||||
# Clear existing
|
||||
while self.content.layout().count():
|
||||
item = self.content.layout().takeAt(0)
|
||||
if item.widget():
|
||||
item.widget().deleteLater()
|
||||
|
||||
self.content.layout().addWidget(widget)
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
"""
|
||||
Metrics card widget for displaying statistics.
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
try:
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QGridLayout
|
||||
)
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtGui import QFont
|
||||
HAS_QT = True
|
||||
except ImportError:
|
||||
HAS_QT = False
|
||||
QWidget = object
|
||||
|
||||
|
||||
@dataclass
|
||||
class StatItem:
|
||||
"""Single statistic item."""
|
||||
label: str
|
||||
value: str
|
||||
change: Optional[str] = None
|
||||
positive: bool = True
|
||||
|
||||
|
||||
class MetricsCard(QWidget if HAS_QT else object):
|
||||
"""Card displaying multiple metrics."""
|
||||
|
||||
def __init__(self, title: str = "Metrics", parent=None):
|
||||
if not HAS_QT:
|
||||
return
|
||||
super().__init__(parent)
|
||||
self._stats: List[StatItem] = []
|
||||
self._setup_ui(title)
|
||||
|
||||
def _setup_ui(self, title: str):
|
||||
self.setStyleSheet("""
|
||||
MetricsCard {
|
||||
background-color: #2d2d2d;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
}
|
||||
QLabel {
|
||||
color: #ffffff;
|
||||
}
|
||||
.metric-label {
|
||||
color: #888888;
|
||||
font-size: 12px;
|
||||
}
|
||||
.metric-value {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.metric-positive {
|
||||
color: #4caf50;
|
||||
}
|
||||
.metric-negative {
|
||||
color: #f44336;
|
||||
}
|
||||
""")
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(15, 15, 15, 15)
|
||||
layout.setSpacing(15)
|
||||
|
||||
# Title
|
||||
self.title_label = QLabel(title)
|
||||
font = QFont()
|
||||
font.setBold(True)
|
||||
font.setPointSize(14)
|
||||
self.title_label.setFont(font)
|
||||
layout.addWidget(self.title_label)
|
||||
|
||||
# Stats grid
|
||||
self.stats_layout = QGridLayout()
|
||||
self.stats_layout.setSpacing(10)
|
||||
layout.addLayout(self.stats_layout)
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
def set_stats(self, stats: List[StatItem]):
|
||||
"""Update displayed statistics."""
|
||||
if not HAS_QT:
|
||||
return
|
||||
|
||||
self._stats = stats
|
||||
|
||||
# Clear existing
|
||||
while self.stats_layout.count():
|
||||
item = self.stats_layout.takeAt(0)
|
||||
if item.widget():
|
||||
item.widget().deleteLater()
|
||||
|
||||
# Add stats
|
||||
for i, stat in enumerate(stats):
|
||||
row = i // 2
|
||||
col = (i % 2) * 2
|
||||
|
||||
# Label
|
||||
label = QLabel(stat.label)
|
||||
label.setStyleSheet("color: #888888; font-size: 11px;")
|
||||
self.stats_layout.addWidget(label, row * 2, col)
|
||||
|
||||
# Value with optional change
|
||||
value_text = stat.value
|
||||
if stat.change:
|
||||
value_text += f" <span style='color: {'#4caf50' if stat.positive else '#f44336'}'>{stat.change}</span>"
|
||||
|
||||
value_label = QLabel(value_text)
|
||||
value_label.setStyleSheet("font-size: 16px; font-weight: bold; color: #ffffff;")
|
||||
self.stats_layout.addWidget(value_label, row * 2 + 1, col)
|
||||
|
||||
def update_stat(self, index: int, value: str, change: Optional[str] = None):
|
||||
"""Update a single statistic."""
|
||||
if 0 <= index < len(self._stats):
|
||||
self._stats[index].value = value
|
||||
self._stats[index].change = change
|
||||
self.set_stats(self._stats)
|
||||
Loading…
Reference in New Issue