TEST: add custom logging
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user