# -*- 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')