"""Читаемый вывод в консоли: цвета и перенос длинных строк. Windows-консоль по умолчанию не разбирает ANSI-последовательности и печатает их как мусор вида `[34mINFO[0m`. Начиная с Windows 10 разбор можно включить через WinAPI - это и делается ниже. Если включить не удалось (старая система, вывод перенаправлен в файл), цвета молча отключаются: лучше блёклый лог, чем лог с мусором. """ import logging import os import shutil import sys import textwrap from collections.abc import Callable __all__ = ["enable_ansi", "ConsoleFormatter", "setup_logging"] # Цвет только там, где он несёт смысл: обычные сообщения остаются обычными, # чтобы предупреждения и ошибки было видно с одного взгляда. COLORS = { "DEBUG": "\033[90m", "INFO": "", "WARNING": "\033[33m", "ERROR": "\033[31m", "CRITICAL": "\033[1;31m", } DIM = "\033[90m" RESET = "\033[0m" MIN_WIDTH = 60 FALLBACK_WIDTH = 100 def enable_ansi() -> bool: """Включает разбор ANSI в консоли Windows. Возвращает, можно ли красить.""" if not sys.stdout.isatty(): return False if os.name != "nt": return True try: import ctypes kernel32 = ctypes.windll.kernel32 handle = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE mode = ctypes.c_uint32() if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)): return False # ENABLE_VIRTUAL_TERMINAL_PROCESSING return bool(kernel32.SetConsoleMode(handle, mode.value | 0x0004)) except (ImportError, AttributeError, OSError): return False def console_width() -> int: """Ширина окна консоли. При перенаправлении в файл берём разумную по умолчанию.""" width = shutil.get_terminal_size((FALLBACK_WIDTH, 24)).columns return max(MIN_WIDTH, width) class ConsoleFormatter(logging.Formatter): """Формат вида `09:29:46 WARNING текст`, длинные строки переносятся по словам. Продолжение переносится с отступом под текст сообщения, а не под начало строки: так видно, где кончается одно сообщение и начинается следующее. """ def __init__(self, colored: bool, width: Callable[[], int] = console_width) -> None: super().__init__(datefmt="%H:%M:%S") self.colored = colored # Ширину берём через переданную функцию, а не из глобальной: окно консоли # меняется на ходу, а в тестах ширину надо задавать явно. self.width = width def format(self, record: logging.LogRecord) -> str: stamp = self.formatTime(record, self.datefmt) level = record.levelname head = f"{stamp} {level:<8}" indent = " " * len(head) body = record.getMessage() if record.exc_info: body = f"{body}\n{self.formatException(record.exc_info)}" lines: list[str] = [] room = self.width() - len(head) for chunk in body.splitlines() or [""]: lines.extend(textwrap.wrap(chunk, width=room) or [""]) if not self.colored: return "\n".join([head + lines[0]] + [indent + line for line in lines[1:]]) tint = COLORS.get(level, "") painted = f"{DIM}{stamp}{RESET} {tint}{level:<8}{RESET}" if tint \ else f"{DIM}{stamp}{RESET} {level:<8}" return "\n".join([painted + lines[0]] + [indent + line for line in lines[1:]]) def setup_logging(level: int = logging.INFO) -> bool: """Настраивает единый обработчик вывода. Возвращает, включены ли цвета.""" colored = enable_ansi() handler = logging.StreamHandler(sys.stdout) handler.setFormatter(ConsoleFormatter(colored)) root = logging.getLogger() root.handlers[:] = [handler] root.setLevel(level) return colored