Coverage for src / local_deep_research / settings / env_settings.py: 98%

154 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-02-25 01:07 +0000

1""" 

2Environment-only settings that are loaded early and never stored in database. 

3 

4These settings are: 

51. Required before database initialization 

62. Used for testing/CI configuration 

73. System bootstrap configuration 

8 

9They are accessed through SettingsManager but always read from environment variables. 

10 

11Why some settings must be environment-only: 

12- Bootstrap settings (paths, encryption keys) are needed to initialize the database itself 

13- Database configuration settings must be available before connecting to the database 

14- Testing flags need to be checked before any database operations occur 

15- CI/CD variables control build-time behavior before the application starts 

16 

17These settings cannot be stored in the database because they are prerequisites for 

18accessing the database. This creates a bootstrapping requirement where certain 

19configuration must come from the environment to establish the system state needed 

20to access persisted settings. 

21""" 

22 

23import os 

24from pathlib import Path 

25from typing import Any, Dict, Optional, List, Set, TypeVar, Generic 

26from abc import ABC, abstractmethod 

27from loguru import logger 

28 

29 

30T = TypeVar("T") 

31 

32 

33class EnvSetting(ABC, Generic[T]): 

34 """Base class for all environment settings.""" 

35 

36 def __init__( 

37 self, 

38 key: str, 

39 description: str, 

40 default: Optional[T] = None, 

41 required: bool = False, 

42 deprecated_env_var: Optional[str] = None, 

43 ): 

44 self.key = key 

45 # Auto-generate env_var from key 

46 # e.g., "testing.test_mode" -> "LDR_TESTING_TEST_MODE" 

47 self.env_var = "LDR_" + key.upper().replace(".", "_") 

48 self.description = description 

49 self.default = default 

50 self.required = required 

51 self.deprecated_env_var = deprecated_env_var 

52 

53 def get_value(self) -> Optional[T]: 

54 """Get the value from environment with type conversion.""" 

55 raw = self._get_raw_value() 

56 if raw is None: 

57 if self.required and self.default is None: 

58 raise ValueError( 

59 f"Required environment variable {self.env_var} is not set" 

60 ) 

61 return self.default 

62 return self._convert_value(raw) 

63 

64 @abstractmethod 

65 def _convert_value(self, raw: str) -> T: 

66 """Convert raw string value to the appropriate type.""" 

67 pass 

68 

69 def _get_raw_value(self) -> Optional[str]: 

70 """Get raw string value from environment. 

71 

72 Checks the canonical env var first. If not set and a deprecated 

73 alias is configured, falls back to the deprecated name with a 

74 warning guiding users to migrate. 

75 """ 

76 value = os.environ.get(self.env_var) 

77 if value is not None: 

78 return value 

79 

80 if self.deprecated_env_var: 

81 deprecated_value = os.environ.get(self.deprecated_env_var) 

82 if deprecated_value is not None: 

83 logger.warning( 

84 f"Environment variable '{self.deprecated_env_var}' is deprecated " 

85 f"and will be removed in a future release. " 

86 f"Please use '{self.env_var}' instead." 

87 ) 

88 return deprecated_value 

89 

90 return None 

91 

92 @property 

93 def is_set(self) -> bool: 

94 """Check if the environment variable is set.""" 

95 return self.env_var in os.environ 

96 

97 def __repr__(self) -> str: 

98 """String representation for debugging.""" 

99 return f"{self.__class__.__name__}(key='{self.key}', env_var='{self.env_var}')" 

100 

101 

102class BooleanSetting(EnvSetting[bool]): 

103 """Boolean environment setting.""" 

104 

105 def __init__( 

106 self, 

107 key: str, 

108 description: str, 

109 default: bool = False, 

110 deprecated_env_var: Optional[str] = None, 

111 ): 

112 super().__init__( 

113 key, description, default, deprecated_env_var=deprecated_env_var 

114 ) 

115 

116 def _convert_value(self, raw: str) -> bool: 

117 """Convert string to boolean.""" 

118 return raw.lower() in ("true", "1", "yes", "on", "enabled") 

119 

120 

121class StringSetting(EnvSetting[str]): 

122 """String environment setting.""" 

123 

124 def __init__( 

125 self, 

126 key: str, 

127 description: str, 

128 default: Optional[str] = None, 

129 required: bool = False, 

130 deprecated_env_var: Optional[str] = None, 

131 ): 

132 super().__init__( 

133 key, 

134 description, 

135 default, 

136 required, 

137 deprecated_env_var=deprecated_env_var, 

138 ) 

139 

140 def _convert_value(self, raw: str) -> str: 

141 """Return string value as-is.""" 

142 return raw 

143 

144 

145class IntegerSetting(EnvSetting[int]): 

146 """Integer environment setting.""" 

147 

148 def __init__( 

149 self, 

150 key: str, 

151 description: str, 

152 default: Optional[int] = None, 

153 min_value: Optional[int] = None, 

154 max_value: Optional[int] = None, 

155 deprecated_env_var: Optional[str] = None, 

156 ): 

157 super().__init__( 

158 key, description, default, deprecated_env_var=deprecated_env_var 

159 ) 

160 self.min_value = min_value 

161 self.max_value = max_value 

162 

163 def _convert_value(self, raw: str) -> Optional[int]: 

164 """Convert string to integer with validation.""" 

165 try: 

166 value = int(raw) 

167 if self.min_value is not None and value < self.min_value: 

168 raise ValueError( 

169 f"{self.env_var} value {value} is below minimum {self.min_value}" 

170 ) 

171 if self.max_value is not None and value > self.max_value: 

172 raise ValueError( 

173 f"{self.env_var} value {value} is above maximum {self.max_value}" 

174 ) 

175 return value 

176 except ValueError as e: 

177 if "invalid literal" in str(e): 

178 logger.warning( 

179 f"Invalid integer value '{raw}' for {self.env_var}, using default: {self.default}" 

180 ) 

181 return self.default 

182 raise 

183 

184 

185class PathSetting(StringSetting): 

186 """Path environment setting with validation.""" 

187 

188 def __init__( 

189 self, 

190 key: str, 

191 description: str, 

192 default: Optional[str] = None, 

193 must_exist: bool = False, 

194 create_if_missing: bool = False, 

195 ): 

196 super().__init__(key, description, default) 

197 self.must_exist = must_exist 

198 self.create_if_missing = create_if_missing 

199 

200 def get_value(self) -> Optional[str]: 

201 """Get path value with optional validation/creation.""" 

202 path_str = super().get_value() 

203 if path_str is None: 

204 return None 

205 

206 # Use pathlib for path operations 

207 path = Path(path_str).expanduser() 

208 # Expand environment variables manually since pathlib doesn't have expandvars 

209 # Note: os.path.expandvars is kept here as there's no pathlib equivalent 

210 # noqa: PLR0402 - Suppress pathlib check for this line 

211 path_str = os.path.expandvars(str(path)) 

212 path = Path(path_str) 

213 

214 if self.create_if_missing and not path.exists(): 

215 try: 

216 path.mkdir(parents=True, exist_ok=True) 

217 except OSError: 

218 # Silently fail if we can't create - let app handle it 

219 pass 

220 elif self.must_exist and not path.exists(): 

221 # Only raise if explicitly required to exist 

222 raise ValueError( 

223 f"Path {path} specified in {self.env_var} does not exist" 

224 ) 

225 

226 return str(path) 

227 

228 

229class SecretSetting(StringSetting): 

230 """Secret/sensitive environment setting.""" 

231 

232 def __init__( 

233 self, 

234 key: str, 

235 description: str, 

236 default: Optional[str] = None, 

237 required: bool = False, 

238 ): 

239 super().__init__(key, description, default, required) 

240 

241 def __repr__(self) -> str: 

242 """Hide the value in string representation.""" 

243 return f"SecretSetting(key='{self.key}', value=***)" 

244 

245 def __str__(self) -> str: 

246 """Hide the value in string conversion.""" 

247 value = "SET" if self.is_set else "NOT SET" 

248 return f"{self.key}=<{value}>" 

249 

250 

251class EnumSetting(EnvSetting[str]): 

252 """Enum-style setting with allowed values.""" 

253 

254 def __init__( 

255 self, 

256 key: str, 

257 description: str, 

258 allowed_values: Set[str], 

259 default: Optional[str] = None, 

260 case_sensitive: bool = False, 

261 deprecated_env_var: Optional[str] = None, 

262 ): 

263 super().__init__( 

264 key, description, default, deprecated_env_var=deprecated_env_var 

265 ) 

266 self.allowed_values = allowed_values 

267 self.case_sensitive = case_sensitive 

268 

269 # Store lowercase versions for case-insensitive comparison 

270 if not case_sensitive: 

271 self._allowed_lower = {v.lower() for v in allowed_values} 

272 # Create a mapping from lowercase to original case 

273 self._canonical_map = {v.lower(): v for v in allowed_values} 

274 

275 def _convert_value(self, raw: str) -> str: 

276 """Convert and validate value against allowed values.""" 

277 if self.case_sensitive: 

278 if raw not in self.allowed_values: 

279 raise ValueError( 

280 f"{self.env_var} value '{raw}' not in allowed values: {self.allowed_values}" 

281 ) 

282 return raw 

283 else: 

284 # Case-insensitive matching 

285 raw_lower = raw.lower() 

286 if raw_lower not in self._allowed_lower: 

287 raise ValueError( 

288 f"{self.env_var} value '{raw}' not in allowed values: {self.allowed_values}" 

289 ) 

290 # Return the canonical version (from allowed_values) 

291 return self._canonical_map[raw_lower] 

292 

293 

294class SettingsRegistry: 

295 """Registry for all environment settings.""" 

296 

297 def __init__(self): 

298 self._settings: Dict[str, EnvSetting] = {} 

299 self._categories: Dict[str, List[EnvSetting]] = {} 

300 

301 def register_category(self, category: str, settings: List[EnvSetting]): 

302 """Register a category of settings.""" 

303 self._categories[category] = settings 

304 for setting in settings: 

305 self._settings[setting.key] = setting 

306 

307 def get(self, key: str, default: Optional[Any] = None) -> Any: 

308 """ 

309 Get a setting value. 

310 

311 Args: 

312 key: Setting key (e.g., "testing.test_mode") 

313 default: Default value if not set or on error 

314 

315 Returns: 

316 Setting value or default 

317 """ 

318 setting = self._settings.get(key) 

319 if not setting: 

320 return default 

321 

322 try: 

323 value = setting.get_value() 

324 # Use provided default if setting returns None 

325 return value if value is not None else default 

326 except ValueError: 

327 # On validation error, return default 

328 return default 

329 

330 def get_setting_object(self, key: str) -> Optional[EnvSetting]: 

331 """Get the setting object itself for introspection.""" 

332 return self._settings.get(key) 

333 

334 def is_env_only(self, key: str) -> bool: 

335 """Check if a key is an env-only setting.""" 

336 return key in self._settings 

337 

338 def get_env_var(self, key: str) -> Optional[str]: 

339 """Get the environment variable name for a key.""" 

340 setting = self._settings.get(key) 

341 return setting.env_var if setting else None 

342 

343 def get_all_env_vars(self) -> Dict[str, str]: 

344 """Get all environment variables and descriptions.""" 

345 return { 

346 setting.env_var: setting.description 

347 for setting in self._settings.values() 

348 } 

349 

350 def get_category_settings(self, category: str) -> List[EnvSetting]: 

351 """Get all settings in a category.""" 

352 return self._categories.get(category, []) 

353 

354 def get_bootstrap_vars(self) -> Dict[str, str]: 

355 """Get bootstrap environment variables (bootstrap + db_config).""" 

356 result = {} 

357 for category in ["bootstrap", "db_config"]: 

358 for setting in self._categories.get(category, []): 

359 result[setting.env_var] = setting.description 

360 return result 

361 

362 def get_testing_vars(self) -> Dict[str, str]: 

363 """Get testing environment variables.""" 

364 result = {} 

365 for setting in self._categories.get("testing", []): 

366 result[setting.env_var] = setting.description 

367 return result 

368 

369 def list_all_settings(self) -> List[str]: 

370 """List all registered setting keys.""" 

371 return list(self._settings.keys()) 

372 

373 

374# Export list for better IDE discovery 

375__all__ = [ 

376 "EnvSetting", 

377 "BooleanSetting", 

378 "StringSetting", 

379 "IntegerSetting", 

380 "PathSetting", 

381 "SecretSetting", 

382 "EnumSetting", 

383 "SettingsRegistry", 

384]