| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341 |
- # Human friendly input/output in Python.
- #
- # Author: Peter Odding <peter@peterodding.com>
- # Last Change: February 16, 2020
- # URL: https://humanfriendly.readthedocs.io
- """
- Functions that render ASCII tables.
- Some generic notes about the table formatting functions in this module:
- - These functions were not written with performance in mind (*at all*) because
- they're intended to format tabular data to be presented on a terminal. If
- someone were to run into a performance problem using these functions, they'd
- be printing so much tabular data to the terminal that a human wouldn't be
- able to digest the tabular data anyway, so the point is moot :-).
- - These functions ignore ANSI escape sequences (at least the ones generated by
- the :mod:`~humanfriendly.terminal` module) in the calculation of columns
- widths. On reason for this is that column names are highlighted in color when
- connected to a terminal. It also means that you can use ANSI escape sequences
- to highlight certain column's values if you feel like it (for example to
- highlight deviations from the norm in an overview of calculated values).
- """
- # Standard library modules.
- import collections
- import re
- # Modules included in our package.
- from humanfriendly.compat import coerce_string
- from humanfriendly.terminal import (
- ansi_strip,
- ansi_width,
- ansi_wrap,
- terminal_supports_colors,
- find_terminal_size,
- HIGHLIGHT_COLOR,
- )
- # Public identifiers that require documentation.
- __all__ = (
- 'format_pretty_table',
- 'format_robust_table',
- 'format_rst_table',
- 'format_smart_table',
- )
- # Compiled regular expression pattern to recognize table columns containing
- # numeric data (integer and/or floating point numbers). Used to right-align the
- # contents of such columns.
- #
- # Pre-emptive snarky comment: This pattern doesn't match every possible
- # floating point number notation!?!1!1
- #
- # Response: I know, that's intentional. The use of this regular expression
- # pattern has a very high DWIM level and weird floating point notations do not
- # fall under the DWIM umbrella :-).
- NUMERIC_DATA_PATTERN = re.compile(r'^\d+(\.\d+)?$')
- def format_smart_table(data, column_names):
- """
- Render tabular data using the most appropriate representation.
- :param data: An iterable (e.g. a :func:`tuple` or :class:`list`)
- containing the rows of the table, where each row is an
- iterable containing the columns of the table (strings).
- :param column_names: An iterable of column names (strings).
- :returns: The rendered table (a string).
- If you want an easy way to render tabular data on a terminal in a human
- friendly format then this function is for you! It works as follows:
- - If the input data doesn't contain any line breaks the function
- :func:`format_pretty_table()` is used to render a pretty table. If the
- resulting table fits in the terminal without wrapping the rendered pretty
- table is returned.
- - If the input data does contain line breaks or if a pretty table would
- wrap (given the width of the terminal) then the function
- :func:`format_robust_table()` is used to render a more robust table that
- can deal with data containing line breaks and long text.
- """
- # Normalize the input in case we fall back from a pretty table to a robust
- # table (in which case we'll definitely iterate the input more than once).
- data = [normalize_columns(r) for r in data]
- column_names = normalize_columns(column_names)
- # Make sure the input data doesn't contain any line breaks (because pretty
- # tables break horribly when a column's text contains a line break :-).
- if not any(any('\n' in c for c in r) for r in data):
- # Render a pretty table.
- pretty_table = format_pretty_table(data, column_names)
- # Check if the pretty table fits in the terminal.
- table_width = max(map(ansi_width, pretty_table.splitlines()))
- num_rows, num_columns = find_terminal_size()
- if table_width <= num_columns:
- # The pretty table fits in the terminal without wrapping!
- return pretty_table
- # Fall back to a robust table when a pretty table won't work.
- return format_robust_table(data, column_names)
- def format_pretty_table(data, column_names=None, horizontal_bar='-', vertical_bar='|'):
- """
- Render a table using characters like dashes and vertical bars to emulate borders.
- :param data: An iterable (e.g. a :func:`tuple` or :class:`list`)
- containing the rows of the table, where each row is an
- iterable containing the columns of the table (strings).
- :param column_names: An iterable of column names (strings).
- :param horizontal_bar: The character used to represent a horizontal bar (a
- string).
- :param vertical_bar: The character used to represent a vertical bar (a
- string).
- :returns: The rendered table (a string).
- Here's an example:
- >>> from humanfriendly.tables import format_pretty_table
- >>> column_names = ['Version', 'Uploaded on', 'Downloads']
- >>> humanfriendly_releases = [
- ... ['1.23', '2015-05-25', '218'],
- ... ['1.23.1', '2015-05-26', '1354'],
- ... ['1.24', '2015-05-26', '223'],
- ... ['1.25', '2015-05-26', '4319'],
- ... ['1.25.1', '2015-06-02', '197'],
- ... ]
- >>> print(format_pretty_table(humanfriendly_releases, column_names))
- -------------------------------------
- | Version | Uploaded on | Downloads |
- -------------------------------------
- | 1.23 | 2015-05-25 | 218 |
- | 1.23.1 | 2015-05-26 | 1354 |
- | 1.24 | 2015-05-26 | 223 |
- | 1.25 | 2015-05-26 | 4319 |
- | 1.25.1 | 2015-06-02 | 197 |
- -------------------------------------
- Notes about the resulting table:
- - If a column contains numeric data (integer and/or floating point
- numbers) in all rows (ignoring column names of course) then the content
- of that column is right-aligned, as can be seen in the example above. The
- idea here is to make it easier to compare the numbers in different
- columns to each other.
- - The column names are highlighted in color so they stand out a bit more
- (see also :data:`.HIGHLIGHT_COLOR`). The following screen shot shows what
- that looks like (my terminals are always set to white text on a black
- background):
- .. image:: images/pretty-table.png
- """
- # Normalize the input because we'll have to iterate it more than once.
- data = [normalize_columns(r, expandtabs=True) for r in data]
- if column_names is not None:
- column_names = normalize_columns(column_names)
- if column_names:
- if terminal_supports_colors():
- column_names = [highlight_column_name(n) for n in column_names]
- data.insert(0, column_names)
- # Calculate the maximum width of each column.
- widths = collections.defaultdict(int)
- numeric_data = collections.defaultdict(list)
- for row_index, row in enumerate(data):
- for column_index, column in enumerate(row):
- widths[column_index] = max(widths[column_index], ansi_width(column))
- if not (column_names and row_index == 0):
- numeric_data[column_index].append(bool(NUMERIC_DATA_PATTERN.match(ansi_strip(column))))
- # Create a horizontal bar of dashes as a delimiter.
- line_delimiter = horizontal_bar * (sum(widths.values()) + len(widths) * 3 + 1)
- # Start the table with a vertical bar.
- lines = [line_delimiter]
- # Format the rows and columns.
- for row_index, row in enumerate(data):
- line = [vertical_bar]
- for column_index, column in enumerate(row):
- padding = ' ' * (widths[column_index] - ansi_width(column))
- if all(numeric_data[column_index]):
- line.append(' ' + padding + column + ' ')
- else:
- line.append(' ' + column + padding + ' ')
- line.append(vertical_bar)
- lines.append(u''.join(line))
- if column_names and row_index == 0:
- lines.append(line_delimiter)
- # End the table with a vertical bar.
- lines.append(line_delimiter)
- # Join the lines, returning a single string.
- return u'\n'.join(lines)
- def format_robust_table(data, column_names):
- """
- Render tabular data with one column per line (allowing columns with line breaks).
- :param data: An iterable (e.g. a :func:`tuple` or :class:`list`)
- containing the rows of the table, where each row is an
- iterable containing the columns of the table (strings).
- :param column_names: An iterable of column names (strings).
- :returns: The rendered table (a string).
- Here's an example:
- >>> from humanfriendly.tables import format_robust_table
- >>> column_names = ['Version', 'Uploaded on', 'Downloads']
- >>> humanfriendly_releases = [
- ... ['1.23', '2015-05-25', '218'],
- ... ['1.23.1', '2015-05-26', '1354'],
- ... ['1.24', '2015-05-26', '223'],
- ... ['1.25', '2015-05-26', '4319'],
- ... ['1.25.1', '2015-06-02', '197'],
- ... ]
- >>> print(format_robust_table(humanfriendly_releases, column_names))
- -----------------------
- Version: 1.23
- Uploaded on: 2015-05-25
- Downloads: 218
- -----------------------
- Version: 1.23.1
- Uploaded on: 2015-05-26
- Downloads: 1354
- -----------------------
- Version: 1.24
- Uploaded on: 2015-05-26
- Downloads: 223
- -----------------------
- Version: 1.25
- Uploaded on: 2015-05-26
- Downloads: 4319
- -----------------------
- Version: 1.25.1
- Uploaded on: 2015-06-02
- Downloads: 197
- -----------------------
- The column names are highlighted in bold font and color so they stand out a
- bit more (see :data:`.HIGHLIGHT_COLOR`).
- """
- blocks = []
- column_names = ["%s:" % n for n in normalize_columns(column_names)]
- if terminal_supports_colors():
- column_names = [highlight_column_name(n) for n in column_names]
- # Convert each row into one or more `name: value' lines (one per column)
- # and group each `row of lines' into a block (i.e. rows become blocks).
- for row in data:
- lines = []
- for column_index, column_text in enumerate(normalize_columns(row)):
- stripped_column = column_text.strip()
- if '\n' not in stripped_column:
- # Columns without line breaks are formatted inline.
- lines.append("%s %s" % (column_names[column_index], stripped_column))
- else:
- # Columns with line breaks could very well contain indented
- # lines, so we'll put the column name on a separate line. This
- # way any indentation remains intact, and it's easier to
- # copy/paste the text.
- lines.append(column_names[column_index])
- lines.extend(column_text.rstrip().splitlines())
- blocks.append(lines)
- # Calculate the width of the row delimiter.
- num_rows, num_columns = find_terminal_size()
- longest_line = max(max(map(ansi_width, lines)) for lines in blocks)
- delimiter = u"\n%s\n" % ('-' * min(longest_line, num_columns))
- # Force a delimiter at the start and end of the table.
- blocks.insert(0, "")
- blocks.append("")
- # Embed the row delimiter between every two blocks.
- return delimiter.join(u"\n".join(b) for b in blocks).strip()
- def format_rst_table(data, column_names=None):
- """
- Render a table in reStructuredText_ format.
- :param data: An iterable (e.g. a :func:`tuple` or :class:`list`)
- containing the rows of the table, where each row is an
- iterable containing the columns of the table (strings).
- :param column_names: An iterable of column names (strings).
- :returns: The rendered table (a string).
- Here's an example:
- >>> from humanfriendly.tables import format_rst_table
- >>> column_names = ['Version', 'Uploaded on', 'Downloads']
- >>> humanfriendly_releases = [
- ... ['1.23', '2015-05-25', '218'],
- ... ['1.23.1', '2015-05-26', '1354'],
- ... ['1.24', '2015-05-26', '223'],
- ... ['1.25', '2015-05-26', '4319'],
- ... ['1.25.1', '2015-06-02', '197'],
- ... ]
- >>> print(format_rst_table(humanfriendly_releases, column_names))
- ======= =========== =========
- Version Uploaded on Downloads
- ======= =========== =========
- 1.23 2015-05-25 218
- 1.23.1 2015-05-26 1354
- 1.24 2015-05-26 223
- 1.25 2015-05-26 4319
- 1.25.1 2015-06-02 197
- ======= =========== =========
- .. _reStructuredText: https://en.wikipedia.org/wiki/ReStructuredText
- """
- data = [normalize_columns(r) for r in data]
- if column_names:
- data.insert(0, normalize_columns(column_names))
- # Calculate the maximum width of each column.
- widths = collections.defaultdict(int)
- for row in data:
- for index, column in enumerate(row):
- widths[index] = max(widths[index], len(column))
- # Pad the columns using whitespace.
- for row in data:
- for index, column in enumerate(row):
- if index < (len(row) - 1):
- row[index] = column.ljust(widths[index])
- # Add table markers.
- delimiter = ['=' * w for i, w in sorted(widths.items())]
- if column_names:
- data.insert(1, delimiter)
- data.insert(0, delimiter)
- data.append(delimiter)
- # Join the lines and columns together.
- return '\n'.join(' '.join(r) for r in data)
- def normalize_columns(row, expandtabs=False):
- results = []
- for value in row:
- text = coerce_string(value)
- if expandtabs:
- text = text.expandtabs()
- results.append(text)
- return results
- def highlight_column_name(name):
- return ansi_wrap(name, bold=True, color=HIGHLIGHT_COLOR)
|