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
.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 tkinter as tk
from tkinter import messagebox
import flet as ft
# Compatibility fix for Flet 0.28+ (Icons vs icons, Colors vs colors)
ft.icons = ft.Icons
ft.colors = ft.Colors
# Import our modular components
try:
@@ -24,11 +26,26 @@ except ImportError as e:
class GitHubAutomationApp:
"""Main application class that orchestrates all components"""
def __init__(self):
def __init__(self, page: ft.Page):
"""Initialize the application"""
self.root = tk.Tk()
self.root.title("GitHub Pulse")
self.root.geometry("1400x1000")
self.page = page
# 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
self.config_manager = ConfigManager()
@@ -41,20 +58,30 @@ class GitHubAutomationApp:
dry_run_config = self.config.get('DRY_RUN', 'false')
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
self.main_gui = MainGUI(
root=self.root,
page=self.page,
config_manager=self.config_manager,
ai_manager=self.ai_manager,
app=self
)
# Set up AI provider check after GUI is ready
self.root.after(100, self._check_ai_provider_setup)
# Build UI
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"""
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()
if not ai_provider or ai_provider in ['none', '']:
@@ -64,7 +91,7 @@ class GitHubAutomationApp:
return # Unknown provider
# 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:
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
return GitHubAPI(token, logger, dry_run)
def run(self):
"""Start the application"""
try:
self.root.mainloop()
except KeyboardInterrupt:
print("Application interrupted by user")
except Exception as e:
messagebox.showerror("Application Error", f"An unexpected error occurred:\n{str(e)}")
print(f"Application error: {e}")
def _on_setting_changed(self, key: str, value: any):
"""
Handle settings changes with live updates (no restart needed!)
Args:
key: Setting key that changed
value: New value
"""
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():
"""Main entry point"""
async def main(page: ft.Page):
"""Main entry point for Flet application"""
try:
app = GitHubAutomationApp()
app.run()
app = GitHubAutomationApp(page)
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}")
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__":
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)
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:
"""Show detailed AI modules information"""
try:
+180 -216
View File
@@ -1,221 +1,135 @@
"""
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 json
from typing import Dict, Any, Optional
from pathlib import Path
from .settings_manager import SettingsManager
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):
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]:
"""Get default configuration values"""
return {
'GITHUB_PAT': None,
'GITHUB_REPO': None,
'FORKED_REPO': None, # User's fork repository
'AI_PROVIDER': None,
'CLAUDE_API_KEY': None,
'OPENAI_API_KEY': None,
'GITHUB_TOKEN': None, # For GitHub Copilot AI Provider
'OLLAMA_URL': None, # Ollama server URL
'OLLAMA_API_KEY': None, # Optional Ollama API key/password
'OLLAMA_MODEL': None, # Selected Ollama model
'LOCAL_REPO_PATH': None,
'DRY_RUN': 'false',
'CUSTOM_INSTRUCTIONS': None # Custom AI instructions
}
# Check if .env exists and offer migration
env_path = Path('.env')
if env_path.exists() and not Path('application/config.json').exists():
print("\n" + "="*60)
print("NOTICE: Legacy .env file detected!")
print("="*60)
print("Your app now uses a modern settings system with:")
print(" ✓ Secure API key storage (Windows Credential Manager)")
print(" ✓ Live settings updates (no restart needed)")
print(" ✓ Better configuration management")
print()
print("Migrating settings from .env to new system...")
print()
if self._settings.migrate_from_env(env_path):
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]:
"""Load configuration from launch.json first, then .env as fallback"""
config = self._get_default_config()
launch_json_keys = set()
"""
Load configuration from new system (config.json + keyring).
# First, try to load from launch.json
launch_json_path = os.path.join('.vscode', 'launch.json')
if os.path.exists(launch_json_path):
try:
with open(launch_json_path, 'r', encoding='utf-8') as f:
launch_data = json.load(f)
# 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}")
Returns:
Dictionary of all settings
"""
self.config = self._settings.load()
self._apply_token_defaults()
return self.config
def save_configuration(self, config_values: Dict[str, Any]) -> bool:
"""Save configuration to .env file"""
try:
print(f"DEBUG: Saving config values: {config_values}")
print(f"DEBUG: AI_PROVIDER value being saved: '{config_values.get('AI_PROVIDER', 'NOT_FOUND')}'")
"""
Save configuration using new system.
# Update internal config
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}'")
No restart required - changes apply immediately!
# Build .env file content
env_content = []
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("")
Args:
config_values: Settings to save
env_content.append("# GitHub Configuration")
env_content.append(f"GITHUB_PAT={self.config.get('GITHUB_PAT', '')}")
env_content.append(f"GITHUB_REPO={self.config.get('GITHUB_REPO', '')}")
env_content.append(f"FORKED_REPO={self.config.get('FORKED_REPO', '')}")
env_content.append("")
Returns:
True if successful
"""
# Save using new system
success = self._settings.save(config_values)
env_content.append("# Application Settings")
dry_run_value = str(self.config.get('DRY_RUN', 'false')).lower()
env_content.append(f"DRY_RUN={dry_run_value}")
env_content.append("")
if success:
# Reload to get updated values
self.config = self._settings.get_all()
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)")
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
return success
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()
# 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_pat = config.get('GITHUB_PAT', '').strip() if config.get('GITHUB_PAT') else ''
@@ -225,18 +139,73 @@ CUSTOM_INSTRUCTIONS=
return config
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:
"""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:
"""Set a specific configuration value"""
if key in self.config:
"""
Set a specific configuration 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:
"""Get the path to the PR counter file"""
script_dir = os.path.dirname(os.path.abspath(__file__))
@@ -251,32 +220,27 @@ CUSTOM_INSTRUCTIONS=
return json.load(f)
except (json.JSONDecodeError, FileNotFoundError):
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"""
counter_file = self.get_pr_counter_file()
try:
# Ensure directory exists
os.makedirs(os.path.dirname(counter_file), exist_ok=True)
with open(counter_file, 'w', encoding='utf-8') as f:
json.dump(counter, f, indent=2)
return True
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:
"""
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
"""
def increment_pr_counter(self) -> int:
"""Increment and return the PR counter"""
counter = self.load_pr_counter()
current_number = counter.get(provider_key, 0)
next_number = current_number + 1
counter[provider_key] = next_number
counter['count'] = counter.get('count', 0) + 1
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
requests>=2.31.0
keyring>=24.0.0 # Secure credential storage
# UI Framework
flet>=0.23.0
# AI providers (optional - installed automatically when needed)
anthropic>=0.18.0 # Claude AI