Skip to content

Commit 1fb56e0

Browse files
committed
Refactor config loading and env handling
- Extract environment helpers (_get_required_env, _get_optional_env, _get_choice_env) and path/config helpers (_load_env_from_locations, _try_load_dotenv_from_path, _get_validated_path, _get_azure_config, _get_ollama_config). - Standardize .env lookup (priority: CWD/.env, XDG config dir, fallback to load_dotenv). - Centralize loading and validation of DIARY_PATH and PLANNER_PATH with clearer error messages and optional validation toggle for tests. - Consolidate LLM provider handling and provider-specific config population (azure vs ollama), and normalize defaults. - Use helper for BRAIN_LOG_LEVEL retrieval.
1 parent ea401a7 commit 1fb56e0

File tree

1 file changed

+218
-84
lines changed

1 file changed

+218
-84
lines changed

brain_core/config.py

Lines changed: 218 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,199 @@
11
"""Configuration management for diary system."""
22

33
import os
4+
from collections.abc import Callable
45
from functools import lru_cache
56
from pathlib import Path
67

78
from dotenv import load_dotenv
89

910

11+
def _get_required_env(key: str, error_context: str = "") -> str:
12+
"""Get required environment variable or raise clear error.
13+
14+
Args:
15+
key: Environment variable name
16+
error_context: Additional context for error message
17+
18+
Returns:
19+
Value of environment variable
20+
21+
Raises:
22+
ValueError: If environment variable is not set
23+
"""
24+
value = os.getenv(key)
25+
if not value:
26+
error_msg = f"{key} must be set in .env"
27+
if error_context:
28+
error_msg += f" {error_context}"
29+
raise ValueError(error_msg)
30+
return value
31+
32+
33+
def _get_optional_env(key: str, default: str) -> str:
34+
"""Get optional environment variable with default.
35+
36+
Args:
37+
key: Environment variable name
38+
default: Default value if not set
39+
40+
Returns:
41+
Value of environment variable or default
42+
"""
43+
return os.getenv(key, default)
44+
45+
46+
def _get_choice_env(
47+
key: str,
48+
allowed_choices: list[str],
49+
default: str,
50+
transform: Callable[[str], str] = str.lower,
51+
) -> str:
52+
"""Get environment variable and validate against allowed choices.
53+
54+
Args:
55+
key: Environment variable name
56+
allowed_choices: List of valid values
57+
default: Default value if not set
58+
transform: Optional transformation function (e.g., str.lower)
59+
60+
Returns:
61+
Validated and transformed value
62+
63+
Raises:
64+
ValueError: If value is not in allowed choices
65+
"""
66+
value = transform(os.getenv(key, default))
67+
if value not in allowed_choices:
68+
choices_str = "', '".join(allowed_choices)
69+
raise ValueError(f"{key} must be one of ['{choices_str}'], got: {value}")
70+
return value
71+
72+
73+
def _load_env_from_locations(env_file: Path | None = None) -> bool:
74+
"""Load environment variables from .env file in standard locations.
75+
76+
Args:
77+
env_file: Optional explicit path to .env file
78+
79+
Returns:
80+
True if .env file was found and loaded, False otherwise
81+
"""
82+
if env_file:
83+
load_dotenv(env_file)
84+
return True
85+
86+
# Search for .env in standard locations (in priority order)
87+
env_locations = [
88+
Path.cwd() / ".env", # Current directory (highest priority)
89+
Path.home() / ".config" / "brain" / ".env", # XDG config dir
90+
]
91+
92+
for env_path in env_locations:
93+
if _try_load_dotenv_from_path(env_path):
94+
return True
95+
96+
# Try default load_dotenv() which searches up the directory tree
97+
load_dotenv()
98+
return False # Unknown if found, but we tried
99+
100+
101+
def _try_load_dotenv_from_path(env_path: Path) -> bool:
102+
"""Attempt to load .env from a specific path.
103+
104+
Args:
105+
env_path: Path to .env file
106+
107+
Returns:
108+
True if file was loaded successfully, False otherwise
109+
"""
110+
try:
111+
resolved_path = env_path.resolve(strict=False)
112+
# Security: Only load if file is readable and is a regular file
113+
if resolved_path.exists() and resolved_path.is_file():
114+
load_dotenv(resolved_path)
115+
return True
116+
except (OSError, RuntimeError):
117+
# Skip paths that can't be resolved (permission issues, etc.)
118+
pass
119+
return False
120+
121+
122+
def _get_validated_path(key: str, validate: bool = True) -> Path:
123+
"""Get path from environment and optionally validate it exists.
124+
125+
Args:
126+
key: Environment variable name
127+
validate: If True, validate that path exists
128+
129+
Returns:
130+
Path object
131+
132+
Raises:
133+
ValueError: If path is not set or doesn't exist (when validate=True)
134+
"""
135+
path_str = os.getenv(key)
136+
if not path_str:
137+
if key == "DIARY_PATH":
138+
raise ValueError(
139+
"DIARY_PATH must be set in .env file.\n"
140+
"Create .env file in one of these locations:\n"
141+
f" - {Path.home() / '.config' / 'brain' / '.env'} (recommended)\n"
142+
f" - {Path.home() / '.brain' / '.env'}\n"
143+
f" - {Path.cwd() / '.env'}\n"
144+
"See SETUP_CHECKLIST.md for configuration guide."
145+
)
146+
raise ValueError(f"{key} must be set in .env")
147+
148+
path = Path(path_str)
149+
if validate and not path.exists():
150+
raise ValueError(f"{key} does not exist: {path}")
151+
return path
152+
153+
154+
def _get_azure_config() -> dict[str, str]:
155+
"""Get Azure OpenAI configuration from environment.
156+
157+
Returns:
158+
Dictionary with Azure config keys
159+
160+
Raises:
161+
ValueError: If required Azure credentials are missing
162+
"""
163+
api_key = _get_required_env(
164+
"AZURE_OPENAI_API_KEY",
165+
"when LLM_PROVIDER=azure",
166+
)
167+
endpoint = _get_required_env(
168+
"AZURE_OPENAI_ENDPOINT",
169+
"when LLM_PROVIDER=azure",
170+
)
171+
deployment = _get_optional_env("AZURE_OPENAI_DEPLOYMENT", "gpt-4o")
172+
api_version = _get_optional_env("AZURE_OPENAI_API_VERSION", "2024-02-15-preview")
173+
174+
return {
175+
"api_key": api_key,
176+
"endpoint": endpoint,
177+
"deployment": deployment,
178+
"api_version": api_version,
179+
}
180+
181+
182+
def _get_ollama_config() -> dict[str, str]:
183+
"""Get Ollama configuration from environment.
184+
185+
Returns:
186+
Dictionary with Ollama config keys
187+
"""
188+
base_url = _get_optional_env("OLLAMA_BASE_URL", "http://localhost:11434")
189+
model = _get_optional_env("OLLAMA_MODEL", "llama3.1")
190+
191+
return {
192+
"base_url": base_url,
193+
"model": model,
194+
}
195+
196+
10197
class Config:
11198
"""Configuration loaded from .env file.
12199
@@ -38,98 +225,45 @@ def __init__(self, env_file: Path | None = None, validate_paths: bool = True):
38225
env_file: Optional path to .env file. If None, searches standard locations.
39226
validate_paths: If True, validates that paths exist. Set to False for testing.
40227
"""
41-
if env_file:
42-
load_dotenv(env_file)
43-
else:
44-
# Search for .env in standard locations (in priority order)
45-
env_locations = [
46-
Path.cwd() / ".env", # Current directory (highest priority)
47-
Path.home() / ".config" / "brain" / ".env", # XDG config dir
48-
]
49-
50-
env_found = False
51-
for env_path in env_locations:
52-
# Resolve symlinks and check if file exists
53-
try:
54-
resolved_path = env_path.resolve(strict=False)
55-
# Security: Only load if file is readable and is a regular file
56-
if resolved_path.exists() and resolved_path.is_file():
57-
load_dotenv(resolved_path)
58-
env_found = True
59-
break
60-
except (OSError, RuntimeError):
61-
# Skip paths that can't be resolved (permission issues, etc.)
62-
continue
63-
64-
if not env_found:
65-
# Try default load_dotenv() which searches up the directory tree
66-
load_dotenv()
67-
68-
# Required paths
69-
diary_path = os.getenv("DIARY_PATH")
70-
if not diary_path:
71-
raise ValueError(
72-
"DIARY_PATH must be set in .env file.\n"
73-
"Create .env file in one of these locations:\n"
74-
f" - {Path.home() / '.config' / 'brain' / '.env'} (recommended)\n"
75-
f" - {Path.home() / '.brain' / '.env'}\n"
76-
f" - {Path.cwd() / '.env'}\n"
77-
"See SETUP_CHECKLIST.md for configuration guide."
78-
)
79-
80-
planner_path = os.getenv("PLANNER_PATH")
81-
if not planner_path:
82-
raise ValueError("PLANNER_PATH must be set in .env")
83-
84-
self.diary_path = Path(diary_path)
85-
self.planner_path = Path(planner_path)
86-
87-
# Validate paths exist (can be disabled for testing)
88-
if validate_paths:
89-
if not self.diary_path.exists():
90-
raise ValueError(f"DIARY_PATH does not exist: {self.diary_path}")
91-
if not self.planner_path.exists():
92-
raise ValueError(f"PLANNER_PATH does not exist: {self.planner_path}")
93-
94-
# LLM provider configuration
95-
self.llm_provider = os.getenv("LLM_PROVIDER", "azure").lower()
96-
if self.llm_provider not in ["azure", "ollama"]:
97-
raise ValueError(f"LLM_PROVIDER must be 'azure' or 'ollama', got: {self.llm_provider}")
228+
# Load environment variables from .env file
229+
_load_env_from_locations(env_file)
230+
231+
# Load required paths
232+
self.diary_path = _get_validated_path("DIARY_PATH", validate=validate_paths)
233+
self.planner_path = _get_validated_path("PLANNER_PATH", validate=validate_paths)
234+
235+
# Load LLM provider configuration
236+
self.llm_provider = _get_choice_env(
237+
"LLM_PROVIDER",
238+
allowed_choices=["azure", "ollama"],
239+
default="azure",
240+
transform=str.lower,
241+
)
98242

99-
# Azure OpenAI configuration (required if using Azure)
243+
# Load provider-specific configuration
100244
if self.llm_provider == "azure":
101-
self.azure_api_key = os.getenv("AZURE_OPENAI_API_KEY")
102-
if not self.azure_api_key:
103-
raise ValueError("AZURE_OPENAI_API_KEY must be set in .env when LLM_PROVIDER=azure")
104-
105-
self.azure_endpoint = os.getenv("AZURE_OPENAI_ENDPOINT")
106-
if not self.azure_endpoint:
107-
raise ValueError(
108-
"AZURE_OPENAI_ENDPOINT must be set in .env when LLM_PROVIDER=azure"
109-
)
110-
111-
self.azure_deployment = os.getenv("AZURE_OPENAI_DEPLOYMENT", "gpt-4o")
112-
self.azure_api_version = os.getenv("AZURE_OPENAI_API_VERSION", "2024-02-15-preview")
245+
azure_config = _get_azure_config()
246+
self.azure_api_key = azure_config["api_key"]
247+
self.azure_endpoint = azure_config["endpoint"]
248+
self.azure_deployment = azure_config["deployment"]
249+
self.azure_api_version = azure_config["api_version"]
250+
# Set Ollama to None when using Azure
251+
self.ollama_base_url = None
252+
self.ollama_model = None
113253
else:
114-
# Set defaults for Azure when using Ollama (for optional Azure features)
254+
# Using Ollama
255+
ollama_config = _get_ollama_config()
256+
self.ollama_base_url = ollama_config["base_url"]
257+
self.ollama_model = ollama_config["model"]
258+
# Set Azure to None when using Ollama
115259
self.azure_api_key = None
116260
self.azure_endpoint = None
117261
self.azure_deployment = None
118-
self.azure_api_version = None
262+
self.azure_api_version = "2025-01-01-preview" # Keep default for consistency
119263

120-
# Ollama configuration (required if using Ollama)
121-
if self.llm_provider == "ollama":
122-
self.ollama_base_url = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
123-
self.ollama_model = os.getenv("OLLAMA_MODEL", "llama3.1")
124-
else:
125-
self.ollama_base_url = None
126-
self.ollama_model = None
127-
128-
# Cost tracking configuration (optional)
264+
# Load optional configuration
129265
self.cost_db_path = os.getenv("BRAIN_COST_DB_PATH") # Default handled in CostTracker
130-
131-
# Logging configuration (optional)
132-
self.log_level = os.getenv("BRAIN_LOG_LEVEL", "INFO")
266+
self.log_level = _get_optional_env("BRAIN_LOG_LEVEL", "INFO")
133267
self.log_file = os.getenv("BRAIN_LOG_FILE") # Optional file logging
134268

135269

0 commit comments

Comments
 (0)