Coverage for src/local_deep_research/api/settings_utils.py: 95%

189 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1""" 

2Utilities for managing settings in the programmatic API. 

3 

4This module provides functions to create settings snapshots for the API 

5without requiring database access, reusing the same mechanisms as the 

6web interface. 

7""" 

8 

9import copy 

10from typing import Any 

11from loguru import logger 

12 

13from ..settings import SettingsManager 

14from ..settings.base import ISettingsManager 

15from ..settings.env_registry import registry as env_registry 

16from ..settings.exceptions import SettingsValueError 

17from ..settings.manager import ( 

18 UI_ELEMENT_TO_SETTING_TYPE as UI_ELEMENT_TO_SETTING_TYPE, 

19 validate_imported_setting_value, 

20 check_env_setting, 

21 get_typed_setting_value, 

22 is_operator_only_setting, 

23 is_valid_setting_key, 

24) 

25from ..utilities.type_utils import to_bool, unwrap_setting 

26 

27 

28class InMemorySettingsManager(ISettingsManager): 

29 """ 

30 In-memory settings manager that doesn't require database access. 

31 

32 This is used for the programmatic API to provide settings without 

33 needing a database connection. 

34 """ 

35 

36 def __init__(self): 

37 """Initialize with default settings from JSON file.""" 

38 # Create a base manager to get default settings 

39 self._base_manager = SettingsManager(db_session=None) 

40 self._settings = {} 

41 self._load_defaults() 

42 

43 def _get_typed_value( 

44 self, 

45 setting_data: dict[str, Any], 

46 value: Any, 

47 *, 

48 key: str | None = None, 

49 ) -> Any: 

50 """Validate a stored value without applying environment overrides.""" 

51 if not isinstance(setting_data, dict): 51 ↛ 52line 51 didn't jump to line 52 because the condition on line 51 was never true

52 raise SettingsValueError( 

53 key or "<in-memory>", 

54 source="value", 

55 reason="invalid setting metadata", 

56 ) 

57 return get_typed_setting_value( 

58 key if key is not None else setting_data.get("key", "<in-memory>"), 

59 value, 

60 setting_data.get("ui_element", "text"), 

61 check_env=False, 

62 ) 

63 

64 def _load_defaults(self): 

65 """Load default settings from the JSON file.""" 

66 # Get default settings from the base manager 

67 defaults = self._base_manager.default_settings 

68 

69 # Convert to the format expected by get_all_settings 

70 for key, setting_data in defaults.items(): 

71 self._settings[key] = setting_data.copy() 

72 

73 # Load search engine configurations from individual JSON files 

74 from importlib import resources 

75 import json 

76 

77 try: 

78 # Load search engines from defaults/settings/search_engines/ 

79 search_engines_dir = resources.files( 

80 "local_deep_research.defaults.settings" 

81 ).joinpath("search_engines") 

82 

83 if search_engines_dir.exists() and search_engines_dir.is_dir(): 83 ↛ exitline 83 didn't return from function '_load_defaults' because the condition on line 83 was always true

84 for json_file in search_engines_dir.glob("*.json"): 

85 try: 

86 engine_settings = json.loads( 

87 json_file.read_text(encoding="utf-8-sig") 

88 ) 

89 # Merge into main settings 

90 for key, setting_data in engine_settings.items(): 

91 if key not in self._settings: 

92 self._settings[key] = setting_data.copy() 

93 except Exception: 

94 logger.warning( 

95 f"Failed to load search engine config from {json_file.name}" 

96 ) 

97 except Exception: 

98 logger.warning("Failed to load search engine configs") 

99 

100 def _read_setting_value( 

101 self, 

102 key: str, 

103 setting_data: dict[str, Any], 

104 default: Any = None, 

105 check_env: bool = True, 

106 ) -> Any: 

107 """Read the selected value or report invalid configuration.""" 

108 if not isinstance(setting_data, dict): 

109 raise SettingsValueError( 

110 key, source="value", reason="invalid setting metadata" 

111 ) 

112 ui_element = setting_data.get("ui_element", "text") 

113 return get_typed_setting_value( 

114 key, 

115 setting_data.get("value"), 

116 ui_element, 

117 default=default, 

118 check_env=check_env, 

119 ) 

120 

121 def get_setting( 

122 self, key: str, default: Any = None, check_env: bool = True 

123 ) -> Any: 

124 """Read a typed value, or a namespace when no exact entry exists. 

125 

126 An environment override for a key with no entry and no declared 

127 type is returned as the raw string, as ``SettingsManager`` does. 

128 """ 

129 if not isinstance(key, str) or not key: 

130 return default 

131 

132 if env_registry.is_env_only(key): 

133 return env_registry.get_strict(key, default, check_env=check_env) 

134 

135 if key in self._settings: 

136 setting_data = self._settings[key] 

137 return self._read_setting_value( 

138 key, setting_data, default=default, check_env=check_env 

139 ) 

140 

141 # Preserve exact-entry precedence and the DB reader's scalar-only 

142 # treatment of operator-owned paths. Malformed legacy children do 

143 # not turn a missing leaf into a namespace. 

144 if not is_operator_only_setting(key): 

145 children = { 

146 child.removeprefix(f"{key}."): self._read_setting_value( 

147 child, data, default=default, check_env=check_env 

148 ) 

149 for child, data in self._settings.items() 

150 if is_valid_setting_key(child) and child.startswith(f"{key}.") 

151 } 

152 if children: 

153 return children 

154 

155 if check_env: 

156 env_value = check_env_setting(key) 

157 if env_value is not None: 

158 metadata = self._base_manager.default_settings.get(key) 

159 if isinstance(metadata, dict): 

160 return get_typed_setting_value( 

161 key, 

162 None, 

163 metadata.get("ui_element", "text"), 

164 default=default, 

165 ) 

166 # Same as SettingsManager.get_setting: with no declared type 

167 # there is nothing to validate against, so the override is 

168 # returned unconverted. Typed keys still raise above. 

169 logger.warning( 

170 "Setting '{}' has env var override but is not in " 

171 "defaults — returning raw string without type " 

172 "conversion. Add this setting to a defaults JSON " 

173 "file with a ui_element type to enable proper " 

174 "type conversion.", 

175 key, 

176 ) 

177 return env_value 

178 return default 

179 

180 def set_setting(self, key: str, value: Any, commit: bool = True) -> bool: 

181 """Set a setting value (in memory only).""" 

182 if key in self._settings: 

183 # Validate and convert the value to the correct type 

184 typed_value = self._get_typed_value( 

185 self._settings[key], value, key=key 

186 ) 

187 self._settings[key]["value"] = typed_value 

188 return True 

189 return False 

190 

191 def get_all_settings( 

192 self, 

193 bypass_cache: bool = False, 

194 include_environment_overrides: bool = True, 

195 strict: bool = False, 

196 ) -> dict[str, Any]: 

197 """Validate effective settings, or return raw storage for repair. 

198 

199 Effective reads validate stored values without changing their saved 

200 representation. Only active environment overrides replace values. 

201 Raw exports skip validation and preserve the stored metadata. 

202 """ 

203 result = copy.deepcopy(self._settings) 

204 if include_environment_overrides: 

205 for key, setting_data in result.items(): 

206 value = self._read_setting_value(key, setting_data) 

207 if check_env_setting(key) is not None: 

208 setting_data["value"] = value 

209 setting_data["editable"] = False 

210 return result 

211 

212 def load_from_defaults_file( 

213 self, 

214 commit: bool = True, 

215 preserve_environment_locked: bool = False, 

216 ) -> None: 

217 """Reload defaults while honoring environment-lock preservation. 

218 

219 No ``**kwargs`` passthrough (see the ``ISettingsManager`` contract, 

220 #5841): the in-memory store has no settings lock, so the 

221 ``override_locked`` bypass is meaningless here — and now also 

222 structurally rejected. 

223 """ 

224 preserved_values = ( 

225 { 

226 key: copy.deepcopy(value["value"]) 

227 for key, value in self._settings.items() 

228 if isinstance(value, dict) 

229 and "value" in value 

230 and check_env_setting(key) is not None 

231 } 

232 if preserve_environment_locked 

233 else {} 

234 ) 

235 self._settings.clear() 

236 self._load_defaults() 

237 for key, value in preserved_values.items(): 

238 if key in self._settings: 

239 self._settings[key]["value"] = value 

240 

241 def create_or_update_setting( 

242 self, setting: dict[str, Any] | Any, commit: bool = True 

243 ) -> Any | None: 

244 """Create or update a setting (in memory only).""" 

245 if isinstance(setting, dict) and "key" in setting: 

246 key = setting["key"] 

247 # Validate metadata even when an optional value is absent. 

248 typed_value = self._get_typed_value(setting, setting.get("value")) 

249 if "value" in setting: 

250 setting = setting.copy() # Don't modify the original 

251 setting["value"] = typed_value 

252 self._settings[key] = setting 

253 return setting 

254 return None 

255 

256 def delete_setting( 

257 self, key: str, commit: bool = True, override_locked: bool = False 

258 ) -> bool: 

259 """Delete a setting (in memory only). 

260 

261 ``override_locked`` is accepted to satisfy the ``ISettingsManager`` 

262 contract (#5738) and otherwise ignored: the in-memory store has no 

263 settings lock, so there is nothing for it to override. 

264 """ 

265 if key in self._settings: 

266 del self._settings[key] 

267 return True 

268 return False 

269 

270 def get_bool_setting( 

271 self, key: str, default: bool = False, check_env: bool = True 

272 ) -> bool: 

273 """Get a setting value as a boolean.""" 

274 value = self.get_setting(key, default, check_env) 

275 return to_bool(value, default) 

276 

277 def get_settings_snapshot(self, strict: bool = False) -> dict[str, Any]: 

278 """Get a simplified settings snapshot with just key-value pairs.""" 

279 all_settings = self.get_all_settings(strict=strict) 

280 snapshot = {} 

281 for key, setting in all_settings.items(): 

282 if isinstance(setting, dict) and "value" in setting: 

283 snapshot[key] = setting["value"] 

284 else: 

285 snapshot[key] = setting 

286 return snapshot 

287 

288 def import_settings( 

289 self, 

290 settings_data: dict[str, Any], 

291 commit: bool = True, 

292 overwrite: bool = True, 

293 delete_extra: bool = False, 

294 preserve_environment_locked: bool = False, 

295 override_locked: bool = False, 

296 ) -> None: 

297 """Import settings from a dictionary. 

298 

299 Conversion failures leave the live settings unchanged. Values that 

300 fail schema validation retain the existing skip-and-preserve policy. 

301 

302 ``override_locked`` is accepted to satisfy the ``ISettingsManager`` 

303 contract (#5738) and otherwise ignored: the in-memory store has no 

304 settings lock, so there is nothing for it to override. 

305 """ 

306 # Schema-aware import (#5589) — validates untrusted file values 

307 # against the current schema; see validate_imported_setting_value's 

308 # docstring for the trusted-vs-untrusted rationale (values retained 

309 # from existing in-memory state are trusted here, not validated). 

310 defaults_for_import = self._base_manager.default_settings 

311 # Rows are replaced, not mutated, during import. Stage those 

312 # replacements so a later conversion error cannot expose a partial 

313 # import or leave live settings cleared by delete_extra. 

314 pending_settings = self._settings.copy() 

315 # Under `delete_extra=True`, entries cleared below must still be 

316 # restorable for keys whose imported value is rejected by 

317 # validation — mirroring the DB manager, where a rejected key keeps 

318 # its existing row (the key is retained before validation runs). 

319 prior_settings = ( 

320 {key: copy.deepcopy(value) for key, value in self._settings.items()} 

321 if delete_extra 

322 else None 

323 ) 

324 if delete_extra: 

325 preserved = ( 

326 { 

327 key: copy.deepcopy(value) 

328 for key, value in self._settings.items() 

329 if check_env_setting(key) is not None 

330 } 

331 if preserve_environment_locked 

332 else {} 

333 ) 

334 pending_settings = preserved 

335 

336 for key, value in settings_data.items(): 

337 existing_setting = pending_settings.get(key) 

338 preserve_value = ( 

339 preserve_environment_locked 

340 and check_env_setting(key) is not None 

341 and isinstance(existing_setting, dict) 

342 and "value" in existing_setting 

343 ) 

344 if preserve_value and not isinstance(value, dict): 

345 # A bare-scalar import carries no metadata to merge; keep 

346 # the stored env-locked entry untouched. 

347 continue 

348 if overwrite or key not in pending_settings or preserve_value: 

349 # Ensure proper type handling for imported settings 

350 setting_values = ( 

351 value.copy() if isinstance(value, dict) else value 

352 ) 

353 if ( 

354 isinstance(value, dict) 

355 and "value" in value 

356 and not preserve_value 

357 ): 

358 default_meta = defaults_for_import.get(key) 

359 if ( 

360 default_meta is not None 

361 and not preserve_value 

362 and validate_imported_setting_value( 

363 key, value["value"], default_meta 

364 ) 

365 is not None 

366 ): 

367 # The file-supplied value is invalid under the 

368 # current defaults schema (#5589); skip the entry 

369 # and keep any prior entry (mirrors the DB manager, 

370 # which leaves the existing row in place). 

371 logger.warning( 

372 "Skipping import of setting {!r}: value is " 

373 "invalid under the current defaults schema", 

374 key, 

375 ) 

376 if prior_settings is not None and key in prior_settings: 

377 pending_settings[key] = prior_settings[key] 

378 continue 

379 typed_value = self._get_typed_value( 

380 value, value["value"], key=key 

381 ) 

382 setting_values["value"] = typed_value 

383 if preserve_value: 

384 setting_values["value"] = copy.deepcopy( 

385 existing_setting["value"] 

386 ) 

387 pending_settings[key] = setting_values 

388 

389 self._settings = pending_settings 

390 

391 

392def get_default_settings_snapshot() -> dict[str, Any]: 

393 """ 

394 Get a complete settings snapshot with default values. 

395 

396 This uses the same mechanism as the web interface but without 

397 requiring database access. Environment variables are checked 

398 for overrides. 

399 

400 Returns: 

401 Dict mapping setting keys to their values and metadata 

402 """ 

403 manager = InMemorySettingsManager() 

404 return manager.get_all_settings() 

405 

406 

407def create_settings_snapshot( 

408 overrides: dict[str, Any] | None = None, 

409 base_settings: dict[str, Any] | None = None, 

410 **kwargs, 

411) -> dict[str, Any]: 

412 """ 

413 Create a settings snapshot for the programmatic API. 

414 

415 Args: 

416 overrides: Dict of setting overrides (e.g., {"llm.provider": "openai"}) 

417 This is the most common use case - pass a dict of settings to override. 

418 base_settings: Base settings dict (defaults to get_default_settings_snapshot()) 

419 Rarely needed - only for advanced use cases. 

420 **kwargs: Common setting shortcuts: 

421 - provider: Maps to "llm.provider" 

422 - api_key: Maps to "llm.{provider}.api_key" 

423 - temperature: Maps to "llm.temperature" 

424 - max_search_results: Maps to "search.max_results" 

425 - search_engines: Maps to enabled search engines 

426 

427 Returns: 

428 Complete settings snapshot for use with the API 

429 

430 Examples: 

431 # Most common - pass overrides as first argument 

432 settings = create_settings_snapshot({"search.tool": "wikipedia"}) 

433 

434 # Or use named parameter 

435 settings = create_settings_snapshot(overrides={"llm.provider": "openai"}) 

436 

437 # Use kwargs shortcuts 

438 settings = create_settings_snapshot(provider="openai", temperature=0.7) 

439 

440 # Advanced - provide custom base settings 

441 settings = create_settings_snapshot( 

442 overrides={"search.tool": "wikipedia"}, 

443 base_settings=my_custom_defaults 

444 ) 

445 """ 

446 # Start with base settings or defaults 

447 if base_settings is None: 

448 settings = get_default_settings_snapshot() 

449 else: 

450 settings = copy.deepcopy(base_settings) 

451 

452 # Apply overrides if provided 

453 if overrides: 

454 for key, value in overrides.items(): 

455 if key in settings: 

456 if isinstance(settings[key], dict) and "value" in settings[key]: 456 ↛ 459line 456 didn't jump to line 459 because the condition on line 456 was always true

457 settings[key]["value"] = value 

458 else: 

459 settings[key] = value 

460 else: 

461 # Create a simple setting entry for unknown keys 

462 # Infer ui_element from value type 

463 ui_element = "text" # default 

464 if isinstance(value, bool): 

465 ui_element = "checkbox" 

466 elif isinstance(value, (int, float)): 

467 ui_element = "number" 

468 elif isinstance(value, dict): 

469 ui_element = "json" 

470 

471 settings[key] = {"value": value, "ui_element": ui_element} 

472 

473 # Handle common kwargs shortcuts 

474 if "provider" in kwargs: 

475 provider = kwargs["provider"] 

476 if "llm.provider" in settings: 476 ↛ 479line 476 didn't jump to line 479 because the condition on line 476 was always true

477 settings["llm.provider"]["value"] = provider 

478 else: 

479 settings["llm.provider"] = {"value": provider} 

480 

481 # Handle api_key if provided 

482 if "api_key" in kwargs: 

483 api_key = kwargs["api_key"] 

484 api_key_setting = f"llm.{provider}.api_key" 

485 if api_key_setting in settings: 

486 settings[api_key_setting]["value"] = api_key 

487 else: 

488 settings[api_key_setting] = {"value": api_key} 

489 

490 if "temperature" in kwargs: 

491 if "llm.temperature" in settings: 491 ↛ 494line 491 didn't jump to line 494 because the condition on line 491 was always true

492 settings["llm.temperature"]["value"] = kwargs["temperature"] 

493 else: 

494 settings["llm.temperature"] = {"value": kwargs["temperature"]} 

495 

496 if "max_search_results" in kwargs: 

497 if "search.max_results" in settings: 497 ↛ 502line 497 didn't jump to line 502 because the condition on line 497 was always true

498 settings["search.max_results"]["value"] = kwargs[ 

499 "max_search_results" 

500 ] 

501 else: 

502 settings["search.max_results"] = { 

503 "value": kwargs["max_search_results"] 

504 } 

505 

506 # Add any other common shortcuts here... 

507 

508 return settings 

509 

510 

511def extract_setting_value( 

512 settings_snapshot: dict[str, Any], key: str, default: Any = None 

513) -> Any: 

514 """ 

515 Extract a setting value from a settings snapshot. 

516 

517 Args: 

518 settings_snapshot: Settings snapshot dict 

519 key: Setting key (e.g., "llm.provider") 

520 default: Default value if not found 

521 

522 Returns: 

523 The setting value 

524 """ 

525 if settings_snapshot is None: 

526 return default 

527 if key in settings_snapshot: 

528 setting = settings_snapshot[key] 

529 return unwrap_setting(setting) 

530 return default 

531 

532 

533def extract_bool_setting( 

534 settings_snapshot: dict[str, Any], key: str, default: bool = False 

535) -> bool: 

536 """ 

537 Extract a boolean setting value from a settings snapshot. 

538 

539 This is a convenience wrapper around extract_setting_value that 

540 handles string-to-boolean conversion. 

541 

542 Args: 

543 settings_snapshot: Settings snapshot dict 

544 key: Setting key (e.g., "local_search_normalize_vectors") 

545 default: Default boolean value if not found 

546 

547 Returns: 

548 Boolean value of the setting 

549 """ 

550 value = extract_setting_value(settings_snapshot, key, default) 

551 return to_bool(value, default)