Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions batch_processing/batch_method.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand All @@ -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 = (
Expand Down
22 changes: 21 additions & 1 deletion settings/models_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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:
Expand Down Expand Up @@ -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
202 changes: 202 additions & 0 deletions settings/user_config.py
Original file line number Diff line number Diff line change
@@ -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()
Loading