Coverage for src / ezpl / config / manager.py: 90.24%

132 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-13 19:35 +0000

1# /////////////////////////////////////////////////////////////// 

2# EZPL - Configuration Manager 

3# Project: ezpl 

4# /////////////////////////////////////////////////////////////// 

5 

6""" 

7Configuration manager for Ezpl logging framework. 

8 

9This module provides centralized configuration management with support for 

10file-based configuration, environment variables, and runtime configuration. 

11""" 

12 

13from __future__ import annotations 

14 

15# /////////////////////////////////////////////////////////////// 

16# IMPORTS 

17# /////////////////////////////////////////////////////////////// 

18# Standard library imports 

19import json 

20import os 

21import shlex 

22import warnings 

23from pathlib import Path 

24from typing import Any, cast 

25 

26# Local imports 

27from ..core.exceptions import FileOperationError 

28from .defaults import DefaultConfiguration 

29 

30# /////////////////////////////////////////////////////////////// 

31# CLASSES 

32# /////////////////////////////////////////////////////////////// 

33 

34 

35class ConfigurationManager: 

36 """ 

37 Centralized configuration manager for Ezpl. 

38 

39 This class handles all configuration operations including loading, 

40 saving, and merging configuration from multiple sources. 

41 """ 

42 

43 ENV_MAPPINGS = { 

44 "EZPL_LOG_LEVEL": "log-level", 

45 "EZPL_LOG_FILE": "log-file", 

46 "EZPL_LOG_DIR": "log-dir", 

47 "EZPL_PRINTER_LEVEL": "printer-level", 

48 "EZPL_INDENT_STEP": "indent-step", 

49 "EZPL_INDENT_SYMBOL": "indent-symbol", 

50 "EZPL_BASE_INDENT_SYMBOL": "base-indent-symbol", 

51 "EZPL_FILE_LOGGER_LEVEL": "file-logger-level", 

52 "EZPL_LOG_FORMAT": "log-format", 

53 "EZPL_LOG_ROTATION": "log-rotation", 

54 "EZPL_LOG_RETENTION": "log-retention", 

55 "EZPL_LOG_COMPRESSION": "log-compression", 

56 } 

57 

58 # /////////////////////////////////////////////////////////////// 

59 # INIT 

60 # /////////////////////////////////////////////////////////////// 

61 

62 def __init__(self, config_file: Path | None = None): 

63 """ 

64 Initialize the configuration manager. 

65 

66 Args: 

67 config_file: Optional path to configuration file. 

68 Defaults to ~/.ezpl/config.json 

69 """ 

70 self._config_file = config_file or DefaultConfiguration.CONFIG_FILE 

71 self._config: dict[str, Any] = {} 

72 self._load_configuration() 

73 

74 # ------------------------------------------------ 

75 # PRIVATE HELPER METHODS 

76 # ------------------------------------------------ 

77 

78 def _load_configuration(self) -> None: 

79 """ 

80 Load configuration from file and environment variables. 

81 

82 Priority order: 

83 1. Environment variables (highest priority) 

84 2. Configuration file 

85 3. Default values (lowest priority) 

86 """ 

87 # Start with defaults 

88 self._config = DefaultConfiguration.get_all_defaults().copy() 

89 

90 # Load from file if it exists 

91 if self._config_file.exists(): 

92 try: 

93 with open(self._config_file, encoding="utf-8") as f: 

94 file_config = json.load(f) 

95 self._config.update(file_config) 

96 except (OSError, json.JSONDecodeError) as e: 

97 # If file is corrupted, use defaults 

98 warnings.warn( 

99 f"Could not load config file {self._config_file}: {e}", 

100 UserWarning, 

101 stacklevel=2, 

102 ) 

103 

104 # Override with environment variables 

105 self._load_from_environment() 

106 

107 def _load_from_environment(self) -> None: 

108 """ 

109 Load configuration from environment variables. 

110 

111 Environment variables should be prefixed with 'EZPL_' and use 

112 uppercase with underscores (e.g., EZPL_LOG_LEVEL). 

113 """ 

114 user_env_vars = self._load_user_env_file() 

115 

116 for env_var, config_key in self.ENV_MAPPINGS.items(): 

117 value = os.getenv(env_var) 

118 if value is None: 

119 value = user_env_vars.get(env_var) 

120 if value is not None: 

121 # Convert string values to appropriate types 

122 if config_key in ["indent-step"]: 122 ↛ 123line 122 didn't jump to line 123 because the condition on line 122 was never true

123 try: 

124 self._config[config_key] = int(value) 

125 except ValueError as e: 

126 raise ValueError( 

127 f"Failed to convert {value} to int: {e}" 

128 ) from e 

129 else: 

130 self._config[config_key] = value 

131 

132 def _load_user_env_file(self) -> dict[str, str]: 

133 """ 

134 Load user-level EZPL environment variables from ~/.ezpl/.env. 

135 

136 Returns: 

137 Dictionary of environment variables found in user env file. 

138 """ 

139 env_file = DefaultConfiguration.CONFIG_DIR / ".env" 

140 env_vars: dict[str, str] = {} 

141 

142 if not env_file.exists(): 

143 return env_vars 

144 

145 try: 

146 with open(env_file, encoding="utf-8") as f: 

147 for line in f: 

148 stripped = line.strip() 

149 if not stripped or stripped.startswith("#") or "=" not in stripped: 149 ↛ 150line 149 didn't jump to line 150 because the condition on line 149 was never true

150 continue 

151 key, value = stripped.split("=", 1) 

152 env_vars[key.strip()] = value.strip() 

153 except OSError: 

154 return {} 

155 

156 return env_vars 

157 

158 # /////////////////////////////////////////////////////////////// 

159 # GETTER 

160 # /////////////////////////////////////////////////////////////// 

161 

162 @property 

163 def config_file(self) -> Path: 

164 """Return the path to the configuration file.""" 

165 return self._config_file 

166 

167 def get(self, key: str, default: Any = None) -> Any: 

168 """ 

169 Get a configuration value. 

170 

171 Args: 

172 key: Configuration key 

173 default: Default value if key not found 

174 

175 Returns: 

176 Configuration value or default 

177 """ 

178 return self._config.get(key, default) 

179 

180 def has_key(self, key: str) -> bool: 

181 """ 

182 Check if a configuration key is explicitly set (not just a default). 

183 

184 Args: 

185 key: Configuration key to check 

186 

187 Returns: 

188 True if the key is explicitly set in config or environment, False otherwise 

189 """ 

190 # Check if key exists in config (from file or explicitly set) 

191 if key in self._config: 191 ↛ 194line 191 didn't jump to line 194 because the condition on line 191 was always true

192 return True 

193 # Check if corresponding environment variable exists 

194 env_key = f"EZPL_{key.replace('-', '_').upper()}" 

195 return env_key in os.environ 

196 

197 def get_log_level(self) -> str: 

198 """Get the current log level.""" 

199 return cast(str, self.get("log-level", DefaultConfiguration.LOG_LEVEL)) 

200 

201 def get_log_file(self) -> Path: 

202 """Get the current log file path.""" 

203 log_file = self.get("log-file", DefaultConfiguration.LOG_FILE) 

204 log_dir = self.get("log-dir", DefaultConfiguration.LOG_DIR) 

205 

206 # Convert to Path if string 

207 log_file_path = Path(log_file) if isinstance(log_file, str) else log_file 

208 log_dir_path = Path(log_dir) if isinstance(log_dir, str) else log_dir 

209 

210 # If log_file is relative, make it relative to log_dir 

211 if not log_file_path.is_absolute(): 211 ↛ 213line 211 didn't jump to line 213 because the condition on line 211 was always true

212 return log_dir_path / log_file_path 

213 return log_file_path 

214 

215 def get_printer_level(self) -> str: 

216 """Get the current printer level.""" 

217 return cast(str, self.get("printer-level", DefaultConfiguration.PRINTER_LEVEL)) 

218 

219 def get_file_logger_level(self) -> str: 

220 """Get the current file logger level.""" 

221 return cast( 

222 str, self.get("file-logger-level", DefaultConfiguration.FILE_LOGGER_LEVEL) 

223 ) 

224 

225 def get_indent_step(self) -> int: 

226 """Get the current indent step.""" 

227 return cast(int, self.get("indent-step", DefaultConfiguration.INDENT_STEP)) 

228 

229 def get_indent_symbol(self) -> str: 

230 """Get the current indent symbol.""" 

231 return cast(str, self.get("indent-symbol", DefaultConfiguration.INDENT_SYMBOL)) 

232 

233 def get_base_indent_symbol(self) -> str: 

234 """Get the current base indent symbol.""" 

235 return cast( 

236 str, self.get("base-indent-symbol", DefaultConfiguration.BASE_INDENT_SYMBOL) 

237 ) 

238 

239 def get_log_format(self) -> str: 

240 """Get the current log format.""" 

241 return cast(str, self.get("log-format", DefaultConfiguration.LOG_FORMAT)) 

242 

243 def get_log_rotation(self) -> str | None: 

244 """Get the current log rotation setting.""" 

245 return cast( 

246 str | None, self.get("log-rotation", DefaultConfiguration.LOG_ROTATION) 

247 ) 

248 

249 def get_log_retention(self) -> str | None: 

250 """Get the current log retention setting.""" 

251 return cast( 

252 str | None, self.get("log-retention", DefaultConfiguration.LOG_RETENTION) 

253 ) 

254 

255 def get_log_compression(self) -> str | None: 

256 """Get the current log compression setting.""" 

257 return cast( 

258 str | None, 

259 self.get("log-compression", DefaultConfiguration.LOG_COMPRESSION), 

260 ) 

261 

262 def get_all(self) -> dict[str, Any]: 

263 """ 

264 Get all configuration values. 

265 

266 Returns: 

267 Dictionary containing all configuration values 

268 """ 

269 return self._config.copy() 

270 

271 # /////////////////////////////////////////////////////////////// 

272 # SETTER 

273 # /////////////////////////////////////////////////////////////// 

274 

275 def set(self, key: str, value: Any) -> None: 

276 """ 

277 Set a configuration value. 

278 

279 Args: 

280 key: Configuration key 

281 value: Configuration value 

282 """ 

283 self._config[key] = value 

284 

285 def update(self, config_dict: dict[str, Any]) -> None: 

286 """ 

287 Update configuration with new values. 

288 

289 Args: 

290 config_dict: Dictionary of configuration values to update 

291 """ 

292 self._config.update(config_dict) 

293 

294 # /////////////////////////////////////////////////////////////// 

295 # FILE OPERATIONS 

296 # /////////////////////////////////////////////////////////////// 

297 

298 def save(self) -> None: 

299 """ 

300 Save current configuration to file. 

301 

302 Raises: 

303 FileOperationError: If unable to write to configuration file 

304 """ 

305 try: 

306 # Ensure config directory exists 

307 self._config_file.parent.mkdir(parents=True, exist_ok=True) 

308 

309 # Save configuration 

310 with open(self._config_file, "w", encoding="utf-8") as f: 

311 json.dump(self._config, f, indent=4, ensure_ascii=False) 

312 except OSError as e: 

313 raise FileOperationError( 

314 f"Could not save configuration to {self._config_file}: {e}", 

315 str(self._config_file), 

316 "save", 

317 ) from e 

318 

319 def reset_to_defaults(self) -> None: 

320 """Reset configuration to default values.""" 

321 self._config = DefaultConfiguration.get_all_defaults().copy() 

322 

323 def reload(self) -> None: 

324 """ 

325 Reload configuration from file and environment variables. 

326 

327 This method reloads the configuration, useful when environment 

328 variables or the config file have changed after initialization. 

329 """ 

330 self._load_configuration() 

331 

332 def export_to_script( 

333 self, output_file: str | Path, platform: str | None = None 

334 ) -> None: 

335 """ 

336 Export configuration as environment variables script. 

337 

338 Args: 

339 output_file: Path to output script file 

340 platform: Target platform ('windows', 'unix', or None for auto-detect) 

341 

342 Raises: 

343 FileOperationError: If unable to write to output file 

344 """ 

345 if platform is None: 

346 import sys 

347 

348 platform = "windows" if sys.platform == "win32" else "unix" 

349 

350 output_path = Path(output_file) 

351 

352 try: 

353 with open(output_path, "w", encoding="utf-8") as f: 

354 if platform == "windows": 

355 # Generate Batch script for Windows 

356 for env_var, config_key in self.ENV_MAPPINGS.items(): 

357 value = self._config.get(config_key) 

358 if value is None: 

359 continue 

360 f.write(f"set {env_var}={value}\n") 

361 else: 

362 # Generate Bash script for Unix/Linux/macOS 

363 f.write("#!/bin/bash\n") 

364 for env_var, config_key in self.ENV_MAPPINGS.items(): 

365 value = self._config.get(config_key) 

366 if value is None: 

367 continue 

368 f.write(f"export {env_var}={shlex.quote(str(value))}\n") 

369 except OSError as e: 

370 raise FileOperationError( 

371 f"Could not write to {output_path}: {e}", 

372 str(output_path), 

373 "export", 

374 ) from e 

375 

376 # /////////////////////////////////////////////////////////////// 

377 # REPRESENTATION METHODS 

378 # /////////////////////////////////////////////////////////////// 

379 

380 def __str__(self) -> str: 

381 """String representation of the configuration.""" 

382 return f"ConfigurationManager(config_file={self._config_file})" 

383 

384 def __repr__(self) -> str: 

385 """Detailed string representation of the configuration.""" 

386 return f"ConfigurationManager(config_file={self._config_file}, config={self._config})"