blob: ec863f557981ff3b1d23b6e928adeaaf8766ce60 [file] [log] [blame]
# Copyright 2021 The Pigweed Authors
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
# use this file except in compliance with the License. You may obtain a copy of
# the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under
# the License.
"""Table view renderer for LogLines."""
import collections
import copy
from prompt_toolkit.formatted_text import StyleAndTextTuples
from pw_console.console_prefs import ConsolePrefs
from pw_console.log_line import LogLine
import pw_console.text_formatting
class TableView:
"""Store column information and render logs into formatted tables."""
# TODO(tonymd): Add a method to provide column formatters externally.
# Should allow for string format, column color, and column ordering.
FLOAT_FORMAT = '%.3f'
INT_FORMAT = '%s'
LAST_TABLE_COLUMN_NAMES = ['msg', 'message']
def __init__(self, prefs: ConsolePrefs):
self.set_prefs(prefs)
self.column_widths: collections.OrderedDict = collections.OrderedDict()
self._header_fragment_cache = None
# Assume common defaults here before recalculating in set_formatting().
self._default_time_width: int = 17
self.column_widths['time'] = self._default_time_width
self.column_widths['level'] = 3
self._year_month_day_width: int = 9
# Width of all columns except the final message
self.column_width_prefix_total = 0
def set_prefs(self, prefs: ConsolePrefs) -> None:
self.prefs = prefs
# Max column widths of each log field
self.column_padding = ' ' * self.prefs.spaces_between_columns
def all_column_names(self):
columns_names = [
name for name, _width in self._ordered_column_widths()
]
return columns_names + ['message']
def _width_of_justified_fields(self):
"""Calculate the width of all columns except LAST_TABLE_COLUMN_NAMES."""
padding_width = len(self.column_padding)
used_width = sum([
width + padding_width for key, width in self.column_widths.items()
if key not in TableView.LAST_TABLE_COLUMN_NAMES
])
return used_width
def _ordered_column_widths(self):
"""Return each column and width in the preferred order."""
if self.prefs.column_order:
# Get ordered_columns
columns = copy.copy(self.column_widths)
ordered_columns = {}
for column_name in self.prefs.column_order:
# If valid column name
if column_name in columns:
ordered_columns[column_name] = columns.pop(column_name)
# Add remaining columns unless user preference to hide them.
if not self.prefs.omit_unspecified_columns:
for column_name in columns:
ordered_columns[column_name] = columns[column_name]
else:
ordered_columns = copy.copy(self.column_widths)
if not self.prefs.show_python_file and 'py_file' in ordered_columns:
del ordered_columns['py_file']
if not self.prefs.show_python_logger and 'py_logger' in ordered_columns:
del ordered_columns['py_logger']
if not self.prefs.show_source_file and 'file' in ordered_columns:
del ordered_columns['file']
return ordered_columns.items()
def update_metadata_column_widths(self, log: LogLine) -> None:
"""Calculate the max widths for each metadata field."""
if log.metadata is None:
log.update_metadata()
# If extra fields still don't exist, no need to update column widths.
if log.metadata is None:
return
for field_name, value in log.metadata.fields.items():
value_string = str(value)
# Get width of formatted numbers
if isinstance(value, float):
value_string = TableView.FLOAT_FORMAT % value
elif isinstance(value, int):
value_string = TableView.INT_FORMAT % value
current_width = self.column_widths.get(field_name, 0)
if len(value_string) > current_width:
self.column_widths[field_name] = len(value_string)
# Update log level character width.
ansi_stripped_level = pw_console.text_formatting.strip_ansi(
log.record.levelname)
if len(ansi_stripped_level) > self.column_widths['level']:
self.column_widths['level'] = len(ansi_stripped_level)
self.column_width_prefix_total = self._width_of_justified_fields()
self._update_table_header()
def _update_table_header(self):
default_style = 'bold'
fragments: collections.deque = collections.deque()
# Update time column width to current prefs setting
self.column_widths['time'] = self._default_time_width
if self.prefs.hide_date_from_log_time:
self.column_widths['time'] = (self._default_time_width -
self._year_month_day_width)
for name, width in self._ordered_column_widths():
# These fields will be shown at the end
if name in TableView.LAST_TABLE_COLUMN_NAMES:
continue
fragments.append(
(default_style, name.title()[:width].ljust(width)))
fragments.append(('', self.column_padding))
fragments.append((default_style, 'Message'))
self._header_fragment_cache = list(fragments)
def formatted_header(self):
"""Get pre-formatted table header."""
return self._header_fragment_cache
def formatted_row(self, log: LogLine) -> StyleAndTextTuples:
"""Render a single table row."""
# pylint: disable=too-many-locals
padding_formatted_text = ('', self.column_padding)
# Don't apply any background styling that would override the parent
# window or selected-log-line style.
default_style = ''
table_fragments: StyleAndTextTuples = []
# NOTE: To preseve ANSI formatting on log level use:
# table_fragments.extend(
# ANSI(log.record.levelname.ljust(
# self.column_widths['level'])).__pt_formatted_text__())
# Collect remaining columns to display after host time and level.
columns = {}
for name, width in self._ordered_column_widths():
# Skip these modifying these fields
if name in TableView.LAST_TABLE_COLUMN_NAMES:
continue
# hasattr checks are performed here since a log record may not have
# asctime or levelname if they are not included in the formatter
# fmt string.
if name == 'time' and hasattr(log.record, 'asctime'):
time_text = log.record.asctime
if self.prefs.hide_date_from_log_time:
time_text = time_text[self._year_month_day_width:]
time_style = self.prefs.column_style('time',
time_text,
default='class:log-time')
columns['time'] = (time_style,
time_text.ljust(self.column_widths['time']))
continue
if name == 'level' and hasattr(log.record, 'levelname'):
# Remove any existing ANSI formatting and apply our colors.
level_text = pw_console.text_formatting.strip_ansi(
log.record.levelname)
level_style = self.prefs.column_style(
'level',
level_text,
default='class:log-level-{}'.format(log.record.levelno))
columns['level'] = (level_style,
level_text.ljust(
self.column_widths['level']))
continue
value = log.metadata.fields.get(name, ' ')
left_justify = True
# Right justify and format numbers
if isinstance(value, float):
value = TableView.FLOAT_FORMAT % value
left_justify = False
elif isinstance(value, int):
value = TableView.INT_FORMAT % value
left_justify = False
if left_justify:
columns[name] = value.ljust(width)
else:
columns[name] = value.rjust(width)
# Grab the message to appear after the justified columns with ANSI
# escape sequences removed.
message_text = pw_console.text_formatting.strip_ansi(
log.record.message)
message = log.metadata.fields.get(
'msg',
message_text.rstrip(), # Remove any trailing line breaks
)
# Alternatively ANSI formatting can be preserved with:
# message = ANSI(log.record.message).__pt_formatted_text__()
# Convert to FormattedText if we have a raw string from fields.
if isinstance(message, str):
message_style = default_style
if log.record.levelno >= 30: # Warning, Error and Critical
# Style the whole message to match it's level
message_style = 'class:log-level-{}'.format(log.record.levelno)
message = (message_style, message)
# Add to columns
columns['message'] = message
index_modifier = 0
# Go through columns and convert to FormattedText where needed.
for i, column in enumerate(columns.items()):
column_name, column_value = column
if i in [0, 1] and column_name in ['time', 'level']:
index_modifier -= 1
# For raw strings that don't have their own ANSI colors, apply the
# theme color style for this column.
if isinstance(column_value, str):
fallback_style = 'class:log-table-column-{}'.format(
i + index_modifier) if 0 <= i <= 7 else default_style
style = self.prefs.column_style(column_name,
column_value.rstrip(),
default=fallback_style)
table_fragments.append((style, column_value))
table_fragments.append(padding_formatted_text)
# Add this tuple to table_fragments.
elif isinstance(column, tuple):
table_fragments.append(column_value)
# Add padding if not the last column.
if i < len(columns) - 1:
table_fragments.append(padding_formatted_text)
# Add the final new line for this row.
table_fragments.append(('', '\n'))
return table_fragments