Source code for armi.utils.tabulate

# Copyright 2024 TerraPower, LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

r"""Pretty-print tabular data.

This file started out as the MIT-licensed "tabulate". Though we have made, and will continue to
make, many arbitrary changes as we need. Thanks to the tabulate team.

https://github.com/astanin/python-tabulate

Usage
-----
The module provides just one function, `tabulate`, which takes a list of lists or other tabular data
type as the first argument, and outputs anicely-formatted plain-text table::

    >>> from armi.utils.tabulate import tabulate

    >>> table = [["Sun",696000,1989100000],["Earth",6371,5973.6],
    ...          ["Moon",1737,73.5],["Mars",3390,641.85]]

    >>> print(tabulate(table))
    -----  ------  -------------
    Sun    696000     1.9891e+09
    Earth    6371  5973.6
    Moon     1737    73.5
    Mars     3390   641.85
    -----  ------  -------------

The following tabular data types are supported:

- list of lists or another iterable of iterables
- list or another iterable of dicts (keys as columns)
- dict of iterables (keys as columns)
- list of dataclasses (field names as columns)
- two-dimensional NumPy array
- NumPy record arrays (names as columns)

Table headers
-------------
To print nice column headers, supply the second argument (`headers`):

  - `headers` can be an explicit list of column headers
  - if `headers="firstrow"`, then the first row of data is used
  - if `headers="keys"`, then dictionary keys or column indices are used

Otherwise a headerless table is produced.

If the number of headers is less than the number of columns, they are supposed to be names of
the last columns. This is consistent with the plain-text format of R::

    >>> print(tabulate([["sex","age"],["Alice","F",24],["Bob","M",19]],
    ...       headers="firstrow"))
           sex      age
    -----  -----  -----
    Alice  F         24
    Bob    M         19

Column and Headers alignment
----------------------------
`tabulate` tries to detect column types automatically, and aligns the values properly. By
default it aligns decimal points of the numbers (or flushes integer numbers to the right), and
flushes everything else to the left. Possible column alignments (`numAlign`, `strAlign`) are:
"right", "center", "left", "decimal" (only for `numAlign`), and None (to disable alignment).

`colGlobalAlign` allows for global alignment of columns, before any specific override from
    `colAlign`. Possible values are: None (defaults according to coltype), "right", "center",
    "decimal", "left".
`colAlign` allows for column-wise override starting from left-most column. Possible values are:
    "global" (no override), "right", "center", "decimal", "left".
`headersGlobalAlign` allows for global headers alignment, before any specific override from
    `headersAlign`. Possible values are: None (follow columns alignment), "right", "center",
    "left".
`headersAlign` allows for header-wise override starting from left-most given header. Possible
    values are: "global" (no override), "same" (follow column alignment), "right", "center",
    "left".

Note on intended behaviour: If there is no `data`, any column alignment argument is ignored. Hence,
in this case, header alignment cannot be inferred from column alignment.

Table formats
-------------
`intFmt` is a format specification used for columns which contain numeric data without a decimal
point. This can also be a list or tuple of format strings, one per column.

`floatFmt` is a format specification used for columns which contain numeric data with a decimal
point. This can also be a list or tuple of format strings, one per column.

`None` values are replaced with a `missingVal` string (like `floatFmt`, this can also be a list
of values for different columns)::

    >>> print(tabulate([["spam", 1, None],
    ...                 ["eggs", 42, 3.14],
    ...                 ["other", None, 2.7]], missingVal="?"))
    -----  --  ----
    spam    1  ?
    eggs   42  3.14
    other   ?  2.7
    -----  --  ----

Various plain-text table formats (`tableFmt`) are supported: 'plain', 'simple', 'grid', 'rst', and
`tsv`. Variable `tabulateFormats` contains the list of currently supported formats.

"plain" format doesn't use any pseudographics to draw tables, it separates columns with a double
space::

    >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]],
    ...                 ["strings", "numbers"], "plain"))
    strings      numbers
    spam         41.9999
    eggs        451

    >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], tableFmt="plain"))
    spam   41.9999
    eggs  451

"simple" format is like Pandoc simple_tables::

    >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]],
    ...                 ["strings", "numbers"], "simple"))
    strings      numbers
    ---------  ---------
    spam         41.9999
    eggs        451

    >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], tableFmt="simple"))
    ----  --------
    spam   41.9999
    eggs  451
    ----  --------

"grid" is similar to tables produced by Emacs table.el package or Pandoc grid_tables::

    >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]],
    ...                ["strings", "numbers"], "grid"))
    +-----------+-----------+
    | strings   |   numbers |
    +===========+===========+
    | spam      |   41.9999 |
    +-----------+-----------+
    | eggs      |  451      |
    +-----------+-----------+

    >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], tableFmt="grid"))
    +------+----------+
    | spam |  41.9999 |
    +------+----------+
    | eggs | 451      |
    +------+----------+

"rst" is like a simple table format from reStructuredText; please note that reStructuredText
accepts also "grid" tables::

    >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]],
    ...                ["strings", "numbers"], "rst"))
    =========  =========
    strings      numbers
    =========  =========
    spam         41.9999
    eggs        451
    =========  =========

    >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], tableFmt="rst"))
    ====  ========
    spam   41.9999
    eggs  451
    ====  ========

Number parsing
--------------
By default, anything which can be parsed as a number is a number. This ensures numbers represented
as strings are aligned properly. This can lead to weird results for particular strings such as
specific git SHAs e.g. "42992e1" will be parsed into the number 429920 and aligned as such.

To completely disable number parsing (and alignment), use `disableNumParse=True`. For more fine
grained control, a list column indices is used to disable number parsing only on those columns e.g.
`disableNumParse=[0, 2]` would disable number parsing only on the first and third columns.

Column Widths and Auto Line Wrapping
------------------------------------
Tabulate will, by default, set the width of each column to the length of the longest element in that
column. However, in situations where fields are expected to reasonably be too long to look good as a
single line, tabulate can help automate word wrapping long fields for you. Use the parameter
`maxcolwidth` to provide a list of maximal column widths::

    >>> print(tabulate( \
          [('1', 'John Smith', \
            'This is a rather long description that might look better if it is wrapped a bit')], \
          headers=("Issue Id", "Author", "Description"), \
          maxColWidths=[None, None, 30], \
          tableFmt="grid"  \
        ))
    +------------+------------+-------------------------------+
    |   Issue Id | Author     | Description                   |
    +============+============+===============================+
    |          1 | John Smith | This is a rather long         |
    |            |            | description that might look   |
    |            |            | better if it is wrapped a bit |
    +------------+------------+-------------------------------+

Header column width can be specified in a similar way using `maxheadercolwidth`.
"""
from collections import namedtuple
from collections.abc import Iterable, Sized
from functools import reduce, partial
from itertools import chain, zip_longest
from textwrap import TextWrapper
import dataclasses
import math
import re

from armi import runLog

__all__ = ["tabulate", "tabulateFormats"]


# minimum extra space in headers
MIN_PADDING = 2

# Whether or not to preserve leading/trailing whitespace in data.
PRESERVE_WHITESPACE = False

_DEFAULT_FLOAT_FMT = "g"
_DEFAULT_INT_FMT = ""
_DEFAULT_MISSING_VAL = ""
# default align will be overwritten by "left", "center" or "decimal" depending on the formatter
_DEFAULT_ALIGN = "default"

# Constant that can be used as part of passed rows to generate a separating line. It is purposely an
# unprintable character, very unlikely to be used in a table
SEPARATING_LINE = "\001"

Line = namedtuple("Line", ["begin", "hline", "sep", "end"])
DataRow = namedtuple("DataRow", ["begin", "sep", "end"])

# A table structure is supposed to be:
#
#     --- lineabove ---------
#         headerrow
#     --- linebelowheader ---
#         datarow
#     --- linebetweenrows ---
#     ... (more datarows) ...
#     --- linebetweenrows ---
#         last datarow
#     --- linebelow ---------
#
# TableFormat's line* elements can be
#
#   - either None, if the element is not used,
#   - or a Line tuple,
#   - or a function: [col_widths], [col_alignments] -> string.
#
# TableFormat's *row elements can be
#
#   - either None, if the element is not used,
#   - or a DataRow tuple,
#   - or a function: [cell_values], [col_widths], [col_alignments] -> string.
#
# padding (an integer) is the amount of white space around data values.
#
# withHeaderHide:
#
#   - either None, to display all table elements unconditionally,
#   - or a list of elements not to be displayed if the table has column headers.
#
TableFormat = namedtuple(
    "TableFormat",
    [
        "lineabove",
        "linebelowheader",
        "linebetweenrows",
        "linebelow",
        "headerrow",
        "datarow",
        "padding",
        "withHeaderHide",
    ],
)


def _isSeparatingLine(row):
    rowType = type(row)
    isSl = (rowType is list or rowType is str) and (
        (len(row) >= 1 and row[0] == SEPARATING_LINE)
        or (len(row) >= 2 and row[1] == SEPARATING_LINE)
    )
    return isSl


def _rstEscapeFirstColumn(rows, headers):
    def escapeEmpty(val):
        if isinstance(val, (str, bytes)) and not val.strip():
            return ".."
        else:
            return val

    newHeaders = list(headers)
    newRows = []
    if headers:
        newHeaders[0] = escapeEmpty(headers[0])
    for row in rows:
        newRow = list(row)
        if newRow:
            newRow[0] = escapeEmpty(row[0])
        newRows.append(newRow)
    return newRows, newHeaders


_tableFormats = {
    "armi": TableFormat(
        lineabove=Line("", "-", "  ", ""),
        linebelowheader=Line("", "-", "  ", ""),
        linebetweenrows=None,
        linebelow=Line("", "-", "  ", ""),
        headerrow=DataRow("", "  ", ""),
        datarow=DataRow("", "  ", ""),
        padding=0,
        withHeaderHide=None,
    ),
    "simple": TableFormat(
        lineabove=Line("", "-", "  ", ""),
        linebelowheader=Line("", "-", "  ", ""),
        linebetweenrows=None,
        linebelow=Line("", "-", "  ", ""),
        headerrow=DataRow("", "  ", ""),
        datarow=DataRow("", "  ", ""),
        padding=0,
        withHeaderHide=["lineabove", "linebelow"],
    ),
    "plain": TableFormat(
        lineabove=None,
        linebelowheader=None,
        linebetweenrows=None,
        linebelow=None,
        headerrow=DataRow("", "  ", ""),
        datarow=DataRow("", "  ", ""),
        padding=0,
        withHeaderHide=None,
    ),
    "grid": TableFormat(
        lineabove=Line("+", "-", "+", "+"),
        linebelowheader=Line("+", "=", "+", "+"),
        linebetweenrows=Line("+", "-", "+", "+"),
        linebelow=Line("+", "-", "+", "+"),
        headerrow=DataRow("|", "|", "|"),
        datarow=DataRow("|", "|", "|"),
        padding=1,
        withHeaderHide=None,
    ),
    "github": TableFormat(
        lineabove=Line("|", "-", "|", "|"),
        linebelowheader=Line("|", "-", "|", "|"),
        linebetweenrows=None,
        linebelow=None,
        headerrow=DataRow("|", "|", "|"),
        datarow=DataRow("|", "|", "|"),
        padding=1,
        withHeaderHide=["lineabove"],
    ),
    "pretty": TableFormat(
        lineabove=Line("+", "-", "+", "+"),
        linebelowheader=Line("+", "-", "+", "+"),
        linebetweenrows=None,
        linebelow=Line("+", "-", "+", "+"),
        headerrow=DataRow("|", "|", "|"),
        datarow=DataRow("|", "|", "|"),
        padding=1,
        withHeaderHide=None,
    ),
    "psql": TableFormat(
        lineabove=Line("+", "-", "+", "+"),
        linebelowheader=Line("|", "-", "+", "|"),
        linebetweenrows=None,
        linebelow=Line("+", "-", "+", "+"),
        headerrow=DataRow("|", "|", "|"),
        datarow=DataRow("|", "|", "|"),
        padding=1,
        withHeaderHide=None,
    ),
    "rst": TableFormat(
        lineabove=Line("", "=", "  ", ""),
        linebelowheader=Line("", "=", "  ", ""),
        linebetweenrows=None,
        linebelow=Line("", "=", "  ", ""),
        headerrow=DataRow("", "  ", ""),
        datarow=DataRow("", "  ", ""),
        padding=0,
        withHeaderHide=None,
    ),
    "tsv": TableFormat(
        lineabove=None,
        linebelowheader=None,
        linebetweenrows=None,
        linebelow=None,
        headerrow=DataRow("", "\t", ""),
        datarow=DataRow("", "\t", ""),
        padding=0,
        withHeaderHide=None,
    ),
}


tabulateFormats = list(sorted(_tableFormats.keys()))

# The table formats for which multiline cells will be folded into subsequent table rows. The key is
# the original format, the value is the format that will be used to represent it.
multilineFormats = {
    "armi": "armi",
    "plain": "plain",
    "simple": "simple",
    "grid": "grid",
    "pretty": "pretty",
    "psql": "psql",
    "rst": "rst",
}

_multilineCodes = re.compile(r"\r|\n|\r\n")
_multilineCodesBytes = re.compile(b"\r|\n|\r\n")

# Handle ANSI escape sequences for both control sequence introducer (CSI) and operating system
# command (OSC). Both of these begin with 0x1b (or octal 033), which will be shown below as ESC.
#
# CSI ANSI escape codes have the following format, defined in section 5.4 of ECMA-48:
#
# CSI: ESC followed by the '[' character (0x5b)
# Parameter Bytes: 0..n bytes in the range 0x30-0x3f
# Intermediate Bytes: 0..n bytes in the range 0x20-0x2f
# Final Byte: a single byte in the range 0x40-0x7e
#
# Also include the terminal hyperlink sequences as described here:
# https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda
#
# OSC 8 ; params ; uri ST display_text OSC 8 ;; ST
#
# Example: \x1b]8;;https://example.com\x5ctext to show\x1b]8;;\x5c
#
# Where:
# OSC: ESC followed by the ']' character (0x5d)
# params: 0..n optional key value pairs separated by ':' (e.g. foo=bar:baz=qux:abc=123)
# URI: the actual URI with protocol scheme (e.g. https://, file://, ftp://)
# ST: ESC followed by the '\' character (0x5c)
_esc = r"\x1b"
_csi = rf"{_esc}\["
_osc = rf"{_esc}\]"
_st = rf"{_esc}\\"

_ansiEscapePat = rf"""
    (
        # terminal colors, etc
        {_csi}        # CSI
        [\x30-\x3f]*  # parameter bytes
        [\x20-\x2f]*  # intermediate bytes
        [\x40-\x7e]   # final byte
    |
        # terminal hyperlinks
        {_osc}8;        # OSC opening
        (\w+=\w+:?)*    # key=value params list (submatch 2)
        ;               # delimiter
        ([^{_esc}]+)    # URI - anything but ESC (submatch 3)
        {_st}           # ST
        ([^{_esc}]+)    # link text - anything but ESC (submatch 4)
        {_osc}8;;{_st}  # "closing" OSC sequence
    )
"""
_ansiCodes = re.compile(_ansiEscapePat, re.VERBOSE)
_ansiCodesBytes = re.compile(_ansiEscapePat.encode("utf8"), re.VERBOSE)
_floatWithThousandsSeparators = re.compile(
    r"^(([+-]?[0-9]{1,3})(?:,([0-9]{3}))*)?(?(1)\.[0-9]*|\.[0-9]+)?$"
)


def _isnumberWithThousandsSeparator(string):
    """Function to test of a string is a number with a thousands separator.

    >>> _isnumberWithThousandsSeparator(".")
    False
    >>> _isnumberWithThousandsSeparator("1")
    True
    >>> _isnumberWithThousandsSeparator("1.")
    True
    >>> _isnumberWithThousandsSeparator(".1")
    True
    >>> _isnumberWithThousandsSeparator("1000")
    False
    >>> _isnumberWithThousandsSeparator("1,000")
    True
    >>> _isnumberWithThousandsSeparator("1,0000")
    False
    >>> _isnumberWithThousandsSeparator(b"1,000.1234")
    True
    >>> _isnumberWithThousandsSeparator("+1,000.1234")
    True
    >>> _isnumberWithThousandsSeparator("-1,000.1234")
    True
    """
    try:
        string = string.decode()
    except (UnicodeDecodeError, AttributeError):
        pass

    return bool(re.match(_floatWithThousandsSeparators, string))


def _isconvertible(conv, string):
    try:
        conv(string)
        return True
    except (ValueError, TypeError):
        return False


def _isnumber(string):
    """Helper function; is this string a number.

    >>> _isnumber("123.45")
    True
    >>> _isnumber("123")
    True
    >>> _isnumber("spam")
    False
    >>> _isnumber("123e45678")
    False
    >>> _isnumber("inf")
    True
    """
    if not _isconvertible(float, string):
        return False
    elif isinstance(string, (str, bytes)) and (
        math.isinf(float(string)) or math.isnan(float(string))
    ):
        return string.lower() in ["inf", "-inf", "nan"]
    return True


def _isint(string, inttype=int):
    """Determine if a string is an integer.

    >>> _isint("123")
    True
    >>> _isint("123.45")
    False
    """
    return (
        type(string) is inttype
        or (
            (hasattr(string, "is_integer") or hasattr(string, "__array__"))
            and str(type(string)).startswith("<class 'numpy.int")
        )  # numpy.int64 and similar
        or (
            isinstance(string, (bytes, str)) and _isconvertible(inttype, string)
        )  # integer as string
    )


def _isbool(string):
    """Test if a string is a boolean.

    >>> _isbool(True)
    True
    >>> _isbool("False")
    True
    >>> _isbool(1)
    False
    """
    return type(string) is bool or (
        isinstance(string, (bytes, str)) and string in ("True", "False")
    )


def _type(string, hasInvisible=True, numparse=True):
    r"""The least generic type (type(None), int, float, str, unicode).

    >>> _type(None) is type(None)
    True
    >>> _type("foo") is type("")
    True
    >>> _type("1") is type(1)
    True
    >>> _type('\x1b[31m42\x1b[0m') is type(42)
    True
    >>> _type('\x1b[31m42\x1b[0m') is type(42)
    True

    """
    if hasInvisible and isinstance(string, (str, bytes)):
        string = _stripAnsi(string)

    if string is None:
        return type(None)
    elif hasattr(string, "isoformat"):
        # datetime.datetime, date, and time
        return str
    elif _isbool(string):
        return bool
    elif _isint(string) and numparse:
        return int
    elif _isnumber(string) and numparse:
        return float
    elif isinstance(string, bytes):
        return bytes
    else:
        return str


def _afterpoint(string):
    """Symbols after a decimal point, -1 if the string lacks the decimal point.

    >>> _afterpoint("123.45")
    2
    >>> _afterpoint("1001")
    -1
    >>> _afterpoint("eggs")
    -1
    >>> _afterpoint("123e45")
    2
    >>> _afterpoint("123,456.78")
    2

    """
    if _isnumber(string) or _isnumberWithThousandsSeparator(string):
        if _isint(string):
            return -1
        else:
            pos = string.rfind(".")
            pos = string.lower().rfind("e") if pos < 0 else pos
            if pos >= 0:
                return len(string) - pos - 1
            else:
                # no point
                return -1
    else:
        # not a number
        return -1


def _padleft(width, s):
    r"""Flush right.

    >>> _padleft(6, '\u044f\u0439\u0446\u0430') == '  \u044f\u0439\u0446\u0430'
    True

    """
    fmt = "{0:>%ds}" % width
    return fmt.format(s)


def _padright(width, s):
    r"""Flush left.

    >>> _padright(6, '\u044f\u0439\u0446\u0430') == '\u044f\u0439\u0446\u0430  '
    True

    """
    fmt = "{0:<%ds}" % width
    return fmt.format(s)


def _padboth(width, s):
    r"""Center string.

    >>> _padboth(6, '\u044f\u0439\u0446\u0430') == ' \u044f\u0439\u0446\u0430 '
    True

    """
    fmt = "{0:^%ds}" % width
    return fmt.format(s)


def _padnone(ignoreWidth, s):
    return s


def _stripAnsi(s):
    r"""Remove ANSI escape sequences, both CSI and OSC hyperlinks.

    CSI sequences are simply removed from the output, while OSC hyperlinks are replaced with the
    link text. Note: it may be desirable to show the URI instead but this is not supported.

        >>> repr(_stripAnsi('\x1B]8;;https://example.com\x1B\\This is a link\x1B]8;;\x1B\\'))
        "'This is a link'"

        >>> repr(_stripAnsi('\x1b[31mred\x1b[0m text'))
        "'red text'"

    """
    if isinstance(s, str):
        return _ansiCodes.sub(r"\4", s)
    else:  # a bytestring
        return _ansiCodesBytes.sub(r"\4", s)


def _visibleWidth(s):
    r"""Visible width of a printed string.

    >>> _visibleWidth('\x1b[31mhello\x1b[0m'), _visibleWidth("world")
    (5, 5)

    """
    if isinstance(s, (str, bytes)):
        return len(_stripAnsi(s))
    else:
        return len(str(s))


def _isMultiline(s):
    if isinstance(s, str):
        return bool(re.search(_multilineCodes, s))
    else:
        # a bytestring
        return bool(re.search(_multilineCodesBytes, s))


def _multilineWidth(multilineS, lineWidthFn=len):
    """Visible width of a potentially multiline content."""
    return max(map(lineWidthFn, re.split("[\r\n]", multilineS)))


def _chooseWidthFn(hasInvisible, isMultiline):
    """Return a function to calculate visible cell width."""
    if hasInvisible:
        lineWidthFn = _visibleWidth
    else:
        lineWidthFn = len

    if isMultiline:
        widthFn = lambda s: _multilineWidth(s, lineWidthFn)
    else:
        widthFn = lineWidthFn

    return widthFn


def _alignColumnChoosePadfn(strings, alignment, hasInvisible):
    if alignment == "right":
        if not PRESERVE_WHITESPACE:
            strings = [s.strip() for s in strings]
        padfn = _padleft
    elif alignment == "center":
        if not PRESERVE_WHITESPACE:
            strings = [s.strip() for s in strings]
        padfn = _padboth
    elif alignment == "decimal":
        if hasInvisible:
            decimals = [_afterpoint(_stripAnsi(s)) for s in strings]
        else:
            decimals = [_afterpoint(s) for s in strings]
        maxdecimals = max(decimals)
        strings = [s + (maxdecimals - decs) * " " for s, decs in zip(strings, decimals)]
        padfn = _padleft
    elif not alignment:
        padfn = _padnone
    else:
        if not PRESERVE_WHITESPACE:
            strings = [s.strip() for s in strings]
        padfn = _padright
    return strings, padfn


def _alignColumnChooseWidthFn(hasInvisible, isMultiline):
    if hasInvisible:
        lineWidthFn = _visibleWidth
    else:
        lineWidthFn = len

    if isMultiline:
        widthFn = lambda s: _alignColumnMultilineWidth(s, lineWidthFn)
    else:
        widthFn = lineWidthFn

    return widthFn


def _alignColumnMultilineWidth(multilineS, lineWidthFn=len):
    """Visible width of a potentially multiline content."""
    return list(map(lineWidthFn, re.split("[\r\n]", multilineS)))


def _flatList(nestedList):
    ret = []
    for item in nestedList:
        if isinstance(item, list):
            for subitem in item:
                ret.append(subitem)
        else:
            ret.append(item)
    return ret


def _alignColumn(strings, alignment, minwidth=0, hasInvisible=True, isMultiline=False):
    """[string] -> [padded_string]."""
    strings, padfn = _alignColumnChoosePadfn(strings, alignment, hasInvisible)
    widthFn = _alignColumnChooseWidthFn(hasInvisible, isMultiline)

    sWidths = list(map(widthFn, strings))
    maxwidth = max(max(_flatList(sWidths)), minwidth)
    if isMultiline:
        if not hasInvisible:
            paddedStrings = [
                "\n".join([padfn(maxwidth, s) for s in ms.splitlines()])
                for ms in strings
            ]
        else:
            # enable wide-character width corrections
            sLens = [[len(s) for s in re.split("[\r\n]", ms)] for ms in strings]
            visibleWidths = [
                [maxwidth - (w - ll) for w, ll in zip(mw, ml)]
                for mw, ml in zip(sWidths, sLens)
            ]
            # wcswidth and _visibleWidth don't count invisible characters;
            # padfn doesn't need to apply another correction
            paddedStrings = [
                "\n".join([padfn(w, s) for s, w in zip((ms.splitlines() or ms), mw)])
                for ms, mw in zip(strings, visibleWidths)
            ]
    else:  # single-line cell values
        if not hasInvisible:
            paddedStrings = [padfn(maxwidth, s) for s in strings]
        else:
            # enable wide-character width corrections
            sLens = list(map(len, strings))
            visibleWidths = [maxwidth - (w - ll) for w, ll in zip(sWidths, sLens)]
            # wcswidth and _visibleWidth don't count invisible characters;
            # padfn doesn't need to apply another correction
            paddedStrings = [padfn(w, s) for s, w in zip(strings, visibleWidths)]

    return paddedStrings


def _moreGeneric(type1, type2):
    types = {
        type(None): 0,
        bool: 1,
        int: 2,
        float: 3,
        bytes: 4,
        str: 5,
    }
    invtypes = {
        5: str,
        4: bytes,
        3: float,
        2: int,
        1: bool,
        0: type(None),
    }
    moregeneric = max(types.get(type1, 5), types.get(type2, 5))
    return invtypes[moregeneric]


def _columnType(strings, hasInvisible=True, numparse=True):
    r"""The least generic type all column values are convertible to.

    >>> _columnType([True, False]) is bool
    True
    >>> _columnType(["1", "2"]) is int
    True
    >>> _columnType(["1", "2.3"]) is float
    True
    >>> _columnType(["1", "2.3", "four"]) is str
    True
    >>> _columnType(["four", '\u043f\u044f\u0442\u044c']) is str
    True
    >>> _columnType([None, "brux"]) is str
    True
    >>> _columnType([1, 2, None]) is int
    True
    >>> import datetime as dt
    >>> _columnType([dt.datetime(1991,2,19), dt.time(17,35)]) is str
    True

    """
    types = [_type(s, hasInvisible, numparse) for s in strings]
    return reduce(_moreGeneric, types, bool)


def _format(val, valtype, floatFmt, intFmt, missingVal="", hasInvisible=True):
    r"""Format a value according to its type.

    Unicode is supported::

        >>> hrow = ['\u0431\u0443\u043a\u0432\u0430', '\u0446\u0438\u0444\u0440\u0430'] ; \
            tbl = [['\u0430\u0437', 2], ['\u0431\u0443\u043a\u0438', 4]] ; \
            good_result = '\\u0431\\u0443\\u043a\\u0432\\u0430      \\u0446\\u0438\\u0444\\u0440\\u0430\\n-------  -------\\n\\u0430\\u0437             2\\n\\u0431\\u0443\\u043a\\u0438           4' ; \
            tabulate(tbl, headers=hrow) == good_result
        True

    """  # noqa
    if val is None:
        return missingVal

    if valtype is str:
        return f"{val}"
    elif valtype is int:
        return format(val, intFmt)
    elif valtype is bytes:
        try:
            return str(val, "ascii")
        except (TypeError, UnicodeDecodeError):
            return str(val)
    elif valtype is float:
        isAColoredNumber = hasInvisible and isinstance(val, (str, bytes))
        if isAColoredNumber:
            rawVal = _stripAnsi(val)
            formattedVal = format(float(rawVal), floatFmt)
            return val.replace(rawVal, formattedVal)
        else:
            return format(float(val), floatFmt)
    else:
        return f"{val}"


def _alignHeader(
    header, alignment, width, visibleWidth, isMultiline=False, widthFn=None
):
    """Pad string header to width chars given known visibleWidth of the header."""
    if isMultiline:
        headerLines = re.split(_multilineCodes, header)
        paddedLines = [
            _alignHeader(h, alignment, width, widthFn(h)) for h in headerLines
        ]
        return "\n".join(paddedLines)
    # else: not multiline
    ninvisible = len(header) - visibleWidth
    width += ninvisible
    if alignment == "left":
        return _padright(width, header)
    elif alignment == "center":
        return _padboth(width, header)
    elif not alignment:
        return f"{header}"
    else:
        return _padleft(width, header)


def _removeSeparatingLines(rows):
    if type(rows) is list:
        separatingLines = []
        sansRows = []
        for index, row in enumerate(rows):
            if _isSeparatingLine(row):
                separatingLines.append(index)
            else:
                sansRows.append(row)
        return sansRows, separatingLines
    else:
        return rows, None


def _reinsertSeparatingLines(rows, separatingLines):
    if separatingLines:
        for index in separatingLines:
            rows.insert(index, SEPARATING_LINE)


def _prependRowIndex(rows, index):
    """Add a left-most index column."""
    if index is None or index is False:
        return rows
    if isinstance(index, Sized) and len(index) != len(rows):
        raise ValueError(
            "index must be as long as the number of data rows: "
            + "len(index)={} len(rows)={}".format(len(index), len(rows))
        )
    sansRows, separatingLines = _removeSeparatingLines(rows)
    newRows = []
    indexIter = iter(index)
    for row in sansRows:
        indexV = next(indexIter)
        newRows.append([indexV] + list(row))
    rows = newRows
    _reinsertSeparatingLines(rows, separatingLines)
    return rows


def _bool(val):
    """A wrapper around standard bool() which doesn't throw on NumPy arrays."""
    try:
        return bool(val)
    except ValueError:
        # val is likely to be a numpy array with many elements
        return False


def _normalizeTabularData(data, headers, showIndex="default"):
    """Transform a supported data type to a list of lists & a list of headers, with header padding.

    Supported tabular data types:

    * list-of-lists or another iterable of iterables
    * list of named tuples (usually used with headers="keys")
    * list of dicts (usually used with headers="keys")
    * list of OrderedDicts (usually used with headers="keys")
    * list of dataclasses (Python 3.7+ only, usually used with headers="keys")
    * 2D NumPy arrays
    * NumPy record arrays (usually used with headers="keys")
    * dict of iterables (usually used with headers="keys")

    The first row can be used as headers if headers="firstrow", column indices can be used as
    headers if headers="keys".

    If showIndex="always", show row indices for all types of data.
    If showIndex="never", don't show row indices for all types of data.
    If showIndex is an iterable, show its values as row indices.
    """
    try:
        bool(headers)
    except ValueError:
        # numpy.ndarray, ...
        headers = list(headers)

    index = None
    if type(data) is dict:
        # dict-like
        keys = data.keys()
        # columns have to be transposed
        rows = list(zip_longest(*data.values()))

        if headers == "keys":
            # headers should be strings
            headers = list(map(str, keys))
    else:
        # it's a usual iterable of iterables, or a NumPy array, or an iterable of dataclasses
        rows = list(data)

        if headers == "keys" and not rows:
            # an empty table
            headers = []
        elif (
            headers == "keys"
            and hasattr(data, "dtype")
            and getattr(data.dtype, "names")
        ):
            # numpy record array
            headers = data.dtype.names
        elif (
            headers == "keys"
            and len(rows) > 0
            and isinstance(rows[0], tuple)
            and hasattr(rows[0], "_fields")
        ):
            # namedtuple
            headers = list(map(str, rows[0]._fields))
        elif len(rows) > 0 and hasattr(rows[0], "keys") and hasattr(rows[0], "values"):
            # dict-like object
            uniqKeys = set()  # implements hashed lookup
            keys = []  # storage for set
            if headers == "firstrow":
                firstdict = rows[0] if len(rows) > 0 else {}
                keys.extend(firstdict.keys())
                uniqKeys.update(keys)
                rows = rows[1:]
            for row in rows:
                for k in row.keys():
                    # Save unique items in input order
                    if k not in uniqKeys:
                        keys.append(k)
                        uniqKeys.add(k)
            if headers == "keys":
                headers = keys
            elif isinstance(headers, dict):
                # a dict of headers for a list of dicts
                headers = [headers.get(k, k) for k in keys]
                headers = list(map(str, headers))
            elif headers == "firstrow":
                if len(rows) > 0:
                    headers = [firstdict.get(k, k) for k in keys]
                    headers = list(map(str, headers))
                else:
                    headers = []
            elif headers:
                raise ValueError(
                    "headers for a list of dicts is not a dict or a keyword"
                )
            rows = [[row.get(k) for k in keys] for row in rows]
        elif len(rows) > 0 and dataclasses.is_dataclass(rows[0]):
            # Python 3.7+'s dataclass
            fieldNames = [field.name for field in dataclasses.fields(rows[0])]
            if headers == "keys":
                headers = fieldNames
            rows = [[getattr(row, f) for f in fieldNames] for row in rows]
        elif headers == "keys" and len(rows) > 0:
            # keys are column indices
            headers = list(map(str, range(len(rows[0]))))

    # take headers from the first row if necessary
    if headers == "firstrow" and len(rows) > 0:
        if index is not None:
            headers = [index[0]] + list(rows[0])
            index = index[1:]
        else:
            headers = rows[0]
        headers = list(map(str, headers))  # headers should be strings
        rows = rows[1:]
    elif headers == "firstrow":
        headers = []

    headers = list(map(str, headers))
    rows = list(map(lambda r: r if _isSeparatingLine(r) else list(r), rows))

    # add or remove an index column
    showIndexIsSStr = type(showIndex) in [str, bytes]
    if showIndex == "default" and index is not None:
        rows = _prependRowIndex(rows, index)
    elif isinstance(showIndex, Sized) and not showIndexIsSStr:
        rows = _prependRowIndex(rows, list(showIndex))
    elif isinstance(showIndex, Iterable) and not showIndexIsSStr:
        rows = _prependRowIndex(rows, showIndex)
    elif showIndex == "always" or (_bool(showIndex) and not showIndexIsSStr):
        if index is None:
            index = list(range(len(rows)))
        rows = _prependRowIndex(rows, index)

    # pad with empty headers for initial columns if necessary
    headersPad = 0
    if headers and len(rows) > 0:
        headersPad = max(0, len(rows[0]) - len(headers))
        headers = [""] * headersPad + headers

    return rows, headers, headersPad


def _wrapTextToColWidths(listOfLists, colwidths, numparses=True):
    if len(listOfLists):
        numCols = len(listOfLists[0])
    else:
        numCols = 0

    numparses = _expandIterable(numparses, numCols, True)
    result = []

    for row in listOfLists:
        newRow = []
        for cell, width, numparse in zip(row, colwidths, numparses):
            if _isnumber(cell) and numparse:
                newRow.append(cell)
                continue

            if width is not None:
                wrapper = TextWrapper(width=width)
                # Cast based on our internal type handling. Any future custom formatting of types
                # (such as datetimes) may need to be more explicit than just `str` of the object
                castedCell = (
                    str(cell) if _isnumber(cell) else _type(cell, numparse)(cell)
                )
                wrapped = [
                    "\n".join(wrapper.wrap(line))
                    for line in castedCell.splitlines()
                    if line.strip() != ""
                ]
                newRow.append("\n".join(wrapped))
            else:
                newRow.append(cell)
        result.append(newRow)

    return result


def _toStr(s, encoding="utf8", errors="ignore"):
    """
    A type safe wrapper for converting a bytestring to str.

    This is essentially just a wrapper around .decode() intended for use with things like map(), but
    with some specific behavior:

    1. if the given parameter is not a bytestring, it is returned unmodified
    2. decode() is called for the given parameter and assumes utf8 encoding, but the default error
       behavior is changed from 'strict' to 'ignore'

        >>> repr(_toStr(b'foo'))
        "'foo'"

        >>> repr(_toStr('foo'))
        "'foo'"

        >>> repr(_toStr(42))
        "'42'"

    """
    if isinstance(s, bytes):
        return s.decode(encoding=encoding, errors=errors)
    return str(s)


[docs]def tabulate( data, headers=(), tableFmt="simple", floatFmt=_DEFAULT_FLOAT_FMT, intFmt=_DEFAULT_INT_FMT, numAlign=_DEFAULT_ALIGN, strAlign=_DEFAULT_ALIGN, missingVal=_DEFAULT_MISSING_VAL, showIndex="default", disableNumParse=False, colGlobalAlign=None, colAlign=None, maxColWidths=None, headersGlobalAlign=None, headersAlign=None, rowAlign=None, maxHeaderColWidths=None, ): """Format a fixed width table for pretty printing. Parameters ---------- data : object The tabular data you want to print. This can be a list-of-lists/iterables, dict-of-lists/ iterables, 2D numpy arrays, or list of dataclasses. headers=(), optional Nice column names. If this is "firstrow", the first row of the data will be used. If it is "keys"m, then dictionary keys or column indices are used. tableFmt : str, optional There are custom table formats defined in this file, and you can choose between them with this string: "armi", "simple", "plain", "grid", "github", "pretty", "psql", "rst", "tsv". floatFmt : str, optional A format specification used for columns which contain numeric data with a decimal point. This can also be a list or tuple of format strings, one per column. intFmt : str, optional A format specification used for columns which contain numeric data without a decimal point. This can also be a list or tuple of format strings, one per column. numAlign : str, optional Specially align numbers, options: "right", "center", "left", "decimal". strAlign : str, optional Specially align strings, options: "right", "center", "left". missingVal : str, optional `None` values are replaced with a `missingVal` string. showIndex : str, optional Show these rows of data. If "always", show row indices for all types of data. If "never", don't show row indices for all types of data. If showIndex is an iterable, show its values.. disableNumParse : bool, optional To disable number parsing (and alignment), use `disableNumParse=True`. For more fine grained control, `[0, 2]` would disable number parsing on the first and third columns. colGlobalAlign : str, optional Allows for global alignment of columns, before any specific override from `colAlign`. Possible values are: None, "right", "center", "decimal", "left". colAlign : str, optional Allows for column-wise override starting from left-most column. Possible values are: "global" (no override), "right", "center", "decimal", "left". maxColWidths : list, optional A list of the maximum column widths. headersGlobalAlign : str, optional Allows for global headers alignment, before any specific override from `headersAlign`. Possible values are: None (follow columns alignment), "right", "center", "left". headersAlign : str, optional Allows for header-wise override starting from left-most given header. Possible values are: "global" (no override), "same" (follow column alignment), "right", "center", "left". rowAlign : str, optional How do you want to align rows: "right", "center", "decimal", "left". maxHeaderColWidths : list, optional List of column widths for the header. Returns ------- str A text representation of the tabular data. """ if data is None: data = [] listOfLists, headers, headersPad = _normalizeTabularData( data, headers, showIndex=showIndex ) listOfLists, separatingLines = _removeSeparatingLines(listOfLists) if maxColWidths is not None: if len(listOfLists): numCols = len(listOfLists[0]) else: numCols = 0 if isinstance(maxColWidths, int): # Expand scalar for all columns maxColWidths = _expandIterable(maxColWidths, numCols, maxColWidths) else: # Ignore col width for any 'trailing' columns maxColWidths = _expandIterable(maxColWidths, numCols, None) numparses = _expandNumparse(disableNumParse, numCols) listOfLists = _wrapTextToColWidths( listOfLists, maxColWidths, numparses=numparses ) if maxHeaderColWidths is not None: numCols = len(listOfLists[0]) if isinstance(maxHeaderColWidths, int): # Expand scalar for all columns maxHeaderColWidths = _expandIterable( maxHeaderColWidths, numCols, maxHeaderColWidths ) else: # Ignore col width for any 'trailing' columns maxHeaderColWidths = _expandIterable(maxHeaderColWidths, numCols, None) numparses = _expandNumparse(disableNumParse, numCols) headers = _wrapTextToColWidths( [headers], maxHeaderColWidths, numparses=numparses )[0] # empty values in the first column of RST tables should be escaped # "" should be escaped as "\\ " or ".." if tableFmt == "rst": listOfLists, headers = _rstEscapeFirstColumn(listOfLists, headers) # Pretty table formatting does not use any extra padding. Numbers are not parsed and are treated # the same as strings for alignment. Check if pretty is the format being used and override the # defaults so it does not impact other formats. minPadding = MIN_PADDING if tableFmt == "pretty": minPadding = 0 disableNumParse = True numAlign = "center" if numAlign == _DEFAULT_ALIGN else numAlign strAlign = "center" if strAlign == _DEFAULT_ALIGN else strAlign else: numAlign = "decimal" if numAlign == _DEFAULT_ALIGN else numAlign strAlign = "left" if strAlign == _DEFAULT_ALIGN else strAlign # optimization: look for ANSI control codes once, enable smart width functions only if a control # code is found # # convert the headers and rows into a single, tab-delimited string ensuring that any bytestrings # are decoded safely (i.e. errors ignored) plainText = "\t".join( chain( # headers map(_toStr, headers), # rows: chain the rows together into a single iterable after mapping the bytestring # conversion to each cell value chain.from_iterable(map(_toStr, row) for row in listOfLists), ) ) hasInvisible = _ansiCodes.search(plainText) is not None if ( not isinstance(tableFmt, TableFormat) and tableFmt in multilineFormats and _isMultiline(plainText) ): tableFmt = multilineFormats.get(tableFmt, tableFmt) isMultiline = True else: isMultiline = False widthFn = _chooseWidthFn(hasInvisible, isMultiline) # format rows and columns, convert numeric values to strings cols = list(zip_longest(*listOfLists)) numparses = _expandNumparse(disableNumParse, len(cols)) coltypes = [_columnType(col, numparse=np) for col, np in zip(cols, numparses)] if isinstance(floatFmt, str): # old version: just duplicate the string to use in each column floatFormats = len(cols) * [floatFmt] else: # if floatFmt is list, tuple etc we have one per column floatFormats = list(floatFmt) if len(floatFormats) < len(cols): floatFormats.extend((len(cols) - len(floatFormats)) * [_DEFAULT_FLOAT_FMT]) if isinstance(intFmt, str): # old version: just duplicate the string to use in each column intFormats = len(cols) * [intFmt] else: # if intFmt is list, tuple etc we have one per column intFormats = list(intFmt) if len(intFormats) < len(cols): intFormats.extend((len(cols) - len(intFormats)) * [_DEFAULT_INT_FMT]) if isinstance(missingVal, str): missingVals = len(cols) * [missingVal] else: missingVals = list(missingVal) if len(missingVals) < len(cols): missingVals.extend((len(cols) - len(missingVals)) * [_DEFAULT_MISSING_VAL]) cols = [ [_format(v, ct, flFmt, intFmt, missV, hasInvisible) for v in c] for c, ct, flFmt, intFmt, missV in zip( cols, coltypes, floatFormats, intFormats, missingVals ) ] # align columns # first set global alignment if colGlobalAlign is not None: # if global alignment provided aligns = [colGlobalAlign] * len(cols) else: # default aligns = [numAlign if ct in [int, float] else strAlign for ct in coltypes] # then specific alignements if colAlign is not None: assert isinstance(colAlign, Iterable) if isinstance(colAlign, str): runLog.warning( f"As a string, `colAlign` is interpreted as {[c for c in colAlign]}. Did you " + f'mean `colGlobalAlign = "{colAlign}"` or `colAlign = ("{colAlign}",)`?' ) for idx, align in enumerate(colAlign): if not idx < len(aligns): break elif align != "global": aligns[idx] = align minwidths = ( [widthFn(h) + minPadding for h in headers] if headers else [0] * len(cols) ) cols = [ _alignColumn(c, a, minw, hasInvisible, isMultiline) for c, a, minw in zip(cols, aligns, minwidths) ] alignsHeaders = None if headers: # align headers and add headers tCols = cols or [[""]] * len(headers) # first set global alignment if headersGlobalAlign is not None: # if global alignment provided alignsHeaders = [headersGlobalAlign] * len(tCols) else: # default alignsHeaders = aligns or [strAlign] * len(headers) # then specific header alignements if headersAlign is not None: assert isinstance(headersAlign, Iterable) if isinstance(headersAlign, str): runLog.warning( f"As a string, `headersAlign` is interpreted as {[c for c in headersAlign]}. " + f'Did you mean `headersGlobalAlign = "{headersAlign}"` or `headersAlign = ' + f'("{headersAlign}",)`?' ) for idx, align in enumerate(headersAlign): hidx = headersPad + idx if not hidx < len(alignsHeaders): break elif align == "same" and hidx < len(aligns): # same as column align alignsHeaders[hidx] = aligns[hidx] elif align != "global": alignsHeaders[hidx] = align minwidths = [ max(minw, max(widthFn(cl) for cl in c)) for minw, c in zip(minwidths, tCols) ] headers = [ _alignHeader(h, a, minw, widthFn(h), isMultiline, widthFn) for h, a, minw in zip(headers, alignsHeaders, minwidths) ] rows = list(zip(*cols)) else: minwidths = [max(widthFn(cl) for cl in c) for c in cols] rows = list(zip(*cols)) if not isinstance(tableFmt, TableFormat): tableFmt = _tableFormats.get(tableFmt, _tableFormats["simple"]) raDefault = rowAlign if isinstance(rowAlign, str) else None rowAligns = _expandIterable(rowAlign, len(rows), raDefault) _reinsertSeparatingLines(rows, separatingLines) return _formatTable( tableFmt, headers, alignsHeaders, rows, minwidths, aligns, isMultiline, rowAligns=rowAligns, )
def _expandNumparse(disableNumParse, columnCount): """ Return a list of bools of length `columnCount` which indicates whether number parsing should be used on each column. If `disableNumParse` is a list of indices, each of those indices are False, and everything else is True. If `disableNumParse` is a bool, then the returned list is all the same. """ if isinstance(disableNumParse, Iterable): numparses = [True] * columnCount for index in disableNumParse: numparses[index] = False return numparses else: return [not disableNumParse] * columnCount def _expandIterable(original, numDesired, default): """ Expands the `original` argument to return a return a list of length `numDesired`. If `original` is shorter than `numDesired`, it will be padded with the value in `default`. If `original` is not a list to begin with (i.e. scalar value) a list of length `numDesired` completely populated with `default` will be returned """ if isinstance(original, Iterable) and not isinstance(original, str): return original + [default] * (numDesired - len(original)) else: return [default] * numDesired def _padRow(cells, padding): if cells: pad = " " * padding paddedCells = [pad + cell + pad for cell in cells] return paddedCells else: return cells def _buildSimpleRow(paddedCells, rowfmt): """Format row according to DataRow format without padding.""" begin, sep, end = rowfmt return (begin + sep.join(paddedCells) + end).rstrip() def _buildRow(paddedCells, colwidths, colAligns, rowfmt): """Return a string which represents a row of data cells.""" if not rowfmt: return None if hasattr(rowfmt, "__call__"): return rowfmt(paddedCells, colwidths, colAligns) else: return _buildSimpleRow(paddedCells, rowfmt) def _appendBasicRow(lines, paddedCells, colwidths, colAligns, rowfmt, rowAlign=None): # NOTE: rowAlign is ignored and exists for api compatibility with _appendMultilineRow lines.append(_buildRow(paddedCells, colwidths, colAligns, rowfmt)) return lines def _alignCellVeritically(textLines, numLines, columnWidth, rowAlignment): deltaLines = numLines - len(textLines) blank = [" " * columnWidth] if rowAlignment == "bottom": return blank * deltaLines + textLines elif rowAlignment == "center": topDelta = deltaLines // 2 bottomDelta = deltaLines - topDelta return topDelta * blank + textLines + bottomDelta * blank else: return textLines + blank * deltaLines def _appendMultilineRow( lines, paddedMultilineCells, paddedWidths, colAligns, rowfmt, pad, rowAlign=None ): colwidths = [w - 2 * pad for w in paddedWidths] cellsLines = [c.splitlines() for c in paddedMultilineCells] nlines = max(map(len, cellsLines)) # number of lines in the row cellsLines = [ _alignCellVeritically(cl, nlines, w, rowAlign) for cl, w in zip(cellsLines, colwidths) ] linesCells = [[cl[i] for cl in cellsLines] for i in range(nlines)] for ln in linesCells: paddedLn = _padRow(ln, pad) _appendBasicRow(lines, paddedLn, colwidths, colAligns, rowfmt) return lines def _buildLine(colwidths, colAligns, linefmt): """Return a string which represents a horizontal line.""" if not linefmt: return None if hasattr(linefmt, "__call__"): return linefmt(colwidths, colAligns) else: begin, fill, sep, end = linefmt cells = [fill * w for w in colwidths] return _buildSimpleRow(cells, (begin, sep, end)) def _appendLine(lines, colwidths, colAligns, linefmt): lines.append(_buildLine(colwidths, colAligns, linefmt)) return lines def _formatTable( fmt, headers, headersAligns, rows, colwidths, colAligns, isMultiline, rowAligns ): """Produce a plain-text representation of the table.""" lines = [] hidden = fmt.withHeaderHide if (headers and fmt.withHeaderHide) else [] pad = fmt.padding headerrow = fmt.headerrow paddedWidths = [(w + 2 * pad) for w in colwidths] if isMultiline: padRow = lambda row, _: row appendRow = partial(_appendMultilineRow, pad=pad) else: padRow = _padRow appendRow = _appendBasicRow paddedHeaders = padRow(headers, pad) paddedRows = [padRow(row, pad) for row in rows] if fmt.lineabove and "lineabove" not in hidden: _appendLine(lines, paddedWidths, colAligns, fmt.lineabove) if paddedHeaders: appendRow(lines, paddedHeaders, paddedWidths, headersAligns, headerrow) if fmt.linebelowheader and "linebelowheader" not in hidden: _appendLine(lines, paddedWidths, colAligns, fmt.linebelowheader) if paddedRows and fmt.linebetweenrows and "linebetweenrows" not in hidden: # initial rows with a line below for row, ralign in zip(paddedRows[:-1], rowAligns): appendRow(lines, row, paddedWidths, colAligns, fmt.datarow, rowAlign=ralign) _appendLine(lines, paddedWidths, colAligns, fmt.linebetweenrows) # the last row without a line below appendRow( lines, paddedRows[-1], paddedWidths, colAligns, fmt.datarow, rowAlign=rowAligns[-1], ) else: separatingLine = ( fmt.linebetweenrows or fmt.linebelowheader or fmt.linebelow or fmt.lineabove or Line("", "", "", "") ) for row in paddedRows: # test to see if either the 1st column or the 2nd column has the SEPARATING_LINE flag if _isSeparatingLine(row): _appendLine(lines, paddedWidths, colAligns, separatingLine) else: appendRow(lines, row, paddedWidths, colAligns, fmt.datarow) if fmt.linebelow and "linebelow" not in hidden: _appendLine(lines, paddedWidths, colAligns, fmt.linebelow) if headers or rows: return "\n".join(lines) else: return ""