Source code for ascii_colors.logging

# -*- coding: utf-8 -*-
"""
Logging compatibility layer for ascii_colors.
Provides standard logging module compatibility.
"""

import sys
import io
import warnings
from typing import Any, Dict, List, Optional, Union, Callable, Tuple, Iterator

from ascii_colors.constants import LogLevel, DEBUG, INFO, WARNING, ERROR, CRITICAL, NOTSET
from ascii_colors.core import ASCIIColors
from ascii_colors.handlers import Handler, ConsoleHandler, FileHandler, RotatingFileHandler, StreamHandler, FolderRouterHandler
from ascii_colors.formatters import Formatter, JSONFormatter

# Expose context methods for compatibility
set_context = ASCIIColors.set_context
clear_context = ASCIIColors.clear_context
context = ASCIIColors.context
get_thread_context = ASCIIColors.get_thread_context


# Cache for logger adapters
_logger_cache: Dict[str, "_AsciiLoggerAdapter"] = {}


[docs] class _AsciiLoggerAdapter: """Adapter that provides standard logging.Logger interface using ASCIIColors backend.""" def __init__(self, name: str): self.name = name self.level = NOTSET self.handlers: List[Handler] = [] self.disabled = False self.propagate = True def _get_effective_level(self) -> int: """Get the effective level for this logger.""" if self.level != NOTSET: return self.level # Return global level from ASCIIColors return ASCIIColors._global_level.value
[docs] def isEnabledFor(self, level: int) -> bool: """ Check if this logger is enabled for the specified level. This is a standard logging.Logger method that checks whether a message at the given level would actually be logged. Args: level: The log level to check (e.g., DEBUG, INFO, WARNING) Returns: True if the logger is enabled for the given level, False otherwise """ # Check if logger is disabled if self.disabled: return False # Get effective level and compare effective_level = self._get_effective_level() return level >= effective_level
[docs] def setLevel(self, level: Union[int, LogLevel]) -> None: """Set the logging level.""" if isinstance(level, LogLevel): self.level = level.value else: self.level = level
[docs] def getEffectiveLevel(self) -> int: """Get the effective level for this logger.""" return self._get_effective_level()
[docs] def debug(self, msg: str, *args, **kwargs) -> None: """Log a debug message.""" if self.isEnabledFor(DEBUG): self._log(LogLevel.DEBUG, msg, args, **kwargs)
[docs] def info(self, msg: str, *args, **kwargs) -> None: """Log an info message.""" if self.isEnabledFor(INFO): self._log(LogLevel.INFO, msg, args, **kwargs)
[docs] def warning(self, msg: str, *args, **kwargs) -> None: """Log a warning message.""" if self.isEnabledFor(WARNING): self._log(LogLevel.WARNING, msg, args, **kwargs)
[docs] def warn(self, msg: str, *args, **kwargs) -> None: """Deprecated alias for warning.""" self.warning(msg, *args, **kwargs)
[docs] def error(self, msg: str, *args, **kwargs) -> None: """Log an error message.""" if self.isEnabledFor(ERROR): self._log(LogLevel.ERROR, msg, args, **kwargs)
[docs] def critical(self, msg: str, *args, **kwargs) -> None: """Log a critical message.""" if self.isEnabledFor(CRITICAL): self._log(LogLevel.CRITICAL, msg, args, **kwargs)
[docs] def fatal(self, msg: str, *args, **kwargs) -> None: """Log a fatal message (alias for critical).""" self.critical(msg, *args, **kwargs)
[docs] def exception(self, msg: str, *args, **kwargs) -> None: """Log an exception message with traceback.""" kwargs['exc_info'] = True self.error(msg, *args, **kwargs)
[docs] def log(self, level: int, msg: str, *args, **kwargs) -> None: """Log a message at the specified level.""" if self.isEnabledFor(level): log_level = LogLevel(level) if isinstance(level, int) else level self._log(log_level, msg, args, **kwargs)
def _log(self, level: LogLevel, msg: str, args: Tuple, **kwargs) -> None: """Internal logging method.""" # Get exception info if requested exc_info = kwargs.get('exc_info', False) # Format message here before passing to ASCIIColors._log # ASCIIColors._log expects pre-formatted message formatted_msg = (msg % args) if args else msg # Call ASCIIColors._log with logger name ASCIIColors._log( level=level, message=formatted_msg, args=(), # Already formatted, no args needed exc_info=exc_info, logger_name=self.name, **{k: v for k, v in kwargs.items() if k not in ('exc_info', 'stack_info', 'extra')} )
[docs] def addHandler(self, hdlr: Handler) -> None: """Add a handler to this logger.""" ASCIIColors.add_handler(hdlr) self.handlers.append(hdlr)
[docs] def removeHandler(self, hdlr: Handler) -> None: """Remove a handler from this logger.""" ASCIIColors.remove_handler(hdlr) if hdlr in self.handlers: self.handlers.remove(hdlr)
[docs] def hasHandlers(self) -> bool: """Check if this logger has any handlers.""" return len(self.handlers) > 0 or len(ASCIIColors._handlers) > 0
[docs] def filter(self, record: Any) -> bool: """Filter method (stub for compatibility).""" return True
[docs] def handle(self, record: Any) -> None: """Handle a log record.""" pass
[docs] def findCaller(self, stack_info: bool = False, stacklevel: int = 1) -> Tuple: """Find the caller (stub for compatibility).""" import inspect frame = inspect.currentframe() if frame: frame = frame.f_back if frame and frame.f_back: frame = frame.f_back co = frame.f_code return (co.co_filename, frame.f_lineno, co.co_name, None) return ("(unknown file)", 0, "(unknown function)", None)
[docs] def getChild(self, suffix: str) -> "_AsciiLoggerAdapter": """Get a child logger.""" if self.root: return getLogger(f"{self.name}.{suffix}") return getLogger(suffix)
@property def root(self) -> bool: """Check if this is the root logger.""" return self.name == "root" or not self.name def __repr__(self) -> str: return f"<_AsciiLoggerAdapter({self.name!r})>"
[docs] def getLogger(name: Optional[str] = None) -> _AsciiLoggerAdapter: """ Get a logger with the specified name. This function mirrors logging.getLogger() for drop-in compatibility. """ name = name or "root" if name not in _logger_cache: _logger_cache[name] = _AsciiLoggerAdapter(name) return _logger_cache[name]
def _add_dual_console_handler( formatter: Formatter, console_level: Optional[int], console_stream: Optional[io.TextIOWrapper], ) -> None: """Attach a ConsoleHandler alongside a file/folder handler for dual logging. Used by :func:`basicConfig` when ``also_console=True`` so a single :func:`basicConfig` call can both persist logs to disk and mirror them to the terminal. Args: formatter: The formatter to attach to the new console handler. console_level: Optional explicit level for the console handler. Falls back to the current ``ASCIIColors`` global level when ``None``. console_stream: Optional stream override for the console handler. Defaults to ``sys.stderr``. """ console_handler = ConsoleHandler( level=console_level if console_level is not None else ASCIIColors._global_level, stream=console_stream or sys.stderr, ) console_handler.setFormatter(formatter) ASCIIColors.add_handler(console_handler)
[docs] def basicConfig( *, filename: Optional[str] = None, filemode: str = 'a', format: Optional[str] = None, datefmt: Optional[str] = None, style: str = '%', level: Optional[int] = None, stream: Optional[io.TextIOWrapper] = None, handlers: Optional[List[Handler]] = None, force: bool = False, log_folder: Optional[str] = None, log_folder_mode: str = 'rolling', log_folder_maxBytes: int = 0, log_folder_backupCount: int = 0, file_maxBytes: int = 0, file_backupCount: int = 0, also_console: bool = False, console_level: Optional[int] = None, console_stream: Optional[io.TextIOWrapper] = None, ) -> None: """ Configure basic logging. Mirrors logging.basicConfig() for drop-in compatibility. Folder routing: log_folder: Directory path to store routed log files. log_folder_mode: 'rolling', 'overwrite', or 'timestamp'. log_folder_maxBytes: Max size per file before rotation (rolling mode). log_folder_backupCount: Number of backups to keep (rolling mode). File rotation: file_maxBytes: Max size in bytes before rotating when using `filename` (0 disables rotation, uses plain FileHandler). file_backupCount: Number of backup files to keep when rotating. Dual logging: also_console: If True, also attach a ConsoleHandler when configuring a file or folder-based handler. Useful for getting both persistent file logs and real-time console output. Has no effect when no file/folder handler is configured (i.e. the default console-only path) or when an explicit ``handlers=`` list is supplied. console_level: Optional separate level for the added console handler. Defaults to the global level when None. console_stream: Optional separate stream for the added console handler. Defaults to ``sys.stderr``. Example: >>> import ascii_colors as logging >>> logging.basicConfig( ... filename="app.log", ... level=logging.INFO, ... also_console=True, # mirror to terminal too ... console_level=logging.DEBUG, # console can be chattier ... ) """ # Check if already configured (thread-safe) with ASCIIColors._handler_lock: handler_count = len(ASCIIColors._handlers) already_configured = ASCIIColors._basicConfig_called # If already configured (either via basicConfig or manually adding handlers), do nothing unless forced if (already_configured or handler_count > 0) and not force: return # Mark as configured (thread-safe) with ASCIIColors._handler_lock: ASCIIColors._basicConfig_called = True if force: ASCIIColors.clear_handlers() _logger_cache.clear() # Set level if provided (only after we've determined we should configure) if level is not None: ASCIIColors.set_log_level(level) # Create formatter fmt = format or "%(levelname)s:%(name)s:%(message)s" formatter = Formatter(fmt=fmt, datefmt=datefmt, style=style) # Handle explicit handlers list if handlers: for hdlr in handlers: if hdlr.formatter is None: hdlr.setFormatter(formatter) ASCIIColors.add_handler(hdlr) elif log_folder: hdlr = FolderRouterHandler( log_folder, mode=log_folder_mode, maxBytes=log_folder_maxBytes, backupCount=log_folder_backupCount, level=level or DEBUG, ) hdlr.setFormatter(formatter) ASCIIColors.add_handler(hdlr) if also_console: _add_dual_console_handler(formatter, console_level, console_stream) else: # Create default console or rotating file handler if filename: if file_maxBytes > 0: hdlr = RotatingFileHandler( filename, mode=filemode, maxBytes=file_maxBytes, backupCount=file_backupCount, ) else: hdlr = FileHandler(filename, mode=filemode) hdlr.setFormatter(formatter) ASCIIColors.add_handler(hdlr) if also_console: _add_dual_console_handler(formatter, console_level, console_stream) else: hdlr = ConsoleHandler(stream=stream or sys.stderr) hdlr.setFormatter(formatter) ASCIIColors.add_handler(hdlr) ASCIIColors._basicConfig_called = True
[docs] def shutdown() -> None: """Shutdown the logging system.""" for handler in ASCIIColors._handlers[:]: try: handler.close() except Exception: pass ASCIIColors.clear_handlers() _logger_cache.clear()
# Convenience function for exception logging def trace_exception(ex: Exception, enhanced: bool = True, max_width: Optional[int] = None) -> None: """Log an exception with traceback.""" from ascii_colors.utils import get_trace_exception formatted = get_trace_exception(ex, enhanced=enhanced, max_width=max_width) ASCIIColors._log(LogLevel.ERROR, formatted, (), exc_info=None, logger_name='trace_exception')