# Copyright 2022 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. """Module for size report ASCII tables from DataSourceMaps.""" import enum from typing import ( Iterable, Tuple, Union, Type, List, Optional, NamedTuple, cast, ) from pw_bloat.label import DataSourceMap, DiffDataSourceMap, Label class AsciiCharset(enum.Enum): """Set of ASCII characters for drawing tables.""" TL = '+' TM = '+' TR = '+' ML = '+' MM = '+' MR = '+' BL = '+' BM = '+' BR = '+' V = '|' H = '-' HH = '=' class LineCharset(enum.Enum): """Set of line-drawing characters for tables.""" TL = '┌' TM = '┬' TR = '┐' ML = '├' MM = '┼' MR = '┤' BL = '└' BM = '┴' BR = '┘' V = '│' H = '─' HH = '═' class _Align(enum.Enum): CENTER = 0 LEFT = 1 RIGHT = 2 def get_label_status(curr_label: Label) -> str: if curr_label.is_new(): return 'NEW' if curr_label.is_del(): return 'DEL' return '' def diff_sign_sizes(size: int, diff_mode: bool) -> str: if diff_mode: size_sign = '+' if size > 0 else '' return f"{size_sign}{size:,}" return f"{size:,}" class BloatTableOutput: """ASCII Table generator from DataSourceMap.""" _RST_PADDING_WIDTH = 6 _DEFAULT_MAX_WIDTH = 80 class _LabelContent(NamedTuple): name: str size: int label_status: str def __init__( self, ds_map: Union[DiffDataSourceMap, DataSourceMap], col_max_width: int = _DEFAULT_MAX_WIDTH, charset: Union[Type[AsciiCharset], Type[LineCharset]] = AsciiCharset, rst_output: bool = False, diff_label: Optional[str] = None, ): self._data_source_map = ds_map self._cs = charset self._total_size = 0 col_names = [*self._data_source_map.get_ds_names(), 'sizes'] self._diff_mode = False self._diff_label = diff_label if isinstance(self._data_source_map, DiffDataSourceMap): col_names = ['diff', *col_names] self._diff_mode = True self._col_names = col_names self._additional_padding = 0 self._ascii_table_rows: List[str] = [] self._rst_output = rst_output self._total_divider = self._cs.HH.value if self._rst_output: self._total_divider = self._cs.H.value self._additional_padding = self._RST_PADDING_WIDTH self._col_widths = self._generate_col_width(col_max_width) def _generate_col_width(self, col_max_width: int) -> List[int]: """Find column width for all data sources and sizes.""" max_len_size = 0 diff_len_col_width = 0 col_list = [ len(ds_name) for ds_name in self._data_source_map.get_ds_names() ] for curr_label in self._data_source_map.labels(): self._total_size += curr_label.size max_len_size = max( len(diff_sign_sizes(self._total_size, self._diff_mode)), len(diff_sign_sizes(curr_label.size, self._diff_mode)), max_len_size, ) for index, parent_label in enumerate( [*curr_label.parents, curr_label.name] ): if len(parent_label) > col_max_width: col_list[index] = col_max_width elif len(parent_label) > col_list[index]: col_list[index] = len(parent_label) diff_same = 0 if self._diff_mode: col_list = [len('Total'), *col_list] diff_same = len('(SAME)') col_list.append(max(max_len_size, len('sizes'), diff_same)) if self._diff_label is not None: sum_all_col_names = sum(col_list) if sum_all_col_names < len(self._diff_label): diff_len_col_width = ( len(self._diff_label) - sum_all_col_names ) // len(self._col_names) return [ (x + self._additional_padding + diff_len_col_width) for x in col_list ] def _diff_label_names( self, old_labels: Optional[Tuple[_LabelContent, ...]], new_labels: Tuple[_LabelContent, ...], ) -> Tuple[_LabelContent, ...]: """Return difference between arrays of labels.""" if old_labels is None: return new_labels diff_list = [] for new_lb, old_lb in zip(new_labels, old_labels): if (new_lb.name == old_lb.name) and (new_lb.size == old_lb.size): diff_list.append(self._LabelContent('', 0, '')) else: diff_list.append(new_lb) return tuple(diff_list) def _label_title_row(self) -> List[str]: label_rows = [] label_cells = '' divider_cells = '' for width in self._col_widths: label_cells += ' ' * width + ' ' divider_cells += (self._cs.H.value * width) + self._cs.H.value if self._diff_label is not None: label_cells = self._diff_label.center(len(label_cells[:-1]), ' ') label_rows.extend( [ f"{self._cs.TL.value}{divider_cells[:-1]}{self._cs.TR.value}", f"{self._cs.V.value}{label_cells}{self._cs.V.value}", f"{self._cs.ML.value}{divider_cells[:-1]}{self._cs.MR.value}", ] ) return label_rows def create_table(self) -> str: """Parse DataSourceMap to create ASCII table.""" curr_lb_hierachy = None last_diff_name = '' if self._diff_mode: self._ascii_table_rows.extend([*self._label_title_row()]) else: self._ascii_table_rows.extend( [self._create_border(True, self._cs.H.value)] ) self._ascii_table_rows.extend([*self._create_title_row()]) has_entries = False for curr_label in self._data_source_map.labels(): if curr_label.size == 0: continue has_entries = True new_lb_hierachy = tuple( [ *self._get_ds_label_size(curr_label.parents), self._LabelContent( curr_label.name, curr_label.size, get_label_status(curr_label), ), ] ) diff_list = self._diff_label_names( curr_lb_hierachy, new_lb_hierachy ) curr_lb_hierachy = new_lb_hierachy if curr_label.parents and curr_label.parents[0] == last_diff_name: continue if ( self._diff_mode and diff_list[0].name and ( not cast( DiffDataSourceMap, self._data_source_map ).has_diff_sublabels(diff_list[0].name) ) ): if (len(self._ascii_table_rows) > 5) and ( self._ascii_table_rows[-1][0] != '+' ): self._ascii_table_rows.append( self._row_divider( len(self._col_names), self._cs.H.value ) ) self._ascii_table_rows.append( self._create_same_label_row(1, diff_list[0].name) ) last_diff_name = curr_label.parents[0] else: self._ascii_table_rows += self._create_diff_rows(diff_list) if self._rst_output and self._ascii_table_rows[-1][0] == '+': self._ascii_table_rows.pop() self._ascii_table_rows.extend( [*self._create_total_row(is_empty=not has_entries)] ) return '\n'.join(self._ascii_table_rows) + '\n' def _create_same_label_row(self, col_index: int, label: str) -> str: label_row = '' for col in range(len(self._col_names) - 1): if col == col_index: curr_cell = self._create_cell(label, False, col, _Align.LEFT) else: curr_cell = self._create_cell('', False, col) label_row += curr_cell label_row += self._create_cell( "(SAME)", True, len(self._col_widths) - 1, _Align.RIGHT ) return label_row def _get_ds_label_size( self, parent_labels: Tuple[str, ...] ) -> Iterable[_LabelContent]: """Produce label, size pairs from parent label names.""" parent_label_sizes = [] for index, target_label in enumerate(parent_labels): for curr_label in self._data_source_map.labels(index): if curr_label.name == target_label: diff_label = get_label_status(curr_label) parent_label_sizes.append( self._LabelContent( curr_label.name, curr_label.size, diff_label ) ) break return parent_label_sizes def _create_total_row(self, is_empty: bool = False) -> Iterable[str]: complete_total_rows = [] if self._diff_mode and is_empty: # When diffing two identical binaries, output a row indicating that # the two are the same. no_diff_row = '' for i in range(len(self._col_names)): if i == 0: no_diff_row += self._create_cell( 'N/A', False, i, _Align.CENTER ) elif i == len(self._col_names) - 1: no_diff_row += self._create_cell('0', True, i) else: no_diff_row += self._create_cell( '(same)', False, i, _Align.CENTER ) complete_total_rows.append(no_diff_row) complete_total_rows.append( self._row_divider(len(self._col_names), self._total_divider) ) total_row = '' for i in range(len(self._col_names)): if i == 0: total_row += self._create_cell('Total', False, i, _Align.LEFT) elif i == len(self._col_names) - 1: total_size_str = diff_sign_sizes( self._total_size, self._diff_mode ) total_row += self._create_cell(total_size_str, True, i) else: total_row += self._create_cell('', False, i, _Align.CENTER) complete_total_rows.extend( [total_row, self._create_border(False, self._cs.H.value)] ) return complete_total_rows def _create_diff_rows( self, diff_list: Tuple[_LabelContent, ...] ) -> Iterable[str]: """Create rows for each label according to its index in diff_list.""" curr_row = '' diff_index = 0 diff_rows = [] for index, label_content in enumerate(diff_list): if label_content.name: if self._diff_mode: curr_row += self._create_cell( label_content.label_status, False, 0 ) diff_index = 1 for cell_index in range( diff_index, len(diff_list) + diff_index ): if cell_index == index + diff_index: if ( cell_index == diff_index and len(self._ascii_table_rows) > 5 and not self._rst_output ): diff_rows.append( self._row_divider( len(self._col_names), self._cs.H.value ) ) if ( len(label_content.name) + self._additional_padding ) > self._col_widths[cell_index]: curr_row = self._multi_row_label( label_content.name, cell_index ) break curr_row += self._create_cell( label_content.name, False, cell_index, _Align.LEFT ) else: curr_row += self._create_cell('', False, cell_index) # Add size end of current row. curr_size = diff_sign_sizes(label_content.size, self._diff_mode) curr_row += self._create_cell( curr_size, True, len(self._col_widths) - 1, _Align.RIGHT ) diff_rows.append(curr_row) if self._rst_output: diff_rows.append( self._row_divider( len(self._col_names), self._cs.H.value ) ) curr_row = '' return diff_rows def _create_cell( self, content: str, last_cell: bool, col_index: int, align: Optional[_Align] = _Align.RIGHT, ) -> str: v_border = self._cs.V.value if self._rst_output and content: content = content.replace('_', '\\_') pad_diff = self._col_widths[col_index] - len(content) padding = (pad_diff // 2) * ' ' odd_pad = ' ' if pad_diff % 2 == 1 else '' string_cell = '' if align == _Align.CENTER: string_cell = f'{v_border}{odd_pad}{padding}{content}{padding}' elif align == _Align.LEFT: string_cell = f'{v_border}{content}{padding*2}{odd_pad}' elif align == _Align.RIGHT: string_cell = f'{v_border}{padding*2}{odd_pad}{content}' if last_cell: string_cell += self._cs.V.value return string_cell def _multi_row_label(self, content: str, target_col_index: int) -> str: """Split content name into multiple rows within correct column.""" max_len = self._col_widths[target_col_index] - self._additional_padding split_content = '...'.join( content[max_len:][i : i + max_len - 3] for i in range(0, len(content[max_len:]), max_len - 3) ) split_content = f"{content[:max_len]}...{split_content}" split_tab_content = [ split_content[i : i + max_len] for i in range(0, len(split_content), max_len) ] multi_label = [] curr_row = '' for index, cut_content in enumerate(split_tab_content): last_cell = False for blank_cell_index in range(len(self._col_names)): if blank_cell_index == target_col_index: curr_row += self._create_cell( cut_content, False, target_col_index, _Align.LEFT ) else: if blank_cell_index == len(self._col_names) - 1: if index == len(split_tab_content) - 1: break last_cell = True curr_row += self._create_cell( '', last_cell, blank_cell_index ) multi_label.append(curr_row) curr_row = '' return '\n'.join(multi_label) def _row_divider(self, col_num: int, h_div: str) -> str: l_border = '' r_border = '' row_div = '' for col in range(col_num): if col == 0: l_border = self._cs.ML.value r_border = '' elif col == (col_num - 1): l_border = self._cs.MM.value r_border = self._cs.MR.value else: l_border = self._cs.MM.value r_border = '' row_div += f"{l_border}{self._col_widths[col] * h_div}{r_border}" return row_div def _create_title_row(self) -> Iterable[str]: title_rows = [] title_cells = '' last_cell = False for index, curr_name in enumerate(self._col_names): if index == len(self._col_names) - 1: last_cell = True title_cells += self._create_cell( curr_name, last_cell, index, _Align.CENTER ) title_rows.extend( [ title_cells, self._row_divider(len(self._col_names), self._cs.HH.value), ] ) return title_rows def _create_border(self, top: bool, h_div: str): """Top or bottom borders of ASCII table.""" row_div = '' for col in range(len(self._col_names)): if top: if col == 0: l_div = self._cs.TL.value r_div = '' elif col == (len(self._col_names) - 1): l_div = self._cs.TM.value r_div = self._cs.TR.value else: l_div = self._cs.TM.value r_div = '' else: if col == 0: l_div = self._cs.BL.value r_div = '' elif col == (len(self._col_names) - 1): l_div = self._cs.BM.value r_div = self._cs.BR.value else: l_div = self._cs.BM.value r_div = '' row_div += f"{l_div}{self._col_widths[col] * h_div}{r_div}" return row_div class RstOutput: """Tabular output in ASCII format, which is also valid RST.""" def __init__( self, ds_map: DataSourceMap, table_label: Optional[str] = None ): self._data_source_map = ds_map self._table_label = table_label self._diff_mode = False if isinstance(self._data_source_map, DiffDataSourceMap): self._diff_mode = True def create_table(self) -> str: """Initializes RST table and builds first row.""" table_builder = [ '\n.. list-table::', ' :widths: auto', ' :header-rows: 1\n', ] header_cols = ['Label', 'Segment', 'Delta'] for i, col_name in enumerate(header_cols): list_space = '*' if i == 0 else ' ' table_builder.append(f" {list_space} - {col_name}") return '\n'.join(table_builder) + f'\n{self.add_report_row()}\n' def _label_status_unchanged(self, parent_lb_name: str) -> bool: """Determines if parent label has no status change in diff mode.""" for curr_lb in self._data_source_map.labels(): if curr_lb.size != 0: if ( curr_lb.parents and (parent_lb_name == curr_lb.parents[0]) ) or (curr_lb.name == parent_lb_name): if get_label_status(curr_lb) != '': return False return True def add_report_row(self) -> str: """Add in new size report row with Label, Segment, and Delta. Returns: RST string that is the current row with a full symbols table breakdown of the corresponding segment. """ table_rows = [] curr_row = [] curr_label_name = '' for parent_lb in self._data_source_map.labels(0): if parent_lb.size != 0: if (self._table_label is not None) and ( curr_label_name != self._table_label ): curr_row.append(f' * - {self._table_label} ') curr_label_name = self._table_label else: curr_row.append(' * -') curr_row.extend( [ f' - .. dropdown:: {parent_lb.name}', ' :animate: fade-in\n', ' .. list-table::', ' :widths: auto\n', ] ) if self._label_status_unchanged(parent_lb.name): skip_status = 1, '*' else: skip_status = 0, ' ' for curr_lb in self._data_source_map.labels(): if (curr_lb.size != 0) and ( ( curr_lb.parents and (parent_lb.name == curr_lb.parents[0]) ) or (curr_lb.name == parent_lb.name) ): sign_size = diff_sign_sizes( curr_lb.size, self._diff_mode ) curr_status = get_label_status(curr_lb) curr_name = curr_lb.name.replace('_', '\\_') to_extend = [ f' * - {curr_status}', f' {skip_status[1]} - {sign_size}', f' - {curr_name}\n', ][skip_status[0] :] curr_row.extend(to_extend) curr_row.append( f' - {diff_sign_sizes(parent_lb.size, self._diff_mode)}' ) table_rows.extend(curr_row) curr_row = [] # No size difference. if len(table_rows) == 0: table_rows.extend( [f'\n * - {self._table_label}', ' - (ALL)', ' - 0'] ) return '\n'.join(table_rows)