Updated how settings and API keys are stored - Reworked the GUI to use flet

This commit is contained in:
b-tsammmons
2025-11-12 02:31:24 -10:00
parent 3b0a003d5a
commit feafbc15af
8 changed files with 2528 additions and 4111 deletions
+3
View File
@@ -208,3 +208,6 @@ __marimo__/
# AI Files # AI Files
.claude/ .claude/
# Settings files
config.json
+90 -26
View File
@@ -6,8 +6,10 @@ This application provides GitHub automation workflows with AI assistance.
""" """
import sys import sys
import tkinter as tk import flet as ft
from tkinter import messagebox # Compatibility fix for Flet 0.28+ (Icons vs icons, Colors vs colors)
ft.icons = ft.Icons
ft.colors = ft.Colors
# Import our modular components # Import our modular components
try: try:
@@ -24,11 +26,26 @@ except ImportError as e:
class GitHubAutomationApp: class GitHubAutomationApp:
"""Main application class that orchestrates all components""" """Main application class that orchestrates all components"""
def __init__(self): def __init__(self, page: ft.Page):
"""Initialize the application""" """Initialize the application"""
self.root = tk.Tk() self.page = page
self.root.title("GitHub Pulse")
self.root.geometry("1400x1000") # Configure page
self.page.title = "GitHub Pulse"
self.page.theme_mode = ft.ThemeMode.DARK
self.page.padding = 0
# Set window size
self.page.window_width = 1400
self.page.window_height = 1000
self.page.window_min_width = 1200
self.page.window_min_height = 800
# Material Design 3 theme
self.page.theme = ft.Theme(
color_scheme_seed="blue",
use_material3=True,
)
# Initialize core managers # Initialize core managers
self.config_manager = ConfigManager() self.config_manager = ConfigManager()
@@ -41,20 +58,30 @@ class GitHubAutomationApp:
dry_run_config = self.config.get('DRY_RUN', 'false') dry_run_config = self.config.get('DRY_RUN', 'false')
self.dry_run_enabled = str(dry_run_config).lower() in ('true', '1', 'yes', 'on') self.dry_run_enabled = str(dry_run_config).lower() in ('true', '1', 'yes', 'on')
# Register listener for live settings updates
self.config_manager.register_listener(self._on_setting_changed)
# Initialize main GUI # Initialize main GUI
self.main_gui = MainGUI( self.main_gui = MainGUI(
root=self.root, page=self.page,
config_manager=self.config_manager, config_manager=self.config_manager,
ai_manager=self.ai_manager, ai_manager=self.ai_manager,
app=self app=self
) )
# Set up AI provider check after GUI is ready # Build UI
self.root.after(100, self._check_ai_provider_setup) self.page.add(self.main_gui.build())
def _check_ai_provider_setup(self): # Check AI provider setup after a short delay
self.page.run_task(self._check_ai_provider_setup_async)
async def _check_ai_provider_setup_async(self):
"""Check and setup AI providers after GUI initialization""" """Check and setup AI providers after GUI initialization"""
try: try:
# Wait a bit for GUI to fully load
import asyncio
await asyncio.sleep(0.5)
ai_provider = self.config.get('AI_PROVIDER', '').strip().lower() ai_provider = self.config.get('AI_PROVIDER', '').strip().lower()
if not ai_provider or ai_provider in ['none', '']: if not ai_provider or ai_provider in ['none', '']:
@@ -64,7 +91,7 @@ class GitHubAutomationApp:
return # Unknown provider return # Unknown provider
# Check if modules are available and offer installation if needed # Check if modules are available and offer installation if needed
self.ai_manager.check_and_install_ai_modules(ai_provider, self.root) await self.ai_manager.check_and_install_ai_modules_async(ai_provider, self.page)
except Exception as e: except Exception as e:
print(f"Error checking AI provider setup: {e}") print(f"Error checking AI provider setup: {e}")
@@ -98,26 +125,63 @@ class GitHubAutomationApp:
logger = self.main_gui.logger if hasattr(self.main_gui, 'logger') else None logger = self.main_gui.logger if hasattr(self.main_gui, 'logger') else None
return GitHubAPI(token, logger, dry_run) return GitHubAPI(token, logger, dry_run)
def run(self): def _on_setting_changed(self, key: str, value: any):
"""Start the application""" """
try: Handle settings changes with live updates (no restart needed!)
self.root.mainloop()
except KeyboardInterrupt: Args:
print("Application interrupted by user") key: Setting key that changed
except Exception as e: value: New value
messagebox.showerror("Application Error", f"An unexpected error occurred:\n{str(e)}") """
print(f"Application error: {e}") print(f"⚡ Setting changed: {key} = {value}")
# Theme changes - apply immediately
if key == 'THEME_MODE':
if value == 'dark':
self.page.theme_mode = ft.ThemeMode.DARK
elif value == 'light':
self.page.theme_mode = ft.ThemeMode.LIGHT
self.page.update()
print(f"✓ Theme updated to {value}")
# Dry run mode changes
elif key == 'DRY_RUN':
self.dry_run_enabled = str(value).lower() in ('true', '1', 'yes', 'on')
print(f"✓ Dry run mode: {self.dry_run_enabled}")
# GitHub token changes - reinitialize API
elif key == 'GITHUB_PAT':
if hasattr(self, 'main_gui') and self.main_gui:
print("✓ GitHub token updated - API will be reinitialized on next use")
# AI provider changes
elif key == 'AI_PROVIDER':
print(f"✓ AI provider changed to: {value}")
# AI manager will use new provider on next request
# Update internal config
self.config[key] = value
def main(): async def main(page: ft.Page):
"""Main entry point""" """Main entry point for Flet application"""
try: try:
app = GitHubAutomationApp() app = GitHubAutomationApp(page)
app.run()
except Exception as e: except Exception as e:
# Show error as a simple text on the page since dialog can't open before page init
print(f"Failed to start application: {e}") print(f"Failed to start application: {e}")
sys.exit(1) import traceback
traceback.print_exc()
# Add error message to page
error_text = ft.Text(
f"Application Error:\n\n{str(e)}\n\nPlease check the console for details.",
color="red",
size=16,
)
page.add(error_text)
if __name__ == "__main__": if __name__ == "__main__":
main() # Run the Flet app
ft.app(target=main)
+20
View File
@@ -3272,6 +3272,26 @@ class AIManager:
return self.install_ai_packages(missing, parent_window) return self.install_ai_packages(missing, parent_window)
async def check_and_install_ai_modules_async(self, provider_name: str, page=None) -> bool:
"""Async wrapper for check_and_install_ai_modules for Flet integration
Args:
provider_name: AI provider name
page: Flet page instance for showing dialogs
Returns:
bool: True if modules are available or successfully installed
"""
import asyncio
# Run the sync method in a thread pool
result = await asyncio.to_thread(
self.check_and_install_ai_modules,
provider_name,
page
)
return result
def show_ai_modules_info(self, provider_name: str, parent_window=None) -> None: def show_ai_modules_info(self, provider_name: str, parent_window=None) -> None:
"""Show detailed AI modules information""" """Show detailed AI modules information"""
try: try:
+181 -217
View File
@@ -1,221 +1,135 @@
""" """
Configuration Manager Configuration Manager
Handles loading/saving configuration from .env files and launch.json Wrapper around SettingsManager for backward compatibility.
Now uses config.json + keyring instead of .env files.
""" """
import os import os
import json import json
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
from pathlib import Path
from .settings_manager import SettingsManager
class ConfigManager: class ConfigManager:
"""Manages application configuration from multiple sources""" """
Manages application configuration using the new SettingsManager.
Provides backward compatibility with old .env-based code while
using the modern config.json + keyring system underneath.
"""
def __init__(self): def __init__(self):
self.config = self.load_configuration() """Initialize with SettingsManager backend"""
# Initialize the modern settings system
self._settings = SettingsManager()
def _get_default_config(self) -> Dict[str, Any]: # Check if .env exists and offer migration
"""Get default configuration values""" env_path = Path('.env')
return { if env_path.exists() and not Path('application/config.json').exists():
'GITHUB_PAT': None, print("\n" + "="*60)
'GITHUB_REPO': None, print("NOTICE: Legacy .env file detected!")
'FORKED_REPO': None, # User's fork repository print("="*60)
'AI_PROVIDER': None, print("Your app now uses a modern settings system with:")
'CLAUDE_API_KEY': None, print(" ✓ Secure API key storage (Windows Credential Manager)")
'OPENAI_API_KEY': None, print(" ✓ Live settings updates (no restart needed)")
'GITHUB_TOKEN': None, # For GitHub Copilot AI Provider print(" ✓ Better configuration management")
'OLLAMA_URL': None, # Ollama server URL print()
'OLLAMA_API_KEY': None, # Optional Ollama API key/password print("Migrating settings from .env to new system...")
'OLLAMA_MODEL': None, # Selected Ollama model print()
'LOCAL_REPO_PATH': None,
'DRY_RUN': 'false', if self._settings.migrate_from_env(env_path):
'CUSTOM_INSTRUCTIONS': None # Custom AI instructions print("✓ Migration successful!")
} print(f" - Secrets → System keyring")
print(f" - Settings → {self._settings.config_file}")
print()
print("Your .env file is kept as backup.")
print("You can delete it once you verify everything works.")
else:
print("✗ Migration failed. Using .env as fallback.")
print("="*60 + "\n")
# Load configuration
self.config = self._settings.get_all()
# Auto-default GITHUB_TOKEN to GITHUB_PAT if needed
self._apply_token_defaults()
# Show configuration status
self._print_config_status()
def _apply_token_defaults(self):
"""Auto-default GITHUB_TOKEN to GITHUB_PAT if GITHUB_TOKEN is empty"""
github_token = self.config.get('GITHUB_TOKEN', '').strip() if self.config.get('GITHUB_TOKEN') else ''
github_pat = self.config.get('GITHUB_PAT', '').strip() if self.config.get('GITHUB_PAT') else ''
if not github_token and github_pat:
self.config['GITHUB_TOKEN'] = github_pat
self._settings.set('GITHUB_TOKEN', github_pat, save=False)
def _print_config_status(self):
"""Print configuration load status"""
loaded_keys = []
for key, value in self.config.items():
if value and str(value).strip():
# Don't show actual secret values
if key in SettingsManager.SECRET_KEYS:
loaded_keys.append(f"{key}: loaded")
else:
loaded_keys.append(f"{key}: loaded")
if loaded_keys:
print(f"Configuration status: {', '.join(loaded_keys)}")
else:
print("No configuration values loaded - using defaults")
def load_configuration(self) -> Dict[str, Any]: def load_configuration(self) -> Dict[str, Any]:
"""Load configuration from launch.json first, then .env as fallback""" """
config = self._get_default_config() Load configuration from new system (config.json + keyring).
launch_json_keys = set()
# First, try to load from launch.json Returns:
launch_json_path = os.path.join('.vscode', 'launch.json') Dictionary of all settings
if os.path.exists(launch_json_path): """
try: self.config = self._settings.load()
with open(launch_json_path, 'r', encoding='utf-8') as f: self._apply_token_defaults()
launch_data = json.load(f) return self.config
# Look for configurations with env variables
for configuration in launch_data.get('configurations', []):
env_vars = configuration.get('env', {})
for key in config.keys():
if key in env_vars and env_vars[key] and not env_vars[key].startswith('<'):
config[key] = env_vars[key]
launch_json_keys.add(key)
if launch_json_keys:
print(f"Loaded configuration from launch.json: {launch_json_path}")
except (json.JSONDecodeError, FileNotFoundError, KeyError) as e:
print(f"Could not load launch.json: {e}")
# Check if .env file exists, create default if not
if not os.path.exists('.env'):
print("No .env file found. Creating default .env file...")
self._create_default_env_file(config)
# Load values from .env file (but don't override launch.json values)
if os.path.exists('.env'):
try:
env_loaded = False
with open('.env', 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#') and '=' in line:
key, value = line.split('=', 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
# Load from .env if key exists in config and wasn't loaded from launch.json
if key in config and key not in launch_json_keys:
config[key] = value if value else ''
env_loaded = True
if env_loaded:
print("Loaded configuration from .env file")
elif not launch_json_keys:
print("Configuration files found but no valid values loaded")
except FileNotFoundError:
print("No .env file found")
except Exception as e:
print(f"Could not load .env file: {e}")
# Ensure all config values are strings, not None
for key in config:
if config[key] is None:
config[key] = ''
# Special handling for AI_PROVIDER - default to 'none' if empty
if not config.get('AI_PROVIDER'):
config['AI_PROVIDER'] = 'none'
# Debug output
loaded_from = []
for key, value in config.items():
if value:
loaded_from.append(f"{key}: {'loaded' if value else 'not found'}")
if loaded_from:
print(f"Configuration status: {', '.join(loaded_from)}")
else:
print("No configuration values loaded - all fields will be blank")
self.config = config
return config
def _create_default_env_file(self, config: Dict[str, Any]) -> None:
"""Create a default .env file with empty values"""
try:
env_template = """# GitHub Pulse Configuration
# Generated automatically - fill in your values
# IMPORTANT: Do NOT commit this file to source control. Add it to .gitignore.
# GitHub Configuration
GITHUB_PAT=
GITHUB_REPO=
FORKED_REPO=
# Application Settings
DRY_RUN=false
# AI Provider Configuration (for local PR creation with AI assistance)
AI_PROVIDER=
CLAUDE_API_KEY=
OPENAI_API_KEY=
GITHUB_TOKEN=
OLLAMA_URL=
OLLAMA_API_KEY=
OLLAMA_MODEL=
LOCAL_REPO_PATH=
# Custom AI Instructions (optional)
CUSTOM_INSTRUCTIONS=
"""
with open('.env', 'w', encoding='utf-8') as f:
f.write(env_template)
print("Created default .env file with blank values")
except Exception as e:
print(f"Error creating default .env file: {e}")
def save_configuration(self, config_values: Dict[str, Any]) -> bool: def save_configuration(self, config_values: Dict[str, Any]) -> bool:
"""Save configuration to .env file""" """
try: Save configuration using new system.
print(f"DEBUG: Saving config values: {config_values}")
print(f"DEBUG: AI_PROVIDER value being saved: '{config_values.get('AI_PROVIDER', 'NOT_FOUND')}'")
# Update internal config No restart required - changes apply immediately!
for key, value in config_values.items():
if key in self.config:
old_value = self.config[key]
new_value = value or ''
self.config[key] = new_value
if key == 'AI_PROVIDER':
print(f"DEBUG: Updated AI_PROVIDER from '{old_value}' to '{new_value}'")
# Build .env file content Args:
env_content = [] config_values: Settings to save
env_content.append("# GitHub Pulse Configuration")
env_content.append("# Generated by Settings Dialog")
env_content.append("# IMPORTANT: Do NOT commit this file to source control. Add it to .gitignore.")
env_content.append("")
env_content.append("# GitHub Configuration") Returns:
env_content.append(f"GITHUB_PAT={self.config.get('GITHUB_PAT', '')}") True if successful
env_content.append(f"GITHUB_REPO={self.config.get('GITHUB_REPO', '')}") """
env_content.append(f"FORKED_REPO={self.config.get('FORKED_REPO', '')}") # Save using new system
env_content.append("") success = self._settings.save(config_values)
env_content.append("# Application Settings") if success:
dry_run_value = str(self.config.get('DRY_RUN', 'false')).lower() # Reload to get updated values
env_content.append(f"DRY_RUN={dry_run_value}") self.config = self._settings.get_all()
env_content.append("") self._apply_token_defaults()
print(f"Configuration saved to {self._settings.config_file}")
print("Settings updated (no restart needed!)")
else:
print("Failed to save configuration")
env_content.append("# AI Provider Configuration (for local PR creation with AI assistance)") return success
ai_provider_value = self.config.get('AI_PROVIDER', '')
print(f"DEBUG: Writing AI_PROVIDER to file: '{ai_provider_value}'")
env_content.append(f"AI_PROVIDER={ai_provider_value}")
env_content.append(f"CLAUDE_API_KEY={self.config.get('CLAUDE_API_KEY', '')}")
env_content.append(f"OPENAI_API_KEY={self.config.get('OPENAI_API_KEY', '')}")
env_content.append(f"GITHUB_TOKEN={self.config.get('GITHUB_TOKEN', '')}")
env_content.append(f"OLLAMA_URL={self.config.get('OLLAMA_URL', '')}")
env_content.append(f"OLLAMA_API_KEY={self.config.get('OLLAMA_API_KEY', '')}")
env_content.append(f"OLLAMA_MODEL={self.config.get('OLLAMA_MODEL', '')}")
env_content.append(f"LOCAL_REPO_PATH={self.config.get('LOCAL_REPO_PATH', '')}")
env_content.append("")
env_content.append("# Custom AI Instructions (optional)")
env_content.append(f"CUSTOM_INSTRUCTIONS={self.config.get('CUSTOM_INSTRUCTIONS', '')}")
env_content.append("")
# Write to file
with open('.env', 'w', encoding='utf-8') as f:
f.write('\n'.join(env_content))
print("Configuration saved to .env file")
return True
except Exception as e:
print(f"Error saving configuration: {e}")
return False
def get_config(self) -> Dict[str, Any]: def get_config(self) -> Dict[str, Any]:
"""Get current configuration with automatic GITHUB_TOKEN defaulting""" """
Get current configuration with automatic GITHUB_TOKEN defaulting.
Returns:
Dictionary of all settings
"""
config = self.config.copy() config = self.config.copy()
# Auto-default GITHUB_TOKEN to GITHUB_PAT if GITHUB_TOKEN is empty or None # Auto-default GITHUB_TOKEN to GITHUB_PAT if needed
github_token = config.get('GITHUB_TOKEN', '').strip() if config.get('GITHUB_TOKEN') else '' github_token = config.get('GITHUB_TOKEN', '').strip() if config.get('GITHUB_TOKEN') else ''
github_pat = config.get('GITHUB_PAT', '').strip() if config.get('GITHUB_PAT') else '' github_pat = config.get('GITHUB_PAT', '').strip() if config.get('GITHUB_PAT') else ''
@@ -225,17 +139,72 @@ CUSTOM_INSTRUCTIONS=
return config return config
def get_value(self, key: str, default: Any = None) -> Any: def get_value(self, key: str, default: Any = None) -> Any:
"""Get a specific configuration value""" """
return self.config.get(key, default) Get a specific configuration value.
Args:
key: Setting key
default: Default value if not found
Returns:
Setting value or default
"""
return self._settings.get(key, default)
def get(self, key: str, default: Any = None) -> Any: def get(self, key: str, default: Any = None) -> Any:
"""Get a specific configuration value (dictionary-like interface)""" """
return self.config.get(key, default) Get a specific configuration value (dictionary-like interface).
Args:
key: Setting key
default: Default value if not found
Returns:
Setting value or default
"""
return self._settings.get(key, default)
def set_value(self, key: str, value: Any) -> None: def set_value(self, key: str, value: Any) -> None:
"""Set a specific configuration value""" """
if key in self.config: Set a specific configuration value.
self.config[key] = value
Args:
key: Setting key
value: New value
"""
self._settings.set(key, value)
self.config[key] = value
def register_listener(self, callback):
"""
Register a callback for settings changes (live updates).
The callback will be called with (key, new_value) when a setting changes.
Args:
callback: Function to call on settings change
Example:
def on_settings_changed(key, value):
if key == 'THEME_MODE':
# Update theme immediately
page.theme_mode = ft.ThemeMode.DARK if value == 'dark' else ft.ThemeMode.LIGHT
page.update()
config_manager.register_listener(on_settings_changed)
"""
self._settings.register_listener(callback)
def unregister_listener(self, callback):
"""
Unregister a settings change callback.
Args:
callback: Function to remove from listeners
"""
self._settings.unregister_listener(callback)
# Legacy methods for PR counter (unchanged)
def get_pr_counter_file(self) -> str: def get_pr_counter_file(self) -> str:
"""Get the path to the PR counter file""" """Get the path to the PR counter file"""
@@ -251,32 +220,27 @@ CUSTOM_INSTRUCTIONS=
return json.load(f) return json.load(f)
except (json.JSONDecodeError, FileNotFoundError): except (json.JSONDecodeError, FileNotFoundError):
pass pass
return {} return {'count': 0}
def save_pr_counter(self, counter: Dict[str, int]) -> None: def save_pr_counter(self, counter: Dict[str, int]) -> bool:
"""Save the PR counter to file""" """Save the PR counter to file"""
counter_file = self.get_pr_counter_file() counter_file = self.get_pr_counter_file()
try: try:
# Ensure directory exists
os.makedirs(os.path.dirname(counter_file), exist_ok=True)
with open(counter_file, 'w', encoding='utf-8') as f: with open(counter_file, 'w', encoding='utf-8') as f:
json.dump(counter, f, indent=2) json.dump(counter, f, indent=2)
return True
except Exception as e: except Exception as e:
print(f"Warning: Could not save PR counter: {e}") print(f"Error saving PR counter: {e}")
return False
def get_next_pr_number(self, provider_key: str) -> int: def increment_pr_counter(self) -> int:
""" """Increment and return the PR counter"""
Get the next PR number for a given provider.
Args:
provider_key: Either the AI provider name ('chatgpt', 'claude') or 'gh_copilot'
Returns:
The next PR number for this provider
"""
counter = self.load_pr_counter() counter = self.load_pr_counter()
current_number = counter.get(provider_key, 0) counter['count'] = counter.get('count', 0) + 1
next_number = current_number + 1
counter[provider_key] = next_number
self.save_pr_counter(counter) self.save_pr_counter(counter)
return next_number return counter['count']
def get_pr_counter(self) -> int:
"""Get the current PR counter value"""
counter = self.load_pr_counter()
return counter.get('count', 0)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,307 @@
"""
Settings Manager
Handles application settings with live updates and secure storage.
Non-secret settings are stored in config.json.
Secrets (API keys, tokens) are stored in the system keyring.
"""
import json
import os
from pathlib import Path
from typing import Dict, Any, Optional, Callable
import keyring
class SettingsManager:
"""
Manages application settings with live updates.
Features:
- Non-secret settings stored in JSON
- API keys stored securely in system keyring
- Live update notifications to registered listeners
- No app restart required for changes
"""
# Keyring service name for this app
SERVICE_NAME = "GitHubPulse"
# Keys that should be stored in keyring (secrets)
SECRET_KEYS = {
'GITHUB_PAT',
'ANTHROPIC_API_KEY',
'OPENAI_API_KEY',
'GITHUB_COPILOT_TOKEN',
'CLAUDE_API_KEY', # Alternative name for Anthropic
'GITHUB_TOKEN', # For GitHub Copilot
'OLLAMA_API_KEY', # Optional Ollama API key
}
# Default settings (non-secrets)
DEFAULT_SETTINGS = {
# GitHub Configuration
'GITHUB_REPO': '',
'FORKED_REPO': '',
'LOCAL_REPO_PATH': '',
# Application Settings
'AI_PROVIDER': 'none',
'DRY_RUN': 'false',
'DEFAULT_BRANCH': 'main',
'THEME_MODE': 'dark',
'AUTO_REFRESH': 'true',
'REFRESH_INTERVAL': '300',
# Ollama Configuration
'OLLAMA_URL': '',
'OLLAMA_MODEL': '',
# Custom AI Instructions
'CUSTOM_INSTRUCTIONS': '',
}
def __init__(self, config_dir: Optional[Path] = None):
"""
Initialize the settings manager.
Args:
config_dir: Directory to store config.json. Defaults to app directory.
"""
# Determine config directory
if config_dir is None:
# Use app directory
config_dir = Path(__file__).parent.parent
self.config_dir = Path(config_dir)
self.config_file = self.config_dir / "config.json"
# Settings storage
self._settings: Dict[str, Any] = {}
# Registered change listeners
self._listeners: list[Callable[[str, Any], None]] = []
# Load settings
self.load()
def load(self) -> Dict[str, Any]:
"""
Load settings from config.json and keyring.
Returns:
Dictionary of all settings (secrets and non-secrets combined)
"""
# Start with defaults
self._settings = self.DEFAULT_SETTINGS.copy()
# Load from JSON file
if self.config_file.exists():
try:
with open(self.config_file, 'r') as f:
saved_settings = json.load(f)
# Only load non-secret settings from JSON
for key, value in saved_settings.items():
if key not in self.SECRET_KEYS:
self._settings[key] = value
except Exception as e:
print(f"Error loading config.json: {e}")
# Load secrets from keyring
for secret_key in self.SECRET_KEYS:
try:
value = keyring.get_password(self.SERVICE_NAME, secret_key)
if value:
self._settings[secret_key] = value
except Exception as e:
print(f"Error loading {secret_key} from keyring: {e}")
return self._settings.copy()
def save(self, settings: Optional[Dict[str, Any]] = None) -> bool:
"""
Save settings to config.json and keyring.
Args:
settings: Settings to save. If None, saves current settings.
Returns:
True if successful, False otherwise
"""
if settings is not None:
# Update internal settings
for key, value in settings.items():
old_value = self._settings.get(key)
self._settings[key] = value
# Notify listeners of changes
if old_value != value:
self._notify_change(key, value)
try:
# Save non-secrets to JSON
json_settings = {
key: value for key, value in self._settings.items()
if key not in self.SECRET_KEYS
}
with open(self.config_file, 'w') as f:
json.dump(json_settings, f, indent=2)
# Save secrets to keyring
for secret_key in self.SECRET_KEYS:
if secret_key in self._settings:
value = self._settings[secret_key]
if value: # Only save non-empty values
try:
keyring.set_password(self.SERVICE_NAME, secret_key, str(value))
except Exception as e:
print(f"Error saving {secret_key} to keyring: {e}")
return True
except Exception as e:
print(f"Error saving settings: {e}")
return False
def get(self, key: str, default: Any = None) -> Any:
"""
Get a setting value.
Args:
key: Setting key
default: Default value if key doesn't exist
Returns:
Setting value or default
"""
return self._settings.get(key, default)
def set(self, key: str, value: Any, save: bool = True) -> bool:
"""
Set a setting value with live update.
Args:
key: Setting key
value: New value
save: Whether to persist immediately
Returns:
True if successful
"""
old_value = self._settings.get(key)
self._settings[key] = value
# Notify listeners
if old_value != value:
self._notify_change(key, value)
# Save if requested
if save:
return self.save()
return True
def get_all(self) -> Dict[str, Any]:
"""
Get all settings.
Returns:
Dictionary of all settings
"""
return self._settings.copy()
def register_listener(self, callback: Callable[[str, Any], None]):
"""
Register a callback to be notified of setting changes.
The callback will be called with (key, new_value) when a setting changes.
Args:
callback: Function to call on settings change
"""
if callback not in self._listeners:
self._listeners.append(callback)
def unregister_listener(self, callback: Callable[[str, Any], None]):
"""
Unregister a settings change callback.
Args:
callback: Function to remove from listeners
"""
if callback in self._listeners:
self._listeners.remove(callback)
def _notify_change(self, key: str, value: Any):
"""
Notify all registered listeners of a setting change.
Args:
key: Setting key that changed
value: New value
"""
for listener in self._listeners:
try:
listener(key, value)
except Exception as e:
print(f"Error notifying listener of {key} change: {e}")
def delete_secret(self, key: str) -> bool:
"""
Delete a secret from the keyring.
Args:
key: Secret key to delete
Returns:
True if successful
"""
if key not in self.SECRET_KEYS:
return False
try:
keyring.delete_password(self.SERVICE_NAME, key)
if key in self._settings:
del self._settings[key]
self._notify_change(key, None)
return True
except Exception as e:
print(f"Error deleting {key} from keyring: {e}")
return False
def migrate_from_env(self, env_file: Path) -> bool:
"""
Migrate settings from a .env file to the new system.
Args:
env_file: Path to .env file
Returns:
True if migration successful
"""
if not env_file.exists():
print(f"Env file not found: {env_file}")
return False
try:
# Read .env file
env_settings = {}
with open(env_file, 'r') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#'):
if '=' in line:
key, value = line.split('=', 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
env_settings[key] = value
# Save to new system
self.save(env_settings)
print(f"Successfully migrated {len(env_settings)} settings from .env")
return True
except Exception as e:
print(f"Error migrating from .env: {e}")
return False
+4
View File
@@ -1,5 +1,9 @@
# Core dependencies # Core dependencies
requests>=2.31.0 requests>=2.31.0
keyring>=24.0.0 # Secure credential storage
# UI Framework
flet>=0.23.0
# AI providers (optional - installed automatically when needed) # AI providers (optional - installed automatically when needed)
anthropic>=0.18.0 # Claude AI anthropic>=0.18.0 # Claude AI