summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cmd2/argparse_completer.py9
-rw-r--r--cmd2/argparse_custom.py19
-rw-r--r--cmd2/cmd2.py4
-rw-r--r--cmd2/constants.py1
-rw-r--r--cmd2/table_creator.py805
-rw-r--r--cmd2/utils.py92
-rw-r--r--docs/api/index.rst2
-rw-r--r--docs/api/table_creator.rst35
-rw-r--r--docs/api/utils.rst1
-rw-r--r--docs/features/index.rst1
-rw-r--r--docs/features/table_creation.rst40
-rwxr-xr-xexamples/table_creation.py77
-rwxr-xr-xexamples/table_display.py204
-rw-r--r--tests/test_argparse_custom.py10
-rwxr-xr-xtests/test_cmd2.py6
-rw-r--r--tests/test_table_creator.py411
-rw-r--r--tests/test_utils.py45
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'