123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417 |
- # pretty_logger.py
-
- import json
- import logging
- import logging.handlers
- import sys
- import threading
- import time
- from contextlib import ContextDecorator
- from functools import wraps
-
- # ANSI escape codes for colors
- RESET = "\x1b[0m"
- BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE = (
- "\x1b[30m",
- "\x1b[31m",
- "\x1b[32m",
- "\x1b[33m",
- "\x1b[34m",
- "\x1b[35m",
- "\x1b[36m",
- "\x1b[37m",
- )
-
- _LEVEL_COLORS = {
- logging.DEBUG: CYAN,
- logging.INFO: GREEN,
- logging.WARNING: YELLOW,
- logging.ERROR: RED,
- logging.CRITICAL: MAGENTA,
- }
-
-
- def _supports_color() -> bool:
- """
- Detect whether the running terminal supports ANSI colors.
- """
- if hasattr(sys.stdout, "isatty") and sys.stdout.isatty():
- platform = sys.platform
- if platform == "win32":
- # On Windows, modern terminals may support ANSI; assume yes for Windows 10+
- return True
- return True
- return False
-
-
- class PrettyFormatter(logging.Formatter):
- """
- A logging.Formatter that outputs colorized logs to the console
- (if enabled) and supports JSON formatting (if requested).
- """
-
- def __init__(
- self,
- fmt: str = None,
- datefmt: str = "%Y-%m-%d %H:%M:%S",
- use_colors: bool = True,
- json_output: bool = False,
- ):
- super().__init__(fmt=fmt, datefmt=datefmt)
- self.use_colors = use_colors and _supports_color()
- self.json_output = json_output
-
- def format(self, record: logging.LogRecord) -> str:
- # If JSON output is requested, dump a dict of relevant fields
- if self.json_output:
- log_record = {
- "timestamp": self.formatTime(record, self.datefmt),
- "name": record.name,
- "level": record.levelname,
- "message": record.getMessage(),
- }
- # Include extra/contextual fields
- for k, v in record.__dict__.get("extra_fields", {}).items():
- log_record[k] = v
- if record.exc_info:
- log_record["exception"] = self.formatException(record.exc_info)
- return json.dumps(log_record, ensure_ascii=False)
-
- # Otherwise, build a colorized/“pretty” line
- level_color = _LEVEL_COLORS.get(record.levelno, WHITE) if self.use_colors else ""
- reset = RESET if self.use_colors else ""
- timestamp = self.formatTime(record, self.datefmt)
- name = record.name
- level = record.levelname
-
- # Basic format: [timestamp] [LEVEL] [name]: message
- base = f"[{timestamp}] [{level}] [{name}]: {record.getMessage()}"
-
- # If there are any extra fields attached via LoggerAdapter, append them
- extra_fields = record.__dict__.get("extra_fields", {})
- if extra_fields:
- # key1=val1 key2=val2 …
- extras_str = " ".join(f"{k}={v}" for k, v in extra_fields.items())
- base = f"{base} :: {extras_str}"
-
- # If exception info is present, include traceback
- if record.exc_info:
- exc_text = self.formatException(record.exc_info)
- base = f"{base}\n{exc_text}"
-
- if self.use_colors:
- return f"{level_color}{base}{reset}"
- else:
- return base
-
-
- class JsonFormatter(PrettyFormatter):
- """
- Shortcut to force JSON formatting (ignores color flags).
- """
-
- def __init__(self, datefmt: str = "%Y-%m-%d %H:%M:%S"):
- super().__init__(fmt=None, datefmt=datefmt, use_colors=False, json_output=True)
-
-
- class ContextFilter(logging.Filter):
- """
- Inject extra_fields from a LoggerAdapter into the LogRecord, so the Formatter can find them.
- """
-
- def filter(self, record: logging.LogRecord) -> bool:
- extra = getattr(record, "extra", None)
- if isinstance(extra, dict):
- record.extra_fields = extra
- else:
- record.extra_fields = {}
- return True
-
-
- class Timer(ContextDecorator):
- """
- Context manager & decorator to measure execution time of a code block or function.
- Usage as context manager:
- with Timer(logger, "block name"):
- # code …
- Usage as decorator:
- @Timer(logger, "function foo")
- def foo(...):
- ...
- """
-
- def __init__(self, logger: logging.Logger, name: str = None, level: int = logging.INFO):
- self.logger = logger
- self.name = name or ""
- self.level = level
-
- def __enter__(self):
- self.start_time = time.time()
- return self
-
- def __exit__(self, exc_type, exc, exc_tb):
- elapsed = time.time() - self.start_time
- label = f"[Timer:{self.name}]" if self.name else "[Timer]"
- self.logger.log(self.level, f"{label} elapsed {elapsed:.4f} sec")
- return False # do not suppress exceptions
-
-
- def log_function(logger: logging.Logger, level: int = logging.DEBUG):
- """
- Decorator factory to log function entry/exit and execution time.
- Usage:
- @log_function(logger, level=logging.INFO)
- def my_func(...):
- ...
- """
-
- def decorator(func):
- @wraps(func)
- def wrapper(*args, **kwargs):
- logger.log(level, f"➤ Entering {func.__name__}()")
- start = time.time()
- try:
- result = func(*args, **kwargs)
- except Exception:
- logger.exception(f"✖ Exception in {func.__name__}()")
- raise
- elapsed = time.time() - start
- logger.log(level, f"✔ Exiting {func.__name__}(), elapsed {elapsed:.4f} sec")
- return result
-
- return wrapper
-
- return decorator
-
-
- class PrettyLogger:
- """
- Encapsulates a logging.Logger with “pretty” formatting, colorized console output,
- rotating file handler, JSON option, contextual fields, etc.
- """
-
- _instances = {}
- _lock = threading.Lock()
-
- def __new__(cls, name: str = "root", **kwargs):
- """
- Implements a simple singleton: multiple calls with the same name return the same instance.
- """
- with cls._lock:
- if name not in cls._instances:
- instance = super().__new__(cls)
- cls._instances[name] = instance
- return cls._instances[name]
-
- def __init__(
- self,
- name: str = "root",
- level: int = logging.DEBUG,
- log_to_console: bool = True,
- console_level: int = logging.DEBUG,
- log_to_file: bool = False,
- log_file: str = "app.log",
- file_level: int = logging.INFO,
- max_bytes: int = 10 * 1024 * 1024, # 10 MB
- backup_count: int = 5,
- formatter: PrettyFormatter = None,
- json_output: bool = False,
- ):
- """
- Initialize (or re‐configure) a PrettyLogger.
-
- Args:
- name: logger name.
- level: root level for the logger (lowest level that will be processed).
- log_to_console: whether to attach a console (StreamHandler).
- console_level: level for console output.
- log_to_file: whether to attach a rotating file handler.
- log_file: path to the log file.
- file_level: level for file output.
- max_bytes: rotation threshold in bytes.
- backup_count: number of backup files to keep.
- formatter: an instance of PrettyFormatter. If None, a default is created.
- json_output: if True, forces JSON formatting on all handlers.
- """
- # Avoid re‐initializing if already done
- logger = logging.getLogger(name)
- if getattr(logger, "_pretty_logger_inited", False):
- return
-
- self.logger = logger
- self.logger.setLevel(level)
- self.logger.propagate = False # avoid double‐logging if root logger is configured elsewhere
-
- # Create a default formatter if not supplied
- if formatter is None:
- fmt = "[%(asctime)s] [%(levelname)s] [%(name)s]: %(message)s"
- formatter = PrettyFormatter(
- fmt=fmt, use_colors=not json_output, json_output=json_output
- )
-
- # Add ContextFilter so extra_fields is always present
- context_filter = ContextFilter()
- self.logger.addFilter(context_filter)
-
- # Console handler
- if log_to_console:
- ch = logging.StreamHandler(sys.stdout)
- ch.setLevel(console_level)
- ch.setFormatter(formatter)
- self.logger.addHandler(ch)
-
- # Rotating file handler
- if log_to_file:
- fh = logging.handlers.RotatingFileHandler(
- filename=log_file,
- maxBytes=max_bytes,
- backupCount=backup_count,
- encoding="utf-8",
- )
- fh.setLevel(file_level)
- # For file, typically we don’t colorize
- file_formatter = (
- JsonFormatter(datefmt="%Y-%m-%d %H:%M:%S")
- if json_output
- else PrettyFormatter(fmt=fmt, use_colors=False, json_output=False)
- )
- fh.setFormatter(file_formatter)
- self.logger.addHandler(fh)
-
- # Mark as initialized
- setattr(self.logger, "_pretty_logger_inited", True)
-
- def addHandler(self, handler: logging.Handler):
- """
- Add a custom handler to the logger.
- This can be used to add any logging.Handler instance.
- """
- self.logger.addHandler(handler)
-
- def bind(self, **kwargs):
- """
- Return a LoggerAdapter that automatically injects extra fields into all log records.
- Example:
- ctx_logger = pretty_logger.bind(request_id=123, user="alice")
- ctx_logger.info("Something happened") # will include request_id and user in the output
- """
- return logging.LoggerAdapter(self.logger, {"extra": kwargs})
-
- def add_stream_handler(
- self, stream=None, level: int = logging.DEBUG, formatter: PrettyFormatter = None
- ):
- """
- Add an additional stream handler (e.g. to stderr).
- """
- handler = logging.StreamHandler(stream or sys.stdout)
- handler.setLevel(level)
- if formatter is None:
- fmt = "[%(asctime)s] [%(levelname)s] [%(name)s]: %(message)s"
- formatter = PrettyFormatter(fmt=fmt)
- handler.setFormatter(formatter)
- self.logger.addHandler(handler)
- return handler
-
- def add_rotating_file_handler(
- self,
- log_file: str,
- level: int = logging.INFO,
- max_bytes: int = 10 * 1024 * 1024,
- backup_count: int = 5,
- json_output: bool = False,
- ):
- """
- Add another rotating file handler at runtime.
- """
- fh = logging.handlers.RotatingFileHandler(
- filename=log_file,
- maxBytes=max_bytes,
- backupCount=backup_count,
- encoding="utf-8",
- )
- fh.setLevel(level)
- if json_output:
- formatter = JsonFormatter()
- else:
- fmt = "[%(asctime)s] [%(levelname)s] [%(name)s]: %(message)s"
- formatter = PrettyFormatter(fmt=fmt, use_colors=False, json_output=False)
- fh.setFormatter(formatter)
- self.logger.addHandler(fh)
- return fh
-
- def remove_handler(self, handler: logging.Handler):
- """
- Remove a handler from the logger.
- """
- self.logger.removeHandler(handler)
-
- def set_level(self, level: int):
- """
- Change the logger’s root level at runtime.
- """
- self.logger.setLevel(level)
-
- def get_logger(self) -> logging.Logger:
- return self.logger
-
- # Convenience methods to expose common logging calls directly from this object:
-
- def debug(self, msg, *args, **kwargs):
- self.logger.debug(msg, *args, **kwargs)
-
- def info(self, msg, *args, **kwargs):
- self.logger.info(msg, *args, **kwargs)
-
- def warning(self, msg, *args, **kwargs):
- self.logger.warning(msg, *args, **kwargs)
-
- def error(self, msg, *args, **kwargs):
- self.logger.error(msg, *args, **kwargs)
-
- def critical(self, msg, *args, **kwargs):
- self.logger.critical(msg, *args, **kwargs)
-
- def exception(self, msg, *args, exc_info=True, **kwargs):
- """
- Log an error message with exception traceback. By default exc_info=True.
- """
- self.logger.error(msg, *args, exc_info=exc_info, **kwargs)
-
-
- # Convenience function for a module‐level “global” pretty logger:
- _global_loggers = {}
- _global_lock = threading.Lock()
-
-
- def get_pretty_logger(
- name: str = "root",
- level: int = logging.DEBUG,
- log_to_console: bool = True,
- console_level: int = logging.DEBUG,
- log_to_file: bool = False,
- log_file: str = "app.log",
- file_level: int = logging.INFO,
- max_bytes: int = 10 * 1024 * 1024,
- backup_count: int = 5,
- json_output: bool = False,
- ) -> PrettyLogger:
- """
- Return a PrettyLogger instance for `name`, creating/configuring it on first use.
- Subsequent calls with the same `name` will return the same logger (singleton‐style).
- """
- with _global_lock:
- if name not in _global_loggers:
- pl = PrettyLogger(
- name=name,
- level=level,
- log_to_console=log_to_console,
- console_level=console_level,
- log_to_file=log_to_file,
- log_file=log_file,
- file_level=file_level,
- max_bytes=max_bytes,
- backup_count=backup_count,
- json_output=json_output,
- )
- _global_loggers[name] = pl
- return _global_loggers[name]
|