diff --git a/pyproject.toml b/pyproject.toml index 6b095845..e577b142 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,17 +67,6 @@ files = ["src", "tests"] show_error_codes = true pretty = true -# The following whitelist is used to allow for incremental adoption -# of Mypy. Modules should be removed from this whitelist as and when -# their respective type errors have been addressed. No new modules -# should be added to this whitelist. - -[[tool.mypy.overrides]] -module = [ - "cleo.ui.table", -] -ignore_errors = true - [tool.coverage.report] omit = [ "src/cleo/_compat.py", diff --git a/src/cleo/commands/command.py b/src/cleo/commands/command.py index 10bb5342..c569031b 100644 --- a/src/cleo/commands/command.py +++ b/src/cleo/commands/command.py @@ -22,8 +22,8 @@ from cleo.ui.progress_bar import ProgressBar from cleo.ui.progress_indicator import ProgressIndicator from cleo.ui.question import Question + from cleo.ui.table import Rows from cleo.ui.table import Table - from cleo.ui.table import _Rows class Command(BaseCommand): @@ -184,7 +184,7 @@ def create_question( def table( self, header: str | None = None, - rows: _Rows | None = None, + rows: Rows | None = None, style: str | None = None, ) -> Table: """ @@ -210,7 +210,7 @@ def table_separator(self) -> TableSeparator: return TableSeparator() - def render_table(self, headers: str, rows: _Rows, style: str | None = None) -> None: + def render_table(self, headers: str, rows: Rows, style: str | None = None) -> None: """ Format input to textual table. """ diff --git a/src/cleo/py.typed b/src/cleo/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/src/cleo/ui/table.py b/src/cleo/ui/table.py index a35850ee..cf3cc238 100644 --- a/src/cleo/ui/table.py +++ b/src/cleo/ui/table.py @@ -5,11 +5,13 @@ from copy import deepcopy from typing import TYPE_CHECKING -from typing import Generator +from typing import Iterator from typing import List from typing import Union +from typing import cast from cleo.formatters.formatter import Formatter +from cleo.io.outputs.output import Output from cleo.ui.table_cell import TableCell from cleo.ui.table_cell_style import TableCellStyle from cleo.ui.table_separator import TableSeparator @@ -18,10 +20,10 @@ if TYPE_CHECKING: from cleo.io.io import IO - from cleo.io.outputs.output import Output -_Row = List[Union[str, TableCell]] -_Rows = List[Union[_Row, TableSeparator]] +Row = List[Union[str, TableCell]] +Rows = List[Union[Row, TableSeparator]] +Header = Row class Table: @@ -45,14 +47,14 @@ def __init__(self, io: IO | Output, style: str | None = None) -> None: self._header_title: str | None = None self._footer_title: str | None = None - self._headers: list[str] = [] + self._headers: list[Header] = [] - self._rows: _Rows = [] + self._rows: Rows = [] self._horizontal = False self._effective_column_widths: dict[int, int] = {} - self._number_of_columns = None + self._number_of_columns: int | None = None self._column_styles: dict[int, TableStyle] = {} self._column_widths: dict[int, int] = {} @@ -66,6 +68,7 @@ def __init__(self, io: IO | Output, style: str | None = None) -> None: @property def style(self) -> TableStyle: + assert self._style is not None return self._style def set_style(self, name: str) -> Table: @@ -79,7 +82,7 @@ def column_style(self, column_index: int) -> TableStyle: if column_index in self._column_styles: return self._column_styles[column_index] - return self._style + return self.style def set_column_style(self, column_index: int, style: str | TableStyle) -> Table: self._column_styles[column_index] = self._resolve_style(style) @@ -104,26 +107,29 @@ def set_column_max_width(self, column_index: int, width: int) -> Table: return self - def set_headers(self, headers: list[str]) -> Table: + def set_headers(self, headers: Header | list[Header]) -> Table: if headers and not isinstance(headers[0], list): + headers = cast("Header", headers) headers = [headers] + headers = cast("List[Header]", headers) + self._headers = headers return self - def set_rows(self, rows: _Rows) -> Table: + def set_rows(self, rows: Rows) -> Table: self._rows = [] return self.add_rows(rows) - def add_rows(self, rows: _Rows) -> Table: + def add_rows(self, rows: Rows) -> Table: for row in rows: self.add_row(row) return self - def add_row(self, row: _Row | TableSeparator) -> Table: + def add_row(self, row: Row | TableSeparator) -> Table: if isinstance(row, TableSeparator): self._rows.append(row) @@ -152,7 +158,7 @@ def render(self) -> None: divider = TableSeparator() if self._horizontal: - rows = [] + rows: Rows = [] headers = self._headers[0] if self._headers else [] for i, header in enumerate(headers): rows.append([header]) @@ -160,15 +166,18 @@ def render(self) -> None: if isinstance(row, TableSeparator): continue + rows_i = rows[i] + assert not isinstance(rows_i, TableSeparator) + if len(row) > i: - rows[i].append(row[i]) - elif isinstance(rows[i][0], TableCell) and rows[i][0].colspan >= 2: + rows_i.append(row[i]) + elif isinstance(rows_i[0], TableCell) and rows_i[0].colspan >= 2: # There is a title pass else: - rows[i].append(None) + rows_i.append("") else: - rows = self._headers + [divider] + self._rows + rows = cast("Rows", self._headers) + [divider] + self._rows self._calculate_number_of_columns(rows) rows = list(self._build_table_rows(rows)) @@ -200,25 +209,25 @@ def render(self) -> None: self._render_row_separator( self.SEPARATOR_TOP, self._header_title, - self._style.header_title_format, + self.style.header_title_format, ) if self._horizontal: self._render_row( - row, self._style.cell_row_format, self._style.cell_header_format + row, self.style.cell_row_format, self.style.cell_header_format ) else: self._render_row( row, - self._style.cell_header_format + self.style.cell_header_format if is_header - else self._style.cell_row_format, + else self.style.cell_row_format, ) self._render_row_separator( self.SEPARATOR_BOTTOM, self._footer_title, - self._style.footer_title_format, + self.style.footer_title_format, ) self._cleanup() @@ -241,11 +250,11 @@ def _render_row_separator( if not count: return - borders = self._style.border_chars - if not borders[0] and not borders[2] and not self._style.crossing_char: + borders = self.style.border_chars + if not borders[0] and not borders[2] and not self.style.crossing_char: return - crossings = self._style.crossing_chars + crossings = self.style.crossing_chars if type == self.SEPARATOR_MID: horizontal, left_char, mid_char, right_char = ( borders[2], @@ -281,6 +290,7 @@ def _render_row_separator( markup += right_char if column == count - 1 else mid_char if title is not None: + assert title_format is not None formatted_title = title_format.format(title) title_length = len(self._io.remove_format(formatted_title)) markup_length = len(markup) @@ -300,15 +310,15 @@ def _render_row_separator( + markup[title_start + title_length :] ) - self._io.write_line(self._style.border_format.format(markup)) + self._io.write_line(self.style.border_format.format(markup)) def _render_column_separator(self, type: int = BORDER_OUTSIDE) -> str: """ Renders vertical column separator. """ - borders = self._style.border_chars + borders = self.style.border_chars - return self._style.border_format.format( + return self.style.border_format.format( borders[1] if type == self.BORDER_OUTSIDE else borders[3] ) @@ -337,7 +347,7 @@ def _render_row( self._io.write_line(row_content) - def _render_cell(self, row: _Row, column: int, cell_format: str) -> str: + def _render_cell(self, row: Row, column: int, cell_format: str) -> str: """ Renders a table cell with padding. """ @@ -369,23 +379,25 @@ def _render_cell(self, row: _Row, column: int, cell_format: str) -> str: r"^<(\w+|(\w+=[\w,]+;?)*)>.+$", str(cell) ) if is_not_styled_by_tag: - cell_format = cell.style.cell_format - if cell_format is None: - cell_format = f"<{cell.style.tag}>{{}}" + cell_format = ( + cell.style.cell_format + if cell.style.cell_format is not None + else f"<{cell.style.tag}>{{}}" + ) if "" in content: content = content.replace("", "") width -= 3 if "" in content: - content = content.replace("") + content = content.replace("", "") width -= len("") pad = cell.style.pad return cell_format.format(pad(content, width, style.padding_char)) - def _calculate_number_of_columns(self, rows: _Rows) -> None: + def _calculate_number_of_columns(self, rows: Rows) -> None: columns = [0] for row in rows: if isinstance(row, TableSeparator): @@ -395,8 +407,8 @@ def _calculate_number_of_columns(self, rows: _Rows) -> None: self._number_of_columns = max(columns) - def _build_table_rows(self, rows: _Rows) -> Generator: - unmerged_rows = {} + def _build_table_rows(self, rows: Rows) -> Iterator[Row | TableSeparator]: + unmerged_rows: dict[int, dict[int, Row]] = {} row_key = 0 while row_key < len(rows): rows = self._fill_next_rows(rows, row_key) @@ -408,6 +420,7 @@ def _build_table_rows(self, rows: _Rows) -> Generator: if column in self._column_max_widths and self._column_max_widths[ column ] < len(self._io.remove_format(cell)): + assert isinstance(self._io, Output) cell = self._io.formatter.format_and_wrap( cell, self._column_max_widths[column] * colspan ) @@ -430,7 +443,9 @@ def _build_table_rows(self, rows: _Rows) -> Generator: line = TableCell(line, colspan=colspan) if line_key == 0: - rows[row_key][column] = line + row = rows[row_key] + assert not isinstance(row, TableSeparator) + row[column] = line else: if row_key not in unmerged_rows: unmerged_rows[row_key] = {} @@ -454,7 +469,9 @@ def _build_table_rows(self, rows: _Rows) -> Generator: def _calculate_row_count(self) -> int: number_of_rows = len( list( - self._build_table_rows(self._headers + [TableSeparator()] + self._rows) + self._build_table_rows( + cast("Rows", self._headers) + [TableSeparator()] + self._rows + ) ) ) @@ -466,16 +483,16 @@ def _calculate_row_count(self) -> int: return number_of_rows - def _fill_next_rows(self, rows: _Rows, line: int) -> _Rows: + def _fill_next_rows(self, rows: Rows, line: int) -> Rows: """ Fill rows that contains rowspan > 1. """ - unmerged_rows: dict[int, _Row] = {} + unmerged_rows: dict[int, dict[int, str | TableCell]] = {} for column, cell in enumerate(rows[line]): if isinstance(cell, TableCell) and cell.rowspan > 1: nb_lines = cell.rowspan - 1 - lines = [cell] + lines: Row = [cell] if "\n" in cell: lines = cell.replace("\n", "\n").split( "\n" @@ -483,19 +500,21 @@ def _fill_next_rows(self, rows: _Rows, line: int) -> _Rows: if len(lines) > nb_lines: nb_lines = cell.count("\n") - rows[line][column] = TableCell( + row = rows[line] + assert not isinstance(row, TableSeparator) + + row[column] = TableCell( lines[0], colspan=cell.colspan, style=cell.style ) # Create a two dimensional dict (rowspan x colspan) - placeholder = {k: {} for k in range(line + 1, line + 1 + nb_lines)} + placeholder: dict[int, dict[int, str | TableCell]] = { + k: {} for k in range(line + 1, line + 1 + nb_lines) + } for k, v in unmerged_rows.items(): if k in placeholder: for l, m in unmerged_rows[k].items(): # noqa: E741 - if l in placeholder[k]: - placeholder[k][l].update(m) - else: - placeholder[k][l] = m + placeholder[k][l] = m else: placeholder[k] = v @@ -514,12 +533,14 @@ def _fill_next_rows(self, rows: _Rows, line: int) -> _Rows: for unmerged_row_key, unmerged_row in unmerged_rows.items(): # we need to know if unmerged_row will be merged or inserted into rows + assert self._number_of_columns is not None + this_row = None if unmerged_row_key >= len(rows) else rows[unmerged_row_key] if ( - unmerged_row_key < len(rows) - and isinstance(rows[unmerged_row_key], list) + this_row is not None + and not isinstance(this_row, TableSeparator) and ( ( - self._get_number_of_columns(rows[unmerged_row_key]) + self._get_number_of_columns(this_row) + self._get_number_of_columns( list(unmerged_rows[unmerged_row_key].values()) ) @@ -529,7 +550,7 @@ def _fill_next_rows(self, rows: _Rows, line: int) -> _Rows: ): # insert cell into row at cell_key position for cell_key, cell in unmerged_row.items(): - rows[unmerged_row_key].insert(cell_key, cell) + this_row.insert(cell_key, cell) else: row = self._copy_row(rows, unmerged_row_key - 1) for column, cell in unmerged_row.items(): @@ -540,7 +561,7 @@ def _fill_next_rows(self, rows: _Rows, line: int) -> _Rows: return rows - def _fill_cells(self, row: _Row) -> list[str | TableCell]: + def _fill_cells(self, row: Row | TableSeparator) -> Row | TableSeparator: """ Fills cells for a row that contains colspan > 1. """ @@ -559,7 +580,7 @@ def _fill_cells(self, row: _Row) -> list[str | TableCell]: return row - def _copy_row(self, rows: _Rows, line: int) -> _Row: + def _copy_row(self, rows: Rows, line: int) -> Row: """ Copies a row. """ @@ -572,7 +593,7 @@ def _copy_row(self, rows: _Rows, line: int) -> _Row: return row - def _get_number_of_columns(self, row: _Row): + def _get_number_of_columns(self, row: Row) -> int: """ Gets number of columns by row. """ @@ -583,10 +604,11 @@ def _get_number_of_columns(self, row: _Row): return columns - def _get_row_columns(self, row: _Row) -> list[int]: + def _get_row_columns(self, row: Row) -> list[int]: """ Gets list of columns for the given row. """ + assert self._number_of_columns is not None columns = list(range(0, self._number_of_columns)) for cell_key, cell in enumerate(row): @@ -600,10 +622,11 @@ def _get_row_columns(self, row: _Row) -> list[int]: return columns - def _calculate_column_widths(self, rows: _Rows) -> None: + def _calculate_column_widths(self, rows: Rows) -> None: """ Calculates column widths. """ + assert self._number_of_columns is not None for column in range(0, self._number_of_columns): lengths = [0] for row in rows: @@ -631,13 +654,13 @@ def _calculate_column_widths(self, rows: _Rows) -> None: lengths.append(self._get_cell_width(row_, column)) self._effective_column_widths[column] = ( - max(lengths) + len(self._style.cell_row_content_format) - 2 + max(lengths) + len(self.style.cell_row_content_format) - 2 ) def _get_column_separator_width(self) -> int: - return len(self._style.border_format.format(self._style.border_chars[3])) + return len(self.style.border_format.format(self.style.border_chars[3])) - def _get_cell_width(self, row: _Row, column: int) -> int: + def _get_cell_width(self, row: Row, column: int) -> int: """ Gets cell width. """ @@ -704,6 +727,7 @@ def _resolve_style(cls, name: str | TableStyle) -> TableStyle: if isinstance(name, TableStyle): return name + assert cls._styles is not None if name in cls._styles: return deepcopy(cls._styles[name]) diff --git a/tests/ui/test_table.py b/tests/ui/test_table.py index e2afcce3..d10b60c2 100644 --- a/tests/ui/test_table.py +++ b/tests/ui/test_table.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: from cleo.io.buffered_io import BufferedIO - from cleo.ui.table import _Rows + from cleo.ui.table import Rows books = [ ["99921-58-10-7", "Divine Comedy", "Dante Alighieri"], @@ -417,7 +417,7 @@ ], ) def test_render( - io: BufferedIO, headers: list[str], rows: _Rows, style: str, expected: str + io: BufferedIO, headers: list[str], rows: Rows, style: str, expected: str ) -> None: table = Table(io, style=style) table.set_headers(headers) @@ -458,7 +458,7 @@ def test_column_style(io: BufferedIO) -> None: def test_style_for_side_effects(io: BufferedIO) -> None: headers = ["Type", "Class", "Name"] - rows: _Rows = [ + rows: Rows = [ ["GSV", "Range", "Bora Horza Gobuchul"], ["GSV", "Plate", "Sleeper Service"], ["GCU", "Ridge", "Grey Area"],