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

527 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-03 23:15 +0000

1import functools 

2import json 

3import os 

4import threading 

5import time 

6from pathlib import Path 

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

8 

9from loguru import logger 

10from sqlalchemy import func, or_ 

11from sqlalchemy.exc import SQLAlchemyError 

12from sqlalchemy.orm import Session 

13 

14from .. import defaults 

15from ..__version__ import __version__ as package_version 

16from ..database.models import Setting, SettingType 

17from ..web.models.settings import ( 

18 AppSetting, 

19 BaseSetting, 

20 ChatSetting, 

21 LLMSetting, 

22 ReportSetting, 

23 SearchSetting, 

24) 

25from ..utilities.type_utils import to_bool 

26from .base import ISettingsManager 

27from .env_registry import registry as env_registry 

28 

29 

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

31 """ 

32 Convert various representations to boolean using HTML checkbox semantics. 

33 

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

35 ensuring consistent behavior across client and server. 

36 

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

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

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

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

41 

42 **Examples**: 

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

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

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

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

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

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

49 

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

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

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

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

54 parse_boolean(None) # False - missing = unchecked 

55 

56 **Why "disabled" returns True**: 

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

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

59 HTML checkboxes - only presence vs absence matters. 

60 

61 Args: 

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

63 

64 Returns: 

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

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

67 

68 Note: 

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

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

71 """ 

72 # Constants for boolean value parsing 

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

74 

75 # Handle already-boolean values 

76 if isinstance(value, bool): 

77 return value 

78 

79 # Handle None (missing values) 

80 if value is None: 

81 return False 

82 

83 # Handle string values 

84 if isinstance(value, str): 

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

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

87 if value_lower in FALSY_VALUES: 

88 return False 

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

90 return True 

91 

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

93 return bool(value) 

94 

95 

96def _parse_number(x): 

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

98 f = float(x) 

99 if f.is_integer(): 

100 return int(f) 

101 return f 

102 

103 

104def _parse_json_value(x): 

105 """Parse JSON ui_element values. 

106 

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

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

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

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

111 """ 

112 if isinstance(x, str): 

113 stripped = x.strip() 

114 if stripped: 

115 try: 

116 return json.loads(stripped) 

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

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

119 return x 

120 return x 

121 

122 

123def _parse_multiselect(x): 

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

125 

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

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

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

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

130 """ 

131 if isinstance(x, list): 

132 return x 

133 if isinstance(x, str): 

134 stripped = x.strip() 

135 if stripped.startswith("["): 

136 try: 

137 parsed = json.loads(stripped) 

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

139 return parsed 

140 except (json.JSONDecodeError, ValueError): 

141 pass 

142 # Comma-separated fallback 

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

144 return x 

145 

146 

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

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

149 

150 Prevents crashes when default_settings.json contains keys not present 

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

152 """ 

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

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

155 

156 

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

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

159 

160 Args: 

161 value: The value to infer the ui_element from. 

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

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

164 """ 

165 if current != "text": 

166 return current 

167 if isinstance(value, bool): 

168 return "checkbox" 

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

170 return "number" 

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

172 return "json" 

173 return "text" 

174 

175 

176# Default categories for each typed setting prefix, used by the self-heal 

177# block in set_setting() when a row's type column doesn't match its key 

178# prefix. The values mirror the canonical strings in 

179# web/routes/settings_routes.py: a legacy chat.* row with type=APP and a 

180# stale category gets repointed to type=CHAT + category="chat" on next 

181# save. We use the most-general category per prefix here — sub-classifying 

182# llm_general vs llm_parameters depends on the specific key, but the 

183# self-heal only fires when the row was already mis-typed, so over- 

184# generalizing the category is preferable to leaving it stale. 

185_INFERRED_CATEGORY: Dict[str, str] = { 

186 "llm.": "llm_general", 

187 "search.": "search_general", 

188 "report.": "report_parameters", 

189 "database.": "database_parameters", 

190 "chat.": "chat", 

191} 

192 

193 

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

195 "text": str, 

196 "json": _parse_json_value, 

197 "password": str, 

198 "select": str, 

199 "number": _parse_number, 

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

201 "checkbox": parse_boolean, 

202 "textarea": str, 

203 "multiselect": _parse_multiselect, 

204} 

205 

206 

207def get_typed_setting_value( 

208 key: str, 

209 value: Any, 

210 ui_element: str, 

211 default: Any = None, 

212 check_env: bool = True, 

213) -> Any: 

214 """ 

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

216 correct type. 

217 

218 Args: 

219 key: The setting key. 

220 value: The setting value from the database. 

221 ui_element: The setting UI element ID. 

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

223 invalid. 

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

225 this setting before reading from the DB. 

226 

227 Returns: 

228 The value of the setting. 

229 

230 """ 

231 setting_type = UI_ELEMENT_TO_SETTING_TYPE.get(ui_element, None) 

232 if setting_type is None: 

233 logger.warning( 

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

235 ui_element, 

236 key, 

237 ) 

238 return default 

239 

240 # Check environment variable first (highest priority). 

241 if check_env: 

242 env_value = check_env_setting(key) 

243 if env_value is not None: 

244 try: 

245 return setting_type(env_value) 

246 except ValueError: 

247 logger.warning( 

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

249 key, 

250 env_value, 

251 ) 

252 

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

254 if value is None: 

255 return default 

256 

257 # Read from the database. 

258 try: 

259 return setting_type(value) 

260 except (ValueError, TypeError): 

261 logger.warning( 

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

263 key, 

264 value, 

265 ) 

266 return default 

267 

268 

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

270 """ 

271 Checks environment variables for a particular setting. 

272 

273 Args: 

274 key: The database key for the setting. 

275 

276 Returns: 

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

278 is not set or is empty. 

279 

280 Note: 

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

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

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

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

285 Terraform, and Kubernetes manifests often cannot conditionally omit env 

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

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

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

289 

290 """ 

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

292 env_value = os.getenv(env_variable_name) 

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

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

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

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

297 return env_value 

298 if env_value == "": 

299 logger.warning( 

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

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

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

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

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

305 env_variable_name, 

306 key, 

307 ) 

308 return None 

309 

310 

311class SettingsManager(ISettingsManager): 

312 """ 

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

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

315 """ 

316 

317 def __init__( 

318 self, 

319 db_session: Optional[Session] = None, 

320 owns_session: bool = False, 

321 ): 

322 """ 

323 Initialize the settings manager 

324 

325 Args: 

326 db_session: SQLAlchemy session for database operations 

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

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

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

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

331 """ 

332 self.db_session = db_session 

333 self._owns_session = owns_session 

334 self._closed = False 

335 self.db_first = True # Always prioritize DB settings 

336 

337 # Store the thread ID this instance was created in 

338 self._creation_thread_id = threading.get_ident() 

339 

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

341 self.__settings_locked: Optional[bool] = None 

342 

343 # Auto-initialize settings if database is empty 

344 if self.db_session: 

345 self._ensure_settings_initialized() 

346 

347 def close(self): 

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

349 

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

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

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

353 """ 

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

355 try: 

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

357 self.db_session.close() 

358 except Exception: 

359 logger.warning( 

360 "Failed to close SettingsManager DB session — " 

361 "connection may leak", 

362 ) 

363 self._closed = True 

364 self.db_session = None 

365 

366 def _ensure_settings_initialized(self): 

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

368 # Check if we have any settings at all 

369 from ..database.models import Setting 

370 

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

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

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

374 

375 if settings_count == 0: 

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

377 self.load_from_defaults_file(commit=True) 

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

379 

380 def _check_thread_safety(self): 

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

382 current_thread_id = threading.get_ident() 

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

384 raise RuntimeError( 

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

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

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

388 ) 

389 

390 @property 

391 def settings_locked(self) -> bool: 

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

393 if self.__settings_locked is None: 

394 try: 

395 self.__settings_locked = self.get_setting( 

396 "app.lock_settings", False 

397 ) 

398 if self.settings_locked: 

399 logger.info( 

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

401 ) 

402 except Exception: 

403 logger.warning( 

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

405 ) 

406 self.__settings_locked = False 

407 return bool(self.__settings_locked) 

408 

409 @functools.cached_property 

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

411 """ 

412 Returns: 

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

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

415 directory and its subdirectories. 

416 Theme options are dynamically injected from the theme registry. 

417 

418 """ 

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

420 

421 try: 

422 # Get the defaults package path 

423 defaults_path = Path(defaults.__file__).parent 

424 

425 # Find all JSON files recursively in the defaults directory 

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

427 

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

429 

430 # Load and merge all JSON files 

431 for json_file in json_files: 

432 try: 

433 with open(json_file, "r", encoding="utf-8-sig") as f: 

434 file_settings = json.load(f) 

435 

436 # Get relative path for logging 

437 relative_path = json_file.relative_to(defaults_path) 

438 

439 # Warn about key conflicts 

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

441 if conflicts: 

442 logger.warning( 

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

444 f"override existing values" 

445 ) 

446 

447 settings.update(file_settings) 

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

449 

450 except json.JSONDecodeError: 

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

452 except Exception: 

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

454 

455 except Exception: 

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

457 

458 # Inject dynamic theme options from theme registry 

459 if "app.theme" in settings: 

460 try: 

461 from local_deep_research.web.themes import theme_registry 

462 

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

464 theme_registry.get_settings_options() 

465 ) 

466 except ImportError: 

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

468 pass 

469 

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

471 if "search.search_strategy" in settings: 

472 from local_deep_research.constants import get_available_strategies 

473 

474 # Check the show_all_strategies setting to decide which list 

475 show_all = False 

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

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

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

479 

480 strategies = get_available_strategies(show_all=show_all) 

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

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

483 ] 

484 

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

486 return settings 

487 

488 def __get_typed_setting_value( 

489 self, 

490 setting: Setting, 

491 default: Any = None, 

492 check_env: bool = True, 

493 ) -> Any: 

494 """ 

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

496 correct type. 

497 

498 Args: 

499 setting: The setting to get the value for. 

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

501 invalid. 

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

503 this setting before reading from the DB. 

504 

505 Returns: 

506 The value of the setting. 

507 

508 """ 

509 return get_typed_setting_value( 

510 str(setting.key), 

511 setting.value, 

512 str(setting.ui_element), 

513 default=default, 

514 check_env=check_env, 

515 ) 

516 

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

518 """ 

519 Abstraction for querying settings that also transparently handles 

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

521 

522 Args: 

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

524 

525 Returns: 

526 The settings it queried. 

527 

528 """ 

529 if self.db_session: 

530 self._check_thread_safety() 

531 query = self.db_session.query(Setting) 

532 if key is not None: 

533 # This will find exact matches and any subkeys. 

534 query = query.filter( 

535 or_( 

536 Setting.key == key, 

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

538 ) 

539 ) 

540 return query.all() 

541 

542 logger.debug( 

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

544 ) 

545 

546 settings = [] 

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

548 if key is None or ( 

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

550 ): 

551 settings.append( 

552 Setting( 

553 key=candidate_key, # gitleaks:allow 

554 **_filter_setting_columns(setting), 

555 ) 

556 ) 

557 

558 return settings 

559 

560 def get_setting( 

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

562 ) -> Any: 

563 """ 

564 Get a setting value 

565 

566 Args: 

567 key: Setting key 

568 default: Default value if setting is not found 

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

570 this setting before reading from the DB. 

571 

572 Returns: 

573 Setting value or default if not found 

574 """ 

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

576 logger.error( 

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

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

579 key, 

580 ) 

581 raise RuntimeError( 

582 "SettingsManager has been closed. " 

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

584 ) 

585 

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

587 if env_registry.is_env_only(key): 

588 return env_registry.get(key, default) 

589 

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

591 try: 

592 settings = self.__query_settings(key) 

593 if len(settings) == 1: 

594 # This is a bottom-level key. 

595 return self.__get_typed_setting_value( 

596 settings[0], default, check_env 

597 ) 

598 # Cache the result 

599 if len(settings) > 1: 

600 # This is a higher-level key. 

601 settings_map = {} 

602 for setting in settings: 

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

604 settings_map[output_key] = self.__get_typed_setting_value( 

605 setting, default, check_env 

606 ) 

607 return settings_map 

608 except SQLAlchemyError: 

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

610 

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

612 if check_env: 

613 env_value = check_env_setting(key) 

614 if env_value is not None: 

615 default_meta = self.default_settings.get(key) 

616 if default_meta and isinstance(default_meta, dict): 

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

618 return get_typed_setting_value( 

619 key, 

620 None, 

621 ui_element, 

622 default=default, 

623 check_env=True, 

624 ) 

625 logger.warning( 

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

627 "defaults — returning raw string without type " 

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

629 "file with a ui_element type to enable proper " 

630 "type conversion.", 

631 key, 

632 ) 

633 return env_value 

634 

635 # Return default if not found 

636 return default 

637 

638 def get_bool_setting( 

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

640 ) -> bool: 

641 """ 

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

643 

644 Args: 

645 key: Setting key 

646 default: Default boolean value if setting is not found 

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

648 this setting before reading from the DB. 

649 

650 Returns: 

651 Boolean value of the setting 

652 """ 

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

654 return to_bool(value, default) 

655 

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

657 """ 

658 Set a setting value 

659 

660 Args: 

661 key: Setting key 

662 value: Setting value 

663 commit: Whether to commit the change 

664 

665 Returns: 

666 True if successful, False otherwise 

667 """ 

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

669 logger.error( 

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

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

672 key, 

673 ) 

674 raise RuntimeError( 

675 "SettingsManager has been closed. " 

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

677 ) 

678 if not self.db_session: 

679 logger.error( 

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

681 ) 

682 return False 

683 if self.settings_locked: 

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

685 return False 

686 

687 # Always update database if available 

688 try: 

689 self._check_thread_safety() 

690 setting = ( 

691 self.db_session.query(Setting) 

692 .filter(Setting.key == key) 

693 .first() 

694 ) 

695 if setting: 

696 if not setting.editable: 

697 logger.error( 

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

699 "is marked as non-editable.", 

700 key, 

701 ) 

702 return False 

703 

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

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

706 func.now() 

707 ) # Explicitly set the current timestamp 

708 

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

710 setting.ui_element = _infer_ui_element( 

711 value, setting.ui_element 

712 ) 

713 

714 # Self-heal stale type from before the prefix dispatch was 

715 # added (e.g. legacy chat.* rows created with type=APP). 

716 # Also re-points category to the canonical per-prefix 

717 # value, since a row with the wrong type column was 

718 # almost certainly created before category dispatch was 

719 # in place either. 

720 inferred_type: Optional[SettingType] = None 

721 inferred_category: Optional[str] = None 

722 for prefix, category in _INFERRED_CATEGORY.items(): 

723 if key.startswith(prefix): 

724 if prefix == "llm.": 

725 inferred_type = SettingType.LLM 

726 elif prefix == "search.": 726 ↛ 727line 726 didn't jump to line 727 because the condition on line 726 was never true

727 inferred_type = SettingType.SEARCH 

728 elif prefix == "report.": 728 ↛ 729line 728 didn't jump to line 729 because the condition on line 728 was never true

729 inferred_type = SettingType.REPORT 

730 elif prefix == "database.": 730 ↛ 731line 730 didn't jump to line 731 because the condition on line 730 was never true

731 inferred_type = SettingType.DATABASE 

732 elif prefix == "chat.": 732 ↛ 734line 732 didn't jump to line 734 because the condition on line 732 was always true

733 inferred_type = SettingType.CHAT 

734 inferred_category = category 

735 break 

736 # Only self-heal when the key matches a known prefix. Keys 

737 # outside the dispatch map (e.g. focused_iteration.*, 

738 # langgraph_agent.* which ship as type=SEARCH) must keep their 

739 # shipped type — defaulting to APP here would wrongly demote 

740 # them on every edit. 

741 if inferred_type is not None and setting.type != inferred_type: 

742 setting.type = inferred_type # type: ignore[assignment] 

743 if inferred_category is not None: 743 ↛ 773line 743 didn't jump to line 773 because the condition on line 743 was always true

744 setting.category = inferred_category # type: ignore[assignment] 

745 else: 

746 # Determine setting type from key 

747 setting_type = SettingType.APP 

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

749 setting_type = SettingType.LLM 

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

751 setting_type = SettingType.SEARCH 

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

753 setting_type = SettingType.REPORT 

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

755 setting_type = SettingType.DATABASE 

756 elif key.startswith("chat."): 756 ↛ 757line 756 didn't jump to line 757 because the condition on line 756 was never true

757 setting_type = SettingType.CHAT 

758 

759 # Infer ui_element from the value type 

760 ui_element = _infer_ui_element(value) 

761 

762 # Create a new setting 

763 new_setting = Setting( 

764 key=key, 

765 value=value, 

766 type=setting_type, 

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

768 ui_element=ui_element, 

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

770 ) 

771 self.db_session.add(new_setting) 

772 

773 if commit: 

774 self.db_session.commit() 

775 # Emit WebSocket event for settings change 

776 self._emit_settings_changed([key]) 

777 

778 return True 

779 except SQLAlchemyError: 

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

781 self.db_session.rollback() 

782 return False 

783 

784 def clear_cache(self): 

785 """Clear the settings cache.""" 

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

787 logger.debug("Settings cache cleared") 

788 

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

790 """ 

791 Get all settings, merging defaults with database values. 

792 

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

794 appear in the UI without requiring a database reset. 

795 

796 Args: 

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

798 

799 Returns: 

800 Dictionary of all settings 

801 """ 

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

803 logger.error( 

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

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

806 ) 

807 raise RuntimeError( 

808 "SettingsManager has been closed. " 

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

810 ) 

811 

812 result = {} 

813 

814 # Start with defaults so new settings are always included 

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

816 result[key] = dict(default_setting) 

817 

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

819 env_value = check_env_setting(key) 

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

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

822 typed_value = get_typed_setting_value( 

823 key, 

824 None, 

825 ui_element, 

826 default=env_value, 

827 check_env=True, 

828 ) 

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

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

831 

832 # Override with database settings 

833 try: 

834 db_settings = self.__query_settings() 

835 except SQLAlchemyError: 

836 logger.exception( 

837 "Error querying settings from database in get_all_settings" 

838 ) 

839 db_settings = [] 

840 

841 for setting in db_settings: 

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

843 setting_type = setting.type 

844 if hasattr(setting_type, "name"): 

845 setting_type = setting_type.name 

846 

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

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

849 logger.debug( 

850 f"Database contains custom setting not in " 

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

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

853 ) 

854 

855 # Override default with database value 

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

857 "value": setting.value, 

858 "type": setting_type, 

859 "name": setting.name, 

860 "description": setting.description, 

861 "category": setting.category, 

862 "ui_element": setting.ui_element, 

863 "options": setting.options, 

864 "min_value": setting.min_value, 

865 "max_value": setting.max_value, 

866 "step": setting.step, 

867 "visible": setting.visible, 

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

869 } 

870 

871 # Override from the environment variables if needed. 

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

873 if env_value is not None: 

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

875 "ui_element", setting.ui_element 

876 ) 

877 typed_value = get_typed_setting_value( 

878 str(setting.key), 

879 None, 

880 ui_element, 

881 default=env_value, 

882 check_env=True, 

883 ) 

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

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

886 # value have no effect as long as the environment 

887 # variable is set. 

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

889 

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

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

892 if "search.search_strategy" in result: 

893 from local_deep_research.constants import get_available_strategies 

894 

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

896 "value", False 

897 ) 

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

899 strategies = get_available_strategies(show_all=show_all) 

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

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

902 ] 

903 

904 return result 

905 

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

907 """ 

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

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

910 

911 Returns: 

912 Dictionary with setting keys mapped to their values 

913 """ 

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

915 logger.error( 

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

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

918 ) 

919 raise RuntimeError( 

920 "SettingsManager has been closed. " 

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

922 ) 

923 

924 all_settings = self.get_all_settings() 

925 settings_snapshot = {} 

926 

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

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

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

930 else: 

931 settings_snapshot[key] = setting 

932 

933 return settings_snapshot 

934 

935 def create_or_update_setting( 

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

937 ) -> Optional[Setting]: 

938 """ 

939 Create or update a setting 

940 

941 Args: 

942 setting: Setting object or dictionary 

943 commit: Whether to commit the change 

944 

945 Returns: 

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

947 """ 

948 if not self.db_session: 

949 logger.warning( 

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

951 ) 

952 return None 

953 if self.settings_locked: 

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

955 return None 

956 

957 # Convert dict to BaseSetting if needed 

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

959 # Determine type from key if not specified 

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

961 setting_obj: BaseSetting 

962 key = setting["key"] 

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

964 setting_obj = LLMSetting(**setting) 

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

966 setting_obj = SearchSetting(**setting) 

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

968 setting_obj = ReportSetting(**setting) 

969 elif key.startswith("chat."): 969 ↛ 970line 969 didn't jump to line 970 because the condition on line 969 was never true

970 setting_obj = ChatSetting(**setting) 

971 elif key.startswith("app."): 

972 setting_obj = AppSetting(**setting) 

973 else: 

974 # Keys outside the four buckets (e.g. local_search_*, 

975 # embeddings.*, rag.*) live in their own namespaces. 

976 # Use BaseSetting so the key is written verbatim — 

977 # AppSetting's validator would otherwise prepend 

978 # `app.` and silently relocate the row away from 

979 # where every reader looks it up. See #4208. 

980 setting_obj = BaseSetting(type=SettingType.APP, **setting) 

981 else: 

982 # Use generic BaseSetting 

983 setting_obj = BaseSetting(**setting) 

984 else: 

985 setting_obj = setting 

986 

987 try: 

988 # Check if setting exists 

989 db_setting = ( 

990 self.db_session.query(Setting) 

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

992 .first() 

993 ) 

994 

995 if db_setting: 

996 # Update existing setting 

997 if not db_setting.editable: 

998 logger.error( 

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

1000 "is marked as non-editable.", 

1001 setting_obj.key, 

1002 ) 

1003 return None 

1004 

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

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

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

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

1009 db_setting.type = setting_obj.type # type: ignore[assignment] 

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

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

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

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

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

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

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

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

1018 func.now() 

1019 ) # Explicitly set the current timestamp 

1020 else: 

1021 # Create new setting 

1022 db_setting = Setting( 

1023 key=setting_obj.key, 

1024 value=setting_obj.value, 

1025 type=setting_obj.type, 

1026 name=setting_obj.name, 

1027 description=setting_obj.description, 

1028 category=setting_obj.category, 

1029 ui_element=setting_obj.ui_element, 

1030 options=setting_obj.options, 

1031 min_value=setting_obj.min_value, 

1032 max_value=setting_obj.max_value, 

1033 step=setting_obj.step, 

1034 visible=setting_obj.visible, 

1035 editable=setting_obj.editable, 

1036 ) 

1037 self.db_session.add(db_setting) 

1038 

1039 if commit: 

1040 self.db_session.commit() 

1041 # Emit WebSocket event for settings change 

1042 self._emit_settings_changed([setting_obj.key]) 

1043 

1044 return db_setting 

1045 

1046 except SQLAlchemyError: 

1047 logger.exception( 

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

1049 ) 

1050 self.db_session.rollback() 

1051 return None 

1052 

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

1054 """ 

1055 Delete a setting 

1056 

1057 Args: 

1058 key: Setting key 

1059 commit: Whether to commit the change 

1060 

1061 Returns: 

1062 True if successful, False otherwise 

1063 """ 

1064 if not self.db_session: 

1065 logger.warning( 

1066 "No database session available, cannot delete setting" 

1067 ) 

1068 return False 

1069 

1070 try: 

1071 # Remove from database 

1072 result = ( 

1073 self.db_session.query(Setting) 

1074 .filter(Setting.key == key) 

1075 .delete() 

1076 ) 

1077 

1078 if commit: 

1079 self.db_session.commit() 

1080 

1081 return result > 0 

1082 except SQLAlchemyError: 

1083 logger.exception("Error deleting setting") 

1084 self.db_session.rollback() 

1085 return False 

1086 

1087 def load_from_defaults_file( 

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

1089 ) -> None: 

1090 """ 

1091 Import settings from the defaults settings file. 

1092 

1093 Args: 

1094 commit: Whether to commit changes to database. The post-login 

1095 atomic block in `web/auth/routes.py` passes ``commit=False`` 

1096 and combines this call with ``update_db_version(commit=False)`` 

1097 under a single terminal ``db_session.commit()`` — preserving 

1098 the all-or-nothing invariant is what prevents the sticky-loop 

1099 bug where `app.version` is missing after a partial write. 

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

1101 

1102 """ 

1103 start = time.perf_counter() 

1104 row_count = len(self.default_settings) 

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

1106 elapsed_ms = (time.perf_counter() - start) * 1000 

1107 if elapsed_ms > 100: 

1108 logger.info( 

1109 f"load_from_defaults_file imported {row_count} settings " 

1110 f"in {elapsed_ms:.0f}ms (commit={commit})" 

1111 ) 

1112 else: 

1113 logger.debug( 

1114 f"load_from_defaults_file imported {row_count} settings " 

1115 f"in {elapsed_ms:.0f}ms (commit={commit})" 

1116 ) 

1117 

1118 def db_version_matches_package(self) -> bool: 

1119 """ 

1120 Returns: 

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

1122 

1123 """ 

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

1125 logger.debug( 

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

1127 f"settings from version {package_version}." 

1128 ) 

1129 

1130 return bool(db_version == package_version) 

1131 

1132 def update_db_version(self, commit: bool = True) -> None: 

1133 """ 

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

1135 

1136 Args: 

1137 commit: Whether to commit the version write to the database. 

1138 Callers that want to combine this with other writes into 

1139 a single atomic transaction should pass commit=False and 

1140 commit the session themselves. The post-login block in 

1141 `web/auth/routes.py` relies on this to bundle the defaults 

1142 import and the `app.version` write into one SQLite 

1143 transaction; splitting them risks the sticky-loop state 

1144 where `app.version` never gets written. 

1145 """ 

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

1147 

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

1149 version = Setting( 

1150 key="app.version", 

1151 value=package_version, 

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

1153 editable=False, 

1154 name="App Version", 

1155 type=SettingType.APP, 

1156 ui_element="text", 

1157 visible=False, 

1158 ) 

1159 

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

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

1162 self.db_session.add(version) 

1163 if commit: 

1164 self.db_session.commit() 

1165 

1166 def import_settings( 

1167 self, 

1168 settings_data: Dict[str, Any], 

1169 commit: bool = True, 

1170 overwrite: bool = True, 

1171 delete_extra: bool = False, 

1172 ) -> None: 

1173 """ 

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

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

1176 

1177 Args: 

1178 settings_data: The raw settings data to import. 

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

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

1181 are already in the database. 

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

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

1184 `settings_data`. 

1185 

1186 """ 

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

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

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

1190 

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

1192 setting_values = dict(setting_values) 

1193 if not overwrite: 

1194 existing_value = self.get_setting(key) 

1195 if existing_value is not None: 

1196 # Preserve the value from this setting. 

1197 setting_values["value"] = existing_value 

1198 

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

1200 self.delete_setting(key, commit=False) 

1201 

1202 # Convert type string to SettingType enum if needed 

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

1204 setting_values["type"], str 

1205 ): 

1206 setting_values["type"] = SettingType[setting_values["type"]] 

1207 

1208 setting = Setting( 

1209 key=key, **_filter_setting_columns(setting_values) 

1210 ) 

1211 self.db_session.add(setting) 

1212 

1213 if commit or delete_extra: 

1214 self.db_session.commit() 

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

1216 # Emit WebSocket event for all imported settings 

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

1218 

1219 if delete_extra: 

1220 all_settings = self.get_all_settings() 

1221 for key in all_settings: 

1222 if key not in settings_data: 

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

1224 self.delete_setting(key, commit=False) 

1225 

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

1227 """Create a setting with appropriate metadata""" 

1228 

1229 # Determine appropriate category 

1230 category = None 

1231 ui_element = "text" 

1232 

1233 # Determine category based on key pattern 

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

1235 category = "app_interface" 

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

1237 if any( 

1238 param in key 

1239 for param in [ 

1240 "temperature", 

1241 "max_tokens", 

1242 "n_batch", 

1243 "n_gpu_layers", 

1244 ] 

1245 ): 

1246 category = "llm_parameters" 

1247 else: 

1248 category = "llm_general" 

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

1250 if any( 

1251 param in key 

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

1253 ): 

1254 category = "search_parameters" 

1255 else: 

1256 category = "search_general" 

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

1258 category = "report_parameters" 

1259 

1260 # Determine UI element type based on value 

1261 ui_element = _infer_ui_element(value) 

1262 

1263 # Build setting object 

1264 setting_dict = { 

1265 "key": key, 

1266 "value": value, 

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

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

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

1270 "category": category, 

1271 "ui_element": ui_element, 

1272 } 

1273 

1274 # Create the setting in the database 

1275 self.create_or_update_setting(setting_dict, commit=False) 

1276 

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

1278 """ 

1279 Emit WebSocket event when settings change 

1280 

1281 Args: 

1282 changed_keys: List of setting keys that changed 

1283 """ 

1284 try: 

1285 # Import here to avoid circular imports 

1286 from ..web.services.socket_service import SocketIOService 

1287 

1288 try: 

1289 socket_service = SocketIOService() 

1290 except ValueError: 

1291 logger.debug( 

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

1293 ) 

1294 return 

1295 

1296 # Get the changed settings 

1297 settings_data = {} 

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

1299 for key in changed_keys: 

1300 setting_value = self.get_setting(key) 

1301 if setting_value is not None: 

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

1303 

1304 # Emit the settings change event 

1305 from datetime import datetime, UTC 

1306 

1307 socket_service.emit_socket_event( 

1308 "settings_changed", 

1309 { 

1310 "changed_keys": changed_keys or [], 

1311 "settings": settings_data, 

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

1313 }, 

1314 ) 

1315 

1316 logger.debug( 

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

1318 ) 

1319 

1320 except Exception: 

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

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

1323 

1324 @staticmethod 

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

1326 """ 

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

1328 These are critical for system initialization. 

1329 

1330 Returns: 

1331 Dict mapping env var names to their descriptions 

1332 """ 

1333 # Get bootstrap vars from env registry 

1334 return env_registry.get_bootstrap_vars() 

1335 

1336 @staticmethod 

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

1338 """ 

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

1340 

1341 Args: 

1342 env_var: Environment variable name 

1343 

1344 Returns: 

1345 True if this is a bootstrap variable 

1346 """ 

1347 bootstrap_vars = SettingsManager.get_bootstrap_env_vars() 

1348 return env_var in bootstrap_vars 

1349 

1350 @staticmethod 

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

1352 """ 

1353 Check if a setting key is environment-only. 

1354 

1355 Args: 

1356 key: Setting key to check 

1357 

1358 Returns: 

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

1360 """ 

1361 return env_registry.is_env_only(key) 

1362 

1363 @staticmethod 

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

1365 """ 

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

1367 

1368 Args: 

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

1370 

1371 Returns: 

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

1373 """ 

1374 # Use the same logic as check_env_setting for consistency 

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

1376 

1377 @staticmethod 

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

1379 """ 

1380 Get the setting key for a given environment variable. 

1381 

1382 Args: 

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

1384 

1385 Returns: 

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

1387 """ 

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

1389 return None 

1390 

1391 # Remove LDR_ prefix and convert to lowercase 

1392 without_prefix = env_var[4:] 

1393 parts = without_prefix.split("_") 

1394 

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

1396 

1397 

1398class SnapshotSettingsContext: 

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

1400 

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

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

1403 """ 

1404 

1405 def __init__( 

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

1407 ): 

1408 self.snapshot = snapshot or {} 

1409 self.username = username 

1410 self._missing_key_log_level = missing_key_log_level 

1411 self.values = {} 

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

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

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

1415 else: 

1416 self.values[key] = setting 

1417 

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

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

1420 if key in self.values: 

1421 return self.values[key] 

1422 logger.log( 

1423 self._missing_key_log_level, 

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

1425 key, 

1426 ) 

1427 return default