1# Copyright 2021 The Pigweed Authors 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); you may not 4# use this file except in compliance with the License. You may obtain a copy of 5# the License at 6# 7# https://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12# License for the specific language governing permissions and limitations under 13# the License. 14"""Table view renderer for LogLines.""" 15 16import collections 17import copy 18 19from prompt_toolkit.formatted_text import StyleAndTextTuples 20 21from pw_console.console_prefs import ConsolePrefs 22from pw_console.log_line import LogLine 23from pw_console.text_formatting import strip_ansi 24 25 26class TableView: 27 """Store column information and render logs into formatted tables.""" 28 29 # TODO(tonymd): Add a method to provide column formatters externally. 30 # Should allow for string format, column color, and column ordering. 31 FLOAT_FORMAT = '%.3f' 32 INT_FORMAT = '%s' 33 LAST_TABLE_COLUMN_NAMES = ['msg', 'message'] 34 35 def __init__(self, prefs: ConsolePrefs): 36 self.set_prefs(prefs) 37 self.column_widths: collections.OrderedDict = collections.OrderedDict() 38 self._header_fragment_cache = None 39 40 # Assume common defaults here before recalculating in set_formatting(). 41 self._default_time_width: int = 17 42 self.column_widths['time'] = self._default_time_width 43 self.column_widths['level'] = 3 44 self._year_month_day_width: int = 9 45 46 # Width of all columns except the final message 47 self.column_width_prefix_total = 0 48 49 def set_prefs(self, prefs: ConsolePrefs) -> None: 50 self.prefs = prefs 51 # Max column widths of each log field 52 self.column_padding = ' ' * self.prefs.spaces_between_columns 53 54 def all_column_names(self): 55 columns_names = [name for name, _width in self._ordered_column_widths()] 56 return columns_names + ['message'] 57 58 def _width_of_justified_fields(self): 59 """Calculate the width of all columns except LAST_TABLE_COLUMN_NAMES.""" 60 padding_width = len(self.column_padding) 61 used_width = sum( 62 [ 63 width + padding_width 64 for key, width in self.column_widths.items() 65 if key not in TableView.LAST_TABLE_COLUMN_NAMES 66 ] 67 ) 68 return used_width 69 70 def _ordered_column_widths(self): 71 """Return each column and width in the preferred order.""" 72 if self.prefs.column_order: 73 # Get ordered_columns 74 columns = copy.copy(self.column_widths) 75 ordered_columns = {} 76 77 for column_name in self.prefs.column_order: 78 # If valid column name 79 if column_name in columns: 80 ordered_columns[column_name] = columns.pop(column_name) 81 82 # Add remaining columns unless user preference to hide them. 83 if not self.prefs.omit_unspecified_columns: 84 for column_name in columns: 85 ordered_columns[column_name] = columns[column_name] 86 else: 87 ordered_columns = copy.copy(self.column_widths) 88 89 if not self.prefs.show_python_file and 'py_file' in ordered_columns: 90 del ordered_columns['py_file'] 91 if not self.prefs.show_python_logger and 'py_logger' in ordered_columns: 92 del ordered_columns['py_logger'] 93 if not self.prefs.show_source_file and 'file' in ordered_columns: 94 del ordered_columns['file'] 95 96 return ordered_columns.items() 97 98 def update_metadata_column_widths(self, log: LogLine) -> None: 99 """Calculate the max widths for each metadata field.""" 100 if log.metadata is None: 101 log.update_metadata() 102 # If extra fields still don't exist, no need to update column widths. 103 if log.metadata is None: 104 return 105 106 for field_name, value in log.metadata.fields.items(): 107 value_string = str(value) 108 109 # Get width of formatted numbers 110 if isinstance(value, float): 111 value_string = TableView.FLOAT_FORMAT % value 112 elif isinstance(value, int): 113 value_string = TableView.INT_FORMAT % value 114 115 current_width = self.column_widths.get(field_name, 0) 116 if len(value_string) > current_width: 117 self.column_widths[field_name] = len(value_string) 118 119 # Update log level character width. 120 ansi_stripped_level = strip_ansi(log.record.levelname) 121 if len(ansi_stripped_level) > self.column_widths['level']: 122 self.column_widths['level'] = len(ansi_stripped_level) 123 124 self.column_width_prefix_total = self._width_of_justified_fields() 125 self._update_table_header() 126 127 def _update_table_header(self): 128 default_style = 'bold' 129 fragments: collections.deque = collections.deque() 130 131 # Update time column width to current prefs setting 132 self.column_widths['time'] = self._default_time_width 133 if self.prefs.hide_date_from_log_time: 134 self.column_widths['time'] = ( 135 self._default_time_width - self._year_month_day_width 136 ) 137 138 for name, width in self._ordered_column_widths(): 139 # These fields will be shown at the end 140 if name in TableView.LAST_TABLE_COLUMN_NAMES: 141 continue 142 fragments.append((default_style, name.title()[:width].ljust(width))) 143 fragments.append(('', self.column_padding)) 144 145 fragments.append((default_style, 'Message')) 146 147 self._header_fragment_cache = list(fragments) 148 149 def formatted_header(self): 150 """Get pre-formatted table header.""" 151 return self._header_fragment_cache 152 153 def formatted_row(self, log: LogLine) -> StyleAndTextTuples: 154 """Render a single table row.""" 155 # pylint: disable=too-many-locals 156 padding_formatted_text = ('', self.column_padding) 157 # Don't apply any background styling that would override the parent 158 # window or selected-log-line style. 159 default_style = '' 160 161 table_fragments: StyleAndTextTuples = [] 162 163 # NOTE: To preseve ANSI formatting on log level use: 164 # table_fragments.extend( 165 # ANSI(log.record.levelname.ljust( 166 # self.column_widths['level'])).__pt_formatted_text__()) 167 168 # Collect remaining columns to display after host time and level. 169 columns = {} 170 for name, width in self._ordered_column_widths(): 171 # Skip these modifying these fields 172 if name in TableView.LAST_TABLE_COLUMN_NAMES: 173 continue 174 175 # hasattr checks are performed here since a log record may not have 176 # asctime or levelname if they are not included in the formatter 177 # fmt string. 178 if name == 'time' and hasattr(log.record, 'asctime'): 179 time_text = log.record.asctime 180 if self.prefs.hide_date_from_log_time: 181 time_text = time_text[self._year_month_day_width :] 182 time_style = self.prefs.column_style( 183 'time', time_text, default='class:log-time' 184 ) 185 columns['time'] = ( 186 time_style, 187 time_text.ljust(self.column_widths['time']), 188 ) 189 continue 190 191 if name == 'level' and hasattr(log.record, 'levelname'): 192 # Remove any existing ANSI formatting and apply our colors. 193 level_text = strip_ansi(log.record.levelname) 194 level_style = self.prefs.column_style( 195 'level', 196 level_text, 197 default='class:log-level-{}'.format(log.record.levelno), 198 ) 199 columns['level'] = ( 200 level_style, 201 level_text.ljust(self.column_widths['level']), 202 ) 203 continue 204 205 value = log.metadata.fields.get(name, ' ') 206 left_justify = True 207 208 # Right justify and format numbers 209 if isinstance(value, float): 210 value = TableView.FLOAT_FORMAT % value 211 left_justify = False 212 elif isinstance(value, int): 213 value = TableView.INT_FORMAT % value 214 left_justify = False 215 216 if left_justify: 217 columns[name] = value.ljust(width) 218 else: 219 columns[name] = value.rjust(width) 220 221 # Grab the message to appear after the justified columns with ANSI 222 # escape sequences removed. 223 message_text = strip_ansi(log.record.message) 224 message = log.metadata.fields.get( 225 'msg', 226 message_text.rstrip(), # Remove any trailing line breaks 227 ) 228 # Alternatively ANSI formatting can be preserved with: 229 # message = ANSI(log.record.message).__pt_formatted_text__() 230 231 # Convert to FormattedText if we have a raw string from fields. 232 if isinstance(message, str): 233 message_style = default_style 234 if log.record.levelno >= 30: # Warning, Error and Critical 235 # Style the whole message to match it's level 236 message_style = 'class:log-level-{}'.format(log.record.levelno) 237 message = (message_style, message) 238 # Add to columns 239 columns['message'] = message 240 241 index_modifier = 0 242 # Go through columns and convert to FormattedText where needed. 243 for i, column in enumerate(columns.items()): 244 column_name, column_value = column 245 if i in [0, 1] and column_name in ['time', 'level']: 246 index_modifier -= 1 247 # For raw strings that don't have their own ANSI colors, apply the 248 # theme color style for this column. 249 if isinstance(column_value, str): 250 fallback_style = ( 251 'class:log-table-column-{}'.format(i + index_modifier) 252 if 0 <= i <= 7 253 else default_style 254 ) 255 256 style = self.prefs.column_style( 257 column_name, column_value.rstrip(), default=fallback_style 258 ) 259 260 table_fragments.append((style, column_value)) 261 table_fragments.append(padding_formatted_text) 262 # Add this tuple to table_fragments. 263 elif isinstance(column, tuple): 264 table_fragments.append(column_value) 265 # Add padding if not the last column. 266 if i < len(columns) - 1: 267 table_fragments.append(padding_formatted_text) 268 269 # Add the final new line for this row. 270 table_fragments.append(('', '\n')) 271 return table_fragments 272