xref: /aosp_15_r20/external/pigweed/pw_console/py/pw_console/widgets/table.py (revision 61c4878ac05f98d0ceed94b57d316916de578985)
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