Repository: python-cmd2/cmd2
Pull request #916 (2020-04-09 20:40:03)

Initial commit of table creation API
Adding table creation API

It offers the following advantages over other table creation libraries
1. Already styled text is preserved, even when wrapping over multiple lines in a cell
2. Tables can be built 1 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.
3. Text wrapping handles ANSI style sequences and characters with display widths greater than 1

The main class is `TableCreator`. It has 1 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 provided: `SimpleTable`, `BorderedTable`, `AlternatingTable`.

It doesn't have certain advance features provided by the Python library [TableFormatter](https://github.com/python-tableformatter/tableformatter) like:
1. Assigning a data formatter function to a column
2. Handling of Panda and NumPy structures

This is intentional to keep the `TableCreator` class generic. However a child class or wrapper could be written to support these functions.



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 03b88d03..23596a4b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,12 @@
         * Starting `ipy` when `Cmd.in_pyscript()` is already True is not allowed.
     * `with_argument_list`, `with_argparser`, and `with_argparser_and_unknown_args` wrappers now pass
       `kwargs` through to their wrapped command function.
+    * Added `table_creator` module for creating richly formatted tables. This module is in beta and subject
+      to change.
+        * See [table_creation](https://cmd2.readthedocs.io/en/latest/features/table_creation.html)
+          documentation for an overview.
+        * See [table_creation.py](https://github.com/python-cmd2/cmd2/blob/master/examples/table_creation.py)
+          for an example.
     
 ## 1.0.2 (April 06, 2020)
 * Bug Fixes
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())


Test patch:
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'