Files

668 lines
21 KiB
Python

"""
@author: Azornes
@title: AzLogs
@version: 2.0.1
@description: Logging Setup - Central logging system
Features:
- Different log levels (DEBUG, INFO, WARN, ERROR)
- Ability to enable/disable logs globally or per module
- Colored logs in console
- Log file rotation
- Configuration via environment variables
"""
import json
import logging
import os
import re
import sys
import threading
import time
import traceback
from enum import IntEnum
from logging.handlers import RotatingFileHandler
# Log levels
class LogLevel(IntEnum):
DEBUG = 10
INFO = 20
WARN = 30
ERROR = 40
FATAL = 50
NONE = 100
# Level mapping
LEVEL_MAP = {
LogLevel.DEBUG: logging.DEBUG,
LogLevel.INFO: logging.INFO,
LogLevel.WARN: logging.WARNING,
LogLevel.ERROR: logging.ERROR,
LogLevel.FATAL: logging.CRITICAL,
LogLevel.NONE: logging.CRITICAL + 1,
}
# ANSI reset sequence (all coloring uses LEVEL_THEME RGB values)
ANSI_RESET = "\033[0m"
# RGB color theme per log level
LEVEL_THEME = {
"DEBUG": (155, 89, 182), # #9B59B6
"INFO": (46, 204, 113), # #2ECC71
"WARN": (243, 156, 18), # #F39C12
"ERROR": (192, 57, 43), # #C0392B
"FATAL": (231, 76, 60), # #E74C3C — brighter red than ERROR
}
# Default configuration
DEFAULT_CONFIG = {
"global_level": LogLevel.INFO,
"module_settings": {},
"use_colors": True,
"log_to_file": False,
"log_dir": "logs",
"max_file_size_mb": 10,
"backup_count": 5,
"timestamp_format": "%H:%M:%S",
"level_field_width": 5,
"source_field_width": 0,
}
_WINDOWS_VT_PROCESSING_ENABLED = False
def rotated_log_filename(default_name):
"""Keep the .log extension at the end of rotated log filenames."""
match = re.fullmatch(r"(.+)\.log\.(\d+)", str(default_name or ""))
if not match:
return default_name
return f"{match.group(1)}.{match.group(2)}.log"
def parse_rotated_log_filename(filename):
"""Return the log family and rotation number for the current filename style."""
name = os.path.basename(str(filename or ""))
match = re.fullmatch(r"(.+)\.(\d+)\.log", name)
if match:
return match.group(1), int(match.group(2))
if name.endswith(".log"):
return name[:-4], 0
return None
def _enable_windows_virtual_terminal_processing():
"""Enable ANSI escape sequence handling for classic Windows consoles."""
global _WINDOWS_VT_PROCESSING_ENABLED
if _WINDOWS_VT_PROCESSING_ENABLED or os.name != "nt":
return
try:
import ctypes
enable_virtual_terminal_processing = 0x0004
std_handles = (-11, -12)
invalid_handle_value = ctypes.c_void_p(-1).value
kernel32 = ctypes.windll.kernel32
kernel32.GetStdHandle.restype = ctypes.c_void_p
enabled_any = False
for std_handle in std_handles:
handle = kernel32.GetStdHandle(std_handle)
if handle in (None, 0, invalid_handle_value):
continue
mode = ctypes.c_uint32()
if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
continue
new_mode = mode.value | enable_virtual_terminal_processing
if new_mode != mode.value:
kernel32.SetConsoleMode(handle, new_mode)
enabled_any = True
_WINDOWS_VT_PROCESSING_ENABLED = enabled_any
except Exception:
return
def _get_console_output_stream():
"""Return the console stream that preserves ANSI colors under wrapped launchers."""
for stream_name in ("__stdout__", "__stderr__", "stdout", "stderr"):
stream = getattr(sys, stream_name, None)
if stream is None:
continue
if getattr(stream, "closed", False):
continue
return stream
return None
class SafeRotatingFileHandler(RotatingFileHandler):
"""Rotating file handler that disables itself if a launcher breaks its stream."""
def __init__(self, *args, **kwargs):
self._disabled_by_stream_error = False
super().__init__(*args, **kwargs)
self.namer = rotated_log_filename
def emit(self, record):
if self._disabled_by_stream_error:
return
try:
if self.stream is not None and self.maxBytes > 0:
tell = getattr(self.stream, "tell", None)
if not callable(tell):
raise OSError(
"Log file stream does not support tell(); disabling file logs"
)
super().emit(record)
except Exception:
self.handleError(record)
def handleError(self, record):
self._disabled_by_stream_error = True
try:
self.close()
except Exception:
pass
class DirectConsoleHandler(logging.Handler):
"""Console handler that writes directly to the original console stream."""
terminator = "\n"
def __init__(self, stream=None):
super().__init__()
self.stream = stream
def emit(self, record):
try:
stream = self.stream or _get_console_output_stream()
if stream is None:
return
message = self.format(record) + self.terminator
try:
stream.write(message)
except UnicodeEncodeError:
encoding = getattr(stream, "encoding", None) or "utf-8"
safe_message = message.encode(
encoding,
errors="replace",
).decode(encoding, errors="replace")
stream.write(safe_message)
stream.flush()
except Exception:
self.handleError(record)
class ColoredFormatter(logging.Formatter):
"""Formatter that adds colors to console logs"""
def __init__(
self,
fmt=None,
datefmt=None,
use_colors=True,
level_field_width=DEFAULT_CONFIG["level_field_width"],
source_field_width=DEFAULT_CONFIG["source_field_width"],
include_milliseconds=True,
include_brackets=True,
):
super().__init__(fmt, datefmt)
self.use_colors = use_colors
self.level_field_width = self._field_width(level_field_width)
self.source_field_width = self._field_width(source_field_width)
self.include_milliseconds = include_milliseconds
self.include_brackets = include_brackets
def format(self, record):
# Get the formatted message from the record
message = record.getMessage()
if record.exc_info:
message += "\n" + self.formatException(record.exc_info)
levelname = self._display_level_name(record.levelname)
level = self._format_level(levelname)
logger_name = self._display_logger_name(record.name)
logger_root, logger_detail = self._split_logger_name(logger_name)
source = (
f"({logger_detail}:{record.lineno})"
if logger_detail
else f"({logger_root}:{record.lineno})"
)
if self.source_field_width:
source = source.ljust(self.source_field_width)
timestamp = self._format_time(record)
separator = " "
root_label = logger_root
if self.include_brackets:
timestamp = f"[{timestamp}]"
root_label = f"[{root_label}]"
if self.use_colors:
level = self._color_level_badge(levelname, level)
timestamp = self._color_context_badge(timestamp)
root_label = self._color_context_badge(root_label)
separator = self._color_separator(levelname, separator)
message = self._color_message(levelname, message)
return f"{level}{timestamp}{separator}{root_label}{separator} {message} {source}"
return f"{level} {timestamp} {root_label} {message} {source}"
def _field_width(self, value):
try:
return max(0, int(value))
except (TypeError, ValueError):
return 0
def _display_logger_name(self, name):
return str(name or "").removeprefix("azlogs.")
def _split_logger_name(self, name):
parts = str(name or "").split(".", 1)
if len(parts) == 1:
return parts[0], ""
return parts[0], parts[1]
def _display_level_name(self, levelname):
name = str(levelname or "")
if name == "WARNING":
return "WARN"
if name == "CRITICAL":
return "FATAL"
return name
def _format_level(self, levelname):
if self.include_brackets:
width = self.level_field_width + 2
return f"[{levelname}]".ljust(width)
return levelname.ljust(self.level_field_width)
def _format_time(self, record):
timestamp = time.strftime(
self.datefmt or "%H:%M:%S", self.converter(record.created)
)
if self.include_milliseconds:
return f"{timestamp}.{int(record.msecs):03d}"
return timestamp
def _color_level_badge(self, levelname, text):
rgb = LEVEL_THEME.get(levelname)
if not rgb:
return text
r, g, b = rgb
return f"\033[1;97;48;2;{r};{g};{b}m {text} {ANSI_RESET}"
def _color_context_badge(self, text):
return f"\033[97;48;2;38;63;76m {text} {ANSI_RESET}"
def _color_separator(self, levelname, text):
rgb = LEVEL_THEME.get(levelname)
if not rgb:
return text
r, g, b = rgb
return f"\033[48;2;{r};{g};{b}m{text}{ANSI_RESET}"
def _color_message(self, levelname, text):
rgb = LEVEL_THEME.get(levelname)
if not rgb:
# Fallback: cyan for unknown levels
return f"\033[96m{text}{ANSI_RESET}"
r, g, b = rgb
return f"\033[38;2;{r};{g};{b}m{text}{ANSI_RESET}"
class AzLogsLogger:
"""Main logger class for AzLogs"""
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self):
if self._initialized:
return
self.config = DEFAULT_CONFIG.copy()
self.enabled = True
self.loggers = {}
self.file_handlers = {}
self._lock = threading.RLock()
# Load configuration from environment variables
self._load_config_from_env()
self._initialized = True
def _load_config_from_env(self):
"""Load configuration from environment variables"""
# Global level
if "AZLOGS_LOG_LEVEL" in os.environ:
level_name = os.environ["AZLOGS_LOG_LEVEL"].upper()
if hasattr(LogLevel, level_name):
self.config["global_level"] = getattr(LogLevel, level_name)
# Module settings
if "AZLOGS_MODULE_LEVELS" in os.environ:
try:
module_settings = json.loads(os.environ["AZLOGS_MODULE_LEVELS"])
for module, level_name in module_settings.items():
if hasattr(LogLevel, level_name.upper()):
self.config["module_settings"][module] = getattr(
LogLevel, level_name.upper()
)
except json.JSONDecodeError:
pass
# Other settings
if "AZLOGS_USE_COLORS" in os.environ:
self.config["use_colors"] = (
os.environ["AZLOGS_USE_COLORS"].lower() == "true"
)
if "AZLOGS_LOG_TO_FILE" in os.environ:
self.config["log_to_file"] = (
os.environ["AZLOGS_LOG_TO_FILE"].lower() == "true"
)
if "AZLOGS_LOG_DIR" in os.environ:
self.config["log_dir"] = os.environ["AZLOGS_LOG_DIR"]
if "AZLOGS_MAX_FILE_SIZE_MB" in os.environ:
try:
self.config["max_file_size_mb"] = int(
os.environ["AZLOGS_MAX_FILE_SIZE_MB"]
)
except ValueError:
pass
if "AZLOGS_BACKUP_COUNT" in os.environ:
try:
self.config["backup_count"] = int(os.environ["AZLOGS_BACKUP_COUNT"])
except ValueError:
pass
def configure(self, config):
"""Configure the logger"""
self.config.update(config)
self.reset_loggers()
if self.config.get("use_colors"):
_enable_windows_virtual_terminal_processing()
# If file logging is enabled, ensure the directory exists
if self.config.get("log_to_file") and self.config.get("log_dir"):
try:
os.makedirs(self.config["log_dir"], exist_ok=True)
except OSError as e:
# This is a fatal situation, so use print
print(
f"[FATAL] Could not create log directory: {self.config['log_dir']}. Error: {e}"
)
traceback.print_exc()
# Disable file logging to avoid further errors
self.config["log_to_file"] = False
return self
def set_enabled(self, enabled):
"""Enable/disable the logger globally"""
self.enabled = enabled
return self
def set_global_level(self, level):
"""Set global logging level"""
self.config["global_level"] = level
return self
def set_module_level(self, module, level):
"""Set logging level for a specific module"""
self.config["module_settings"][module] = level
return self
def is_level_enabled(self, module, level):
"""Check if a given logging level is active for the module"""
if not self.enabled:
return False
# Determine effective logging level, considering module and global settings
effective_level = self.config["module_settings"].get(
module, self.config["global_level"]
)
# If effective level is NONE, logging is completely disabled
if effective_level == LogLevel.NONE:
return False
# Otherwise check if log level is high enough
return level >= effective_level
def _sanitize_logger_name(self, value):
"""Sanitize logger/module names for safe filesystem usage."""
sanitized = re.sub(r'[<>:"/\\|?*\s]+', "_", str(value or "")).strip("._")
return sanitized or "default"
def _root_module_name(self, module):
"""Return the top-level logger name used for shared log files."""
return str(module or "default").split(".", 1)[0] or "default"
def _get_log_file(self, module):
safe_root = self._sanitize_logger_name(self._root_module_name(module))
return os.path.join(self.config["log_dir"], f"azlogs_{safe_root}.log")
def _get_file_handler(self, module):
"""Get or create a shared rotating file handler for the root module."""
log_file = self._get_log_file(module)
# Fast path without lock
if log_file in self.file_handlers:
return self.file_handlers[log_file]
with self._lock:
# Double-check after acquiring lock
if log_file in self.file_handlers:
return self.file_handlers[log_file]
os.makedirs(self.config["log_dir"], exist_ok=True)
file_handler = SafeRotatingFileHandler(
log_file,
maxBytes=self.config["max_file_size_mb"] * 1024 * 1024,
backupCount=self.config["backup_count"],
encoding="utf-8",
)
file_formatter = ColoredFormatter(
datefmt="%Y-%m-%d %H:%M:%S",
use_colors=False,
source_field_width=self.config["source_field_width"],
include_milliseconds=True,
)
file_handler.setFormatter(file_formatter)
self.file_handlers[log_file] = file_handler
return file_handler
def reset_loggers(self):
"""Remove configured handlers so new settings are applied cleanly."""
with self._lock:
handlers_to_close = set()
for module in self.loggers:
configured_logger = logging.getLogger(f"azlogs.{module}")
for handler in list(configured_logger.handlers):
configured_logger.removeHandler(handler)
if isinstance(handler, RotatingFileHandler):
handlers_to_close.add(handler)
for handler in self.file_handlers.values():
handlers_to_close.add(handler)
for handler in handlers_to_close:
handler.close()
self.loggers = {}
self.file_handlers = {}
return self
def _get_logger(self, module):
"""Get or create a logger for the module"""
# Fast path without lock
if module in self.loggers:
return self.loggers[module]
with self._lock:
# Double-check after acquiring lock
if module in self.loggers:
return self.loggers[module]
# Create new logger
new_logger = logging.getLogger(f"azlogs.{module}")
new_logger.setLevel(logging.DEBUG) # Set lowest level, filtering will be done later
new_logger.propagate = False
# Add console handler
if self.config["use_colors"]:
_enable_windows_virtual_terminal_processing()
console_stream = _get_console_output_stream()
console_handler = DirectConsoleHandler(console_stream)
console_formatter = ColoredFormatter(
fmt="[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s",
datefmt=self.config["timestamp_format"],
use_colors=self.config["use_colors"],
level_field_width=self.config["level_field_width"],
source_field_width=self.config["source_field_width"],
include_brackets=False,
)
console_handler.setFormatter(console_formatter)
new_logger.addHandler(console_handler)
# Add file handler if file logging is enabled
if self.config["log_to_file"]:
try:
file_handler = self._get_file_handler(module)
new_logger.addHandler(file_handler)
except OSError as e:
self.config["log_to_file"] = False
new_logger.warning(
"AzLogs: disabling file logging after handler setup failed for %s: %s",
self._get_log_file(module),
e,
)
self.loggers[module] = new_logger
return new_logger
def log(self, module, level, *args, **kwargs):
"""Write log"""
if not self.is_level_enabled(module, level):
return
logger = self._get_logger(module)
# Convert arguments to string
message = " ".join(str(arg) for arg in args)
# Extract known kwargs
exc_info = kwargs.get("exc_info")
stacklevel = kwargs.get("stacklevel", 1)
extra = kwargs.get("extra")
# Map LogLevel to logging level
log_level = LEVEL_MAP.get(level, logging.INFO)
# Write log
logger.log(log_level, message, exc_info=exc_info, stacklevel=stacklevel, extra=extra)
def debug(self, module, *args, **kwargs):
"""Log at DEBUG level"""
self.log(module, LogLevel.DEBUG, *args, **kwargs)
def info(self, module, *args, **kwargs):
"""Log at INFO level"""
self.log(module, LogLevel.INFO, *args, **kwargs)
def warn(self, module, *args, **kwargs):
"""Log at WARN level"""
self.log(module, LogLevel.WARN, *args, **kwargs)
def error(self, module, *args, **kwargs):
"""Log at ERROR level"""
self.log(module, LogLevel.ERROR, *args, **kwargs)
def exception(self, module, *args, **kwargs):
"""Log exception at ERROR level"""
kwargs["exc_info"] = True
self.log(module, LogLevel.ERROR, *args, **kwargs)
def fatal(self, module, *args, **kwargs):
"""Log at FATAL level"""
self.log(module, LogLevel.FATAL, *args, **kwargs)
# Singleton
logger = AzLogsLogger()
# Helper functions
def debug(module, *args, **kwargs):
"""Log at DEBUG level"""
logger.log(module, LogLevel.DEBUG, *args, **kwargs)
def info(module, *args, **kwargs):
"""Log at INFO level"""
logger.log(module, LogLevel.INFO, *args, **kwargs)
def warn(module, *args, **kwargs):
"""Log at WARN level"""
logger.log(module, LogLevel.WARN, *args, **kwargs)
def error(module, *args, **kwargs):
"""Log at ERROR level"""
logger.log(module, LogLevel.ERROR, *args, **kwargs)
def exception(module, *args, **kwargs):
"""Log exception at ERROR level"""
kwargs["exc_info"] = True
logger.log(module, LogLevel.ERROR, *args, **kwargs)
def fatal(module, *args, **kwargs):
"""Log at FATAL level"""
logger.log(module, LogLevel.FATAL, *args, **kwargs)
# Function to quickly enable/disable debugging
def set_debug(enabled=True):
"""Enable/disable debugging globally"""
if enabled:
logger.set_global_level(LogLevel.DEBUG)
else:
logger.set_global_level(LogLevel.INFO)
return logger
# Function to enable/disable file logging
def set_file_logging(enabled=True, log_dir=None):
"""Enable/disable logging to file"""
logger.config["log_to_file"] = enabled
if log_dir:
logger.config["log_dir"] = log_dir
os.makedirs(log_dir, exist_ok=True)
# Reset loggers to apply new settings
logger.reset_loggers()
return logger