| # 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): |
| """Calculate the max widths for each metadata field.""" |
| 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 ['msg', 'message']: |
| 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 ['msg', 'message']: |
| 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 |