Source code for prismalog.config

"""
Configuration Management Module for prismalog

This module provides a centralized configuration system for the prismalog package,
enabling flexible and hierarchical configuration from multiple sources. It handles
loading, prioritizing, and accessing configuration settings throughout the application.

Key features:
- Hierarchical configuration with clear priority order
- Multi-source configuration (default values, files, environment variables, CLI)
- Support for YAML configuration files
- Automatic type conversion for numeric and boolean values
- Command-line argument integration with argparse
- Singleton pattern to ensure configuration consistency

The configuration system follows this priority order (highest to lowest):
1. Programmatic settings via direct API calls or kwargs to initialize()
2. Command-line arguments
3. Configuration files (YAML)
4. Environment variables (with support for CI/CD environments)
5. Default values

Environment Variables:
    Standard environment variables use the ``LOG_`` prefix:

    - ``LOG_DIR``: Directory for log files
    - ``LOG_LEVEL``: Default logging level (e.g., INFO, DEBUG)
    - ``LOG_ROTATION_SIZE``: Size in MB for log rotation
    - ``LOG_BACKUP_COUNT``: Number of backup log files
    - ``LOG_FORMAT``: Log message format string
    - ``LOG_DATEFMT``: Log message date format string
    - ``LOG_FILENAME``: Base filename prefix for log files (default: 'app')
    - ``LOG_COLORED_CONSOLE``: Whether to use colored console output (true/false)
    - ``LOG_DISABLE_ROTATION``: Whether to disable log rotation (true/false)
    - ``LOG_EXIT_ON_CRITICAL``: Whether to exit on critical logs (true/false)
    - ``LOG_TEST_MODE``: Whether logger is in test mode (true/false)

    For GitHub Actions, the same variables are supported with ``GITHUB_`` prefix:

    - ``GITHUB_LOG_DIR``, ``GITHUB_LOG_LEVEL``, ``GITHUB_LOG_FILENAME``, ``GITHUB_LOG_DATEFMT``, etc.

Command-Line Arguments:
    The following arguments can be parsed if `use_cli_args=True` during initialization:

    - ``--log-config`` / ``--log-conf``: Path to logging configuration file (YAML)
    - ``--log-level`` / ``--logging-level``: Default logging level (DEBUG, INFO, etc.)
    - ``--log-dir`` / ``--logging-dir``: Directory for log files
    - ``--log-format`` / ``--logging-format``: Format string for log messages
    - ``--log-datefmt`` / ``--logging-datefmt``: Format string for log timestamps
    - ``--log-filename`` / ``--logging-filename``: Prefix for log filenames
    - ``--no-color`` / ``--no-colors``: Disable colored console output
    - ``--disable-rotation``: Disable log file rotation
    - ``--exit-on-critical``: Exit the program on critical errors
    - ``--rotation-size``: Log file rotation size in MB
    - ``--backup-count``: Number of backup log files to keep

Type Conversion:
    String values from configuration files and environment variables are automatically
    converted to the appropriate types (boolean, integer) based on the default config's
    type definition. Boolean values support multiple string representations:
    - True: "true", "1", "yes", "y", "t", "on"
    - False: "false", "0", "no", "n", "f", "off", "none"

Usage examples:
    # Basic initialization with defaults
    LoggingConfig.initialize()

    # Initialization with configuration file and CLI args enabled
    LoggingConfig.initialize(config_file="logging_config.yaml", use_cli_args=True)

    # Accessing configuration values
    log_dir = LoggingConfig.get("log_dir")
    filename_prefix = LoggingConfig.get_filename_prefix()

    # Setting configuration values programmatically
    LoggingConfig.set("colored_console", False)

    # Getting appropriate log level for a specific logger
    level = LoggingConfig.get_level("requests.packages.urllib3")
"""

import argparse
import os
from typing import Any, Dict, Optional, Type, cast


[docs] class LoggingConfig: """ Configuration manager for prismalog package. Handles loading configuration from multiple sources with a priority order: 1. Programmatic settings 2. Command-line arguments 3. Configuration files (YAML) 4. Environment variables (including GitHub Actions secrets) 5. Default values This class uses a singleton pattern to ensure consistent configuration throughout the application lifecycle. It supports configuration from multiple sources and provides automatic type conversion. Attributes: DEFAULT_CONFIG (Dict[str, Any]): Default configuration values _instance (LoggingConfig): Singleton instance of LoggingConfig _config (Dict[str, Any]): Current active configuration _initialized (bool): Whether the configuration has been initialized _debug_mode (bool): Whether to print debug messages during configuration Examples: # Initialize with default settings LoggingConfig.initialize() # Initialize with a configuration file LoggingConfig.initialize(config_file="logging.yaml") # Get a configuration value log_dir = LoggingConfig.get("log_dir") """ DEFAULT_CONFIG = { "log_dir": "logs", "default_level": "INFO", "rotation_size_mb": 10, "backup_count": 5, "log_format": "%(asctime)s - %(filename)s - %(name)s - [%(levelname)s] - %(message)s", "datefmt": "%Y-%m-%d %H:%M:%S.%f", "log_filename": "app", # Default log filename prefix "colored_console": True, "disable_rotation": False, "exit_on_critical": False, # Whether to exit the program on critical logs "test_mode": False, # Whether the logger is running in test mode } _instance = None _config: Dict[str, Any] = {} _initialized = False _debug_mode = False def __new__(cls) -> "LoggingConfig": """Singleton pattern to ensure only one instance of LoggingConfig exists.""" if cls._instance is None: cls._instance = super(LoggingConfig, cls).__new__(cls) cls._config = cls.DEFAULT_CONFIG.copy() return cls._instance
[docs] @classmethod def debug_print(cls, message: str) -> None: """ Print debug message only if debug mode is enabled. Args: message: The message to print when debugging is enabled """ if cls._debug_mode: print(message)
[docs] @classmethod def initialize(cls, config_file: Optional[str] = None, use_cli_args: bool = True, **kwargs: Any) -> Dict[str, Any]: """ Initialize configuration from various sources using a two-phase approach. The configuration is loaded in two phases: 1. Collection Phase: Gather and convert configurations from all sources 2. Application Phase: Apply configurations in priority order 3. Finalization Phase: Set the initialized flag Args: config_file: Path to configuration file (YAML) use_cli_args: Whether to parse command-line arguments **kwargs: Direct configuration values (highest priority) Returns: The complete configuration dictionary Examples: # Basic initialization LoggingConfig.initialize() # Initialize with a YAML config file LoggingConfig.initialize(config_file="logging.yaml") # Initialize with direct override values LoggingConfig.initialize(log_level="DEBUG", colored_console=False) """ # Phase 1: Collect configurations from all sources config_sources = cls._collect_configurations(config_file, use_cli_args, kwargs) # Phase 2: Apply configurations in priority order cls._apply_configurations(config_sources) # Phase 3: Set initialized flag cls._initialized = True return cls._config
@classmethod def _collect_configurations( cls, config_file: Optional[str], use_cli_args: bool, kwargs: Dict[str, Any] ) -> Dict[str, Dict[str, Any]]: """ Collect configurations from all possible sources and convert types immediately. This method gathers configuration from multiple sources: - Default values - Environment variables - Configuration files (if specified) - Command-line arguments (if use_cli_args is True) - Direct keyword arguments Each source's values are converted to appropriate types during collection. Args: config_file: Optional path to a configuration file use_cli_args: Whether to parse command-line arguments kwargs: Direct configuration values passed to initialize() Returns: Dictionary containing configuration values from each source """ # Start with empty collections for each source sources: Dict[str, Dict[str, Any]] = { "defaults": cls.DEFAULT_CONFIG.copy(), "file": {}, "env": {}, "cli": {}, "kwargs": kwargs.copy() if kwargs else {}, } # Collect and convert file configuration if config_file and os.path.exists(config_file): raw_file_config = cls._load_raw_file_config(config_file) if raw_file_config: # Convert types immediately file_config = cls._convert_config_values(raw_file_config) sources["file"] = file_config cls.debug_print(f"Collected from file: {file_config}") # Collect and convert environment variables raw_env_config = cls._load_raw_env_config() if raw_env_config: # Convert types immediately env_config = cls._convert_config_values(raw_env_config) sources["env"] = env_config cls.debug_print(f"Collected from environment: {env_config}") # Collect and convert command line arguments if use_cli_args: raw_arg_config = cls._load_raw_cli_args() if raw_arg_config: # Convert types immediately arg_config = cls._convert_config_values(raw_arg_config) sources["cli"] = arg_config cls.debug_print(f"Collected from CLI: {arg_config}") return sources @classmethod def _apply_configurations(cls, sources: Dict[str, Dict[str, Any]]) -> None: """ Apply configurations in the correct priority order. Priority order (lowest to highest): 1. Default values (starting point) 2. Environment variables (override defaults) 3. Configuration file (overrides environment) 4. Command-line arguments (overrides file) 5. Direct keyword arguments (highest priority) Args: sources: Dictionary containing configurations from different sources """ # 1. Start with defaults cls._config = sources["defaults"].copy() cls.debug_print(f"1. Starting with defaults: {cls._config}") # 2. Apply environment variables (overrides defaults) if sources["env"]: cls.debug_print("2. Applying environment variables") cls._config.update(sources["env"]) cls.debug_print(f" Config after env vars: {cls._config}") # 3. Apply configuration file (overrides env) if sources["file"]: cls.debug_print("3. Applying file configuration") cls._config.update(sources["file"]) cls.debug_print(f" Config after file: {cls._config}") # 4. Apply command-line arguments (overrides file) if sources["cli"]: cls.debug_print("4. Applying command line arguments") cls._config.update(sources["cli"]) cls.debug_print(f" Config after CLI args: {cls._config}") # 5. Apply direct kwargs (highest priority) if sources["kwargs"]: cls.debug_print("5. Applying kwargs") cls._config.update(sources["kwargs"]) cls.debug_print(f" Config after kwargs: {cls._config}") cls._initialized = True cls.debug_print(f"Final configuration: {cls._config}") @classmethod def _convert_config_values(cls, config_dict: Dict[str, Any]) -> Dict[str, Any]: """ Convert string configuration values to their appropriate types. This is the central type conversion method used by all config sources. It handles two types of conversions: 1. Boolean values: Converts strings like "true", "yes", "1" to True and their opposites to False 2. Numeric values: Converts digit strings to integers The method processes boolean conversions first, then numeric conversions. Invalid conversion attempts result in the key being removed from the result. Args: config_dict: Dictionary containing configuration values to convert Returns: Dictionary with values converted to appropriate types Examples: >>> LoggingConfig._convert_config_values({"colored_console": "true", "rotation_size_mb": "50"}) {'colored_console': True, 'rotation_size_mb': 50} """ if not config_dict: return {} result = config_dict.copy() # Get the keys and their types from DEFAULT_CONFIG numeric_keys = [k for k, v in cls.DEFAULT_CONFIG.items() if isinstance(v, int)] boolean_keys = [k for k, v in cls.DEFAULT_CONFIG.items() if isinstance(v, bool)] # First, convert boolean values for key in boolean_keys: if key in result and isinstance(result[key], str): val = result[key].strip().lower() if val in ["true", "1", "yes", "y", "t", "on"]: result[key] = True cls.debug_print(f"Converted '{key}={val}' to boolean True") elif val in ["false", "0", "no", "n", "f", "off", "none"]: result[key] = False cls.debug_print(f"Converted '{key}={val}' to boolean False") else: cls.debug_print(f"Warning: Cannot convert '{key}={val}' to boolean") result.pop(key, None) # Remove invalid values # Then, convert numeric values for key in numeric_keys: if key in result and isinstance(result[key], str): val = result[key].strip() if val.isdigit() or (val.startswith("-") and val[1:].isdigit()): result[key] = int(val) cls.debug_print(f"Converted '{key}={val}' to integer {int(val)}") else: cls.debug_print(f"Warning: Cannot convert '{key}={val}' to integer") result.pop(key, None) # Remove invalid values return result @classmethod def _load_raw_file_config(cls, config_path: str) -> Dict[str, Any]: """ Load raw configuration from file without type conversion. Supports YAML file format. If PyYAML is not installed, YAML files cannot be loaded and an empty dictionary is returned. Args: config_path: Path to the configuration file Returns: Dictionary with raw configuration values from the file, or empty dictionary if file doesn't exist or has invalid format """ file_config: Dict[str, Any] = {} # Add type annotation here if not os.path.exists(config_path): return file_config try: with open(config_path, mode="r", encoding="utf-8") as f: if config_path.endswith((".yaml", ".yml")): try: import yaml file_config = yaml.safe_load(f) except ImportError: print("YAML configuration requires PyYAML. Install with: pip install PyYAML") print("Continuing with default configuration.") return file_config else: cls.debug_print(f"Unsupported config file format: {config_path}") return file_config except Exception as e: cls.debug_print(f"Error loading config file: {e}") return file_config # Process level values to ensure they are uppercase if "default_level" in file_config and isinstance(file_config["default_level"], str): file_config["default_level"] = file_config["default_level"].upper() # Also handle external_loggers levels if "external_loggers" in file_config and isinstance(file_config["external_loggers"], dict): for logger, level in file_config["external_loggers"].items(): if isinstance(level, str): file_config["external_loggers"][logger] = level.upper() return file_config @classmethod def _load_raw_env_config(cls) -> Dict[str, Any]: """ Load raw environment variables without type conversion. Looks for environment variables with both LOG_ and GITHUB_ prefixes. For each configuration key, it checks the variables in order and uses the first one found. Returns: Dictionary mapping configuration keys to environment variable values """ env_config = {} # Define lookup tables with ordered priorities env_vars = { "log_dir": ["LOG_DIR", "GITHUB_LOG_DIR"], "default_level": ["LOG_LEVEL", "GITHUB_LOG_LEVEL"], "rotation_size_mb": ["LOG_ROTATION_SIZE", "GITHUB_LOG_ROTATION_SIZE"], "backup_count": ["LOG_BACKUP_COUNT", "GITHUB_LOG_BACKUP_COUNT"], "log_format": ["LOG_FORMAT", "GITHUB_LOG_FORMAT"], "datefmt": ["LOG_DATEFMT", "GITHUB_LOG_DATEFMT"], "log_filename": ["LOG_FILENAME", "GITHUB_LOG_FILENAME"], "colored_console": ["LOG_COLORED_CONSOLE", "GITHUB_LOG_COLORED_CONSOLE"], "disable_rotation": ["LOG_DISABLE_ROTATION", "GITHUB_LOG_DISABLE_ROTATION"], "exit_on_critical": ["LOG_EXIT_ON_CRITICAL", "GITHUB_LOG_EXIT_ON_CRITICAL"], "test_mode": ["LOG_TEST_MODE", "GITHUB_LOG_TEST_MODE"], } # Efficiently check each config key using direct lookup for config_key, env_vars_list in env_vars.items(): for env_var in env_vars_list: if env_var in os.environ: env_config[config_key] = os.environ[env_var] break # Stop after finding the first matching env var return env_config @classmethod def _load_raw_cli_args(cls) -> Dict[str, Any]: """ Parse command-line arguments for logging configuration without type conversion. This method creates an ArgumentParser that only handles logging-specific arguments and uses parse_known_args() to avoid conflicts with application-specific arguments. If a config file is specified in the arguments, its content is loaded and merged with the CLI arguments (with CLI args taking precedence). Returns: Dictionary of raw string values from command-line arguments """ parser = argparse.ArgumentParser(add_help=False) cls._add_logging_args_to_parser(parser) # Parse only known args to avoid conflicts with application args args, _ = parser.parse_known_args() # Process the parsed arguments - collect only non-None values arg_dict = {k: v for k, v in vars(args).items() if v is not None} # Handle config file if present, but don't convert types yet if "config_file" in arg_dict: config_path = arg_dict.pop("config_file") # Remove the path from arg_dict raw_file_config = cls._load_raw_file_config(config_path) # Merge file config with arg_dict (CLI args take precedence) merged_config = raw_file_config.copy() merged_config.update(arg_dict) # CLI args override file config return merged_config return arg_dict
[docs] @classmethod def load_from_file(cls, config_path: str) -> Dict[str, Any]: """ Load and convert configuration from a YAML file. This is a convenience method that loads raw configuration from a file and then converts the values to appropriate types. Args: config_path: Path to the configuration file Returns: Dictionary with configuration values converted to appropriate types """ raw_config = cls._load_raw_file_config(config_path) return cls._convert_config_values(raw_config)
[docs] @classmethod def load_from_env(cls) -> Dict[str, Any]: """ Load and convert configuration from environment variables. This is a convenience method that loads raw configuration from environment variables and then converts the values to appropriate types. Returns: Dictionary with configuration values converted to appropriate types """ raw_config = cls._load_raw_env_config() return cls._convert_config_values(raw_config)
@classmethod def _add_logging_args_to_parser(cls, parser: argparse.ArgumentParser) -> argparse.ArgumentParser: """ Add standard logging arguments to an existing parser. Adds the following arguments to the parser: - --log-config, --log-conf: Path to logging configuration file - --log-level, --logging-level: Default logging level - --log-dir, --logging-dir: Directory for log files - --log-format, --logging-format: Format string for log messages - --log-datefmt, --logging-datefmt: Format string for log timestamps - --log-filename, --logging-filename: Prefix for log filenames Args: parser: An existing ArgumentParser to add arguments to Returns: The modified ArgumentParser with logging arguments added """ parser.add_argument( "--log-config", "--log-conf", dest="config_file", help="Path to logging configuration file", ) parser.add_argument( "--log-level", "--logging-level", dest="default_level", type=str.upper, choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], help="Set the default logging level", ) parser.add_argument( "--log-dir", "--logging-dir", dest="log_dir", help="Directory where log files will be stored" ) parser.add_argument( "--log-format", "--logging-format", dest="log_format", help="Format string for log messages (e.g. '%(asctime)s - %(message)s')", ) parser.add_argument( "--log-datefmt", "--logging-datefmt", dest="datefmt", help="Format string for log timestamps (e.g. '%%Y-%%m-%%d %%H:%%M:%%S.%%f')", ) parser.add_argument( "--log-filename", "--logging-filename", dest="log_filename", help="Prefix for log filenames", ) return parser
[docs] @classmethod def parse_cli_args(cls) -> Dict[str, Any]: """ Parse command-line arguments for logging configuration. Parses command-line arguments for logging configuration options. Only recognizes specific logging-related arguments and ignores other arguments that might be intended for the application. Supported arguments: --log-config, --log-conf: Path to logging configuration file --log-level, --logging-level: Default logging level --log-dir, --logging-dir: Directory for log files Returns: Dictionary of parsed command-line arguments with proper type conversion Note: This method uses argparse.parse_known_args() to avoid conflicts with application-specific command-line arguments. """ parser = argparse.ArgumentParser(add_help=False) cls._add_logging_args_to_parser(parser) # Parse only known args to avoid conflicts with application args args, _ = parser.parse_known_args() # Process the parsed arguments arg_dict = {k: v for k, v in vars(args).items() if v is not None} # If --log-config was provided, load that config file if "config_file" in arg_dict: config_path = arg_dict.pop("config_file") # Remove the path from arg_dict file_config = cls.load_from_file(config_path) # Merge file config with arg_dict (CLI args take precedence) merged_config = file_config.copy() merged_config.update(arg_dict) # CLI args override file config return merged_config return arg_dict
[docs] @classmethod def get_config(cls) -> Dict[str, Any]: """ Get the complete current configuration. If the configuration hasn't been initialized yet, this will initialize it with default values before returning. Returns: Dictionary containing all configuration values Example: ```python config = LoggingConfig.get_config() print(f"Log directory: {config['log_dir']}") ``` """ if not cls._initialized: cls.initialize() return cls._config
[docs] @classmethod def get(cls, key: str, default: Any = None) -> Any: """ Get a specific configuration value. Args: key: The configuration key to retrieve default: Value to return if the key is not found Returns: The configuration value or the default if not found Example: ```python log_dir = LoggingConfig.get("log_dir") level = LoggingConfig.get("default_level", "INFO") ``` """ if not cls._initialized: cls.initialize() # Special handling for nested keys if key == "module_levels": # Return the module_levels dictionary if it exists if "module_levels" in cls._config and isinstance(cls._config["module_levels"], dict): return cls._config["module_levels"] return {} # Normal key # First check if it's in current config if key in cls._config: return cls._config[key] # If not, check if it's in DEFAULT_CONFIG if key in cls.DEFAULT_CONFIG: return cls.DEFAULT_CONFIG[key] # If not found in either place, return the provided default return default
[docs] @classmethod def set(cls, key: str, value: Any) -> None: """ Set or update a configuration value. This allows runtime modification of configuration values. Changes will be reflected in any new loggers created after the change. Supports nested keys with dot notation (e.g., "module_levels.my_module"). Args: key: The configuration key to set value: The value to assign to the key Example: >>> # Disable exiting on critical logs >>> LoggingConfig.set("exit_on_critical", False) >>> >>> # Set module-specific log level >>> LoggingConfig.set("module_levels.my_module", "DEBUG") """ # Handle nested keys (with dot notation) if "." in key: # Split the key path parts = key.split(".") main_key = parts[0] sub_key = parts[1] # Ensure parent dictionary exists if main_key not in cls._config: cls._config[main_key] = {} elif not isinstance(cls._config[main_key], dict): # If it exists but is not a dict, convert it to a dict cls._config[main_key] = {} # Set the value in the nested dict cls._config[main_key][sub_key] = value else: # Simple case: direct key cls._config[key] = value
[docs] @classmethod def get_level(cls, name: Optional[str] = None, default_level: Optional[str] = None) -> int: """ Get the numeric log level based on configuration priority. This method determines the appropriate log level based on priority order: 1. Explicit level parameter (highest priority) 2. Logger-specific configuration from external_loggers 3. Default level from configuration Args: name: Optional logger name to check for specific configuration default_level: Optional override for the default level Returns: The numeric logging level (from logging module constants) Examples: Get default level for application: >>> default_level = LoggingConfig.get_level() Get level for a specific module: >>> requests_level = LoggingConfig.get_level("requests.packages.urllib3") Override with explicit level: >>> debug_level = LoggingConfig.get_level(default_level="DEBUG") """ # 1. First priority: Use explicit level if provided if default_level: return cls.map_level(default_level) # 2. Second priority: Check logger-specific config if name: external_loggers = cls.get("external_loggers", {}) if name in external_loggers: return cls.map_level(external_loggers[name]) # 3. Third priority: Use configured default level config_level = cls.get("default_level", "INFO") return cls.map_level(config_level)
[docs] @classmethod def map_level(cls, level: str) -> int: """ Map string log level to numeric level. Converts string log level names to their corresponding numeric values from the logging module. If the level string is not recognized, defaults to logging.INFO. Args: level: String log level ('DEBUG', 'INFO', etc.) Returns: The corresponding numeric logging level (from logging module constants) Example: ```python debug_level = LoggingConfig.map_level("DEBUG") # Returns 10 warn_level = LoggingConfig.map_level("WARNING") # Returns 30 ``` """ import logging # pylint: disable=import-outside-toplevel return { "DEBUG": logging.DEBUG, "INFO": logging.INFO, "WARNING": logging.WARNING, "WARN": logging.WARNING, "ERROR": logging.ERROR, "CRITICAL": logging.CRITICAL, }.get(level, logging.INFO)
[docs] @classmethod def is_initialized(cls) -> bool: """Check if logging configuration has been initialized.""" return cls._initialized
[docs] @classmethod def reset(cls) -> Type["LoggingConfig"]: """ Reset configuration to default values. This method restores all configuration settings to their default values, which is particularly useful for testing where each test needs to start with a clean configuration state. Returns: The LoggingConfig class for method chaining Example: ```python # Reset to defaults and then set a specific value LoggingConfig.reset().set("colored_console", False) ``` """ # Reset to default values cls._config = cls.DEFAULT_CONFIG.copy() # Reset initialization state cls._initialized = False # For debugging cls.debug_print("LoggingConfig reset to default values") return cls
[docs] @classmethod def get_filename_prefix(cls) -> str: """ Get the configured log filename prefix. Returns: The configured prefix string, defaulting to 'app'. """ value = cls.get("log_filename", "app") return cast(str, value)