diff options
-rw-r--r-- | cmd2/argparse_completer.py | 9 | ||||
-rw-r--r-- | cmd2/argparse_custom.py | 19 | ||||
-rw-r--r-- | cmd2/cmd2.py | 4 | ||||
-rw-r--r-- | cmd2/constants.py | 1 | ||||
-rw-r--r-- | cmd2/table_creator.py | 805 | ||||
-rw-r--r-- | cmd2/utils.py | 92 | ||||
-rw-r--r-- | docs/api/index.rst | 2 | ||||
-rw-r--r-- | docs/api/table_creator.rst | 35 | ||||
-rw-r--r-- | docs/api/utils.rst | 1 | ||||
-rw-r--r-- | docs/features/index.rst | 1 | ||||
-rw-r--r-- | docs/features/table_creation.rst | 40 | ||||
-rwxr-xr-x | examples/table_creation.py | 77 | ||||
-rwxr-xr-x | examples/table_display.py | 204 | ||||
-rw-r--r-- | tests/test_argparse_custom.py | 10 | ||||
-rwxr-xr-x | tests/test_cmd2.py | 6 | ||||
-rw-r--r-- | tests/test_table_creator.py | 411 | ||||
-rw-r--r-- | tests/test_utils.py | 45 |
17 files changed, 1500 insertions, 262 deletions
diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 707b36ba..f61f5fd8 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -13,9 +13,8 @@ import shutil from collections import deque from typing import Dict, List, Optional, Union -from . import ansi -from . import cmd2 -from .argparse_custom import ATTR_CHOICES_CALLABLE, INFINITY, generate_range_error +from . import ansi, cmd2, constants +from .argparse_custom import ATTR_CHOICES_CALLABLE, generate_range_error from .argparse_custom import ATTR_SUPPRESS_TAB_HINT, ATTR_DESCRIPTIVE_COMPLETION_HEADER, ATTR_NARGS_RANGE from .argparse_custom import ChoicesCallable, CompletionItem from .utils import basic_complete, CompletionError @@ -85,10 +84,10 @@ class _ArgumentState: self.max = 1 elif self.action.nargs == argparse.ZERO_OR_MORE or self.action.nargs == argparse.REMAINDER: self.min = 0 - self.max = INFINITY + self.max = constants.INFINITY elif self.action.nargs == argparse.ONE_OR_MORE: self.min = 1 - self.max = INFINITY + self.max = constants.INFINITY else: self.min = self.action.nargs self.max = self.action.nargs diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index c24d8d9a..5c3d6223 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -209,10 +209,7 @@ import sys from argparse import ZERO_OR_MORE, ONE_OR_MORE, ArgumentError, _ from typing import Callable, Optional, Tuple, Type, Union -from . import ansi - -# Used in nargs ranges to signify there is no maximum -INFINITY = float('inf') +from . import ansi, constants ############################################################################################################ # The following are names of custom argparse argument attributes added by cmd2 @@ -236,7 +233,7 @@ def generate_range_error(range_min: int, range_max: Union[int, float]) -> str: """Generate an error message when the the number of arguments provided is not within the expected range""" err_str = "expected " - if range_max == INFINITY: + if range_max == constants.INFINITY: err_str += "at least {} argument".format(range_min) if range_min != 1: @@ -407,11 +404,11 @@ def _add_argument_wrapper(self, *args, # Handle 1-item tuple by setting max to INFINITY if len(nargs) == 1: - nargs = (nargs[0], INFINITY) + nargs = (nargs[0], constants.INFINITY) # Validate nargs tuple if len(nargs) != 2 or not isinstance(nargs[0], int) or \ - not (isinstance(nargs[1], int) or nargs[1] == INFINITY): + not (isinstance(nargs[1], int) or nargs[1] == constants.INFINITY): raise ValueError('Ranged values for nargs must be a tuple of 1 or 2 integers') if nargs[0] >= nargs[1]: raise ValueError('Invalid nargs range. The first value must be less than the second') @@ -432,10 +429,10 @@ def _add_argument_wrapper(self, *args, nargs_range = None else: nargs_adjusted = argparse.ZERO_OR_MORE - if range_max == INFINITY: + if range_max == constants.INFINITY: # No range needed since (0, INFINITY) is just argparse.ZERO_OR_MORE nargs_range = None - elif range_min == 1 and range_max == INFINITY: + elif range_min == 1 and range_max == constants.INFINITY: nargs_adjusted = argparse.ONE_OR_MORE # No range needed since (1, INFINITY) is just argparse.ONE_OR_MORE @@ -487,7 +484,7 @@ def _get_nargs_pattern_wrapper(self, action) -> str: # Wrapper around ArgumentParser._get_nargs_pattern behavior to support nargs ranges nargs_range = getattr(action, ATTR_NARGS_RANGE, None) if nargs_range is not None: - if nargs_range[1] == INFINITY: + if nargs_range[1] == constants.INFINITY: range_max = '' else: range_max = nargs_range[1] @@ -712,7 +709,7 @@ class Cmd2HelpFormatter(argparse.RawTextHelpFormatter): nargs_range = getattr(action, ATTR_NARGS_RANGE, None) if nargs_range is not None: - if nargs_range[1] == INFINITY: + if nargs_range[1] == constants.INFINITY: range_str = '{}+'.format(nargs_range[0]) else: range_str = '{}..{}'.format(nargs_range[0], nargs_range[1]) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 34f53044..d14b4d99 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -1749,7 +1749,7 @@ class Cmd(cmd.Cmd): :param line: the line being parsed :return: the completed Statement :raises: Cmd2ShlexError if a shlex error occurs (e.g. No closing quotation) - EmptyStatement when the resulting Statement is blank + :raises: EmptyStatement when the resulting Statement is blank """ while True: try: @@ -1808,7 +1808,7 @@ class Cmd(cmd.Cmd): :param line: the line being parsed :return: parsed command line as a Statement :raises: Cmd2ShlexError if a shlex error occurs (e.g. No closing quotation) - EmptyStatement when the resulting Statement is blank + :raises: EmptyStatement when the resulting Statement is blank """ used_macros = [] orig_line = None diff --git a/cmd2/constants.py b/cmd2/constants.py index d7e52cc9..81d1a29b 100644 --- a/cmd2/constants.py +++ b/cmd2/constants.py @@ -5,6 +5,7 @@ # Unless documented in https://cmd2.readthedocs.io/en/latest/api/index.html # nothing here should be considered part of the public API of this module +INFINITY = float('inf') # Used for command parsing, output redirection, tab completion and word # breaks. Do not change. diff --git a/cmd2/table_creator.py b/cmd2/table_creator.py new file mode 100644 index 00000000..70b7c1eb --- /dev/null +++ b/cmd2/table_creator.py @@ -0,0 +1,805 @@ +# coding=utf-8 +""" +cmd2 table creation API +This API is built upon two core classes: Column and TableCreator +The general use case is to inherit from TableCreator to create a table class with custom formatting options. +There are already implemented and ready-to-use examples of this below TableCreator's code. +""" +import functools +import io +from collections import deque +from enum import Enum +from typing import Any, Deque, Optional, Sequence, Tuple, Union + +from wcwidth import wcwidth + +from . import ansi, constants, utils + +# Constants +EMPTY = '' +SPACE = ' ' + + +class HorizontalAlignment(Enum): + """Horizontal alignment of text in a cell""" + LEFT = 1 + CENTER = 2 + RIGHT = 3 + + +class VerticalAlignment(Enum): + """Vertical alignment of text in a cell""" + TOP = 1 + MIDDLE = 2 + BOTTOM = 3 + + +class Column: + """Table column configuration""" + def __init__(self, header: str, *, width: Optional[int] = None, + header_horiz_align: HorizontalAlignment = HorizontalAlignment.LEFT, + header_vert_align: VerticalAlignment = VerticalAlignment.BOTTOM, + data_horiz_align: HorizontalAlignment = HorizontalAlignment.LEFT, + data_vert_align: VerticalAlignment = VerticalAlignment.TOP, + max_data_lines: Union[int, float] = constants.INFINITY) -> None: + """ + Column initializer + + :param header: label for column header + :param width: display width of column. This does not account for any borders or padding which + may be added (e.g pre_line, inter_cell, and post_line). Header and data text wrap within + this width using word-based wrapping (defaults to width of header or 1 if header is blank) + :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 data_horiz_align: horizontal alignment of data cells (defaults to left) + :param data_vert_align: vertical alignment of data cells (defaults to top) + :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 + :raises: ValueError if max_data_lines is less than 1 + """ + self.header = header + + if width is None: + # Use the width of the widest line in the header or 1 if the header has no width + line_widths = [ansi.style_aware_wcswidth(line) for line in self.header.splitlines()] + line_widths.append(1) + self.width = max(line_widths) + elif width < 1: + raise ValueError("Column width cannot be less than 1") + else: + self.width = width + + self.header_horiz_align = header_horiz_align + self.header_vert_align = header_vert_align + self.data_horiz_align = data_horiz_align + self.data_vert_align = data_vert_align + + if max_data_lines < 1: + raise ValueError("Max data lines cannot be less than 1") + + self.max_data_lines = max_data_lines + + +class TableCreator: + """ + Base table creation class. This class handles ANSI style sequences and characters with display widths greater than 1 + when performing width calculations. It was designed with the ability to build tables one row at a time. This helps + when you have large data sets that you don't want to hold in memory or when you receive portions of the data set + incrementally. + + TableCreator has one public method: generate_row() + + This function and the Column class provide all features needed to build tables with headers, borders, colors, + horizontal and vertical alignment, and wrapped text. However, it's generally easier to inherit from this class and + implement a more granular API rather than use TableCreator directly. There are ready-to-use examples of this + defined after this class. + """ + def __init__(self, cols: Sequence[Column], *, tab_width: int = 4) -> None: + """ + TableCreator initializer + + :param cols: column definitions for this table + :param tab_width: all tabs will be replaced with this many spaces. If a row's fill_char is a tab, + then it will be converted to one space. + """ + self.cols = cols + self.tab_width = tab_width + + @staticmethod + def _wrap_long_word(word: str, max_width: int, max_lines: Union[int, float], is_last_word: bool) -> Tuple[str, int, int]: + """ + Used by _wrap_text() to wrap a long word over multiple lines + + :param word: word being wrapped + :param max_width: maximum display width of a line + :param max_lines: maximum lines to wrap before ending the last line displayed with an ellipsis + :param is_last_word: True if this is the last word of the total text being wrapped + :return: Tuple(wrapped text, lines used, display width of last line) + """ + styles = utils.get_styles_in_text(word) + wrapped_buf = io.StringIO() + + # How many lines we've used + total_lines = 1 + + # Display width of the current line we are building + cur_line_width = 0 + + char_index = 0 + while char_index < len(word): + # We've reached the last line. Let truncate_line do the rest. + if total_lines == max_lines: + # If this isn't the last word, but it's gonna fill the final line, then force truncate_line + # to place an ellipsis at the end of it by making the word too wide. + remaining_word = word[char_index:] + if not is_last_word and ansi.style_aware_wcswidth(remaining_word) == max_width: + remaining_word += "EXTRA" + + truncated_line = utils.truncate_line(remaining_word, max_width) + cur_line_width = ansi.style_aware_wcswidth(truncated_line) + wrapped_buf.write(truncated_line) + break + + # Check if we're at a style sequence. These don't count toward display width. + if char_index in styles: + wrapped_buf.write(styles[char_index]) + char_index += len(styles[char_index]) + continue + + cur_char = word[char_index] + cur_char_width = wcwidth(cur_char) + + if cur_char_width > max_width: + # We have a case where the character is wider than max_width. This can happen if max_width + # is 1 and the text contains wide characters (e.g. East Asian). Replace it with an ellipsis. + cur_char = constants.HORIZONTAL_ELLIPSIS + cur_char_width = wcwidth(cur_char) + + if cur_line_width + cur_char_width > max_width: + # Adding this char will exceed the max_width. Start a new line. + wrapped_buf.write('\n') + total_lines += 1 + cur_line_width = 0 + continue + + # Add this character and move to the next one + cur_line_width += cur_char_width + wrapped_buf.write(cur_char) + char_index += 1 + + return wrapped_buf.getvalue(), total_lines, cur_line_width + + @staticmethod + def _wrap_text(text: str, max_width: int, max_lines: Union[int, float]) -> str: + """ + Wrap text into lines with a display width no longer than max_width. This function breaks words on whitespace + boundaries. If a word is longer than the space remaining on a line, then it will start on a new line. + ANSI escape sequences do not count toward the width of a line. + + :param text: text to be wrapped + :param max_width: maximum display width of a line + :param max_lines: maximum lines to wrap before ending the last line displayed with an ellipsis + :return: wrapped text + """ + def add_word(word_to_add: str, is_last_word: bool): + """ + Called from loop to add a word to the wrapped text + + :param word_to_add: the word being added + :param is_last_word: True if this is the last word of the total text being wrapped + """ + nonlocal cur_line_width + nonlocal total_lines + + # No more space to add word + if total_lines == max_lines and cur_line_width == max_width: + return + + word_width = ansi.style_aware_wcswidth(word_to_add) + + # If the word is wider than max width of a line, attempt to start it on its own line and wrap it + if word_width > max_width: + room_to_add = True + + if cur_line_width > 0: + # The current line already has text, check if there is room to create a new line + if total_lines < max_lines: + wrapped_buf.write('\n') + total_lines += 1 + else: + # We will truncate this word on the remaining line + room_to_add = False + + if room_to_add: + wrapped_word, lines_used, cur_line_width = TableCreator._wrap_long_word(word_to_add, + max_width, + max_lines - total_lines + 1, + is_last_word) + # Write the word to the buffer + wrapped_buf.write(wrapped_word) + total_lines += lines_used - 1 + return + + # We aren't going to wrap the word across multiple lines + remaining_width = max_width - cur_line_width + + # Check if we need to start a new line + if word_width > remaining_width and total_lines < max_lines: + # Save the last character in wrapped_buf, which can't be empty at this point. + seek_pos = wrapped_buf.tell() - 1 + wrapped_buf.seek(seek_pos) + last_char = wrapped_buf.read() + + wrapped_buf.write('\n') + total_lines += 1 + cur_line_width = 0 + remaining_width = max_width + + # Only when a space is following a space do we want to start the next line with it. + if word_to_add == SPACE and last_char != SPACE: + return + + # Check if we've hit the last line we're allowed to create + if total_lines == max_lines: + # If this word won't fit, truncate it + if word_width > remaining_width: + word_to_add = utils.truncate_line(word_to_add, remaining_width) + word_width = remaining_width + + # If this isn't the last word, but it's gonna fill the final line, then force truncate_line + # to place an ellipsis at the end of it by making the word too wide. + elif not is_last_word and word_width == remaining_width: + word_to_add = utils.truncate_line(word_to_add + "EXTRA", remaining_width) + + cur_line_width += word_width + wrapped_buf.write(word_to_add) + + ############################################################################################################ + # _wrap_text() main code + ############################################################################################################ + # Buffer of the wrapped text + wrapped_buf = io.StringIO() + + # How many lines we've used + total_lines = 0 + + # Respect the existing line breaks + data_str_lines = text.splitlines() + for data_line_index, data_line in enumerate(data_str_lines): + total_lines += 1 + + if data_line_index > 0: + wrapped_buf.write('\n') + + # Locate the styles in this line + styles = utils.get_styles_in_text(data_line) + + # Display width of the current line we are building + cur_line_width = 0 + + # Current word being built + cur_word_buf = io.StringIO() + + char_index = 0 + while char_index < len(data_line): + if total_lines == max_lines and cur_line_width == max_width: + break + + # Check if we're at a style sequence. These don't count toward display width. + if char_index in styles: + cur_word_buf.write(styles[char_index]) + char_index += len(styles[char_index]) + continue + + cur_char = data_line[char_index] + if cur_char == SPACE: + # If we've reached the end of a word, then add the word to the wrapped text + if cur_word_buf.tell() > 0: + # is_last_word is False since there is a space after the word + add_word(cur_word_buf.getvalue(), is_last_word=False) + cur_word_buf = io.StringIO() + + # Add the space to the wrapped text + last_word = data_line_index == len(data_str_lines) - 1 and char_index == len(data_line) - 1 + add_word(cur_char, last_word) + else: + # Add this character to the word buffer + cur_word_buf.write(cur_char) + + char_index += 1 + + # Add the final word of this line if it's been started + if cur_word_buf.tell() > 0: + last_word = data_line_index == len(data_str_lines) - 1 and char_index == len(data_line) + add_word(cur_word_buf.getvalue(), last_word) + + # Stop line loop if we've written to max_lines + if total_lines == max_lines: + # If this isn't the last data line and there is space left on the final wrapped line, then add an ellipsis + if data_line_index < len(data_str_lines) - 1 and cur_line_width < max_width: + wrapped_buf.write(constants.HORIZONTAL_ELLIPSIS) + break + + return wrapped_buf.getvalue() + + def _generate_cell_lines(self, cell_data: Any, is_header: bool, col: Column, fill_char: str) -> Tuple[Deque[str], int]: + """ + Generate the lines of a table cell + + :param cell_data: data to be included in cell + :param is_header: True if writing a header cell, otherwise writing a data cell + :param col: Column definition for this cell + :param fill_char: character that fills remaining space in a cell. If your text has a background color, + then give fill_char the same background color. (Cannot be a line breaking character) + :return: Tuple of cell lines deque and the display width of the cell + """ + # Convert data to string and replace tabs with spaces + data_str = str(cell_data).replace('\t', SPACE * self.tab_width) + + # Wrap text in this cell + max_lines = constants.INFINITY if is_header else col.max_data_lines + wrapped_text = self._wrap_text(data_str, col.width, max_lines) + + # Align the text horizontally + horiz_alignment = col.header_horiz_align if is_header else col.data_horiz_align + if horiz_alignment == HorizontalAlignment.LEFT: + text_alignment = utils.TextAlignment.LEFT + elif horiz_alignment == HorizontalAlignment.CENTER: + text_alignment = utils.TextAlignment.CENTER + else: + text_alignment = utils.TextAlignment.RIGHT + + aligned_text = utils.align_text(wrapped_text, fill_char=fill_char, width=col.width, alignment=text_alignment) + + lines = deque(aligned_text.splitlines()) + cell_width = max([ansi.style_aware_wcswidth(line) for line in lines]) + return lines, cell_width + + def generate_row(self, *, row_data: Optional[Sequence[Any]] = None, fill_char: str = SPACE, + pre_line: str = EMPTY, inter_cell: str = (2 * SPACE), post_line: str = EMPTY) -> str: + """ + Generate a header or data table row + + :param row_data: If this is None then a header row is generated. Otherwise data should have an entry for each + column in the row. (Defaults to None) + :param fill_char: character that fills remaining space in a cell. Defaults to space. If this is a tab, then it will + be converted to one space. (Cannot be a line breaking character) + :param pre_line: string to print before each line of a row. This can be used for a left row border and + padding before the first cell's text. (Defaults to blank) + :param inter_cell: string to print where two cells meet. This can be used for a border between cells and padding + between it and the 2 cells' text. (Defaults to 2 spaces) + :param post_line: string to print after each line of a row. This can be used for padding after the last cell's text + and a right row border. (Defaults to blank) + :return: row string + :raises: ValueError if data isn't the same length as self.cols + :raises: TypeError if fill_char is more than one character (not including ANSI style sequences) + :raises: ValueError if fill_char, pre_line, inter_cell, or post_line contains an unprintable + character like a newline + """ + class Cell: + """Inner class which represents a table cell""" + def __init__(self) -> None: + # Data in this cell split into individual lines + self.lines = [] + + # Display width of this cell + self.width = 0 + + if row_data is None: + row_data = [col.header for col in self.cols] + is_header = True + else: + if len(row_data) != len(self.cols): + raise ValueError("Length of row_data must match length of cols") + is_header = False + + # Replace tabs (tabs in data strings will be handled in _generate_cell_lines()) + fill_char = fill_char.replace('\t', SPACE) + pre_line = pre_line.replace('\t', SPACE * self.tab_width) + inter_cell = inter_cell.replace('\t', SPACE * self.tab_width) + post_line = post_line.replace('\t', SPACE * self.tab_width) + + # Validate fill_char character count + if len(ansi.strip_style(fill_char)) != 1: + raise TypeError("Fill character must be exactly one character long") + + # Look for unprintable characters + validation_dict = {'fill_char': fill_char, 'pre_line': pre_line, + 'inter_cell': inter_cell, 'post_line': post_line} + for key, val in validation_dict.items(): + if ansi.style_aware_wcswidth(val) == -1: + raise (ValueError("{} contains an unprintable character".format(key))) + + # Number of lines this row uses + total_lines = 0 + + # Generate the cells for this row + cells = list() + + for col_index, col in enumerate(self.cols): + cell = Cell() + cell.lines, cell.width = self._generate_cell_lines(row_data[col_index], is_header, col, fill_char) + cells.append(cell) + total_lines = max(len(cell.lines), total_lines) + + row_buf = io.StringIO() + + # Vertically align each cell + for cell_index, cell in enumerate(cells): + col = self.cols[cell_index] + vert_align = col.header_vert_align if is_header else col.data_vert_align + + # Check if this cell need vertical filler + line_diff = total_lines - len(cell.lines) + if line_diff == 0: + continue + + # Add vertical filler lines + padding_line = utils.align_left(EMPTY, fill_char=fill_char, width=cell.width) + if vert_align == VerticalAlignment.TOP: + to_top = 0 + to_bottom = line_diff + elif vert_align == VerticalAlignment.MIDDLE: + to_top = line_diff // 2 + to_bottom = line_diff - to_top + else: + to_top = line_diff + to_bottom = 0 + + for i in range(to_top): + cell.lines.appendleft(padding_line) + for i in range(to_bottom): + cell.lines.append(padding_line) + + # Build this row one line at a time + for line_index in range(total_lines): + for cell_index, cell in enumerate(cells): + if cell_index == 0: + row_buf.write(pre_line) + + row_buf.write(cell.lines[line_index]) + + if cell_index < len(self.cols) - 1: + row_buf.write(inter_cell) + if cell_index == len(self.cols) - 1: + row_buf.write(post_line) + + # Add a newline if this is not the last row + row_buf.write('\n') + + return row_buf.getvalue() + + +############################################################################################################ +# The following are implementations of TableCreator which demonstrate how to make various types +# of tables. They can be used as-is or serve as inspiration for other custom table classes. +############################################################################################################ +class SimpleTable(TableCreator): + """ + Implementation of TableCreator which generates a borderless table with an optional divider row after the header. + This class can be used to create the whole table at once or one row at a time. + """ + def __init__(self, cols: Sequence[Column], *, tab_width: int = 4, divider_char: Optional[str] = '-') -> None: + """ + SimpleTable initializer + + :param cols: column definitions for this table + :param tab_width: all tabs will be replaced with this many spaces. If a row's fill_char is a tab, + then it will be converted to one space. + :param divider_char: optional character used to build the header divider row. If provided, its value must meet the + same requirements as fill_char in TableCreator.generate_row() or exceptions will be raised. + Set this to None if you don't want a divider row. (Defaults to dash) + """ + super().__init__(cols, tab_width=tab_width) + self.divider_char = divider_char + self.empty_data = [EMPTY for _ in self.cols] + + def generate_header(self) -> str: + """ + Generate header with an optional divider row + """ + header_buf = io.StringIO() + + # Create the header labels + if self.divider_char is None: + inter_cell = 2 * SPACE + else: + inter_cell = SPACE * ansi.style_aware_wcswidth(2 * self.divider_char) + header = self.generate_row(inter_cell=inter_cell) + header_buf.write(header) + + # Create the divider. Use empty strings for the row_data. + if self.divider_char is not None: + divider = self.generate_row(row_data=self.empty_data, fill_char=self.divider_char, + inter_cell=(2 * self.divider_char)) + header_buf.write(divider) + return header_buf.getvalue() + + def generate_data_row(self, row_data: Sequence[Any]) -> str: + """ + Generate a data row + + :param row_data: data with an entry for each column in the row + :return: data row string + """ + if self.divider_char is None: + inter_cell = 2 * SPACE + else: + inter_cell = SPACE * ansi.style_aware_wcswidth(2 * self.divider_char) + return self.generate_row(row_data=row_data, inter_cell=inter_cell) + + def generate_table(self, table_data: Sequence[Sequence[Any]], *, + include_header: bool = True, row_spacing: int = 1) -> str: + """ + Generate a table from a data set + + :param table_data: Data with an entry for each data row of the table. Each entry should have data for + each column in the row. + :param include_header: If True, then a header will be included at top of table. (Defaults to True) + :param row_spacing: A number 0 or greater specifying how many blank lines to place between each row (Defaults to 1) + :raises: ValueError if row_spacing is less than 0 + """ + if row_spacing < 0: + raise ValueError("Row spacing cannot be less than 0") + + table_buf = io.StringIO() + + if include_header: + header = self.generate_header() + table_buf.write(header) + + for index, row_data in enumerate(table_data): + if index > 0 and row_spacing > 0: + table_buf.write(row_spacing * '\n') + + row = self.generate_data_row(row_data) + table_buf.write(row) + + return table_buf.getvalue() + + +class BorderedTable(TableCreator): + """ + Implementation of TableCreator which generates a table with borders around the table and between rows. Borders + between columns can also be toggled. This class can be used to create the whole table at once or one row at a time. + """ + def __init__(self, cols: Sequence[Column], *, tab_width: int = 4, + column_borders: bool = True, padding: int = 1) -> None: + """ + BorderedTable initializer + + :param cols: column definitions for this table + :param tab_width: all tabs will be replaced with this many spaces. If a row's fill_char is a tab, + then it will be converted to one space. + :param column_borders: if True, borders between columns will be included. This gives the table a grid-like + appearance. Turning off column borders results in a unified appearance between + a row's cells. (Defaults to True) + :param padding: number of spaces between text and left/right borders of cell + :raises: ValueError if padding is less than 0 + """ + super().__init__(cols, tab_width=tab_width) + self.empty_data = [EMPTY for _ in self.cols] + self.column_borders = column_borders + + if padding < 0: + raise ValueError("Padding cannot be less than 0") + self.padding = padding + + def generate_table_top_border(self): + """Generate a border which appears at the top of the header and data section""" + pre_line = '╔' + self.padding * '═' + + inter_cell = self.padding * '═' + if self.column_borders: + inter_cell += "╤" + inter_cell += self.padding * '═' + + post_line = self.padding * '═' + '╗' + + return self.generate_row(row_data=self.empty_data, fill_char='═', pre_line=pre_line, + inter_cell=inter_cell, post_line=post_line) + + def generate_header_bottom_border(self): + """Generate a border which appears at the bottom of the header""" + pre_line = '╠' + self.padding * '═' + + inter_cell = self.padding * '═' + if self.column_borders: + inter_cell += '╪' + inter_cell += self.padding * '═' + + post_line = self.padding * '═' + '╣' + + return self.generate_row(row_data=self.empty_data, fill_char='═', pre_line=pre_line, + inter_cell=inter_cell, post_line=post_line) + + def generate_row_bottom_border(self): + """Generate a border which appears at the bottom of rows""" + pre_line = '╟' + self.padding * '─' + + inter_cell = self.padding * '─' + if self.column_borders: + inter_cell += '┼' + inter_cell += self.padding * '─' + + post_line = self.padding * '─' + '╢' + + return self.generate_row(row_data=self.empty_data, fill_char='─', pre_line=pre_line, + inter_cell=inter_cell, post_line=post_line) + + def generate_table_bottom_border(self): + """Generate a border which appears at the bottom of the table""" + pre_line = '╚' + self.padding * '═' + + inter_cell = self.padding * '═' + if self.column_borders: + inter_cell += '╧' + inter_cell += self.padding * '═' + + post_line = self.padding * '═' + '╝' + + return self.generate_row(row_data=self.empty_data, fill_char='═', pre_line=pre_line, + inter_cell=inter_cell, post_line=post_line) + + def generate_header(self) -> str: + """ + Generate header + :return: header string + """ + pre_line = '║' + self.padding * SPACE + + inter_cell = self.padding * SPACE + if self.column_borders: + inter_cell += '│' + inter_cell += self.padding * SPACE + + post_line = self.padding * SPACE + '║' + + # Create the bordered header + header_buf = io.StringIO() + header_buf.write(self.generate_table_top_border()) + header_buf.write(self.generate_row(pre_line=pre_line, inter_cell=inter_cell, post_line=post_line)) + header_buf.write(self.generate_header_bottom_border()) + + return header_buf.getvalue() + + def generate_data_row(self, row_data: Sequence[Any]) -> str: + """ + Generate a data row + + :param row_data: data with an entry for each column in the row + :return: data row string + """ + pre_line = '║' + self.padding * SPACE + + inter_cell = self.padding * SPACE + if self.column_borders: + inter_cell += '│' + inter_cell += self.padding * SPACE + + post_line = self.padding * SPACE + '║' + + return self.generate_row(row_data=row_data, pre_line=pre_line, inter_cell=inter_cell, post_line=post_line) + + def generate_table(self, table_data: Sequence[Sequence[Any]], *, include_header: bool = True) -> str: + """ + Generate a table from a data set + + :param table_data: Data with an entry for each data row of the table. Each entry should have data for + each column in the row. + :param include_header: If True, then a header will be included at top of table. (Defaults to True) + """ + table_buf = io.StringIO() + + if include_header: + header = self.generate_header() + table_buf.write(header) + else: + top_border = self.generate_table_top_border() + table_buf.write(top_border) + + for index, row_data in enumerate(table_data): + if index > 0: + row_bottom_border = self.generate_row_bottom_border() + table_buf.write(row_bottom_border) + + row = self.generate_data_row(row_data) + table_buf.write(row) + + table_buf.write(self.generate_table_bottom_border()) + return table_buf.getvalue() + + +class AlternatingTable(BorderedTable): + """ + Implementation of BorderedTable which uses background colors to distinguish between rows instead of row border lines. + This class can be used to create the whole table at once or one row at a time. + """ + def __init__(self, cols: Sequence[Column], *, tab_width: int = 4, column_borders: bool = True, padding: int = 1, + bg_odd: Optional[ansi.bg] = None, bg_even: Optional[ansi.bg] = ansi.bg.bright_black) -> None: + """ + AlternatingTable initializer + + :param cols: column definitions for this table + :param tab_width: all tabs will be replaced with this many spaces. If a row's fill_char is a tab, + then it will be converted to one space. + :param column_borders: if True, borders between columns will be included. This gives the table a grid-like + appearance. Turning off column borders results in a unified appearance between + a row's cells. (Defaults to True) + :param padding: number of spaces between text and left/right borders of cell + :param bg_odd: optional background color for odd numbered rows (defaults to None) + :param bg_even: optional background color for even numbered rows (defaults to gray) + :raises: ValueError if padding is less than 0 + """ + super().__init__(cols, tab_width=tab_width, column_borders=column_borders, padding=padding) + self.row_num = 1 + self.bg_odd = None if bg_odd is None else functools.partial(ansi.style, bg=bg_odd) + self.bg_even = None if bg_even is None else functools.partial(ansi.style, bg=bg_even) + + def _apply_bg_color(self, data: Any) -> str: + """ + Convert data to text and apply background color to it based on what row is being generated + + :param data: data being colored + :return: converted data + """ + if self.row_num % 2 == 0 and self.bg_even is not None: + return self.bg_even(data) + elif self.row_num % 2 != 0 and self.bg_odd is not None: + return self.bg_odd(data) + else: + return str(data) + + def generate_data_row(self, row_data: Sequence[Any]) -> str: + """ + Generate a data row + + :param row_data: data with an entry for each column in the row + :return: data row string + """ + pre_line = '║' + self.padding * SPACE + + inter_cell = self.padding * SPACE + if self.column_borders: + inter_cell += '│' + inter_cell += self.padding * SPACE + + post_line = self.padding * SPACE + '║' + + fill_char = self._apply_bg_color(SPACE) + pre_line = self._apply_bg_color(pre_line) + inter_cell = self._apply_bg_color(inter_cell) + post_line = self._apply_bg_color(post_line) + + # Apply appropriate background color to data, but don't change the original + to_display = [self._apply_bg_color(col) for col in row_data] + + row = self.generate_row(row_data=to_display, fill_char=fill_char, pre_line=pre_line, + inter_cell=inter_cell, post_line=post_line) + self.row_num += 1 + return row + + def generate_table(self, table_data: Sequence[Sequence[Any]], *, include_header: bool = True) -> str: + """ + Generate a table from a data set + + :param table_data: Data with an entry for each data row of the table. Each entry should have data for + each column in the row. + :param include_header: If True, then a header will be included at top of table. (Defaults to True) + """ + table_buf = io.StringIO() + + if include_header: + header = self.generate_header() + table_buf.write(header) + else: + top_border = self.generate_table_top_border() + table_buf.write(top_border) + + for row_data in table_data: + row = self.generate_data_row(row_data) + table_buf.write(row) + + table_buf.write(self.generate_table_bottom_border()) + return table_buf.getvalue() diff --git a/cmd2/utils.py b/cmd2/utils.py index cd0c7d54..3fd9e3ac 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -704,6 +704,7 @@ def basic_complete(text: str, line: str, begidx: int, endidx: int, match_against class TextAlignment(Enum): + """Horizontal text alignment""" LEFT = 1 CENTER = 2 RIGHT = 3 @@ -723,13 +724,13 @@ def align_text(text: str, alignment: TextAlignment, *, fill_char: str = ' ', :param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character) :param width: display width of the aligned text. Defaults to width of the terminal. :param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will - be converted to a space. + be converted to one space. :param truncate: if True, then each line will be shortened to fit within the display width. The truncated portions are replaced by a '…' character. Defaults to False. :return: aligned text :raises: TypeError if fill_char is more than one character (not including ANSI style sequences) - ValueError if text or fill_char contains an unprintable character - ValueError if width is less than 1 + :raises: ValueError if text or fill_char contains an unprintable character + :raises: ValueError if width is less than 1 """ import io import shutil @@ -744,8 +745,7 @@ def align_text(text: str, alignment: TextAlignment, *, fill_char: str = ' ', # Handle tabs text = text.replace('\t', ' ' * tab_width) - if fill_char == '\t': - fill_char = ' ' + fill_char = fill_char.replace('\t', ' ') if len(ansi.strip_style(fill_char)) != 1: raise TypeError("Fill character must be exactly one character long") @@ -761,6 +761,28 @@ def align_text(text: str, alignment: TextAlignment, *, fill_char: str = ' ', text_buf = io.StringIO() + # ANSI style sequences that may affect future lines will be cancelled by the fill_char's style. + # To avoid this, we save the state of a line's style so we can restore it when beginning the next line. + # This also allows the lines to be used independently and still have their style. TableCreator does this. + aggregate_styles = '' + + # Save the ANSI style sequences in fill_char + fill_char_styles = get_styles_in_text(fill_char) + + # Create a space with the same style as fill_char for cases in which + # fill_char does not divide evenly into the gap. + styled_space = '' + char_index = 0 + while char_index < len(fill_char): + if char_index in fill_char_styles: + # Preserve this style in styled_space + styled_space += fill_char_styles[char_index] + char_index += len(fill_char_styles[char_index]) + else: + # We've reached the visible fill_char. Replace it with a space. + styled_space += ' ' + char_index += 1 + for index, line in enumerate(lines): if index > 0: text_buf.write('\n') @@ -772,13 +794,16 @@ def align_text(text: str, alignment: TextAlignment, *, fill_char: str = ' ', if line_width == -1: raise(ValueError("Text to align contains an unprintable character")) - elif line_width >= width: - # No need to add fill characters - text_buf.write(line) - continue + # Get the styles in this line + line_styles = get_styles_in_text(line) # Calculate how wide each side of filling needs to be - total_fill_width = width - line_width + if line_width >= width: + # Don't return here even though the line needs no fill chars. + # There may be styles sequences to restore. + total_fill_width = 0 + else: + total_fill_width = width - line_width if alignment == TextAlignment.LEFT: left_fill_width = 0 @@ -795,11 +820,25 @@ def align_text(text: str, alignment: TextAlignment, *, fill_char: str = ' ', right_fill = (right_fill_width // fill_char_width) * fill_char # In cases where the fill character display width didn't divide evenly into - # the gaps being filled, pad the remainder with spaces. - left_fill += ' ' * (left_fill_width - ansi.style_aware_wcswidth(left_fill)) - right_fill += ' ' * (right_fill_width - ansi.style_aware_wcswidth(right_fill)) + # the gap being filled, pad the remainder with styled_space. + left_fill += styled_space * (left_fill_width - ansi.style_aware_wcswidth(left_fill)) + right_fill += styled_space * (right_fill_width - ansi.style_aware_wcswidth(right_fill)) + + # Don't allow styles in fill_char and text to affect one another + if fill_char_styles or aggregate_styles or line_styles: + if left_fill: + left_fill = ansi.RESET_ALL + left_fill + left_fill += ansi.RESET_ALL + + if right_fill: + right_fill = ansi.RESET_ALL + right_fill + right_fill += ansi.RESET_ALL + + # Write the line and restore any styles from previous lines + text_buf.write(left_fill + aggregate_styles + line + right_fill) - text_buf.write(left_fill + line + right_fill) + # Update the aggregate with styles in this line + aggregate_styles += ''.join(line_styles.values()) return text_buf.getvalue() @@ -815,13 +854,13 @@ def align_left(text: str, *, fill_char: str = ' ', width: Optional[int] = None, :param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character) :param width: display width of the aligned text. Defaults to width of the terminal. :param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will - be converted to a space. + be converted to one space. :param truncate: if True, then text will be shortened to fit within the display width. The truncated portion is replaced by a '…' character. Defaults to False. :return: left-aligned text :raises: TypeError if fill_char is more than one character (not including ANSI style sequences) - ValueError if text or fill_char contains an unprintable character - ValueError if width is less than 1 + :raises: ValueError if text or fill_char contains an unprintable character + :raises: ValueError if width is less than 1 """ return align_text(text, TextAlignment.LEFT, fill_char=fill_char, width=width, tab_width=tab_width, truncate=truncate) @@ -838,13 +877,13 @@ def align_center(text: str, *, fill_char: str = ' ', width: Optional[int] = None :param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character) :param width: display width of the aligned text. Defaults to width of the terminal. :param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will - be converted to a space. + be converted to one space. :param truncate: if True, then text will be shortened to fit within the display width. The truncated portion is replaced by a '…' character. Defaults to False. :return: centered text :raises: TypeError if fill_char is more than one character (not including ANSI style sequences) - ValueError if text or fill_char contains an unprintable character - ValueError if width is less than 1 + :raises: ValueError if text or fill_char contains an unprintable character + :raises: ValueError if width is less than 1 """ return align_text(text, TextAlignment.CENTER, fill_char=fill_char, width=width, tab_width=tab_width, truncate=truncate) @@ -861,13 +900,13 @@ def align_right(text: str, *, fill_char: str = ' ', width: Optional[int] = None, :param fill_char: character that fills the alignment gap. Defaults to space. (Cannot be a line breaking character) :param width: display width of the aligned text. Defaults to width of the terminal. :param tab_width: any tabs in the text will be replaced with this many spaces. if fill_char is a tab, then it will - be converted to a space. + be converted to one space. :param truncate: if True, then text will be shortened to fit within the display width. The truncated portion is replaced by a '…' character. Defaults to False. :return: right-aligned text :raises: TypeError if fill_char is more than one character (not including ANSI style sequences) - ValueError if text or fill_char contains an unprintable character - ValueError if width is less than 1 + :raises: ValueError if text or fill_char contains an unprintable character + :raises: ValueError if width is less than 1 """ return align_text(text, TextAlignment.RIGHT, fill_char=fill_char, width=width, tab_width=tab_width, truncate=truncate) @@ -884,14 +923,15 @@ def truncate_line(line: str, max_width: int, *, tab_width: int = 4) -> str: This is done to prevent issues caused in cases like: truncate_string(fg.blue + hello + fg.reset, 3) In this case, "hello" would be truncated before fg.reset resets the color from blue. Appending the remaining style - sequences makes sure the style is in the same state had the entire string been printed. + sequences makes sure the style is in the same state had the entire string been printed. align_text() relies on this + behavior when preserving style over multiple lines. :param line: text to truncate :param max_width: the maximum display width the resulting string is allowed to have :param tab_width: any tabs in the text will be replaced with this many spaces :return: line that has a display width less than or equal to width - :raises: ValueError if text contains an unprintable character like a new line - ValueError if max_width is less than 1 + :raises: ValueError if text contains an unprintable character like a newline + :raises: ValueError if max_width is less than 1 """ import io from . import ansi diff --git a/docs/api/index.rst b/docs/api/index.rst index aa3371b9..7b66a684 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -28,6 +28,7 @@ This documentation is for ``cmd2`` version |version|. history plugin py_bridge + table_creator constants **Modules** @@ -50,4 +51,5 @@ This documentation is for ``cmd2`` version |version|. - :ref:`api/plugin:cmd2.plugin` - data classes for hook methods - :ref:`api/py_bridge:cmd2.py_bridge` - classes for bridging calls from the embedded python environment to the host app +- :ref:`api/table_creator:cmd2.table_creator` - table creation module - :ref:`api/constants:cmd2.constants` - just like it says on the tin diff --git a/docs/api/table_creator.rst b/docs/api/table_creator.rst new file mode 100644 index 00000000..00dd70ba --- /dev/null +++ b/docs/api/table_creator.rst @@ -0,0 +1,35 @@ +cmd2.table_creator +================== + +.. autoclass:: cmd2.table_creator.HorizontalAlignment + :members: + :undoc-members: + +.. autoclass:: cmd2.table_creator.VerticalAlignment + :members: + :undoc-members: + +.. autoclass:: cmd2.table_creator.Column + :members: + + .. automethod:: __init__ + +.. autoclass:: cmd2.table_creator.TableCreator + :members: + + .. automethod:: __init__ + +.. autoclass:: cmd2.table_creator.SimpleTable + :members: + + .. automethod:: __init__ + +.. autoclass:: cmd2.table_creator.BorderedTable + :members: + + .. automethod:: __init__ + +.. autoclass:: cmd2.table_creator.AlternatingTable + :members: + + .. automethod:: __init__ diff --git a/docs/api/utils.rst b/docs/api/utils.rst index 8121fea8..d9166401 100644 --- a/docs/api/utils.rst +++ b/docs/api/utils.rst @@ -50,6 +50,7 @@ Text Alignment .. autoclass:: cmd2.utils.TextAlignment :members: + :undoc-members: .. autofunction:: cmd2.utils.align_text diff --git a/docs/features/index.rst b/docs/features/index.rst index dc64badd..efc0fe67 100644 --- a/docs/features/index.rst +++ b/docs/features/index.rst @@ -27,4 +27,5 @@ Features settings shortcuts_aliases_macros startup_commands + table_creation transcripts diff --git a/docs/features/table_creation.rst b/docs/features/table_creation.rst new file mode 100644 index 00000000..08bd5150 --- /dev/null +++ b/docs/features/table_creation.rst @@ -0,0 +1,40 @@ +Table Creation +============== + +``cmd2`` provides a table creation class called +:attr:`cmd2.table_creator.TableCreator`. This class handles ANSI style +sequences and characters with display widths greater than 1 when performing +width calculations. It was designed with the ability to build tables one row at +a time. This helps when you have large data sets that you don't want to hold +in memory or when you receive portions of the data set incrementally. + +``TableCreator`` has one public method: +:attr:`cmd2.table_creator.TableCreator.generate_row()` + +This function and the :attr:`cmd2.table_creator.Column` +class provide all features needed to build tables with headers, borders, +colors, horizontal and vertical alignment, and wrapped text. However, it's +generally easier to inherit from this class and implement a more granular API +rather than use ``TableCreator`` directly. + +The following table classes build upon ``TableCreator`` and are provided in +the :ref:`api/table_creator:cmd2.table_creator` module. They can be used as is +or as examples for how to build your own table classes. + +:attr:`cmd2.table_creator.SimpleTable` - Implementation of TableCreator which +generates a borderless table with an optional divider row after the header. +This class can be used to create the whole table at once or one row at a time. + +:attr:`cmd2.table_creator.BorderedTable` - Implementation of TableCreator which +generates a table with borders around the table and between rows. Borders +between columns can also be toggled. This class can be used to create the whole +table at once or one row at a time. + +:attr:`cmd2.table_creator.AlternatingTable` - Implementation of BorderedTable +which uses background colors to distinguish between rows instead of row border +lines. This class can be used to create the whole table at once or one row at a +time. + +See the table_creation_ example to see these classes in use + +.. _table_creation: https://github.com/python-cmd2/cmd2/blob/master/examples/table_creation.py
\ No newline at end of file diff --git a/examples/table_creation.py b/examples/table_creation.py new file mode 100755 index 00000000..85bfc3f0 --- /dev/null +++ b/examples/table_creation.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python +# coding=utf-8 +"""Examples of using the cmd2 table creation API""" +import functools +import sys +from typing import Any, List + +from cmd2 import ansi +from cmd2.table_creator import AlternatingTable, BorderedTable, Column, HorizontalAlignment, SimpleTable + + +class DollarFormatter: + """Example class to show that any object type can be passed as data to TableCreator and converted to a string""" + def __init__(self, val: float) -> None: + self.val = val + + def __str__(self) -> str: + """Returns the value in dollar currency form (e.g. $100.22)""" + return "${:,.2f}".format(self.val) + + +# Text styles used in the data +bold_yellow = functools.partial(ansi.style, fg=ansi.fg.bright_yellow, bold=True) +blue = functools.partial(ansi.style, fg=ansi.fg.bright_blue) +green = functools.partial(ansi.style, fg=ansi.fg.green) + +# 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)) + +# 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)]) + + +def ansi_print(text): + """Wraps style_aware_write so style can be stripped if needed""" + ansi.style_aware_write(sys.stdout, text + '\n') + + +def main(): + # Default to terminal mode so redirecting to a file won't include the ANSI style sequences + ansi.allow_style = ansi.STYLE_TERMINAL + + st = SimpleTable(columns) + table = st.generate_table(data_list) + ansi_print(table) + + bt = BorderedTable(columns) + table = bt.generate_table(data_list) + ansi_print(table) + + at = AlternatingTable(columns) + table = at.generate_table(data_list) + ansi_print(table) + + +if __name__ == '__main__': + main() diff --git a/examples/table_display.py b/examples/table_display.py deleted file mode 100755 index 01143598..00000000 --- a/examples/table_display.py +++ /dev/null @@ -1,204 +0,0 @@ -#!/usr/bin/env python -# coding=utf-8 -"""A simple example demonstrating the following: - 1) How to display tabular data - 2) How to display output using a pager - -NOTE: IF the table does not entirely fit within the screen of your terminal, then it will be displayed using a pager. -You can use the arrow keys (left, right, up, and down) to scroll around the table as well as the PageUp/PageDown keys. -You can quit out of the pager by typing "q". You can also search for text within the pager using "/". - -WARNING: This example requires the tableformatter module: https://github.com/python-tableformatter/tableformatter -and either the colored or colorama module -- pip install tableformatter colorama -""" -from typing import Tuple - -import tableformatter as tf - -import cmd2 -from cmd2 import Cmd2ArgumentParser - -# Configure colors for when users chooses the "-c" flag to enable color in the table output -try: - from colored import bg - BACK_PRI = bg(4) - BACK_ALT = bg(22) -except ImportError: - try: - from colorama import Back - BACK_PRI = Back.LIGHTBLUE_EX - BACK_ALT = Back.LIGHTYELLOW_EX - except ImportError: - BACK_PRI = '' - BACK_ALT = '' - - -# Formatter functions -def no_dec(num: float) -> str: - """Format a floating point number with no decimal places.""" - return "{}".format(round(num)) - - -def two_dec(num: float) -> str: - """Format a floating point number with 2 decimal places.""" - return "{0:.2f}".format(num) - - -# Population data from Wikipedia: https://en.wikipedia.org/wiki/List_of_cities_proper_by_population - -# ############ Table data formatted as an iterable of iterable fields ############ -EXAMPLE_ITERABLE_DATA = [['Shanghai (上海)', 'Shanghai', 'China', 'Asia', 24183300, 6340.5], - ['Beijing (北京市)', 'Hebei', 'China', 'Asia', 20794000, 1749.57], - ['Karachi (کراچی)', 'Sindh', 'Pakistan', 'Asia', 14910352, 615.58], - ['Shenzen (深圳市)', 'Guangdong', 'China', 'Asia', 13723000, 1493.32], - ['Guangzho (广州市)', 'Guangdong', 'China', 'Asia', 13081000, 1347.81], - ['Mumbai (मुंबई)', 'Maharashtra', 'India', 'Asia', 12442373, 465.78], - ['Istanbul (İstanbuld)', 'Istanbul', 'Turkey', 'Eurasia', 12661000, 620.29], - ] - -# Calculate population density -for row in EXAMPLE_ITERABLE_DATA: - row.append(row[-2] / row[-1]) - - -# Column headers plus optional formatting info for each column -COLUMNS = [tf.Column('City', width=11, header_halign=tf.ColumnAlignment.AlignCenter), - tf.Column('Province', header_halign=tf.ColumnAlignment.AlignCenter), - 'Country', # NOTE: If you don't need any special effects, you can just pass a string - tf.Column('Continent', cell_halign=tf.ColumnAlignment.AlignCenter), - tf.Column('Population', cell_halign=tf.ColumnAlignment.AlignRight, formatter=tf.FormatCommas()), - tf.Column('Area (km²)', width=7, header_halign=tf.ColumnAlignment.AlignCenter, - cell_halign=tf.ColumnAlignment.AlignRight, formatter=two_dec), - tf.Column('Pop. Density (/km²)', width=12, header_halign=tf.ColumnAlignment.AlignCenter, - cell_halign=tf.ColumnAlignment.AlignRight, formatter=no_dec), - ] - - -# ######## Table data formatted as an iterable of python objects ######### - -class CityInfo: - """City information container""" - def __init__(self, city: str, province: str, country: str, continent: str, population: int, area: float): - self.city = city - self.province = province - self.country = country - self.continent = continent - self._population = population - self._area = area - - def get_population(self): - """Population of the city""" - return self._population - - def get_area(self): - """Area of city in km²""" - return self._area - - -def pop_density(data: CityInfo) -> str: - """Calculate the population density from the data entry""" - if not isinstance(data, CityInfo): - raise AttributeError("Argument to pop_density() must be an instance of CityInfo") - return no_dec(data.get_population() / data.get_area()) - - -# Convert the Iterable of Iterables data to an Iterable of non-iterable objects for demonstration purposes -EXAMPLE_OBJECT_DATA = [] -for city_data in EXAMPLE_ITERABLE_DATA: - # Pass all city data other than population density to construct CityInfo - EXAMPLE_OBJECT_DATA.append(CityInfo(*city_data[:-1])) - -# If table entries are python objects, all columns must be defined with the object attribute to query for each field -# - attributes can be fields or functions. If a function is provided, the formatter will automatically call -# the function to retrieve the value -OBJ_COLS = [tf.Column('City', attrib='city', header_halign=tf.ColumnAlignment.AlignCenter), - tf.Column('Province', attrib='province', header_halign=tf.ColumnAlignment.AlignCenter), - tf.Column('Country', attrib='country'), - tf.Column('Continent', attrib='continent', cell_halign=tf.ColumnAlignment.AlignCenter), - tf.Column('Population', attrib='get_population', cell_halign=tf.ColumnAlignment.AlignRight, - formatter=tf.FormatCommas()), - tf.Column('Area (km²)', attrib='get_area', width=7, header_halign=tf.ColumnAlignment.AlignCenter, - cell_halign=tf.ColumnAlignment.AlignRight, formatter=two_dec), - tf.Column('Pop. Density (/km²)', width=12, header_halign=tf.ColumnAlignment.AlignCenter, - cell_halign=tf.ColumnAlignment.AlignRight, obj_formatter=pop_density), - ] - - -EXTREMELY_HIGH_POULATION_DENSITY = 25000 - - -def high_density_tuples(row_tuple: Tuple) -> dict: - """Color rows with extremely high population density red.""" - opts = dict() - if len(row_tuple) >= 7 and row_tuple[6] > EXTREMELY_HIGH_POULATION_DENSITY: - opts[tf.TableFormatter.ROW_OPT_TEXT_COLOR] = tf.TableColors.TEXT_COLOR_RED - return opts - - -def high_density_objs(row_obj: CityInfo) -> dict: - """Color rows with extremely high population density red.""" - opts = dict() - if float(pop_density(row_obj)) > EXTREMELY_HIGH_POULATION_DENSITY: - opts[tf.TableFormatter.ROW_OPT_TEXT_COLOR] = tf.TableColors.TEXT_COLOR_RED - return opts - - -def make_table_parser() -> Cmd2ArgumentParser: - """Create a unique instance of an argparse Argument parser for processing table arguments. - - NOTE: The two cmd2 argparse decorators require that each parser be unique, even if they are essentially a deep copy - of each other. For cases like that, you can create a function to return a unique instance of a parser, which is - what is being done here. - """ - table_parser = Cmd2ArgumentParser() - table_item_group = table_parser.add_mutually_exclusive_group() - table_item_group.add_argument('-c', '--color', action='store_true', help='Enable color') - table_item_group.add_argument('-f', '--fancy', action='store_true', help='Fancy Grid') - table_item_group.add_argument('-s', '--sparse', action='store_true', help='Sparse Grid') - return table_parser - - -class TableDisplay(cmd2.Cmd): - """Example cmd2 application showing how you can display tabular data.""" - - def __init__(self): - super().__init__() - - def ptable(self, rows, columns, grid_args, row_stylist): - """Format tabular data for pretty-printing as a fixed-width table and then display it using a pager. - - :param rows: can be a list-of-lists (or another iterable of iterables), a two-dimensional - NumPy array, or an Iterable of non-iterable objects - :param columns: column headers and formatting options per column - :param grid_args: argparse arguments for formatting the grid - :param row_stylist: function to determine how each row gets styled - """ - if grid_args.color: - grid = tf.AlternatingRowGrid(BACK_PRI, BACK_ALT) - elif grid_args.fancy: - grid = tf.FancyGrid() - elif grid_args.sparse: - grid = tf.SparseGrid() - else: - grid = None - - formatted_table = tf.generate_table(rows=rows, columns=columns, grid_style=grid, row_tagger=row_stylist) - self.ppaged(formatted_table, chop=True) - - @cmd2.with_argparser(make_table_parser()) - def do_table(self, args): - """Display data in iterable form on the Earth's most populated cities in a table.""" - self.ptable(EXAMPLE_ITERABLE_DATA, COLUMNS, args, high_density_tuples) - - @cmd2.with_argparser(make_table_parser()) - def do_object_table(self, args): - """Display data in object form on the Earth's most populated cities in a table.""" - self.ptable(EXAMPLE_OBJECT_DATA, OBJ_COLS, args, high_density_objs) - - -if __name__ == '__main__': - import sys - app = TableDisplay() - app.debug = True - sys.exit(app.cmdloop()) diff --git a/tests/test_argparse_custom.py b/tests/test_argparse_custom.py index ce789f8e..92b2ecb4 100644 --- a/tests/test_argparse_custom.py +++ b/tests/test_argparse_custom.py @@ -7,8 +7,8 @@ import argparse import pytest import cmd2 -from cmd2 import Cmd2ArgumentParser -from cmd2.argparse_custom import generate_range_error, INFINITY +from cmd2 import Cmd2ArgumentParser, constants +from cmd2.argparse_custom import generate_range_error from .conftest import run_cmd @@ -188,7 +188,7 @@ def test_apcustom_narg_tuple_other_ranges(): parser = Cmd2ArgumentParser() arg = parser.add_argument('arg', nargs=(2,)) assert arg.nargs == argparse.ONE_OR_MORE - assert arg.nargs_range == (2, INFINITY) + assert arg.nargs_range == (2, constants.INFINITY) # Test finite range parser = Cmd2ArgumentParser() @@ -216,10 +216,10 @@ def test_apcustom_print_message(capsys): def test_generate_range_error(): # max is INFINITY - err_str = generate_range_error(1, INFINITY) + err_str = generate_range_error(1, constants.INFINITY) assert err_str == "expected at least 1 argument" - err_str = generate_range_error(2, INFINITY) + err_str = generate_range_error(2, constants.INFINITY) assert err_str == "expected at least 2 arguments" # min and max are equal diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 2afcf701..fe3f25a6 100755 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -166,10 +166,8 @@ def test_set_allow_style(base_app, new_val, is_valid, expected): assert not err assert "now: {!r}".format(new_val.capitalize()) in out[1] - # Reload ansi module to reset allow_style to its default since it's an - # application-wide setting that can affect other unit tests. - import importlib - importlib.reload(ansi) + # Reset allow_style to its default since it's an application-wide setting that can affect other unit tests + ansi.allow_style = ansi.STYLE_TERMINAL class OnChangeHookApp(cmd2.Cmd): diff --git a/tests/test_table_creator.py b/tests/test_table_creator.py new file mode 100644 index 00000000..917ba5cc --- /dev/null +++ b/tests/test_table_creator.py @@ -0,0 +1,411 @@ +# coding=utf-8 +# flake8: noqa E501 +""" +Unit testing for cmd2/table_creator.py module +""" +import pytest + +from cmd2 import ansi +from cmd2.table_creator import (AlternatingTable, BorderedTable, Column, HorizontalAlignment, + SimpleTable, TableCreator, VerticalAlignment) + + +def test_column_creation(): + # No width specified, blank label + c = Column("") + assert c.width == 1 + + # No width specified, label isn't blank but has no width + c = Column(ansi.style('', fg=ansi.fg.green)) + assert c.width == 1 + + # No width specified, label has width + c = Column("short\nreally long") + assert c.width == ansi.style_aware_wcswidth("really long") + + # Width less than 1 + with pytest.raises(ValueError) as excinfo: + Column("Column 1", width=0) + assert "Column width cannot be less than 1" in str(excinfo.value) + + # Width specified + c = Column("header", width=20) + assert c.width == 20 + + # max_data_lines less than 1 + with pytest.raises(ValueError) as excinfo: + Column("Column 1", max_data_lines=0) + assert "Max data lines cannot be less than 1" in str(excinfo.value) + + +def test_column_alignment(): + column_1 = Column("Col 1", width=10, + header_horiz_align=HorizontalAlignment.LEFT, header_vert_align=VerticalAlignment.TOP, + data_horiz_align=HorizontalAlignment.LEFT, data_vert_align=VerticalAlignment.TOP) + column_2 = Column("Col 2", width=10, + header_horiz_align=HorizontalAlignment.CENTER, header_vert_align=VerticalAlignment.MIDDLE, + data_horiz_align=HorizontalAlignment.CENTER, data_vert_align=VerticalAlignment.MIDDLE) + column_3 = Column("Col 3", width=10, + header_horiz_align=HorizontalAlignment.RIGHT, header_vert_align=VerticalAlignment.BOTTOM, + data_horiz_align=HorizontalAlignment.RIGHT, data_vert_align=VerticalAlignment.BOTTOM) + column_4 = Column("Three\nline\nheader", width=10) + + columns = [column_1, column_2, column_3, column_4] + tc = TableCreator(columns) + + # Check defaults + assert column_4.header_horiz_align == HorizontalAlignment.LEFT + assert column_4.header_vert_align == VerticalAlignment.BOTTOM + assert column_4.data_horiz_align == HorizontalAlignment.LEFT + assert column_4.data_vert_align == VerticalAlignment.TOP + + # Create a header row + header = tc.generate_row() + assert header == ('Col 1 Three \n' + ' Col 2 line \n' + ' Col 3 header \n') + + # Create a data row + row_data = ["Val 1", "Val 2", "Val 3", "Three\nline\ndata"] + row = tc.generate_row(row_data=row_data) + assert row == ('Val 1 Three \n' + ' Val 2 line \n' + ' Val 3 data \n') + + +def test_wrap_text(): + column_1 = Column("Col 1", width=10) + tc = TableCreator([column_1]) + + # Test normal wrapping + row_data = ['Some text to wrap\nA new line that will wrap\nNot wrap\n 1 2 3'] + row = tc.generate_row(row_data=row_data) + assert row == ('Some text \n' + 'to wrap \n' + 'A new line\n' + 'that will \n' + 'wrap \n' + 'Not wrap \n' + ' 1 2 3 \n') + + # Test preserving a multiple space sequence across a line break + row_data = ['First last one'] + row = tc.generate_row(row_data=row_data) + assert row == ('First \n' + ' last one \n') + + +def test_wrap_text_max_lines(): + column_1 = Column("Col 1", width=10, max_data_lines=2) + tc = TableCreator([column_1]) + + # Test not needing to truncate the final line + row_data = ['First line last line'] + row = tc.generate_row(row_data=row_data) + assert row == ('First line\n' + 'last line \n') + + # Test having to truncate the last word because it's too long for the final line + row_data = ['First line last lineextratext'] + row = tc.generate_row(row_data=row_data) + assert row == ('First line\n' + 'last line…\n') + + # Test having to truncate the last word because it fits the final line but there is more text not being included + row_data = ['First line thistxtfit extra'] + row = tc.generate_row(row_data=row_data) + assert row == ('First line\n' + 'thistxtfi…\n') + + # Test having to truncate the last word because it fits the final line but there are more lines not being included + row_data = ['First line thistxtfit\nextra'] + row = tc.generate_row(row_data=row_data) + assert row == ('First line\n' + 'thistxtfi…\n') + + # Test having space left on the final line and adding an ellipsis because there are more lines not being included + row_data = ['First line last line\nextra line'] + row = tc.generate_row(row_data=row_data) + assert row == ('First line\n' + 'last line…\n') + + +def test_wrap_long_word(): + # Make sure words wider than column start on own line and wrap + column_1 = Column("LongColumnName", width=10) + column_2 = Column("Col 2", width=10) + + columns = [column_1, column_2] + tc = TableCreator(columns) + + # Test header row + header = tc.generate_row() + assert header == ('LongColumn \n' + 'Name Col 2 \n') + + # Test data row + row_data = list() + + # Long word should start on the first line (style should not affect width) + row_data.append(ansi.style("LongerThan10", fg=ansi.fg.green)) + + # Long word should start on the second line + row_data.append("Word LongerThan10") + + row = tc.generate_row(row_data=row_data) + expected = (ansi.RESET_ALL + ansi.fg.green + "LongerThan" + ansi.RESET_ALL + " Word \n" + + ansi.RESET_ALL + ansi.fg.green + "10" + ansi.fg.reset + ansi.RESET_ALL + ' ' + ansi.RESET_ALL + ' LongerThan\n' + ' 10 \n') + assert row == expected + + +def test_wrap_long_word_max_data_lines(): + column_1 = Column("Col 1", width=10, max_data_lines=2) + column_2 = Column("Col 2", width=10, max_data_lines=2) + column_3 = Column("Col 3", width=10, max_data_lines=2) + column_4 = Column("Col 4", width=10, max_data_lines=1) + + columns = [column_1, column_2, column_3, column_4] + tc = TableCreator(columns) + + row_data = list() + + # This long word will exactly fit the last line and it's the final word in the text. No ellipsis should appear. + row_data.append("LongerThan10FitsLast") + + # This long word will exactly fit the last line but it's not the final word in the text. + # Make sure ellipsis word's final character. + row_data.append("LongerThan10FitsLast\nMore lines") + + # This long word will run over the last line. Make sure it is truncated. + row_data.append("LongerThan10RunsOverLast") + + # This long word will start on the final line after another word. Therefore it won't wrap but will instead be truncated. + row_data.append("A LongerThan10RunsOverLast") + + row = tc.generate_row(row_data=row_data) + assert row == ('LongerThan LongerThan LongerThan A LongerT…\n' + '10FitsLast 10FitsLas… 10RunsOve… \n') + + +def test_wrap_long_char_wider_than_max_width(): + """ + This tests case where a character is wider than max_width. This can happen if max_width + is 1 and the text contains wide characters (e.g. East Asian). Replace it with an ellipsis. + """ + column_1 = Column("Col 1", width=1) + tc = TableCreator([column_1]) + row = tc.generate_row(row_data=['深']) + assert row == '…\n' + + +def test_generate_row_exceptions(): + column_1 = Column("Col 1") + tc = TableCreator([column_1]) + row_data = ['fake'] + + # fill_char too long + with pytest.raises(TypeError) as excinfo: + tc.generate_row(row_data=row_data, fill_char='too long') + assert "Fill character must be exactly one character long" in str(excinfo.value) + + # Unprintable characters + for arg in ['fill_char', 'pre_line', 'inter_cell', 'post_line']: + kwargs = {arg: '\n'} + with pytest.raises(ValueError) as excinfo: + tc.generate_row(row_data=row_data, **kwargs) + assert "{} contains an unprintable character".format(arg) in str(excinfo.value) + + # data with too many columns + row_data = ['Data 1', 'Extra Column'] + with pytest.raises(ValueError) as excinfo: + tc.generate_row(row_data=row_data) + assert "Length of row_data must match length of cols" in str(excinfo.value) + + +def test_tabs(): + column_1 = Column("Col\t1", width=20) + column_2 = Column("Col 2") + tc = TableCreator([column_1, column_2], tab_width=2) + + row = tc.generate_row(fill_char='\t', pre_line='\t', + inter_cell='\t', post_line='\t') + assert row == ' Col 1 Col 2 \n' + + +def test_simple_table(): + column_1 = Column("Col 1", width=15) + column_2 = Column("Col 2", width=15) + + row_data = list() + row_data.append(["Col 1 Row 1", "Col 2 Row 1"]) + row_data.append(["Col 1 Row 2", "Col 2 Row 2"]) + + # Default options + st = SimpleTable([column_1, column_2]) + table = st.generate_table(row_data) + + assert table == ('Col 1 Col 2 \n' + '--------------------------------\n' + 'Col 1 Row 1 Col 2 Row 1 \n' + '\n' + 'Col 1 Row 2 Col 2 Row 2 \n') + + # Custom divider + st = SimpleTable([column_1, column_2], 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' + 'Col 1 Row 2 Col 2 Row 2 \n') + + # No divider + st = SimpleTable([column_1, column_2], divider_char=None) + table = st.generate_table(row_data) + + assert table == ('Col 1 Col 2 \n' + 'Col 1 Row 1 Col 2 Row 1 \n' + '\n' + 'Col 1 Row 2 Col 2 Row 2 \n') + + # No row spacing + st = SimpleTable([column_1, column_2]) + table = st.generate_table(row_data, row_spacing=0) + assert table == ('Col 1 Col 2 \n' + '--------------------------------\n' + 'Col 1 Row 1 Col 2 Row 1 \n' + 'Col 1 Row 2 Col 2 Row 2 \n') + + # No header + st = SimpleTable([column_1, column_2]) + table = st.generate_table(row_data, include_header=False) + + assert table == ('Col 1 Row 1 Col 2 Row 1 \n' + '\n' + 'Col 1 Row 2 Col 2 Row 2 \n') + + # Invalid row spacing + st = SimpleTable([column_1, column_2]) + with pytest.raises(ValueError) as excinfo: + st.generate_table(row_data, row_spacing=-1) + assert "Row spacing cannot be less than 0" in str(excinfo.value) + + +def test_bordered_table(): + column_1 = Column("Col 1", width=15) + column_2 = Column("Col 2", width=15) + + row_data = list() + row_data.append(["Col 1 Row 1", "Col 2 Row 1"]) + row_data.append(["Col 1 Row 2", "Col 2 Row 2"]) + + # Default options + bt = BorderedTable([column_1, column_2]) + table = bt.generate_table(row_data) + assert table == ('╔═════════════════╤═════════════════╗\n' + '║ Col 1 │ Col 2 ║\n' + '╠═════════════════╪═════════════════╣\n' + '║ Col 1 Row 1 │ Col 2 Row 1 ║\n' + '╟─────────────────┼─────────────────╢\n' + '║ Col 1 Row 2 │ Col 2 Row 2 ║\n' + '╚═════════════════╧═════════════════╝\n') + + # No column borders + bt = BorderedTable([column_1, column_2], column_borders=False) + table = bt.generate_table(row_data) + assert table == ('╔══════════════════════════════════╗\n' + '║ Col 1 Col 2 ║\n' + '╠══════════════════════════════════╣\n' + '║ Col 1 Row 1 Col 2 Row 1 ║\n' + '╟──────────────────────────────────╢\n' + '║ Col 1 Row 2 Col 2 Row 2 ║\n' + '╚══════════════════════════════════╝\n') + + # No header + bt = BorderedTable([column_1, column_2]) + table = bt.generate_table(row_data, include_header=False) + assert table == ('╔═════════════════╤═════════════════╗\n' + '║ Col 1 Row 1 │ Col 2 Row 1 ║\n' + '╟─────────────────┼─────────────────╢\n' + '║ Col 1 Row 2 │ Col 2 Row 2 ║\n' + '╚═════════════════╧═════════════════╝\n') + + # Non-default padding + bt = BorderedTable([column_1, column_2], padding=2) + table = bt.generate_table(row_data) + assert table == ('╔═══════════════════╤═══════════════════╗\n' + '║ Col 1 │ Col 2 ║\n' + '╠═══════════════════╪═══════════════════╣\n' + '║ Col 1 Row 1 │ Col 2 Row 1 ║\n' + '╟───────────────────┼───────────────────╢\n' + '║ Col 1 Row 2 │ Col 2 Row 2 ║\n' + '╚═══════════════════╧═══════════════════╝\n') + + # Invalid padding + with pytest.raises(ValueError) as excinfo: + BorderedTable([column_1, column_2], padding=-1) + assert "Padding cannot be less than 0" in str(excinfo.value) + + +def test_alternating_table(): + column_1 = Column("Col 1", width=15) + column_2 = Column("Col 2", width=15) + + row_data = list() + row_data.append(["Col 1 Row 1", "Col 2 Row 1"]) + row_data.append(["Col 1 Row 2", "Col 2 Row 2"]) + + # Default options + at = AlternatingTable([column_1, column_2]) + table = at.generate_table(row_data) + assert table == ('╔═════════════════╤═════════════════╗\n' + '║ Col 1 │ Col 2 ║\n' + '╠═════════════════╪═════════════════╣\n' + '║ Col 1 Row 1 │ Col 2 Row 1 ║\n' + '\x1b[100m║ \x1b[49m\x1b[0m\x1b[100mCol 1 Row 2\x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[0m\x1b[100m │ \x1b[49m\x1b[0m\x1b[100mCol 2 Row 2\x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[0m\x1b[100m ║\x1b[49m\n' + '╚═════════════════╧═════════════════╝\n') + + # Other bg colors + at = AlternatingTable([column_1, column_2], bg_odd=ansi.bg.bright_blue, bg_even=ansi.bg.green) + table = at.generate_table(row_data) + assert table == ('╔═════════════════╤═════════════════╗\n' + '║ Col 1 │ Col 2 ║\n' + '╠═════════════════╪═════════════════╣\n' + '\x1b[104m║ \x1b[49m\x1b[0m\x1b[104mCol 1 Row 1\x1b[49m\x1b[0m\x1b[104m \x1b[49m\x1b[104m \x1b[49m\x1b[104m \x1b[49m\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[104m \x1b[49m\x1b[104m \x1b[49m\x1b[104m \x1b[49m\x1b[0m\x1b[104m ║\x1b[49m\n' + '\x1b[42m║ \x1b[49m\x1b[0m\x1b[42mCol 1 Row 2\x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[42m \x1b[49m\x1b[42m \x1b[49m\x1b[42m \x1b[49m\x1b[0m\x1b[42m │ \x1b[49m\x1b[0m\x1b[42mCol 2 Row 2\x1b[49m\x1b[0m\x1b[42m \x1b[49m\x1b[42m \x1b[49m\x1b[42m \x1b[49m\x1b[42m \x1b[49m\x1b[0m\x1b[42m ║\x1b[49m\n' + '╚═════════════════╧═════════════════╝\n') + + # No column borders + at = AlternatingTable([column_1, column_2], column_borders=False) + table = at.generate_table(row_data) + assert table == ('╔══════════════════════════════════╗\n' + '║ Col 1 Col 2 ║\n' + '╠══════════════════════════════════╣\n' + '║ Col 1 Row 1 Col 2 Row 1 ║\n' + '\x1b[100m║ \x1b[49m\x1b[0m\x1b[100mCol 1 Row 2\x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[0m\x1b[100mCol 2 Row 2\x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[0m\x1b[100m ║\x1b[49m\n' + '╚══════════════════════════════════╝\n') + + # No header + at = AlternatingTable([column_1, column_2]) + table = at.generate_table(row_data, include_header=False) + assert table == ('╔═════════════════╤═════════════════╗\n' + '║ Col 1 Row 1 │ Col 2 Row 1 ║\n' + '\x1b[100m║ \x1b[49m\x1b[0m\x1b[100mCol 1 Row 2\x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[0m\x1b[100m │ \x1b[49m\x1b[0m\x1b[100mCol 2 Row 2\x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[0m\x1b[100m ║\x1b[49m\n' + '╚═════════════════╧═════════════════╝\n') + + # Non-default padding + at = AlternatingTable([column_1, column_2], padding=2) + table = at.generate_table(row_data) + assert table == ('╔═══════════════════╤═══════════════════╗\n' + '║ Col 1 │ Col 2 ║\n' + '╠═══════════════════╪═══════════════════╣\n' + '║ Col 1 Row 1 │ Col 2 Row 1 ║\n' + '\x1b[100m║ \x1b[49m\x1b[0m\x1b[100mCol 1 Row 2\x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[0m\x1b[100m │ \x1b[49m\x1b[0m\x1b[100mCol 2 Row 2\x1b[49m\x1b[0m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[100m \x1b[49m\x1b[0m\x1b[100m ║\x1b[49m\n' + '╚═══════════════════╧═══════════════════╝\n') + + # Invalid padding + with pytest.raises(ValueError) as excinfo: + AlternatingTable([column_1, column_2], padding=-1) + assert "Padding cannot be less than 0" in str(excinfo.value) diff --git a/tests/test_utils.py b/tests/test_utils.py index 7546184e..27bf4743 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -368,14 +368,49 @@ def test_align_text_fill_char_is_tab(): aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width) assert aligned == text + ' ' -def test_align_text_fill_char_has_color(): +def test_align_text_with_style(): from cmd2 import ansi - text = 'foo' - fill_char = ansi.fg.bright_yellow + '-' + ansi.fg.reset - width = 5 + # Single line with only left fill + text = ansi.style('line1', fg=ansi.fg.bright_blue) + fill_char = ansi.style('-', fg=ansi.fg.bright_yellow) + width = 6 + + aligned = cu.align_text(text, cu.TextAlignment.RIGHT, fill_char=fill_char, width=width) + + left_fill = ansi.RESET_ALL + fill_char + ansi.RESET_ALL + right_fill = ansi.RESET_ALL + line_1_text = ansi.fg.bright_blue + 'line1' + ansi.FG_RESET + + assert aligned == (left_fill + line_1_text + right_fill) + + # Single line with only right fill + text = ansi.style('line1', fg=ansi.fg.bright_blue) + fill_char = ansi.style('-', fg=ansi.fg.bright_yellow) + width = 6 + aligned = cu.align_text(text, cu.TextAlignment.LEFT, fill_char=fill_char, width=width) - assert aligned == text + fill_char * 2 + + left_fill = ansi.RESET_ALL + right_fill = ansi.RESET_ALL + fill_char + ansi.RESET_ALL + line_1_text = ansi.fg.bright_blue + 'line1' + ansi.FG_RESET + + assert aligned == (left_fill + line_1_text + right_fill) + + # Multiple lines to show that style is preserved across all lines. Also has left and right fill. + text = ansi.style('line1\nline2', fg=ansi.fg.bright_blue) + fill_char = ansi.style('-', fg=ansi.fg.bright_yellow) + width = 7 + + aligned = cu.align_text(text, cu.TextAlignment.CENTER, fill_char=fill_char, width=width) + + left_fill = ansi.RESET_ALL + fill_char + ansi.RESET_ALL + right_fill = ansi.RESET_ALL + fill_char + ansi.RESET_ALL + line_1_text = ansi.fg.bright_blue + 'line1' + line_2_text = ansi.fg.bright_blue + 'line2' + ansi.FG_RESET + + assert aligned == (left_fill + line_1_text + right_fill + '\n' + + left_fill + line_2_text + right_fill) def test_align_text_width_is_too_small(): text = 'foo' |