1# Copyright 2022 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"""Module for size report ASCII tables from DataSourceMaps.""" 15 16import enum 17from typing import ( 18 Iterable, 19 Tuple, 20 Union, 21 Type, 22 List, 23 Optional, 24 NamedTuple, 25 cast, 26) 27 28from pw_bloat.label import DataSourceMap, DiffDataSourceMap, Label 29 30 31class AsciiCharset(enum.Enum): 32 """Set of ASCII characters for drawing tables.""" 33 34 TL = '+' 35 TM = '+' 36 TR = '+' 37 ML = '+' 38 MM = '+' 39 MR = '+' 40 BL = '+' 41 BM = '+' 42 BR = '+' 43 V = '|' 44 H = '-' 45 HH = '=' 46 47 48class LineCharset(enum.Enum): 49 """Set of line-drawing characters for tables.""" 50 51 TL = '┌' 52 TM = '┬' 53 TR = '┐' 54 ML = '├' 55 MM = '┼' 56 MR = '┤' 57 BL = '└' 58 BM = '┴' 59 BR = '┘' 60 V = '│' 61 H = '─' 62 HH = '═' 63 64 65class _Align(enum.Enum): 66 CENTER = 0 67 LEFT = 1 68 RIGHT = 2 69 70 71def get_label_status(curr_label: Label) -> str: 72 if curr_label.is_new(): 73 return 'NEW' 74 if curr_label.is_del(): 75 return 'DEL' 76 return '' 77 78 79def diff_sign_sizes(size: int, diff_mode: bool) -> str: 80 if diff_mode: 81 size_sign = '+' if size > 0 else '' 82 return f"{size_sign}{size:,}" 83 return f"{size:,}" 84 85 86class BloatTableOutput: 87 """ASCII Table generator from DataSourceMap.""" 88 89 _RST_PADDING_WIDTH = 6 90 _DEFAULT_MAX_WIDTH = 80 91 92 class _LabelContent(NamedTuple): 93 name: str 94 size: int 95 label_status: str 96 97 def __init__( 98 self, 99 ds_map: Union[DiffDataSourceMap, DataSourceMap], 100 col_max_width: int = _DEFAULT_MAX_WIDTH, 101 charset: Union[Type[AsciiCharset], Type[LineCharset]] = AsciiCharset, 102 rst_output: bool = False, 103 diff_label: Optional[str] = None, 104 ): 105 self._data_source_map = ds_map 106 self._cs = charset 107 self._total_size = 0 108 col_names = [*self._data_source_map.get_ds_names(), 'sizes'] 109 self._diff_mode = False 110 self._diff_label = diff_label 111 if isinstance(self._data_source_map, DiffDataSourceMap): 112 col_names = ['diff', *col_names] 113 self._diff_mode = True 114 self._col_names = col_names 115 self._additional_padding = 0 116 self._ascii_table_rows: List[str] = [] 117 self._rst_output = rst_output 118 self._total_divider = self._cs.HH.value 119 if self._rst_output: 120 self._total_divider = self._cs.H.value 121 self._additional_padding = self._RST_PADDING_WIDTH 122 123 self._col_widths = self._generate_col_width(col_max_width) 124 125 def _generate_col_width(self, col_max_width: int) -> List[int]: 126 """Find column width for all data sources and sizes.""" 127 max_len_size = 0 128 diff_len_col_width = 0 129 130 col_list = [ 131 len(ds_name) for ds_name in self._data_source_map.get_ds_names() 132 ] 133 for curr_label in self._data_source_map.labels(): 134 self._total_size += curr_label.size 135 max_len_size = max( 136 len(diff_sign_sizes(self._total_size, self._diff_mode)), 137 len(diff_sign_sizes(curr_label.size, self._diff_mode)), 138 max_len_size, 139 ) 140 for index, parent_label in enumerate( 141 [*curr_label.parents, curr_label.name] 142 ): 143 if len(parent_label) > col_max_width: 144 col_list[index] = col_max_width 145 elif len(parent_label) > col_list[index]: 146 col_list[index] = len(parent_label) 147 148 diff_same = 0 149 if self._diff_mode: 150 col_list = [len('Total'), *col_list] 151 diff_same = len('(SAME)') 152 col_list.append(max(max_len_size, len('sizes'), diff_same)) 153 154 if self._diff_label is not None: 155 sum_all_col_names = sum(col_list) 156 if sum_all_col_names < len(self._diff_label): 157 diff_len_col_width = ( 158 len(self._diff_label) - sum_all_col_names 159 ) // len(self._col_names) 160 161 return [ 162 (x + self._additional_padding + diff_len_col_width) 163 for x in col_list 164 ] 165 166 def _diff_label_names( 167 self, 168 old_labels: Optional[Tuple[_LabelContent, ...]], 169 new_labels: Tuple[_LabelContent, ...], 170 ) -> Tuple[_LabelContent, ...]: 171 """Return difference between arrays of labels.""" 172 173 if old_labels is None: 174 return new_labels 175 diff_list = [] 176 for new_lb, old_lb in zip(new_labels, old_labels): 177 if (new_lb.name == old_lb.name) and (new_lb.size == old_lb.size): 178 diff_list.append(self._LabelContent('', 0, '')) 179 else: 180 diff_list.append(new_lb) 181 182 return tuple(diff_list) 183 184 def _label_title_row(self) -> List[str]: 185 label_rows = [] 186 label_cells = '' 187 divider_cells = '' 188 for width in self._col_widths: 189 label_cells += ' ' * width + ' ' 190 divider_cells += (self._cs.H.value * width) + self._cs.H.value 191 if self._diff_label is not None: 192 label_cells = self._diff_label.center(len(label_cells[:-1]), ' ') 193 label_rows.extend( 194 [ 195 f"{self._cs.TL.value}{divider_cells[:-1]}{self._cs.TR.value}", 196 f"{self._cs.V.value}{label_cells}{self._cs.V.value}", 197 f"{self._cs.ML.value}{divider_cells[:-1]}{self._cs.MR.value}", 198 ] 199 ) 200 return label_rows 201 202 def create_table(self) -> str: 203 """Parse DataSourceMap to create ASCII table.""" 204 curr_lb_hierachy = None 205 last_diff_name = '' 206 if self._diff_mode: 207 self._ascii_table_rows.extend([*self._label_title_row()]) 208 else: 209 self._ascii_table_rows.extend( 210 [self._create_border(True, self._cs.H.value)] 211 ) 212 self._ascii_table_rows.extend([*self._create_title_row()]) 213 214 has_entries = False 215 216 for curr_label in self._data_source_map.labels(): 217 if curr_label.size == 0: 218 continue 219 220 has_entries = True 221 222 new_lb_hierachy = tuple( 223 [ 224 *self._get_ds_label_size(curr_label.parents), 225 self._LabelContent( 226 curr_label.name, 227 curr_label.size, 228 get_label_status(curr_label), 229 ), 230 ] 231 ) 232 diff_list = self._diff_label_names( 233 curr_lb_hierachy, new_lb_hierachy 234 ) 235 curr_lb_hierachy = new_lb_hierachy 236 237 if curr_label.parents and curr_label.parents[0] == last_diff_name: 238 continue 239 if ( 240 self._diff_mode 241 and diff_list[0].name 242 and ( 243 not cast( 244 DiffDataSourceMap, self._data_source_map 245 ).has_diff_sublabels(diff_list[0].name) 246 ) 247 ): 248 if (len(self._ascii_table_rows) > 5) and ( 249 self._ascii_table_rows[-1][0] != '+' 250 ): 251 self._ascii_table_rows.append( 252 self._row_divider( 253 len(self._col_names), self._cs.H.value 254 ) 255 ) 256 self._ascii_table_rows.append( 257 self._create_same_label_row(1, diff_list[0].name) 258 ) 259 260 last_diff_name = curr_label.parents[0] 261 else: 262 self._ascii_table_rows += self._create_diff_rows(diff_list) 263 264 if self._rst_output and self._ascii_table_rows[-1][0] == '+': 265 self._ascii_table_rows.pop() 266 267 self._ascii_table_rows.extend( 268 [*self._create_total_row(is_empty=not has_entries)] 269 ) 270 271 return '\n'.join(self._ascii_table_rows) + '\n' 272 273 def _create_same_label_row(self, col_index: int, label: str) -> str: 274 label_row = '' 275 for col in range(len(self._col_names) - 1): 276 if col == col_index: 277 curr_cell = self._create_cell(label, False, col, _Align.LEFT) 278 else: 279 curr_cell = self._create_cell('', False, col) 280 label_row += curr_cell 281 label_row += self._create_cell( 282 "(SAME)", True, len(self._col_widths) - 1, _Align.RIGHT 283 ) 284 return label_row 285 286 def _get_ds_label_size( 287 self, parent_labels: Tuple[str, ...] 288 ) -> Iterable[_LabelContent]: 289 """Produce label, size pairs from parent label names.""" 290 parent_label_sizes = [] 291 for index, target_label in enumerate(parent_labels): 292 for curr_label in self._data_source_map.labels(index): 293 if curr_label.name == target_label: 294 diff_label = get_label_status(curr_label) 295 parent_label_sizes.append( 296 self._LabelContent( 297 curr_label.name, curr_label.size, diff_label 298 ) 299 ) 300 break 301 return parent_label_sizes 302 303 def _create_total_row(self, is_empty: bool = False) -> Iterable[str]: 304 complete_total_rows = [] 305 306 if self._diff_mode and is_empty: 307 # When diffing two identical binaries, output a row indicating that 308 # the two are the same. 309 no_diff_row = '' 310 for i in range(len(self._col_names)): 311 if i == 0: 312 no_diff_row += self._create_cell( 313 'N/A', False, i, _Align.CENTER 314 ) 315 elif i == len(self._col_names) - 1: 316 no_diff_row += self._create_cell('0', True, i) 317 else: 318 no_diff_row += self._create_cell( 319 '(same)', False, i, _Align.CENTER 320 ) 321 complete_total_rows.append(no_diff_row) 322 323 complete_total_rows.append( 324 self._row_divider(len(self._col_names), self._total_divider) 325 ) 326 total_row = '' 327 328 for i in range(len(self._col_names)): 329 if i == 0: 330 total_row += self._create_cell('Total', False, i, _Align.LEFT) 331 elif i == len(self._col_names) - 1: 332 total_size_str = diff_sign_sizes( 333 self._total_size, self._diff_mode 334 ) 335 total_row += self._create_cell(total_size_str, True, i) 336 else: 337 total_row += self._create_cell('', False, i, _Align.CENTER) 338 339 complete_total_rows.extend( 340 [total_row, self._create_border(False, self._cs.H.value)] 341 ) 342 return complete_total_rows 343 344 def _create_diff_rows( 345 self, diff_list: Tuple[_LabelContent, ...] 346 ) -> Iterable[str]: 347 """Create rows for each label according to its index in diff_list.""" 348 curr_row = '' 349 diff_index = 0 350 diff_rows = [] 351 for index, label_content in enumerate(diff_list): 352 if label_content.name: 353 if self._diff_mode: 354 curr_row += self._create_cell( 355 label_content.label_status, False, 0 356 ) 357 diff_index = 1 358 for cell_index in range( 359 diff_index, len(diff_list) + diff_index 360 ): 361 if cell_index == index + diff_index: 362 if ( 363 cell_index == diff_index 364 and len(self._ascii_table_rows) > 5 365 and not self._rst_output 366 ): 367 diff_rows.append( 368 self._row_divider( 369 len(self._col_names), self._cs.H.value 370 ) 371 ) 372 if ( 373 len(label_content.name) + self._additional_padding 374 ) > self._col_widths[cell_index]: 375 curr_row = self._multi_row_label( 376 label_content.name, cell_index 377 ) 378 break 379 curr_row += self._create_cell( 380 label_content.name, False, cell_index, _Align.LEFT 381 ) 382 else: 383 curr_row += self._create_cell('', False, cell_index) 384 385 # Add size end of current row. 386 curr_size = diff_sign_sizes(label_content.size, self._diff_mode) 387 curr_row += self._create_cell( 388 curr_size, True, len(self._col_widths) - 1, _Align.RIGHT 389 ) 390 diff_rows.append(curr_row) 391 if self._rst_output: 392 diff_rows.append( 393 self._row_divider( 394 len(self._col_names), self._cs.H.value 395 ) 396 ) 397 curr_row = '' 398 399 return diff_rows 400 401 def _create_cell( 402 self, 403 content: str, 404 last_cell: bool, 405 col_index: int, 406 align: Optional[_Align] = _Align.RIGHT, 407 ) -> str: 408 v_border = self._cs.V.value 409 if self._rst_output and content: 410 content = content.replace('_', '\\_') 411 pad_diff = self._col_widths[col_index] - len(content) 412 padding = (pad_diff // 2) * ' ' 413 odd_pad = ' ' if pad_diff % 2 == 1 else '' 414 string_cell = '' 415 416 if align == _Align.CENTER: 417 string_cell = f'{v_border}{odd_pad}{padding}{content}{padding}' 418 elif align == _Align.LEFT: 419 string_cell = f'{v_border}{content}{padding*2}{odd_pad}' 420 elif align == _Align.RIGHT: 421 string_cell = f'{v_border}{padding*2}{odd_pad}{content}' 422 423 if last_cell: 424 string_cell += self._cs.V.value 425 return string_cell 426 427 def _multi_row_label(self, content: str, target_col_index: int) -> str: 428 """Split content name into multiple rows within correct column.""" 429 max_len = self._col_widths[target_col_index] - self._additional_padding 430 split_content = '...'.join( 431 content[max_len:][i : i + max_len - 3] 432 for i in range(0, len(content[max_len:]), max_len - 3) 433 ) 434 split_content = f"{content[:max_len]}...{split_content}" 435 split_tab_content = [ 436 split_content[i : i + max_len] 437 for i in range(0, len(split_content), max_len) 438 ] 439 multi_label = [] 440 curr_row = '' 441 for index, cut_content in enumerate(split_tab_content): 442 last_cell = False 443 for blank_cell_index in range(len(self._col_names)): 444 if blank_cell_index == target_col_index: 445 curr_row += self._create_cell( 446 cut_content, False, target_col_index, _Align.LEFT 447 ) 448 else: 449 if blank_cell_index == len(self._col_names) - 1: 450 if index == len(split_tab_content) - 1: 451 break 452 last_cell = True 453 curr_row += self._create_cell( 454 '', last_cell, blank_cell_index 455 ) 456 multi_label.append(curr_row) 457 curr_row = '' 458 459 return '\n'.join(multi_label) 460 461 def _row_divider(self, col_num: int, h_div: str) -> str: 462 l_border = '' 463 r_border = '' 464 row_div = '' 465 for col in range(col_num): 466 if col == 0: 467 l_border = self._cs.ML.value 468 r_border = '' 469 elif col == (col_num - 1): 470 l_border = self._cs.MM.value 471 r_border = self._cs.MR.value 472 else: 473 l_border = self._cs.MM.value 474 r_border = '' 475 476 row_div += f"{l_border}{self._col_widths[col] * h_div}{r_border}" 477 return row_div 478 479 def _create_title_row(self) -> Iterable[str]: 480 title_rows = [] 481 title_cells = '' 482 last_cell = False 483 for index, curr_name in enumerate(self._col_names): 484 if index == len(self._col_names) - 1: 485 last_cell = True 486 title_cells += self._create_cell( 487 curr_name, last_cell, index, _Align.CENTER 488 ) 489 title_rows.extend( 490 [ 491 title_cells, 492 self._row_divider(len(self._col_names), self._cs.HH.value), 493 ] 494 ) 495 return title_rows 496 497 def _create_border(self, top: bool, h_div: str): 498 """Top or bottom borders of ASCII table.""" 499 row_div = '' 500 for col in range(len(self._col_names)): 501 if top: 502 if col == 0: 503 l_div = self._cs.TL.value 504 r_div = '' 505 elif col == (len(self._col_names) - 1): 506 l_div = self._cs.TM.value 507 r_div = self._cs.TR.value 508 else: 509 l_div = self._cs.TM.value 510 r_div = '' 511 else: 512 if col == 0: 513 l_div = self._cs.BL.value 514 r_div = '' 515 elif col == (len(self._col_names) - 1): 516 l_div = self._cs.BM.value 517 r_div = self._cs.BR.value 518 else: 519 l_div = self._cs.BM.value 520 r_div = '' 521 522 row_div += f"{l_div}{self._col_widths[col] * h_div}{r_div}" 523 return row_div 524 525 526class RstOutput: 527 """Tabular output in ASCII format, which is also valid RST.""" 528 529 def __init__( 530 self, ds_map: DataSourceMap, table_label: Optional[str] = None 531 ): 532 self._data_source_map = ds_map 533 self._table_label = table_label 534 self._diff_mode = False 535 if isinstance(self._data_source_map, DiffDataSourceMap): 536 self._diff_mode = True 537 538 def create_table(self) -> str: 539 """Initializes RST table and builds first row.""" 540 table_builder = [ 541 '\n.. list-table::', 542 ' :widths: auto', 543 ' :header-rows: 1\n', 544 ] 545 header_cols = ['Label', 'Segment', 'Delta'] 546 for i, col_name in enumerate(header_cols): 547 list_space = '*' if i == 0 else ' ' 548 table_builder.append(f" {list_space} - {col_name}") 549 550 return '\n'.join(table_builder) + f'\n{self.add_report_row()}\n' 551 552 def _label_status_unchanged(self, parent_lb_name: str) -> bool: 553 """Determines if parent label has no status change in diff mode.""" 554 for curr_lb in self._data_source_map.labels(): 555 if curr_lb.size != 0: 556 if ( 557 curr_lb.parents and (parent_lb_name == curr_lb.parents[0]) 558 ) or (curr_lb.name == parent_lb_name): 559 if get_label_status(curr_lb) != '': 560 return False 561 return True 562 563 def add_report_row(self) -> str: 564 """Add in new size report row with Label, Segment, and Delta. 565 566 Returns: 567 RST string that is the current row with a full symbols 568 table breakdown of the corresponding segment. 569 """ 570 table_rows = [] 571 curr_row = [] 572 curr_label_name = '' 573 for parent_lb in self._data_source_map.labels(0): 574 if parent_lb.size != 0: 575 if (self._table_label is not None) and ( 576 curr_label_name != self._table_label 577 ): 578 curr_row.append(f' * - {self._table_label} ') 579 curr_label_name = self._table_label 580 else: 581 curr_row.append(' * -') 582 curr_row.extend( 583 [ 584 f' - .. dropdown:: {parent_lb.name}', 585 ' :animate: fade-in\n', 586 ' .. list-table::', 587 ' :widths: auto\n', 588 ] 589 ) 590 if self._label_status_unchanged(parent_lb.name): 591 skip_status = 1, '*' 592 else: 593 skip_status = 0, ' ' 594 for curr_lb in self._data_source_map.labels(): 595 if (curr_lb.size != 0) and ( 596 ( 597 curr_lb.parents 598 and (parent_lb.name == curr_lb.parents[0]) 599 ) 600 or (curr_lb.name == parent_lb.name) 601 ): 602 sign_size = diff_sign_sizes( 603 curr_lb.size, self._diff_mode 604 ) 605 curr_status = get_label_status(curr_lb) 606 curr_name = curr_lb.name.replace('_', '\\_') 607 to_extend = [ 608 f' * - {curr_status}', 609 f' {skip_status[1]} - {sign_size}', 610 f' - {curr_name}\n', 611 ][skip_status[0] :] 612 curr_row.extend(to_extend) 613 curr_row.append( 614 f' - {diff_sign_sizes(parent_lb.size, self._diff_mode)}' 615 ) 616 table_rows.extend(curr_row) 617 curr_row = [] 618 619 # No size difference. 620 if len(table_rows) == 0: 621 table_rows.extend( 622 [f'\n * - {self._table_label}', ' - (ALL)', ' - 0'] 623 ) 624 625 return '\n'.join(table_rows) 626