diff --git a/batch_processing/batch_method.py b/batch_processing/batch_method.py index 6153597..1d0e9df 100644 --- a/batch_processing/batch_method.py +++ b/batch_processing/batch_method.py @@ -11,6 +11,7 @@ from file_handling.data_conversion import to_long_df, save_as_csv, join_datasets from file_handling.data_import import import_data from settings import config, secrets_store +from settings.user_config import get_setting from openai import OpenAI from batch_processing.batch_creation import generate_single_label_batch, generate_multi_label_batch, \ generate_keyword_extraction_batch @@ -29,7 +30,10 @@ def get_client() -> OpenAI: Raises: Exception: If API key is not configured or invalid """ - return OpenAI(api_key=secrets_store.load_api_key()) + api_key = secrets_store.load_api_key() + if not api_key: + raise Exception("OpenAI API key not configured. Please set it in Settings.") + return OpenAI(api_key=api_key) def send_batch(root: Any, type: str) -> Any: @@ -112,7 +116,7 @@ def send_batch(root: Any, type: str) -> Any: endpoint="/v1/responses", completion_window="24h", metadata={ - "model": config.model, + "model": get_setting("model", "gpt-4o"), "type": type, "dataset(s)": joined_datasets } @@ -214,7 +218,7 @@ def list_batches(): The number of batches returned is limited by config.max_batches. Timestamps are converted to the configured timezone. """ - limit = config.max_batches + limit = get_setting("max_batches", 4) client = get_client() batches = client.batches.list(limit=limit) @@ -226,7 +230,7 @@ def list_batches(): for batch in batches: # Convert timestamp to configured timezone - created_time = datetime.fromtimestamp(batch.created_at, ZoneInfo(config.time_zone)) + created_time = datetime.fromtimestamp(batch.created_at, ZoneInfo(get_setting("time_zone", "UTC"))) md = (batch.metadata or {}) tuple_of_batch_data = ( diff --git a/settings/models_registry.py b/settings/models_registry.py index 97dcef8..3b84e92 100644 --- a/settings/models_registry.py +++ b/settings/models_registry.py @@ -7,7 +7,12 @@ from settings import secrets_store from openai import OpenAI -client = OpenAI(api_key=secrets_store.load_api_key()) +# Initialize client only if API key is available, otherwise set to None +try: + api_key = secrets_store.load_api_key() + client = OpenAI(api_key=api_key) if api_key else None +except Exception: + client = None # Optional: persist across runs (so you don't hit the API even on first open) _CACHE_FILE = Path.home() / ".codebookai_models_cache.json" @@ -34,6 +39,8 @@ def _fetch_models_from_api() -> list[str]: Replace this with your real API call. Must return a list[str] of model IDs. """ + if client is None: + raise Exception("No API key available") api_list_of_models = client.models.list() models = [] for m in api_list_of_models.data: @@ -91,3 +98,16 @@ def refresh_models() -> list[str]: # Keep the existing list on failure pass return _MODELS or [] + + +def refresh_client() -> None: + """ + Refresh the OpenAI client instance when API key is updated. + Call this after saving a new API key to reinitialize the client. + """ + global client + try: + api_key = secrets_store.load_api_key() + client = OpenAI(api_key=api_key) if api_key else None + except Exception: + client = None diff --git a/settings/user_config.py b/settings/user_config.py new file mode 100644 index 0000000..b304563 --- /dev/null +++ b/settings/user_config.py @@ -0,0 +1,202 @@ +""" +User-specific configuration storage for CodebookAI. + +This module handles storing user settings in platform-appropriate directories +instead of writing directly to the application's config.py file. It provides: +- Platform-specific user config directory detection +- JSON-based settings storage in user-writable locations +- Configuration merging between defaults and user overrides +- Safe fallbacks for bundled environments (PyInstaller, etc.) + +User settings are stored in: +- Windows: %APPDATA%/CodebookAI/config.json +- macOS: ~/Library/Application Support/CodebookAI/config.json +- Linux: ~/.config/CodebookAI/config.json +""" + +from __future__ import annotations + +import json +import platform +from pathlib import Path +from typing import Any, Dict, Optional + +from settings import config # Import for defaults + + +def get_user_config_dir() -> Path: + """ + Get the platform-appropriate user configuration directory. + + Returns: + Path to the user configuration directory for CodebookAI + """ + system = platform.system() + + if system == "Windows": + # Use %APPDATA%/CodebookAI + appdata = Path.home() / "AppData" / "Roaming" + # Fallback to environment variable if available + import os + if "APPDATA" in os.environ: + appdata = Path(os.environ["APPDATA"]) + return appdata / "CodebookAI" + + elif system == "Darwin": # macOS + # Use ~/Library/Application Support/CodebookAI + return Path.home() / "Library" / "Application Support" / "CodebookAI" + + else: # Linux and other Unix-like systems + # Use ~/.config/CodebookAI (XDG Base Directory Specification) + xdg_config = Path.home() / ".config" + import os + if "XDG_CONFIG_HOME" in os.environ: + xdg_config = Path(os.environ["XDG_CONFIG_HOME"]) + return xdg_config / "CodebookAI" + + +def get_user_config_file() -> Path: + """ + Get the path to the user configuration JSON file. + + Returns: + Path to config.json in the user config directory + """ + return get_user_config_dir() / "config.json" + + +def ensure_user_config_dir() -> Path: + """ + Ensure the user configuration directory exists. + + Returns: + Path to the user configuration directory + + Raises: + OSError: If directory cannot be created + """ + config_dir = get_user_config_dir() + config_dir.mkdir(parents=True, exist_ok=True) + return config_dir + + +def load_user_settings() -> Dict[str, Any]: + """ + Load user settings from the JSON config file. + + Returns: + Dictionary of user settings, empty dict if file doesn't exist or is invalid + """ + try: + config_file = get_user_config_file() + if config_file.exists(): + content = config_file.read_text(encoding="utf-8") + return json.loads(content) + except (OSError, json.JSONDecodeError, UnicodeDecodeError): + # Return empty dict on any error - we'll use defaults + pass + + return {} + + +def save_user_settings(settings: Dict[str, Any]) -> None: + """ + Save user settings to the JSON config file. + + Args: + settings: Dictionary of settings to save + + Raises: + OSError: If settings cannot be saved + """ + ensure_user_config_dir() + config_file = get_user_config_file() + + # Write atomically using a temporary file + temp_file = config_file.with_suffix(".json.tmp") + content = json.dumps(settings, indent=2, ensure_ascii=False) + temp_file.write_text(content, encoding="utf-8") + + # Atomic replace + temp_file.replace(config_file) + + +def get_setting(key: str, default: Any = None) -> Any: + """ + Get a setting value, preferring user settings over defaults. + + Args: + key: Setting key name + default: Default value if not found anywhere + + Returns: + Setting value from user config, or from defaults, or provided default + """ + # First try user settings + user_settings = load_user_settings() + if key in user_settings: + return user_settings[key] + + # Then try default config.py + if hasattr(config, key): + return getattr(config, key) + + # Finally use provided default + return default + + +def set_setting(key: str, value: Any) -> None: + """ + Set a user setting value. + + Args: + key: Setting key name + value: Setting value to save + + Raises: + OSError: If settings cannot be saved + """ + user_settings = load_user_settings() + user_settings[key] = value + save_user_settings(user_settings) + + +def get_merged_config() -> Dict[str, Any]: + """ + Get a merged configuration combining defaults and user overrides. + + Returns: + Dictionary with all configuration values (defaults + user overrides) + """ + # Start with defaults from config.py + merged = {} + + # Add all attributes from config module (excluding private/special ones) + for attr_name in dir(config): + if not attr_name.startswith('_'): + merged[attr_name] = getattr(config, attr_name) + + # Overlay user settings + user_settings = load_user_settings() + merged.update(user_settings) + + return merged + + +class ConfigProxy: + """ + A proxy object that provides attribute access to merged configuration. + This allows existing code to continue using config.attribute_name syntax. + """ + + def __getattr__(self, name: str) -> Any: + """Get attribute from merged configuration.""" + return get_setting(name) + + def __setattr__(self, name: str, value: Any) -> None: + """Set attribute in user configuration (not allowed in this implementation).""" + raise AttributeError(f"Direct assignment to config.{name} is not allowed. Use set_setting() instead.") + + +# Create a proxy instance that can be used to replace the config module +merged_config = ConfigProxy() \ No newline at end of file diff --git a/ui/settings_window.py b/ui/settings_window.py index fc02a12..3e3a6bf 100644 --- a/ui/settings_window.py +++ b/ui/settings_window.py @@ -13,16 +13,14 @@ from __future__ import annotations -import importlib -import io -import os from pathlib import Path import tkinter as tk from tkinter import ttk, messagebox from zoneinfo import available_timezones from settings import config -from settings.models_registry import get_models, refresh_models +from settings.user_config import get_setting, set_setting, get_merged_config +from settings.models_registry import get_models, refresh_models, refresh_client from settings.secrets_store import save_api_key, load_api_key, clear_api_key @@ -86,9 +84,9 @@ def __init__(self, parent: tk.Tk | tk.Toplevel): # --- State variables --- self.var_api_key = tk.StringVar(value=load_api_key() or "") - self.var_model = tk.StringVar(value=getattr(config, "model", "gpt-4o")) - self.var_max_batches = tk.StringVar(value=str(getattr(config, "max_batches", 20))) - self.var_timezone = tk.StringVar(value=getattr(config, "time_zone", "UTC")) + self.var_model = tk.StringVar(value=get_setting("model", "gpt-4o")) + self.var_max_batches = tk.StringVar(value=str(get_setting("max_batches", 20))) + self.var_timezone = tk.StringVar(value=get_setting("time_zone", "UTC")) self.var_show_key = tk.BooleanVar(value=False) # --- Layout root frame --- @@ -203,19 +201,19 @@ def _toggle_api_visibility(self): self.ent_api.config(show="" if self.var_show_key.get() else "•") def _reset_from_file(self): - """Reload non-secret settings from disk and reset UI fields. API key reloads from keyring.""" + """Reload settings from defaults and user config, reset UI fields. API key reloads from keyring.""" try: - importlib.reload(config) self.var_api_key.set(load_api_key() or "") - self.var_model.set(getattr(config, "model", "gpt-4o")) - self.var_max_batches.set(str(getattr(config, "max_batches", 20))) - self.var_timezone.set(getattr(config, "time_zone", "UTC")) + self.var_model.set(get_setting("model", "gpt-4o")) + self.var_max_batches.set(str(get_setting("max_batches", 20))) + self.var_timezone.set(get_setting("time_zone", "UTC")) except Exception as e: messagebox.showerror("Reset Error", str(e)) def _clear_key(self): try: clear_api_key() + refresh_client() # Refresh the models registry client self.var_api_key.set("") messagebox.showinfo("Settings", "API key cleared from secure storage.") except Exception as e: @@ -247,51 +245,18 @@ def _save(self): # 1) Save secret to secure store if api_key: save_api_key(api_key) + # Refresh the models registry client with the new API key + refresh_client() - # 2) Persist non-secrets to config.py - self._write_config_file(model, max_batches, time_zone) - - # 3) Reload config for the running process - importlib.reload(config) + # 2) Save non-secrets to user config (JSON file in user directory) + set_setting("model", model) + set_setting("max_batches", max_batches) + set_setting("time_zone", time_zone) self.destroy() except Exception as e: messagebox.showerror("Save Error", f"Could not save settings:\n{e}") - def _write_config_file(self, model: str, max_batches: int, time_zone: str) -> None: - """ - Serialize non-secret settings and overwrite config.py with a .bak backup. - Never writes the API key. - """ - cfg_path = Path(getattr(config, "__file__", "config.py")).resolve() - if cfg_path.suffix == ".pyc": - cfg_path = cfg_path.with_suffix(".py") - if not cfg_path.exists(): - raise FileNotFoundError(f"config.py not found at {cfg_path}") - - # Backup - bak_path = cfg_path.with_suffix(".py.bak") - try: - if bak_path.exists(): - bak_path.unlink() - cfg_path.replace(bak_path) - except Exception as e: - messagebox.showwarning("Backup Warning", f"Could not create backup: {e}") - - # Prepare new file contents (explicit + no secrets) - buf = io.StringIO() - buf.write("# Non-secret runtime settings\n") - buf.write(f"model = {repr(model)}\n") - buf.write(f"max_batches = {int(max_batches)}\n\n") - buf.write(f"time_zone = {repr(time_zone)}\n") - - text = buf.getvalue() - - # Write atomically - tmp_path = cfg_path.with_suffix(".py.tmp") - tmp_path.write_text(text, encoding="utf-8") - os.replace(tmp_path, cfg_path) - # ---- UI callbacks ---- def _on_refresh_models(self): """ diff --git a/wiki/File/Settings.md b/wiki/File/Settings.md index 24fc9e0..df6b530 100644 --- a/wiki/File/Settings.md +++ b/wiki/File/Settings.md @@ -11,6 +11,22 @@ --- +## 💾 Settings Storage + +### User Settings Location +User-configurable settings are stored in platform-appropriate directories: +- **Windows**: `%APPDATA%\CodebookAI\config.json` +- **macOS**: `~/Library/Application Support/CodebookAI/config.json` +- **Linux**: `~/.config/CodebookAI/config.json` + +### Configuration System +- **Application Defaults**: Read from `settings/config.py` (read-only) +- **User Overrides**: Stored in user-specific JSON configuration file +- **Merged Configuration**: User settings override defaults at runtime +- **Bundled Application Safe**: Works correctly in PyInstaller and other bundled environments + +--- + ## 🔑 API Key Configuration ### Setting Your API Key