• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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