diff --git a/app/logging_config.py b/app/logging_config.py index 0bd21c5b..b1308c98 100644 --- a/app/logging_config.py +++ b/app/logging_config.py @@ -48,6 +48,14 @@ def _clean_logger_name(logger: Any, method_name: str, event_dict: dict[str, Any] return event_dict +def _prefix_logger_name(logger: Any, method_name: str, event_dict: dict[str, Any]) -> dict[str, Any]: + """Move logger name before event text: [module.name] event text.""" + logger_name = event_dict.pop('logger', None) + if logger_name: + event_dict['event'] = f'[{logger_name}] {event_dict.get("event", "")}' + return event_dict + + def setup_logging() -> tuple[logging.Formatter, logging.Formatter, Any]: """Configure structlog and return formatters + notifier. @@ -103,6 +111,7 @@ def setup_logging() -> tuple[logging.Formatter, logging.Formatter, Any]: foreign_pre_chain=shared_processors, processors=[ structlog.stdlib.ProcessorFormatter.remove_processors_meta, + _prefix_logger_name, structlog.dev.ConsoleRenderer( colors=False, pad_event_to=0, @@ -118,6 +127,7 @@ def setup_logging() -> tuple[logging.Formatter, logging.Formatter, Any]: foreign_pre_chain=shared_processors, processors=[ structlog.stdlib.ProcessorFormatter.remove_processors_meta, + _prefix_logger_name, structlog.dev.ConsoleRenderer( pad_event_to=0, pad_level=False, diff --git a/app/utils/startup_timeline.py b/app/utils/startup_timeline.py index feb548e5..c289303b 100644 --- a/app/utils/startup_timeline.py +++ b/app/utils/startup_timeline.py @@ -1,11 +1,58 @@ import platform import time +import unicodedata from collections.abc import Iterable, Sequence from contextlib import asynccontextmanager from dataclasses import dataclass from typing import Any +def _char_width(ch: str) -> int: + """Return terminal display width of a single character.""" + cp = ord(ch) + # Variation selector U+FE0F / U+FE0E — zero width (handled by caller) + if cp in (0xFE0E, 0xFE0F, 0x200D): + return 0 + # Combining marks — zero width + if unicodedata.category(ch).startswith('M'): + return 0 + # East Asian Wide / Fullwidth + if unicodedata.east_asian_width(ch) in ('W', 'F'): + return 2 + return 1 + + +def _display_width(text: str) -> int: + """Calculate terminal display width accounting for wide chars and emoji.""" + width = 0 + prev_base = 0 + for ch in text: + cp = ord(ch) + # U+FE0F emoji presentation selector — upgrades previous char to 2 cells + if cp == 0xFE0F: + if prev_base == 1: + width += 1 # upgrade 1 → 2 + prev_base = 2 + continue + cw = _char_width(ch) + if cw > 0: + prev_base = cw + width += cw + return width + + +def _ljust(text: str, width: int) -> str: + """Left-justify text to given display width.""" + return text + ' ' * max(0, width - _display_width(text)) + + +def _center(text: str, width: int) -> str: + """Center text to given display width.""" + pad = max(0, width - _display_width(text)) + left = pad // 2 + return ' ' * left + text + ' ' * (pad - left) + + @dataclass class StepRecord: title: str @@ -85,25 +132,25 @@ class StartupTimeline: subtitle_parts.append(f'{key}: {value}') subtitle_text = ' | '.join(subtitle_parts) - width = max(len(title_text), len(subtitle_text)) + width = max(_display_width(title_text), _display_width(subtitle_text)) border = '╔' + '═' * (width + 2) + '╗' self.logger.info(border) - self.logger.info('║ ' + title_text.ljust(width) + ' ║') - self.logger.info('║ ' + subtitle_text.ljust(width) + ' ║') + self.logger.info('║ ' + _ljust(title_text, width) + ' ║') + self.logger.info('║ ' + _ljust(subtitle_text, width) + ' ║') self.logger.info('╚' + '═' * (width + 2) + '╝') def log_section(self, title: str, lines: Iterable[str], icon: str = '📄') -> None: items = [f'{icon} {title}'] + [f'• {line}' for line in lines] - width = max(len(item) for item in items) + width = max(_display_width(item) for item in items) top = '┌ ' + '─' * width + ' ┐' middle = '├ ' + '─' * width + ' ┤' bottom = '└ ' + '─' * width + ' ┘' self.logger.info(top) - self.logger.info('│ ' + items[0].ljust(width) + ' │') + self.logger.info('│ ' + _ljust(items[0], width) + ' │') self.logger.info(middle) for item in items[1:]: - self.logger.info('│ ' + item.ljust(width) + ' │') + self.logger.info('│ ' + _ljust(item, width) + ' │') self.logger.info(bottom) def add_manual_step( @@ -169,15 +216,15 @@ class StartupTimeline: base += f' :: {step.message}' lines.append(base) - width = max(len(line) for line in lines) + width = max(_display_width(line) for line in lines) border_top = '┏' + '━' * (width + 2) + '┓' border_mid = '┣' + '━' * (width + 2) + '┫' border_bottom = '┗' + '━' * (width + 2) + '┛' title = 'РЕЗЮМЕ ЗАПУСКА' self.logger.info(border_top) - self.logger.info('┃ ' + title.center(width) + ' ┃') + self.logger.info('┃ ' + _center(title, width) + ' ┃') self.logger.info(border_mid) for line in lines: - self.logger.info('┃ ' + line.ljust(width) + ' ┃') + self.logger.info('┃ ' + _ljust(line, width) + ' ┃') self.logger.info(border_bottom)