Coverage for src / local_deep_research / settings / manager.py: 92%

491 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-14 23:55 +0000

1import functools 

2import json 

3import os 

4import threading 

5from pathlib import Path 

6from typing import Any, Callable, Dict, List, Optional, Union 

7 

8from loguru import logger 

9from sqlalchemy import func, or_ 

10from sqlalchemy.exc import SQLAlchemyError 

11from sqlalchemy.orm import Session 

12 

13from .. import defaults 

14from ..__version__ import __version__ as package_version 

15from ..database.models import Setting, SettingType 

16from ..web.models.settings import ( 

17 AppSetting, 

18 BaseSetting, 

19 LLMSetting, 

20 ReportSetting, 

21 SearchSetting, 

22) 

23from ..utilities.type_utils import to_bool 

24from .base import ISettingsManager 

25from .env_registry import registry as env_registry 

26 

27 

28def parse_boolean(value: Any) -> bool: 

29 """ 

30 Convert various representations to boolean using HTML checkbox semantics. 

31 

32 This function handles form values, JSON booleans, and environment variables, 

33 ensuring consistent behavior across client and server. 

34 

35 **HTML Checkbox Semantics** (INTENTIONAL DESIGN): 

36 - **Any value present (except explicit false) = checked = True** 

37 - This matches standard HTML form behavior where checkbox presence indicates checked state 

38 - In HTML forms, checkboxes send a value when checked, nothing when unchecked 

39 

40 **Examples**: 

41 parse_boolean("on") # True - standard HTML checkbox value 

42 parse_boolean("true") # True - explicit true 

43 parse_boolean("1") # True - numeric true 

44 parse_boolean("enabled") # True - any non-empty string 

45 parse_boolean("disabled") # True - INTENTIONAL: any string = checkbox was checked! 

46 parse_boolean("custom") # True - custom checkbox value 

47 

48 parse_boolean("false") # False - explicit false 

49 parse_boolean("off") # False - explicit false 

50 parse_boolean("0") # False - explicit false 

51 parse_boolean("") # False - empty string = unchecked 

52 parse_boolean(None) # False - missing = unchecked 

53 

54 **Why "disabled" returns True**: 

55 This is NOT a bug! If a checkbox sends the value "disabled", it means the checkbox 

56 was checked (present in form data). The actual string content doesn't matter for 

57 HTML checkboxes - only presence vs absence matters. 

58 

59 Args: 

60 value: Value to convert to boolean. Accepts strings, booleans, or None. 

61 

62 Returns: 

63 bool: True for truthy values (any non-empty string except explicit false); 

64 False for falsy values ('off', 'false', '0', '', 'no', False, None) 

65 

66 Note: 

67 This function implements HTML form semantics, NOT generic boolean parsing. 

68 See tests/settings/test_boolean_parsing.py for comprehensive test coverage. 

69 """ 

70 # Constants for boolean value parsing 

71 FALSY_VALUES = ("off", "false", "0", "", "no") 

72 

73 # Handle already-boolean values 

74 if isinstance(value, bool): 

75 return value 

76 

77 # Handle None (missing values) 

78 if value is None: 

79 return False 

80 

81 # Handle string values 

82 if isinstance(value, str): 

83 value_lower = value.lower().strip() 

84 # Explicitly falsy values (empty string, false-like values) 

85 if value_lower in FALSY_VALUES: 

86 return False 

87 # Any other non-empty string = True (HTML checkbox semantics) 

88 return True 

89 

90 # For other types (numbers, lists, etc.), use Python's bool conversion 

91 return bool(value) 

92 

93 

94def _parse_number(x): 

95 """Parse number, returning int if it's a whole number, otherwise float.""" 

96 f = float(x) 

97 if f.is_integer(): 

98 return int(f) 

99 return f 

100 

101 

102def _parse_json_value(x): 

103 """Parse JSON ui_element values. 

104 

105 DB values (via SQLAlchemy JSON column) arrive as Python objects already. 

106 Form POST and env var overrides arrive as raw strings and need parsing. 

107 For example, a textarea containing ``["general"]`` arrives as the string 

108 ``'[\\r\\n "general"\\r\\n]'`` which must be decoded into a list. 

109 """ 

110 if isinstance(x, str): 

111 stripped = x.strip() 

112 if stripped: 

113 try: 

114 return json.loads(stripped) 

115 except (json.JSONDecodeError, ValueError, RecursionError): 

116 logger.warning("Failed to parse JSON value, returning raw") 

117 return x 

118 return x 

119 

120 

121def _parse_multiselect(x): 

122 """Parse multiselect value, handling both lists and strings. 

123 

124 DB values (via SQLAlchemy JSON column) arrive as Python lists already. 

125 Env var overrides arrive as strings and need parsing — either as JSON 

126 arrays (e.g. '["markdown","latex"]') or comma-separated values 

127 (e.g. 'markdown,latex'). 

128 """ 

129 if isinstance(x, list): 

130 return x 

131 if isinstance(x, str): 

132 stripped = x.strip() 

133 if stripped.startswith("["): 

134 try: 

135 parsed = json.loads(stripped) 

136 if isinstance(parsed, list): 136 ↛ 141line 136 didn't jump to line 141 because the condition on line 136 was always true

137 return parsed 

138 except (json.JSONDecodeError, ValueError): 

139 pass 

140 # Comma-separated fallback 

141 return [item.strip() for item in stripped.split(",") if item.strip()] 

142 return x 

143 

144 

145def _filter_setting_columns(data: dict) -> dict: 

146 """Filter a dict to only keys that are valid Setting model columns. 

147 

148 Prevents crashes when default_settings.json contains keys not present 

149 as columns on the Setting model (e.g. future flags). 

150 """ 

151 valid_columns = {c.name for c in Setting.__table__.columns} 

152 return {k: v for k, v in data.items() if k in valid_columns} 

153 

154 

155def _infer_ui_element(value: Any, current: str = "text") -> str: 

156 """Infer the appropriate ui_element string from a Python value's type. 

157 

158 Args: 

159 value: The value to infer the ui_element from. 

160 current: The existing ui_element. If it is already something more 

161 specific than ``"text"``, it is kept as-is. 

162 """ 

163 if current != "text": 

164 return current 

165 if isinstance(value, bool): 

166 return "checkbox" 

167 if isinstance(value, (int, float)): 

168 return "number" 

169 if isinstance(value, (list, dict)): 

170 return "json" 

171 return "text" 

172 

173 

174UI_ELEMENT_TO_SETTING_TYPE: Dict[str, Callable[..., Any]] = { 

175 "text": str, 

176 "json": _parse_json_value, 

177 "password": str, 

178 "select": str, 

179 "number": _parse_number, 

180 "range": _parse_number, # Same behavior as number for consistency 

181 "checkbox": parse_boolean, 

182 "textarea": str, 

183 "multiselect": _parse_multiselect, 

184} 

185 

186 

187def get_typed_setting_value( 

188 key: str, 

189 value: Any, 

190 ui_element: str, 

191 default: Any = None, 

192 check_env: bool = True, 

193) -> Any: 

194 """ 

195 Extracts the value for a particular setting, ensuring that it has the 

196 correct type. 

197 

198 Args: 

199 key: The setting key. 

200 value: The setting value from the database. 

201 ui_element: The setting UI element ID. 

202 default: Default value to return if the value of the setting is 

203 invalid. 

204 check_env: If true, it will check the environment variable for 

205 this setting before reading from the DB. 

206 

207 Returns: 

208 The value of the setting. 

209 

210 """ 

211 setting_type = UI_ELEMENT_TO_SETTING_TYPE.get(ui_element, None) 

212 if setting_type is None: 

213 logger.warning( 

214 "Got unknown type {} for setting {}, returning default value.", 

215 ui_element, 

216 key, 

217 ) 

218 return default 

219 

220 # Check environment variable first (highest priority). 

221 if check_env: 

222 env_value = check_env_setting(key) 

223 if env_value is not None: 

224 try: 

225 return setting_type(env_value) 

226 except ValueError: 

227 logger.warning( 

228 "Setting {} has invalid value {}. Falling back to DB.", 

229 key, 

230 env_value, 

231 ) 

232 

233 # If value is None (not in database), return default. 

234 if value is None: 

235 return default 

236 

237 # Read from the database. 

238 try: 

239 return setting_type(value) 

240 except (ValueError, TypeError): 

241 logger.warning( 

242 "Setting {} has invalid value {}. Returning default.", 

243 key, 

244 value, 

245 ) 

246 return default 

247 

248 

249def check_env_setting(key: str) -> str | None: 

250 """ 

251 Checks environment variables for a particular setting. 

252 

253 Args: 

254 key: The database key for the setting. 

255 

256 Returns: 

257 The setting from the environment variables, or None if the variable 

258 is not set or is empty. 

259 

260 Note: 

261 Empty environment variables ("") are treated as unset. This is standard 

262 practice across the ecosystem — see CPython's official docs (PYTHON* 

263 env vars require "a non-empty string"), botocore PR #1687, Pallets/Click 

264 PR #2223, and Vercel Turborepo PR #6929. Orchestration tools like Unraid, 

265 Terraform, and Kubernetes manifests often cannot conditionally omit env 

266 var declarations, so they pass "" for unconfigured values. Treating "" 

267 as unset prevents these empty strings from overriding database defaults. 

268 See: https://github.com/LearningCircuit/local-deep-research/pull/3362 

269 

270 """ 

271 env_variable_name = f"LDR_{'_'.join(key.split('.')).upper()}" 

272 env_value = os.getenv(env_variable_name) 

273 # Treat empty string as unset — orchestration tools (Unraid, Terraform, K8s) 

274 # often cannot omit env var declarations and pass "" for unconfigured values. 

275 if env_value is not None and env_value != "": 

276 logger.debug(f"Overriding {key} setting from environment variable.") 

277 return env_value 

278 if env_value == "": 

279 logger.warning( 

280 "Environment variable {} is set but empty — " 

281 "ignoring it and falling back to DB/default for setting '{}'. " 

282 "This is expected on Unraid or Docker templates that create " 

283 "all variables even when left blank. To suppress this warning, " 

284 "remove the variable from your environment or set a value.", 

285 env_variable_name, 

286 key, 

287 ) 

288 return None 

289 

290 

291class SettingsManager(ISettingsManager): 

292 """ 

293 Manager for handling application settings with database storage and file fallback. 

294 Provides methods to get and set settings, with the ability to override settings in memory. 

295 """ 

296 

297 def __init__( 

298 self, 

299 db_session: Optional[Session] = None, 

300 owns_session: bool = False, 

301 ): 

302 """ 

303 Initialize the settings manager 

304 

305 Args: 

306 db_session: SQLAlchemy session for database operations 

307 owns_session: If True, close() will close the session. 

308 Defaults to False (safe for borrowed sessions). Set to True 

309 only when this manager created/owns the session — currently 

310 only get_settings_manager() in db_utils.py does this. 

311 """ 

312 self.db_session = db_session 

313 self._owns_session = owns_session 

314 self._closed = False 

315 self.db_first = True # Always prioritize DB settings 

316 

317 # Store the thread ID this instance was created in 

318 self._creation_thread_id = threading.get_ident() 

319 

320 # Initialize settings lock as None - will be checked lazily 

321 self.__settings_locked: Optional[bool] = None 

322 

323 # Auto-initialize settings if database is empty 

324 if self.db_session: 

325 self._ensure_settings_initialized() 

326 

327 def close(self): 

328 """Close the DB session if this manager owns it. 

329 

330 Borrowed sessions (owns_session=False) are left open for their 

331 owner to close (e.g. Flask teardown closes g.db_session). 

332 Safe to call multiple times — subsequent calls are no-ops. 

333 """ 

334 if self._owns_session and self.db_session is not None: 

335 try: 

336 logger.debug("Closing owned DB session in SettingsManager") 

337 self.db_session.close() 

338 except Exception: 

339 logger.warning( 

340 "Failed to close SettingsManager DB session — " 

341 "connection may leak", 

342 ) 

343 self._closed = True 

344 self.db_session = None 

345 

346 def _ensure_settings_initialized(self): 

347 """Ensure settings are initialized in the database.""" 

348 # Check if we have any settings at all 

349 from ..database.models import Setting 

350 

351 if self.db_session is None: 351 ↛ 352line 351 didn't jump to line 352 because the condition on line 351 was never true

352 raise RuntimeError("Database session is not initialized") 

353 settings_count = self.db_session.query(Setting).count() 

354 

355 if settings_count == 0: 

356 logger.info("No settings found in database, loading defaults") 

357 self.load_from_defaults_file(commit=True) 

358 logger.info("Default settings loaded successfully") 

359 

360 def _check_thread_safety(self): 

361 """Check if this instance is being used in the same thread it was created in.""" 

362 current_thread_id = threading.get_ident() 

363 if self.db_session and current_thread_id != self._creation_thread_id: 

364 raise RuntimeError( 

365 f"SettingsManager instance created in thread {self._creation_thread_id} " 

366 f"is being used in thread {current_thread_id}. This is not thread-safe! " 

367 f"Create a new SettingsManager instance within the current thread context." 

368 ) 

369 

370 @property 

371 def settings_locked(self) -> bool: 

372 """Check if settings are locked (lazy evaluation).""" 

373 if self.__settings_locked is None: 

374 try: 

375 self.__settings_locked = self.get_setting( 

376 "app.lock_settings", False 

377 ) 

378 if self.settings_locked: 

379 logger.info( 

380 "Settings are locked. Disabling all settings changes." 

381 ) 

382 except Exception: 

383 logger.warning( 

384 "Failed to check settings lock status, assuming not locked" 

385 ) 

386 self.__settings_locked = False 

387 return bool(self.__settings_locked) 

388 

389 @functools.cached_property 

390 def default_settings(self) -> Dict[str, Any]: 

391 """ 

392 Returns: 

393 The default settings, loaded from JSON files and merged. 

394 Automatically discovers and loads all .json files in the defaults 

395 directory and its subdirectories. 

396 Theme options are dynamically injected from the theme registry. 

397 

398 """ 

399 settings: Dict[str, Any] = {} 

400 

401 try: 

402 # Get the defaults package path 

403 defaults_path = Path(defaults.__file__).parent 

404 

405 # Find all JSON files recursively in the defaults directory 

406 json_files = sorted(defaults_path.rglob("*.json")) 

407 

408 logger.debug(f"Found {len(json_files)} JSON settings files") 

409 

410 # Load and merge all JSON files 

411 for json_file in json_files: 

412 try: 

413 with open(json_file, "r") as f: 

414 file_settings = json.load(f) 

415 

416 # Get relative path for logging 

417 relative_path = json_file.relative_to(defaults_path) 

418 

419 # Warn about key conflicts 

420 conflicts = set(settings.keys()) & set(file_settings.keys()) 

421 if conflicts: 

422 logger.warning( 

423 f"Keys {conflicts} from {relative_path} " 

424 f"override existing values" 

425 ) 

426 

427 settings.update(file_settings) 

428 logger.debug(f"Loaded {relative_path}") 

429 

430 except json.JSONDecodeError: 

431 logger.exception(f"Invalid JSON in {json_file}") 

432 except Exception: 

433 logger.warning(f"Could not load {json_file}") 

434 

435 except Exception: 

436 logger.warning("Error loading settings files") 

437 

438 # Inject dynamic theme options from theme registry 

439 if "app.theme" in settings: 

440 try: 

441 from local_deep_research.web.themes import theme_registry 

442 

443 settings["app.theme"]["options"] = ( 

444 theme_registry.get_settings_options() 

445 ) 

446 except ImportError: 

447 # Theme registry not available, use static options from JSON 

448 pass 

449 

450 # Inject search strategy options from code (single source of truth) 

451 if "search.search_strategy" in settings: 

452 from local_deep_research.constants import get_available_strategies 

453 

454 # Check the show_all_strategies setting to decide which list 

455 show_all = False 

456 if "search.show_all_strategies" in settings: 456 ↛ 460line 456 didn't jump to line 460 because the condition on line 456 was always true

457 val = settings["search.show_all_strategies"].get("value", False) 

458 show_all = val is True or val == "true" 

459 

460 strategies = get_available_strategies(show_all=show_all) 

461 settings["search.search_strategy"]["options"] = [ 

462 {"label": s["label"], "value": s["name"]} for s in strategies 

463 ] 

464 

465 logger.debug(f"Loaded {len(settings)} total settings") 

466 return settings 

467 

468 def __get_typed_setting_value( 

469 self, 

470 setting: Setting, 

471 default: Any = None, 

472 check_env: bool = True, 

473 ) -> Any: 

474 """ 

475 Extracts the value for a particular setting, ensuring that it has the 

476 correct type. 

477 

478 Args: 

479 setting: The setting to get the value for. 

480 default: Default value to return if the value of the setting is 

481 invalid. 

482 check_env: If true, it will check the environment variable for 

483 this setting before reading from the DB. 

484 

485 Returns: 

486 The value of the setting. 

487 

488 """ 

489 return get_typed_setting_value( 

490 str(setting.key), 

491 setting.value, 

492 str(setting.ui_element), 

493 default=default, 

494 check_env=check_env, 

495 ) 

496 

497 def __query_settings(self, key: str | None = None) -> List[Setting]: 

498 """ 

499 Abstraction for querying settings that also transparently handles 

500 reading the default settings file if the DB is not enabled. 

501 

502 Args: 

503 key: The key to read. If None, it will read everything. 

504 

505 Returns: 

506 The settings it queried. 

507 

508 """ 

509 if self.db_session: 

510 self._check_thread_safety() 

511 query = self.db_session.query(Setting) 

512 if key is not None: 

513 # This will find exact matches and any subkeys. 

514 query = query.filter( 

515 or_( 

516 Setting.key == key, 

517 Setting.key.startswith(f"{key}."), 

518 ) 

519 ) 

520 return query.all() 

521 

522 logger.debug( 

523 "DB is disabled, reading setting '{}' from defaults file.", key 

524 ) 

525 

526 settings = [] 

527 for candidate_key, setting in self.default_settings.items(): 

528 if key is None or ( 

529 candidate_key == key or candidate_key.startswith(f"{key}.") 

530 ): 

531 settings.append( 

532 Setting( 

533 key=candidate_key, # gitleaks:allow 

534 **_filter_setting_columns(setting), 

535 ) 

536 ) 

537 

538 return settings 

539 

540 def get_setting( 

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

542 ) -> Any: 

543 """ 

544 Get a setting value 

545 

546 Args: 

547 key: Setting key 

548 default: Default value if setting is not found 

549 check_env: If true, it will check the environment variable for 

550 this setting before reading from the DB. 

551 

552 Returns: 

553 Setting value or default if not found 

554 """ 

555 if self._closed: 555 ↛ 556line 555 didn't jump to line 556 because the condition on line 555 was never true

556 logger.error( 

557 "SettingsManager.get_setting('{}') called after close() — " 

558 "this is a bug; the caller should not reuse a closed manager", 

559 key, 

560 ) 

561 raise RuntimeError( 

562 "SettingsManager has been closed. " 

563 "Create a new instance or call close() only at end of lifecycle." 

564 ) 

565 

566 # First check if this is an env-only setting 

567 if env_registry.is_env_only(key): 

568 return env_registry.get(key, default) 

569 

570 # If using database first approach and session available, check database 

571 try: 

572 settings = self.__query_settings(key) 

573 if len(settings) == 1: 

574 # This is a bottom-level key. 

575 return self.__get_typed_setting_value( 

576 settings[0], default, check_env 

577 ) 

578 # Cache the result 

579 if len(settings) > 1: 

580 # This is a higher-level key. 

581 settings_map = {} 

582 for setting in settings: 

583 output_key = str(setting.key).removeprefix(f"{key}.") 

584 settings_map[output_key] = self.__get_typed_setting_value( 

585 setting, default, check_env 

586 ) 

587 return settings_map 

588 except SQLAlchemyError: 

589 logger.exception(f"Error retrieving setting {key} from database") 

590 

591 # Check env var before returning default (setting not in DB) 

592 if check_env: 

593 env_value = check_env_setting(key) 

594 if env_value is not None: 

595 default_meta = self.default_settings.get(key) 

596 if default_meta and isinstance(default_meta, dict): 

597 ui_element = default_meta.get("ui_element", "text") 

598 return get_typed_setting_value( 

599 key, 

600 None, 

601 ui_element, 

602 default=default, 

603 check_env=True, 

604 ) 

605 logger.warning( 

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

607 "defaults — returning raw string without type " 

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

609 "file with a ui_element type to enable proper " 

610 "type conversion.", 

611 key, 

612 ) 

613 return env_value 

614 

615 # Return default if not found 

616 return default 

617 

618 def get_bool_setting( 

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

620 ) -> bool: 

621 """ 

622 Get a setting value as a boolean, handling string conversion. 

623 

624 Args: 

625 key: Setting key 

626 default: Default boolean value if setting is not found 

627 check_env: If true, it will check the environment variable for 

628 this setting before reading from the DB. 

629 

630 Returns: 

631 Boolean value of the setting 

632 """ 

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

634 return to_bool(value, default) 

635 

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

637 """ 

638 Set a setting value 

639 

640 Args: 

641 key: Setting key 

642 value: Setting value 

643 commit: Whether to commit the change 

644 

645 Returns: 

646 True if successful, False otherwise 

647 """ 

648 if self._closed: 648 ↛ 649line 648 didn't jump to line 649 because the condition on line 648 was never true

649 logger.error( 

650 "SettingsManager.set_setting('{}') called after close() — " 

651 "this is a bug; the caller should not reuse a closed manager", 

652 key, 

653 ) 

654 raise RuntimeError( 

655 "SettingsManager has been closed. " 

656 "Create a new instance or call close() only at end of lifecycle." 

657 ) 

658 if not self.db_session: 

659 logger.error( 

660 "Cannot edit setting {} because no DB was provided.", key 

661 ) 

662 return False 

663 if self.settings_locked: 

664 logger.error("Cannot edit setting {} because they are locked.", key) 

665 return False 

666 

667 # Always update database if available 

668 try: 

669 self._check_thread_safety() 

670 setting = ( 

671 self.db_session.query(Setting) 

672 .filter(Setting.key == key) 

673 .first() 

674 ) 

675 if setting: 

676 if not setting.editable: 

677 logger.error( 

678 "Cannot change setting '{}' because it " 

679 "is marked as non-editable.", 

680 key, 

681 ) 

682 return False 

683 

684 setting.value = value # type: ignore[assignment] 

685 setting.updated_at = ( # type: ignore[assignment] 

686 func.now() 

687 ) # Explicitly set the current timestamp 

688 

689 # Self-heal stale ui_element from before inference was added 

690 setting.ui_element = _infer_ui_element( 

691 value, setting.ui_element 

692 ) 

693 else: 

694 # Determine setting type from key 

695 setting_type = SettingType.APP 

696 if key.startswith("llm."): 

697 setting_type = SettingType.LLM 

698 elif key.startswith("search."): 

699 setting_type = SettingType.SEARCH 

700 elif key.startswith("report."): 

701 setting_type = SettingType.REPORT 

702 elif key.startswith("database."): 702 ↛ 703line 702 didn't jump to line 703 because the condition on line 702 was never true

703 setting_type = SettingType.DATABASE 

704 

705 # Infer ui_element from the value type 

706 ui_element = _infer_ui_element(value) 

707 

708 # Create a new setting 

709 new_setting = Setting( 

710 key=key, 

711 value=value, 

712 type=setting_type, 

713 name=key.split(".")[-1].replace("_", " ").title(), 

714 ui_element=ui_element, 

715 description=f"Setting for {key}", 

716 ) 

717 self.db_session.add(new_setting) 

718 

719 if commit: 

720 self.db_session.commit() 

721 # Emit WebSocket event for settings change 

722 self._emit_settings_changed([key]) 

723 

724 return True 

725 except SQLAlchemyError: 

726 logger.exception(f"Error setting value for key: {key}") 

727 self.db_session.rollback() 

728 return False 

729 

730 def clear_cache(self): 

731 """Clear the settings cache.""" 

732 self.__dict__.pop("default_settings", None) 

733 logger.debug("Settings cache cleared") 

734 

735 def get_all_settings(self, bypass_cache: bool = False) -> Dict[str, Any]: 

736 """ 

737 Get all settings, merging defaults with database values. 

738 

739 This ensures that new settings added to defaults.json automatically 

740 appear in the UI without requiring a database reset. 

741 

742 Args: 

743 bypass_cache: If True, bypass the cache and read directly from database 

744 

745 Returns: 

746 Dictionary of all settings 

747 """ 

748 if self._closed: 748 ↛ 749line 748 didn't jump to line 749 because the condition on line 748 was never true

749 logger.error( 

750 "SettingsManager.get_all_settings() called after close() — " 

751 "this is a bug; the caller should not reuse a closed manager", 

752 ) 

753 raise RuntimeError( 

754 "SettingsManager has been closed. " 

755 "Create a new instance or call close() only at end of lifecycle." 

756 ) 

757 

758 result = {} 

759 

760 # Start with defaults so new settings are always included 

761 for key, default_setting in self.default_settings.items(): 

762 result[key] = dict(default_setting) 

763 

764 # Check env var override for defaults not yet in DB 

765 env_value = check_env_setting(key) 

766 if env_value is not None: 766 ↛ 767line 766 didn't jump to line 767 because the condition on line 766 was never true

767 ui_element = default_setting.get("ui_element", "text") 

768 typed_value = get_typed_setting_value( 

769 key, 

770 None, 

771 ui_element, 

772 default=env_value, 

773 check_env=True, 

774 ) 

775 result[key]["value"] = typed_value 

776 result[key]["editable"] = False 

777 

778 # Override with database settings 

779 try: 

780 db_settings = self.__query_settings() 

781 except SQLAlchemyError: 

782 logger.exception( 

783 "Error querying settings from database in get_all_settings" 

784 ) 

785 db_settings = [] 

786 

787 for setting in db_settings: 

788 # Handle type field - it might be a string or an enum 

789 setting_type = setting.type 

790 if hasattr(setting_type, "name"): 

791 setting_type = setting_type.name 

792 

793 # Log if this is a custom setting not in defaults 

794 if str(setting.key) not in result: 

795 logger.debug( 

796 f"Database contains custom setting not in " 

797 f"defaults: {setting.key} (type={setting_type}, " 

798 f"category={setting.category})" 

799 ) 

800 

801 # Override default with database value 

802 result[str(setting.key)] = { 

803 "value": setting.value, 

804 "type": setting_type, 

805 "name": setting.name, 

806 "description": setting.description, 

807 "category": setting.category, 

808 "ui_element": setting.ui_element, 

809 "options": setting.options, 

810 "min_value": setting.min_value, 

811 "max_value": setting.max_value, 

812 "step": setting.step, 

813 "visible": setting.visible, 

814 "editable": False if self.settings_locked else setting.editable, 

815 } 

816 

817 # Override from the environment variables if needed. 

818 env_value = check_env_setting(str(setting.key)) 

819 if env_value is not None: 

820 ui_element = result[str(setting.key)].get( 

821 "ui_element", setting.ui_element 

822 ) 

823 typed_value = get_typed_setting_value( 

824 str(setting.key), 

825 None, 

826 ui_element, 

827 default=env_value, 

828 check_env=True, 

829 ) 

830 result[str(setting.key)]["value"] = typed_value 

831 # Mark it as non-editable, because changes to the DB 

832 # value have no effect as long as the environment 

833 # variable is set. 

834 result[str(setting.key)]["editable"] = False 

835 

836 # Re-inject search strategy options from code after DB merge, 

837 # since the DB stores options=null for this setting. 

838 if "search.search_strategy" in result: 

839 from local_deep_research.constants import get_available_strategies 

840 

841 show_all_val = result.get("search.show_all_strategies", {}).get( 

842 "value", False 

843 ) 

844 show_all = show_all_val is True or show_all_val == "true" 

845 strategies = get_available_strategies(show_all=show_all) 

846 result["search.search_strategy"]["options"] = [ 

847 {"label": s["label"], "value": s["name"]} for s in strategies 

848 ] 

849 

850 return result 

851 

852 def get_settings_snapshot(self) -> Dict[str, Any]: 

853 """ 

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

855 This is useful for passing settings to background threads or storing in metadata. 

856 

857 Returns: 

858 Dictionary with setting keys mapped to their values 

859 """ 

860 if self._closed: 860 ↛ 861line 860 didn't jump to line 861 because the condition on line 860 was never true

861 logger.error( 

862 "SettingsManager.get_settings_snapshot() called after close() — " 

863 "this is a bug; the caller should not reuse a closed manager", 

864 ) 

865 raise RuntimeError( 

866 "SettingsManager has been closed. " 

867 "Create a new instance or call close() only at end of lifecycle." 

868 ) 

869 

870 all_settings = self.get_all_settings() 

871 settings_snapshot = {} 

872 

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

874 if isinstance(setting, dict) and "value" in setting: 874 ↛ 877line 874 didn't jump to line 877 because the condition on line 874 was always true

875 settings_snapshot[key] = setting["value"] 

876 else: 

877 settings_snapshot[key] = setting 

878 

879 return settings_snapshot 

880 

881 def create_or_update_setting( 

882 self, setting: Union[BaseSetting, Dict[str, Any]], commit: bool = True 

883 ) -> Optional[Setting]: 

884 """ 

885 Create or update a setting 

886 

887 Args: 

888 setting: Setting object or dictionary 

889 commit: Whether to commit the change 

890 

891 Returns: 

892 The created or updated Setting model, or None if failed 

893 """ 

894 if not self.db_session: 

895 logger.warning( 

896 "No database session available, cannot create/update setting" 

897 ) 

898 return None 

899 if self.settings_locked: 

900 logger.error("Cannot edit settings because they are locked.") 

901 return None 

902 

903 # Convert dict to BaseSetting if needed 

904 if isinstance(setting, dict): 904 ↛ 921line 904 didn't jump to line 921 because the condition on line 904 was always true

905 # Determine type from key if not specified 

906 if "type" not in setting and "key" in setting: 

907 setting_obj: BaseSetting 

908 key = setting["key"] 

909 if key.startswith("llm."): 

910 setting_obj = LLMSetting(**setting) 

911 elif key.startswith("search."): 

912 setting_obj = SearchSetting(**setting) 

913 elif key.startswith("report."): 

914 setting_obj = ReportSetting(**setting) 

915 else: 

916 setting_obj = AppSetting(**setting) 

917 else: 

918 # Use generic BaseSetting 

919 setting_obj = BaseSetting(**setting) 

920 else: 

921 setting_obj = setting 

922 

923 try: 

924 # Check if setting exists 

925 db_setting = ( 

926 self.db_session.query(Setting) 

927 .filter(Setting.key == setting_obj.key) 

928 .first() 

929 ) 

930 

931 if db_setting: 

932 # Update existing setting 

933 if not db_setting.editable: 

934 logger.error( 

935 "Cannot change setting '{}' because it " 

936 "is marked as non-editable.", 

937 setting_obj.key, 

938 ) 

939 return None 

940 

941 db_setting.value = setting_obj.value # type: ignore[assignment] 

942 db_setting.name = setting_obj.name # type: ignore[assignment] 

943 db_setting.description = setting_obj.description # type: ignore[assignment] 

944 db_setting.category = setting_obj.category # type: ignore[assignment] 

945 db_setting.ui_element = setting_obj.ui_element # type: ignore[assignment] 

946 db_setting.options = setting_obj.options # type: ignore[assignment] 

947 db_setting.min_value = setting_obj.min_value # type: ignore[assignment] 

948 db_setting.max_value = setting_obj.max_value # type: ignore[assignment] 

949 db_setting.step = setting_obj.step # type: ignore[assignment] 

950 db_setting.visible = setting_obj.visible # type: ignore[assignment] 

951 db_setting.editable = setting_obj.editable # type: ignore[assignment] 

952 db_setting.updated_at = ( # type: ignore[assignment] 

953 func.now() 

954 ) # Explicitly set the current timestamp 

955 else: 

956 # Create new setting 

957 db_setting = Setting( 

958 key=setting_obj.key, 

959 value=setting_obj.value, 

960 type=setting_obj.type, 

961 name=setting_obj.name, 

962 description=setting_obj.description, 

963 category=setting_obj.category, 

964 ui_element=setting_obj.ui_element, 

965 options=setting_obj.options, 

966 min_value=setting_obj.min_value, 

967 max_value=setting_obj.max_value, 

968 step=setting_obj.step, 

969 visible=setting_obj.visible, 

970 editable=setting_obj.editable, 

971 ) 

972 self.db_session.add(db_setting) 

973 

974 if commit: 

975 self.db_session.commit() 

976 # Emit WebSocket event for settings change 

977 self._emit_settings_changed([setting_obj.key]) 

978 

979 return db_setting 

980 

981 except SQLAlchemyError: 

982 logger.exception( 

983 f"Error creating/updating setting {setting_obj.key}" 

984 ) 

985 self.db_session.rollback() 

986 return None 

987 

988 def delete_setting(self, key: str, commit: bool = True) -> bool: 

989 """ 

990 Delete a setting 

991 

992 Args: 

993 key: Setting key 

994 commit: Whether to commit the change 

995 

996 Returns: 

997 True if successful, False otherwise 

998 """ 

999 if not self.db_session: 

1000 logger.warning( 

1001 "No database session available, cannot delete setting" 

1002 ) 

1003 return False 

1004 

1005 try: 

1006 # Remove from database 

1007 result = ( 

1008 self.db_session.query(Setting) 

1009 .filter(Setting.key == key) 

1010 .delete() 

1011 ) 

1012 

1013 if commit: 

1014 self.db_session.commit() 

1015 

1016 return result > 0 

1017 except SQLAlchemyError: 

1018 logger.exception("Error deleting setting") 

1019 self.db_session.rollback() 

1020 return False 

1021 

1022 def load_from_defaults_file( 

1023 self, commit: bool = True, **kwargs: Any 

1024 ) -> None: 

1025 """ 

1026 Import settings from the defaults settings file. 

1027 

1028 Args: 

1029 commit: Whether to commit changes to database 

1030 **kwargs: Will be passed to `import_settings`. 

1031 

1032 """ 

1033 self.import_settings(self.default_settings, commit=commit, **kwargs) 

1034 

1035 def db_version_matches_package(self) -> bool: 

1036 """ 

1037 Returns: 

1038 True if the version saved in the DB matches the package version. 

1039 

1040 """ 

1041 db_version = self.get_setting("app.version") 

1042 logger.debug( 

1043 f"App version saved in DB is {db_version}, have package " 

1044 f"settings from version {package_version}." 

1045 ) 

1046 

1047 return bool(db_version == package_version) 

1048 

1049 def update_db_version(self) -> None: 

1050 """ 

1051 Updates the version saved in the DB based on the package version. 

1052 

1053 """ 

1054 logger.debug(f"Updating saved DB version to {package_version}.") 

1055 

1056 self.delete_setting("app.version", commit=False) 

1057 version = Setting( 

1058 key="app.version", 

1059 value=package_version, 

1060 description="Version of the app this database is associated with.", 

1061 editable=False, 

1062 name="App Version", 

1063 type=SettingType.APP, 

1064 ui_element="text", 

1065 visible=False, 

1066 ) 

1067 

1068 if self.db_session is None: 1068 ↛ 1069line 1068 didn't jump to line 1069 because the condition on line 1068 was never true

1069 raise RuntimeError("Database session is not initialized") 

1070 self.db_session.add(version) 

1071 self.db_session.commit() 

1072 

1073 def import_settings( 

1074 self, 

1075 settings_data: Dict[str, Any], 

1076 commit: bool = True, 

1077 overwrite: bool = True, 

1078 delete_extra: bool = False, 

1079 ) -> None: 

1080 """ 

1081 Import settings directly from the export format. This can be used to 

1082 re-import settings that have been exported with `get_all_settings()`. 

1083 

1084 Args: 

1085 settings_data: The raw settings data to import. 

1086 commit: Whether to commit the DB after loading the settings. 

1087 overwrite: If true, it will overwrite the value of settings that 

1088 are already in the database. 

1089 delete_extra: If true, it will delete any settings that are in 

1090 the database but don't have a corresponding entry in 

1091 `settings_data`. 

1092 

1093 """ 

1094 if self.db_session is None: 1094 ↛ 1095line 1094 didn't jump to line 1095 because the condition on line 1094 was never true

1095 raise RuntimeError("Database session is not initialized") 

1096 logger.debug(f"Importing {len(settings_data)} settings") 

1097 

1098 for key, setting_values in settings_data.items(): 

1099 setting_values = dict(setting_values) 

1100 if not overwrite: 

1101 existing_value = self.get_setting(key) 

1102 if existing_value is not None: 

1103 # Preserve the value from this setting. 

1104 setting_values["value"] = existing_value 

1105 

1106 # Delete any existing setting so we can completely overwrite it. 

1107 self.delete_setting(key, commit=False) 

1108 

1109 # Convert type string to SettingType enum if needed 

1110 if "type" in setting_values and isinstance( 1110 ↛ 1115line 1110 didn't jump to line 1115 because the condition on line 1110 was always true

1111 setting_values["type"], str 

1112 ): 

1113 setting_values["type"] = SettingType[setting_values["type"]] 

1114 

1115 setting = Setting( 

1116 key=key, **_filter_setting_columns(setting_values) 

1117 ) 

1118 self.db_session.add(setting) 

1119 

1120 if commit or delete_extra: 1120 ↛ 1126line 1120 didn't jump to line 1126 because the condition on line 1120 was always true

1121 self.db_session.commit() 

1122 logger.info(f"Successfully imported {len(settings_data)} settings") 

1123 # Emit WebSocket event for all imported settings 

1124 self._emit_settings_changed(list(settings_data.keys())) 

1125 

1126 if delete_extra: 

1127 all_settings = self.get_all_settings() 

1128 for key in all_settings: 

1129 if key not in settings_data: 

1130 logger.debug(f"Deleting extraneous setting: {key}") 

1131 self.delete_setting(key, commit=False) 

1132 

1133 def _create_setting(self, key, value, setting_type): 

1134 """Create a setting with appropriate metadata""" 

1135 

1136 # Determine appropriate category 

1137 category = None 

1138 ui_element = "text" 

1139 

1140 # Determine category based on key pattern 

1141 if key.startswith("app."): 

1142 category = "app_interface" 

1143 elif key.startswith("llm."): 

1144 if any( 

1145 param in key 

1146 for param in [ 

1147 "temperature", 

1148 "max_tokens", 

1149 "n_batch", 

1150 "n_gpu_layers", 

1151 ] 

1152 ): 

1153 category = "llm_parameters" 

1154 else: 

1155 category = "llm_general" 

1156 elif key.startswith("search."): 

1157 if any( 

1158 param in key 

1159 for param in ["iterations", "questions", "results", "region"] 

1160 ): 

1161 category = "search_parameters" 

1162 else: 

1163 category = "search_general" 

1164 elif key.startswith("report."): 

1165 category = "report_parameters" 

1166 

1167 # Determine UI element type based on value 

1168 ui_element = _infer_ui_element(value) 

1169 

1170 # Build setting object 

1171 setting_dict = { 

1172 "key": key, 

1173 "value": value, 

1174 "type": setting_type.value.lower(), 

1175 "name": key.split(".")[-1].replace("_", " ").title(), 

1176 "description": f"Setting for {key}", 

1177 "category": category, 

1178 "ui_element": ui_element, 

1179 } 

1180 

1181 # Create the setting in the database 

1182 self.create_or_update_setting(setting_dict, commit=False) 

1183 

1184 def _emit_settings_changed(self, changed_keys: Optional[List[Any]] = None): 

1185 """ 

1186 Emit WebSocket event when settings change 

1187 

1188 Args: 

1189 changed_keys: List of setting keys that changed 

1190 """ 

1191 try: 

1192 # Import here to avoid circular imports 

1193 from ..web.services.socket_service import SocketIOService 

1194 

1195 try: 

1196 socket_service = SocketIOService() 

1197 except ValueError: 

1198 logger.debug( 

1199 "Not emitting socket event because server is not initialized." 

1200 ) 

1201 return 

1202 

1203 # Get the changed settings 

1204 settings_data = {} 

1205 if changed_keys: 1205 ↛ 1212line 1205 didn't jump to line 1212 because the condition on line 1205 was always true

1206 for key in changed_keys: 

1207 setting_value = self.get_setting(key) 

1208 if setting_value is not None: 

1209 settings_data[key] = {"value": setting_value} 

1210 

1211 # Emit the settings change event 

1212 from datetime import datetime, UTC 

1213 

1214 socket_service.emit_socket_event( 

1215 "settings_changed", 

1216 { 

1217 "changed_keys": changed_keys or [], 

1218 "settings": settings_data, 

1219 "timestamp": datetime.now(UTC).isoformat(), 

1220 }, 

1221 ) 

1222 

1223 logger.debug( 

1224 f"Emitted settings_changed event for keys: {changed_keys}" 

1225 ) 

1226 

1227 except Exception: 

1228 logger.exception("Failed to emit settings change event") 

1229 # Don't let WebSocket emission failures break settings saving 

1230 

1231 @staticmethod 

1232 def get_bootstrap_env_vars() -> Dict[str, str]: 

1233 """ 

1234 Get environment variables that must be available before database access. 

1235 These are critical for system initialization. 

1236 

1237 Returns: 

1238 Dict mapping env var names to their descriptions 

1239 """ 

1240 # Get bootstrap vars from env registry 

1241 return env_registry.get_bootstrap_vars() 

1242 

1243 @staticmethod 

1244 def is_bootstrap_env_var(env_var: str) -> bool: 

1245 """ 

1246 Check if an environment variable is a bootstrap variable (needed before DB access). 

1247 

1248 Args: 

1249 env_var: Environment variable name 

1250 

1251 Returns: 

1252 True if this is a bootstrap variable 

1253 """ 

1254 bootstrap_vars = SettingsManager.get_bootstrap_env_vars() 

1255 return env_var in bootstrap_vars 

1256 

1257 @staticmethod 

1258 def is_env_only_setting(key: str) -> bool: 

1259 """ 

1260 Check if a setting key is environment-only. 

1261 

1262 Args: 

1263 key: Setting key to check 

1264 

1265 Returns: 

1266 True if it's an env-only setting, False otherwise 

1267 """ 

1268 return env_registry.is_env_only(key) 

1269 

1270 @staticmethod 

1271 def get_env_var_for_setting(setting_key: str) -> str: 

1272 """ 

1273 Get the environment variable name for a given setting key. 

1274 

1275 Args: 

1276 setting_key: Setting key (e.g., "app.host") 

1277 

1278 Returns: 

1279 Environment variable name (e.g., "LDR_APP_HOST") 

1280 """ 

1281 # Use the same logic as check_env_setting for consistency 

1282 return f"LDR_{'_'.join(setting_key.split('.')).upper()}" 

1283 

1284 @staticmethod 

1285 def get_setting_key_for_env_var(env_var: str) -> Optional[str]: 

1286 """ 

1287 Get the setting key for a given environment variable. 

1288 

1289 Args: 

1290 env_var: Environment variable name (e.g., "LDR_APP_HOST") 

1291 

1292 Returns: 

1293 Setting key (e.g., "app.host") or None if not a valid LDR env var 

1294 """ 

1295 if not env_var.startswith("LDR_"): 

1296 return None 

1297 

1298 # Remove LDR_ prefix and convert to lowercase 

1299 without_prefix = env_var[4:] 

1300 parts = without_prefix.split("_") 

1301 

1302 return ".".join(part.lower() for part in parts) 

1303 

1304 

1305class SnapshotSettingsContext: 

1306 """Read-only settings context backed by a snapshot dict. 

1307 

1308 Unwraps {"value": x} setting objects into plain values and provides 

1309 get_setting(key, default) for thread-safe snapshot access. 

1310 """ 

1311 

1312 def __init__( 

1313 self, snapshot=None, username=None, missing_key_log_level="DEBUG" 

1314 ): 

1315 self.snapshot = snapshot or {} 

1316 self.username = username 

1317 self._missing_key_log_level = missing_key_log_level 

1318 self.values = {} 

1319 for key, setting in self.snapshot.items(): 

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

1321 self.values[key] = setting["value"] 

1322 else: 

1323 self.values[key] = setting 

1324 

1325 def get_setting(self, key, default=None): 

1326 """Return the setting value for *key*, or *default* if absent.""" 

1327 if key in self.values: 

1328 return self.values[key] 

1329 logger.log( 

1330 self._missing_key_log_level, 

1331 "Setting '{}' not found in snapshot, using default", 

1332 key, 

1333 ) 

1334 return default