diff options
-rw-r--r-- | CHANGELOG.md | 2 | ||||
-rw-r--r-- | cmd2/table_creator.py | 89 | ||||
-rwxr-xr-x | examples/table_creation.py | 186 | ||||
-rw-r--r-- | tests/test_table_creator.py | 53 |
4 files changed, 266 insertions, 64 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 4951cfc7..af3e321b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and/or data text. This allows for things like nesting an AlternatingTable in another AlternatingTable. * AlternatingTable no longer applies background color to outer borders. This was done to improve appearance since the background color extended beyond the borders of the table. - * Added ability to colorize all aspects of `BorderedTables` and `AlternatingTables`. + * Added ability to colorize all aspects of `AlternatingTables`, `BorderedTables` and `SimpleTables`. * Added support for 8-bit/256-colors with the `cmd2.EightBitFg` and `cmd2.EightBitBg` classes. * Added support for 24-bit/RGB colors with the `cmd2.RgbFg` and `cmd2.RgbBg` classes. * Removed dependency on colorama. diff --git a/cmd2/table_creator.py b/cmd2/table_creator.py index 0778d0c8..de2c41c7 100644 --- a/cmd2/table_creator.py +++ b/cmd2/table_creator.py @@ -80,15 +80,15 @@ class Column: :param header_horiz_align: horizontal alignment of header cells (defaults to left) :param header_vert_align: vertical alignment of header cells (defaults to bottom) :param override_header_style: if True, then the table is allowed to apply text styles to the header, which may - interfere with any styles the header already has. If False, the header is printed as is. - Table classes which apply style to headers must respect this flag. See the BorderedTable - class for an example of this. (defaults to True) + conflict with any styles the header already has. If False, the header is printed as is. + Table classes which apply style to headers must account for the value of this flag. + (defaults to True) :param data_horiz_align: horizontal alignment of data cells (defaults to left) :param data_vert_align: vertical alignment of data cells (defaults to top) :param override_data_style: if True, then the table is allowed to apply text styles to the data, which may - interfere with any styles the data already has. If False, the data is printed as is. - Table classes which apply style to data must respect this flag. See the BorderedTable - class for an example of this. (defaults to True) + conflict with any styles the data already has. If False, the data is printed as is. + Table classes which apply style to data must account for the value of this flag. + (defaults to True) :param max_data_lines: maximum lines allowed in a data cell. If line count exceeds this, then the final line displayed will be truncated with an ellipsis. (defaults to INFINITY) :raises: ValueError if width is less than 1 @@ -549,7 +549,14 @@ class SimpleTable(TableCreator): """ def __init__( - self, cols: Sequence[Column], *, column_spacing: int = 2, tab_width: int = 4, divider_char: Optional[str] = '-' + self, + cols: Sequence[Column], + *, + column_spacing: int = 2, + tab_width: int = 4, + divider_char: Optional[str] = '-', + header_bg: Optional[ansi.BgColor] = None, + data_bg: Optional[ansi.BgColor] = None, ) -> None: """ SimpleTable initializer @@ -560,14 +567,19 @@ class SimpleTable(TableCreator): then it will be converted to one space. :param divider_char: optional character used to build the header divider row. Set this to blank or None if you don't want a divider row. Defaults to dash. (Cannot be a line breaking character) - :raises: ValueError if column_spacing is less than 0 + :param header_bg: optional background color for header cells (defaults to None) + :param data_bg: optional background color for data cells (defaults to None) :raises: ValueError if tab_width is less than 1 + :raises: ValueError if column_spacing is less than 0 :raises: TypeError if divider_char is longer than one character :raises: ValueError if divider_char is an unprintable character """ + super().__init__(cols, tab_width=tab_width) + if column_spacing < 0: raise ValueError("Column spacing cannot be less than 0") - self.inter_cell = column_spacing * SPACE + + self.column_spacing = column_spacing if divider_char == '': divider_char = None @@ -580,8 +592,29 @@ class SimpleTable(TableCreator): if divider_char_width == -1: raise ValueError("Divider character is an unprintable character") - super().__init__(cols, tab_width=tab_width) self.divider_char = divider_char + self.header_bg = header_bg + self.data_bg = data_bg + + def apply_header_bg(self, value: Any) -> str: + """ + If defined, apply the header background color to header text + :param value: object whose text is to be colored + :return: formatted text + """ + if self.header_bg is None: + return str(value) + return ansi.style(value, bg=self.header_bg) + + def apply_data_bg(self, value: Any) -> str: + """ + If defined, apply the data background color to data text + :param value: object whose text is to be colored + :return: formatted data string + """ + if self.data_bg is None: + return str(value) + return ansi.style(value, bg=self.data_bg) @classmethod def base_width(cls, num_cols: int, *, column_spacing: int = 2) -> int: @@ -608,7 +641,7 @@ class SimpleTable(TableCreator): def total_width(self) -> int: """Calculate the total display width of this table""" - base_width = self.base_width(len(self.cols), column_spacing=ansi.style_aware_wcswidth(self.inter_cell)) + base_width = self.base_width(len(self.cols), column_spacing=self.column_spacing) data_width = sum(col.width for col in self.cols) return base_width + data_width @@ -616,9 +649,20 @@ class SimpleTable(TableCreator): """Generate table header with an optional divider row""" header_buf = io.StringIO() + fill_char = self.apply_header_bg(SPACE) + inter_cell = self.apply_header_bg(self.column_spacing * SPACE) + + # Apply background color to header text in Columns which allow it + to_display: List[Any] = [] + for index, col in enumerate(self.cols): + if col.override_header_style: + to_display.append(self.apply_header_bg(col.header)) + else: + to_display.append(col.header) + # Create the header labels - header = self.generate_row(inter_cell=self.inter_cell) - header_buf.write(header) + header_labels = self.generate_row(row_data=to_display, fill_char=fill_char, inter_cell=inter_cell) + header_buf.write(header_labels) # Add the divider if necessary divider = self.generate_divider() @@ -641,7 +685,18 @@ class SimpleTable(TableCreator): :param row_data: data with an entry for each column in the row :return: data row string """ - return self.generate_row(row_data=row_data, inter_cell=self.inter_cell) + fill_char = self.apply_data_bg(SPACE) + inter_cell = self.apply_data_bg(self.column_spacing * SPACE) + + # Apply background color to data text in Columns which allow it + to_display: List[Any] = [] + for index, col in enumerate(self.cols): + if col.override_data_style: + to_display.append(self.apply_data_bg(row_data[index])) + else: + to_display.append(row_data[index]) + + return self.generate_row(row_data=to_display, fill_char=fill_char, inter_cell=inter_cell) def generate_table(self, table_data: Sequence[Sequence[Any]], *, include_header: bool = True, row_spacing: int = 1) -> str: """ @@ -665,9 +720,11 @@ class SimpleTable(TableCreator): if len(table_data) > 0: table_buf.write('\n') + row_divider = utils.align_left('', fill_char=self.apply_data_bg(SPACE), width=self.total_width()) + '\n' + for index, row_data in enumerate(table_data): if index > 0 and row_spacing > 0: - table_buf.write(row_spacing * '\n') + table_buf.write(row_spacing * row_divider) row = self.generate_data_row(row_data) table_buf.write(row) @@ -707,6 +764,7 @@ class BorderedTable(TableCreator): :param border_fg: optional foreground color for borders (defaults to None) :param header_bg: optional background color for header cells (defaults to None) :param data_bg: optional background color for data cells (defaults to None) + :raises: ValueError if tab_width is less than 1 :raises: ValueError if padding is less than 0 """ super().__init__(cols, tab_width=tab_width) @@ -1003,6 +1061,7 @@ class AlternatingTable(BorderedTable): :param header_bg: optional background color for header cells (defaults to None) :param odd_bg: optional background color for odd numbered data rows (defaults to None) :param even_bg: optional background color for even numbered data rows (defaults to StdBg.DARK_GRAY) + :raises: ValueError if tab_width is less than 1 :raises: ValueError if padding is less than 0 """ super().__init__( diff --git a/examples/table_creation.py b/examples/table_creation.py index 87707782..0c637aad 100755 --- a/examples/table_creation.py +++ b/examples/table_creation.py @@ -9,6 +9,8 @@ from typing import ( ) from cmd2 import ( + EightBitBg, + EightBitFg, Fg, ansi, ) @@ -20,6 +22,11 @@ from cmd2.table_creator import ( SimpleTable, ) +# Text styles used in the tables +bold_yellow = functools.partial(ansi.style, fg=Fg.LIGHT_YELLOW, bold=True) +blue = functools.partial(ansi.style, fg=Fg.LIGHT_BLUE) +green = functools.partial(ansi.style, fg=Fg.GREEN) + class DollarFormatter: """Example class to show that any object type can be passed as data to TableCreator and converted to a string""" @@ -32,37 +39,22 @@ class DollarFormatter: return "${:,.2f}".format(self.val) -# Text styles used in the data -bold_yellow = functools.partial(ansi.style, fg=Fg.LIGHT_YELLOW, bold=True) -blue = functools.partial(ansi.style, fg=Fg.LIGHT_BLUE) -green = functools.partial(ansi.style, fg=Fg.GREEN) +class Book: + """Class used for example data""" -# Table Columns (width does not account for any borders or padding which may be added) -columns: List[Column] = list() -columns.append(Column("Name", width=20)) -columns.append(Column("Address", width=38)) -columns.append( - Column("Income", width=14, header_horiz_align=HorizontalAlignment.RIGHT, data_horiz_align=HorizontalAlignment.RIGHT) -) + def __init__(self, title: str, due_date: str) -> None: + self.title = title + self.due_date = due_date -# Table data which demonstrates handling of wrapping and text styles -data_list: List[List[Any]] = list() -data_list.append(["Billy Smith", "123 Sesame St.\n" "Fake Town, USA 33445", DollarFormatter(100333.03)]) -data_list.append( - [ - "William Longfellow Marmaduke III", - "984 Really Long Street Name Which Will Wrap Nicely\n" "Apt 22G\n" "Pensacola, FL 32501", - DollarFormatter(55135.22), - ] -) -data_list.append( - [ - "James " + blue("Bluestone"), - bold_yellow("This address has line feeds,\n" "text styles, and wrapping. ") + blue("Style is preserved across lines."), - DollarFormatter(300876.10), - ] -) -data_list.append(["John Jones", "9235 Highway 32\n" + green("Greenville") + ", SC 29604", DollarFormatter(82987.71)]) + +class Person: + """Class used for example data""" + + def __init__(self, name: str, birthday: str, department: str) -> None: + self.name = name + self.birthday = birthday + self.department = department + self.books: List[Book] = [] def ansi_print(text): @@ -70,9 +62,36 @@ def ansi_print(text): ansi.style_aware_write(sys.stdout, text + '\n\n') -def main(): - # Default to terminal mode so redirecting to a file won't include the ANSI style sequences - ansi.allow_style = ansi.AllowStyle.TERMINAL +def basic_tables(): + """Demonstrates basic examples of the table classes""" + + # Table data which demonstrates handling of wrapping and text styles + data_list: List[List[Any]] = list() + data_list.append(["Billy Smith", "123 Sesame St.\n" "Fake Town, USA 33445", DollarFormatter(100333.03)]) + data_list.append( + [ + "William Longfellow Marmaduke III", + "984 Really Long Street Name Which Will Wrap Nicely\n" "Apt 22G\n" "Pensacola, FL 32501", + DollarFormatter(55135.22), + ] + ) + data_list.append( + [ + "James " + blue("Bluestone"), + bold_yellow("This address has line feeds,\n" "text styles, and wrapping. ") + + blue("Style is preserved across lines."), + DollarFormatter(300876.10), + ] + ) + data_list.append(["John Jones", "9235 Highway 32\n" + green("Greenville") + ", SC 29604", DollarFormatter(82987.71)]) + + # Table Columns (width does not account for any borders or padding which may be added) + columns: List[Column] = list() + columns.append(Column("Name", width=20)) + columns.append(Column("Address", width=38)) + columns.append( + Column("Income", width=14, header_horiz_align=HorizontalAlignment.RIGHT, data_horiz_align=HorizontalAlignment.RIGHT) + ) st = SimpleTable(columns) table = st.generate_table(data_list) @@ -87,5 +106,106 @@ def main(): ansi_print(table) +def nested_tables(): + """ + Demonstrates how to nest tables using the override_data_style keyword to handle tables with conflicting styles. + In these cases, the inner tables reset the background color applied by the outer AlternatingTable. + + It also demonstrates coloring various aspects of tables. + """ + + # Create data for this example + person_data: List[Person] = [] + person_1 = Person("Bill Anderson", "01/22/1955", "Accounting") + person_1.books.append(Book("Great Expectations", "11/01/2025")) + person_1.books.append(Book("Strange Case of Dr Jekyll and Mr Hyde", "07/16/2026")) + person_1.books.append(Book("Dune", "01/24/2027")) + + person_2 = Person("Arthur Smith", "06/11/1974", "Automotive") + person_2.books.append(Book("Nineteen Eighty-Four", "08/07/2025")) + person_2.books.append(Book("Pride and Prejudice", "04/13/2026")) + person_2.books.append(Book("Fahrenheit 451", "07/29/2026")) + person_2.books.append(Book("The Count of Monte Cristo", "10/15/2027")) + + person_data.append(person_1) + person_data.append(person_2) + + # Define table which presents Person data fields vertically with no header. + # This will be nested in the parent table. + person_columns: List[Column] = list() + person_columns.append(Column("", width=10)) + person_columns.append(Column("", width=20)) + + # The text labels in this table will be bold text. They will also be aligned by the table code. + # When styled text is aligned, a TextStyle.RESET_ALL sequence is inserted between the aligned text + # and the fill characters. Therefore, the Person table will contain TextStyle.RESET_ALL sequences, + # which would interfere with the background color applied by the parent table. To account for this, + # we will color the Person tables to match the background colors of the parent AlternatingTable's rows + # and set override_data_style to False in the Person column. See below for that. + odd_person_tbl = SimpleTable(person_columns, data_bg=EightBitBg.GRAY_0) + even_person_tbl = SimpleTable(person_columns, data_bg=EightBitBg.GRAY_15) + + # Define AlternatingTable table for books checked out by people in the first table. + # This will also be nested in the parent table. + books_columns: List[Column] = list() + books_columns.append(Column("Title", width=28)) + books_columns.append(Column("Due Date", width=10)) + + books_tbl = AlternatingTable( + books_columns, + column_borders=False, + border_fg=EightBitFg.GRAY_15, + header_bg=EightBitBg.GRAY_0, + odd_bg=EightBitBg.GRAY_0, + even_bg=EightBitBg.GRAY_15, + ) + + # Define parent AlternatingTable which contains Person and Book tables + parent_tbl_columns: List[Column] = list() + + # Both the Person and Books tables already have background colors. Set override_data_style + # to False so the parent AlternatingTable does not apply background color to them. + parent_tbl_columns.append(Column("Person", width=odd_person_tbl.total_width(), override_data_style=False)) + parent_tbl_columns.append(Column("Books", width=books_tbl.total_width(), override_data_style=False)) + + parent_tbl = AlternatingTable( + parent_tbl_columns, + column_borders=False, + header_bg=EightBitBg.GRAY_0, + odd_bg=EightBitBg.GRAY_0, + even_bg=EightBitBg.GRAY_15, + ) + + # Construct the tables + parent_table_data: List[List[Any]] = [] + for row, person in enumerate(person_data, start=1): + # First build the person table and color it based on row number + person_tbl = even_person_tbl if row % 2 == 0 else odd_person_tbl + + # This table has three rows and two columns + table_data = [ + [ansi.style("Name", bold=True), person.name], + [ansi.style("Birthday", bold=True), person.birthday], + [ansi.style("Department", bold=True), person.department], + ] + + # Build the person table string + person_tbl_str = person_tbl.generate_table(table_data, include_header=False, row_spacing=0) + + # Now build this person's book table + table_data = [[book.title, book.due_date] for book in person.books] + book_tbl_str = books_tbl.generate_table(table_data) + + # Add these tables to the parent table's data + parent_table_data.append(['\n' + person_tbl_str, '\n' + book_tbl_str + '\n\n']) + + # Build the parent table + top_table_str = parent_tbl.generate_table(parent_table_data) + ansi_print(top_table_str) + + if __name__ == '__main__': - main() + # Default to terminal mode so redirecting to a file won't include the ANSI style sequences + ansi.allow_style = ansi.AllowStyle.TERMINAL + basic_tables() + nested_tables() diff --git a/tests/test_table_creator.py b/tests/test_table_creator.py index c5b5f36e..12c3e36b 100644 --- a/tests/test_table_creator.py +++ b/tests/test_table_creator.py @@ -252,7 +252,7 @@ def test_wrap_long_word(): + ' ' + TextStyle.RESET_ALL + ' LongerThan\n' - ' 10 ' + ' 10 ' ) assert row == expected @@ -350,7 +350,7 @@ def test_simple_table_creation(): 'Col 1 Col 2 \n' '----------------------------------\n' 'Col 1 Row 1 Col 2 Row 1 \n' - '\n' + ' \n' 'Col 1 Row 2 Col 2 Row 2 ' ) @@ -362,7 +362,7 @@ def test_simple_table_creation(): 'Col 1 Col 2 \n' '-------------------------------------\n' 'Col 1 Row 1 Col 2 Row 1 \n' - '\n' + ' \n' 'Col 1 Row 2 Col 2 Row 2 ' ) @@ -374,7 +374,7 @@ def test_simple_table_creation(): 'Col 1 Col 2 \n' '──────────────────────────────────\n' 'Col 1 Row 1 Col 2 Row 1 \n' - '\n' + ' \n' 'Col 1 Row 2 Col 2 Row 2 ' ) @@ -388,7 +388,7 @@ def test_simple_table_creation(): assert no_divider_1 == no_divider_2 == ( 'Col 1 Col 2 \n' 'Col 1 Row 1 Col 2 Row 1 \n' - '\n' + ' \n' 'Col 1 Row 2 Col 2 Row 2 ' ) @@ -407,7 +407,7 @@ def test_simple_table_creation(): table = st.generate_table(row_data, include_header=False) assert table == ('Col 1 Row 1 Col 2 Row 1 \n' - '\n' + ' \n' 'Col 1 Row 2 Col 2 Row 2 ') # Wide custom divider (divider needs no padding) @@ -418,20 +418,20 @@ def test_simple_table_creation(): 'Col 1 Col 2 \n' '深深深深深深深深深深深深深深深深深\n' 'Col 1 Row 1 Col 2 Row 1 \n' - '\n' + ' \n' 'Col 1 Row 2 Col 2 Row 2 ' ) # Wide custom divider (divider needs padding) - column_2 = Column("Col 2", width=17) - st = SimpleTable([column_1, column_2], divider_char='深') + st = SimpleTable([column_1, Column("Col 2", width=17)], + divider_char='深') table = st.generate_table(row_data) assert table == ( 'Col 1 Col 2 \n' '深深深深深深深深深深深深深深深深深 \n' 'Col 1 Row 1 Col 2 Row 1 \n' - '\n' + ' \n' 'Col 1 Row 2 Col 2 Row 2 ' ) @@ -455,6 +455,28 @@ def test_simple_table_creation(): st.generate_table(row_data, row_spacing=-1) assert "Row spacing cannot be less than 0" in str(excinfo.value) + # Test header and data colors + st = SimpleTable([column_1, column_2], divider_char=None, header_bg=Bg.GREEN, data_bg=Bg.LIGHT_BLUE) + table = st.generate_table(row_data) + assert table == ( + '\x1b[0m\x1b[42mCol 1\x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[0m\x1b[42mCol 2\x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[0m\n' + '\x1b[0m\x1b[104mCol 1 Row 1\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104mCol 2 Row 1\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\n' + '\x1b[0m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\n' + '\x1b[0m\x1b[104mCol 1 Row 2\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104mCol 2 Row 2\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m' + ) + + # Make sure SimpleTable respects override_header_style override_data_style flags. + # Don't apply parent table's background colors to header or data text in second column. + st = SimpleTable([column_1, Column("Col 2", width=16, override_header_style=False, override_data_style=False)], + divider_char=None, header_bg=Bg.GREEN, data_bg=Bg.LIGHT_BLUE) + table = st.generate_table(row_data) + assert table == ( + '\x1b[0m\x1b[42mCol 1\x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[0mCol 2\x1b[0m\x1b[42m \x1b[49m\x1b[0m\n' + '\x1b[0m\x1b[104mCol 1 Row 1\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0mCol 2 Row 1\x1b[0m\x1b[104m \x1b[49m\x1b[0m\n' + '\x1b[0m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\n' + '\x1b[0m\x1b[104mCol 1 Row 2\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[0mCol 2 Row 2\x1b[0m\x1b[104m \x1b[49m\x1b[0m' + ) + def test_simple_table_width(): # Base width @@ -556,8 +578,8 @@ def test_bordered_table_creation(): # Make sure BorderedTable respects override_header_style override_data_style flags. # Don't apply parent table's background colors to header or data text in second column. - column_2 = Column("Col 2", width=15, override_header_style=False, override_data_style=False) - bt = BorderedTable([column_1, column_2], header_bg=Bg.GREEN, data_bg=Bg.LIGHT_BLUE) + bt = BorderedTable([column_1, Column("Col 2", width=15, override_header_style=False, override_data_style=False)], + header_bg=Bg.GREEN, data_bg=Bg.LIGHT_BLUE) table = bt.generate_table(row_data) assert table == ( '╔═════════════════╤═════════════════╗\n' @@ -668,7 +690,8 @@ def test_alternating_table_creation(): assert "Padding cannot be less than 0" in str(excinfo.value) # Test border, header, and data colors - at = AlternatingTable([column_1, column_2], border_fg=Fg.LIGHT_YELLOW, header_bg=Bg.GREEN, odd_bg=Bg.LIGHT_BLUE, even_bg=Bg.LIGHT_RED) + at = AlternatingTable([column_1, column_2], border_fg=Fg.LIGHT_YELLOW, header_bg=Bg.GREEN, + odd_bg=Bg.LIGHT_BLUE, even_bg=Bg.LIGHT_RED) table = at.generate_table(row_data) assert table == ( '\x1b[93m╔═\x1b[39m\x1b[0m\x1b[0m\x1b[93m═══════════════\x1b[39m\x1b[0m\x1b[93m═╤═\x1b[39m\x1b[0m\x1b[0m\x1b[93m═══════════════\x1b[39m\x1b[0m\x1b[93m═╗\x1b[39m\n' @@ -681,8 +704,8 @@ def test_alternating_table_creation(): # Make sure AlternatingTable respects override_header_style override_data_style flags. # Don't apply parent table's background colors to header or data text in second column. - column_2 = Column("Col 2", width=15, override_header_style=False, override_data_style=False) - at = AlternatingTable([column_1, column_2], header_bg=Bg.GREEN, odd_bg=Bg.LIGHT_BLUE, even_bg=Bg.LIGHT_RED) + at = AlternatingTable([column_1, Column("Col 2", width=15, override_header_style=False, override_data_style=False)], + header_bg=Bg.GREEN, odd_bg=Bg.LIGHT_BLUE, even_bg=Bg.LIGHT_RED) table = at.generate_table(row_data) assert table == ( '╔═════════════════╤═════════════════╗\n' |