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