diff options
Diffstat (limited to 'terminaltables3')
-rw-r--r-- | terminaltables3/__init__.py | 17 | ||||
-rw-r--r-- | terminaltables3/ascii_table.py | 63 | ||||
-rw-r--r-- | terminaltables3/base_table.py | 236 | ||||
-rw-r--r-- | terminaltables3/build.py | 174 | ||||
-rw-r--r-- | terminaltables3/github_table.py | 73 | ||||
-rw-r--r-- | terminaltables3/other_tables.py | 173 | ||||
-rw-r--r-- | terminaltables3/terminal_io.py | 102 | ||||
-rw-r--r-- | terminaltables3/width_and_alignment.py | 196 |
8 files changed, 1034 insertions, 0 deletions
diff --git a/terminaltables3/__init__.py b/terminaltables3/__init__.py new file mode 100644 index 0000000..f0b4858 --- /dev/null +++ b/terminaltables3/__init__.py @@ -0,0 +1,17 @@ +"""Generate simple tables in terminals from a nested list of strings. + +Use SingleTable or DoubleTable instead of AsciiTable for box-drawing characters. + +https://github.com/Robpol86/terminaltables3 +https://pypi.python.org/pypi/terminaltables3 +""" + +from terminaltables3.ascii_table import AsciiTable # noqa +from terminaltables3.github_table import GithubFlavoredMarkdownTable # noqa +from terminaltables3.other_tables import DoubleTable # noqa +from terminaltables3.other_tables import PorcelainTable # noqa +from terminaltables3.other_tables import SingleTable # noqa + +__author__ = "@Robpol86" +__license__ = "MIT" +__version__ = "3.1.0" diff --git a/terminaltables3/ascii_table.py b/terminaltables3/ascii_table.py new file mode 100644 index 0000000..4630f74 --- /dev/null +++ b/terminaltables3/ascii_table.py @@ -0,0 +1,63 @@ +"""AsciiTable is the main table class. To be inherited by other tables. Define convenience methods here.""" + +from terminaltables3.base_table import BaseTable +from terminaltables3.terminal_io import terminal_size +from terminaltables3.width_and_alignment import ( + column_max_width, + max_dimensions, + table_width, +) + + +class AsciiTable(BaseTable): + """Draw a table using regular ASCII characters, such as ``+``, ``|``, and ``-``. + + :ivar iter table_data: List (empty or list of lists of strings) representing the table. + :ivar str title: Optional title to show within the top border of the table. + :ivar bool inner_column_border: Separates columns. + :ivar bool inner_footing_row_border: Show a border before the last row. + :ivar bool inner_heading_row_border: Show a border after the first row. + :ivar bool inner_row_border: Show a border in between every row. + :ivar bool outer_border: Show the top, left, right, and bottom border. + :ivar dict justify_columns: Horizontal justification. Keys are column indexes (int). Values are right/left/center. + :ivar int padding_left: Number of spaces to pad on the left side of every cell. + :ivar int padding_right: Number of spaces to pad on the right side of every cell. + """ + + def column_max_width(self, column_number: int) -> int: + """Return the maximum width of a column based on the current terminal width. + + :param int column_number: The column number to query. + + :return: The max width of the column. + :rtype: int + """ + inner_widths = max_dimensions(self.table_data)[0] + outer_border = 2 if self.outer_border else 0 + inner_border = 1 if self.inner_column_border else 0 + padding = self.padding_left + self.padding_right + return column_max_width( + inner_widths, column_number, outer_border, inner_border, padding + ) + + @property + def column_widths(self) -> list[int]: + """Return a list of integers representing the widths of each table column without padding.""" + if not self.table_data: + return [] + return max_dimensions(self.table_data)[0] + + @property + def ok(self) -> bool: # Too late to change API. # pylint: disable=invalid-name + """Return True if the table fits within the terminal width, False if the table breaks.""" + return self.table_width <= terminal_size()[0] + + @property + def table_width(self) -> int: + """Return the width of the table including padding and borders.""" + outer_widths = max_dimensions( + self.table_data, self.padding_left, self.padding_right + )[2] + outer_border = 2 if self.outer_border else 0 + inner_border = 1 if self.inner_column_border else 0 + return table_width(outer_widths, outer_border, inner_border) diff --git a/terminaltables3/base_table.py b/terminaltables3/base_table.py new file mode 100644 index 0000000..4c375af --- /dev/null +++ b/terminaltables3/base_table.py @@ -0,0 +1,236 @@ +"""Base table class. Define just the bare minimum to build tables.""" + +from typing import Generator, Optional, Sequence, Tuple + +from terminaltables3.build import build_border, build_row, flatten +from terminaltables3.width_and_alignment import align_and_pad_cell, max_dimensions + + +class BaseTable: + """Base table class. + + :ivar iter table_data: List (empty or list of lists of strings) representing the table. + :ivar str title: Optional title to show within the top border of the table. + :ivar bool inner_column_border: Separates columns. + :ivar bool inner_footing_row_border: Show a border before the last row. + :ivar bool inner_heading_row_border: Show a border after the first row. + :ivar bool inner_row_border: Show a border in between every row. + :ivar bool outer_border: Show the top, left, right, and bottom border. + :ivar dict justify_columns: Horizontal justification. Keys are column indexes (int). Values are right/left/center. + :ivar int padding_left: Number of spaces to pad on the left side of every cell. + :ivar int padding_right: Number of spaces to pad on the right side of every cell. + """ + + CHAR_F_INNER_HORIZONTAL = "-" + CHAR_F_INNER_INTERSECT = "+" + CHAR_F_INNER_VERTICAL = "|" + CHAR_F_OUTER_LEFT_INTERSECT = "+" + CHAR_F_OUTER_LEFT_VERTICAL = "|" + CHAR_F_OUTER_RIGHT_INTERSECT = "+" + CHAR_F_OUTER_RIGHT_VERTICAL = "|" + CHAR_H_INNER_HORIZONTAL = "-" + CHAR_H_INNER_INTERSECT = "+" + CHAR_H_INNER_VERTICAL = "|" + CHAR_H_OUTER_LEFT_INTERSECT = "+" + CHAR_H_OUTER_LEFT_VERTICAL = "|" + CHAR_H_OUTER_RIGHT_INTERSECT = "+" + CHAR_H_OUTER_RIGHT_VERTICAL = "|" + CHAR_INNER_HORIZONTAL = "-" + CHAR_INNER_INTERSECT = "+" + CHAR_INNER_VERTICAL = "|" + CHAR_OUTER_BOTTOM_HORIZONTAL = "-" + CHAR_OUTER_BOTTOM_INTERSECT = "+" + CHAR_OUTER_BOTTOM_LEFT = "+" + CHAR_OUTER_BOTTOM_RIGHT = "+" + CHAR_OUTER_LEFT_INTERSECT = "+" + CHAR_OUTER_LEFT_VERTICAL = "|" + CHAR_OUTER_RIGHT_INTERSECT = "+" + CHAR_OUTER_RIGHT_VERTICAL = "|" + CHAR_OUTER_TOP_HORIZONTAL = "-" + CHAR_OUTER_TOP_INTERSECT = "+" + CHAR_OUTER_TOP_LEFT = "+" + CHAR_OUTER_TOP_RIGHT = "+" + + def __init__( + self, table_data: Sequence[Sequence[str]], title: Optional[str] = None + ): + """Constructor. + + :param iter table_data: List (empty or list of lists of strings) representing the table. + :param title: Optional title to show within the top border of the table. + """ + self.table_data = table_data + self.title = title + + self.inner_column_border = True + self.inner_footing_row_border = False + self.inner_heading_row_border = True + self.inner_row_border = False + self.outer_border = True + + self.justify_columns = {} # {0: 'right', 1: 'left', 2: 'center'} + self.padding_left = 1 + self.padding_right = 1 + + def horizontal_border( + self, style: str, outer_widths: Sequence[int] + ) -> Tuple[str, ...]: + """Build any kind of horizontal border for the table. + + :param str style: Type of border to return. + :param iter outer_widths: List of widths (with padding) for each column. + + :return: Prepared border as a tuple of strings. + :rtype: tuple + """ + if style == "top": + horizontal = self.CHAR_OUTER_TOP_HORIZONTAL + left = self.CHAR_OUTER_TOP_LEFT + intersect = ( + self.CHAR_OUTER_TOP_INTERSECT if self.inner_column_border else "" + ) + right = self.CHAR_OUTER_TOP_RIGHT + title = self.title + elif style == "bottom": + horizontal = self.CHAR_OUTER_BOTTOM_HORIZONTAL + left = self.CHAR_OUTER_BOTTOM_LEFT + intersect = ( + self.CHAR_OUTER_BOTTOM_INTERSECT if self.inner_column_border else "" + ) + right = self.CHAR_OUTER_BOTTOM_RIGHT + title = None + elif style == "heading": + horizontal = self.CHAR_H_INNER_HORIZONTAL + left = self.CHAR_H_OUTER_LEFT_INTERSECT if self.outer_border else "" + intersect = self.CHAR_H_INNER_INTERSECT if self.inner_column_border else "" + right = self.CHAR_H_OUTER_RIGHT_INTERSECT if self.outer_border else "" + title = None + elif style == "footing": + horizontal = self.CHAR_F_INNER_HORIZONTAL + left = self.CHAR_F_OUTER_LEFT_INTERSECT if self.outer_border else "" + intersect = self.CHAR_F_INNER_INTERSECT if self.inner_column_border else "" + right = self.CHAR_F_OUTER_RIGHT_INTERSECT if self.outer_border else "" + title = None + else: + horizontal = self.CHAR_INNER_HORIZONTAL + left = self.CHAR_OUTER_LEFT_INTERSECT if self.outer_border else "" + intersect = self.CHAR_INNER_INTERSECT if self.inner_column_border else "" + right = self.CHAR_OUTER_RIGHT_INTERSECT if self.outer_border else "" + title = None + return build_border(outer_widths, horizontal, left, intersect, right, title) + + def gen_row_lines( + self, row: Sequence[str], style: str, inner_widths: Sequence[int], height: int + ) -> Generator[Tuple[str, ...], None, None]: + r"""Combine cells in row and group them into lines with vertical borders. + + Caller is expected to pass yielded lines to ''.join() to combine them into a printable line. Caller must append + newline character to the end of joined line. + + In: + ['Row One Column One', 'Two', 'Three'] + Out: + [ + ('|', ' Row One Column One ', '|', ' Two ', '|', ' Three ', '|'), + ] + + In: + ['Row One\nColumn One', 'Two', 'Three'], + Out: + [ + ('|', ' Row One ', '|', ' Two ', '|', ' Three ', '|'), + ('|', ' Column One ', '|', ' ', '|', ' ', '|'), + ] + + :param iter row: One row in the table. List of cells. + :param str style: Type of border characters to use. + :param iter inner_widths: List of widths (no padding) for each column. + :param int height: Inner height (no padding) (number of lines) to expand row to. + + :return: Yields lines split into components in a list. Caller must ''.join() line. + """ + cells_in_row = [] + + # Resize row if it doesn't have enough cells. + if len(row) != len(inner_widths): + row = row + [""] * (len(inner_widths) - len(row)) + + # Pad and align each cell. Split each cell into lines to support multi-line cells. + for i, cell in enumerate(row): + align = (self.justify_columns.get(i),) + inner_dimensions = (inner_widths[i], height) + padding = (self.padding_left, self.padding_right, 0, 0) + cells_in_row.append( + align_and_pad_cell(cell, align, inner_dimensions, padding) + ) + + # Determine border characters. + if style == "heading": + left = self.CHAR_H_OUTER_LEFT_VERTICAL if self.outer_border else "" + center = self.CHAR_H_INNER_VERTICAL if self.inner_column_border else "" + right = self.CHAR_H_OUTER_RIGHT_VERTICAL if self.outer_border else "" + elif style == "footing": + left = self.CHAR_F_OUTER_LEFT_VERTICAL if self.outer_border else "" + center = self.CHAR_F_INNER_VERTICAL if self.inner_column_border else "" + right = self.CHAR_F_OUTER_RIGHT_VERTICAL if self.outer_border else "" + else: + left = self.CHAR_OUTER_LEFT_VERTICAL if self.outer_border else "" + center = self.CHAR_INNER_VERTICAL if self.inner_column_border else "" + right = self.CHAR_OUTER_RIGHT_VERTICAL if self.outer_border else "" + + # Yield each line. + yield from build_row(cells_in_row, left, center, right) + + def gen_table( + self, + inner_widths: Sequence[int], + inner_heights: Sequence[int], + outer_widths: Sequence[int], + ) -> Generator[Tuple[str, ...], None, None]: + """Combine everything and yield every line of the entire table with borders. + + :param iter inner_widths: List of widths (no padding) for each column. + :param iter inner_heights: List of heights (no padding) for each row. + :param iter outer_widths: List of widths (with padding) for each column. + :return: + """ + # Yield top border. + if self.outer_border: + yield self.horizontal_border("top", outer_widths) + + # Yield table body. + row_count = len(self.table_data) + last_row_index, before_last_row_index = row_count - 1, row_count - 2 + for i, row in enumerate(self.table_data): + # Yield the row line by line (e.g. multi-line rows). + if self.inner_heading_row_border and i == 0: + style = "heading" + elif self.inner_footing_row_border and i == last_row_index: + style = "footing" + else: + style = "row" + yield from self.gen_row_lines(row, style, inner_widths, inner_heights[i]) + # If this is the last row then break. No separator needed. + if i == last_row_index: + break + # Yield heading separator. + if self.inner_heading_row_border and i == 0: + yield self.horizontal_border("heading", outer_widths) + # Yield footing separator. + elif self.inner_footing_row_border and i == before_last_row_index: + yield self.horizontal_border("footing", outer_widths) + # Yield row separator. + elif self.inner_row_border: + yield self.horizontal_border("row", outer_widths) + + # Yield bottom border. + if self.outer_border: + yield self.horizontal_border("bottom", outer_widths) + + @property + def table(self) -> str: + """Return a large string of the entire table ready to be printed to the terminal.""" + dimensions = max_dimensions( + self.table_data, self.padding_left, self.padding_right + )[:3] + return flatten(self.gen_table(*dimensions)) diff --git a/terminaltables3/build.py b/terminaltables3/build.py new file mode 100644 index 0000000..c01349f --- /dev/null +++ b/terminaltables3/build.py @@ -0,0 +1,174 @@ +"""Combine cells into rows.""" + +from typing import Generator, Iterator, Optional, Sequence, Union + +from terminaltables3.width_and_alignment import visible_width + + +def combine( + line: Union[ + Generator[Union[int, str], None, None], Iterator[Optional[Union[int, str]]] + ], + left: str, + intersect: Optional[str], + right: str, +) -> Generator[int, None, None]: + """Zip borders between items in `line`. + + e.g. ('l', '1', 'c', '2', 'c', '3', 'r') + + :param iter line: List to iterate. + :param left: Left border. + :param intersect: Column separator. + :param right: Right border. + + :return: Yields combined objects. + """ + # Yield left border. + if left: + yield left + + # Yield items with intersect characters. + if intersect: + try: + for j, i in enumerate(line, start=-len(line) + 1): + yield i + if j: + yield intersect + except TypeError: # Generator. + try: + item = next(line) + except StopIteration: # Was empty all along. + pass + else: + while True: + yield item + try: + peek = next(line) + except StopIteration: + break + yield intersect + item = peek + else: + yield from line + + # Yield right border. + if right: + yield right + + +def build_border( + outer_widths: Sequence[int], + horizontal: str, + left: str, + intersect: str, + right: str, + title: Optional[str] = None, +): + """Build the top/bottom/middle row. Optionally embed the table title within the border. + + Title is hidden if it doesn't fit between the left/right characters/edges. + + Example return value: + ('<', '-----', '+', '------', '+', '-------', '>') + ('<', 'My Table', '----', '+', '------->') + + :param iter outer_widths: List of widths (with padding) for each column. + :param str horizontal: Character to stretch across each column. + :param str left: Left border. + :param str intersect: Column separator. + :param str right: Right border. + :param title: Overlay the title on the border between the left and right characters. + + :return: Returns a generator of strings representing a border. + :rtype: iter + """ + length = 0 + + # Hide title if it doesn't fit. + if title is not None and outer_widths: + try: + length = visible_width(title) + except TypeError: + title = str(title) + length = visible_width(title) + if length > sum(outer_widths) + len(intersect) * (len(outer_widths) - 1): + title = None + + # Handle no title. + if title is None or not outer_widths or not horizontal: + return combine((horizontal * c for c in outer_widths), left, intersect, right) + + # Handle title fitting in the first column. + if length == outer_widths[0]: + return combine( + [title] + [horizontal * c for c in outer_widths[1:]], left, intersect, right + ) + if length < outer_widths[0]: + columns = [title + horizontal * (outer_widths[0] - length)] + [ + horizontal * c for c in outer_widths[1:] + ] + return combine(columns, left, intersect, right) + + # Handle wide titles/narrow columns. + columns_and_intersects = [title] + for width in combine(outer_widths, None, bool(intersect), None): + # If title is taken care of. + if length < 1: + columns_and_intersects.append( + intersect if width is True else horizontal * width + ) + # If title's last character overrides an intersect character. + elif width is True and length == 1: + length = 0 + # If this is an intersect character that is overridden by the title. + elif width is True: + length -= 1 + # If title's last character is within a column. + elif width >= length: + columns_and_intersects[0] += horizontal * ( + width - length + ) # Append horizontal chars to title. + length = 0 + # If remainder of title won't fit in a column. + else: + length -= width + + return combine(columns_and_intersects, left, None, right) + + +def build_row(row, left, center, right): + """Combine single or multi-lined cells into a single row of list of lists including borders. + + Row must already be padded and extended so each cell has the same number of lines. + + Example return value: + [ + ['>', 'Left ', '|', 'Center', '|', 'Right', '<'], + ['>', 'Cell1', '|', 'Cell2 ', '|', 'Cell3', '<'], + ] + + :param iter row: List of cells for one row. + :param str left: Left border. + :param str center: Column separator. + :param str right: Right border. + + :return: Yields other generators that yield strings. + :rtype: iter + """ + if not row or not row[0]: + yield combine((), left, center, right) + return + for row_index in range(len(row[0])): + yield combine((c[row_index] for c in row), left, center, right) + + +def flatten(table): + """Flatten table data into a single string with newlines. + + :param iter table: Padded and bordered table data. + + :return: Joined rows/cells. + :rtype: str + """ + return "\n".join("".join(r) for r in table) diff --git a/terminaltables3/github_table.py b/terminaltables3/github_table.py new file mode 100644 index 0000000..a00d189 --- /dev/null +++ b/terminaltables3/github_table.py @@ -0,0 +1,73 @@ +"""GithubFlavoredMarkdownTable class.""" + +from typing import Sequence + +from terminaltables3.ascii_table import AsciiTable +from terminaltables3.build import combine + + +class GithubFlavoredMarkdownTable(AsciiTable): + """Github flavored markdown table. + + https://help.github.com/articles/github-flavored-markdown/#tables + + :ivar iter table_data: List (empty or list of lists of strings) representing the table. + :ivar dict justify_columns: Horizontal justification. Keys are column indexes (int). Values are right/left/center. + """ + + def __init__(self, table_data: Sequence[Sequence[str]]): + """Constructor. + + :param iter table_data: List (empty or list of lists of strings) representing the table. + """ + # Github flavored markdown table won't support title. + super().__init__(table_data) + + def horizontal_border(self, _, outer_widths): + """Handle the GitHub heading border. + + E.g.: + |:---|:---:|---:|----| + + :param _: Unused. + :param iter outer_widths: List of widths (with padding) for each column. + + :return: Prepared border strings in a generator. + :rtype: iter + """ + horizontal = str(self.CHAR_INNER_HORIZONTAL) + left = self.CHAR_OUTER_LEFT_VERTICAL + intersect = self.CHAR_INNER_VERTICAL + right = self.CHAR_OUTER_RIGHT_VERTICAL + + columns = [] + for i, width in enumerate(outer_widths): + justify = self.justify_columns.get(i) + width = max( + 3, width + ) # Width should be at least 3 so justification can be applied. + if justify == "left": + columns.append(":" + horizontal * (width - 1)) + elif justify == "right": + columns.append(horizontal * (width - 1) + ":") + elif justify == "center": + columns.append(":" + horizontal * (width - 2) + ":") + else: + columns.append(horizontal * width) + + return combine(columns, left, intersect, right) + + def gen_table(self, inner_widths, inner_heights, outer_widths): + """Combine everything and yield every line of the entire table with borders. + + :param iter inner_widths: List of widths (no padding) for each column. + :param iter inner_heights: List of heights (no padding) for each row. + :param iter outer_widths: List of widths (with padding) for each column. + :return: + """ + for i, row in enumerate(self.table_data): + # Yield the row line by line (e.g. multi-line rows). + yield from self.gen_row_lines(row, "row", inner_widths, inner_heights[i]) + # Yield heading separator. + if i == 0: + yield self.horizontal_border(None, outer_widths) diff --git a/terminaltables3/other_tables.py b/terminaltables3/other_tables.py new file mode 100644 index 0000000..9fb1b05 --- /dev/null +++ b/terminaltables3/other_tables.py @@ -0,0 +1,173 @@ +"""Additional simple tables defined here.""" + +from terminaltables3.ascii_table import AsciiTable +from terminaltables3.terminal_io import IS_WINDOWS + + +class UnixTable(AsciiTable): + """Draw a table using box-drawing characters on Unix platforms. Table borders won't have any gaps between lines. + + Similar to the tables shown on PC BIOS boot messages, but not double-lined. + """ + + CHAR_F_INNER_HORIZONTAL = "\033(0\x71\033(B" + CHAR_F_INNER_INTERSECT = "\033(0\x6e\033(B" + CHAR_F_INNER_VERTICAL = "\033(0\x78\033(B" + CHAR_F_OUTER_LEFT_INTERSECT = "\033(0\x74\033(B" + CHAR_F_OUTER_LEFT_VERTICAL = "\033(0\x78\033(B" + CHAR_F_OUTER_RIGHT_INTERSECT = "\033(0\x75\033(B" + CHAR_F_OUTER_RIGHT_VERTICAL = "\033(0\x78\033(B" + CHAR_H_INNER_HORIZONTAL = "\033(0\x71\033(B" + CHAR_H_INNER_INTERSECT = "\033(0\x6e\033(B" + CHAR_H_INNER_VERTICAL = "\033(0\x78\033(B" + CHAR_H_OUTER_LEFT_INTERSECT = "\033(0\x74\033(B" + CHAR_H_OUTER_LEFT_VERTICAL = "\033(0\x78\033(B" + CHAR_H_OUTER_RIGHT_INTERSECT = "\033(0\x75\033(B" + CHAR_H_OUTER_RIGHT_VERTICAL = "\033(0\x78\033(B" + CHAR_INNER_HORIZONTAL = "\033(0\x71\033(B" + CHAR_INNER_INTERSECT = "\033(0\x6e\033(B" + CHAR_INNER_VERTICAL = "\033(0\x78\033(B" + CHAR_OUTER_BOTTOM_HORIZONTAL = "\033(0\x71\033(B" + CHAR_OUTER_BOTTOM_INTERSECT = "\033(0\x76\033(B" + CHAR_OUTER_BOTTOM_LEFT = "\033(0\x6d\033(B" + CHAR_OUTER_BOTTOM_RIGHT = "\033(0\x6a\033(B" + CHAR_OUTER_LEFT_INTERSECT = "\033(0\x74\033(B" + CHAR_OUTER_LEFT_VERTICAL = "\033(0\x78\033(B" + CHAR_OUTER_RIGHT_INTERSECT = "\033(0\x75\033(B" + CHAR_OUTER_RIGHT_VERTICAL = "\033(0\x78\033(B" + CHAR_OUTER_TOP_HORIZONTAL = "\033(0\x71\033(B" + CHAR_OUTER_TOP_INTERSECT = "\033(0\x77\033(B" + CHAR_OUTER_TOP_LEFT = "\033(0\x6c\033(B" + CHAR_OUTER_TOP_RIGHT = "\033(0\x6b\033(B" + + @property + def table(self): + """Return a large string of the entire table ready to be printed to the terminal.""" + ascii_table = super().table + optimized = ascii_table.replace("\033(B\033(0", "") + return optimized + + +class WindowsTable(AsciiTable): + """Draw a table using box-drawing characters on Windows platforms. This uses Code Page 437. Single-line borders. + + From: http://en.wikipedia.org/wiki/Code_page_437#Characters + """ + + CHAR_F_INNER_HORIZONTAL = b"\xc4".decode("ibm437") + CHAR_F_INNER_INTERSECT = b"\xc5".decode("ibm437") + CHAR_F_INNER_VERTICAL = b"\xb3".decode("ibm437") + CHAR_F_OUTER_LEFT_INTERSECT = b"\xc3".decode("ibm437") + CHAR_F_OUTER_LEFT_VERTICAL = b"\xb3".decode("ibm437") + CHAR_F_OUTER_RIGHT_INTERSECT = b"\xb4".decode("ibm437") + CHAR_F_OUTER_RIGHT_VERTICAL = b"\xb3".decode("ibm437") + CHAR_H_INNER_HORIZONTAL = b"\xc4".decode("ibm437") + CHAR_H_INNER_INTERSECT = b"\xc5".decode("ibm437") + CHAR_H_INNER_VERTICAL = b"\xb3".decode("ibm437") + CHAR_H_OUTER_LEFT_INTERSECT = b"\xc3".decode("ibm437") + CHAR_H_OUTER_LEFT_VERTICAL = b"\xb3".decode("ibm437") + CHAR_H_OUTER_RIGHT_INTERSECT = b"\xb4".decode("ibm437") + CHAR_H_OUTER_RIGHT_VERTICAL = b"\xb3".decode("ibm437") + CHAR_INNER_HORIZONTAL = b"\xc4".decode("ibm437") + CHAR_INNER_INTERSECT = b"\xc5".decode("ibm437") + CHAR_INNER_VERTICAL = b"\xb3".decode("ibm437") + CHAR_OUTER_BOTTOM_HORIZONTAL = b"\xc4".decode("ibm437") + CHAR_OUTER_BOTTOM_INTERSECT = b"\xc1".decode("ibm437") + CHAR_OUTER_BOTTOM_LEFT = b"\xc0".decode("ibm437") + CHAR_OUTER_BOTTOM_RIGHT = b"\xd9".decode("ibm437") + CHAR_OUTER_LEFT_INTERSECT = b"\xc3".decode("ibm437") + CHAR_OUTER_LEFT_VERTICAL = b"\xb3".decode("ibm437") + CHAR_OUTER_RIGHT_INTERSECT = b"\xb4".decode("ibm437") + CHAR_OUTER_RIGHT_VERTICAL = b"\xb3".decode("ibm437") + CHAR_OUTER_TOP_HORIZONTAL = b"\xc4".decode("ibm437") + CHAR_OUTER_TOP_INTERSECT = b"\xc2".decode("ibm437") + CHAR_OUTER_TOP_LEFT = b"\xda".decode("ibm437") + CHAR_OUTER_TOP_RIGHT = b"\xbf".decode("ibm437") + + +class WindowsTableDouble(AsciiTable): + """Draw a table using box-drawing characters on Windows platforms. This uses Code Page 437. Double-line borders.""" + + CHAR_F_INNER_HORIZONTAL = b"\xcd".decode("ibm437") + CHAR_F_INNER_INTERSECT = b"\xce".decode("ibm437") + CHAR_F_INNER_VERTICAL = b"\xba".decode("ibm437") + CHAR_F_OUTER_LEFT_INTERSECT = b"\xcc".decode("ibm437") + CHAR_F_OUTER_LEFT_VERTICAL = b"\xba".decode("ibm437") + CHAR_F_OUTER_RIGHT_INTERSECT = b"\xb9".decode("ibm437") + CHAR_F_OUTER_RIGHT_VERTICAL = b"\xba".decode("ibm437") + CHAR_H_INNER_HORIZONTAL = b"\xcd".decode("ibm437") + CHAR_H_INNER_INTERSECT = b"\xce".decode("ibm437") + CHAR_H_INNER_VERTICAL = b"\xba".decode("ibm437") + CHAR_H_OUTER_LEFT_INTERSECT = b"\xcc".decode("ibm437") + CHAR_H_OUTER_LEFT_VERTICAL = b"\xba".decode("ibm437") + CHAR_H_OUTER_RIGHT_INTERSECT = b"\xb9".decode("ibm437") + CHAR_H_OUTER_RIGHT_VERTICAL = b"\xba".decode("ibm437") + CHAR_INNER_HORIZONTAL = b"\xcd".decode("ibm437") + CHAR_INNER_INTERSECT = b"\xce".decode("ibm437") + CHAR_INNER_VERTICAL = b"\xba".decode("ibm437") + CHAR_OUTER_BOTTOM_HORIZONTAL = b"\xcd".decode("ibm437") + CHAR_OUTER_BOTTOM_INTERSECT = b"\xca".decode("ibm437") + CHAR_OUTER_BOTTOM_LEFT = b"\xc8".decode("ibm437") + CHAR_OUTER_BOTTOM_RIGHT = b"\xbc".decode("ibm437") + CHAR_OUTER_LEFT_INTERSECT = b"\xcc".decode("ibm437") + CHAR_OUTER_LEFT_VERTICAL = b"\xba".decode("ibm437") + CHAR_OUTER_RIGHT_INTERSECT = b"\xb9".decode("ibm437") + CHAR_OUTER_RIGHT_VERTICAL = b"\xba".decode("ibm437") + CHAR_OUTER_TOP_HORIZONTAL = b"\xcd".decode("ibm437") + CHAR_OUTER_TOP_INTERSECT = b"\xcb".decode("ibm437") + CHAR_OUTER_TOP_LEFT = b"\xc9".decode("ibm437") + CHAR_OUTER_TOP_RIGHT = b"\xbb".decode("ibm437") + + +class SingleTable(WindowsTable if IS_WINDOWS else UnixTable): + """Cross-platform table with single-line box-drawing characters. + + :ivar iter table_data: List (empty or list of lists of strings) representing the table. + :ivar str title: Optional title to show within the top border of the table. + :ivar bool inner_column_border: Separates columns. + :ivar bool inner_footing_row_border: Show a border before the last row. + :ivar bool inner_heading_row_border: Show a border after the first row. + :ivar bool inner_row_border: Show a border in between every row. + :ivar bool outer_border: Show the top, left, right, and bottom border. + :ivar dict justify_columns: Horizontal justification. Keys are column indexes (int). Values are right/left/center. + :ivar int padding_left: Number of spaces to pad on the left side of every cell. + :ivar int padding_right: Number of spaces to pad on the right side of every cell. + """ + + +class DoubleTable(WindowsTableDouble): + """Cross-platform table with box-drawing characters. On Windows it's double borders, on Linux/OSX it's unicode. + + :ivar iter table_data: List (empty or list of lists of strings) representing the table. + :ivar str title: Optional title to show within the top border of the table. + :ivar bool inner_column_border: Separates columns. + :ivar bool inner_footing_row_border: Show a border before the last row. + :ivar bool inner_heading_row_border: Show a border after the first row. + :ivar bool inner_row_border: Show a border in between every row. + :ivar bool outer_border: Show the top, left, right, and bottom border. + :ivar dict justify_columns: Horizontal justification. Keys are column indexes (int). Values are right/left/center. + :ivar int padding_left: Number of spaces to pad on the left side of every cell. + :ivar int padding_right: Number of spaces to pad on the right side of every cell. + """ + + +class PorcelainTable(AsciiTable): + """An AsciiTable stripped to a minimum. + + Meant to be machine passable and roughly follow format set by git --porcelain option (hence the name). + + :ivar iter table_data: List (empty or list of lists of strings) representing the table. + """ + + def __init__(self, table_data): + """Constructor. + + :param iter table_data: List (empty or list of lists of strings) representing the table. + """ + # Porcelain table won't support title since it has no outer birders. + super().__init__(table_data) + + # Removes outer border, and inner footing and header row borders. + self.inner_footing_row_border = False + self.inner_heading_row_border = False + self.outer_border = False diff --git a/terminaltables3/terminal_io.py b/terminaltables3/terminal_io.py new file mode 100644 index 0000000..dc04431 --- /dev/null +++ b/terminaltables3/terminal_io.py @@ -0,0 +1,102 @@ +"""Get info about the current terminal window/screen buffer.""" + +import ctypes +import struct +import sys +from typing import Tuple, Union + +DEFAULT_HEIGHT = 24 +DEFAULT_WIDTH = 79 +INVALID_HANDLE_VALUE = -1 +IS_WINDOWS = sys.platform == "win32" +STD_ERROR_HANDLE = -12 +STD_OUTPUT_HANDLE = -11 + + +def get_console_info(kernel32, handle: int) -> Tuple[int, int]: + """Get information about this current console window (Windows only). + + https://github.com/Robpol86/colorclass/blob/ab42da59/colorclass/windows.py#L111 + + :raise OSError: When handle is invalid or GetConsoleScreenBufferInfo API call fails. + + :param ctypes.windll.kernel32 kernel32: Loaded kernel32 instance. + :param int handle: stderr or stdout handle. + + :return: Width (number of characters) and height (number of lines) of the terminal. + :rtype: tuple + """ + if handle == INVALID_HANDLE_VALUE: + raise OSError("Invalid handle.") + + # Query Win32 API. + lpcsbi = ctypes.create_string_buffer(22) # Populated by GetConsoleScreenBufferInfo. + if not kernel32.GetConsoleScreenBufferInfo(handle, lpcsbi): + raise ctypes.WinError() # Subclass of OSError. + + # Parse data. + left, top, right, bottom = struct.unpack("hhhhHhhhhhh", lpcsbi.raw)[5:-2] + width, height = right - left, bottom - top + return width, height + + +def terminal_size(kernel32=None) -> Tuple[int, int]: + """Get the width and height of the terminal. + + http://code.activestate.com/recipes/440694-determine-size-of-console-window-on-windows/ + http://stackoverflow.com/questions/17993814/why-the-irrelevant-code-made-a-difference + + :param kernel32: Optional mock kernel32 object. For testing. + + :return: Width (number of characters) and height (number of lines) of the terminal. + :rtype: tuple + """ + if IS_WINDOWS: + kernel32 = kernel32 or ctypes.windll.kernel32 + try: + return get_console_info(kernel32, kernel32.GetStdHandle(STD_ERROR_HANDLE)) + except OSError: + try: + return get_console_info( + kernel32, kernel32.GetStdHandle(STD_OUTPUT_HANDLE) + ) + except OSError: + return DEFAULT_WIDTH, DEFAULT_HEIGHT + + try: + device = __import__("fcntl").ioctl( + 0, __import__("termios").TIOCGWINSZ, "\0\0\0\0\0\0\0\0" + ) + except OSError: + return DEFAULT_WIDTH, DEFAULT_HEIGHT + height, width = struct.unpack("hhhh", device)[:2] + return width, height + + +def set_terminal_title(title: Union[str, bytes], kernel32=None) -> bool: + """Set the terminal title. + + :param title: The title to set (string, unicode, bytes accepted). + :param kernel32: Optional mock kernel32 object. For testing. + + :return: If title changed successfully (Windows only, always True on Linux/OSX). + :rtype: bool + """ + try: + title_bytes = title.encode("utf-8") + except AttributeError: + title_bytes = title + + if IS_WINDOWS: + kernel32 = kernel32 or ctypes.windll.kernel32 + try: + is_ascii = all(ord(c) < 128 for c in title) # str/unicode. + except TypeError: + is_ascii = all(c < 128 for c in title) # bytes. + if is_ascii: + return kernel32.SetConsoleTitleA(title_bytes) != 0 + return kernel32.SetConsoleTitleW(title) != 0 + + # Linux/OSX. + sys.stdout.write(b"\033]0;" + title_bytes + b"\007") + return True diff --git a/terminaltables3/width_and_alignment.py b/terminaltables3/width_and_alignment.py new file mode 100644 index 0000000..6f7d807 --- /dev/null +++ b/terminaltables3/width_and_alignment.py @@ -0,0 +1,196 @@ +"""Functions that handle alignment, padding, widths, etc.""" + +import re +import unicodedata +from typing import Sequence, Tuple + +from terminaltables3.terminal_io import terminal_size + +RE_COLOR_ANSI = re.compile(r"(\033\[[\d;]+m)") + + +def visible_width(string: str) -> int: + """Get the visible width of a unicode string. + + Some CJK unicode characters are more than one byte unlike ASCII and latin unicode characters. + + From: https://github.com/Robpol86/terminaltables3/pull/9 + + :param str string: String to measure. + + :return: String's width. + :rtype: int + """ + if "\033" in string: + string = RE_COLOR_ANSI.sub("", string) + + # Convert to unicode. + try: + string = string.decode("u8") + except (AttributeError, UnicodeEncodeError): + pass + + width = 0 + for char in string: + if unicodedata.east_asian_width(char) in ("F", "W"): + width += 2 + else: + width += 1 + + return width + + +def align_and_pad_cell( + string: str, + align: Tuple, + inner_dimensions: Tuple, + padding: Sequence[int], + space: str = " ", +) -> list[str]: + """Align a string horizontally and vertically. Also add additional padding in both dimensions. + + :param str string: Input string to operate on. + :param tuple align: Tuple that contains one of left/center/right and/or top/middle/bottom. + :param tuple inner_dimensions: Width and height ints to expand string to without padding. + :param iter padding: Number of space chars for left, right, top, and bottom (4 ints). + :param str space: Character to use as white space for resizing/padding (use single visible chars only). + + :return: Padded cell split into lines. + :rtype: list + """ + if not hasattr(string, "splitlines"): + string = str(string) + + # Handle trailing newlines or empty strings, str.splitlines() does not satisfy. + lines = string.splitlines() or [""] + if string.endswith("\n"): + lines.append("") + + # Vertically align and pad. + if "bottom" in align: + lines = ( + ([""] * (inner_dimensions[1] - len(lines) + padding[2])) + + lines + + ([""] * padding[3]) + ) + elif "middle" in align: + delta = inner_dimensions[1] - len(lines) + lines = ( + ([""] * (delta // 2 + delta % 2 + padding[2])) + + lines + + ([""] * (delta // 2 + padding[3])) + ) + else: + lines = ( + ([""] * padding[2]) + + lines + + ([""] * (inner_dimensions[1] - len(lines) + padding[3])) + ) + + # Horizontally align and pad. + for i, line in enumerate(lines): + new_width = inner_dimensions[0] + len(line) - visible_width(line) + if "right" in align: + lines[i] = line.rjust(padding[0] + new_width, space) + (space * padding[1]) + elif "center" in align: + lines[i] = ( + (space * padding[0]) + + line.center(new_width, space) + + (space * padding[1]) + ) + else: + lines[i] = (space * padding[0]) + line.ljust(new_width + padding[1], space) + + return lines + + +def max_dimensions( + table_data, padding_left=0, padding_right=0, padding_top=0, padding_bottom=0 +): + """Get maximum widths of each column and maximum height of each row. + + :param iter table_data: List of list of strings (unmodified table data). + :param int padding_left: Number of space chars on left side of cell. + :param int padding_right: Number of space chars on right side of cell. + :param int padding_top: Number of empty lines on top side of cell. + :param int padding_bottom: Number of empty lines on bottom side of cell. + + :return: 4-item tuple of n-item lists. Inner column widths and row heights, outer column widths and row heights. + :rtype: tuple + """ + inner_widths = [0] * (max(len(r) for r in table_data) if table_data else 0) + inner_heights = [0] * len(table_data) + + # Find max width and heights. + for j, row in enumerate(table_data): + for i, cell in enumerate(row): + if not hasattr(cell, "count") or not hasattr(cell, "splitlines"): + cell = str(cell) + if not cell: + continue + inner_heights[j] = max(inner_heights[j], cell.count("\n") + 1) + inner_widths[i] = max( + inner_widths[i], + *[visible_width(the_line) for the_line in cell.splitlines()] + ) + + # Calculate with padding. + outer_widths = [padding_left + i + padding_right for i in inner_widths] + outer_heights = [padding_top + i + padding_bottom for i in inner_heights] + + return inner_widths, inner_heights, outer_widths, outer_heights + + +def column_max_width( + inner_widths: Sequence[int], + column_number: int, + outer_border: int, + inner_border: int, + padding: int, +) -> int: + """Determine the maximum width of a column based on the current terminal width. + + :param iter inner_widths: List of widths (no padding) for each column. + :param int column_number: The column number to query. + :param int outer_border: Sum of left and right outer border visible widths. + :param int inner_border: Visible width of the inner border character. + :param int padding: Total padding per cell (left + right padding). + + :return: The maximum width the column can be without causing line wrapping. + """ + column_count = len(inner_widths) + terminal_width = terminal_size()[0] + + # Count how much space padding, outer, and inner borders take up. + non_data_space = outer_border + non_data_space += inner_border * (column_count - 1) + non_data_space += column_count * padding + + # Exclude selected column's width. + data_space = sum(inner_widths) - inner_widths[column_number] + + return terminal_width - data_space - non_data_space + + +def table_width( + outer_widths: Sequence[int], outer_border: int, inner_border: int +) -> int: + """Determine the width of the entire table including borders and padding. + + :param iter outer_widths: List of widths (with padding) for each column. + :param int outer_border: Sum of left and right outer border visible widths. + :param int inner_border: Visible width of the inner border character. + + :return: The width of the table. + :rtype: int + """ + column_count = len(outer_widths) + + # Count how much space outer and inner borders take up. + non_data_space = outer_border + if column_count: + non_data_space += inner_border * (column_count - 1) + + # Space of all columns and their padding. + data_space = sum(outer_widths) + return data_space + non_data_space |