TEST: add custom logging

This commit is contained in:
2026-01-07 03:29:58 +03:00
parent 2f189df8ee
commit 6ca186903e
2 changed files with 386 additions and 11 deletions

View File

@@ -6,19 +6,382 @@
- Ротации логов
- Записи в файл и консоль
- Интеграции с модулем config
- Красивого форматирования CAN фреймов и dict объектов
"""
import json
import logging
import sys
from logging.handlers import RotatingFileHandler
from pathlib import Path
from typing import Optional
from typing import Optional, Dict, Any
from datetime import datetime
from config import get_config
class PrettyFormatter(logging.Formatter):
"""
Красивый форматтер для логирования с блочным выводом.
Форматирует dict объекты и CAN фреймы в читаемые блоки.
"""
# ANSI цвета для консоли
COLORS = {
'DEBUG': '\033[36m', # Cyan
'INFO': '\033[32m', # Green
'WARNING': '\033[33m', # Yellow
'ERROR': '\033[31m', # Red
'CRITICAL': '\033[35m', # Magenta
'RESET': '\033[0m', # Reset
'BOLD': '\033[1m',
'DIM': '\033[2m',
}
# Символы для блоков
BOX_CHARS = {
'top_left': '',
'top_right': '',
'bottom_left': '',
'bottom_right': '',
'horizontal': '',
'vertical': '',
'cross': '',
'top_tee': '',
'bottom_tee': '',
}
def __init__(self, fmt: Optional[str] = None, use_colors: bool = True):
"""
Инициализация форматтера.
Args:
fmt: Формат строки (если None, используется стандартный)
use_colors: Использовать ли цвета (только для консоли)
"""
super().__init__(fmt)
self.use_colors = use_colors and sys.stdout.isatty()
def _format_dict_block(self, data: Dict[str, Any], title: Optional[str] = None) -> str:
"""
Форматирование словаря в красивый блок.
Args:
data: Словарь для форматирования
title: Заголовок блока
Returns:
Отформатированная строка
"""
if not data:
return ""
lines = []
# Определяем максимальную ширину ключа
max_key_width = max(len(str(k)) for k in data.keys()) if data else 0
max_key_width = max(max_key_width, 20) # Минимум 20 символов
# Заголовок
if title:
title_line = f" {title} "
border_width = max(len(title_line), max_key_width + 20)
lines.append(
f"{self.BOX_CHARS['top_left']}"
f"{self.BOX_CHARS['horizontal'] * (border_width - 2)}"
f"{self.BOX_CHARS['top_right']}"
)
lines.append(
f"{self.BOX_CHARS['vertical']}"
f"{title_line.center(border_width - 2)}"
f"{self.BOX_CHARS['vertical']}"
)
lines.append(
f"{self.BOX_CHARS['top_tee']}"
f"{self.BOX_CHARS['horizontal'] * (border_width - 2)}"
f"{self.BOX_CHARS['top_right']}"
)
else:
border_width = max_key_width + 20
lines.append(
f"{self.BOX_CHARS['top_left']}"
f"{self.BOX_CHARS['horizontal'] * (border_width - 2)}"
f"{self.BOX_CHARS['top_right']}"
)
# Содержимое
for i, (key, value) in enumerate(data.items()):
# Форматируем значение
formatted_value = self._format_value(value)
# Разбиваем длинные значения на несколько строк
value_lines = formatted_value.split('\n')
for j, value_line in enumerate(value_lines):
if j == 0:
# Первая строка с ключом
key_str = str(key).ljust(max_key_width)
lines.append(
f"{self.BOX_CHARS['vertical']} "
f"{key_str}: {value_line}"
f"{' ' * (border_width - len(key_str) - len(value_line) - 4)}"
f" {self.BOX_CHARS['vertical']}"
)
else:
# Продолжение значения
lines.append(
f"{self.BOX_CHARS['vertical']} "
f"{' ' * (max_key_width + 2)}{value_line}"
f"{' ' * (border_width - max_key_width - len(value_line) - 4)}"
f" {self.BOX_CHARS['vertical']}"
)
# Разделитель между элементами (кроме последнего)
if i < len(data) - 1:
lines.append(
f"{self.BOX_CHARS['vertical']}"
f"{' ' * (border_width - 2)}"
f"{self.BOX_CHARS['vertical']}"
)
# Нижняя граница
lines.append(
f"{self.BOX_CHARS['bottom_left']}"
f"{self.BOX_CHARS['horizontal'] * (border_width - 2)}"
f"{self.BOX_CHARS['bottom_right']}"
)
return '\n' + '\n'.join(lines)
def _format_value(self, value: Any) -> str:
"""
Форматирование значения для вывода.
Args:
value: Значение для форматирования
Returns:
Отформатированная строка
"""
if isinstance(value, dict):
# Вложенные словари - форматируем как JSON с отступами
return json.dumps(value, indent=2, ensure_ascii=False)
elif isinstance(value, list):
# Списки - форматируем как JSON
if len(value) > 5:
return json.dumps(value[:3], ensure_ascii=False) + f" ... ({len(value)} items)"
return json.dumps(value, ensure_ascii=False)
elif isinstance(value, (int, float)):
# Числа - добавляем форматирование для больших чисел
if isinstance(value, float):
return f"{value:.6f}" if abs(value) < 1000 else f"{value:.2f}"
return f"{value:,}" if abs(value) >= 1000 else str(value)
elif isinstance(value, bytes):
# Байты - показываем hex
return value.hex().upper() if len(value) <= 16 else value.hex().upper()[:32] + "..."
elif isinstance(value, bool):
return "" if value else ""
else:
return str(value)
def _format_can_frame(self, data: Dict[str, Any]) -> str:
"""
Специальное форматирование для CAN фреймов.
Args:
data: Данные CAN фрейма
Returns:
Отформатированная строка
"""
# Определяем, это CAN фрейм или нет
can_fields = {'can_id', 'interface', 'bus', 'dlc', 'data', 'data_hex', 'timestamp', 'ts_ns'}
if not any(field in data for field in can_fields):
return self._format_dict_block(data)
# Создаем специальный блок для CAN фрейма
lines = []
border_width = 60
# Заголовок
lines.append(
f"{self.BOX_CHARS['top_left']}"
f"{self.BOX_CHARS['horizontal'] * (border_width - 2)}"
f"{self.BOX_CHARS['top_right']}"
)
lines.append(
f"{self.BOX_CHARS['vertical']}"
f" {'🚗 CAN FRAME'.center(border_width - 4)} "
f"{self.BOX_CHARS['vertical']}"
)
lines.append(
f"{self.BOX_CHARS['top_tee']}"
f"{self.BOX_CHARS['horizontal'] * (border_width - 2)}"
f"{self.BOX_CHARS['top_right']}"
)
# CAN ID (выделяем особо)
can_id = data.get('can_id') or data.get('can_id_int')
can_id_hex = data.get('can_id_hex') or (hex(can_id) if can_id is not None else 'N/A')
interface = data.get('interface') or data.get('bus', 'N/A')
lines.append(
f"{self.BOX_CHARS['vertical']} "
f"CAN ID: {self._colorize(can_id_hex, 'BOLD') if self.use_colors else can_id_hex}"
f"{' ' * (border_width - len(can_id_hex) - 10)}"
f" {self.BOX_CHARS['vertical']}"
)
lines.append(
f"{self.BOX_CHARS['vertical']} "
f"Interface: {interface}"
f"{' ' * (border_width - len(interface) - 13)}"
f" {self.BOX_CHARS['vertical']}"
)
lines.append(
f"{self.BOX_CHARS['vertical']}"
f"{' ' * (border_width - 2)}"
f"{self.BOX_CHARS['vertical']}"
)
# Данные
dlc = data.get('dlc', 'N/A')
data_hex = data.get('data_hex') or data.get('data', '')
if isinstance(data_hex, bytes):
data_hex = data_hex.hex().upper()
elif not isinstance(data_hex, str):
data_hex = str(data_hex)
# Форматируем данные по байтам
if data_hex and data_hex != 'N/A':
data_formatted = ' '.join(data_hex[i:i+2] for i in range(0, min(len(data_hex), 32), 2))
if len(data_hex) > 32:
data_formatted += " ..."
else:
data_formatted = 'N/A'
lines.append(
f"{self.BOX_CHARS['vertical']} "
f"DLC: {dlc} | Data: {data_formatted}"
f"{' ' * (border_width - len(f'DLC: {dlc} | Data: {data_formatted}') - 3)}"
f" {self.BOX_CHARS['vertical']}"
)
# Timestamp
timestamp = data.get('timestamp') or data.get('ts_ns')
if timestamp:
if isinstance(timestamp, (int, float)) and timestamp > 1e10:
# Наносекунды
ts_sec = timestamp / 1e9
dt = datetime.fromtimestamp(ts_sec)
timestamp_str = dt.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]
elif isinstance(timestamp, (int, float)):
# Секунды
dt = datetime.fromtimestamp(timestamp)
timestamp_str = dt.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]
else:
timestamp_str = str(timestamp)
else:
timestamp_str = 'N/A'
lines.append(
f"{self.BOX_CHARS['vertical']} "
f"Timestamp: {timestamp_str}"
f"{' ' * (border_width - len(timestamp_str) - 13)}"
f" {self.BOX_CHARS['vertical']}"
)
# Дополнительные поля
extra_fields = {k: v for k, v in data.items()
if k not in {'can_id', 'can_id_int', 'can_id_hex', 'interface', 'bus',
'dlc', 'data', 'data_hex', 'timestamp', 'ts_ns'}}
if extra_fields:
lines.append(
f"{self.BOX_CHARS['vertical']}"
f"{' ' * (border_width - 2)}"
f"{self.BOX_CHARS['vertical']}"
)
for key, value in extra_fields.items():
formatted_value = self._format_value(value)
value_line = formatted_value.split('\n')[0]
if len(value_line) > border_width - 20:
value_line = value_line[:border_width - 23] + "..."
lines.append(
f"{self.BOX_CHARS['vertical']} "
f"{key}: {value_line}"
f"{' ' * (border_width - len(key) - len(value_line) - 4)}"
f" {self.BOX_CHARS['vertical']}"
)
# Нижняя граница
lines.append(
f"{self.BOX_CHARS['bottom_left']}"
f"{self.BOX_CHARS['horizontal'] * (border_width - 2)}"
f"{self.BOX_CHARS['bottom_right']}"
)
return '\n' + '\n'.join(lines)
def _colorize(self, text: str, color: str) -> str:
"""Добавление цвета к тексту."""
if not self.use_colors:
return text
color_code = self.COLORS.get(color, '')
reset = self.COLORS['RESET']
return f"{color_code}{text}{reset}"
def format(self, record: logging.LogRecord) -> str:
"""Форматирование записи лога."""
# Извлекаем дополнительные поля
extra_fields = {}
for key, value in record.__dict__.items():
if key not in [
'name', 'msg', 'args', 'created', 'filename', 'funcName',
'levelname', 'levelno', 'lineno', 'module', 'msecs',
'message', 'pathname', 'process', 'processName', 'relativeCreated',
'thread', 'threadName', 'exc_info', 'exc_text', 'stack_info'
]:
extra_fields[key] = value
# Форматируем основное сообщение
base_message = super().format(record)
# Определяем уровень логирования для цвета
level_color = self.COLORS.get(record.levelname, '')
reset_color = self.COLORS['RESET'] if self.use_colors else ''
# Форматируем уровень
level_str = f"{level_color}{record.levelname:8s}{reset_color}" if self.use_colors else f"{record.levelname:8s}"
# Основная строка
timestamp = datetime.fromtimestamp(record.created).strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]
formatted = f"{timestamp} | {level_str} | {record.name} | {record.getMessage()}"
# Добавляем дополнительные поля
if extra_fields:
# Проверяем, это CAN фрейм или обычный dict
if any(field in extra_fields for field in ['can_id', 'can_id_int', 'can_id_hex', 'interface', 'bus']):
# CAN фрейм - специальное форматирование
formatted += self._format_can_frame(extra_fields)
elif 'first_message' in extra_fields and isinstance(extra_fields['first_message'], dict):
# Батч с первым сообщением
batch_info = {k: v for k, v in extra_fields.items() if k != 'first_message'}
if batch_info:
formatted += self._format_dict_block(batch_info, "Batch Info")
formatted += self._format_can_frame(extra_fields['first_message'])
else:
# Обычный dict - блочное форматирование
formatted += self._format_dict_block(extra_fields)
# Добавляем информацию об исключении, если есть
if record.exc_info:
formatted += '\n' + self.formatException(record.exc_info)
return formatted
class StructuredFormatter(logging.Formatter):
"""Форматтер для structured logging с дополнительными полями."""
"""Форматтер для structured logging с дополнительными полями (старый формат)."""
def format(self, record: logging.LogRecord) -> str:
"""Форматирование записи лога с поддержкой дополнительных полей."""
@@ -71,14 +434,24 @@ class Logger:
# Очищаем существующие обработчики
self.root_logger.handlers.clear()
# Создаем форматтер
formatter = StructuredFormatter(self.log_config.format)
# Создаем форматтеры
# Для консоли - красивый форматтер
console_formatter = PrettyFormatter(
fmt='%(message)s',
use_colors=True
)
# Для файла - структурированный форматтер (без цветов)
file_formatter = PrettyFormatter(
fmt='%(message)s',
use_colors=False
)
# Обработчик для файла с ротацией
self._setup_file_handler(formatter)
self._setup_file_handler(file_formatter)
# Обработчик для консоли
self._setup_console_handler(formatter)
self._setup_console_handler(console_formatter)
# Предотвращаем распространение на корневой логгер
self.root_logger.propagate = False
@@ -204,4 +577,3 @@ def get_logger(name: Optional[str] = None) -> logging.Logger:
# Для удобства - корневой логгер
logger = get_logger()

View File

@@ -230,11 +230,14 @@ class MessageProcessor:
extra={
"batch_size": len(batch),
"first_message": {
"interface": first_frame.bus,
"can_id": first_frame.can_id_hex,
"bus": first_frame.bus,
"can_id": first_frame.can_id,
"can_id_hex": first_frame.can_id_hex,
"dlc": first_frame.dlc,
"data": first_frame.data_hex,
"timestamp": first_frame.timestamp
"data_hex": first_frame.data_hex,
"ts_ns": first_frame.ts_ns,
"timestamp": first_frame.timestamp,
"is_extended": first_frame.is_extended
}
}
)