Skip to content

Ezpl

Main singleton class for logging management.

Overview

The Ezpl class is the main entry point for the ezpl logging framework. It implements the singleton pattern with thread-safe double-checked locking to ensure only one instance exists across the application.

Class Reference

Ezpl

Main logging singleton for the Ezpl framework.

Ezpl provides a unified, thread-safe interface for console and file logging with advanced features including indentation management, pattern-based logging, and dynamic progress bars. It implements the Singleton pattern to ensure only one instance exists throughout the application lifecycle.

ATTRIBUTE DESCRIPTION
_instance

The singleton instance of Ezpl

TYPE: Ezpl | None

_lock

Thread lock for synchronized access

TYPE: RLock

_config_locked

Whether configuration can be modified

TYPE: bool

_log_file

Path to the log file

TYPE: Path

_printer

Console output handler

TYPE: EzPrinter

_logger

File logging handler

TYPE: EzLogger

_config_manager

Configuration manager instance

TYPE: ConfigurationManager

Note

Once initialized, Ezpl cannot be re-configured unless reset. Access it via the singleton pattern or module-level functions.

Example

log = Ezpl() log.printer.log("INFO", "Application started") log.logger.log("INFO", "Starting logging to file")

Attributes

printer_level property

printer_level: str

Return the current printer logging level.

logger_level property

logger_level: str

Return the current file logger logging level.

Functions

is_initialized classmethod

is_initialized() -> bool

Return True if the Ezpl singleton has already been created.

Useful for libraries that want to know whether they are the first to initialize Ezpl or if they should avoid re-configuring it.

Source code in src/ezpl/ezpl.py
@classmethod
def is_initialized(cls) -> bool:
    """
    Return True if the Ezpl singleton has already been created.

    Useful for libraries that want to know whether they are the first
    to initialize Ezpl or if they should avoid re-configuring it.
    """
    return cls._instance is not None

get_printer

get_printer() -> EzPrinter

Returns the EzPrinter instance.

Returns:

* EzPrinter: The console printer instance providing info(), debug(), success(), etc.
    Implements PrinterProtocol for type safety.
Source code in src/ezpl/ezpl.py
def get_printer(self) -> EzPrinter:
    """
    Returns the EzPrinter instance.

    **Returns:**

        * EzPrinter: The console printer instance providing info(), debug(), success(), etc.
            Implements PrinterProtocol for type safety.
    """
    return self._printer

get_logger

get_logger() -> EzLogger

Returns the EzLogger instance.

Returns:

* EzLogger: The file logger instance for file logging.
    Use logger.info(), logger.debug(), etc. directly.
    For advanced loguru features, use logger.get_loguru()
    Implements LoggerProtocol for type safety.
Source code in src/ezpl/ezpl.py
def get_logger(self) -> EzLogger:
    """
    Returns the EzLogger instance.

    **Returns:**

        * EzLogger: The file logger instance for file logging.
            Use logger.info(), logger.debug(), etc. directly.
            For advanced loguru features, use logger.get_loguru()
            Implements LoggerProtocol for type safety.
    """
    return self._logger

set_level

set_level(level: str, *, force: bool = False, owner: str | None = None, token: str | None = None) -> None

Set the log level for both the printer and the logger simultaneously (compatibility method).

Args:

* `level` (str): The desired log level (e.g., "INFO", "WARNING").
* `force` (bool): If True, bypasses the configuration lock.

Returns:

* `None`.
Source code in src/ezpl/ezpl.py
def set_level(
    self,
    level: str,
    *,
    force: bool = False,
    owner: str | None = None,
    token: str | None = None,
) -> None:
    """
    Set the log level for both the printer and the logger simultaneously (compatibility method).

    **Args:**

        * `level` (str): The desired log level (e.g., "INFO", "WARNING").
        * `force` (bool): If True, bypasses the configuration lock.

    **Returns:**

        * `None`.
    """
    if not self._can_write_config(force=force, owner=owner, token=token):
        warnings.warn(
            "Ezpl configuration is locked. Call Ezpl.unlock_config() or pass a "
            "valid owner/token with force=True to override.",
            UserWarning,
            stacklevel=2,
        )
        return
    self.set_logger_level(level, force=force, owner=owner, token=token)
    self.set_printer_level(level, force=force, owner=owner, token=token)

set_printer_level

set_printer_level(level: str, *, force: bool = False, owner: str | None = None, token: str | None = None) -> None

Set the log level for the printer only.

Args:

* `level` (str): The desired log level for the printer.
* `force` (bool): If True, bypasses the configuration lock.

Returns:

* `None`.
Source code in src/ezpl/ezpl.py
def set_printer_level(
    self,
    level: str,
    *,
    force: bool = False,
    owner: str | None = None,
    token: str | None = None,
) -> None:
    """
    Set the log level for the printer only.

    **Args:**

        * `level` (str): The desired log level for the printer.
        * `force` (bool): If True, bypasses the configuration lock.

    **Returns:**

        * `None`.
    """
    if not self._can_write_config(force=force, owner=owner, token=token):
        warnings.warn(
            "Ezpl configuration is locked. Call Ezpl.unlock_config() or pass a "
            "valid owner/token with force=True to override.",
            UserWarning,
            stacklevel=2,
        )
        return
    self._printer.set_level(level)

set_logger_level

set_logger_level(level: str, *, force: bool = False, owner: str | None = None, token: str | None = None) -> None

Set the log level for the file logger only.

Args:

* `level` (str): The desired log level for the file logger.
* `force` (bool): If True, bypasses the configuration lock.

Returns:

* `None`.
Source code in src/ezpl/ezpl.py
def set_logger_level(
    self,
    level: str,
    *,
    force: bool = False,
    owner: str | None = None,
    token: str | None = None,
) -> None:
    """
    Set the log level for the file logger only.

    **Args:**

        * `level` (str): The desired log level for the file logger.
        * `force` (bool): If True, bypasses the configuration lock.

    **Returns:**

        * `None`.
    """
    if not self._can_write_config(force=force, owner=owner, token=token):
        warnings.warn(
            "Ezpl configuration is locked. Call Ezpl.unlock_config() or pass a "
            "valid owner/token with force=True to override.",
            UserWarning,
            stacklevel=2,
        )
        return
    self._logger.set_level(level)

add_separator

add_separator() -> None

Adds a separator to the log file.

Returns:

* `None`.
Source code in src/ezpl/ezpl.py
def add_separator(self) -> None:
    """
    Adds a separator to the log file.

    **Returns:**

        * `None`.
    """
    self._logger.add_separator()

manage_indent

manage_indent() -> Generator[None, None, None]

Context manager to manage indentation level.

Returns:

* `None`.
Source code in src/ezpl/ezpl.py
@contextmanager
def manage_indent(self) -> Generator[None, None, None]:
    """
    Context manager to manage indentation level.

    **Returns:**

        * `None`.
    """
    with self._printer.manage_indent():
        yield

reset classmethod

reset() -> None

Reset the singleton instance (useful for testing).

Warning: This will destroy the current instance and all its state.

Source code in src/ezpl/ezpl.py
@classmethod
def reset(cls) -> None:
    """
    Reset the singleton instance (useful for testing).

    Warning: This will destroy the current instance and all its state.
    """
    if cls._instance is not None:
        # Close logger handlers to release file handles (important on Windows)
        try:
            if hasattr(cls._instance, "_logger") and cls._instance._logger:
                cls._instance._logger.close()
        except (EzplError, OSError, RuntimeError) as e:
            logger.error(f"Error during cleanup: {e}")
        cls._instance = None
    # Also reset configuration lock
    cls._config_locked = False
    cls._config_lock_owner = None
    cls._config_lock_token = None

lock_config classmethod

lock_config(owner: str = 'app') -> str | None

Lock Ezpl configuration so that future configure() calls are ignored unless explicitly forced.

Intended usage
  1. Root application configures Ezpl once
  2. Calls Ezpl.lock_config()
  3. Libraries calling configure() later will not override settings
Source code in src/ezpl/ezpl.py
@classmethod
def lock_config(cls, owner: str = "app") -> str | None:
    """
    Lock Ezpl configuration so that future configure() calls are ignored
    unless explicitly forced.

    Intended usage:
        1. Root application configures Ezpl once
        2. Calls Ezpl.lock_config()
        3. Libraries calling configure() later will not override settings
    """
    with cls._lock:
        normalized_owner = owner.strip() if owner.strip() else "app"

        if cls._config_locked and cls._config_lock_owner not in (
            None,
            normalized_owner,
        ):
            warnings.warn(
                f"Ezpl configuration is already locked by '{cls._config_lock_owner}'.",
                UserWarning,
                stacklevel=2,
            )
            return None

        cls._config_locked = True
        cls._config_lock_owner = normalized_owner
        cls._config_lock_token = uuid4().hex
        return cls._config_lock_token

unlock_config classmethod

unlock_config(*, owner: str | None = None, token: str | None = None, force: bool = False) -> bool

Unlock Ezpl configuration.

Use with care: this allows configure() to change global logging configuration again.

Source code in src/ezpl/ezpl.py
@classmethod
def unlock_config(
    cls,
    *,
    owner: str | None = None,
    token: str | None = None,
    force: bool = False,
) -> bool:
    """
    Unlock Ezpl configuration.

    Use with care: this allows configure() to change global logging
    configuration again.
    """
    with cls._lock:
        if not cls._config_locked:
            return True

        owner_match = owner is not None and owner == cls._config_lock_owner
        token_match = token is not None and token == cls._config_lock_token

        if not force and not owner_match and not token_match:
            warnings.warn(
                "Unlock denied: provide matching owner or token, or use force=True.",
                UserWarning,
                stacklevel=2,
            )
            return False

        cls._config_locked = False
        cls._config_lock_owner = None
        cls._config_lock_token = None
        return True

config_lock_info classmethod

config_lock_info() -> dict[str, Any]

Return current configuration lock state for diagnostics.

Source code in src/ezpl/ezpl.py
@classmethod
def config_lock_info(cls) -> dict[str, Any]:
    """Return current configuration lock state for diagnostics."""
    return {
        "locked": cls._config_locked,
        "owner": cls._config_lock_owner,
        "has_token": cls._config_lock_token is not None,
    }

set_log_file

set_log_file(log_file: Path | str) -> None

Change the log file (requires reinitialization of the logger).

PARAMETER DESCRIPTION
log_file

New path to the log file

TYPE: Path | str

Note: This will reinitialize the file logger but keep the singleton instance.

Source code in src/ezpl/ezpl.py
def set_log_file(self, log_file: Path | str) -> None:
    """
    Change the log file (requires reinitialization of the logger).

    Args:
        log_file: New path to the log file

    Note: This will reinitialize the file logger but keep the singleton instance.
    """
    new_log_file = Path(log_file)
    if new_log_file != self._log_file:
        self._log_file = new_log_file
        # Update configuration
        self._config_manager.set("log-file", str(new_log_file))
        self._rebuild_logger()

get_log_file

get_log_file() -> Path

Get the current log file path.

RETURNS DESCRIPTION
Path

Path to the current log file

Source code in src/ezpl/ezpl.py
def get_log_file(self) -> Path:
    """
    Get the current log file path.

    Returns:
        Path to the current log file
    """
    return self._log_file

get_config

get_config() -> ConfigurationManager

Get the current configuration manager.

RETURNS DESCRIPTION
ConfigurationManager

ConfigurationManager instance for accessing and modifying configuration

Source code in src/ezpl/ezpl.py
def get_config(self) -> ConfigurationManager:
    """
    Get the current configuration manager.

    Returns:
        ConfigurationManager instance for accessing and modifying configuration
    """
    return self._config_manager

set_printer_class

set_printer_class(printer_class: type[EzPrinter] | EzPrinter, **init_kwargs: Any) -> None

Replace the current printer with a custom printer class or instance.

Allows users to override the default printer with a custom class that inherits from EzPrinter. The method preserves current configuration values (level, indentation settings) unless explicitly overridden in init_kwargs.

PARAMETER DESCRIPTION
printer_class

Custom printer class inheriting from EzPrinter, or an already instantiated EzPrinter instance

TYPE: type[EzPrinter] | EzPrinter

**init_kwargs

Optional initialization parameters for the printer class. If not provided, current configuration values are used.

TYPE: Any DEFAULT: {}

RAISES DESCRIPTION
TypeError

If printer_class is not a valid class or instance

ValidationError

If initialization parameters are invalid

Example

from ezpl import Ezpl, EzPrinter

class CustomPrinter(EzPrinter): ... def info(self, message): ... super().info(f"[CUSTOM] {message}")

ezpl = Ezpl() ezpl.set_printer_class(CustomPrinter, level="DEBUG") ezpl.get_printer().info("Test") [CUSTOM] Test

Source code in src/ezpl/ezpl.py
def set_printer_class(
    self,
    printer_class: type[EzPrinter] | EzPrinter,
    **init_kwargs: Any,
) -> None:
    """
    Replace the current printer with a custom printer class or instance.

    Allows users to override the default printer with a custom class that
    inherits from EzPrinter. The method preserves
    current configuration values (level, indentation settings) unless
    explicitly overridden in init_kwargs.

    Args:
        printer_class: Custom printer class inheriting from EzPrinter,
            or an already instantiated EzPrinter instance
        **init_kwargs: Optional initialization parameters for the printer
            class. If not provided, current configuration values are used.

    Raises:
        TypeError: If printer_class is not a valid class or instance
        ValidationError: If initialization parameters are invalid

    Example:
        >>> from ezpl import Ezpl, EzPrinter
        >>>
        >>> class CustomPrinter(EzPrinter):
        ...     def info(self, message):
        ...         super().info(f"[CUSTOM] {message}")
        >>>
        >>> ezpl = Ezpl()
        >>> ezpl.set_printer_class(CustomPrinter, level="DEBUG")
        >>> ezpl.get_printer().info("Test")
        [CUSTOM] Test
    """
    from .core.exceptions import ValidationError

    # If it's already an instance, use it directly
    if isinstance(printer_class, EzPrinter):
        new_printer = printer_class
    # If it's a class, instantiate it
    elif isinstance(printer_class, type):
        # Validate that it's a subclass of EzPrinter
        if not issubclass(printer_class, EzPrinter):
            raise TypeError(
                f"printer_class must be a subclass of {EzPrinter.__name__}, "
                f"got {printer_class.__name__}"
            )

        # Preserve current configuration values if not provided
        current_level = (
            self._printer.level
            if hasattr(self, "_printer") and self._printer
            else self._config_manager.get_printer_level()
        )
        current_indent_step = (
            self._printer.indent_step
            if hasattr(self, "_printer") and self._printer
            else self._config_manager.get_indent_step()
        )
        current_indent_symbol = (
            self._printer.indent_symbol
            if hasattr(self, "_printer") and self._printer
            else self._config_manager.get_indent_symbol()
        )
        current_base_indent_symbol = (
            self._printer.base_indent_symbol
            if hasattr(self, "_printer") and self._printer
            else self._config_manager.get_base_indent_symbol()
        )

        # Merge kwargs with default values
        init_params = {
            "level": init_kwargs.pop("level", current_level),
            "indent_step": init_kwargs.pop("indent_step", current_indent_step),
            "indent_symbol": init_kwargs.pop(
                "indent_symbol", current_indent_symbol
            ),
            "base_indent_symbol": init_kwargs.pop(
                "base_indent_symbol", current_base_indent_symbol
            ),
        }
        init_params.update(init_kwargs)

        # Create new instance
        try:
            new_printer = printer_class(**init_params)
        except Exception as e:
            raise ValidationError(
                f"Failed to initialize printer class {printer_class.__name__}: {e}",
                "printer_class",
                str(printer_class),
            ) from e
    else:
        raise TypeError(
            f"printer_class must be a class or an instance of {EzPrinter.__name__}, "
            f"got {type(printer_class).__name__}"
        )

    # Replace the instance
    self._printer = new_printer

set_logger_class

set_logger_class(logger_class: type[EzLogger] | EzLogger, **init_kwargs: Any) -> None

Replace the current logger with a custom logger class or instance.

Allows users to override the default logger with a custom class that inherits from EzLogger. The method preserves current configuration values (level, rotation, retention, compression) unless explicitly overridden in init_kwargs.

PARAMETER DESCRIPTION
logger_class

Custom logger class inheriting from EzLogger, or an already instantiated EzLogger instance

TYPE: type[EzLogger] | EzLogger

**init_kwargs

Optional initialization parameters for the logger class. If not provided, current configuration values are used.

TYPE: Any DEFAULT: {}

RAISES DESCRIPTION
TypeError

If logger_class is not a valid class or instance

ValidationError

If initialization parameters are invalid

FileOperationError

If file operations fail during logger creation (may be raised by the logger class constructor)

Example

from ezpl import Ezpl, EzLogger

class CustomLogger(EzLogger): ... def info(self, message): ... super().info(f"[CUSTOM LOG] {message}")

ezpl = Ezpl() ezpl.set_logger_class(CustomLogger, log_file="custom.log") ezpl.get_logger().info("Test")

Source code in src/ezpl/ezpl.py
def set_logger_class(
    self,
    logger_class: type[EzLogger] | EzLogger,
    **init_kwargs: Any,
) -> None:
    """
    Replace the current logger with a custom logger class or instance.

    Allows users to override the default logger with a custom class that
    inherits from EzLogger. The method preserves current
    configuration values (level, rotation, retention, compression) unless
    explicitly overridden in init_kwargs.

    Args:
        logger_class: Custom logger class inheriting from EzLogger,
            or an already instantiated EzLogger instance
        **init_kwargs: Optional initialization parameters for the logger
            class. If not provided, current configuration values are used.

    Raises:
        TypeError: If logger_class is not a valid class or instance
        ValidationError: If initialization parameters are invalid
        FileOperationError: If file operations fail during logger creation
            (may be raised by the logger class constructor)

    Example:
        >>> from ezpl import Ezpl, EzLogger
        >>>
        >>> class CustomLogger(EzLogger):
        ...     def info(self, message):
        ...         super().info(f"[CUSTOM LOG] {message}")
        >>>
        >>> ezpl = Ezpl()
        >>> ezpl.set_logger_class(CustomLogger, log_file="custom.log")
        >>> ezpl.get_logger().info("Test")
    """
    from .core.exceptions import ValidationError

    # If it's already an instance, use it directly
    if isinstance(logger_class, EzLogger):
        new_logger = logger_class
    # If it's a class, instantiate it
    elif isinstance(logger_class, type):
        # Validate that it's a subclass of EzLogger
        if not issubclass(logger_class, EzLogger):
            raise TypeError(
                f"logger_class must be a subclass of {EzLogger.__name__}, "
                f"got {logger_class.__name__}"
            )

        # Preserve current configuration values if not provided
        current_level = (
            self._logger.level
            if hasattr(self, "_logger") and self._logger
            else self._config_manager.get_file_logger_level()
        )
        current_log_file = (
            self._log_file
            if hasattr(self, "_log_file")
            else self._config_manager.get_log_file()
        )
        current_rotation = (
            self._logger.rotation
            if hasattr(self, "_logger") and self._logger
            else self._config_manager.get_log_rotation()
        )
        current_retention = (
            self._logger.retention
            if hasattr(self, "_logger") and self._logger
            else self._config_manager.get_log_retention()
        )
        current_compression = (
            self._logger.compression
            if hasattr(self, "_logger") and self._logger
            else self._config_manager.get_log_compression()
        )

        # Merge kwargs with default values
        init_params = {
            "log_file": init_kwargs.pop("log_file", current_log_file),
            "level": init_kwargs.pop("level", current_level),
            "rotation": init_kwargs.pop("rotation", current_rotation),
            "retention": init_kwargs.pop("retention", current_retention),
            "compression": init_kwargs.pop("compression", current_compression),
        }
        init_params.update(init_kwargs)

        # Close previous logger before creating new one to avoid resource leaks
        try:
            if hasattr(self, "_logger") and self._logger:
                self._logger.close()
        except (EzplError, OSError, RuntimeError) as e:
            logger.error(f"Error while closing previous logger: {e}")

        # Create new instance
        try:
            new_logger = logger_class(**init_params)
        except Exception as e:
            raise ValidationError(
                f"Failed to initialize logger class {logger_class.__name__}: {e}",
                "logger_class",
                str(logger_class),
            ) from e
    else:
        raise TypeError(
            f"logger_class must be a class or an instance of {EzLogger.__name__}, "
            f"got {type(logger_class).__name__}"
        )

    # Replace the instance
    self._logger = new_logger

reload_config

reload_config() -> None

Reload configuration from file and environment variables.

This method reloads the configuration and reapplies it to handlers. Useful when environment variables or the config file have changed after the singleton was initialized.

Note: This will reinitialize handlers with the new configuration.

Source code in src/ezpl/ezpl.py
def reload_config(self) -> None:
    """
    Reload configuration from file and environment variables.

    This method reloads the configuration and reapplies it to handlers.
    Useful when environment variables or the config file have changed
    after the singleton was initialized.

    Note: This will reinitialize handlers with the new configuration.
    """
    # Reload configuration
    self._config_manager.reload()

    # Get configuration values
    printer_level = self._config_manager.get_printer_level()
    file_logger_level = self._config_manager.get_file_logger_level()
    global_log_level = self._config_manager.get_log_level()

    # Check if specific levels are explicitly set (not just defaults)
    # Priority: specific levels > global level
    # Only apply global level if specific levels are not explicitly set
    printer_level_explicit = self._config_manager.has_key("printer-level")
    file_logger_level_explicit = self._config_manager.has_key("file-logger-level")
    global_log_level_explicit = self._config_manager.has_key("log-level")

    # Respect manually set levels: don't override if set via set_level()
    printer_manually_set = self._printer.level_manually_set
    logger_manually_set = self._logger.level_manually_set

    # Reapply to handlers with priority logic (skip if manually set)
    if not printer_manually_set:
        effective_printer = (
            printer_level
            if printer_level_explicit
            else global_log_level
            if global_log_level_explicit
            else printer_level
        )
        self.set_printer_level(effective_printer, force=True)
        self._printer.mark_level_as_configured()

    if not logger_manually_set:
        effective_logger = (
            file_logger_level
            if file_logger_level_explicit
            else (
                global_log_level if global_log_level_explicit else file_logger_level
            )
        )
        self.set_logger_level(effective_logger, force=True)
        self._logger.mark_level_as_configured()

    # Reinitialize logger with new rotation / retention / compression settings
    self._rebuild_logger()
    if not logger_manually_set:
        self._logger.mark_level_as_configured()

    # Reinitialize printer with new indent settings
    self._rebuild_printer()
    if not printer_manually_set:
        self._printer.mark_level_as_configured()

configure

configure(config_dict: dict[str, Any] | None = None, **kwargs: Any) -> bool

Configure Ezpl dynamically.

PARAMETER DESCRIPTION
config_dict

Dictionary of configuration values to update

TYPE: dict[str, Any] | None DEFAULT: None

**kwargs

Configuration options (alternative to config_dict): - log_file or log-file: Path to log file - printer_level or printer-level: Printer log level - logger_level or file-logger-level: File logger level - level or log-level: Set both printer and logger level - log_rotation or log-rotation: Rotation setting (e.g., "10 MB", "1 day") - log_retention or log-retention: Retention period (e.g., "7 days") - log_compression or log-compression: Compression format (e.g., "zip", "gz") - indent_step or indent-step: Indentation step size - indent_symbol or indent-symbol: Symbol for indentation - base_indent_symbol or base-indent-symbol: Base indentation symbol

TYPE: Any DEFAULT: {}

RETURNS DESCRIPTION
bool

True if configuration was applied, False if it was blocked by lock.

Note: Changes are persisted to the configuration file.

Source code in src/ezpl/ezpl.py
def configure(
    self, config_dict: dict[str, Any] | None = None, **kwargs: Any
) -> bool:
    """
    Configure Ezpl dynamically.

    Args:
        config_dict: Dictionary of configuration values to update
        **kwargs: Configuration options (alternative to config_dict):
            - log_file or log-file: Path to log file
            - printer_level or printer-level: Printer log level
            - logger_level or file-logger-level: File logger level
            - level or log-level: Set both printer and logger level
            - log_rotation or log-rotation: Rotation setting (e.g., "10 MB", "1 day")
            - log_retention or log-retention: Retention period (e.g., "7 days")
            - log_compression or log-compression: Compression format (e.g., "zip", "gz")
            - indent_step or indent-step: Indentation step size
            - indent_symbol or indent-symbol: Symbol for indentation
            - base_indent_symbol or base-indent-symbol: Base indentation symbol

    Returns:
        True if configuration was applied, False if it was blocked by lock.

    Note: Changes are persisted to the configuration file.
    """
    # Merge config_dict and kwargs
    if config_dict:
        kwargs.update(config_dict)

    # Special control flag (not stored in configuration):
    # - force=True allows configure() even when configuration is locked
    force = kwargs.pop("force", False)
    owner = kwargs.pop("owner", None)
    token = kwargs.pop("token", None)

    # If configuration is locked and not forced, warn and return False
    if not self._can_write_config(force=force, owner=owner, token=token):
        warnings.warn(
            "Ezpl configuration is locked. Call Ezpl.unlock_config() or pass a "
            "valid owner/token with force=True to override.",
            UserWarning,
            stacklevel=2,
        )
        return False

    # Normalize keys: convert underscores to hyphens for consistency
    normalized_config = {}
    key_mapping = {
        "log_file": "log-file",
        "printer_level": "printer-level",
        "logger_level": "file-logger-level",
        "level": "log-level",
        "log_rotation": "log-rotation",
        "log_retention": "log-retention",
        "log_compression": "log-compression",
        "indent_step": "indent-step",
        "indent_symbol": "indent-symbol",
        "base_indent_symbol": "base-indent-symbol",
    }

    for key, value in kwargs.items():
        # Use normalized key if mapping exists, otherwise keep original
        normalized_key = key_mapping.get(key, key)
        normalized_config[normalized_key] = value

    # Update configuration manager
    self._config_manager.update(normalized_config)
    self._config_manager.save()

    # Apply changes to handlers
    if "log-file" in normalized_config:
        self.set_log_file(normalized_config["log-file"])

    # Handle log level changes with priority: specific > global
    self._apply_level_priority(
        printer_level=normalized_config.get("printer-level"),
        file_logger_level=normalized_config.get("file-logger-level"),
        global_level=normalized_config.get("log-level"),
        force=force,
        owner=owner,
        token=token,
    )

    # Reinitialize logger if rotation settings changed
    rotation_changed = any(
        key in normalized_config
        for key in ["log-rotation", "log-retention", "log-compression"]
    )
    if rotation_changed:
        self._rebuild_logger()

    # Reinitialize printer if indent settings changed
    indent_changed = any(
        key in normalized_config
        for key in ["indent-step", "indent-symbol", "base-indent-symbol"]
    )
    if indent_changed:
        self._rebuild_printer()

    return True