Windows-консоль не разбирала ANSI и печатала их как мусор, поэтому цвет был выключен целиком. Теперь разбор включается через WinAPI, а при неудаче цвет молча отключается. Длинные сообщения переносятся по словам с отступом под текст. Вывод Caddy уведён в файл - он забивал консоль сертификатами. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
108 lines
4.5 KiB
Python
108 lines
4.5 KiB
Python
"""Читаемый вывод в консоли: цвета и перенос длинных строк.
|
||
|
||
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
|