Coverage for src / ezplog / config / manager.py: 92.26%
138 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-03 16:27 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-03 16:27 +0000
1# ///////////////////////////////////////////////////////////////
2# EZPL - Configuration Manager
3# Project: ezpl
4# ///////////////////////////////////////////////////////////////
6"""
7Configuration manager for Ezpl logging framework.
9This module provides centralized configuration management with support for
10file-based configuration, environment variables, and runtime configuration.
11"""
13from __future__ import annotations
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
26# Local imports
27from ..core.exceptions import FileOperationError, ValidationError
28from ._defaults import DefaultConfiguration
30# ///////////////////////////////////////////////////////////////
31# CLASSES
32# ///////////////////////////////////////////////////////////////
35class ConfigurationManager:
36 """
37 Centralized configuration manager for Ezpl.
39 This class handles all configuration operations including loading,
40 saving, and merging configuration from multiple sources.
41 """
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 }
58 # ///////////////////////////////////////////////////////////////
59 # INIT
60 # ///////////////////////////////////////////////////////////////
62 def __init__(self, config_file: Path | None = None):
63 """
64 Initialize the configuration manager.
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._explicit_keys: set[str] = set()
73 self._load_configuration()
75 # ------------------------------------------------
76 # PRIVATE HELPER METHODS
77 # ------------------------------------------------
79 def _load_configuration(self) -> None:
80 """
81 Load configuration from file and environment variables.
83 Priority order:
84 1. Environment variables (highest priority)
85 2. Configuration file
86 3. Default values (lowest priority)
87 """
88 # Start with defaults — these are never considered "explicit"
89 self._config = DefaultConfiguration.get_all_defaults().copy()
90 self._explicit_keys = set()
92 # Load from file if it exists — keys from file are explicit
93 if self._config_file.exists():
94 try:
95 with open(self._config_file, encoding="utf-8") as f:
96 file_config = json.load(f)
97 self._config.update(file_config)
98 self._explicit_keys.update(file_config.keys())
99 except (OSError, json.JSONDecodeError) as e:
100 # If file is corrupted, use defaults
101 warnings.warn(
102 f"Could not load config file {self._config_file}: {e}",
103 UserWarning,
104 stacklevel=2,
105 )
107 # Override with environment variables — env keys are explicit
108 self._load_from_environment()
110 def _load_from_environment(self) -> None:
111 """
112 Load configuration from environment variables.
114 Environment variables should be prefixed with 'EZPL_' and use
115 uppercase with underscores (e.g., EZPL_LOG_LEVEL).
116 """
117 user_env_vars = self._load_user_env_file()
119 for env_var, config_key in self._ENV_MAPPINGS.items():
120 value = os.getenv(env_var)
121 if value is None:
122 value = user_env_vars.get(env_var)
123 if value is not None:
124 # Convert string values to appropriate types
125 if config_key in ["indent-step"]: 125 ↛ 126line 125 didn't jump to line 126 because the condition on line 125 was never true
126 try:
127 self._config[config_key] = int(value)
128 except ValueError as e:
129 raise ValidationError(
130 f"Environment variable {env_var} must be an integer, got: {value!r}",
131 env_var,
132 value,
133 ) from e
134 else:
135 self._config[config_key] = value
136 self._explicit_keys.add(config_key)
138 def _load_user_env_file(self) -> dict[str, str]:
139 """
140 Load user-level EZPL environment variables from ~/.ezpl/.env.
142 Returns:
143 Dictionary of environment variables found in user env file.
144 """
145 env_file = DefaultConfiguration.CONFIG_DIR / ".env"
146 env_vars: dict[str, str] = {}
148 if not env_file.exists():
149 return env_vars
151 try:
152 with open(env_file, encoding="utf-8") as f:
153 for line in f:
154 stripped = line.strip()
155 if not stripped or stripped.startswith("#") or "=" not in stripped: 155 ↛ 156line 155 didn't jump to line 156 because the condition on line 155 was never true
156 continue
157 key, value = stripped.split("=", 1)
158 env_vars[key.strip()] = value.strip()
159 except OSError:
160 return {}
162 return env_vars
164 # ///////////////////////////////////////////////////////////////
165 # GETTER
166 # ///////////////////////////////////////////////////////////////
168 @property
169 def config_file(self) -> Path:
170 """Return the path to the configuration file."""
171 return self._config_file
173 def get(self, key: str, default: Any = None) -> Any:
174 """
175 Get a configuration value.
177 Args:
178 key: Configuration key
179 default: Default value if key not found
181 Returns:
182 Configuration value or default
183 """
184 return self._config.get(key, default)
186 def has_key(self, key: str) -> bool:
187 """
188 Check if a configuration key is explicitly set (not just a default).
190 A key is considered explicit when it comes from a config file, an
191 environment variable, or a direct call to configure()/set()/update().
192 Keys that are present only because of default values return False.
194 Args:
195 key: Configuration key to check
197 Returns:
198 True if the key was explicitly set, False if it is only a default.
199 """
200 return key in self._explicit_keys
202 def get_log_level(self) -> str:
203 """Get the current log level."""
204 return cast(str, self.get("log-level", DefaultConfiguration.LOG_LEVEL))
206 def get_log_file(self) -> Path:
207 """Get the current log file path."""
208 log_file = self.get("log-file", DefaultConfiguration.LOG_FILE)
209 log_dir = self.get("log-dir", DefaultConfiguration.LOG_DIR)
211 # Convert to Path if string
212 log_file_path = Path(log_file) if isinstance(log_file, str) else log_file
213 log_dir_path = Path(log_dir) if isinstance(log_dir, str) else log_dir
215 # If log_file is relative, make it relative to log_dir
216 if not log_file_path.is_absolute(): 216 ↛ 218line 216 didn't jump to line 218 because the condition on line 216 was always true
217 return log_dir_path / log_file_path
218 return log_file_path
220 def get_printer_level(self) -> str:
221 """Get the current printer level."""
222 return cast(str, self.get("printer-level", DefaultConfiguration.PRINTER_LEVEL))
224 def get_file_logger_level(self) -> str:
225 """Get the current file logger level."""
226 return cast(
227 str, self.get("file-logger-level", DefaultConfiguration.FILE_LOGGER_LEVEL)
228 )
230 def get_indent_step(self) -> int:
231 """Get the current indent step."""
232 return cast(int, self.get("indent-step", DefaultConfiguration.INDENT_STEP))
234 def get_indent_symbol(self) -> str:
235 """Get the current indent symbol."""
236 return cast(str, self.get("indent-symbol", DefaultConfiguration.INDENT_SYMBOL))
238 def get_base_indent_symbol(self) -> str:
239 """Get the current base indent symbol."""
240 return cast(
241 str, self.get("base-indent-symbol", DefaultConfiguration.BASE_INDENT_SYMBOL)
242 )
244 def get_log_format(self) -> str:
245 """Get the current log format."""
246 return cast(str, self.get("log-format", DefaultConfiguration._LOG_FORMAT))
248 def get_log_rotation(self) -> str | None:
249 """Get the current log rotation setting."""
250 return cast(
251 str | None, self.get("log-rotation", DefaultConfiguration.LOG_ROTATION)
252 )
254 def get_log_retention(self) -> str | None:
255 """Get the current log retention setting."""
256 return cast(
257 str | None, self.get("log-retention", DefaultConfiguration.LOG_RETENTION)
258 )
260 def get_log_compression(self) -> str | None:
261 """Get the current log compression setting."""
262 return cast(
263 str | None,
264 self.get("log-compression", DefaultConfiguration.LOG_COMPRESSION),
265 )
267 def get_all(self) -> dict[str, Any]:
268 """
269 Get all configuration values.
271 Returns:
272 Dictionary containing all configuration values
273 """
274 return self._config.copy()
276 # ///////////////////////////////////////////////////////////////
277 # SETTER
278 # ///////////////////////////////////////////////////////////////
280 def set(self, key: str, value: Any) -> None:
281 """
282 Set a configuration value.
284 Args:
285 key: Configuration key
286 value: Configuration value
287 """
288 self._config[key] = value
289 self._explicit_keys.add(key)
291 def update(self, config_dict: dict[str, Any]) -> None:
292 """
293 Update configuration with new values.
295 Args:
296 config_dict: Dictionary of configuration values to update
297 """
298 self._config.update(config_dict)
299 self._explicit_keys.update(config_dict.keys())
301 # ///////////////////////////////////////////////////////////////
302 # FILE OPERATIONS
303 # ///////////////////////////////////////////////////////////////
305 def save(self) -> None:
306 """
307 Save current configuration to file.
309 Raises:
310 FileOperationError: If unable to write to configuration file
311 """
312 try:
313 # Ensure config directory exists
314 self._config_file.parent.mkdir(parents=True, exist_ok=True)
316 # Save configuration
317 with open(self._config_file, "w", encoding="utf-8") as f:
318 json.dump(self._config, f, indent=4, ensure_ascii=False)
319 except OSError as e:
320 raise FileOperationError(
321 f"Could not save configuration to {self._config_file}: {e}",
322 str(self._config_file),
323 "save",
324 ) from e
326 def reset_to_defaults(self) -> None:
327 """Reset configuration to default values."""
328 self._config = DefaultConfiguration.get_all_defaults().copy()
329 self._explicit_keys = set()
331 def reload(self) -> None:
332 """
333 Reload configuration from file and environment variables.
335 This method reloads the configuration, useful when environment
336 variables or the config file have changed after initialization.
337 """
338 self._load_configuration()
340 def export_to_script(
341 self, output_file: str | Path, platform: str | None = None
342 ) -> None:
343 """
344 Export configuration as environment variables script.
346 Args:
347 output_file: Path to output script file
348 platform: Target platform ('windows', 'unix', or None for auto-detect)
350 Raises:
351 FileOperationError: If unable to write to output file
352 """
353 if platform is None:
354 import sys
356 platform = "windows" if sys.platform == "win32" else "unix"
358 output_path = Path(output_file)
360 try:
361 with open(output_path, "w", encoding="utf-8") as f:
362 if platform == "windows":
363 # Generate Batch script for Windows
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 safe_value = str(value).replace('"', '""')
369 f.write(f'set "{env_var}={safe_value}"\n')
370 else:
371 # Generate Bash script for Unix/Linux/macOS
372 f.write("#!/bin/bash\n")
373 for env_var, config_key in self._ENV_MAPPINGS.items():
374 value = self._config.get(config_key)
375 if value is None:
376 continue
377 f.write(f"export {env_var}={shlex.quote(str(value))}\n")
378 except OSError as e:
379 raise FileOperationError(
380 f"Could not write to {output_path}: {e}",
381 str(output_path),
382 "export",
383 ) from e
385 # ///////////////////////////////////////////////////////////////
386 # REPRESENTATION METHODS
387 # ///////////////////////////////////////////////////////////////
389 def __str__(self) -> str:
390 """String representation of the configuration."""
391 return f"ConfigurationManager(config_file={self._config_file})"
393 def __repr__(self) -> str:
394 """Detailed string representation of the configuration."""
395 return f"ConfigurationManager(config_file={self._config_file}, config={self._config})"
398# ///////////////////////////////////////////////////////////////
399# PUBLIC API
400# ///////////////////////////////////////////////////////////////
402__all__ = [
403 "ConfigurationManager",
404]