Coverage for src / local_deep_research / api / settings_utils.py: 76%
139 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-01-11 00:51 +0000
« prev ^ index » next coverage.py v7.12.0, created at 2026-01-11 00:51 +0000
1"""
2Utilities for managing settings in the programmatic API.
4This module provides functions to create settings snapshots for the API
5without requiring database access, reusing the same mechanisms as the
6web interface.
7"""
9import os
10import copy
11from typing import Any, Dict, Optional, Union
12from loguru import logger
14from ..settings import SettingsManager
15from ..settings.base import ISettingsManager
18class InMemorySettingsManager(ISettingsManager):
19 """
20 In-memory settings manager that doesn't require database access.
22 This is used for the programmatic API to provide settings without
23 needing a database connection.
24 """
26 # Type mapping from UI elements to Python types (same as SettingsManager)
27 _UI_ELEMENT_TO_SETTING_TYPE = {
28 "text": str,
29 # JSON should already be parsed
30 "json": lambda x: x,
31 "password": str,
32 "select": str,
33 "number": float,
34 "range": float,
35 "checkbox": bool,
36 }
38 def __init__(self):
39 """Initialize with default settings from JSON file."""
40 # Create a base manager to get default settings
41 self._base_manager = SettingsManager(db_session=None)
42 self._settings = {}
43 self._load_defaults()
45 def _get_typed_value(self, setting_data: Dict[str, Any], value: Any) -> Any:
46 """
47 Convert a value to the appropriate type based on the setting's ui_element.
49 Args:
50 setting_data: The setting metadata containing ui_element
51 value: The value to convert
53 Returns:
54 The typed value, or the original value if conversion fails
55 """
56 ui_element = setting_data.get("ui_element", "text")
57 setting_type = self._UI_ELEMENT_TO_SETTING_TYPE.get(ui_element)
59 if setting_type is None: 59 ↛ 60line 59 didn't jump to line 60 because the condition on line 59 was never true
60 logger.warning(
61 f"Unknown ui_element type: {ui_element}, returning value as-is"
62 )
63 return value
65 try:
66 # Special handling for checkbox/bool with string values
67 if ui_element == "checkbox" and isinstance(value, str):
68 return value.lower() in ("true", "1", "yes", "on")
69 return setting_type(value)
70 except (ValueError, TypeError) as e:
71 logger.warning(
72 f"Failed to convert value {value} to type {setting_type}: {e}"
73 )
74 return value
76 def _load_defaults(self):
77 """Load default settings from the JSON file."""
78 # Get default settings from the base manager
79 defaults = self._base_manager.default_settings
81 # Convert to the format expected by get_all_settings
82 for key, setting_data in defaults.items():
83 self._settings[key] = setting_data.copy()
85 # Check environment variable override
86 env_key = f"LDR_{key.upper().replace('.', '_')}"
87 env_value = os.environ.get(env_key)
88 if env_value is not None:
89 # Use the typed value conversion
90 self._settings[key]["value"] = self._get_typed_value(
91 setting_data, env_value
92 )
94 # Load search engine configurations from individual JSON files
95 from importlib import resources
96 import json
98 try:
99 # Load search engines from defaults/settings/search_engines/
100 search_engines_dir = resources.files(
101 "local_deep_research.defaults.settings"
102 ).joinpath("search_engines")
104 if search_engines_dir.exists() and search_engines_dir.is_dir(): 104 ↛ exitline 104 didn't return from function '_load_defaults' because the condition on line 104 was always true
105 for json_file in search_engines_dir.glob("*.json"):
106 try:
107 engine_settings = json.loads(json_file.read_text())
108 # Merge into main settings
109 for key, setting_data in engine_settings.items():
110 if key not in self._settings: 110 ↛ 109line 110 didn't jump to line 109 because the condition on line 110 was always true
111 self._settings[key] = setting_data.copy()
112 except Exception as e:
113 logger.warning(
114 f"Failed to load search engine config from {json_file.name}: {e}"
115 )
116 except Exception as e:
117 logger.warning(f"Failed to load search engine configs: {e}")
119 def get_setting(
120 self, key: str, default: Any = None, check_env: bool = True
121 ) -> Any:
122 """Get a setting value."""
123 if key in self._settings:
124 setting_data = self._settings[key]
125 value = setting_data.get("value", default)
126 # Ensure the value has the correct type
127 return self._get_typed_value(setting_data, value)
128 return default
130 def set_setting(self, key: str, value: Any, commit: bool = True) -> bool:
131 """Set a setting value (in memory only)."""
132 if key in self._settings:
133 # Validate and convert the value to the correct type
134 typed_value = self._get_typed_value(self._settings[key], value)
135 self._settings[key]["value"] = typed_value
136 return True
137 return False
139 def get_all_settings(self) -> Dict[str, Any]:
140 """Get all settings with metadata."""
141 return copy.deepcopy(self._settings)
143 def load_from_defaults_file(
144 self, commit: bool = True, **kwargs: Any
145 ) -> None:
146 """Reload defaults (already done in __init__)."""
147 self._load_defaults()
149 def create_or_update_setting(
150 self, setting: Union[Dict[str, Any], Any], commit: bool = True
151 ) -> Optional[Any]:
152 """Create or update a setting (in memory only)."""
153 if isinstance(setting, dict) and "key" in setting:
154 key = setting["key"]
155 # If the setting has a value, ensure it has the correct type
156 if "value" in setting:
157 typed_value = self._get_typed_value(setting, setting["value"])
158 setting = setting.copy() # Don't modify the original
159 setting["value"] = typed_value
160 self._settings[key] = setting
161 return setting
162 return None
164 def delete_setting(self, key: str, commit: bool = True) -> bool:
165 """Delete a setting (in memory only)."""
166 if key in self._settings: 166 ↛ 167line 166 didn't jump to line 167 because the condition on line 166 was never true
167 del self._settings[key]
168 return True
169 return False
171 def import_settings(
172 self,
173 settings_data: Dict[str, Any],
174 commit: bool = True,
175 overwrite: bool = True,
176 delete_extra: bool = False,
177 ) -> None:
178 """Import settings from a dictionary."""
179 if delete_extra:
180 self._settings.clear()
182 for key, value in settings_data.items():
183 if overwrite or key not in self._settings:
184 # Ensure proper type handling for imported settings
185 if isinstance(value, dict) and "value" in value:
186 typed_value = self._get_typed_value(value, value["value"])
187 value = value.copy()
188 value["value"] = typed_value
189 self._settings[key] = value
192def get_default_settings_snapshot() -> Dict[str, Any]:
193 """
194 Get a complete settings snapshot with default values.
196 This uses the same mechanism as the web interface but without
197 requiring database access. Environment variables are checked
198 for overrides.
200 Returns:
201 Dict mapping setting keys to their values and metadata
202 """
203 manager = InMemorySettingsManager()
204 return manager.get_all_settings()
207def create_settings_snapshot(
208 overrides: Optional[Dict[str, Any]] = None,
209 base_settings: Optional[Dict[str, Any]] = None,
210 **kwargs,
211) -> Dict[str, Any]:
212 """
213 Create a settings snapshot for the programmatic API.
215 Args:
216 overrides: Dict of setting overrides (e.g., {"llm.provider": "openai"})
217 This is the most common use case - pass a dict of settings to override.
218 base_settings: Base settings dict (defaults to get_default_settings_snapshot())
219 Rarely needed - only for advanced use cases.
220 **kwargs: Common setting shortcuts:
221 - provider: Maps to "llm.provider"
222 - api_key: Maps to "llm.{provider}.api_key"
223 - temperature: Maps to "llm.temperature"
224 - max_search_results: Maps to "search.max_results"
225 - search_engines: Maps to enabled search engines
227 Returns:
228 Complete settings snapshot for use with the API
230 Examples:
231 # Most common - pass overrides as first argument
232 settings = create_settings_snapshot({"search.tool": "wikipedia"})
234 # Or use named parameter
235 settings = create_settings_snapshot(overrides={"llm.provider": "openai"})
237 # Use kwargs shortcuts
238 settings = create_settings_snapshot(provider="openai", temperature=0.7)
240 # Advanced - provide custom base settings
241 settings = create_settings_snapshot(
242 overrides={"search.tool": "wikipedia"},
243 base_settings=my_custom_defaults
244 )
245 """
246 # Start with base settings or defaults
247 if base_settings is None:
248 settings = get_default_settings_snapshot()
249 else:
250 settings = copy.deepcopy(base_settings)
252 # Apply overrides if provided
253 if overrides:
254 for key, value in overrides.items():
255 if key in settings:
256 if isinstance(settings[key], dict) and "value" in settings[key]: 256 ↛ 259line 256 didn't jump to line 259 because the condition on line 256 was always true
257 settings[key]["value"] = value
258 else:
259 settings[key] = value
260 else:
261 # Create a simple setting entry for unknown keys
262 # Infer ui_element from value type
263 ui_element = "text" # default
264 if isinstance(value, bool):
265 ui_element = "checkbox"
266 elif isinstance(value, (int, float)):
267 ui_element = "number"
268 elif isinstance(value, dict):
269 ui_element = "json"
271 settings[key] = {"value": value, "ui_element": ui_element}
273 # Handle common kwargs shortcuts
274 if "provider" in kwargs:
275 provider = kwargs["provider"]
276 if "llm.provider" in settings: 276 ↛ 279line 276 didn't jump to line 279 because the condition on line 276 was always true
277 settings["llm.provider"]["value"] = provider
278 else:
279 settings["llm.provider"] = {"value": provider}
281 # Handle api_key if provided
282 if "api_key" in kwargs:
283 api_key = kwargs["api_key"]
284 api_key_setting = f"llm.{provider}.api_key"
285 if api_key_setting in settings:
286 settings[api_key_setting]["value"] = api_key
287 else:
288 settings[api_key_setting] = {"value": api_key}
290 if "temperature" in kwargs:
291 if "llm.temperature" in settings: 291 ↛ 294line 291 didn't jump to line 294 because the condition on line 291 was always true
292 settings["llm.temperature"]["value"] = kwargs["temperature"]
293 else:
294 settings["llm.temperature"] = {"value": kwargs["temperature"]}
296 if "max_search_results" in kwargs:
297 if "search.max_results" in settings: 297 ↛ 302line 297 didn't jump to line 302 because the condition on line 297 was always true
298 settings["search.max_results"]["value"] = kwargs[
299 "max_search_results"
300 ]
301 else:
302 settings["search.max_results"] = {
303 "value": kwargs["max_search_results"]
304 }
306 # Add any other common shortcuts here...
308 return settings
311def extract_setting_value(
312 settings_snapshot: Dict[str, Any], key: str, default: Any = None
313) -> Any:
314 """
315 Extract a setting value from a settings snapshot.
317 Args:
318 settings_snapshot: Settings snapshot dict
319 key: Setting key (e.g., "llm.provider")
320 default: Default value if not found
322 Returns:
323 The setting value
324 """
325 if settings_snapshot is None:
326 return default
327 if key in settings_snapshot:
328 setting = settings_snapshot[key]
329 if isinstance(setting, dict) and "value" in setting:
330 return setting["value"]
331 return setting
332 return default