blob: e1e85701f3c392a8ea7d8925dab71ed7ca8836bd [file]
# Copyright 2026 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Generic logging utilities with ANSI color support."""
from __future__ import annotations
import logging
import os
import re
import sys
from typing import Callable
from colorama import Fore, Style
_LEVEL_COLORS: dict[int, str] = {
logging.DEBUG: Fore.LIGHTBLACK_EX,
logging.INFO: Fore.GREEN,
logging.WARNING: Fore.YELLOW,
logging.ERROR: Fore.RED + Style.BRIGHT,
logging.CRITICAL: Fore.RED + Style.BRIGHT,
}
_PREFIX_RE: re.Pattern[str] = re.compile(r"^(\[[^\]\n]+\])")
_TIMESTAMP_RE: re.Pattern[str] = re.compile(
r"(\b(?:\d{4}-)?\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:[.,]\d{3})?\b)"
)
_TARGET_NAME_RE: re.Pattern[str] = re.compile(
r"\b(fuchsia-(?:emulator|[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}|[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*))\b"
)
_PREFIX_REPL: str = f"{Fore.CYAN}\\1{Style.RESET_ALL}"
_TIMESTAMP_REPL: str = f"{Fore.LIGHTBLACK_EX}\\1{Style.RESET_ALL}"
_TARGET_REPL: str = f"{Fore.CYAN}\\1{Style.RESET_ALL}"
_RESULT_COLORS: dict[str, str] = {
"PASS": f"{Fore.GREEN}{Style.BRIGHT}PASS{Style.RESET_ALL}",
"FAIL": f"{Fore.RED}{Style.BRIGHT}FAIL{Style.RESET_ALL}",
"ERROR": f"{Fore.RED}{Style.BRIGHT}ERROR{Style.RESET_ALL}",
"SKIP": f"{Fore.YELLOW}SKIP{Style.RESET_ALL}",
}
_TEST_RESULT_RE: re.Pattern[str] = re.compile(r"\b(PASS|FAIL|ERROR|SKIP)\b")
_RET_CODE_RE: re.Pattern[str] = re.compile(r"\[ret: (\d+)\]")
def _colorize_ret(match: re.Match[str]) -> str:
code = match.group(1)
color = Fore.GREEN if code == "0" else Fore.RED
return f"[{color}ret: {code}{Style.RESET_ALL}]"
def lacewing_log_formatter(formatted: str) -> str:
"""Applies Lacewing-specific regex color highlights to a formatted log line."""
# Colorize leading bracketed prefix e.g. [GeneratedLocalTestbed] or [Mobly Driver]
formatted = _PREFIX_RE.sub(_PREFIX_REPL, formatted)
# Dim / light-black timestamp e.g. 08-12 13:38:09.334
formatted = _TIMESTAMP_RE.sub(_TIMESTAMP_REPL, formatted)
# Colorize Fuchsia target nodenames (e.g. fuchsia-1a7d-1303-a909, fuchsia-emulator)
formatted = _TARGET_NAME_RE.sub(_TARGET_REPL, formatted)
# Highlight test completion results and return codes
if "[Test]" in formatted or "Completed:" in formatted:
formatted = _TEST_RESULT_RE.sub(
lambda m: _RESULT_COLORS[m.group(1)], formatted
)
formatted = _RET_CODE_RE.sub(_colorize_ret, formatted)
return formatted
def supports_color(stream: object = None) -> bool:
"""Returns True if the given stream (or stdout) supports ANSI color output."""
if os.environ.get("NO_COLOR"):
return False
if os.environ.get("TERM") == "dumb":
return False
if (
os.environ.get("FORCE_COLOR")
or os.environ.get("CLICOLOR_FORCE", "0") != "0"
):
return True
if os.environ.get("FUCHSIA_FX_TEST_RUN") == "1":
return True
if stream is None:
stream = sys.stdout
return bool(hasattr(stream, "isatty") and stream.isatty())
class ColoredFormatter(logging.Formatter):
"""A logging formatter that applies ANSI colors when color is supported."""
def __init__(
self,
fmt: str | None = None,
datefmt: str | None = None,
prefix: str | None = None,
use_color: bool | None = None,
format_func: Callable[[str], str] | None = lacewing_log_formatter,
) -> None:
super().__init__(fmt=fmt, datefmt=datefmt)
self.use_color: bool = (
use_color if use_color is not None else supports_color(sys.stdout)
)
self.format_func: Callable[[str], str] | None = format_func
def format(self, record: logging.LogRecord) -> str:
if not self.use_color:
return super().format(record)
orig_levelname = record.levelname
try:
color = _LEVEL_COLORS.get(record.levelno, "")
record.levelname = f"{color}{record.levelname}{Style.RESET_ALL}"
formatted = super().format(record)
if self.format_func is not None:
formatted = self.format_func(formatted)
return formatted
finally:
record.levelname = orig_levelname
def colorize_logger(
logger: logging.Logger,
prefix: str | None = None,
format_func: Callable[[str], str] | None = lacewing_log_formatter,
) -> None:
"""Updates any console StreamHandler on the logger to use ColoredFormatter.
Leaves FileHandlers untouched to ensure on-disk logs remain plain text.
Args:
logger: The Logger instance whose StreamHandlers to colorize.
prefix: Optional prefix for the formatter.
format_func: Optional function to format/transform the log message after standard
formatting. Defaults to `lacewing_log_formatter`.
"""
for handler in logger.handlers:
if isinstance(handler, logging.StreamHandler) and not isinstance(
handler, logging.FileHandler
):
fmt = getattr(handler.formatter, "_fmt", None)
datefmt = getattr(handler.formatter, "datefmt", None)
handler.setFormatter(
ColoredFormatter(
fmt=fmt,
datefmt=datefmt,
prefix=prefix,
format_func=format_func,
)
)
__all__ = [
"ColoredFormatter",
"colorize_logger",
"lacewing_log_formatter",
"supports_color",
]