Skip to content

Commit 3c5db83

Browse files
committed
feat(logging): enhance Rich handler to support multi-line messages
1 parent c53512c commit 3c5db83

File tree

1 file changed

+100
-67
lines changed

1 file changed

+100
-67
lines changed

tux/utils/logging.py

+100-67
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
"""Rich logging configuration for Tux.
1+
"""
2+
Rich logging configuration for Tux.
23
34
This module sets up global logging configuration using loguru with Rich formatting.
45
It should be imported and initialized at the start of the application.
@@ -24,16 +25,6 @@
2425
def highlight(style: str) -> dict[str, Callable[[Text], Text]]:
2526
"""
2627
Create a highlighter function for the given style.
27-
28-
Parameters
29-
----------
30-
style : str
31-
The style to apply to the text
32-
33-
Returns
34-
-------
35-
dict[str, Callable[[Text], Text]]
36-
A dict containing the highlighter function
3728
"""
3829

3930
def highlighter(text: Text) -> Text:
@@ -49,96 +40,139 @@ class RichHandlerProtocol(Protocol):
4940

5041
def __init__(self, *args: Any, **kwargs: Any) -> None: ...
5142

52-
"""
53-
Initialize the Rich handler.
54-
55-
Parameters
56-
----------
57-
*args : Any
58-
The arguments to pass to the RichHandler constructor
59-
**kwargs : Any
60-
The keyword arguments to pass to the RichHandler constructor
61-
"""
62-
6343
def render_message(self, record: LogRecord, message: str) -> ConsoleRenderable: ...
6444

6545

6646
class LoguruRichHandler(RichHandler, RichHandlerProtocol):
6747
"""
68-
Enhanced Rich handler for loguru that supports better styling and formatting.
48+
Enhanced Rich handler for loguru that splits long messages into two lines.
49+
50+
For messages that fit within the available space (i.e. between the prefix
51+
and the right-aligned source info), a single line is printed. If the
52+
message is too long, then:
53+
54+
- The first line prints as much of the message as possible.
55+
- The second line starts with a continued prefix that is spaced to match
56+
the normal prefix and prints the remainder (with the source info right-aligned).
57+
58+
The normal prefix is:
59+
60+
█ [HH:MM:SS][LEVEL ]
61+
62+
and the continued prefix is:
63+
64+
█ [CONTINUED ]
6965
"""
7066

7167
def __init__(self, *args: Any, **kwargs: Any) -> None:
72-
"""
73-
Initialize the Rich handler.
74-
75-
Parameters
76-
----------
77-
*args : Any
78-
The arguments to pass to the RichHandler constructor
79-
**kwargs : Any
80-
The keyword arguments to pass to the RichHandler constructor
81-
"""
8268
super().__init__(*args, **kwargs)
8369
self._last_time: Text | None = None
8470

8571
def emit(self, record: LogRecord) -> None:
86-
"""
87-
Emit a log record.
88-
89-
Parameters
90-
----------
91-
record : LogRecord
92-
The log record to emit.
93-
"""
9472
try:
95-
# Get the formatted message
73+
# Get the formatted message from loguru's formatter.
9674
message = self.format(record)
9775

98-
# Handle time formatting
76+
# --- Time formatting ---
9977
time_format: str | Callable[[datetime], Text] | None = (
10078
None if self.formatter is None else self.formatter.datefmt
10179
)
10280
time_format = time_format or self._log_render.time_format
10381
log_time = datetime.fromtimestamp(record.created, tz=UTC)
104-
105-
# Handle callable time format
10682
if callable(time_format):
10783
log_time_str = str(time_format(log_time))
10884
else:
10985
log_time_str = log_time.strftime(time_format or "[%X]")
11086

111-
# Format the level with symbols
87+
# --- Level symbol and text ---
11288
level_name = record.levelname.lower()
11389
level_symbols = {
11490
"debug": "[bold cyan]█[/]", # Cyan block for debug
11591
"info": "[bold blue]█[/]", # Blue block for info
11692
"warning": "[bold yellow]█[/]", # Yellow block for warning
11793
"error": "[bold red]█[/]", # Red block for error
118-
"critical": "[bold red on red]█[/]", # Red block on red bg for critical
94+
"critical": "[bold red on red]█[/]", # Red block on red background for critical
11995
"success": "[bold green]█[/]", # Green block for success
12096
"trace": "[dim]█[/]", # Dim block for trace
12197
}
122-
symbol = level_symbols.get(level_name, "[bright_black]█[/]") # Gray block for default
123-
level_str = f"{record.levelname:<7}" # Reduced padding by 1
124-
125-
# Format source info and display it as part of the log prefix (before the actual message)
126-
source_info = (
127-
f"[dim]{record.funcName}[bright_black] @ [/bright_black]{record.filename}:{record.lineno}[/dim]"
98+
symbol = level_symbols.get(level_name, "[bright_black]█[/]") # Default gray block
99+
100+
# --- Constants ---
101+
level_field_width = 10 # Adjust as needed
102+
103+
# --- Build the normal prefix ---
104+
# Example: "█ [02:06:55][INFO ]"
105+
first_prefix_markup = (
106+
f"{symbol} "
107+
+ f"[log.time]{log_time_str}[/]"
108+
+ "[log.bracket][[/]"
109+
+ f"[logging.level.{level_name}]{record.levelname.upper().ljust(level_field_width)}[/]"
110+
+ "[log.bracket]][/]"
111+
+ " "
128112
)
129-
130-
log_prefix = (
131-
f"{symbol} [log.time]{log_time_str}[/]"
132-
f"[log.bracket][[/][logging.level.{level_name}]{level_str}[/][log.bracket]][/] "
133-
f"{source_info} "
113+
first_prefix_plain = Text.from_markup(first_prefix_markup).plain
114+
115+
# --- Build the continued prefix ---
116+
# We want the continued prefix to have the same plain-text width as the normal one.
117+
# The normal prefix width (plain) is:
118+
# len(symbol + " ") + (len(log_time_str) + 2) + (LEVEL_FIELD_WIDTH + 2)
119+
# For the continued prefix we print "CONTINUED" (padded) in a single bracket:
120+
# Total width = len(symbol + " ") + (continued_field_width + 2)
121+
# Setting these equal gives:
122+
# continued_field_width = len(log_time_str) + LEVEL_FIELD_WIDTH + 2
123+
continued_field_width = len(log_time_str) + level_field_width
124+
continued_prefix_markup = (
125+
f"{symbol} "
126+
+ "[log.bracket][[/]"
127+
+ f"[logging.level.info]{'CONTINUED'.ljust(continued_field_width)}[/]"
128+
+ "[log.bracket]][/]"
129+
+ " "
134130
)
131+
continued_prefix_plain = Text.from_markup(continued_prefix_markup).plain
135132

136-
# Print the complete log line with the source info preceding the actual log message.
137-
self.console.print(
138-
f"{log_prefix}{message}",
139-
markup=True,
140-
highlight=False,
133+
# --- Source info ---
134+
# For example: "run @ main.py:215"
135+
source_info = (
136+
f"[dim]{record.funcName}[bright_black] @ [/bright_black]{record.filename}:{record.lineno}[/dim]"
141137
)
138+
source_info_plain = Text.from_markup(source_info).plain
139+
140+
# --- Total width ---
141+
# Use the console's actual width if available.
142+
total_width = (self.console.size.width or self.console.width) or 80
143+
144+
# Convert the formatted message to plain text.
145+
plain_message = Text.from_markup(message).plain
146+
147+
# --- One-line vs two-line decision ---
148+
# For one-line messages, the available space is the total width
149+
# minus the widths of the normal prefix and the source info.
150+
available_for_message = total_width - len(first_prefix_plain) - len(source_info_plain)
151+
if len(plain_message) <= available_for_message:
152+
# The message fits on one line.
153+
padded_msg = plain_message.ljust(available_for_message)
154+
full_line = first_prefix_markup + padded_msg + source_info
155+
self.console.print(full_line, markup=True, highlight=False)
156+
else:
157+
# --- Two-line (continued) layout ---
158+
# First line: Reserve all space after the normal prefix.
159+
first_line_area = total_width - len(first_prefix_plain)
160+
first_line_msg = plain_message[:first_line_area] # Simply cut off without ellipsis
161+
162+
# Second line: use the continued prefix and reserve space for the source info.
163+
second_line_area = total_width - len(continued_prefix_plain) - len(source_info_plain)
164+
# The remainder of the message is everything after what was printed on the first line.
165+
remainder_start = first_line_area # Adjusted to not account for ellipsis
166+
second_line_msg = plain_message[remainder_start:]
167+
if len(second_line_msg) > second_line_area:
168+
second_line_msg = second_line_msg[:second_line_area] # Simply cut off without ellipsis
169+
padded_second_line_msg = second_line_msg.ljust(second_line_area)
170+
self.console.print(first_prefix_markup + first_line_msg, markup=True, highlight=False)
171+
self.console.print(
172+
continued_prefix_markup + padded_second_line_msg + source_info,
173+
markup=True,
174+
highlight=False,
175+
)
142176
except Exception:
143177
self.handleError(record)
144178

@@ -163,21 +197,20 @@ def setup_logging() -> None:
163197
),
164198
)
165199

166-
# Configure loguru with Rich handler
167200
logger.configure(
168201
handlers=[
169202
{
170203
"sink": LoguruRichHandler(
171204
console=console,
172-
show_time=False, # We handle time display ourselves
205+
show_time=False, # We display time ourselves.
173206
show_path=False,
174207
rich_tracebacks=True,
175208
tracebacks_show_locals=True,
176209
log_time_format="[%X]",
177210
markup=True,
178-
highlighter=None, # Disable automatic highlighting
211+
highlighter=None,
179212
),
180-
"format": "{message}", # Just the message since we handle the rest
213+
"format": "{message}",
181214
"level": "DEBUG" if Config.DEV else "INFO",
182215
},
183216
],

0 commit comments

Comments
 (0)