From 422b36ec1191f07ff0816a0e3701ee3874afdb96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladimir=20Vrzi=C4=87?= Date: Tue, 23 Feb 2021 19:27:21 +0100 Subject: [PATCH 01/90] Replace constant PRESERVE_WHITESPACE with an argument to tabulate() --- tabulate.py | 17 ++++++++--------- test/test_output.py | 6 ++---- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/tabulate.py b/tabulate.py index 55b896e..541cf29 100644 --- a/tabulate.py +++ b/tabulate.py @@ -68,9 +68,6 @@ def _is_file(f): # minimum extra space in headers MIN_PADDING = 2 -# Whether or not to preserve leading/trailing whitespace in data. -PRESERVE_WHITESPACE = False - _DEFAULT_FLOATFMT = "g" _DEFAULT_MISSINGVAL = "" # default align will be overwritten by "left", "center" or "decimal" @@ -810,13 +807,13 @@ def _choose_width_fn(has_invisible, enable_widechars, is_multiline): return width_fn -def _align_column_choose_padfn(strings, alignment, has_invisible): +def _align_column_choose_padfn(strings, alignment, has_invisible, preserve_whitespace): if alignment == "right": - if not PRESERVE_WHITESPACE: + if not preserve_whitespace: strings = [s.strip() for s in strings] padfn = _padleft elif alignment == "center": - if not PRESERVE_WHITESPACE: + if not preserve_whitespace: strings = [s.strip() for s in strings] padfn = _padboth elif alignment == "decimal": @@ -830,7 +827,7 @@ def _align_column_choose_padfn(strings, alignment, has_invisible): elif not alignment: padfn = _padnone else: - if not PRESERVE_WHITESPACE: + if not preserve_whitespace: strings = [s.strip() for s in strings] padfn = _padright return strings, padfn @@ -873,9 +870,10 @@ def _align_column( has_invisible=True, enable_widechars=False, is_multiline=False, + preserve_whitespace=False ): """[string] -> [padded_string]""" - strings, padfn = _align_column_choose_padfn(strings, alignment, has_invisible) + strings, padfn = _align_column_choose_padfn(strings, alignment, has_invisible, preserve_whitespace) width_fn = _align_column_choose_width_fn( has_invisible, enable_widechars, is_multiline ) @@ -1224,6 +1222,7 @@ def tabulate( showindex="default", disable_numparse=False, colalign=None, + preserve_whitespace=False ): """Format a fixed width table for pretty printing. @@ -1603,7 +1602,7 @@ def tabulate( [width_fn(h) + min_padding for h in headers] if headers else [0] * len(cols) ) cols = [ - _align_column(c, a, minw, has_invisible, enable_widechars, is_multiline) + _align_column(c, a, minw, has_invisible, enable_widechars, is_multiline, preserve_whitespace) for c, a, minw in zip(cols, aligns, minwidths) ] diff --git a/test/test_output.py b/test/test_output.py index abab439..660f047 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -1598,18 +1598,16 @@ def test_disable_numparse_list(): def test_preserve_whitespace(): "Output: Default table output, but with preserved leading whitespace." - tabulate_module.PRESERVE_WHITESPACE = True table_headers = ["h1", "h2", "h3"] test_table = [[" foo", " bar ", "foo"]] expected = "\n".join( ["h1 h2 h3", "----- ------- ----", " foo bar foo"] ) - result = tabulate(test_table, table_headers) + result = tabulate(test_table, table_headers, preserve_whitespace=True) assert_equal(expected, result) - tabulate_module.PRESERVE_WHITESPACE = False table_headers = ["h1", "h2", "h3"] test_table = [[" foo", " bar ", "foo"]] expected = "\n".join(["h1 h2 h3", "---- ---- ----", "foo bar foo"]) - result = tabulate(test_table, table_headers) + result = tabulate(test_table, table_headers, preserve_whitespace=False) assert_equal(expected, result) From fbdb1fd702d9c5f7c03dbd091d5208141697683e Mon Sep 17 00:00:00 2001 From: Racerroar888 Date: Sat, 5 Nov 2022 08:40:39 -0600 Subject: [PATCH 02/90] fix maxcolwidths doesn't accept tuple --- README.md | 2 +- tabulate/__init__.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d64b99a..3996f7d 100644 --- a/README.md +++ b/README.md @@ -1123,5 +1123,5 @@ Bart Broere, Vilhelm Prytz, Alexander Gažo, Hugo van Kemenade, jamescooke, Matt Warner, Jérôme Provensal, Kevin Deldycke, Kian-Meng Ang, Kevin Patterson, Shodhan Save, cleoold, KOLANICH, Vijaya Krishna Kasula, Furcy Pin, Christian Fibich, Shaun Duncan, -Dimitri Papadopoulos. +Dimitri Papadopoulos, Racerroar. diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 3b1a1e1..2309905 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -2071,6 +2071,8 @@ def tabulate( list_of_lists, separating_lines = _remove_separating_lines(list_of_lists) if maxcolwidths is not None: + if type(maxcolwidths) is tuple: # Check if tuple, convert to list if so + maxcolwidths = list(maxcolwidths) if len(list_of_lists): num_cols = len(list_of_lists[0]) else: From a658c9ffab35df203d15fe430f9dc6d477edd818 Mon Sep 17 00:00:00 2001 From: eliegoudout <114467748+eliegoudout@users.noreply.github.com> Date: Fri, 25 Nov 2022 14:35:02 +0100 Subject: [PATCH 03/90] Added headers align, upgraded column align. changes in: _normalize_tabular_data: - also returns headers_pad (number of first columns without header) tabulate: - new keyword-arguments: `colglobalalign`, `headersglobalalign` and `headersalign` - add "global" support for colalign - updated tabulate.__doc__ _format_table: - takes argument 'headersaligns' --- tabulate/__init__.py | 5519 +++++++++++++++++++++--------------------- 1 file changed, 2780 insertions(+), 2739 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 3b1a1e1..22d24da 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1,2739 +1,2780 @@ -"""Pretty-print tabular data.""" - -from collections import namedtuple -from collections.abc import Iterable, Sized -from html import escape as htmlescape -from itertools import chain, zip_longest as izip_longest -from functools import reduce, partial -import io -import re -import math -import textwrap -import dataclasses - -try: - import wcwidth # optional wide-character (CJK) support -except ImportError: - wcwidth = None - - -def _is_file(f): - return isinstance(f, io.IOBase) - - -__all__ = ["tabulate", "tabulate_formats", "simple_separated_format"] -try: - from .version import version as __version__ # noqa: F401 -except ImportError: - pass # running __init__.py as a script, AppVeyor pytests - - -# minimum extra space in headers -MIN_PADDING = 2 - -# Whether or not to preserve leading/trailing whitespace in data. -PRESERVE_WHITESPACE = False - -_DEFAULT_FLOATFMT = "g" -_DEFAULT_INTFMT = "" -_DEFAULT_MISSINGVAL = "" -# default align will be overwritten by "left", "center" or "decimal" -# depending on the formatter -_DEFAULT_ALIGN = "default" - - -# if True, enable wide-character (CJK) support -WIDE_CHARS_MODE = wcwidth is not None - -# 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. -# -# with_header_hide: -# -# - 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", - "with_header_hide", - ], -) - - -def _is_separating_line(row): - row_type = type(row) - is_sl = (row_type == list or row_type == str) and ( - (len(row) >= 1 and row[0] == SEPARATING_LINE) - or (len(row) >= 2 and row[1] == SEPARATING_LINE) - ) - return is_sl - - -def _pipe_segment_with_colons(align, colwidth): - """Return a segment of a horizontal line with optional colons which - indicate column's alignment (as in `pipe` output format).""" - w = colwidth - if align in ["right", "decimal"]: - return ("-" * (w - 1)) + ":" - elif align == "center": - return ":" + ("-" * (w - 2)) + ":" - elif align == "left": - return ":" + ("-" * (w - 1)) - else: - return "-" * w - - -def _pipe_line_with_colons(colwidths, colaligns): - """Return a horizontal line with optional colons to indicate column's - alignment (as in `pipe` output format).""" - if not colaligns: # e.g. printing an empty data frame (github issue #15) - colaligns = [""] * len(colwidths) - segments = [_pipe_segment_with_colons(a, w) for a, w in zip(colaligns, colwidths)] - return "|" + "|".join(segments) + "|" - - -def _mediawiki_row_with_attrs(separator, cell_values, colwidths, colaligns): - alignment = { - "left": "", - "right": 'style="text-align: right;"| ', - "center": 'style="text-align: center;"| ', - "decimal": 'style="text-align: right;"| ', - } - # hard-coded padding _around_ align attribute and value together - # rather than padding parameter which affects only the value - values_with_attrs = [ - " " + alignment.get(a, "") + c + " " for c, a in zip(cell_values, colaligns) - ] - colsep = separator * 2 - return (separator + colsep.join(values_with_attrs)).rstrip() - - -def _textile_row_with_attrs(cell_values, colwidths, colaligns): - cell_values[0] += " " - alignment = {"left": "<.", "right": ">.", "center": "=.", "decimal": ">."} - values = (alignment.get(a, "") + v for a, v in zip(colaligns, cell_values)) - return "|" + "|".join(values) + "|" - - -def _html_begin_table_without_header(colwidths_ignore, colaligns_ignore): - # this table header will be suppressed if there is a header row - return "\n" - - -def _html_row_with_attrs(celltag, unsafe, cell_values, colwidths, colaligns): - alignment = { - "left": "", - "right": ' style="text-align: right;"', - "center": ' style="text-align: center;"', - "decimal": ' style="text-align: right;"', - } - if unsafe: - values_with_attrs = [ - "<{0}{1}>{2}".format(celltag, alignment.get(a, ""), c) - for c, a in zip(cell_values, colaligns) - ] - else: - values_with_attrs = [ - "<{0}{1}>{2}".format(celltag, alignment.get(a, ""), htmlescape(c)) - for c, a in zip(cell_values, colaligns) - ] - rowhtml = "{}".format("".join(values_with_attrs).rstrip()) - if celltag == "th": # it's a header row, create a new table header - rowhtml = f"
\n\n{rowhtml}\n\n" - return rowhtml - - -def _moin_row_with_attrs(celltag, cell_values, colwidths, colaligns, header=""): - alignment = { - "left": "", - "right": '', - "center": '', - "decimal": '', - } - values_with_attrs = [ - "{}{} {} ".format(celltag, alignment.get(a, ""), header + c + header) - for c, a in zip(cell_values, colaligns) - ] - return "".join(values_with_attrs) + "||" - - -def _latex_line_begin_tabular(colwidths, colaligns, booktabs=False, longtable=False): - alignment = {"left": "l", "right": "r", "center": "c", "decimal": "r"} - tabular_columns_fmt = "".join([alignment.get(a, "l") for a in colaligns]) - return "\n".join( - [ - ("\\begin{tabular}{" if not longtable else "\\begin{longtable}{") - + tabular_columns_fmt - + "}", - "\\toprule" if booktabs else "\\hline", - ] - ) - - -def _asciidoc_row(is_header, *args): - """handle header and data rows for asciidoc format""" - - def make_header_line(is_header, colwidths, colaligns): - # generate the column specifiers - - alignment = {"left": "<", "right": ">", "center": "^", "decimal": ">"} - # use the column widths generated by tabulate for the asciidoc column width specifiers - asciidoc_alignments = zip( - colwidths, [alignment[colalign] for colalign in colaligns] - ) - asciidoc_column_specifiers = [ - "{:d}{}".format(width, align) for width, align in asciidoc_alignments - ] - header_list = ['cols="' + (",".join(asciidoc_column_specifiers)) + '"'] - - # generate the list of options (currently only "header") - options_list = [] - - if is_header: - options_list.append("header") - - if options_list: - header_list += ['options="' + ",".join(options_list) + '"'] - - # generate the list of entries in the table header field - - return "[{}]\n|====".format(",".join(header_list)) - - if len(args) == 2: - # two arguments are passed if called in the context of aboveline - # print the table header with column widths and optional header tag - return make_header_line(False, *args) - - elif len(args) == 3: - # three arguments are passed if called in the context of dataline or headerline - # print the table line and make the aboveline if it is a header - - cell_values, colwidths, colaligns = args - data_line = "|" + "|".join(cell_values) - - if is_header: - return make_header_line(True, colwidths, colaligns) + "\n" + data_line - else: - return data_line - - else: - raise ValueError( - " _asciidoc_row() requires two (colwidths, colaligns) " - + "or three (cell_values, colwidths, colaligns) arguments) " - ) - - -LATEX_ESCAPE_RULES = { - r"&": r"\&", - r"%": r"\%", - r"$": r"\$", - r"#": r"\#", - r"_": r"\_", - r"^": r"\^{}", - r"{": r"\{", - r"}": r"\}", - r"~": r"\textasciitilde{}", - "\\": r"\textbackslash{}", - r"<": r"\ensuremath{<}", - r">": r"\ensuremath{>}", -} - - -def _latex_row(cell_values, colwidths, colaligns, escrules=LATEX_ESCAPE_RULES): - def escape_char(c): - return escrules.get(c, c) - - escaped_values = ["".join(map(escape_char, cell)) for cell in cell_values] - rowfmt = DataRow("", "&", "\\\\") - return _build_simple_row(escaped_values, rowfmt) - - -def _rst_escape_first_column(rows, headers): - def escape_empty(val): - if isinstance(val, (str, bytes)) and not val.strip(): - return ".." - else: - return val - - new_headers = list(headers) - new_rows = [] - if headers: - new_headers[0] = escape_empty(headers[0]) - for row in rows: - new_row = list(row) - if new_row: - new_row[0] = escape_empty(row[0]) - new_rows.append(new_row) - return new_rows, new_headers - - -_table_formats = { - "simple": TableFormat( - lineabove=Line("", "-", " ", ""), - linebelowheader=Line("", "-", " ", ""), - linebetweenrows=None, - linebelow=Line("", "-", " ", ""), - headerrow=DataRow("", " ", ""), - datarow=DataRow("", " ", ""), - padding=0, - with_header_hide=["lineabove", "linebelow"], - ), - "plain": TableFormat( - lineabove=None, - linebelowheader=None, - linebetweenrows=None, - linebelow=None, - headerrow=DataRow("", " ", ""), - datarow=DataRow("", " ", ""), - padding=0, - with_header_hide=None, - ), - "grid": TableFormat( - lineabove=Line("+", "-", "+", "+"), - linebelowheader=Line("+", "=", "+", "+"), - linebetweenrows=Line("+", "-", "+", "+"), - linebelow=Line("+", "-", "+", "+"), - headerrow=DataRow("|", "|", "|"), - datarow=DataRow("|", "|", "|"), - padding=1, - with_header_hide=None, - ), - "simple_grid": TableFormat( - lineabove=Line("┌", "─", "┬", "┐"), - linebelowheader=Line("├", "─", "┼", "┤"), - linebetweenrows=Line("├", "─", "┼", "┤"), - linebelow=Line("└", "─", "┴", "┘"), - headerrow=DataRow("│", "│", "│"), - datarow=DataRow("│", "│", "│"), - padding=1, - with_header_hide=None, - ), - "rounded_grid": TableFormat( - lineabove=Line("╭", "─", "┬", "╮"), - linebelowheader=Line("├", "─", "┼", "┤"), - linebetweenrows=Line("├", "─", "┼", "┤"), - linebelow=Line("╰", "─", "┴", "╯"), - headerrow=DataRow("│", "│", "│"), - datarow=DataRow("│", "│", "│"), - padding=1, - with_header_hide=None, - ), - "heavy_grid": TableFormat( - lineabove=Line("┏", "━", "┳", "┓"), - linebelowheader=Line("┣", "━", "╋", "┫"), - linebetweenrows=Line("┣", "━", "╋", "┫"), - linebelow=Line("┗", "━", "┻", "┛"), - headerrow=DataRow("┃", "┃", "┃"), - datarow=DataRow("┃", "┃", "┃"), - padding=1, - with_header_hide=None, - ), - "mixed_grid": TableFormat( - lineabove=Line("┍", "━", "┯", "┑"), - linebelowheader=Line("┝", "━", "┿", "┥"), - linebetweenrows=Line("├", "─", "┼", "┤"), - linebelow=Line("┕", "━", "┷", "┙"), - headerrow=DataRow("│", "│", "│"), - datarow=DataRow("│", "│", "│"), - padding=1, - with_header_hide=None, - ), - "double_grid": TableFormat( - lineabove=Line("╔", "═", "╦", "╗"), - linebelowheader=Line("╠", "═", "╬", "╣"), - linebetweenrows=Line("╠", "═", "╬", "╣"), - linebelow=Line("╚", "═", "╩", "╝"), - headerrow=DataRow("║", "║", "║"), - datarow=DataRow("║", "║", "║"), - padding=1, - with_header_hide=None, - ), - "fancy_grid": TableFormat( - lineabove=Line("╒", "═", "╤", "╕"), - linebelowheader=Line("╞", "═", "╪", "╡"), - linebetweenrows=Line("├", "─", "┼", "┤"), - linebelow=Line("╘", "═", "╧", "╛"), - headerrow=DataRow("│", "│", "│"), - datarow=DataRow("│", "│", "│"), - padding=1, - with_header_hide=None, - ), - "outline": TableFormat( - lineabove=Line("+", "-", "+", "+"), - linebelowheader=Line("+", "=", "+", "+"), - linebetweenrows=None, - linebelow=Line("+", "-", "+", "+"), - headerrow=DataRow("|", "|", "|"), - datarow=DataRow("|", "|", "|"), - padding=1, - with_header_hide=None, - ), - "simple_outline": TableFormat( - lineabove=Line("┌", "─", "┬", "┐"), - linebelowheader=Line("├", "─", "┼", "┤"), - linebetweenrows=None, - linebelow=Line("└", "─", "┴", "┘"), - headerrow=DataRow("│", "│", "│"), - datarow=DataRow("│", "│", "│"), - padding=1, - with_header_hide=None, - ), - "rounded_outline": TableFormat( - lineabove=Line("╭", "─", "┬", "╮"), - linebelowheader=Line("├", "─", "┼", "┤"), - linebetweenrows=None, - linebelow=Line("╰", "─", "┴", "╯"), - headerrow=DataRow("│", "│", "│"), - datarow=DataRow("│", "│", "│"), - padding=1, - with_header_hide=None, - ), - "heavy_outline": TableFormat( - lineabove=Line("┏", "━", "┳", "┓"), - linebelowheader=Line("┣", "━", "╋", "┫"), - linebetweenrows=None, - linebelow=Line("┗", "━", "┻", "┛"), - headerrow=DataRow("┃", "┃", "┃"), - datarow=DataRow("┃", "┃", "┃"), - padding=1, - with_header_hide=None, - ), - "mixed_outline": TableFormat( - lineabove=Line("┍", "━", "┯", "┑"), - linebelowheader=Line("┝", "━", "┿", "┥"), - linebetweenrows=None, - linebelow=Line("┕", "━", "┷", "┙"), - headerrow=DataRow("│", "│", "│"), - datarow=DataRow("│", "│", "│"), - padding=1, - with_header_hide=None, - ), - "double_outline": TableFormat( - lineabove=Line("╔", "═", "╦", "╗"), - linebelowheader=Line("╠", "═", "╬", "╣"), - linebetweenrows=None, - linebelow=Line("╚", "═", "╩", "╝"), - headerrow=DataRow("║", "║", "║"), - datarow=DataRow("║", "║", "║"), - padding=1, - with_header_hide=None, - ), - "fancy_outline": TableFormat( - lineabove=Line("╒", "═", "╤", "╕"), - linebelowheader=Line("╞", "═", "╪", "╡"), - linebetweenrows=None, - linebelow=Line("╘", "═", "╧", "╛"), - headerrow=DataRow("│", "│", "│"), - datarow=DataRow("│", "│", "│"), - padding=1, - with_header_hide=None, - ), - "github": TableFormat( - lineabove=Line("|", "-", "|", "|"), - linebelowheader=Line("|", "-", "|", "|"), - linebetweenrows=None, - linebelow=None, - headerrow=DataRow("|", "|", "|"), - datarow=DataRow("|", "|", "|"), - padding=1, - with_header_hide=["lineabove"], - ), - "pipe": TableFormat( - lineabove=_pipe_line_with_colons, - linebelowheader=_pipe_line_with_colons, - linebetweenrows=None, - linebelow=None, - headerrow=DataRow("|", "|", "|"), - datarow=DataRow("|", "|", "|"), - padding=1, - with_header_hide=["lineabove"], - ), - "orgtbl": TableFormat( - lineabove=None, - linebelowheader=Line("|", "-", "+", "|"), - linebetweenrows=None, - linebelow=None, - headerrow=DataRow("|", "|", "|"), - datarow=DataRow("|", "|", "|"), - padding=1, - with_header_hide=None, - ), - "jira": TableFormat( - lineabove=None, - linebelowheader=None, - linebetweenrows=None, - linebelow=None, - headerrow=DataRow("||", "||", "||"), - datarow=DataRow("|", "|", "|"), - padding=1, - with_header_hide=None, - ), - "presto": TableFormat( - lineabove=None, - linebelowheader=Line("", "-", "+", ""), - linebetweenrows=None, - linebelow=None, - headerrow=DataRow("", "|", ""), - datarow=DataRow("", "|", ""), - padding=1, - with_header_hide=None, - ), - "pretty": TableFormat( - lineabove=Line("+", "-", "+", "+"), - linebelowheader=Line("+", "-", "+", "+"), - linebetweenrows=None, - linebelow=Line("+", "-", "+", "+"), - headerrow=DataRow("|", "|", "|"), - datarow=DataRow("|", "|", "|"), - padding=1, - with_header_hide=None, - ), - "psql": TableFormat( - lineabove=Line("+", "-", "+", "+"), - linebelowheader=Line("|", "-", "+", "|"), - linebetweenrows=None, - linebelow=Line("+", "-", "+", "+"), - headerrow=DataRow("|", "|", "|"), - datarow=DataRow("|", "|", "|"), - padding=1, - with_header_hide=None, - ), - "rst": TableFormat( - lineabove=Line("", "=", " ", ""), - linebelowheader=Line("", "=", " ", ""), - linebetweenrows=None, - linebelow=Line("", "=", " ", ""), - headerrow=DataRow("", " ", ""), - datarow=DataRow("", " ", ""), - padding=0, - with_header_hide=None, - ), - "mediawiki": TableFormat( - lineabove=Line( - '{| class="wikitable" style="text-align: left;"', - "", - "", - "\n|+ \n|-", - ), - linebelowheader=Line("|-", "", "", ""), - linebetweenrows=Line("|-", "", "", ""), - linebelow=Line("|}", "", "", ""), - headerrow=partial(_mediawiki_row_with_attrs, "!"), - datarow=partial(_mediawiki_row_with_attrs, "|"), - padding=0, - with_header_hide=None, - ), - "moinmoin": TableFormat( - lineabove=None, - linebelowheader=None, - linebetweenrows=None, - linebelow=None, - headerrow=partial(_moin_row_with_attrs, "||", header="'''"), - datarow=partial(_moin_row_with_attrs, "||"), - padding=1, - with_header_hide=None, - ), - "youtrack": TableFormat( - lineabove=None, - linebelowheader=None, - linebetweenrows=None, - linebelow=None, - headerrow=DataRow("|| ", " || ", " || "), - datarow=DataRow("| ", " | ", " |"), - padding=1, - with_header_hide=None, - ), - "html": TableFormat( - lineabove=_html_begin_table_without_header, - linebelowheader="", - linebetweenrows=None, - linebelow=Line("\n
", "", "", ""), - headerrow=partial(_html_row_with_attrs, "th", False), - datarow=partial(_html_row_with_attrs, "td", False), - padding=0, - with_header_hide=["lineabove"], - ), - "unsafehtml": TableFormat( - lineabove=_html_begin_table_without_header, - linebelowheader="", - linebetweenrows=None, - linebelow=Line("\n", "", "", ""), - headerrow=partial(_html_row_with_attrs, "th", True), - datarow=partial(_html_row_with_attrs, "td", True), - padding=0, - with_header_hide=["lineabove"], - ), - "latex": TableFormat( - lineabove=_latex_line_begin_tabular, - linebelowheader=Line("\\hline", "", "", ""), - linebetweenrows=None, - linebelow=Line("\\hline\n\\end{tabular}", "", "", ""), - headerrow=_latex_row, - datarow=_latex_row, - padding=1, - with_header_hide=None, - ), - "latex_raw": TableFormat( - lineabove=_latex_line_begin_tabular, - linebelowheader=Line("\\hline", "", "", ""), - linebetweenrows=None, - linebelow=Line("\\hline\n\\end{tabular}", "", "", ""), - headerrow=partial(_latex_row, escrules={}), - datarow=partial(_latex_row, escrules={}), - padding=1, - with_header_hide=None, - ), - "latex_booktabs": TableFormat( - lineabove=partial(_latex_line_begin_tabular, booktabs=True), - linebelowheader=Line("\\midrule", "", "", ""), - linebetweenrows=None, - linebelow=Line("\\bottomrule\n\\end{tabular}", "", "", ""), - headerrow=_latex_row, - datarow=_latex_row, - padding=1, - with_header_hide=None, - ), - "latex_longtable": TableFormat( - lineabove=partial(_latex_line_begin_tabular, longtable=True), - linebelowheader=Line("\\hline\n\\endhead", "", "", ""), - linebetweenrows=None, - linebelow=Line("\\hline\n\\end{longtable}", "", "", ""), - headerrow=_latex_row, - datarow=_latex_row, - padding=1, - with_header_hide=None, - ), - "tsv": TableFormat( - lineabove=None, - linebelowheader=None, - linebetweenrows=None, - linebelow=None, - headerrow=DataRow("", "\t", ""), - datarow=DataRow("", "\t", ""), - padding=0, - with_header_hide=None, - ), - "textile": TableFormat( - lineabove=None, - linebelowheader=None, - linebetweenrows=None, - linebelow=None, - headerrow=DataRow("|_. ", "|_.", "|"), - datarow=_textile_row_with_attrs, - padding=1, - with_header_hide=None, - ), - "asciidoc": TableFormat( - lineabove=partial(_asciidoc_row, False), - linebelowheader=None, - linebetweenrows=None, - linebelow=Line("|====", "", "", ""), - headerrow=partial(_asciidoc_row, True), - datarow=partial(_asciidoc_row, False), - padding=1, - with_header_hide=["lineabove"], - ), -} - - -tabulate_formats = list(sorted(_table_formats.keys())) - -# The table formats for which multiline cells will be folded into subsequent -# table rows. The key is the original format specified at the API. The value is -# the format that will be used to represent the original format. -multiline_formats = { - "plain": "plain", - "simple": "simple", - "grid": "grid", - "simple_grid": "simple_grid", - "rounded_grid": "rounded_grid", - "heavy_grid": "heavy_grid", - "mixed_grid": "mixed_grid", - "double_grid": "double_grid", - "fancy_grid": "fancy_grid", - "pipe": "pipe", - "orgtbl": "orgtbl", - "jira": "jira", - "presto": "presto", - "pretty": "pretty", - "psql": "psql", - "rst": "rst", - "outline": "outline", - "simple_outline": "simple_outline", - "rounded_outline": "rounded_outline", - "heavy_outline": "heavy_outline", - "mixed_outline": "mixed_outline", - "double_outline": "double_outline", - "fancy_outline": "fancy_outline", -} - -# TODO: Add multiline support for the remaining table formats: -# - mediawiki: Replace \n with
-# - moinmoin: TBD -# - youtrack: TBD -# - html: Replace \n with
-# - latex*: Use "makecell" package: In header, replace X\nY with -# \thead{X\\Y} and in data row, replace X\nY with \makecell{X\\Y} -# - tsv: TBD -# - textile: Replace \n with
(must be well-formed XML) - -_multiline_codes = re.compile(r"\r|\n|\r\n") -_multiline_codes_bytes = 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}\\" - -_ansi_escape_pat = 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 - ) -""" -_ansi_codes = re.compile(_ansi_escape_pat, re.VERBOSE) -_ansi_codes_bytes = re.compile(_ansi_escape_pat.encode("utf8"), re.VERBOSE) -_ansi_color_reset_code = "\033[0m" - -_float_with_thousands_separators = re.compile( - r"^(([+-]?[0-9]{1,3})(?:,([0-9]{3}))*)?(?(1)\.[0-9]*|\.[0-9]+)?$" -) - - -def simple_separated_format(separator): - """Construct a simple TableFormat with columns separated by a separator. - - >>> tsv = simple_separated_format("\\t") ; \ - tabulate([["foo", 1], ["spam", 23]], tablefmt=tsv) == 'foo \\t 1\\nspam\\t23' - True - - """ - return TableFormat( - None, - None, - None, - None, - headerrow=DataRow("", separator, ""), - datarow=DataRow("", separator, ""), - padding=0, - with_header_hide=None, - ) - - -def _isnumber_with_thousands_separator(string): - """ - >>> _isnumber_with_thousands_separator(".") - False - >>> _isnumber_with_thousands_separator("1") - True - >>> _isnumber_with_thousands_separator("1.") - True - >>> _isnumber_with_thousands_separator(".1") - True - >>> _isnumber_with_thousands_separator("1000") - False - >>> _isnumber_with_thousands_separator("1,000") - True - >>> _isnumber_with_thousands_separator("1,0000") - False - >>> _isnumber_with_thousands_separator("1,000.1234") - True - >>> _isnumber_with_thousands_separator(b"1,000.1234") - True - >>> _isnumber_with_thousands_separator("+1,000.1234") - True - >>> _isnumber_with_thousands_separator("-1,000.1234") - True - """ - try: - string = string.decode() - except (UnicodeDecodeError, AttributeError): - pass - - return bool(re.match(_float_with_thousands_separators, string)) - - -def _isconvertible(conv, string): - try: - conv(string) - return True - except (ValueError, TypeError): - return False - - -def _isnumber(string): - """ - >>> _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): - """ - >>> _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(">> _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, has_invisible=True, numparse=True): - """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 has_invisible and isinstance(string, (str, bytes)): - string = _strip_ansi(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 _isnumber_with_thousands_separator(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: - return -1 # no point - else: - return -1 # not a number - - -def _padleft(width, s): - """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): - """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): - """Center string. - - >>> _padboth(6, '\u044f\u0439\u0446\u0430') == ' \u044f\u0439\u0446\u0430 ' - True - - """ - fmt = "{0:^%ds}" % width - return fmt.format(s) - - -def _padnone(ignore_width, s): - return s - - -def _strip_ansi(s): - r"""Remove ANSI escape sequences, both CSI (color codes, etc) 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(_strip_ansi('\x1B]8;;https://example.com\x1B\\This is a link\x1B]8;;\x1B\\')) - "'This is a link'" - - >>> repr(_strip_ansi('\x1b[31mred\x1b[0m text')) - "'red text'" - - """ - if isinstance(s, str): - return _ansi_codes.sub(r"\4", s) - else: # a bytestring - return _ansi_codes_bytes.sub(r"\4", s) - - -def _visible_width(s): - """Visible width of a printed string. ANSI color codes are removed. - - >>> _visible_width('\x1b[31mhello\x1b[0m'), _visible_width("world") - (5, 5) - - """ - # optional wide-character support - if wcwidth is not None and WIDE_CHARS_MODE: - len_fn = wcwidth.wcswidth - else: - len_fn = len - if isinstance(s, (str, bytes)): - return len_fn(_strip_ansi(s)) - else: - return len_fn(str(s)) - - -def _is_multiline(s): - if isinstance(s, str): - return bool(re.search(_multiline_codes, s)) - else: # a bytestring - return bool(re.search(_multiline_codes_bytes, s)) - - -def _multiline_width(multiline_s, line_width_fn=len): - """Visible width of a potentially multiline content.""" - return max(map(line_width_fn, re.split("[\r\n]", multiline_s))) - - -def _choose_width_fn(has_invisible, enable_widechars, is_multiline): - """Return a function to calculate visible cell width.""" - if has_invisible: - line_width_fn = _visible_width - elif enable_widechars: # optional wide-character support if available - line_width_fn = wcwidth.wcswidth - else: - line_width_fn = len - if is_multiline: - width_fn = lambda s: _multiline_width(s, line_width_fn) # noqa - else: - width_fn = line_width_fn - return width_fn - - -def _align_column_choose_padfn(strings, alignment, has_invisible): - 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 has_invisible: - decimals = [_afterpoint(_strip_ansi(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 _align_column_choose_width_fn(has_invisible, enable_widechars, is_multiline): - if has_invisible: - line_width_fn = _visible_width - elif enable_widechars: # optional wide-character support if available - line_width_fn = wcwidth.wcswidth - else: - line_width_fn = len - if is_multiline: - width_fn = lambda s: _align_column_multiline_width(s, line_width_fn) # noqa - else: - width_fn = line_width_fn - return width_fn - - -def _align_column_multiline_width(multiline_s, line_width_fn=len): - """Visible width of a potentially multiline content.""" - return list(map(line_width_fn, re.split("[\r\n]", multiline_s))) - - -def _flat_list(nested_list): - ret = [] - for item in nested_list: - if isinstance(item, list): - for subitem in item: - ret.append(subitem) - else: - ret.append(item) - return ret - - -def _align_column( - strings, - alignment, - minwidth=0, - has_invisible=True, - enable_widechars=False, - is_multiline=False, -): - """[string] -> [padded_string]""" - strings, padfn = _align_column_choose_padfn(strings, alignment, has_invisible) - width_fn = _align_column_choose_width_fn( - has_invisible, enable_widechars, is_multiline - ) - - s_widths = list(map(width_fn, strings)) - maxwidth = max(max(_flat_list(s_widths)), minwidth) - # TODO: refactor column alignment in single-line and multiline modes - if is_multiline: - if not enable_widechars and not has_invisible: - padded_strings = [ - "\n".join([padfn(maxwidth, s) for s in ms.splitlines()]) - for ms in strings - ] - else: - # enable wide-character width corrections - s_lens = [[len(s) for s in re.split("[\r\n]", ms)] for ms in strings] - visible_widths = [ - [maxwidth - (w - l) for w, l in zip(mw, ml)] - for mw, ml in zip(s_widths, s_lens) - ] - # wcswidth and _visible_width don't count invisible characters; - # padfn doesn't need to apply another correction - padded_strings = [ - "\n".join([padfn(w, s) for s, w in zip((ms.splitlines() or ms), mw)]) - for ms, mw in zip(strings, visible_widths) - ] - else: # single-line cell values - if not enable_widechars and not has_invisible: - padded_strings = [padfn(maxwidth, s) for s in strings] - else: - # enable wide-character width corrections - s_lens = list(map(len, strings)) - visible_widths = [maxwidth - (w - l) for w, l in zip(s_widths, s_lens)] - # wcswidth and _visible_width don't count invisible characters; - # padfn doesn't need to apply another correction - padded_strings = [padfn(w, s) for s, w in zip(strings, visible_widths)] - return padded_strings - - -def _more_generic(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 _column_type(strings, has_invisible=True, numparse=True): - """The least generic type all column values are convertible to. - - >>> _column_type([True, False]) is bool - True - >>> _column_type(["1", "2"]) is int - True - >>> _column_type(["1", "2.3"]) is float - True - >>> _column_type(["1", "2.3", "four"]) is str - True - >>> _column_type(["four", '\u043f\u044f\u0442\u044c']) is str - True - >>> _column_type([None, "brux"]) is str - True - >>> _column_type([1, 2, None]) is int - True - >>> import datetime as dt - >>> _column_type([dt.datetime(1991,2,19), dt.time(17,35)]) is str - True - - """ - types = [_type(s, has_invisible, numparse) for s in strings] - return reduce(_more_generic, types, bool) - - -def _format(val, valtype, floatfmt, intfmt, missingval="", has_invisible=True): - """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: - is_a_colored_number = has_invisible and isinstance(val, (str, bytes)) - if is_a_colored_number: - raw_val = _strip_ansi(val) - formatted_val = format(float(raw_val), floatfmt) - return val.replace(raw_val, formatted_val) - else: - return format(float(val), floatfmt) - else: - return f"{val}" - - -def _align_header( - header, alignment, width, visible_width, is_multiline=False, width_fn=None -): - "Pad string header to width chars given known visible_width of the header." - if is_multiline: - header_lines = re.split(_multiline_codes, header) - padded_lines = [ - _align_header(h, alignment, width, width_fn(h)) for h in header_lines - ] - return "\n".join(padded_lines) - # else: not multiline - ninvisible = len(header) - visible_width - 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 _remove_separating_lines(rows): - if type(rows) == list: - separating_lines = [] - sans_rows = [] - for index, row in enumerate(rows): - if _is_separating_line(row): - separating_lines.append(index) - else: - sans_rows.append(row) - return sans_rows, separating_lines - else: - return rows, None - - -def _reinsert_separating_lines(rows, separating_lines): - if separating_lines: - for index in separating_lines: - rows.insert(index, SEPARATING_LINE) - - -def _prepend_row_index(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)) - ) - sans_rows, separating_lines = _remove_separating_lines(rows) - new_rows = [] - index_iter = iter(index) - for row in sans_rows: - index_v = next(index_iter) - new_rows.append([index_v] + list(row)) - rows = new_rows - _reinsert_separating_lines(rows, separating_lines) - 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 _normalize_tabular_data(tabular_data, headers, showindex="default"): - """Transform a supported data type to a list of lists, and a list of headers. - - 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") - - * pandas.DataFrame (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="default", show row indices of the pandas.DataFrame. - 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) - is_headers2bool_broken = False # noqa - except ValueError: # numpy.ndarray, pandas.core.index.Index, ... - is_headers2bool_broken = True # noqa - headers = list(headers) - - index = None - if hasattr(tabular_data, "keys") and hasattr(tabular_data, "values"): - # dict-like and pandas.DataFrame? - if hasattr(tabular_data.values, "__call__"): - # likely a conventional dict - keys = tabular_data.keys() - rows = list( - izip_longest(*tabular_data.values()) - ) # columns have to be transposed - elif hasattr(tabular_data, "index"): - # values is a property, has .index => it's likely a pandas.DataFrame (pandas 0.11.0) - keys = list(tabular_data) - if ( - showindex in ["default", "always", True] - and tabular_data.index.name is not None - ): - if isinstance(tabular_data.index.name, list): - keys[:0] = tabular_data.index.name - else: - keys[:0] = [tabular_data.index.name] - vals = tabular_data.values # values matrix doesn't need to be transposed - # for DataFrames add an index per default - index = list(tabular_data.index) - rows = [list(row) for row in vals] - else: - raise ValueError("tabular data doesn't appear to be a dict or a DataFrame") - - if headers == "keys": - headers = list(map(str, keys)) # headers should be strings - - else: # it's a usual iterable of iterables, or a NumPy array, or an iterable of dataclasses - rows = list(tabular_data) - - if headers == "keys" and not rows: - # an empty table (issue #81) - headers = [] - elif ( - headers == "keys" - and hasattr(tabular_data, "dtype") - and getattr(tabular_data.dtype, "names") - ): - # numpy record array - headers = tabular_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 - uniq_keys = set() # implements hashed lookup - keys = [] # storage for set - if headers == "firstrow": - firstdict = rows[0] if len(rows) > 0 else {} - keys.extend(firstdict.keys()) - uniq_keys.update(keys) - rows = rows[1:] - for row in rows: - for k in row.keys(): - # Save unique items in input order - if k not in uniq_keys: - keys.append(k) - uniq_keys.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 ( - headers == "keys" - and hasattr(tabular_data, "description") - and hasattr(tabular_data, "fetchone") - and hasattr(tabular_data, "rowcount") - ): - # Python Database API cursor object (PEP 0249) - # print tabulate(cursor, headers='keys') - headers = [column[0] for column in tabular_data.description] - - elif ( - dataclasses is not None - and len(rows) > 0 - and dataclasses.is_dataclass(rows[0]) - ): - # Python 3.7+'s dataclass - field_names = [field.name for field in dataclasses.fields(rows[0])] - if headers == "keys": - headers = field_names - rows = [[getattr(row, f) for f in field_names] 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(list, rows)) - rows = list(map(lambda r: r if _is_separating_line(r) else list(r), rows)) - - # add or remove an index column - showindex_is_a_str = type(showindex) in [str, bytes] - if showindex == "default" and index is not None: - rows = _prepend_row_index(rows, index) - elif isinstance(showindex, Sized) and not showindex_is_a_str: - rows = _prepend_row_index(rows, list(showindex)) - elif isinstance(showindex, Iterable) and not showindex_is_a_str: - rows = _prepend_row_index(rows, showindex) - elif showindex == "always" or (_bool(showindex) and not showindex_is_a_str): - if index is None: - index = list(range(len(rows))) - rows = _prepend_row_index(rows, index) - elif showindex == "never" or (not _bool(showindex) and not showindex_is_a_str): - pass - - # pad with empty headers for initial columns if necessary - if headers and len(rows) > 0: - nhs = len(headers) - ncols = len(rows[0]) - if nhs < ncols: - headers = [""] * (ncols - nhs) + headers - - return rows, headers - - -def _wrap_text_to_colwidths(list_of_lists, colwidths, numparses=True): - if len(list_of_lists): - num_cols = len(list_of_lists[0]) - else: - num_cols = 0 - numparses = _expand_iterable(numparses, num_cols, True) - - result = [] - - for row in list_of_lists: - new_row = [] - for cell, width, numparse in zip(row, colwidths, numparses): - if _isnumber(cell) and numparse: - new_row.append(cell) - continue - - if width is not None: - wrapper = _CustomTextWrap(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 - casted_cell = ( - str(cell) if _isnumber(cell) else _type(cell, numparse)(cell) - ) - wrapped = [ - "\n".join(wrapper.wrap(line)) - for line in casted_cell.splitlines() - if line.strip() != "" - ] - new_row.append("\n".join(wrapped)) - else: - new_row.append(cell) - result.append(new_row) - - return result - - -def _to_str(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(_to_str(b'foo')) - "'foo'" - - >>> repr(_to_str('foo')) - "'foo'" - - >>> repr(_to_str(42)) - "'42'" - - """ - if isinstance(s, bytes): - return s.decode(encoding=encoding, errors=errors) - return str(s) - - -def tabulate( - tabular_data, - headers=(), - tablefmt="simple", - floatfmt=_DEFAULT_FLOATFMT, - intfmt=_DEFAULT_INTFMT, - numalign=_DEFAULT_ALIGN, - stralign=_DEFAULT_ALIGN, - missingval=_DEFAULT_MISSINGVAL, - showindex="default", - disable_numparse=False, - colalign=None, - maxcolwidths=None, - rowalign=None, - maxheadercolwidths=None, -): - """Format a fixed width table for pretty printing. - - >>> print(tabulate([[1, 2.34], [-56, "8.999"], ["2", "10001"]])) - --- --------- - 1 2.34 - -56 8.999 - 2 10001 - --- --------- - - The first required argument (`tabular_data`) can be a - list-of-lists (or another iterable of iterables), a list of named - tuples, a dictionary of iterables, an iterable of dictionaries, - an iterable of dataclasses (Python 3.7+), a two-dimensional NumPy array, - NumPy record array, or a Pandas' dataframe. - - - 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 and Pandas' dataframes. - - >>> print(tabulate([["sex","age"],["Alice","F",24],["Bob","M",19]], - ... headers="firstrow")) - sex age - ----- ----- ----- - Alice F 24 - Bob M 19 - - By default, pandas.DataFrame data have an additional column called - row index. To add a similar column to all other types of data, - use `showindex="always"` or `showindex=True`. To suppress row indices - for all types of data, pass `showindex="never" or `showindex=False`. - To add a custom row index column, pass `showindex=some_iterable`. - - >>> print(tabulate([["F",24],["M",19]], showindex="always")) - - - -- - 0 F 24 - 1 M 19 - - - -- - - - Column 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). - - - 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', 'pipe', 'orgtbl', 'rst', 'mediawiki', - 'latex', 'latex_raw', 'latex_booktabs', 'latex_longtable' and tsv. - Variable `tabulate_formats`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 | - +------+----------+ - - "simple_grid" draws a grid using single-line box-drawing - characters: - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], - ... ["strings", "numbers"], "simple_grid")) - ┌───────────┬───────────┐ - │ strings │ numbers │ - ├───────────┼───────────┤ - │ spam │ 41.9999 │ - ├───────────┼───────────┤ - │ eggs │ 451 │ - └───────────┴───────────┘ - - "rounded_grid" draws a grid using single-line box-drawing - characters with rounded corners: - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], - ... ["strings", "numbers"], "rounded_grid")) - ╭───────────┬───────────╮ - │ strings │ numbers │ - ├───────────┼───────────┤ - │ spam │ 41.9999 │ - ├───────────┼───────────┤ - │ eggs │ 451 │ - ╰───────────┴───────────╯ - - "heavy_grid" draws a grid using bold (thick) single-line box-drawing - characters: - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], - ... ["strings", "numbers"], "heavy_grid")) - ┏━━━━━━━━━━━┳━━━━━━━━━━━┓ - ┃ strings ┃ numbers ┃ - ┣━━━━━━━━━━━╋━━━━━━━━━━━┫ - ┃ spam ┃ 41.9999 ┃ - ┣━━━━━━━━━━━╋━━━━━━━━━━━┫ - ┃ eggs ┃ 451 ┃ - ┗━━━━━━━━━━━┻━━━━━━━━━━━┛ - - "mixed_grid" draws a grid using a mix of light (thin) and heavy (thick) lines - box-drawing characters: - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], - ... ["strings", "numbers"], "mixed_grid")) - ┍━━━━━━━━━━━┯━━━━━━━━━━━┑ - │ strings │ numbers │ - ┝━━━━━━━━━━━┿━━━━━━━━━━━┥ - │ spam │ 41.9999 │ - ├───────────┼───────────┤ - │ eggs │ 451 │ - ┕━━━━━━━━━━━┷━━━━━━━━━━━┙ - - "double_grid" draws a grid using double-line box-drawing - characters: - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], - ... ["strings", "numbers"], "double_grid")) - ╔═══════════╦═══════════╗ - ║ strings ║ numbers ║ - ╠═══════════╬═══════════╣ - ║ spam ║ 41.9999 ║ - ╠═══════════╬═══════════╣ - ║ eggs ║ 451 ║ - ╚═══════════╩═══════════╝ - - "fancy_grid" draws a grid using a mix of single and - double-line box-drawing characters: - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], - ... ["strings", "numbers"], "fancy_grid")) - ╒═══════════╤═══════════╕ - │ strings │ numbers │ - ╞═══════════╪═══════════╡ - │ spam │ 41.9999 │ - ├───────────┼───────────┤ - │ eggs │ 451 │ - ╘═══════════╧═══════════╛ - - "outline" is the same as the "grid" format but doesn't draw lines between rows: - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], - ... ["strings", "numbers"], "outline")) - +-----------+-----------+ - | strings | numbers | - +===========+===========+ - | spam | 41.9999 | - | eggs | 451 | - +-----------+-----------+ - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], tablefmt="outline")) - +------+----------+ - | spam | 41.9999 | - | eggs | 451 | - +------+----------+ - - "simple_outline" is the same as the "simple_grid" format but doesn't draw lines between rows: - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], - ... ["strings", "numbers"], "simple_outline")) - ┌───────────┬───────────┐ - │ strings │ numbers │ - ├───────────┼───────────┤ - │ spam │ 41.9999 │ - │ eggs │ 451 │ - └───────────┴───────────┘ - - "rounded_outline" is the same as the "rounded_grid" format but doesn't draw lines between rows: - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], - ... ["strings", "numbers"], "rounded_outline")) - ╭───────────┬───────────╮ - │ strings │ numbers │ - ├───────────┼───────────┤ - │ spam │ 41.9999 │ - │ eggs │ 451 │ - ╰───────────┴───────────╯ - - "heavy_outline" is the same as the "heavy_grid" format but doesn't draw lines between rows: - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], - ... ["strings", "numbers"], "heavy_outline")) - ┏━━━━━━━━━━━┳━━━━━━━━━━━┓ - ┃ strings ┃ numbers ┃ - ┣━━━━━━━━━━━╋━━━━━━━━━━━┫ - ┃ spam ┃ 41.9999 ┃ - ┃ eggs ┃ 451 ┃ - ┗━━━━━━━━━━━┻━━━━━━━━━━━┛ - - "mixed_outline" is the same as the "mixed_grid" format but doesn't draw lines between rows: - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], - ... ["strings", "numbers"], "mixed_outline")) - ┍━━━━━━━━━━━┯━━━━━━━━━━━┑ - │ strings │ numbers │ - ┝━━━━━━━━━━━┿━━━━━━━━━━━┥ - │ spam │ 41.9999 │ - │ eggs │ 451 │ - ┕━━━━━━━━━━━┷━━━━━━━━━━━┙ - - "double_outline" is the same as the "double_grid" format but doesn't draw lines between rows: - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], - ... ["strings", "numbers"], "double_outline")) - ╔═══════════╦═══════════╗ - ║ strings ║ numbers ║ - ╠═══════════╬═══════════╣ - ║ spam ║ 41.9999 ║ - ║ eggs ║ 451 ║ - ╚═══════════╩═══════════╝ - - "fancy_outline" is the same as the "fancy_grid" format but doesn't draw lines between rows: - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], - ... ["strings", "numbers"], "fancy_outline")) - ╒═══════════╤═══════════╕ - │ strings │ numbers │ - ╞═══════════╪═══════════╡ - │ spam │ 41.9999 │ - │ eggs │ 451 │ - ╘═══════════╧═══════════╛ - - "pipe" is like tables in PHP Markdown Extra extension or Pandoc - pipe_tables: - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], - ... ["strings", "numbers"], "pipe")) - | strings | numbers | - |:----------|----------:| - | spam | 41.9999 | - | eggs | 451 | - - "presto" is like tables produce by the Presto CLI: - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], - ... ["strings", "numbers"], "presto")) - strings | numbers - -----------+----------- - spam | 41.9999 - eggs | 451 - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], tablefmt="pipe")) - |:-----|---------:| - | spam | 41.9999 | - | eggs | 451 | - - "orgtbl" is like tables in Emacs org-mode and orgtbl-mode. They - are slightly different from "pipe" format by not using colons to - define column alignment, and using a "+" sign to indicate line - intersections: - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], - ... ["strings", "numbers"], "orgtbl")) - | strings | numbers | - |-----------+-----------| - | spam | 41.9999 | - | eggs | 451 | - - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], tablefmt="orgtbl")) - | 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 - ==== ======== - - "mediawiki" produces a table markup used in Wikipedia and on other - MediaWiki-based sites: - - >>> print(tabulate([["strings", "numbers"], ["spam", 41.9999], ["eggs", "451.0"]], - ... headers="firstrow", tablefmt="mediawiki")) - {| class="wikitable" style="text-align: left;" - |+ - |- - ! strings !! style="text-align: right;"| numbers - |- - | spam || style="text-align: right;"| 41.9999 - |- - | eggs || style="text-align: right;"| 451 - |} - - "html" produces HTML markup as an html.escape'd str - with a ._repr_html_ method so that Jupyter Lab and Notebook display the HTML - and a .str property so that the raw HTML remains accessible - the unsafehtml table format can be used if an unescaped HTML format is required: - - >>> print(tabulate([["strings", "numbers"], ["spam", 41.9999], ["eggs", "451.0"]], - ... headers="firstrow", tablefmt="html")) - - - - - - - - -
strings numbers
spam 41.9999
eggs 451
- - "latex" produces a tabular environment of LaTeX document markup: - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], tablefmt="latex")) - \\begin{tabular}{lr} - \\hline - spam & 41.9999 \\\\ - eggs & 451 \\\\ - \\hline - \\end{tabular} - - "latex_raw" is similar to "latex", but doesn't escape special characters, - such as backslash and underscore, so LaTeX commands may embedded into - cells' values: - - >>> print(tabulate([["spam$_9$", 41.9999], ["\\\\emph{eggs}", "451.0"]], tablefmt="latex_raw")) - \\begin{tabular}{lr} - \\hline - spam$_9$ & 41.9999 \\\\ - \\emph{eggs} & 451 \\\\ - \\hline - \\end{tabular} - - "latex_booktabs" produces a tabular environment of LaTeX document markup - using the booktabs.sty package: - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], tablefmt="latex_booktabs")) - \\begin{tabular}{lr} - \\toprule - spam & 41.9999 \\\\ - eggs & 451 \\\\ - \\bottomrule - \\end{tabular} - - "latex_longtable" produces a tabular environment that can stretch along - multiple pages, using the longtable package for LaTeX. - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], tablefmt="latex_longtable")) - \\begin{longtable}{lr} - \\hline - spam & 41.9999 \\\\ - eggs & 451 \\\\ - \\hline - \\end{longtable} - - - 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 - `disable_numparse=True`. For more fine grained control, a list column - indices is used to disable number parsing only on those columns - e.g. `disable_numparse=[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` - - """ - - if tabular_data is None: - tabular_data = [] - - list_of_lists, headers = _normalize_tabular_data( - tabular_data, headers, showindex=showindex - ) - list_of_lists, separating_lines = _remove_separating_lines(list_of_lists) - - if maxcolwidths is not None: - if len(list_of_lists): - num_cols = len(list_of_lists[0]) - else: - num_cols = 0 - if isinstance(maxcolwidths, int): # Expand scalar for all columns - maxcolwidths = _expand_iterable(maxcolwidths, num_cols, maxcolwidths) - else: # Ignore col width for any 'trailing' columns - maxcolwidths = _expand_iterable(maxcolwidths, num_cols, None) - - numparses = _expand_numparse(disable_numparse, num_cols) - list_of_lists = _wrap_text_to_colwidths( - list_of_lists, maxcolwidths, numparses=numparses - ) - - if maxheadercolwidths is not None: - num_cols = len(list_of_lists[0]) - if isinstance(maxheadercolwidths, int): # Expand scalar for all columns - maxheadercolwidths = _expand_iterable( - maxheadercolwidths, num_cols, maxheadercolwidths - ) - else: # Ignore col width for any 'trailing' columns - maxheadercolwidths = _expand_iterable(maxheadercolwidths, num_cols, None) - - numparses = _expand_numparse(disable_numparse, num_cols) - headers = _wrap_text_to_colwidths( - [headers], maxheadercolwidths, numparses=numparses - )[0] - - # empty values in the first column of RST tables should be escaped (issue #82) - # "" should be escaped as "\\ " or ".." - if tablefmt == "rst": - list_of_lists, headers = _rst_escape_first_column(list_of_lists, headers) - - # PrettyTable 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. - min_padding = MIN_PADDING - if tablefmt == "pretty": - min_padding = 0 - disable_numparse = 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) - plain_text = "\t".join( - chain( - # headers - map(_to_str, headers), - # rows: chain the rows together into a single iterable after mapping - # the bytestring conversino to each cell value - chain.from_iterable(map(_to_str, row) for row in list_of_lists), - ) - ) - - has_invisible = _ansi_codes.search(plain_text) is not None - - enable_widechars = wcwidth is not None and WIDE_CHARS_MODE - if ( - not isinstance(tablefmt, TableFormat) - and tablefmt in multiline_formats - and _is_multiline(plain_text) - ): - tablefmt = multiline_formats.get(tablefmt, tablefmt) - is_multiline = True - else: - is_multiline = False - width_fn = _choose_width_fn(has_invisible, enable_widechars, is_multiline) - - # format rows and columns, convert numeric values to strings - cols = list(izip_longest(*list_of_lists)) - numparses = _expand_numparse(disable_numparse, len(cols)) - coltypes = [_column_type(col, numparse=np) for col, np in zip(cols, numparses)] - if isinstance(floatfmt, str): # old version - float_formats = len(cols) * [ - floatfmt - ] # just duplicate the string to use in each column - else: # if floatfmt is list, tuple etc we have one per column - float_formats = list(floatfmt) - if len(float_formats) < len(cols): - float_formats.extend((len(cols) - len(float_formats)) * [_DEFAULT_FLOATFMT]) - if isinstance(intfmt, str): # old version - int_formats = len(cols) * [ - intfmt - ] # just duplicate the string to use in each column - else: # if intfmt is list, tuple etc we have one per column - int_formats = list(intfmt) - if len(int_formats) < len(cols): - int_formats.extend((len(cols) - len(int_formats)) * [_DEFAULT_INTFMT]) - if isinstance(missingval, str): - missing_vals = len(cols) * [missingval] - else: - missing_vals = list(missingval) - if len(missing_vals) < len(cols): - missing_vals.extend((len(cols) - len(missing_vals)) * [_DEFAULT_MISSINGVAL]) - cols = [ - [_format(v, ct, fl_fmt, int_fmt, miss_v, has_invisible) for v in c] - for c, ct, fl_fmt, int_fmt, miss_v in zip( - cols, coltypes, float_formats, int_formats, missing_vals - ) - ] - - # align columns - aligns = [numalign if ct in [int, float] else stralign for ct in coltypes] - if colalign is not None: - assert isinstance(colalign, Iterable) - for idx, align in enumerate(colalign): - aligns[idx] = align - minwidths = ( - [width_fn(h) + min_padding for h in headers] if headers else [0] * len(cols) - ) - cols = [ - _align_column(c, a, minw, has_invisible, enable_widechars, is_multiline) - for c, a, minw in zip(cols, aligns, minwidths) - ] - - if headers: - # align headers and add headers - t_cols = cols or [[""]] * len(headers) - t_aligns = aligns or [stralign] * len(headers) - minwidths = [ - max(minw, max(width_fn(cl) for cl in c)) - for minw, c in zip(minwidths, t_cols) - ] - headers = [ - _align_header(h, a, minw, width_fn(h), is_multiline, width_fn) - for h, a, minw in zip(headers, t_aligns, minwidths) - ] - rows = list(zip(*cols)) - else: - minwidths = [max(width_fn(cl) for cl in c) for c in cols] - rows = list(zip(*cols)) - - if not isinstance(tablefmt, TableFormat): - tablefmt = _table_formats.get(tablefmt, _table_formats["simple"]) - - ra_default = rowalign if isinstance(rowalign, str) else None - rowaligns = _expand_iterable(rowalign, len(rows), ra_default) - _reinsert_separating_lines(rows, separating_lines) - - return _format_table( - tablefmt, headers, rows, minwidths, aligns, is_multiline, rowaligns=rowaligns - ) - - -def _expand_numparse(disable_numparse, column_count): - """ - Return a list of bools of length `column_count` which indicates whether - number parsing should be used on each column. - If `disable_numparse` is a list of indices, each of those indices are False, - and everything else is True. - If `disable_numparse` is a bool, then the returned list is all the same. - """ - if isinstance(disable_numparse, Iterable): - numparses = [True] * column_count - for index in disable_numparse: - numparses[index] = False - return numparses - else: - return [not disable_numparse] * column_count - - -def _expand_iterable(original, num_desired, default): - """ - Expands the `original` argument to return a return a list of - length `num_desired`. If `original` is shorter than `num_desired`, 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 `num_desired` completely populated with `default will be returned - """ - if isinstance(original, Iterable) and not isinstance(original, str): - return original + [default] * (num_desired - len(original)) - else: - return [default] * num_desired - - -def _pad_row(cells, padding): - if cells: - pad = " " * padding - padded_cells = [pad + cell + pad for cell in cells] - return padded_cells - else: - return cells - - -def _build_simple_row(padded_cells, rowfmt): - "Format row according to DataRow format without padding." - begin, sep, end = rowfmt - return (begin + sep.join(padded_cells) + end).rstrip() - - -def _build_row(padded_cells, colwidths, colaligns, rowfmt): - "Return a string which represents a row of data cells." - if not rowfmt: - return None - if hasattr(rowfmt, "__call__"): - return rowfmt(padded_cells, colwidths, colaligns) - else: - return _build_simple_row(padded_cells, rowfmt) - - -def _append_basic_row(lines, padded_cells, colwidths, colaligns, rowfmt, rowalign=None): - # NOTE: rowalign is ignored and exists for api compatibility with _append_multiline_row - lines.append(_build_row(padded_cells, colwidths, colaligns, rowfmt)) - return lines - - -def _align_cell_veritically(text_lines, num_lines, column_width, row_alignment): - delta_lines = num_lines - len(text_lines) - blank = [" " * column_width] - if row_alignment == "bottom": - return blank * delta_lines + text_lines - elif row_alignment == "center": - top_delta = delta_lines // 2 - bottom_delta = delta_lines - top_delta - return top_delta * blank + text_lines + bottom_delta * blank - else: - return text_lines + blank * delta_lines - - -def _append_multiline_row( - lines, padded_multiline_cells, padded_widths, colaligns, rowfmt, pad, rowalign=None -): - colwidths = [w - 2 * pad for w in padded_widths] - cells_lines = [c.splitlines() for c in padded_multiline_cells] - nlines = max(map(len, cells_lines)) # number of lines in the row - # vertically pad cells where some lines are missing - # cells_lines = [ - # (cl + [" " * w] * (nlines - len(cl))) for cl, w in zip(cells_lines, colwidths) - # ] - - cells_lines = [ - _align_cell_veritically(cl, nlines, w, rowalign) - for cl, w in zip(cells_lines, colwidths) - ] - lines_cells = [[cl[i] for cl in cells_lines] for i in range(nlines)] - for ln in lines_cells: - padded_ln = _pad_row(ln, pad) - _append_basic_row(lines, padded_ln, colwidths, colaligns, rowfmt) - return lines - - -def _build_line(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 _build_simple_row(cells, (begin, sep, end)) - - -def _append_line(lines, colwidths, colaligns, linefmt): - lines.append(_build_line(colwidths, colaligns, linefmt)) - return lines - - -class JupyterHTMLStr(str): - """Wrap the string with a _repr_html_ method so that Jupyter - displays the HTML table""" - - def _repr_html_(self): - return self - - @property - def str(self): - """add a .str property so that the raw string is still accessible""" - return self - - -def _format_table(fmt, headers, rows, colwidths, colaligns, is_multiline, rowaligns): - """Produce a plain-text representation of the table.""" - lines = [] - hidden = fmt.with_header_hide if (headers and fmt.with_header_hide) else [] - pad = fmt.padding - headerrow = fmt.headerrow - - padded_widths = [(w + 2 * pad) for w in colwidths] - if is_multiline: - pad_row = lambda row, _: row # noqa do it later, in _append_multiline_row - append_row = partial(_append_multiline_row, pad=pad) - else: - pad_row = _pad_row - append_row = _append_basic_row - - padded_headers = pad_row(headers, pad) - padded_rows = [pad_row(row, pad) for row in rows] - - if fmt.lineabove and "lineabove" not in hidden: - _append_line(lines, padded_widths, colaligns, fmt.lineabove) - - if padded_headers: - append_row(lines, padded_headers, padded_widths, colaligns, headerrow) - if fmt.linebelowheader and "linebelowheader" not in hidden: - _append_line(lines, padded_widths, colaligns, fmt.linebelowheader) - - if padded_rows and fmt.linebetweenrows and "linebetweenrows" not in hidden: - # initial rows with a line below - for row, ralign in zip(padded_rows[:-1], rowaligns): - append_row( - lines, row, padded_widths, colaligns, fmt.datarow, rowalign=ralign - ) - _append_line(lines, padded_widths, colaligns, fmt.linebetweenrows) - # the last row without a line below - append_row( - lines, - padded_rows[-1], - padded_widths, - colaligns, - fmt.datarow, - rowalign=rowaligns[-1], - ) - else: - separating_line = ( - fmt.linebetweenrows - or fmt.linebelowheader - or fmt.linebelow - or fmt.lineabove - or Line("", "", "", "") - ) - for row in padded_rows: - # test to see if either the 1st column or the 2nd column (account for showindex) has - # the SEPARATING_LINE flag - if _is_separating_line(row): - _append_line(lines, padded_widths, colaligns, separating_line) - else: - append_row(lines, row, padded_widths, colaligns, fmt.datarow) - - if fmt.linebelow and "linebelow" not in hidden: - _append_line(lines, padded_widths, colaligns, fmt.linebelow) - - if headers or rows: - output = "\n".join(lines) - if fmt.lineabove == _html_begin_table_without_header: - return JupyterHTMLStr(output) - else: - return output - else: # a completely empty table - return "" - - -class _CustomTextWrap(textwrap.TextWrapper): - """A custom implementation of CPython's textwrap.TextWrapper. This supports - both wide characters (Korea, Japanese, Chinese) - including mixed string. - For the most part, the `_handle_long_word` and `_wrap_chunks` functions were - copy pasted out of the CPython baseline, and updated with our custom length - and line appending logic. - """ - - def __init__(self, *args, **kwargs): - self._active_codes = [] - self.max_lines = None # For python2 compatibility - textwrap.TextWrapper.__init__(self, *args, **kwargs) - - @staticmethod - def _len(item): - """Custom len that gets console column width for wide - and non-wide characters as well as ignores color codes""" - stripped = _strip_ansi(item) - if wcwidth: - return wcwidth.wcswidth(stripped) - else: - return len(stripped) - - def _update_lines(self, lines, new_line): - """Adds a new line to the list of lines the text is being wrapped into - This function will also track any ANSI color codes in this string as well - as add any colors from previous lines order to preserve the same formatting - as a single unwrapped string. - """ - code_matches = [x for x in _ansi_codes.finditer(new_line)] - color_codes = [ - code.string[code.span()[0] : code.span()[1]] for code in code_matches - ] - - # Add color codes from earlier in the unwrapped line, and then track any new ones we add. - new_line = "".join(self._active_codes) + new_line - - for code in color_codes: - if code != _ansi_color_reset_code: - self._active_codes.append(code) - else: # A single reset code resets everything - self._active_codes = [] - - # Always ensure each line is color terminted if any colors are - # still active, otherwise colors will bleed into other cells on the console - if len(self._active_codes) > 0: - new_line = new_line + _ansi_color_reset_code - - lines.append(new_line) - - def _handle_long_word(self, reversed_chunks, cur_line, cur_len, width): - """_handle_long_word(chunks : [string], - cur_line : [string], - cur_len : int, width : int) - Handle a chunk of text (most likely a word, not whitespace) that - is too long to fit in any line. - """ - # Figure out when indent is larger than the specified width, and make - # sure at least one character is stripped off on every pass - if width < 1: - space_left = 1 - else: - space_left = width - cur_len - - # If we're allowed to break long words, then do so: put as much - # of the next chunk onto the current line as will fit. - if self.break_long_words: - # Tabulate Custom: Build the string up piece-by-piece in order to - # take each charcter's width into account - chunk = reversed_chunks[-1] - i = 1 - while self._len(chunk[:i]) <= space_left: - i = i + 1 - cur_line.append(chunk[: i - 1]) - reversed_chunks[-1] = chunk[i - 1 :] - - # Otherwise, we have to preserve the long word intact. Only add - # it to the current line if there's nothing already there -- - # that minimizes how much we violate the width constraint. - elif not cur_line: - cur_line.append(reversed_chunks.pop()) - - # If we're not allowed to break long words, and there's already - # text on the current line, do nothing. Next time through the - # main loop of _wrap_chunks(), we'll wind up here again, but - # cur_len will be zero, so the next line will be entirely - # devoted to the long word that we can't handle right now. - - def _wrap_chunks(self, chunks): - """_wrap_chunks(chunks : [string]) -> [string] - Wrap a sequence of text chunks and return a list of lines of - length 'self.width' or less. (If 'break_long_words' is false, - some lines may be longer than this.) Chunks correspond roughly - to words and the whitespace between them: each chunk is - indivisible (modulo 'break_long_words'), but a line break can - come between any two chunks. Chunks should not have internal - whitespace; ie. a chunk is either all whitespace or a "word". - Whitespace chunks will be removed from the beginning and end of - lines, but apart from that whitespace is preserved. - """ - lines = [] - if self.width <= 0: - raise ValueError("invalid width %r (must be > 0)" % self.width) - if self.max_lines is not None: - if self.max_lines > 1: - indent = self.subsequent_indent - else: - indent = self.initial_indent - if self._len(indent) + self._len(self.placeholder.lstrip()) > self.width: - raise ValueError("placeholder too large for max width") - - # Arrange in reverse order so items can be efficiently popped - # from a stack of chucks. - chunks.reverse() - - while chunks: - - # Start the list of chunks that will make up the current line. - # cur_len is just the length of all the chunks in cur_line. - cur_line = [] - cur_len = 0 - - # Figure out which static string will prefix this line. - if lines: - indent = self.subsequent_indent - else: - indent = self.initial_indent - - # Maximum width for this line. - width = self.width - self._len(indent) - - # First chunk on line is whitespace -- drop it, unless this - # is the very beginning of the text (ie. no lines started yet). - if self.drop_whitespace and chunks[-1].strip() == "" and lines: - del chunks[-1] - - while chunks: - chunk_len = self._len(chunks[-1]) - - # Can at least squeeze this chunk onto the current line. - if cur_len + chunk_len <= width: - cur_line.append(chunks.pop()) - cur_len += chunk_len - - # Nope, this line is full. - else: - break - - # The current line is full, and the next chunk is too big to - # fit on *any* line (not just this one). - if chunks and self._len(chunks[-1]) > width: - self._handle_long_word(chunks, cur_line, cur_len, width) - cur_len = sum(map(self._len, cur_line)) - - # If the last chunk on this line is all whitespace, drop it. - if self.drop_whitespace and cur_line and cur_line[-1].strip() == "": - cur_len -= self._len(cur_line[-1]) - del cur_line[-1] - - if cur_line: - if ( - self.max_lines is None - or len(lines) + 1 < self.max_lines - or ( - not chunks - or self.drop_whitespace - and len(chunks) == 1 - and not chunks[0].strip() - ) - and cur_len <= width - ): - # Convert current line back to a string and store it in - # list of all lines (return value). - self._update_lines(lines, indent + "".join(cur_line)) - else: - while cur_line: - if ( - cur_line[-1].strip() - and cur_len + self._len(self.placeholder) <= width - ): - cur_line.append(self.placeholder) - self._update_lines(lines, indent + "".join(cur_line)) - break - cur_len -= self._len(cur_line[-1]) - del cur_line[-1] - else: - if lines: - prev_line = lines[-1].rstrip() - if ( - self._len(prev_line) + self._len(self.placeholder) - <= self.width - ): - lines[-1] = prev_line + self.placeholder - break - self._update_lines(lines, indent + self.placeholder.lstrip()) - break - - return lines - - -def _main(): - """\ - Usage: tabulate [options] [FILE ...] - - Pretty-print tabular data. - See also https://github.com/astanin/python-tabulate - - FILE a filename of the file with tabular data; - if "-" or missing, read data from stdin. - - Options: - - -h, --help show this message - -1, --header use the first row of data as a table header - -o FILE, --output FILE print table to FILE (default: stdout) - -s REGEXP, --sep REGEXP use a custom column separator (default: whitespace) - -F FPFMT, --float FPFMT floating point number format (default: g) - -I INTFMT, --int INTFMT integer point number format (default: "") - -f FMT, --format FMT set output table format; supported formats: - plain, simple, grid, fancy_grid, pipe, orgtbl, - rst, mediawiki, html, latex, latex_raw, - latex_booktabs, latex_longtable, tsv - (default: simple) - """ - import getopt - import sys - import textwrap - - usage = textwrap.dedent(_main.__doc__) - try: - opts, args = getopt.getopt( - sys.argv[1:], - "h1o:s:F:A:f:", - ["help", "header", "output", "sep=", "float=", "int=", "align=", "format="], - ) - except getopt.GetoptError as e: - print(e) - print(usage) - sys.exit(2) - headers = [] - floatfmt = _DEFAULT_FLOATFMT - intfmt = _DEFAULT_INTFMT - colalign = None - tablefmt = "simple" - sep = r"\s+" - outfile = "-" - for opt, value in opts: - if opt in ["-1", "--header"]: - headers = "firstrow" - elif opt in ["-o", "--output"]: - outfile = value - elif opt in ["-F", "--float"]: - floatfmt = value - elif opt in ["-I", "--int"]: - intfmt = value - elif opt in ["-C", "--colalign"]: - colalign = value.split() - elif opt in ["-f", "--format"]: - if value not in tabulate_formats: - print("%s is not a supported table format" % value) - print(usage) - sys.exit(3) - tablefmt = value - elif opt in ["-s", "--sep"]: - sep = value - elif opt in ["-h", "--help"]: - print(usage) - sys.exit(0) - files = [sys.stdin] if not args else args - with (sys.stdout if outfile == "-" else open(outfile, "w")) as out: - for f in files: - if f == "-": - f = sys.stdin - if _is_file(f): - _pprint_file( - f, - headers=headers, - tablefmt=tablefmt, - sep=sep, - floatfmt=floatfmt, - intfmt=intfmt, - file=out, - colalign=colalign, - ) - else: - with open(f) as fobj: - _pprint_file( - fobj, - headers=headers, - tablefmt=tablefmt, - sep=sep, - floatfmt=floatfmt, - intfmt=intfmt, - file=out, - colalign=colalign, - ) - - -def _pprint_file(fobject, headers, tablefmt, sep, floatfmt, intfmt, file, colalign): - rows = fobject.readlines() - table = [re.split(sep, r.rstrip()) for r in rows if r.strip()] - print( - tabulate( - table, - headers, - tablefmt, - floatfmt=floatfmt, - intfmt=intfmt, - colalign=colalign, - ), - file=file, - ) - - -if __name__ == "__main__": - _main() +"""Pretty-print tabular data.""" + +from collections import namedtuple +from collections.abc import Iterable, Sized +from html import escape as htmlescape +from itertools import chain, zip_longest as izip_longest +from functools import reduce, partial +import io +import re +import math +import textwrap +import dataclasses + +try: + import wcwidth # optional wide-character (CJK) support +except ImportError: + wcwidth = None + + +def _is_file(f): + return isinstance(f, io.IOBase) + + +__all__ = ["tabulate", "tabulate_formats", "simple_separated_format"] +try: + from .version import version as __version__ # noqa: F401 +except ImportError: + pass # running __init__.py as a script, AppVeyor pytests + + +# minimum extra space in headers +MIN_PADDING = 2 + +# Whether or not to preserve leading/trailing whitespace in data. +PRESERVE_WHITESPACE = False + +_DEFAULT_FLOATFMT = "g" +_DEFAULT_INTFMT = "" +_DEFAULT_MISSINGVAL = "" +# default align will be overwritten by "left", "center" or "decimal" +# depending on the formatter +_DEFAULT_ALIGN = "default" + + +# if True, enable wide-character (CJK) support +WIDE_CHARS_MODE = wcwidth is not None + +# 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. +# +# with_header_hide: +# +# - 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", + "with_header_hide", + ], +) + + +def _is_separating_line(row): + row_type = type(row) + is_sl = (row_type == list or row_type == str) and ( + (len(row) >= 1 and row[0] == SEPARATING_LINE) + or (len(row) >= 2 and row[1] == SEPARATING_LINE) + ) + return is_sl + + +def _pipe_segment_with_colons(align, colwidth): + """Return a segment of a horizontal line with optional colons which + indicate column's alignment (as in `pipe` output format).""" + w = colwidth + if align in ["right", "decimal"]: + return ("-" * (w - 1)) + ":" + elif align == "center": + return ":" + ("-" * (w - 2)) + ":" + elif align == "left": + return ":" + ("-" * (w - 1)) + else: + return "-" * w + + +def _pipe_line_with_colons(colwidths, colaligns): + """Return a horizontal line with optional colons to indicate column's + alignment (as in `pipe` output format).""" + if not colaligns: # e.g. printing an empty data frame (github issue #15) + colaligns = [""] * len(colwidths) + segments = [_pipe_segment_with_colons(a, w) for a, w in zip(colaligns, colwidths)] + return "|" + "|".join(segments) + "|" + + +def _mediawiki_row_with_attrs(separator, cell_values, colwidths, colaligns): + alignment = { + "left": "", + "right": 'style="text-align: right;"| ', + "center": 'style="text-align: center;"| ', + "decimal": 'style="text-align: right;"| ', + } + # hard-coded padding _around_ align attribute and value together + # rather than padding parameter which affects only the value + values_with_attrs = [ + " " + alignment.get(a, "") + c + " " for c, a in zip(cell_values, colaligns) + ] + colsep = separator * 2 + return (separator + colsep.join(values_with_attrs)).rstrip() + + +def _textile_row_with_attrs(cell_values, colwidths, colaligns): + cell_values[0] += " " + alignment = {"left": "<.", "right": ">.", "center": "=.", "decimal": ">."} + values = (alignment.get(a, "") + v for a, v in zip(colaligns, cell_values)) + return "|" + "|".join(values) + "|" + + +def _html_begin_table_without_header(colwidths_ignore, colaligns_ignore): + # this table header will be suppressed if there is a header row + return "\n" + + +def _html_row_with_attrs(celltag, unsafe, cell_values, colwidths, colaligns): + alignment = { + "left": "", + "right": ' style="text-align: right;"', + "center": ' style="text-align: center;"', + "decimal": ' style="text-align: right;"', + } + if unsafe: + values_with_attrs = [ + "<{0}{1}>{2}".format(celltag, alignment.get(a, ""), c) + for c, a in zip(cell_values, colaligns) + ] + else: + values_with_attrs = [ + "<{0}{1}>{2}".format(celltag, alignment.get(a, ""), htmlescape(c)) + for c, a in zip(cell_values, colaligns) + ] + rowhtml = "{}".format("".join(values_with_attrs).rstrip()) + if celltag == "th": # it's a header row, create a new table header + rowhtml = f"
\n\n{rowhtml}\n\n" + return rowhtml + + +def _moin_row_with_attrs(celltag, cell_values, colwidths, colaligns, header=""): + alignment = { + "left": "", + "right": '', + "center": '', + "decimal": '', + } + values_with_attrs = [ + "{}{} {} ".format(celltag, alignment.get(a, ""), header + c + header) + for c, a in zip(cell_values, colaligns) + ] + return "".join(values_with_attrs) + "||" + + +def _latex_line_begin_tabular(colwidths, colaligns, booktabs=False, longtable=False): + alignment = {"left": "l", "right": "r", "center": "c", "decimal": "r"} + tabular_columns_fmt = "".join([alignment.get(a, "l") for a in colaligns]) + return "\n".join( + [ + ("\\begin{tabular}{" if not longtable else "\\begin{longtable}{") + + tabular_columns_fmt + + "}", + "\\toprule" if booktabs else "\\hline", + ] + ) + + +def _asciidoc_row(is_header, *args): + """handle header and data rows for asciidoc format""" + + def make_header_line(is_header, colwidths, colaligns): + # generate the column specifiers + + alignment = {"left": "<", "right": ">", "center": "^", "decimal": ">"} + # use the column widths generated by tabulate for the asciidoc column width specifiers + asciidoc_alignments = zip( + colwidths, [alignment[colalign] for colalign in colaligns] + ) + asciidoc_column_specifiers = [ + "{:d}{}".format(width, align) for width, align in asciidoc_alignments + ] + header_list = ['cols="' + (",".join(asciidoc_column_specifiers)) + '"'] + + # generate the list of options (currently only "header") + options_list = [] + + if is_header: + options_list.append("header") + + if options_list: + header_list += ['options="' + ",".join(options_list) + '"'] + + # generate the list of entries in the table header field + + return "[{}]\n|====".format(",".join(header_list)) + + if len(args) == 2: + # two arguments are passed if called in the context of aboveline + # print the table header with column widths and optional header tag + return make_header_line(False, *args) + + elif len(args) == 3: + # three arguments are passed if called in the context of dataline or headerline + # print the table line and make the aboveline if it is a header + + cell_values, colwidths, colaligns = args + data_line = "|" + "|".join(cell_values) + + if is_header: + return make_header_line(True, colwidths, colaligns) + "\n" + data_line + else: + return data_line + + else: + raise ValueError( + " _asciidoc_row() requires two (colwidths, colaligns) " + + "or three (cell_values, colwidths, colaligns) arguments) " + ) + + +LATEX_ESCAPE_RULES = { + r"&": r"\&", + r"%": r"\%", + r"$": r"\$", + r"#": r"\#", + r"_": r"\_", + r"^": r"\^{}", + r"{": r"\{", + r"}": r"\}", + r"~": r"\textasciitilde{}", + "\\": r"\textbackslash{}", + r"<": r"\ensuremath{<}", + r">": r"\ensuremath{>}", +} + + +def _latex_row(cell_values, colwidths, colaligns, escrules=LATEX_ESCAPE_RULES): + def escape_char(c): + return escrules.get(c, c) + + escaped_values = ["".join(map(escape_char, cell)) for cell in cell_values] + rowfmt = DataRow("", "&", "\\\\") + return _build_simple_row(escaped_values, rowfmt) + + +def _rst_escape_first_column(rows, headers): + def escape_empty(val): + if isinstance(val, (str, bytes)) and not val.strip(): + return ".." + else: + return val + + new_headers = list(headers) + new_rows = [] + if headers: + new_headers[0] = escape_empty(headers[0]) + for row in rows: + new_row = list(row) + if new_row: + new_row[0] = escape_empty(row[0]) + new_rows.append(new_row) + return new_rows, new_headers + + +_table_formats = { + "simple": TableFormat( + lineabove=Line("", "-", " ", ""), + linebelowheader=Line("", "-", " ", ""), + linebetweenrows=None, + linebelow=Line("", "-", " ", ""), + headerrow=DataRow("", " ", ""), + datarow=DataRow("", " ", ""), + padding=0, + with_header_hide=["lineabove", "linebelow"], + ), + "plain": TableFormat( + lineabove=None, + linebelowheader=None, + linebetweenrows=None, + linebelow=None, + headerrow=DataRow("", " ", ""), + datarow=DataRow("", " ", ""), + padding=0, + with_header_hide=None, + ), + "grid": TableFormat( + lineabove=Line("+", "-", "+", "+"), + linebelowheader=Line("+", "=", "+", "+"), + linebetweenrows=Line("+", "-", "+", "+"), + linebelow=Line("+", "-", "+", "+"), + headerrow=DataRow("|", "|", "|"), + datarow=DataRow("|", "|", "|"), + padding=1, + with_header_hide=None, + ), + "simple_grid": TableFormat( + lineabove=Line("┌", "─", "┬", "┐"), + linebelowheader=Line("├", "─", "┼", "┤"), + linebetweenrows=Line("├", "─", "┼", "┤"), + linebelow=Line("└", "─", "┴", "┘"), + headerrow=DataRow("│", "│", "│"), + datarow=DataRow("│", "│", "│"), + padding=1, + with_header_hide=None, + ), + "rounded_grid": TableFormat( + lineabove=Line("╭", "─", "┬", "╮"), + linebelowheader=Line("├", "─", "┼", "┤"), + linebetweenrows=Line("├", "─", "┼", "┤"), + linebelow=Line("╰", "─", "┴", "╯"), + headerrow=DataRow("│", "│", "│"), + datarow=DataRow("│", "│", "│"), + padding=1, + with_header_hide=None, + ), + "heavy_grid": TableFormat( + lineabove=Line("┏", "━", "┳", "┓"), + linebelowheader=Line("┣", "━", "╋", "┫"), + linebetweenrows=Line("┣", "━", "╋", "┫"), + linebelow=Line("┗", "━", "┻", "┛"), + headerrow=DataRow("┃", "┃", "┃"), + datarow=DataRow("┃", "┃", "┃"), + padding=1, + with_header_hide=None, + ), + "mixed_grid": TableFormat( + lineabove=Line("┍", "━", "┯", "┑"), + linebelowheader=Line("┝", "━", "┿", "┥"), + linebetweenrows=Line("├", "─", "┼", "┤"), + linebelow=Line("┕", "━", "┷", "┙"), + headerrow=DataRow("│", "│", "│"), + datarow=DataRow("│", "│", "│"), + padding=1, + with_header_hide=None, + ), + "double_grid": TableFormat( + lineabove=Line("╔", "═", "╦", "╗"), + linebelowheader=Line("╠", "═", "╬", "╣"), + linebetweenrows=Line("╠", "═", "╬", "╣"), + linebelow=Line("╚", "═", "╩", "╝"), + headerrow=DataRow("║", "║", "║"), + datarow=DataRow("║", "║", "║"), + padding=1, + with_header_hide=None, + ), + "fancy_grid": TableFormat( + lineabove=Line("╒", "═", "╤", "╕"), + linebelowheader=Line("╞", "═", "╪", "╡"), + linebetweenrows=Line("├", "─", "┼", "┤"), + linebelow=Line("╘", "═", "╧", "╛"), + headerrow=DataRow("│", "│", "│"), + datarow=DataRow("│", "│", "│"), + padding=1, + with_header_hide=None, + ), + "outline": TableFormat( + lineabove=Line("+", "-", "+", "+"), + linebelowheader=Line("+", "=", "+", "+"), + linebetweenrows=None, + linebelow=Line("+", "-", "+", "+"), + headerrow=DataRow("|", "|", "|"), + datarow=DataRow("|", "|", "|"), + padding=1, + with_header_hide=None, + ), + "simple_outline": TableFormat( + lineabove=Line("┌", "─", "┬", "┐"), + linebelowheader=Line("├", "─", "┼", "┤"), + linebetweenrows=None, + linebelow=Line("└", "─", "┴", "┘"), + headerrow=DataRow("│", "│", "│"), + datarow=DataRow("│", "│", "│"), + padding=1, + with_header_hide=None, + ), + "rounded_outline": TableFormat( + lineabove=Line("╭", "─", "┬", "╮"), + linebelowheader=Line("├", "─", "┼", "┤"), + linebetweenrows=None, + linebelow=Line("╰", "─", "┴", "╯"), + headerrow=DataRow("│", "│", "│"), + datarow=DataRow("│", "│", "│"), + padding=1, + with_header_hide=None, + ), + "heavy_outline": TableFormat( + lineabove=Line("┏", "━", "┳", "┓"), + linebelowheader=Line("┣", "━", "╋", "┫"), + linebetweenrows=None, + linebelow=Line("┗", "━", "┻", "┛"), + headerrow=DataRow("┃", "┃", "┃"), + datarow=DataRow("┃", "┃", "┃"), + padding=1, + with_header_hide=None, + ), + "mixed_outline": TableFormat( + lineabove=Line("┍", "━", "┯", "┑"), + linebelowheader=Line("┝", "━", "┿", "┥"), + linebetweenrows=None, + linebelow=Line("┕", "━", "┷", "┙"), + headerrow=DataRow("│", "│", "│"), + datarow=DataRow("│", "│", "│"), + padding=1, + with_header_hide=None, + ), + "double_outline": TableFormat( + lineabove=Line("╔", "═", "╦", "╗"), + linebelowheader=Line("╠", "═", "╬", "╣"), + linebetweenrows=None, + linebelow=Line("╚", "═", "╩", "╝"), + headerrow=DataRow("║", "║", "║"), + datarow=DataRow("║", "║", "║"), + padding=1, + with_header_hide=None, + ), + "fancy_outline": TableFormat( + lineabove=Line("╒", "═", "╤", "╕"), + linebelowheader=Line("╞", "═", "╪", "╡"), + linebetweenrows=None, + linebelow=Line("╘", "═", "╧", "╛"), + headerrow=DataRow("│", "│", "│"), + datarow=DataRow("│", "│", "│"), + padding=1, + with_header_hide=None, + ), + "github": TableFormat( + lineabove=Line("|", "-", "|", "|"), + linebelowheader=Line("|", "-", "|", "|"), + linebetweenrows=None, + linebelow=None, + headerrow=DataRow("|", "|", "|"), + datarow=DataRow("|", "|", "|"), + padding=1, + with_header_hide=["lineabove"], + ), + "pipe": TableFormat( + lineabove=_pipe_line_with_colons, + linebelowheader=_pipe_line_with_colons, + linebetweenrows=None, + linebelow=None, + headerrow=DataRow("|", "|", "|"), + datarow=DataRow("|", "|", "|"), + padding=1, + with_header_hide=["lineabove"], + ), + "orgtbl": TableFormat( + lineabove=None, + linebelowheader=Line("|", "-", "+", "|"), + linebetweenrows=None, + linebelow=None, + headerrow=DataRow("|", "|", "|"), + datarow=DataRow("|", "|", "|"), + padding=1, + with_header_hide=None, + ), + "jira": TableFormat( + lineabove=None, + linebelowheader=None, + linebetweenrows=None, + linebelow=None, + headerrow=DataRow("||", "||", "||"), + datarow=DataRow("|", "|", "|"), + padding=1, + with_header_hide=None, + ), + "presto": TableFormat( + lineabove=None, + linebelowheader=Line("", "-", "+", ""), + linebetweenrows=None, + linebelow=None, + headerrow=DataRow("", "|", ""), + datarow=DataRow("", "|", ""), + padding=1, + with_header_hide=None, + ), + "pretty": TableFormat( + lineabove=Line("+", "-", "+", "+"), + linebelowheader=Line("+", "-", "+", "+"), + linebetweenrows=None, + linebelow=Line("+", "-", "+", "+"), + headerrow=DataRow("|", "|", "|"), + datarow=DataRow("|", "|", "|"), + padding=1, + with_header_hide=None, + ), + "psql": TableFormat( + lineabove=Line("+", "-", "+", "+"), + linebelowheader=Line("|", "-", "+", "|"), + linebetweenrows=None, + linebelow=Line("+", "-", "+", "+"), + headerrow=DataRow("|", "|", "|"), + datarow=DataRow("|", "|", "|"), + padding=1, + with_header_hide=None, + ), + "rst": TableFormat( + lineabove=Line("", "=", " ", ""), + linebelowheader=Line("", "=", " ", ""), + linebetweenrows=None, + linebelow=Line("", "=", " ", ""), + headerrow=DataRow("", " ", ""), + datarow=DataRow("", " ", ""), + padding=0, + with_header_hide=None, + ), + "mediawiki": TableFormat( + lineabove=Line( + '{| class="wikitable" style="text-align: left;"', + "", + "", + "\n|+ \n|-", + ), + linebelowheader=Line("|-", "", "", ""), + linebetweenrows=Line("|-", "", "", ""), + linebelow=Line("|}", "", "", ""), + headerrow=partial(_mediawiki_row_with_attrs, "!"), + datarow=partial(_mediawiki_row_with_attrs, "|"), + padding=0, + with_header_hide=None, + ), + "moinmoin": TableFormat( + lineabove=None, + linebelowheader=None, + linebetweenrows=None, + linebelow=None, + headerrow=partial(_moin_row_with_attrs, "||", header="'''"), + datarow=partial(_moin_row_with_attrs, "||"), + padding=1, + with_header_hide=None, + ), + "youtrack": TableFormat( + lineabove=None, + linebelowheader=None, + linebetweenrows=None, + linebelow=None, + headerrow=DataRow("|| ", " || ", " || "), + datarow=DataRow("| ", " | ", " |"), + padding=1, + with_header_hide=None, + ), + "html": TableFormat( + lineabove=_html_begin_table_without_header, + linebelowheader="", + linebetweenrows=None, + linebelow=Line("\n
", "", "", ""), + headerrow=partial(_html_row_with_attrs, "th", False), + datarow=partial(_html_row_with_attrs, "td", False), + padding=0, + with_header_hide=["lineabove"], + ), + "unsafehtml": TableFormat( + lineabove=_html_begin_table_without_header, + linebelowheader="", + linebetweenrows=None, + linebelow=Line("\n", "", "", ""), + headerrow=partial(_html_row_with_attrs, "th", True), + datarow=partial(_html_row_with_attrs, "td", True), + padding=0, + with_header_hide=["lineabove"], + ), + "latex": TableFormat( + lineabove=_latex_line_begin_tabular, + linebelowheader=Line("\\hline", "", "", ""), + linebetweenrows=None, + linebelow=Line("\\hline\n\\end{tabular}", "", "", ""), + headerrow=_latex_row, + datarow=_latex_row, + padding=1, + with_header_hide=None, + ), + "latex_raw": TableFormat( + lineabove=_latex_line_begin_tabular, + linebelowheader=Line("\\hline", "", "", ""), + linebetweenrows=None, + linebelow=Line("\\hline\n\\end{tabular}", "", "", ""), + headerrow=partial(_latex_row, escrules={}), + datarow=partial(_latex_row, escrules={}), + padding=1, + with_header_hide=None, + ), + "latex_booktabs": TableFormat( + lineabove=partial(_latex_line_begin_tabular, booktabs=True), + linebelowheader=Line("\\midrule", "", "", ""), + linebetweenrows=None, + linebelow=Line("\\bottomrule\n\\end{tabular}", "", "", ""), + headerrow=_latex_row, + datarow=_latex_row, + padding=1, + with_header_hide=None, + ), + "latex_longtable": TableFormat( + lineabove=partial(_latex_line_begin_tabular, longtable=True), + linebelowheader=Line("\\hline\n\\endhead", "", "", ""), + linebetweenrows=None, + linebelow=Line("\\hline\n\\end{longtable}", "", "", ""), + headerrow=_latex_row, + datarow=_latex_row, + padding=1, + with_header_hide=None, + ), + "tsv": TableFormat( + lineabove=None, + linebelowheader=None, + linebetweenrows=None, + linebelow=None, + headerrow=DataRow("", "\t", ""), + datarow=DataRow("", "\t", ""), + padding=0, + with_header_hide=None, + ), + "textile": TableFormat( + lineabove=None, + linebelowheader=None, + linebetweenrows=None, + linebelow=None, + headerrow=DataRow("|_. ", "|_.", "|"), + datarow=_textile_row_with_attrs, + padding=1, + with_header_hide=None, + ), + "asciidoc": TableFormat( + lineabove=partial(_asciidoc_row, False), + linebelowheader=None, + linebetweenrows=None, + linebelow=Line("|====", "", "", ""), + headerrow=partial(_asciidoc_row, True), + datarow=partial(_asciidoc_row, False), + padding=1, + with_header_hide=["lineabove"], + ), +} + + +tabulate_formats = list(sorted(_table_formats.keys())) + +# The table formats for which multiline cells will be folded into subsequent +# table rows. The key is the original format specified at the API. The value is +# the format that will be used to represent the original format. +multiline_formats = { + "plain": "plain", + "simple": "simple", + "grid": "grid", + "simple_grid": "simple_grid", + "rounded_grid": "rounded_grid", + "heavy_grid": "heavy_grid", + "mixed_grid": "mixed_grid", + "double_grid": "double_grid", + "fancy_grid": "fancy_grid", + "pipe": "pipe", + "orgtbl": "orgtbl", + "jira": "jira", + "presto": "presto", + "pretty": "pretty", + "psql": "psql", + "rst": "rst", + "outline": "outline", + "simple_outline": "simple_outline", + "rounded_outline": "rounded_outline", + "heavy_outline": "heavy_outline", + "mixed_outline": "mixed_outline", + "double_outline": "double_outline", + "fancy_outline": "fancy_outline", +} + +# TODO: Add multiline support for the remaining table formats: +# - mediawiki: Replace \n with
+# - moinmoin: TBD +# - youtrack: TBD +# - html: Replace \n with
+# - latex*: Use "makecell" package: In header, replace X\nY with +# \thead{X\\Y} and in data row, replace X\nY with \makecell{X\\Y} +# - tsv: TBD +# - textile: Replace \n with
(must be well-formed XML) + +_multiline_codes = re.compile(r"\r|\n|\r\n") +_multiline_codes_bytes = 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}\\" + +_ansi_escape_pat = 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 + ) +""" +_ansi_codes = re.compile(_ansi_escape_pat, re.VERBOSE) +_ansi_codes_bytes = re.compile(_ansi_escape_pat.encode("utf8"), re.VERBOSE) +_ansi_color_reset_code = "\033[0m" + +_float_with_thousands_separators = re.compile( + r"^(([+-]?[0-9]{1,3})(?:,([0-9]{3}))*)?(?(1)\.[0-9]*|\.[0-9]+)?$" +) + + +def simple_separated_format(separator): + """Construct a simple TableFormat with columns separated by a separator. + + >>> tsv = simple_separated_format("\\t") ; \ + tabulate([["foo", 1], ["spam", 23]], tablefmt=tsv) == 'foo \\t 1\\nspam\\t23' + True + + """ + return TableFormat( + None, + None, + None, + None, + headerrow=DataRow("", separator, ""), + datarow=DataRow("", separator, ""), + padding=0, + with_header_hide=None, + ) + + +def _isnumber_with_thousands_separator(string): + """ + >>> _isnumber_with_thousands_separator(".") + False + >>> _isnumber_with_thousands_separator("1") + True + >>> _isnumber_with_thousands_separator("1.") + True + >>> _isnumber_with_thousands_separator(".1") + True + >>> _isnumber_with_thousands_separator("1000") + False + >>> _isnumber_with_thousands_separator("1,000") + True + >>> _isnumber_with_thousands_separator("1,0000") + False + >>> _isnumber_with_thousands_separator("1,000.1234") + True + >>> _isnumber_with_thousands_separator(b"1,000.1234") + True + >>> _isnumber_with_thousands_separator("+1,000.1234") + True + >>> _isnumber_with_thousands_separator("-1,000.1234") + True + """ + try: + string = string.decode() + except (UnicodeDecodeError, AttributeError): + pass + + return bool(re.match(_float_with_thousands_separators, string)) + + +def _isconvertible(conv, string): + try: + conv(string) + return True + except (ValueError, TypeError): + return False + + +def _isnumber(string): + """ + >>> _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): + """ + >>> _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(">> _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, has_invisible=True, numparse=True): + """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 has_invisible and isinstance(string, (str, bytes)): + string = _strip_ansi(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 _isnumber_with_thousands_separator(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: + return -1 # no point + else: + return -1 # not a number + + +def _padleft(width, s): + """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): + """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): + """Center string. + + >>> _padboth(6, '\u044f\u0439\u0446\u0430') == ' \u044f\u0439\u0446\u0430 ' + True + + """ + fmt = "{0:^%ds}" % width + return fmt.format(s) + + +def _padnone(ignore_width, s): + return s + + +def _strip_ansi(s): + r"""Remove ANSI escape sequences, both CSI (color codes, etc) 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(_strip_ansi('\x1B]8;;https://example.com\x1B\\This is a link\x1B]8;;\x1B\\')) + "'This is a link'" + + >>> repr(_strip_ansi('\x1b[31mred\x1b[0m text')) + "'red text'" + + """ + if isinstance(s, str): + return _ansi_codes.sub(r"\4", s) + else: # a bytestring + return _ansi_codes_bytes.sub(r"\4", s) + + +def _visible_width(s): + """Visible width of a printed string. ANSI color codes are removed. + + >>> _visible_width('\x1b[31mhello\x1b[0m'), _visible_width("world") + (5, 5) + + """ + # optional wide-character support + if wcwidth is not None and WIDE_CHARS_MODE: + len_fn = wcwidth.wcswidth + else: + len_fn = len + if isinstance(s, (str, bytes)): + return len_fn(_strip_ansi(s)) + else: + return len_fn(str(s)) + + +def _is_multiline(s): + if isinstance(s, str): + return bool(re.search(_multiline_codes, s)) + else: # a bytestring + return bool(re.search(_multiline_codes_bytes, s)) + + +def _multiline_width(multiline_s, line_width_fn=len): + """Visible width of a potentially multiline content.""" + return max(map(line_width_fn, re.split("[\r\n]", multiline_s))) + + +def _choose_width_fn(has_invisible, enable_widechars, is_multiline): + """Return a function to calculate visible cell width.""" + if has_invisible: + line_width_fn = _visible_width + elif enable_widechars: # optional wide-character support if available + line_width_fn = wcwidth.wcswidth + else: + line_width_fn = len + if is_multiline: + width_fn = lambda s: _multiline_width(s, line_width_fn) # noqa + else: + width_fn = line_width_fn + return width_fn + + +def _align_column_choose_padfn(strings, alignment, has_invisible): + 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 has_invisible: + decimals = [_afterpoint(_strip_ansi(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 _align_column_choose_width_fn(has_invisible, enable_widechars, is_multiline): + if has_invisible: + line_width_fn = _visible_width + elif enable_widechars: # optional wide-character support if available + line_width_fn = wcwidth.wcswidth + else: + line_width_fn = len + if is_multiline: + width_fn = lambda s: _align_column_multiline_width(s, line_width_fn) # noqa + else: + width_fn = line_width_fn + return width_fn + + +def _align_column_multiline_width(multiline_s, line_width_fn=len): + """Visible width of a potentially multiline content.""" + return list(map(line_width_fn, re.split("[\r\n]", multiline_s))) + + +def _flat_list(nested_list): + ret = [] + for item in nested_list: + if isinstance(item, list): + for subitem in item: + ret.append(subitem) + else: + ret.append(item) + return ret + + +def _align_column( + strings, + alignment, + minwidth=0, + has_invisible=True, + enable_widechars=False, + is_multiline=False, +): + """[string] -> [padded_string]""" + strings, padfn = _align_column_choose_padfn(strings, alignment, has_invisible) + width_fn = _align_column_choose_width_fn( + has_invisible, enable_widechars, is_multiline + ) + + s_widths = list(map(width_fn, strings)) + maxwidth = max(max(_flat_list(s_widths)), minwidth) + # TODO: refactor column alignment in single-line and multiline modes + if is_multiline: + if not enable_widechars and not has_invisible: + padded_strings = [ + "\n".join([padfn(maxwidth, s) for s in ms.splitlines()]) + for ms in strings + ] + else: + # enable wide-character width corrections + s_lens = [[len(s) for s in re.split("[\r\n]", ms)] for ms in strings] + visible_widths = [ + [maxwidth - (w - l) for w, l in zip(mw, ml)] + for mw, ml in zip(s_widths, s_lens) + ] + # wcswidth and _visible_width don't count invisible characters; + # padfn doesn't need to apply another correction + padded_strings = [ + "\n".join([padfn(w, s) for s, w in zip((ms.splitlines() or ms), mw)]) + for ms, mw in zip(strings, visible_widths) + ] + else: # single-line cell values + if not enable_widechars and not has_invisible: + padded_strings = [padfn(maxwidth, s) for s in strings] + else: + # enable wide-character width corrections + s_lens = list(map(len, strings)) + visible_widths = [maxwidth - (w - l) for w, l in zip(s_widths, s_lens)] + # wcswidth and _visible_width don't count invisible characters; + # padfn doesn't need to apply another correction + padded_strings = [padfn(w, s) for s, w in zip(strings, visible_widths)] + return padded_strings + + +def _more_generic(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 _column_type(strings, has_invisible=True, numparse=True): + """The least generic type all column values are convertible to. + + >>> _column_type([True, False]) is bool + True + >>> _column_type(["1", "2"]) is int + True + >>> _column_type(["1", "2.3"]) is float + True + >>> _column_type(["1", "2.3", "four"]) is str + True + >>> _column_type(["four", '\u043f\u044f\u0442\u044c']) is str + True + >>> _column_type([None, "brux"]) is str + True + >>> _column_type([1, 2, None]) is int + True + >>> import datetime as dt + >>> _column_type([dt.datetime(1991,2,19), dt.time(17,35)]) is str + True + + """ + types = [_type(s, has_invisible, numparse) for s in strings] + return reduce(_more_generic, types, bool) + + +def _format(val, valtype, floatfmt, intfmt, missingval="", has_invisible=True): + """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: + is_a_colored_number = has_invisible and isinstance(val, (str, bytes)) + if is_a_colored_number: + raw_val = _strip_ansi(val) + formatted_val = format(float(raw_val), floatfmt) + return val.replace(raw_val, formatted_val) + else: + return format(float(val), floatfmt) + else: + return f"{val}" + + +def _align_header( + header, alignment, width, visible_width, is_multiline=False, width_fn=None +): + "Pad string header to width chars given known visible_width of the header." + if is_multiline: + header_lines = re.split(_multiline_codes, header) + padded_lines = [ + _align_header(h, alignment, width, width_fn(h)) for h in header_lines + ] + return "\n".join(padded_lines) + # else: not multiline + ninvisible = len(header) - visible_width + 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 _remove_separating_lines(rows): + if type(rows) == list: + separating_lines = [] + sans_rows = [] + for index, row in enumerate(rows): + if _is_separating_line(row): + separating_lines.append(index) + else: + sans_rows.append(row) + return sans_rows, separating_lines + else: + return rows, None + + +def _reinsert_separating_lines(rows, separating_lines): + if separating_lines: + for index in separating_lines: + rows.insert(index, SEPARATING_LINE) + + +def _prepend_row_index(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)) + ) + sans_rows, separating_lines = _remove_separating_lines(rows) + new_rows = [] + index_iter = iter(index) + for row in sans_rows: + index_v = next(index_iter) + new_rows.append([index_v] + list(row)) + rows = new_rows + _reinsert_separating_lines(rows, separating_lines) + 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 _normalize_tabular_data(tabular_data, headers, showindex="default"): + """Transform a supported data type to a list of lists, and a list of headers, with headers 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") + + * pandas.DataFrame (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="default", show row indices of the pandas.DataFrame. + 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) + is_headers2bool_broken = False # noqa + except ValueError: # numpy.ndarray, pandas.core.index.Index, ... + is_headers2bool_broken = True # noqa + headers = list(headers) + + index = None + if hasattr(tabular_data, "keys") and hasattr(tabular_data, "values"): + # dict-like and pandas.DataFrame? + if hasattr(tabular_data.values, "__call__"): + # likely a conventional dict + keys = tabular_data.keys() + rows = list( + izip_longest(*tabular_data.values()) + ) # columns have to be transposed + elif hasattr(tabular_data, "index"): + # values is a property, has .index => it's likely a pandas.DataFrame (pandas 0.11.0) + keys = list(tabular_data) + if ( + showindex in ["default", "always", True] + and tabular_data.index.name is not None + ): + if isinstance(tabular_data.index.name, list): + keys[:0] = tabular_data.index.name + else: + keys[:0] = [tabular_data.index.name] + vals = tabular_data.values # values matrix doesn't need to be transposed + # for DataFrames add an index per default + index = list(tabular_data.index) + rows = [list(row) for row in vals] + else: + raise ValueError("tabular data doesn't appear to be a dict or a DataFrame") + + if headers == "keys": + headers = list(map(str, keys)) # headers should be strings + + else: # it's a usual iterable of iterables, or a NumPy array, or an iterable of dataclasses + rows = list(tabular_data) + + if headers == "keys" and not rows: + # an empty table (issue #81) + headers = [] + elif ( + headers == "keys" + and hasattr(tabular_data, "dtype") + and getattr(tabular_data.dtype, "names") + ): + # numpy record array + headers = tabular_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 + uniq_keys = set() # implements hashed lookup + keys = [] # storage for set + if headers == "firstrow": + firstdict = rows[0] if len(rows) > 0 else {} + keys.extend(firstdict.keys()) + uniq_keys.update(keys) + rows = rows[1:] + for row in rows: + for k in row.keys(): + # Save unique items in input order + if k not in uniq_keys: + keys.append(k) + uniq_keys.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 ( + headers == "keys" + and hasattr(tabular_data, "description") + and hasattr(tabular_data, "fetchone") + and hasattr(tabular_data, "rowcount") + ): + # Python Database API cursor object (PEP 0249) + # print tabulate(cursor, headers='keys') + headers = [column[0] for column in tabular_data.description] + + elif ( + dataclasses is not None + and len(rows) > 0 + and dataclasses.is_dataclass(rows[0]) + ): + # Python 3.7+'s dataclass + field_names = [field.name for field in dataclasses.fields(rows[0])] + if headers == "keys": + headers = field_names + rows = [[getattr(row, f) for f in field_names] 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(list, rows)) + rows = list(map(lambda r: r if _is_separating_line(r) else list(r), rows)) + + # add or remove an index column + showindex_is_a_str = type(showindex) in [str, bytes] + if showindex == "default" and index is not None: + rows = _prepend_row_index(rows, index) + elif isinstance(showindex, Sized) and not showindex_is_a_str: + rows = _prepend_row_index(rows, list(showindex)) + elif isinstance(showindex, Iterable) and not showindex_is_a_str: + rows = _prepend_row_index(rows, showindex) + elif showindex == "always" or (_bool(showindex) and not showindex_is_a_str): + if index is None: + index = list(range(len(rows))) + rows = _prepend_row_index(rows, index) + elif showindex == "never" or (not _bool(showindex) and not showindex_is_a_str): + pass + + # pad with empty headers for initial columns if necessary + headers_pad = 0 + if headers and len(rows) > 0: + headers_pad = len(rows[0]) - len(headers) + headers = [""] * headers_pad + headers + + return rows, headers, headers_pad + + +def _wrap_text_to_colwidths(list_of_lists, colwidths, numparses=True): + if len(list_of_lists): + num_cols = len(list_of_lists[0]) + else: + num_cols = 0 + numparses = _expand_iterable(numparses, num_cols, True) + + result = [] + + for row in list_of_lists: + new_row = [] + for cell, width, numparse in zip(row, colwidths, numparses): + if _isnumber(cell) and numparse: + new_row.append(cell) + continue + + if width is not None: + wrapper = _CustomTextWrap(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 + casted_cell = ( + str(cell) if _isnumber(cell) else _type(cell, numparse)(cell) + ) + wrapped = [ + "\n".join(wrapper.wrap(line)) + for line in casted_cell.splitlines() + if line.strip() != "" + ] + new_row.append("\n".join(wrapped)) + else: + new_row.append(cell) + result.append(new_row) + + return result + + +def _to_str(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(_to_str(b'foo')) + "'foo'" + + >>> repr(_to_str('foo')) + "'foo'" + + >>> repr(_to_str(42)) + "'42'" + + """ + if isinstance(s, bytes): + return s.decode(encoding=encoding, errors=errors) + return str(s) + + +def tabulate( + tabular_data, + headers=(), + tablefmt="simple", + floatfmt=_DEFAULT_FLOATFMT, + intfmt=_DEFAULT_INTFMT, + numalign=_DEFAULT_ALIGN, + stralign=_DEFAULT_ALIGN, + missingval=_DEFAULT_MISSINGVAL, + showindex="default", + disable_numparse=False, + colglobalalign=None, + colalign=None, + maxcolwidths=None, + headersglobalalign=None, + headersalign=None, + rowalign=None, + maxheadercolwidths=None, +): + """Format a fixed width table for pretty printing. + + >>> print(tabulate([[1, 2.34], [-56, "8.999"], ["2", "10001"]])) + --- --------- + 1 2.34 + -56 8.999 + 2 10001 + --- --------- + + The first required argument (`tabular_data`) can be a + list-of-lists (or another iterable of iterables), a list of named + tuples, a dictionary of iterables, an iterable of dictionaries, + an iterable of dataclasses (Python 3.7+), a two-dimensional NumPy array, + NumPy record array, or a Pandas' dataframe. + + + 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 and Pandas' dataframes. + + >>> print(tabulate([["sex","age"],["Alice","F",24],["Bob","M",19]], + ... headers="firstrow")) + sex age + ----- ----- ----- + Alice F 24 + Bob M 19 + + By default, pandas.DataFrame data have an additional column called + row index. To add a similar column to all other types of data, + use `showindex="always"` or `showindex=True`. To suppress row indices + for all types of data, pass `showindex="never" or `showindex=False`. + To add a custom row index column, pass `showindex=some_iterable`. + + >>> print(tabulate([["F",24],["M",19]], showindex="always")) + - - -- + 0 F 24 + 1 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". Other values are treated as "left". + `colalign` allows for column-wise override starting from left-most + column. Possible values are: "global" (no override), "right", + "center", "decimal", "left". Other values are teated as "left". + `headersglobalalign` allows for global headers alignment, before any + specific override from `headersalign`. Possible values are: None + (follow columns alignment), "right", "center", "left". Other + values are treated as "right". + `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". Other + values are teated as "right". + + Note: if column alignment is illegal (treating it as left) and + corresponding header aligns as "same", it will treat it as "right". + Thus, in spite of "same" being specified, alignment will not + visually be the same in the end. + + 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', 'pipe', 'orgtbl', 'rst', 'mediawiki', + 'latex', 'latex_raw', 'latex_booktabs', 'latex_longtable' and tsv. + Variable `tabulate_formats`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 | + +------+----------+ + + "simple_grid" draws a grid using single-line box-drawing + characters: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "simple_grid")) + ┌───────────┬───────────┐ + │ strings │ numbers │ + ├───────────┼───────────┤ + │ spam │ 41.9999 │ + ├───────────┼───────────┤ + │ eggs │ 451 │ + └───────────┴───────────┘ + + "rounded_grid" draws a grid using single-line box-drawing + characters with rounded corners: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "rounded_grid")) + ╭───────────┬───────────╮ + │ strings │ numbers │ + ├───────────┼───────────┤ + │ spam │ 41.9999 │ + ├───────────┼───────────┤ + │ eggs │ 451 │ + ╰───────────┴───────────╯ + + "heavy_grid" draws a grid using bold (thick) single-line box-drawing + characters: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "heavy_grid")) + ┏━━━━━━━━━━━┳━━━━━━━━━━━┓ + ┃ strings ┃ numbers ┃ + ┣━━━━━━━━━━━╋━━━━━━━━━━━┫ + ┃ spam ┃ 41.9999 ┃ + ┣━━━━━━━━━━━╋━━━━━━━━━━━┫ + ┃ eggs ┃ 451 ┃ + ┗━━━━━━━━━━━┻━━━━━━━━━━━┛ + + "mixed_grid" draws a grid using a mix of light (thin) and heavy (thick) lines + box-drawing characters: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "mixed_grid")) + ┍━━━━━━━━━━━┯━━━━━━━━━━━┑ + │ strings │ numbers │ + ┝━━━━━━━━━━━┿━━━━━━━━━━━┥ + │ spam │ 41.9999 │ + ├───────────┼───────────┤ + │ eggs │ 451 │ + ┕━━━━━━━━━━━┷━━━━━━━━━━━┙ + + "double_grid" draws a grid using double-line box-drawing + characters: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "double_grid")) + ╔═══════════╦═══════════╗ + ║ strings ║ numbers ║ + ╠═══════════╬═══════════╣ + ║ spam ║ 41.9999 ║ + ╠═══════════╬═══════════╣ + ║ eggs ║ 451 ║ + ╚═══════════╩═══════════╝ + + "fancy_grid" draws a grid using a mix of single and + double-line box-drawing characters: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "fancy_grid")) + ╒═══════════╤═══════════╕ + │ strings │ numbers │ + ╞═══════════╪═══════════╡ + │ spam │ 41.9999 │ + ├───────────┼───────────┤ + │ eggs │ 451 │ + ╘═══════════╧═══════════╛ + + "outline" is the same as the "grid" format but doesn't draw lines between rows: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "outline")) + +-----------+-----------+ + | strings | numbers | + +===========+===========+ + | spam | 41.9999 | + | eggs | 451 | + +-----------+-----------+ + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], tablefmt="outline")) + +------+----------+ + | spam | 41.9999 | + | eggs | 451 | + +------+----------+ + + "simple_outline" is the same as the "simple_grid" format but doesn't draw lines between rows: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "simple_outline")) + ┌───────────┬───────────┐ + │ strings │ numbers │ + ├───────────┼───────────┤ + │ spam │ 41.9999 │ + │ eggs │ 451 │ + └───────────┴───────────┘ + + "rounded_outline" is the same as the "rounded_grid" format but doesn't draw lines between rows: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "rounded_outline")) + ╭───────────┬───────────╮ + │ strings │ numbers │ + ├───────────┼───────────┤ + │ spam │ 41.9999 │ + │ eggs │ 451 │ + ╰───────────┴───────────╯ + + "heavy_outline" is the same as the "heavy_grid" format but doesn't draw lines between rows: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "heavy_outline")) + ┏━━━━━━━━━━━┳━━━━━━━━━━━┓ + ┃ strings ┃ numbers ┃ + ┣━━━━━━━━━━━╋━━━━━━━━━━━┫ + ┃ spam ┃ 41.9999 ┃ + ┃ eggs ┃ 451 ┃ + ┗━━━━━━━━━━━┻━━━━━━━━━━━┛ + + "mixed_outline" is the same as the "mixed_grid" format but doesn't draw lines between rows: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "mixed_outline")) + ┍━━━━━━━━━━━┯━━━━━━━━━━━┑ + │ strings │ numbers │ + ┝━━━━━━━━━━━┿━━━━━━━━━━━┥ + │ spam │ 41.9999 │ + │ eggs │ 451 │ + ┕━━━━━━━━━━━┷━━━━━━━━━━━┙ + + "double_outline" is the same as the "double_grid" format but doesn't draw lines between rows: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "double_outline")) + ╔═══════════╦═══════════╗ + ║ strings ║ numbers ║ + ╠═══════════╬═══════════╣ + ║ spam ║ 41.9999 ║ + ║ eggs ║ 451 ║ + ╚═══════════╩═══════════╝ + + "fancy_outline" is the same as the "fancy_grid" format but doesn't draw lines between rows: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "fancy_outline")) + ╒═══════════╤═══════════╕ + │ strings │ numbers │ + ╞═══════════╪═══════════╡ + │ spam │ 41.9999 │ + │ eggs │ 451 │ + ╘═══════════╧═══════════╛ + + "pipe" is like tables in PHP Markdown Extra extension or Pandoc + pipe_tables: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "pipe")) + | strings | numbers | + |:----------|----------:| + | spam | 41.9999 | + | eggs | 451 | + + "presto" is like tables produce by the Presto CLI: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "presto")) + strings | numbers + -----------+----------- + spam | 41.9999 + eggs | 451 + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], tablefmt="pipe")) + |:-----|---------:| + | spam | 41.9999 | + | eggs | 451 | + + "orgtbl" is like tables in Emacs org-mode and orgtbl-mode. They + are slightly different from "pipe" format by not using colons to + define column alignment, and using a "+" sign to indicate line + intersections: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "orgtbl")) + | strings | numbers | + |-----------+-----------| + | spam | 41.9999 | + | eggs | 451 | + + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], tablefmt="orgtbl")) + | 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 + ==== ======== + + "mediawiki" produces a table markup used in Wikipedia and on other + MediaWiki-based sites: + + >>> print(tabulate([["strings", "numbers"], ["spam", 41.9999], ["eggs", "451.0"]], + ... headers="firstrow", tablefmt="mediawiki")) + {| class="wikitable" style="text-align: left;" + |+ + |- + ! strings !! style="text-align: right;"| numbers + |- + | spam || style="text-align: right;"| 41.9999 + |- + | eggs || style="text-align: right;"| 451 + |} + + "html" produces HTML markup as an html.escape'd str + with a ._repr_html_ method so that Jupyter Lab and Notebook display the HTML + and a .str property so that the raw HTML remains accessible + the unsafehtml table format can be used if an unescaped HTML format is required: + + >>> print(tabulate([["strings", "numbers"], ["spam", 41.9999], ["eggs", "451.0"]], + ... headers="firstrow", tablefmt="html")) + + + + + + + + +
strings numbers
spam 41.9999
eggs 451
+ + "latex" produces a tabular environment of LaTeX document markup: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], tablefmt="latex")) + \\begin{tabular}{lr} + \\hline + spam & 41.9999 \\\\ + eggs & 451 \\\\ + \\hline + \\end{tabular} + + "latex_raw" is similar to "latex", but doesn't escape special characters, + such as backslash and underscore, so LaTeX commands may embedded into + cells' values: + + >>> print(tabulate([["spam$_9$", 41.9999], ["\\\\emph{eggs}", "451.0"]], tablefmt="latex_raw")) + \\begin{tabular}{lr} + \\hline + spam$_9$ & 41.9999 \\\\ + \\emph{eggs} & 451 \\\\ + \\hline + \\end{tabular} + + "latex_booktabs" produces a tabular environment of LaTeX document markup + using the booktabs.sty package: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], tablefmt="latex_booktabs")) + \\begin{tabular}{lr} + \\toprule + spam & 41.9999 \\\\ + eggs & 451 \\\\ + \\bottomrule + \\end{tabular} + + "latex_longtable" produces a tabular environment that can stretch along + multiple pages, using the longtable package for LaTeX. + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], tablefmt="latex_longtable")) + \\begin{longtable}{lr} + \\hline + spam & 41.9999 \\\\ + eggs & 451 \\\\ + \\hline + \\end{longtable} + + + 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 + `disable_numparse=True`. For more fine grained control, a list column + indices is used to disable number parsing only on those columns + e.g. `disable_numparse=[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` + + """ + + if tabular_data is None: + tabular_data = [] + + list_of_lists, headers, headers_pad = _normalize_tabular_data( + tabular_data, headers, showindex=showindex + ) + list_of_lists, separating_lines = _remove_separating_lines(list_of_lists) + + if maxcolwidths is not None: + if len(list_of_lists): + num_cols = len(list_of_lists[0]) + else: + num_cols = 0 + if isinstance(maxcolwidths, int): # Expand scalar for all columns + maxcolwidths = _expand_iterable(maxcolwidths, num_cols, maxcolwidths) + else: # Ignore col width for any 'trailing' columns + maxcolwidths = _expand_iterable(maxcolwidths, num_cols, None) + + numparses = _expand_numparse(disable_numparse, num_cols) + list_of_lists = _wrap_text_to_colwidths( + list_of_lists, maxcolwidths, numparses=numparses + ) + + if maxheadercolwidths is not None: + num_cols = len(list_of_lists[0]) + if isinstance(maxheadercolwidths, int): # Expand scalar for all columns + maxheadercolwidths = _expand_iterable( + maxheadercolwidths, num_cols, maxheadercolwidths + ) + else: # Ignore col width for any 'trailing' columns + maxheadercolwidths = _expand_iterable(maxheadercolwidths, num_cols, None) + + numparses = _expand_numparse(disable_numparse, num_cols) + headers = _wrap_text_to_colwidths( + [headers], maxheadercolwidths, numparses=numparses + )[0] + + # empty values in the first column of RST tables should be escaped (issue #82) + # "" should be escaped as "\\ " or ".." + if tablefmt == "rst": + list_of_lists, headers = _rst_escape_first_column(list_of_lists, headers) + + # PrettyTable 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. + min_padding = MIN_PADDING + if tablefmt == "pretty": + min_padding = 0 + disable_numparse = 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) + plain_text = "\t".join( + chain( + # headers + map(_to_str, headers), + # rows: chain the rows together into a single iterable after mapping + # the bytestring conversino to each cell value + chain.from_iterable(map(_to_str, row) for row in list_of_lists), + ) + ) + + has_invisible = _ansi_codes.search(plain_text) is not None + + enable_widechars = wcwidth is not None and WIDE_CHARS_MODE + if ( + not isinstance(tablefmt, TableFormat) + and tablefmt in multiline_formats + and _is_multiline(plain_text) + ): + tablefmt = multiline_formats.get(tablefmt, tablefmt) + is_multiline = True + else: + is_multiline = False + width_fn = _choose_width_fn(has_invisible, enable_widechars, is_multiline) + + # format rows and columns, convert numeric values to strings + cols = list(izip_longest(*list_of_lists)) + numparses = _expand_numparse(disable_numparse, len(cols)) + coltypes = [_column_type(col, numparse=np) for col, np in zip(cols, numparses)] + if isinstance(floatfmt, str): # old version + float_formats = len(cols) * [ + floatfmt + ] # just duplicate the string to use in each column + else: # if floatfmt is list, tuple etc we have one per column + float_formats = list(floatfmt) + if len(float_formats) < len(cols): + float_formats.extend((len(cols) - len(float_formats)) * [_DEFAULT_FLOATFMT]) + if isinstance(intfmt, str): # old version + int_formats = len(cols) * [ + intfmt + ] # just duplicate the string to use in each column + else: # if intfmt is list, tuple etc we have one per column + int_formats = list(intfmt) + if len(int_formats) < len(cols): + int_formats.extend((len(cols) - len(int_formats)) * [_DEFAULT_INTFMT]) + if isinstance(missingval, str): + missing_vals = len(cols) * [missingval] + else: + missing_vals = list(missingval) + if len(missing_vals) < len(cols): + missing_vals.extend((len(cols) - len(missing_vals)) * [_DEFAULT_MISSINGVAL]) + cols = [ + [_format(v, ct, fl_fmt, int_fmt, miss_v, has_invisible) for v in c] + for c, ct, fl_fmt, int_fmt, miss_v in zip( + cols, coltypes, float_formats, int_formats, missing_vals + ) + ] + + # 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) + for idx, align in enumerate(colalign): + if align != "global": + aligns[idx] = align + minwidths = ( + [width_fn(h) + min_padding for h in headers] if headers else [0] * len(cols) + ) + cols = [ + _align_column(c, a, minw, has_invisible, enable_widechars, is_multiline) + for c, a, minw in zip(cols, aligns, minwidths) + ] + + aligns_headers = None + if headers: + # align headers and add headers + t_cols = cols or [[""]] * len(headers) + # first set global alignment + if headersglobalalign is not None: # if global alignment provided + aligns_headers = [headersglobalalign] * len(t_cols) + else: # default + aligns_headers = aligns or [stralign] * len(headers) + # then specific header alignements + if headersalign is not None: + assert isinstance(headersalign, Iterable) + for idx, align in enumerate(headersalign): + if align == "same": # same as column align + aligns_headers[headers_pad + idx] = aligns[headers_pad + idx] + elif align != "global": + aligns_headers[headers_pad + idx] = align + minwidths = [ + max(minw, max(width_fn(cl) for cl in c)) + for minw, c in zip(minwidths, t_cols) + ] + headers = [ + _align_header(h, a, minw, width_fn(h), is_multiline, width_fn) + for h, a, minw in zip(headers, aligns_headers, minwidths) + ] + rows = list(zip(*cols)) + else: + minwidths = [max(width_fn(cl) for cl in c) for c in cols] + rows = list(zip(*cols)) + + if not isinstance(tablefmt, TableFormat): + tablefmt = _table_formats.get(tablefmt, _table_formats["simple"]) + + ra_default = rowalign if isinstance(rowalign, str) else None + rowaligns = _expand_iterable(rowalign, len(rows), ra_default) + _reinsert_separating_lines(rows, separating_lines) + + return _format_table( + tablefmt, headers, aligns_headers, rows, minwidths, aligns, is_multiline, rowaligns=rowaligns + ) + + +def _expand_numparse(disable_numparse, column_count): + """ + Return a list of bools of length `column_count` which indicates whether + number parsing should be used on each column. + If `disable_numparse` is a list of indices, each of those indices are False, + and everything else is True. + If `disable_numparse` is a bool, then the returned list is all the same. + """ + if isinstance(disable_numparse, Iterable): + numparses = [True] * column_count + for index in disable_numparse: + numparses[index] = False + return numparses + else: + return [not disable_numparse] * column_count + + +def _expand_iterable(original, num_desired, default): + """ + Expands the `original` argument to return a return a list of + length `num_desired`. If `original` is shorter than `num_desired`, 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 `num_desired` completely populated with `default will be returned + """ + if isinstance(original, Iterable) and not isinstance(original, str): + return original + [default] * (num_desired - len(original)) + else: + return [default] * num_desired + + +def _pad_row(cells, padding): + if cells: + pad = " " * padding + padded_cells = [pad + cell + pad for cell in cells] + return padded_cells + else: + return cells + + +def _build_simple_row(padded_cells, rowfmt): + "Format row according to DataRow format without padding." + begin, sep, end = rowfmt + return (begin + sep.join(padded_cells) + end).rstrip() + + +def _build_row(padded_cells, colwidths, colaligns, rowfmt): + "Return a string which represents a row of data cells." + if not rowfmt: + return None + if hasattr(rowfmt, "__call__"): + return rowfmt(padded_cells, colwidths, colaligns) + else: + return _build_simple_row(padded_cells, rowfmt) + + +def _append_basic_row(lines, padded_cells, colwidths, colaligns, rowfmt, rowalign=None): + # NOTE: rowalign is ignored and exists for api compatibility with _append_multiline_row + lines.append(_build_row(padded_cells, colwidths, colaligns, rowfmt)) + return lines + + +def _align_cell_veritically(text_lines, num_lines, column_width, row_alignment): + delta_lines = num_lines - len(text_lines) + blank = [" " * column_width] + if row_alignment == "bottom": + return blank * delta_lines + text_lines + elif row_alignment == "center": + top_delta = delta_lines // 2 + bottom_delta = delta_lines - top_delta + return top_delta * blank + text_lines + bottom_delta * blank + else: + return text_lines + blank * delta_lines + + +def _append_multiline_row( + lines, padded_multiline_cells, padded_widths, colaligns, rowfmt, pad, rowalign=None +): + colwidths = [w - 2 * pad for w in padded_widths] + cells_lines = [c.splitlines() for c in padded_multiline_cells] + nlines = max(map(len, cells_lines)) # number of lines in the row + # vertically pad cells where some lines are missing + # cells_lines = [ + # (cl + [" " * w] * (nlines - len(cl))) for cl, w in zip(cells_lines, colwidths) + # ] + + cells_lines = [ + _align_cell_veritically(cl, nlines, w, rowalign) + for cl, w in zip(cells_lines, colwidths) + ] + lines_cells = [[cl[i] for cl in cells_lines] for i in range(nlines)] + for ln in lines_cells: + padded_ln = _pad_row(ln, pad) + _append_basic_row(lines, padded_ln, colwidths, colaligns, rowfmt) + return lines + + +def _build_line(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 _build_simple_row(cells, (begin, sep, end)) + + +def _append_line(lines, colwidths, colaligns, linefmt): + lines.append(_build_line(colwidths, colaligns, linefmt)) + return lines + + +class JupyterHTMLStr(str): + """Wrap the string with a _repr_html_ method so that Jupyter + displays the HTML table""" + + def _repr_html_(self): + return self + + @property + def str(self): + """add a .str property so that the raw string is still accessible""" + return self + + +def _format_table(fmt, headers, headersaligns, rows, colwidths, colaligns, is_multiline, rowaligns): + """Produce a plain-text representation of the table.""" + lines = [] + hidden = fmt.with_header_hide if (headers and fmt.with_header_hide) else [] + pad = fmt.padding + headerrow = fmt.headerrow + + padded_widths = [(w + 2 * pad) for w in colwidths] + if is_multiline: + pad_row = lambda row, _: row # noqa do it later, in _append_multiline_row + append_row = partial(_append_multiline_row, pad=pad) + else: + pad_row = _pad_row + append_row = _append_basic_row + + padded_headers = pad_row(headers, pad) + padded_rows = [pad_row(row, pad) for row in rows] + + if fmt.lineabove and "lineabove" not in hidden: + _append_line(lines, padded_widths, colaligns, fmt.lineabove) + + if padded_headers: + append_row(lines, padded_headers, padded_widths, headersaligns, headerrow) + if fmt.linebelowheader and "linebelowheader" not in hidden: + _append_line(lines, padded_widths, headersaligns, fmt.linebelowheader) + + if padded_rows and fmt.linebetweenrows and "linebetweenrows" not in hidden: + # initial rows with a line below + for row, ralign in zip(padded_rows[:-1], rowaligns): + append_row( + lines, row, padded_widths, colaligns, fmt.datarow, rowalign=ralign + ) + _append_line(lines, padded_widths, colaligns, fmt.linebetweenrows) + # the last row without a line below + append_row( + lines, + padded_rows[-1], + padded_widths, + colaligns, + fmt.datarow, + rowalign=rowaligns[-1], + ) + else: + separating_line = ( + fmt.linebetweenrows + or fmt.linebelowheader + or fmt.linebelow + or fmt.lineabove + or Line("", "", "", "") + ) + for row in padded_rows: + # test to see if either the 1st column or the 2nd column (account for showindex) has + # the SEPARATING_LINE flag + if _is_separating_line(row): + _append_line(lines, padded_widths, colaligns, separating_line) + else: + append_row(lines, row, padded_widths, colaligns, fmt.datarow) + + if fmt.linebelow and "linebelow" not in hidden: + _append_line(lines, padded_widths, colaligns, fmt.linebelow) + + if headers or rows: + output = "\n".join(lines) + if fmt.lineabove == _html_begin_table_without_header: + return JupyterHTMLStr(output) + else: + return output + else: # a completely empty table + return "" + + +class _CustomTextWrap(textwrap.TextWrapper): + """A custom implementation of CPython's textwrap.TextWrapper. This supports + both wide characters (Korea, Japanese, Chinese) - including mixed string. + For the most part, the `_handle_long_word` and `_wrap_chunks` functions were + copy pasted out of the CPython baseline, and updated with our custom length + and line appending logic. + """ + + def __init__(self, *args, **kwargs): + self._active_codes = [] + self.max_lines = None # For python2 compatibility + textwrap.TextWrapper.__init__(self, *args, **kwargs) + + @staticmethod + def _len(item): + """Custom len that gets console column width for wide + and non-wide characters as well as ignores color codes""" + stripped = _strip_ansi(item) + if wcwidth: + return wcwidth.wcswidth(stripped) + else: + return len(stripped) + + def _update_lines(self, lines, new_line): + """Adds a new line to the list of lines the text is being wrapped into + This function will also track any ANSI color codes in this string as well + as add any colors from previous lines order to preserve the same formatting + as a single unwrapped string. + """ + code_matches = [x for x in _ansi_codes.finditer(new_line)] + color_codes = [ + code.string[code.span()[0] : code.span()[1]] for code in code_matches + ] + + # Add color codes from earlier in the unwrapped line, and then track any new ones we add. + new_line = "".join(self._active_codes) + new_line + + for code in color_codes: + if code != _ansi_color_reset_code: + self._active_codes.append(code) + else: # A single reset code resets everything + self._active_codes = [] + + # Always ensure each line is color terminted if any colors are + # still active, otherwise colors will bleed into other cells on the console + if len(self._active_codes) > 0: + new_line = new_line + _ansi_color_reset_code + + lines.append(new_line) + + def _handle_long_word(self, reversed_chunks, cur_line, cur_len, width): + """_handle_long_word(chunks : [string], + cur_line : [string], + cur_len : int, width : int) + Handle a chunk of text (most likely a word, not whitespace) that + is too long to fit in any line. + """ + # Figure out when indent is larger than the specified width, and make + # sure at least one character is stripped off on every pass + if width < 1: + space_left = 1 + else: + space_left = width - cur_len + + # If we're allowed to break long words, then do so: put as much + # of the next chunk onto the current line as will fit. + if self.break_long_words: + # Tabulate Custom: Build the string up piece-by-piece in order to + # take each charcter's width into account + chunk = reversed_chunks[-1] + i = 1 + while self._len(chunk[:i]) <= space_left: + i = i + 1 + cur_line.append(chunk[: i - 1]) + reversed_chunks[-1] = chunk[i - 1 :] + + # Otherwise, we have to preserve the long word intact. Only add + # it to the current line if there's nothing already there -- + # that minimizes how much we violate the width constraint. + elif not cur_line: + cur_line.append(reversed_chunks.pop()) + + # If we're not allowed to break long words, and there's already + # text on the current line, do nothing. Next time through the + # main loop of _wrap_chunks(), we'll wind up here again, but + # cur_len will be zero, so the next line will be entirely + # devoted to the long word that we can't handle right now. + + def _wrap_chunks(self, chunks): + """_wrap_chunks(chunks : [string]) -> [string] + Wrap a sequence of text chunks and return a list of lines of + length 'self.width' or less. (If 'break_long_words' is false, + some lines may be longer than this.) Chunks correspond roughly + to words and the whitespace between them: each chunk is + indivisible (modulo 'break_long_words'), but a line break can + come between any two chunks. Chunks should not have internal + whitespace; ie. a chunk is either all whitespace or a "word". + Whitespace chunks will be removed from the beginning and end of + lines, but apart from that whitespace is preserved. + """ + lines = [] + if self.width <= 0: + raise ValueError("invalid width %r (must be > 0)" % self.width) + if self.max_lines is not None: + if self.max_lines > 1: + indent = self.subsequent_indent + else: + indent = self.initial_indent + if self._len(indent) + self._len(self.placeholder.lstrip()) > self.width: + raise ValueError("placeholder too large for max width") + + # Arrange in reverse order so items can be efficiently popped + # from a stack of chucks. + chunks.reverse() + + while chunks: + + # Start the list of chunks that will make up the current line. + # cur_len is just the length of all the chunks in cur_line. + cur_line = [] + cur_len = 0 + + # Figure out which static string will prefix this line. + if lines: + indent = self.subsequent_indent + else: + indent = self.initial_indent + + # Maximum width for this line. + width = self.width - self._len(indent) + + # First chunk on line is whitespace -- drop it, unless this + # is the very beginning of the text (ie. no lines started yet). + if self.drop_whitespace and chunks[-1].strip() == "" and lines: + del chunks[-1] + + while chunks: + chunk_len = self._len(chunks[-1]) + + # Can at least squeeze this chunk onto the current line. + if cur_len + chunk_len <= width: + cur_line.append(chunks.pop()) + cur_len += chunk_len + + # Nope, this line is full. + else: + break + + # The current line is full, and the next chunk is too big to + # fit on *any* line (not just this one). + if chunks and self._len(chunks[-1]) > width: + self._handle_long_word(chunks, cur_line, cur_len, width) + cur_len = sum(map(self._len, cur_line)) + + # If the last chunk on this line is all whitespace, drop it. + if self.drop_whitespace and cur_line and cur_line[-1].strip() == "": + cur_len -= self._len(cur_line[-1]) + del cur_line[-1] + + if cur_line: + if ( + self.max_lines is None + or len(lines) + 1 < self.max_lines + or ( + not chunks + or self.drop_whitespace + and len(chunks) == 1 + and not chunks[0].strip() + ) + and cur_len <= width + ): + # Convert current line back to a string and store it in + # list of all lines (return value). + self._update_lines(lines, indent + "".join(cur_line)) + else: + while cur_line: + if ( + cur_line[-1].strip() + and cur_len + self._len(self.placeholder) <= width + ): + cur_line.append(self.placeholder) + self._update_lines(lines, indent + "".join(cur_line)) + break + cur_len -= self._len(cur_line[-1]) + del cur_line[-1] + else: + if lines: + prev_line = lines[-1].rstrip() + if ( + self._len(prev_line) + self._len(self.placeholder) + <= self.width + ): + lines[-1] = prev_line + self.placeholder + break + self._update_lines(lines, indent + self.placeholder.lstrip()) + break + + return lines + + +def _main(): + """\ + Usage: tabulate [options] [FILE ...] + + Pretty-print tabular data. + See also https://github.com/astanin/python-tabulate + + FILE a filename of the file with tabular data; + if "-" or missing, read data from stdin. + + Options: + + -h, --help show this message + -1, --header use the first row of data as a table header + -o FILE, --output FILE print table to FILE (default: stdout) + -s REGEXP, --sep REGEXP use a custom column separator (default: whitespace) + -F FPFMT, --float FPFMT floating point number format (default: g) + -I INTFMT, --int INTFMT integer point number format (default: "") + -f FMT, --format FMT set output table format; supported formats: + plain, simple, grid, fancy_grid, pipe, orgtbl, + rst, mediawiki, html, latex, latex_raw, + latex_booktabs, latex_longtable, tsv + (default: simple) + """ + import getopt + import sys + import textwrap + + usage = textwrap.dedent(_main.__doc__) + try: + opts, args = getopt.getopt( + sys.argv[1:], + "h1o:s:F:A:f:", + ["help", "header", "output", "sep=", "float=", "int=", "align=", "format="], + ) + except getopt.GetoptError as e: + print(e) + print(usage) + sys.exit(2) + headers = [] + floatfmt = _DEFAULT_FLOATFMT + intfmt = _DEFAULT_INTFMT + colalign = None + tablefmt = "simple" + sep = r"\s+" + outfile = "-" + for opt, value in opts: + if opt in ["-1", "--header"]: + headers = "firstrow" + elif opt in ["-o", "--output"]: + outfile = value + elif opt in ["-F", "--float"]: + floatfmt = value + elif opt in ["-I", "--int"]: + intfmt = value + elif opt in ["-C", "--colalign"]: + colalign = value.split() + elif opt in ["-f", "--format"]: + if value not in tabulate_formats: + print("%s is not a supported table format" % value) + print(usage) + sys.exit(3) + tablefmt = value + elif opt in ["-s", "--sep"]: + sep = value + elif opt in ["-h", "--help"]: + print(usage) + sys.exit(0) + files = [sys.stdin] if not args else args + with (sys.stdout if outfile == "-" else open(outfile, "w")) as out: + for f in files: + if f == "-": + f = sys.stdin + if _is_file(f): + _pprint_file( + f, + headers=headers, + tablefmt=tablefmt, + sep=sep, + floatfmt=floatfmt, + intfmt=intfmt, + file=out, + colalign=colalign, + ) + else: + with open(f) as fobj: + _pprint_file( + fobj, + headers=headers, + tablefmt=tablefmt, + sep=sep, + floatfmt=floatfmt, + intfmt=intfmt, + file=out, + colalign=colalign, + ) + + +def _pprint_file(fobject, headers, tablefmt, sep, floatfmt, intfmt, file, colalign): + rows = fobject.readlines() + table = [re.split(sep, r.rstrip()) for r in rows if r.strip()] + print( + tabulate( + table, + headers, + tablefmt, + floatfmt=floatfmt, + intfmt=intfmt, + colalign=colalign, + ), + file=file, + ) + + +if __name__ == "__main__": + _main() From bd366f283b08df5da4878cf4d94c1dd9411f7f25 Mon Sep 17 00:00:00 2001 From: eliegoudout <114467748+eliegoudout@users.noreply.github.com> Date: Sat, 26 Nov 2022 00:56:49 +0100 Subject: [PATCH 04/90] Corrected when headers or headersalign is too long --- tabulate/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 22d24da..9170945 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1500,7 +1500,7 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): # pad with empty headers for initial columns if necessary headers_pad = 0 if headers and len(rows) > 0: - headers_pad = len(rows[0]) - len(headers) + headers_pad = max(0, len(rows[0]) - len(headers)) headers = [""] * headers_pad + headers return rows, headers, headers_pad @@ -2234,7 +2234,7 @@ def tabulate( # then specific header alignements if headersalign is not None: assert isinstance(headersalign, Iterable) - for idx, align in enumerate(headersalign): + for idx, align in enumerate(headersalign[:len(aligns)]): if align == "same": # same as column align aligns_headers[headers_pad + idx] = aligns[headers_pad + idx] elif align != "global": From 15da6261058d22592137ad75ba1ad1029eab9980 Mon Sep 17 00:00:00 2001 From: eliegoudout <114467748+eliegoudout@users.noreply.github.com> Date: Sat, 26 Nov 2022 01:18:12 +0100 Subject: [PATCH 05/90] Correct (and cleaner) fix for longer headersalign --- tabulate/__init__.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 9170945..86117aa 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -2234,11 +2234,12 @@ def tabulate( # then specific header alignements if headersalign is not None: assert isinstance(headersalign, Iterable) - for idx, align in enumerate(headersalign[:len(aligns)]): - if align == "same": # same as column align - aligns_headers[headers_pad + idx] = aligns[headers_pad + idx] + for idx, align in enumerate(headersalign[:len(aligns_headers)]): + hidx = headers_pad + idx + if align == "same" and hidx < len(aligns): # same as column align + aligns_headers[hidx] = aligns[hidx] elif align != "global": - aligns_headers[headers_pad + idx] = align + aligns_headers[hidx] = align minwidths = [ max(minw, max(width_fn(cl) for cl in c)) for minw, c in zip(minwidths, t_cols) From d3545824ee6167887d521361e4e6553e6c9c9ae1 Mon Sep 17 00:00:00 2001 From: eliegoudout <114467748+eliegoudout@users.noreply.github.com> Date: Sat, 26 Nov 2022 01:40:33 +0100 Subject: [PATCH 06/90] Test compliance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Corrected `tabulate` signature in `test/test_api.py::test_tabulate_signature` • In `tabulate/__init__.py::_format_table`, corrected `linebelowheader` alignment to comply with `test/test_regression.py::test_empty_pipe_table_with_columns`. But now that headers' alignment can differ from column', maybe it would be preferable to decide that `linebelowheader` should be aligned with header instead of with column. --- tabulate/__init__.py | 2 +- test/test_api.py | 129 ++++++++++++++++++++++--------------------- 2 files changed, 67 insertions(+), 64 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 86117aa..4857483 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -2416,7 +2416,7 @@ def _format_table(fmt, headers, headersaligns, rows, colwidths, colaligns, is_mu if padded_headers: append_row(lines, padded_headers, padded_widths, headersaligns, headerrow) if fmt.linebelowheader and "linebelowheader" not in hidden: - _append_line(lines, padded_widths, headersaligns, fmt.linebelowheader) + _append_line(lines, padded_widths, colaligns, fmt.linebelowheader) if padded_rows and fmt.linebetweenrows and "linebetweenrows" not in hidden: # initial rows with a line below diff --git a/test/test_api.py b/test/test_api.py index 046d752..b3db29c 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -1,63 +1,66 @@ -"""API properties. - -""" - -from tabulate import tabulate, tabulate_formats, simple_separated_format -from common import skip - - -try: - from inspect import signature, _empty -except ImportError: - signature = None - _empty = None - - -def test_tabulate_formats(): - "API: tabulate_formats is a list of strings" "" - supported = tabulate_formats - print("tabulate_formats = %r" % supported) - assert type(supported) is list - for fmt in supported: - assert type(fmt) is str # noqa - - -def _check_signature(function, expected_sig): - if not signature: - skip("") - actual_sig = signature(function) - print(f"expected: {expected_sig}\nactual: {str(actual_sig)}\n") - - assert len(actual_sig.parameters) == len(expected_sig) - - for (e, ev), (a, av) in zip(expected_sig, actual_sig.parameters.items()): - assert e == a and ev == av.default - - -def test_tabulate_signature(): - "API: tabulate() type signature is unchanged" "" - assert type(tabulate) is type(lambda: None) # noqa - expected_sig = [ - ("tabular_data", _empty), - ("headers", ()), - ("tablefmt", "simple"), - ("floatfmt", "g"), - ("intfmt", ""), - ("numalign", "default"), - ("stralign", "default"), - ("missingval", ""), - ("showindex", "default"), - ("disable_numparse", False), - ("colalign", None), - ("maxcolwidths", None), - ("rowalign", None), - ("maxheadercolwidths", None), - ] - _check_signature(tabulate, expected_sig) - - -def test_simple_separated_format_signature(): - "API: simple_separated_format() type signature is unchanged" "" - assert type(simple_separated_format) is type(lambda: None) # noqa - expected_sig = [("separator", _empty)] - _check_signature(simple_separated_format, expected_sig) +"""API properties. + +""" + +from tabulate import tabulate, tabulate_formats, simple_separated_format +from common import skip + + +try: + from inspect import signature, _empty +except ImportError: + signature = None + _empty = None + + +def test_tabulate_formats(): + "API: tabulate_formats is a list of strings" "" + supported = tabulate_formats + print("tabulate_formats = %r" % supported) + assert type(supported) is list + for fmt in supported: + assert type(fmt) is str # noqa + + +def _check_signature(function, expected_sig): + if not signature: + skip("") + actual_sig = signature(function) + print(f"expected: {expected_sig}\nactual: {str(actual_sig)}\n") + + assert len(actual_sig.parameters) == len(expected_sig) + + for (e, ev), (a, av) in zip(expected_sig, actual_sig.parameters.items()): + assert e == a and ev == av.default + + +def test_tabulate_signature(): + "API: tabulate() type signature is unchanged" "" + assert type(tabulate) is type(lambda: None) # noqa + expected_sig = [ + ("tabular_data", _empty), + ("headers", ()), + ("tablefmt", "simple"), + ("floatfmt", "g"), + ("intfmt", ""), + ("numalign", "default"), + ("stralign", "default"), + ("missingval", ""), + ("showindex", "default"), + ("disable_numparse", False), + ("colglobalalign", None), + ("colalign", None), + ("maxcolwidths", None), + ("headersglobalalign", None), + ("headersalign", None), + ("rowalign", None), + ("maxheadercolwidths", None), + ] + _check_signature(tabulate, expected_sig) + + +def test_simple_separated_format_signature(): + "API: simple_separated_format() type signature is unchanged" "" + assert type(simple_separated_format) is type(lambda: None) # noqa + expected_sig = [("separator", _empty)] + _check_signature(simple_separated_format, expected_sig) From 22c754260a5a0ff5707293052502f5f94b1f4aa2 Mon Sep 17 00:00:00 2001 From: eliegoudout <114467748+eliegoudout@users.noreply.github.com> Date: Sat, 26 Nov 2022 03:59:37 +0100 Subject: [PATCH 07/90] Fixed files diff: CRLF -> LF --- tabulate/__init__.py | 5562 +++++++++++++++++++++--------------------- test/test_api.py | 132 +- 2 files changed, 2847 insertions(+), 2847 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 4857483..81001ef 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1,2781 +1,2781 @@ -"""Pretty-print tabular data.""" - -from collections import namedtuple -from collections.abc import Iterable, Sized -from html import escape as htmlescape -from itertools import chain, zip_longest as izip_longest -from functools import reduce, partial -import io -import re -import math -import textwrap -import dataclasses - -try: - import wcwidth # optional wide-character (CJK) support -except ImportError: - wcwidth = None - - -def _is_file(f): - return isinstance(f, io.IOBase) - - -__all__ = ["tabulate", "tabulate_formats", "simple_separated_format"] -try: - from .version import version as __version__ # noqa: F401 -except ImportError: - pass # running __init__.py as a script, AppVeyor pytests - - -# minimum extra space in headers -MIN_PADDING = 2 - -# Whether or not to preserve leading/trailing whitespace in data. -PRESERVE_WHITESPACE = False - -_DEFAULT_FLOATFMT = "g" -_DEFAULT_INTFMT = "" -_DEFAULT_MISSINGVAL = "" -# default align will be overwritten by "left", "center" or "decimal" -# depending on the formatter -_DEFAULT_ALIGN = "default" - - -# if True, enable wide-character (CJK) support -WIDE_CHARS_MODE = wcwidth is not None - -# 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. -# -# with_header_hide: -# -# - 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", - "with_header_hide", - ], -) - - -def _is_separating_line(row): - row_type = type(row) - is_sl = (row_type == list or row_type == str) and ( - (len(row) >= 1 and row[0] == SEPARATING_LINE) - or (len(row) >= 2 and row[1] == SEPARATING_LINE) - ) - return is_sl - - -def _pipe_segment_with_colons(align, colwidth): - """Return a segment of a horizontal line with optional colons which - indicate column's alignment (as in `pipe` output format).""" - w = colwidth - if align in ["right", "decimal"]: - return ("-" * (w - 1)) + ":" - elif align == "center": - return ":" + ("-" * (w - 2)) + ":" - elif align == "left": - return ":" + ("-" * (w - 1)) - else: - return "-" * w - - -def _pipe_line_with_colons(colwidths, colaligns): - """Return a horizontal line with optional colons to indicate column's - alignment (as in `pipe` output format).""" - if not colaligns: # e.g. printing an empty data frame (github issue #15) - colaligns = [""] * len(colwidths) - segments = [_pipe_segment_with_colons(a, w) for a, w in zip(colaligns, colwidths)] - return "|" + "|".join(segments) + "|" - - -def _mediawiki_row_with_attrs(separator, cell_values, colwidths, colaligns): - alignment = { - "left": "", - "right": 'style="text-align: right;"| ', - "center": 'style="text-align: center;"| ', - "decimal": 'style="text-align: right;"| ', - } - # hard-coded padding _around_ align attribute and value together - # rather than padding parameter which affects only the value - values_with_attrs = [ - " " + alignment.get(a, "") + c + " " for c, a in zip(cell_values, colaligns) - ] - colsep = separator * 2 - return (separator + colsep.join(values_with_attrs)).rstrip() - - -def _textile_row_with_attrs(cell_values, colwidths, colaligns): - cell_values[0] += " " - alignment = {"left": "<.", "right": ">.", "center": "=.", "decimal": ">."} - values = (alignment.get(a, "") + v for a, v in zip(colaligns, cell_values)) - return "|" + "|".join(values) + "|" - - -def _html_begin_table_without_header(colwidths_ignore, colaligns_ignore): - # this table header will be suppressed if there is a header row - return "\n" - - -def _html_row_with_attrs(celltag, unsafe, cell_values, colwidths, colaligns): - alignment = { - "left": "", - "right": ' style="text-align: right;"', - "center": ' style="text-align: center;"', - "decimal": ' style="text-align: right;"', - } - if unsafe: - values_with_attrs = [ - "<{0}{1}>{2}".format(celltag, alignment.get(a, ""), c) - for c, a in zip(cell_values, colaligns) - ] - else: - values_with_attrs = [ - "<{0}{1}>{2}".format(celltag, alignment.get(a, ""), htmlescape(c)) - for c, a in zip(cell_values, colaligns) - ] - rowhtml = "{}".format("".join(values_with_attrs).rstrip()) - if celltag == "th": # it's a header row, create a new table header - rowhtml = f"
\n\n{rowhtml}\n\n" - return rowhtml - - -def _moin_row_with_attrs(celltag, cell_values, colwidths, colaligns, header=""): - alignment = { - "left": "", - "right": '', - "center": '', - "decimal": '', - } - values_with_attrs = [ - "{}{} {} ".format(celltag, alignment.get(a, ""), header + c + header) - for c, a in zip(cell_values, colaligns) - ] - return "".join(values_with_attrs) + "||" - - -def _latex_line_begin_tabular(colwidths, colaligns, booktabs=False, longtable=False): - alignment = {"left": "l", "right": "r", "center": "c", "decimal": "r"} - tabular_columns_fmt = "".join([alignment.get(a, "l") for a in colaligns]) - return "\n".join( - [ - ("\\begin{tabular}{" if not longtable else "\\begin{longtable}{") - + tabular_columns_fmt - + "}", - "\\toprule" if booktabs else "\\hline", - ] - ) - - -def _asciidoc_row(is_header, *args): - """handle header and data rows for asciidoc format""" - - def make_header_line(is_header, colwidths, colaligns): - # generate the column specifiers - - alignment = {"left": "<", "right": ">", "center": "^", "decimal": ">"} - # use the column widths generated by tabulate for the asciidoc column width specifiers - asciidoc_alignments = zip( - colwidths, [alignment[colalign] for colalign in colaligns] - ) - asciidoc_column_specifiers = [ - "{:d}{}".format(width, align) for width, align in asciidoc_alignments - ] - header_list = ['cols="' + (",".join(asciidoc_column_specifiers)) + '"'] - - # generate the list of options (currently only "header") - options_list = [] - - if is_header: - options_list.append("header") - - if options_list: - header_list += ['options="' + ",".join(options_list) + '"'] - - # generate the list of entries in the table header field - - return "[{}]\n|====".format(",".join(header_list)) - - if len(args) == 2: - # two arguments are passed if called in the context of aboveline - # print the table header with column widths and optional header tag - return make_header_line(False, *args) - - elif len(args) == 3: - # three arguments are passed if called in the context of dataline or headerline - # print the table line and make the aboveline if it is a header - - cell_values, colwidths, colaligns = args - data_line = "|" + "|".join(cell_values) - - if is_header: - return make_header_line(True, colwidths, colaligns) + "\n" + data_line - else: - return data_line - - else: - raise ValueError( - " _asciidoc_row() requires two (colwidths, colaligns) " - + "or three (cell_values, colwidths, colaligns) arguments) " - ) - - -LATEX_ESCAPE_RULES = { - r"&": r"\&", - r"%": r"\%", - r"$": r"\$", - r"#": r"\#", - r"_": r"\_", - r"^": r"\^{}", - r"{": r"\{", - r"}": r"\}", - r"~": r"\textasciitilde{}", - "\\": r"\textbackslash{}", - r"<": r"\ensuremath{<}", - r">": r"\ensuremath{>}", -} - - -def _latex_row(cell_values, colwidths, colaligns, escrules=LATEX_ESCAPE_RULES): - def escape_char(c): - return escrules.get(c, c) - - escaped_values = ["".join(map(escape_char, cell)) for cell in cell_values] - rowfmt = DataRow("", "&", "\\\\") - return _build_simple_row(escaped_values, rowfmt) - - -def _rst_escape_first_column(rows, headers): - def escape_empty(val): - if isinstance(val, (str, bytes)) and not val.strip(): - return ".." - else: - return val - - new_headers = list(headers) - new_rows = [] - if headers: - new_headers[0] = escape_empty(headers[0]) - for row in rows: - new_row = list(row) - if new_row: - new_row[0] = escape_empty(row[0]) - new_rows.append(new_row) - return new_rows, new_headers - - -_table_formats = { - "simple": TableFormat( - lineabove=Line("", "-", " ", ""), - linebelowheader=Line("", "-", " ", ""), - linebetweenrows=None, - linebelow=Line("", "-", " ", ""), - headerrow=DataRow("", " ", ""), - datarow=DataRow("", " ", ""), - padding=0, - with_header_hide=["lineabove", "linebelow"], - ), - "plain": TableFormat( - lineabove=None, - linebelowheader=None, - linebetweenrows=None, - linebelow=None, - headerrow=DataRow("", " ", ""), - datarow=DataRow("", " ", ""), - padding=0, - with_header_hide=None, - ), - "grid": TableFormat( - lineabove=Line("+", "-", "+", "+"), - linebelowheader=Line("+", "=", "+", "+"), - linebetweenrows=Line("+", "-", "+", "+"), - linebelow=Line("+", "-", "+", "+"), - headerrow=DataRow("|", "|", "|"), - datarow=DataRow("|", "|", "|"), - padding=1, - with_header_hide=None, - ), - "simple_grid": TableFormat( - lineabove=Line("┌", "─", "┬", "┐"), - linebelowheader=Line("├", "─", "┼", "┤"), - linebetweenrows=Line("├", "─", "┼", "┤"), - linebelow=Line("└", "─", "┴", "┘"), - headerrow=DataRow("│", "│", "│"), - datarow=DataRow("│", "│", "│"), - padding=1, - with_header_hide=None, - ), - "rounded_grid": TableFormat( - lineabove=Line("╭", "─", "┬", "╮"), - linebelowheader=Line("├", "─", "┼", "┤"), - linebetweenrows=Line("├", "─", "┼", "┤"), - linebelow=Line("╰", "─", "┴", "╯"), - headerrow=DataRow("│", "│", "│"), - datarow=DataRow("│", "│", "│"), - padding=1, - with_header_hide=None, - ), - "heavy_grid": TableFormat( - lineabove=Line("┏", "━", "┳", "┓"), - linebelowheader=Line("┣", "━", "╋", "┫"), - linebetweenrows=Line("┣", "━", "╋", "┫"), - linebelow=Line("┗", "━", "┻", "┛"), - headerrow=DataRow("┃", "┃", "┃"), - datarow=DataRow("┃", "┃", "┃"), - padding=1, - with_header_hide=None, - ), - "mixed_grid": TableFormat( - lineabove=Line("┍", "━", "┯", "┑"), - linebelowheader=Line("┝", "━", "┿", "┥"), - linebetweenrows=Line("├", "─", "┼", "┤"), - linebelow=Line("┕", "━", "┷", "┙"), - headerrow=DataRow("│", "│", "│"), - datarow=DataRow("│", "│", "│"), - padding=1, - with_header_hide=None, - ), - "double_grid": TableFormat( - lineabove=Line("╔", "═", "╦", "╗"), - linebelowheader=Line("╠", "═", "╬", "╣"), - linebetweenrows=Line("╠", "═", "╬", "╣"), - linebelow=Line("╚", "═", "╩", "╝"), - headerrow=DataRow("║", "║", "║"), - datarow=DataRow("║", "║", "║"), - padding=1, - with_header_hide=None, - ), - "fancy_grid": TableFormat( - lineabove=Line("╒", "═", "╤", "╕"), - linebelowheader=Line("╞", "═", "╪", "╡"), - linebetweenrows=Line("├", "─", "┼", "┤"), - linebelow=Line("╘", "═", "╧", "╛"), - headerrow=DataRow("│", "│", "│"), - datarow=DataRow("│", "│", "│"), - padding=1, - with_header_hide=None, - ), - "outline": TableFormat( - lineabove=Line("+", "-", "+", "+"), - linebelowheader=Line("+", "=", "+", "+"), - linebetweenrows=None, - linebelow=Line("+", "-", "+", "+"), - headerrow=DataRow("|", "|", "|"), - datarow=DataRow("|", "|", "|"), - padding=1, - with_header_hide=None, - ), - "simple_outline": TableFormat( - lineabove=Line("┌", "─", "┬", "┐"), - linebelowheader=Line("├", "─", "┼", "┤"), - linebetweenrows=None, - linebelow=Line("└", "─", "┴", "┘"), - headerrow=DataRow("│", "│", "│"), - datarow=DataRow("│", "│", "│"), - padding=1, - with_header_hide=None, - ), - "rounded_outline": TableFormat( - lineabove=Line("╭", "─", "┬", "╮"), - linebelowheader=Line("├", "─", "┼", "┤"), - linebetweenrows=None, - linebelow=Line("╰", "─", "┴", "╯"), - headerrow=DataRow("│", "│", "│"), - datarow=DataRow("│", "│", "│"), - padding=1, - with_header_hide=None, - ), - "heavy_outline": TableFormat( - lineabove=Line("┏", "━", "┳", "┓"), - linebelowheader=Line("┣", "━", "╋", "┫"), - linebetweenrows=None, - linebelow=Line("┗", "━", "┻", "┛"), - headerrow=DataRow("┃", "┃", "┃"), - datarow=DataRow("┃", "┃", "┃"), - padding=1, - with_header_hide=None, - ), - "mixed_outline": TableFormat( - lineabove=Line("┍", "━", "┯", "┑"), - linebelowheader=Line("┝", "━", "┿", "┥"), - linebetweenrows=None, - linebelow=Line("┕", "━", "┷", "┙"), - headerrow=DataRow("│", "│", "│"), - datarow=DataRow("│", "│", "│"), - padding=1, - with_header_hide=None, - ), - "double_outline": TableFormat( - lineabove=Line("╔", "═", "╦", "╗"), - linebelowheader=Line("╠", "═", "╬", "╣"), - linebetweenrows=None, - linebelow=Line("╚", "═", "╩", "╝"), - headerrow=DataRow("║", "║", "║"), - datarow=DataRow("║", "║", "║"), - padding=1, - with_header_hide=None, - ), - "fancy_outline": TableFormat( - lineabove=Line("╒", "═", "╤", "╕"), - linebelowheader=Line("╞", "═", "╪", "╡"), - linebetweenrows=None, - linebelow=Line("╘", "═", "╧", "╛"), - headerrow=DataRow("│", "│", "│"), - datarow=DataRow("│", "│", "│"), - padding=1, - with_header_hide=None, - ), - "github": TableFormat( - lineabove=Line("|", "-", "|", "|"), - linebelowheader=Line("|", "-", "|", "|"), - linebetweenrows=None, - linebelow=None, - headerrow=DataRow("|", "|", "|"), - datarow=DataRow("|", "|", "|"), - padding=1, - with_header_hide=["lineabove"], - ), - "pipe": TableFormat( - lineabove=_pipe_line_with_colons, - linebelowheader=_pipe_line_with_colons, - linebetweenrows=None, - linebelow=None, - headerrow=DataRow("|", "|", "|"), - datarow=DataRow("|", "|", "|"), - padding=1, - with_header_hide=["lineabove"], - ), - "orgtbl": TableFormat( - lineabove=None, - linebelowheader=Line("|", "-", "+", "|"), - linebetweenrows=None, - linebelow=None, - headerrow=DataRow("|", "|", "|"), - datarow=DataRow("|", "|", "|"), - padding=1, - with_header_hide=None, - ), - "jira": TableFormat( - lineabove=None, - linebelowheader=None, - linebetweenrows=None, - linebelow=None, - headerrow=DataRow("||", "||", "||"), - datarow=DataRow("|", "|", "|"), - padding=1, - with_header_hide=None, - ), - "presto": TableFormat( - lineabove=None, - linebelowheader=Line("", "-", "+", ""), - linebetweenrows=None, - linebelow=None, - headerrow=DataRow("", "|", ""), - datarow=DataRow("", "|", ""), - padding=1, - with_header_hide=None, - ), - "pretty": TableFormat( - lineabove=Line("+", "-", "+", "+"), - linebelowheader=Line("+", "-", "+", "+"), - linebetweenrows=None, - linebelow=Line("+", "-", "+", "+"), - headerrow=DataRow("|", "|", "|"), - datarow=DataRow("|", "|", "|"), - padding=1, - with_header_hide=None, - ), - "psql": TableFormat( - lineabove=Line("+", "-", "+", "+"), - linebelowheader=Line("|", "-", "+", "|"), - linebetweenrows=None, - linebelow=Line("+", "-", "+", "+"), - headerrow=DataRow("|", "|", "|"), - datarow=DataRow("|", "|", "|"), - padding=1, - with_header_hide=None, - ), - "rst": TableFormat( - lineabove=Line("", "=", " ", ""), - linebelowheader=Line("", "=", " ", ""), - linebetweenrows=None, - linebelow=Line("", "=", " ", ""), - headerrow=DataRow("", " ", ""), - datarow=DataRow("", " ", ""), - padding=0, - with_header_hide=None, - ), - "mediawiki": TableFormat( - lineabove=Line( - '{| class="wikitable" style="text-align: left;"', - "", - "", - "\n|+ \n|-", - ), - linebelowheader=Line("|-", "", "", ""), - linebetweenrows=Line("|-", "", "", ""), - linebelow=Line("|}", "", "", ""), - headerrow=partial(_mediawiki_row_with_attrs, "!"), - datarow=partial(_mediawiki_row_with_attrs, "|"), - padding=0, - with_header_hide=None, - ), - "moinmoin": TableFormat( - lineabove=None, - linebelowheader=None, - linebetweenrows=None, - linebelow=None, - headerrow=partial(_moin_row_with_attrs, "||", header="'''"), - datarow=partial(_moin_row_with_attrs, "||"), - padding=1, - with_header_hide=None, - ), - "youtrack": TableFormat( - lineabove=None, - linebelowheader=None, - linebetweenrows=None, - linebelow=None, - headerrow=DataRow("|| ", " || ", " || "), - datarow=DataRow("| ", " | ", " |"), - padding=1, - with_header_hide=None, - ), - "html": TableFormat( - lineabove=_html_begin_table_without_header, - linebelowheader="", - linebetweenrows=None, - linebelow=Line("\n
", "", "", ""), - headerrow=partial(_html_row_with_attrs, "th", False), - datarow=partial(_html_row_with_attrs, "td", False), - padding=0, - with_header_hide=["lineabove"], - ), - "unsafehtml": TableFormat( - lineabove=_html_begin_table_without_header, - linebelowheader="", - linebetweenrows=None, - linebelow=Line("\n", "", "", ""), - headerrow=partial(_html_row_with_attrs, "th", True), - datarow=partial(_html_row_with_attrs, "td", True), - padding=0, - with_header_hide=["lineabove"], - ), - "latex": TableFormat( - lineabove=_latex_line_begin_tabular, - linebelowheader=Line("\\hline", "", "", ""), - linebetweenrows=None, - linebelow=Line("\\hline\n\\end{tabular}", "", "", ""), - headerrow=_latex_row, - datarow=_latex_row, - padding=1, - with_header_hide=None, - ), - "latex_raw": TableFormat( - lineabove=_latex_line_begin_tabular, - linebelowheader=Line("\\hline", "", "", ""), - linebetweenrows=None, - linebelow=Line("\\hline\n\\end{tabular}", "", "", ""), - headerrow=partial(_latex_row, escrules={}), - datarow=partial(_latex_row, escrules={}), - padding=1, - with_header_hide=None, - ), - "latex_booktabs": TableFormat( - lineabove=partial(_latex_line_begin_tabular, booktabs=True), - linebelowheader=Line("\\midrule", "", "", ""), - linebetweenrows=None, - linebelow=Line("\\bottomrule\n\\end{tabular}", "", "", ""), - headerrow=_latex_row, - datarow=_latex_row, - padding=1, - with_header_hide=None, - ), - "latex_longtable": TableFormat( - lineabove=partial(_latex_line_begin_tabular, longtable=True), - linebelowheader=Line("\\hline\n\\endhead", "", "", ""), - linebetweenrows=None, - linebelow=Line("\\hline\n\\end{longtable}", "", "", ""), - headerrow=_latex_row, - datarow=_latex_row, - padding=1, - with_header_hide=None, - ), - "tsv": TableFormat( - lineabove=None, - linebelowheader=None, - linebetweenrows=None, - linebelow=None, - headerrow=DataRow("", "\t", ""), - datarow=DataRow("", "\t", ""), - padding=0, - with_header_hide=None, - ), - "textile": TableFormat( - lineabove=None, - linebelowheader=None, - linebetweenrows=None, - linebelow=None, - headerrow=DataRow("|_. ", "|_.", "|"), - datarow=_textile_row_with_attrs, - padding=1, - with_header_hide=None, - ), - "asciidoc": TableFormat( - lineabove=partial(_asciidoc_row, False), - linebelowheader=None, - linebetweenrows=None, - linebelow=Line("|====", "", "", ""), - headerrow=partial(_asciidoc_row, True), - datarow=partial(_asciidoc_row, False), - padding=1, - with_header_hide=["lineabove"], - ), -} - - -tabulate_formats = list(sorted(_table_formats.keys())) - -# The table formats for which multiline cells will be folded into subsequent -# table rows. The key is the original format specified at the API. The value is -# the format that will be used to represent the original format. -multiline_formats = { - "plain": "plain", - "simple": "simple", - "grid": "grid", - "simple_grid": "simple_grid", - "rounded_grid": "rounded_grid", - "heavy_grid": "heavy_grid", - "mixed_grid": "mixed_grid", - "double_grid": "double_grid", - "fancy_grid": "fancy_grid", - "pipe": "pipe", - "orgtbl": "orgtbl", - "jira": "jira", - "presto": "presto", - "pretty": "pretty", - "psql": "psql", - "rst": "rst", - "outline": "outline", - "simple_outline": "simple_outline", - "rounded_outline": "rounded_outline", - "heavy_outline": "heavy_outline", - "mixed_outline": "mixed_outline", - "double_outline": "double_outline", - "fancy_outline": "fancy_outline", -} - -# TODO: Add multiline support for the remaining table formats: -# - mediawiki: Replace \n with
-# - moinmoin: TBD -# - youtrack: TBD -# - html: Replace \n with
-# - latex*: Use "makecell" package: In header, replace X\nY with -# \thead{X\\Y} and in data row, replace X\nY with \makecell{X\\Y} -# - tsv: TBD -# - textile: Replace \n with
(must be well-formed XML) - -_multiline_codes = re.compile(r"\r|\n|\r\n") -_multiline_codes_bytes = 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}\\" - -_ansi_escape_pat = 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 - ) -""" -_ansi_codes = re.compile(_ansi_escape_pat, re.VERBOSE) -_ansi_codes_bytes = re.compile(_ansi_escape_pat.encode("utf8"), re.VERBOSE) -_ansi_color_reset_code = "\033[0m" - -_float_with_thousands_separators = re.compile( - r"^(([+-]?[0-9]{1,3})(?:,([0-9]{3}))*)?(?(1)\.[0-9]*|\.[0-9]+)?$" -) - - -def simple_separated_format(separator): - """Construct a simple TableFormat with columns separated by a separator. - - >>> tsv = simple_separated_format("\\t") ; \ - tabulate([["foo", 1], ["spam", 23]], tablefmt=tsv) == 'foo \\t 1\\nspam\\t23' - True - - """ - return TableFormat( - None, - None, - None, - None, - headerrow=DataRow("", separator, ""), - datarow=DataRow("", separator, ""), - padding=0, - with_header_hide=None, - ) - - -def _isnumber_with_thousands_separator(string): - """ - >>> _isnumber_with_thousands_separator(".") - False - >>> _isnumber_with_thousands_separator("1") - True - >>> _isnumber_with_thousands_separator("1.") - True - >>> _isnumber_with_thousands_separator(".1") - True - >>> _isnumber_with_thousands_separator("1000") - False - >>> _isnumber_with_thousands_separator("1,000") - True - >>> _isnumber_with_thousands_separator("1,0000") - False - >>> _isnumber_with_thousands_separator("1,000.1234") - True - >>> _isnumber_with_thousands_separator(b"1,000.1234") - True - >>> _isnumber_with_thousands_separator("+1,000.1234") - True - >>> _isnumber_with_thousands_separator("-1,000.1234") - True - """ - try: - string = string.decode() - except (UnicodeDecodeError, AttributeError): - pass - - return bool(re.match(_float_with_thousands_separators, string)) - - -def _isconvertible(conv, string): - try: - conv(string) - return True - except (ValueError, TypeError): - return False - - -def _isnumber(string): - """ - >>> _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): - """ - >>> _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(">> _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, has_invisible=True, numparse=True): - """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 has_invisible and isinstance(string, (str, bytes)): - string = _strip_ansi(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 _isnumber_with_thousands_separator(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: - return -1 # no point - else: - return -1 # not a number - - -def _padleft(width, s): - """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): - """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): - """Center string. - - >>> _padboth(6, '\u044f\u0439\u0446\u0430') == ' \u044f\u0439\u0446\u0430 ' - True - - """ - fmt = "{0:^%ds}" % width - return fmt.format(s) - - -def _padnone(ignore_width, s): - return s - - -def _strip_ansi(s): - r"""Remove ANSI escape sequences, both CSI (color codes, etc) 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(_strip_ansi('\x1B]8;;https://example.com\x1B\\This is a link\x1B]8;;\x1B\\')) - "'This is a link'" - - >>> repr(_strip_ansi('\x1b[31mred\x1b[0m text')) - "'red text'" - - """ - if isinstance(s, str): - return _ansi_codes.sub(r"\4", s) - else: # a bytestring - return _ansi_codes_bytes.sub(r"\4", s) - - -def _visible_width(s): - """Visible width of a printed string. ANSI color codes are removed. - - >>> _visible_width('\x1b[31mhello\x1b[0m'), _visible_width("world") - (5, 5) - - """ - # optional wide-character support - if wcwidth is not None and WIDE_CHARS_MODE: - len_fn = wcwidth.wcswidth - else: - len_fn = len - if isinstance(s, (str, bytes)): - return len_fn(_strip_ansi(s)) - else: - return len_fn(str(s)) - - -def _is_multiline(s): - if isinstance(s, str): - return bool(re.search(_multiline_codes, s)) - else: # a bytestring - return bool(re.search(_multiline_codes_bytes, s)) - - -def _multiline_width(multiline_s, line_width_fn=len): - """Visible width of a potentially multiline content.""" - return max(map(line_width_fn, re.split("[\r\n]", multiline_s))) - - -def _choose_width_fn(has_invisible, enable_widechars, is_multiline): - """Return a function to calculate visible cell width.""" - if has_invisible: - line_width_fn = _visible_width - elif enable_widechars: # optional wide-character support if available - line_width_fn = wcwidth.wcswidth - else: - line_width_fn = len - if is_multiline: - width_fn = lambda s: _multiline_width(s, line_width_fn) # noqa - else: - width_fn = line_width_fn - return width_fn - - -def _align_column_choose_padfn(strings, alignment, has_invisible): - 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 has_invisible: - decimals = [_afterpoint(_strip_ansi(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 _align_column_choose_width_fn(has_invisible, enable_widechars, is_multiline): - if has_invisible: - line_width_fn = _visible_width - elif enable_widechars: # optional wide-character support if available - line_width_fn = wcwidth.wcswidth - else: - line_width_fn = len - if is_multiline: - width_fn = lambda s: _align_column_multiline_width(s, line_width_fn) # noqa - else: - width_fn = line_width_fn - return width_fn - - -def _align_column_multiline_width(multiline_s, line_width_fn=len): - """Visible width of a potentially multiline content.""" - return list(map(line_width_fn, re.split("[\r\n]", multiline_s))) - - -def _flat_list(nested_list): - ret = [] - for item in nested_list: - if isinstance(item, list): - for subitem in item: - ret.append(subitem) - else: - ret.append(item) - return ret - - -def _align_column( - strings, - alignment, - minwidth=0, - has_invisible=True, - enable_widechars=False, - is_multiline=False, -): - """[string] -> [padded_string]""" - strings, padfn = _align_column_choose_padfn(strings, alignment, has_invisible) - width_fn = _align_column_choose_width_fn( - has_invisible, enable_widechars, is_multiline - ) - - s_widths = list(map(width_fn, strings)) - maxwidth = max(max(_flat_list(s_widths)), minwidth) - # TODO: refactor column alignment in single-line and multiline modes - if is_multiline: - if not enable_widechars and not has_invisible: - padded_strings = [ - "\n".join([padfn(maxwidth, s) for s in ms.splitlines()]) - for ms in strings - ] - else: - # enable wide-character width corrections - s_lens = [[len(s) for s in re.split("[\r\n]", ms)] for ms in strings] - visible_widths = [ - [maxwidth - (w - l) for w, l in zip(mw, ml)] - for mw, ml in zip(s_widths, s_lens) - ] - # wcswidth and _visible_width don't count invisible characters; - # padfn doesn't need to apply another correction - padded_strings = [ - "\n".join([padfn(w, s) for s, w in zip((ms.splitlines() or ms), mw)]) - for ms, mw in zip(strings, visible_widths) - ] - else: # single-line cell values - if not enable_widechars and not has_invisible: - padded_strings = [padfn(maxwidth, s) for s in strings] - else: - # enable wide-character width corrections - s_lens = list(map(len, strings)) - visible_widths = [maxwidth - (w - l) for w, l in zip(s_widths, s_lens)] - # wcswidth and _visible_width don't count invisible characters; - # padfn doesn't need to apply another correction - padded_strings = [padfn(w, s) for s, w in zip(strings, visible_widths)] - return padded_strings - - -def _more_generic(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 _column_type(strings, has_invisible=True, numparse=True): - """The least generic type all column values are convertible to. - - >>> _column_type([True, False]) is bool - True - >>> _column_type(["1", "2"]) is int - True - >>> _column_type(["1", "2.3"]) is float - True - >>> _column_type(["1", "2.3", "four"]) is str - True - >>> _column_type(["four", '\u043f\u044f\u0442\u044c']) is str - True - >>> _column_type([None, "brux"]) is str - True - >>> _column_type([1, 2, None]) is int - True - >>> import datetime as dt - >>> _column_type([dt.datetime(1991,2,19), dt.time(17,35)]) is str - True - - """ - types = [_type(s, has_invisible, numparse) for s in strings] - return reduce(_more_generic, types, bool) - - -def _format(val, valtype, floatfmt, intfmt, missingval="", has_invisible=True): - """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: - is_a_colored_number = has_invisible and isinstance(val, (str, bytes)) - if is_a_colored_number: - raw_val = _strip_ansi(val) - formatted_val = format(float(raw_val), floatfmt) - return val.replace(raw_val, formatted_val) - else: - return format(float(val), floatfmt) - else: - return f"{val}" - - -def _align_header( - header, alignment, width, visible_width, is_multiline=False, width_fn=None -): - "Pad string header to width chars given known visible_width of the header." - if is_multiline: - header_lines = re.split(_multiline_codes, header) - padded_lines = [ - _align_header(h, alignment, width, width_fn(h)) for h in header_lines - ] - return "\n".join(padded_lines) - # else: not multiline - ninvisible = len(header) - visible_width - 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 _remove_separating_lines(rows): - if type(rows) == list: - separating_lines = [] - sans_rows = [] - for index, row in enumerate(rows): - if _is_separating_line(row): - separating_lines.append(index) - else: - sans_rows.append(row) - return sans_rows, separating_lines - else: - return rows, None - - -def _reinsert_separating_lines(rows, separating_lines): - if separating_lines: - for index in separating_lines: - rows.insert(index, SEPARATING_LINE) - - -def _prepend_row_index(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)) - ) - sans_rows, separating_lines = _remove_separating_lines(rows) - new_rows = [] - index_iter = iter(index) - for row in sans_rows: - index_v = next(index_iter) - new_rows.append([index_v] + list(row)) - rows = new_rows - _reinsert_separating_lines(rows, separating_lines) - 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 _normalize_tabular_data(tabular_data, headers, showindex="default"): - """Transform a supported data type to a list of lists, and a list of headers, with headers 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") - - * pandas.DataFrame (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="default", show row indices of the pandas.DataFrame. - 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) - is_headers2bool_broken = False # noqa - except ValueError: # numpy.ndarray, pandas.core.index.Index, ... - is_headers2bool_broken = True # noqa - headers = list(headers) - - index = None - if hasattr(tabular_data, "keys") and hasattr(tabular_data, "values"): - # dict-like and pandas.DataFrame? - if hasattr(tabular_data.values, "__call__"): - # likely a conventional dict - keys = tabular_data.keys() - rows = list( - izip_longest(*tabular_data.values()) - ) # columns have to be transposed - elif hasattr(tabular_data, "index"): - # values is a property, has .index => it's likely a pandas.DataFrame (pandas 0.11.0) - keys = list(tabular_data) - if ( - showindex in ["default", "always", True] - and tabular_data.index.name is not None - ): - if isinstance(tabular_data.index.name, list): - keys[:0] = tabular_data.index.name - else: - keys[:0] = [tabular_data.index.name] - vals = tabular_data.values # values matrix doesn't need to be transposed - # for DataFrames add an index per default - index = list(tabular_data.index) - rows = [list(row) for row in vals] - else: - raise ValueError("tabular data doesn't appear to be a dict or a DataFrame") - - if headers == "keys": - headers = list(map(str, keys)) # headers should be strings - - else: # it's a usual iterable of iterables, or a NumPy array, or an iterable of dataclasses - rows = list(tabular_data) - - if headers == "keys" and not rows: - # an empty table (issue #81) - headers = [] - elif ( - headers == "keys" - and hasattr(tabular_data, "dtype") - and getattr(tabular_data.dtype, "names") - ): - # numpy record array - headers = tabular_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 - uniq_keys = set() # implements hashed lookup - keys = [] # storage for set - if headers == "firstrow": - firstdict = rows[0] if len(rows) > 0 else {} - keys.extend(firstdict.keys()) - uniq_keys.update(keys) - rows = rows[1:] - for row in rows: - for k in row.keys(): - # Save unique items in input order - if k not in uniq_keys: - keys.append(k) - uniq_keys.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 ( - headers == "keys" - and hasattr(tabular_data, "description") - and hasattr(tabular_data, "fetchone") - and hasattr(tabular_data, "rowcount") - ): - # Python Database API cursor object (PEP 0249) - # print tabulate(cursor, headers='keys') - headers = [column[0] for column in tabular_data.description] - - elif ( - dataclasses is not None - and len(rows) > 0 - and dataclasses.is_dataclass(rows[0]) - ): - # Python 3.7+'s dataclass - field_names = [field.name for field in dataclasses.fields(rows[0])] - if headers == "keys": - headers = field_names - rows = [[getattr(row, f) for f in field_names] 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(list, rows)) - rows = list(map(lambda r: r if _is_separating_line(r) else list(r), rows)) - - # add or remove an index column - showindex_is_a_str = type(showindex) in [str, bytes] - if showindex == "default" and index is not None: - rows = _prepend_row_index(rows, index) - elif isinstance(showindex, Sized) and not showindex_is_a_str: - rows = _prepend_row_index(rows, list(showindex)) - elif isinstance(showindex, Iterable) and not showindex_is_a_str: - rows = _prepend_row_index(rows, showindex) - elif showindex == "always" or (_bool(showindex) and not showindex_is_a_str): - if index is None: - index = list(range(len(rows))) - rows = _prepend_row_index(rows, index) - elif showindex == "never" or (not _bool(showindex) and not showindex_is_a_str): - pass - - # pad with empty headers for initial columns if necessary - headers_pad = 0 - if headers and len(rows) > 0: - headers_pad = max(0, len(rows[0]) - len(headers)) - headers = [""] * headers_pad + headers - - return rows, headers, headers_pad - - -def _wrap_text_to_colwidths(list_of_lists, colwidths, numparses=True): - if len(list_of_lists): - num_cols = len(list_of_lists[0]) - else: - num_cols = 0 - numparses = _expand_iterable(numparses, num_cols, True) - - result = [] - - for row in list_of_lists: - new_row = [] - for cell, width, numparse in zip(row, colwidths, numparses): - if _isnumber(cell) and numparse: - new_row.append(cell) - continue - - if width is not None: - wrapper = _CustomTextWrap(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 - casted_cell = ( - str(cell) if _isnumber(cell) else _type(cell, numparse)(cell) - ) - wrapped = [ - "\n".join(wrapper.wrap(line)) - for line in casted_cell.splitlines() - if line.strip() != "" - ] - new_row.append("\n".join(wrapped)) - else: - new_row.append(cell) - result.append(new_row) - - return result - - -def _to_str(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(_to_str(b'foo')) - "'foo'" - - >>> repr(_to_str('foo')) - "'foo'" - - >>> repr(_to_str(42)) - "'42'" - - """ - if isinstance(s, bytes): - return s.decode(encoding=encoding, errors=errors) - return str(s) - - -def tabulate( - tabular_data, - headers=(), - tablefmt="simple", - floatfmt=_DEFAULT_FLOATFMT, - intfmt=_DEFAULT_INTFMT, - numalign=_DEFAULT_ALIGN, - stralign=_DEFAULT_ALIGN, - missingval=_DEFAULT_MISSINGVAL, - showindex="default", - disable_numparse=False, - colglobalalign=None, - colalign=None, - maxcolwidths=None, - headersglobalalign=None, - headersalign=None, - rowalign=None, - maxheadercolwidths=None, -): - """Format a fixed width table for pretty printing. - - >>> print(tabulate([[1, 2.34], [-56, "8.999"], ["2", "10001"]])) - --- --------- - 1 2.34 - -56 8.999 - 2 10001 - --- --------- - - The first required argument (`tabular_data`) can be a - list-of-lists (or another iterable of iterables), a list of named - tuples, a dictionary of iterables, an iterable of dictionaries, - an iterable of dataclasses (Python 3.7+), a two-dimensional NumPy array, - NumPy record array, or a Pandas' dataframe. - - - 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 and Pandas' dataframes. - - >>> print(tabulate([["sex","age"],["Alice","F",24],["Bob","M",19]], - ... headers="firstrow")) - sex age - ----- ----- ----- - Alice F 24 - Bob M 19 - - By default, pandas.DataFrame data have an additional column called - row index. To add a similar column to all other types of data, - use `showindex="always"` or `showindex=True`. To suppress row indices - for all types of data, pass `showindex="never" or `showindex=False`. - To add a custom row index column, pass `showindex=some_iterable`. - - >>> print(tabulate([["F",24],["M",19]], showindex="always")) - - - -- - 0 F 24 - 1 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". Other values are treated as "left". - `colalign` allows for column-wise override starting from left-most - column. Possible values are: "global" (no override), "right", - "center", "decimal", "left". Other values are teated as "left". - `headersglobalalign` allows for global headers alignment, before any - specific override from `headersalign`. Possible values are: None - (follow columns alignment), "right", "center", "left". Other - values are treated as "right". - `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". Other - values are teated as "right". - - Note: if column alignment is illegal (treating it as left) and - corresponding header aligns as "same", it will treat it as "right". - Thus, in spite of "same" being specified, alignment will not - visually be the same in the end. - - 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', 'pipe', 'orgtbl', 'rst', 'mediawiki', - 'latex', 'latex_raw', 'latex_booktabs', 'latex_longtable' and tsv. - Variable `tabulate_formats`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 | - +------+----------+ - - "simple_grid" draws a grid using single-line box-drawing - characters: - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], - ... ["strings", "numbers"], "simple_grid")) - ┌───────────┬───────────┐ - │ strings │ numbers │ - ├───────────┼───────────┤ - │ spam │ 41.9999 │ - ├───────────┼───────────┤ - │ eggs │ 451 │ - └───────────┴───────────┘ - - "rounded_grid" draws a grid using single-line box-drawing - characters with rounded corners: - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], - ... ["strings", "numbers"], "rounded_grid")) - ╭───────────┬───────────╮ - │ strings │ numbers │ - ├───────────┼───────────┤ - │ spam │ 41.9999 │ - ├───────────┼───────────┤ - │ eggs │ 451 │ - ╰───────────┴───────────╯ - - "heavy_grid" draws a grid using bold (thick) single-line box-drawing - characters: - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], - ... ["strings", "numbers"], "heavy_grid")) - ┏━━━━━━━━━━━┳━━━━━━━━━━━┓ - ┃ strings ┃ numbers ┃ - ┣━━━━━━━━━━━╋━━━━━━━━━━━┫ - ┃ spam ┃ 41.9999 ┃ - ┣━━━━━━━━━━━╋━━━━━━━━━━━┫ - ┃ eggs ┃ 451 ┃ - ┗━━━━━━━━━━━┻━━━━━━━━━━━┛ - - "mixed_grid" draws a grid using a mix of light (thin) and heavy (thick) lines - box-drawing characters: - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], - ... ["strings", "numbers"], "mixed_grid")) - ┍━━━━━━━━━━━┯━━━━━━━━━━━┑ - │ strings │ numbers │ - ┝━━━━━━━━━━━┿━━━━━━━━━━━┥ - │ spam │ 41.9999 │ - ├───────────┼───────────┤ - │ eggs │ 451 │ - ┕━━━━━━━━━━━┷━━━━━━━━━━━┙ - - "double_grid" draws a grid using double-line box-drawing - characters: - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], - ... ["strings", "numbers"], "double_grid")) - ╔═══════════╦═══════════╗ - ║ strings ║ numbers ║ - ╠═══════════╬═══════════╣ - ║ spam ║ 41.9999 ║ - ╠═══════════╬═══════════╣ - ║ eggs ║ 451 ║ - ╚═══════════╩═══════════╝ - - "fancy_grid" draws a grid using a mix of single and - double-line box-drawing characters: - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], - ... ["strings", "numbers"], "fancy_grid")) - ╒═══════════╤═══════════╕ - │ strings │ numbers │ - ╞═══════════╪═══════════╡ - │ spam │ 41.9999 │ - ├───────────┼───────────┤ - │ eggs │ 451 │ - ╘═══════════╧═══════════╛ - - "outline" is the same as the "grid" format but doesn't draw lines between rows: - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], - ... ["strings", "numbers"], "outline")) - +-----------+-----------+ - | strings | numbers | - +===========+===========+ - | spam | 41.9999 | - | eggs | 451 | - +-----------+-----------+ - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], tablefmt="outline")) - +------+----------+ - | spam | 41.9999 | - | eggs | 451 | - +------+----------+ - - "simple_outline" is the same as the "simple_grid" format but doesn't draw lines between rows: - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], - ... ["strings", "numbers"], "simple_outline")) - ┌───────────┬───────────┐ - │ strings │ numbers │ - ├───────────┼───────────┤ - │ spam │ 41.9999 │ - │ eggs │ 451 │ - └───────────┴───────────┘ - - "rounded_outline" is the same as the "rounded_grid" format but doesn't draw lines between rows: - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], - ... ["strings", "numbers"], "rounded_outline")) - ╭───────────┬───────────╮ - │ strings │ numbers │ - ├───────────┼───────────┤ - │ spam │ 41.9999 │ - │ eggs │ 451 │ - ╰───────────┴───────────╯ - - "heavy_outline" is the same as the "heavy_grid" format but doesn't draw lines between rows: - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], - ... ["strings", "numbers"], "heavy_outline")) - ┏━━━━━━━━━━━┳━━━━━━━━━━━┓ - ┃ strings ┃ numbers ┃ - ┣━━━━━━━━━━━╋━━━━━━━━━━━┫ - ┃ spam ┃ 41.9999 ┃ - ┃ eggs ┃ 451 ┃ - ┗━━━━━━━━━━━┻━━━━━━━━━━━┛ - - "mixed_outline" is the same as the "mixed_grid" format but doesn't draw lines between rows: - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], - ... ["strings", "numbers"], "mixed_outline")) - ┍━━━━━━━━━━━┯━━━━━━━━━━━┑ - │ strings │ numbers │ - ┝━━━━━━━━━━━┿━━━━━━━━━━━┥ - │ spam │ 41.9999 │ - │ eggs │ 451 │ - ┕━━━━━━━━━━━┷━━━━━━━━━━━┙ - - "double_outline" is the same as the "double_grid" format but doesn't draw lines between rows: - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], - ... ["strings", "numbers"], "double_outline")) - ╔═══════════╦═══════════╗ - ║ strings ║ numbers ║ - ╠═══════════╬═══════════╣ - ║ spam ║ 41.9999 ║ - ║ eggs ║ 451 ║ - ╚═══════════╩═══════════╝ - - "fancy_outline" is the same as the "fancy_grid" format but doesn't draw lines between rows: - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], - ... ["strings", "numbers"], "fancy_outline")) - ╒═══════════╤═══════════╕ - │ strings │ numbers │ - ╞═══════════╪═══════════╡ - │ spam │ 41.9999 │ - │ eggs │ 451 │ - ╘═══════════╧═══════════╛ - - "pipe" is like tables in PHP Markdown Extra extension or Pandoc - pipe_tables: - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], - ... ["strings", "numbers"], "pipe")) - | strings | numbers | - |:----------|----------:| - | spam | 41.9999 | - | eggs | 451 | - - "presto" is like tables produce by the Presto CLI: - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], - ... ["strings", "numbers"], "presto")) - strings | numbers - -----------+----------- - spam | 41.9999 - eggs | 451 - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], tablefmt="pipe")) - |:-----|---------:| - | spam | 41.9999 | - | eggs | 451 | - - "orgtbl" is like tables in Emacs org-mode and orgtbl-mode. They - are slightly different from "pipe" format by not using colons to - define column alignment, and using a "+" sign to indicate line - intersections: - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], - ... ["strings", "numbers"], "orgtbl")) - | strings | numbers | - |-----------+-----------| - | spam | 41.9999 | - | eggs | 451 | - - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], tablefmt="orgtbl")) - | 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 - ==== ======== - - "mediawiki" produces a table markup used in Wikipedia and on other - MediaWiki-based sites: - - >>> print(tabulate([["strings", "numbers"], ["spam", 41.9999], ["eggs", "451.0"]], - ... headers="firstrow", tablefmt="mediawiki")) - {| class="wikitable" style="text-align: left;" - |+ - |- - ! strings !! style="text-align: right;"| numbers - |- - | spam || style="text-align: right;"| 41.9999 - |- - | eggs || style="text-align: right;"| 451 - |} - - "html" produces HTML markup as an html.escape'd str - with a ._repr_html_ method so that Jupyter Lab and Notebook display the HTML - and a .str property so that the raw HTML remains accessible - the unsafehtml table format can be used if an unescaped HTML format is required: - - >>> print(tabulate([["strings", "numbers"], ["spam", 41.9999], ["eggs", "451.0"]], - ... headers="firstrow", tablefmt="html")) - - - - - - - - -
strings numbers
spam 41.9999
eggs 451
- - "latex" produces a tabular environment of LaTeX document markup: - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], tablefmt="latex")) - \\begin{tabular}{lr} - \\hline - spam & 41.9999 \\\\ - eggs & 451 \\\\ - \\hline - \\end{tabular} - - "latex_raw" is similar to "latex", but doesn't escape special characters, - such as backslash and underscore, so LaTeX commands may embedded into - cells' values: - - >>> print(tabulate([["spam$_9$", 41.9999], ["\\\\emph{eggs}", "451.0"]], tablefmt="latex_raw")) - \\begin{tabular}{lr} - \\hline - spam$_9$ & 41.9999 \\\\ - \\emph{eggs} & 451 \\\\ - \\hline - \\end{tabular} - - "latex_booktabs" produces a tabular environment of LaTeX document markup - using the booktabs.sty package: - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], tablefmt="latex_booktabs")) - \\begin{tabular}{lr} - \\toprule - spam & 41.9999 \\\\ - eggs & 451 \\\\ - \\bottomrule - \\end{tabular} - - "latex_longtable" produces a tabular environment that can stretch along - multiple pages, using the longtable package for LaTeX. - - >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], tablefmt="latex_longtable")) - \\begin{longtable}{lr} - \\hline - spam & 41.9999 \\\\ - eggs & 451 \\\\ - \\hline - \\end{longtable} - - - 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 - `disable_numparse=True`. For more fine grained control, a list column - indices is used to disable number parsing only on those columns - e.g. `disable_numparse=[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` - - """ - - if tabular_data is None: - tabular_data = [] - - list_of_lists, headers, headers_pad = _normalize_tabular_data( - tabular_data, headers, showindex=showindex - ) - list_of_lists, separating_lines = _remove_separating_lines(list_of_lists) - - if maxcolwidths is not None: - if len(list_of_lists): - num_cols = len(list_of_lists[0]) - else: - num_cols = 0 - if isinstance(maxcolwidths, int): # Expand scalar for all columns - maxcolwidths = _expand_iterable(maxcolwidths, num_cols, maxcolwidths) - else: # Ignore col width for any 'trailing' columns - maxcolwidths = _expand_iterable(maxcolwidths, num_cols, None) - - numparses = _expand_numparse(disable_numparse, num_cols) - list_of_lists = _wrap_text_to_colwidths( - list_of_lists, maxcolwidths, numparses=numparses - ) - - if maxheadercolwidths is not None: - num_cols = len(list_of_lists[0]) - if isinstance(maxheadercolwidths, int): # Expand scalar for all columns - maxheadercolwidths = _expand_iterable( - maxheadercolwidths, num_cols, maxheadercolwidths - ) - else: # Ignore col width for any 'trailing' columns - maxheadercolwidths = _expand_iterable(maxheadercolwidths, num_cols, None) - - numparses = _expand_numparse(disable_numparse, num_cols) - headers = _wrap_text_to_colwidths( - [headers], maxheadercolwidths, numparses=numparses - )[0] - - # empty values in the first column of RST tables should be escaped (issue #82) - # "" should be escaped as "\\ " or ".." - if tablefmt == "rst": - list_of_lists, headers = _rst_escape_first_column(list_of_lists, headers) - - # PrettyTable 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. - min_padding = MIN_PADDING - if tablefmt == "pretty": - min_padding = 0 - disable_numparse = 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) - plain_text = "\t".join( - chain( - # headers - map(_to_str, headers), - # rows: chain the rows together into a single iterable after mapping - # the bytestring conversino to each cell value - chain.from_iterable(map(_to_str, row) for row in list_of_lists), - ) - ) - - has_invisible = _ansi_codes.search(plain_text) is not None - - enable_widechars = wcwidth is not None and WIDE_CHARS_MODE - if ( - not isinstance(tablefmt, TableFormat) - and tablefmt in multiline_formats - and _is_multiline(plain_text) - ): - tablefmt = multiline_formats.get(tablefmt, tablefmt) - is_multiline = True - else: - is_multiline = False - width_fn = _choose_width_fn(has_invisible, enable_widechars, is_multiline) - - # format rows and columns, convert numeric values to strings - cols = list(izip_longest(*list_of_lists)) - numparses = _expand_numparse(disable_numparse, len(cols)) - coltypes = [_column_type(col, numparse=np) for col, np in zip(cols, numparses)] - if isinstance(floatfmt, str): # old version - float_formats = len(cols) * [ - floatfmt - ] # just duplicate the string to use in each column - else: # if floatfmt is list, tuple etc we have one per column - float_formats = list(floatfmt) - if len(float_formats) < len(cols): - float_formats.extend((len(cols) - len(float_formats)) * [_DEFAULT_FLOATFMT]) - if isinstance(intfmt, str): # old version - int_formats = len(cols) * [ - intfmt - ] # just duplicate the string to use in each column - else: # if intfmt is list, tuple etc we have one per column - int_formats = list(intfmt) - if len(int_formats) < len(cols): - int_formats.extend((len(cols) - len(int_formats)) * [_DEFAULT_INTFMT]) - if isinstance(missingval, str): - missing_vals = len(cols) * [missingval] - else: - missing_vals = list(missingval) - if len(missing_vals) < len(cols): - missing_vals.extend((len(cols) - len(missing_vals)) * [_DEFAULT_MISSINGVAL]) - cols = [ - [_format(v, ct, fl_fmt, int_fmt, miss_v, has_invisible) for v in c] - for c, ct, fl_fmt, int_fmt, miss_v in zip( - cols, coltypes, float_formats, int_formats, missing_vals - ) - ] - - # 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) - for idx, align in enumerate(colalign): - if align != "global": - aligns[idx] = align - minwidths = ( - [width_fn(h) + min_padding for h in headers] if headers else [0] * len(cols) - ) - cols = [ - _align_column(c, a, minw, has_invisible, enable_widechars, is_multiline) - for c, a, minw in zip(cols, aligns, minwidths) - ] - - aligns_headers = None - if headers: - # align headers and add headers - t_cols = cols or [[""]] * len(headers) - # first set global alignment - if headersglobalalign is not None: # if global alignment provided - aligns_headers = [headersglobalalign] * len(t_cols) - else: # default - aligns_headers = aligns or [stralign] * len(headers) - # then specific header alignements - if headersalign is not None: - assert isinstance(headersalign, Iterable) - for idx, align in enumerate(headersalign[:len(aligns_headers)]): - hidx = headers_pad + idx - if align == "same" and hidx < len(aligns): # same as column align - aligns_headers[hidx] = aligns[hidx] - elif align != "global": - aligns_headers[hidx] = align - minwidths = [ - max(minw, max(width_fn(cl) for cl in c)) - for minw, c in zip(minwidths, t_cols) - ] - headers = [ - _align_header(h, a, minw, width_fn(h), is_multiline, width_fn) - for h, a, minw in zip(headers, aligns_headers, minwidths) - ] - rows = list(zip(*cols)) - else: - minwidths = [max(width_fn(cl) for cl in c) for c in cols] - rows = list(zip(*cols)) - - if not isinstance(tablefmt, TableFormat): - tablefmt = _table_formats.get(tablefmt, _table_formats["simple"]) - - ra_default = rowalign if isinstance(rowalign, str) else None - rowaligns = _expand_iterable(rowalign, len(rows), ra_default) - _reinsert_separating_lines(rows, separating_lines) - - return _format_table( - tablefmt, headers, aligns_headers, rows, minwidths, aligns, is_multiline, rowaligns=rowaligns - ) - - -def _expand_numparse(disable_numparse, column_count): - """ - Return a list of bools of length `column_count` which indicates whether - number parsing should be used on each column. - If `disable_numparse` is a list of indices, each of those indices are False, - and everything else is True. - If `disable_numparse` is a bool, then the returned list is all the same. - """ - if isinstance(disable_numparse, Iterable): - numparses = [True] * column_count - for index in disable_numparse: - numparses[index] = False - return numparses - else: - return [not disable_numparse] * column_count - - -def _expand_iterable(original, num_desired, default): - """ - Expands the `original` argument to return a return a list of - length `num_desired`. If `original` is shorter than `num_desired`, 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 `num_desired` completely populated with `default will be returned - """ - if isinstance(original, Iterable) and not isinstance(original, str): - return original + [default] * (num_desired - len(original)) - else: - return [default] * num_desired - - -def _pad_row(cells, padding): - if cells: - pad = " " * padding - padded_cells = [pad + cell + pad for cell in cells] - return padded_cells - else: - return cells - - -def _build_simple_row(padded_cells, rowfmt): - "Format row according to DataRow format without padding." - begin, sep, end = rowfmt - return (begin + sep.join(padded_cells) + end).rstrip() - - -def _build_row(padded_cells, colwidths, colaligns, rowfmt): - "Return a string which represents a row of data cells." - if not rowfmt: - return None - if hasattr(rowfmt, "__call__"): - return rowfmt(padded_cells, colwidths, colaligns) - else: - return _build_simple_row(padded_cells, rowfmt) - - -def _append_basic_row(lines, padded_cells, colwidths, colaligns, rowfmt, rowalign=None): - # NOTE: rowalign is ignored and exists for api compatibility with _append_multiline_row - lines.append(_build_row(padded_cells, colwidths, colaligns, rowfmt)) - return lines - - -def _align_cell_veritically(text_lines, num_lines, column_width, row_alignment): - delta_lines = num_lines - len(text_lines) - blank = [" " * column_width] - if row_alignment == "bottom": - return blank * delta_lines + text_lines - elif row_alignment == "center": - top_delta = delta_lines // 2 - bottom_delta = delta_lines - top_delta - return top_delta * blank + text_lines + bottom_delta * blank - else: - return text_lines + blank * delta_lines - - -def _append_multiline_row( - lines, padded_multiline_cells, padded_widths, colaligns, rowfmt, pad, rowalign=None -): - colwidths = [w - 2 * pad for w in padded_widths] - cells_lines = [c.splitlines() for c in padded_multiline_cells] - nlines = max(map(len, cells_lines)) # number of lines in the row - # vertically pad cells where some lines are missing - # cells_lines = [ - # (cl + [" " * w] * (nlines - len(cl))) for cl, w in zip(cells_lines, colwidths) - # ] - - cells_lines = [ - _align_cell_veritically(cl, nlines, w, rowalign) - for cl, w in zip(cells_lines, colwidths) - ] - lines_cells = [[cl[i] for cl in cells_lines] for i in range(nlines)] - for ln in lines_cells: - padded_ln = _pad_row(ln, pad) - _append_basic_row(lines, padded_ln, colwidths, colaligns, rowfmt) - return lines - - -def _build_line(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 _build_simple_row(cells, (begin, sep, end)) - - -def _append_line(lines, colwidths, colaligns, linefmt): - lines.append(_build_line(colwidths, colaligns, linefmt)) - return lines - - -class JupyterHTMLStr(str): - """Wrap the string with a _repr_html_ method so that Jupyter - displays the HTML table""" - - def _repr_html_(self): - return self - - @property - def str(self): - """add a .str property so that the raw string is still accessible""" - return self - - -def _format_table(fmt, headers, headersaligns, rows, colwidths, colaligns, is_multiline, rowaligns): - """Produce a plain-text representation of the table.""" - lines = [] - hidden = fmt.with_header_hide if (headers and fmt.with_header_hide) else [] - pad = fmt.padding - headerrow = fmt.headerrow - - padded_widths = [(w + 2 * pad) for w in colwidths] - if is_multiline: - pad_row = lambda row, _: row # noqa do it later, in _append_multiline_row - append_row = partial(_append_multiline_row, pad=pad) - else: - pad_row = _pad_row - append_row = _append_basic_row - - padded_headers = pad_row(headers, pad) - padded_rows = [pad_row(row, pad) for row in rows] - - if fmt.lineabove and "lineabove" not in hidden: - _append_line(lines, padded_widths, colaligns, fmt.lineabove) - - if padded_headers: - append_row(lines, padded_headers, padded_widths, headersaligns, headerrow) - if fmt.linebelowheader and "linebelowheader" not in hidden: - _append_line(lines, padded_widths, colaligns, fmt.linebelowheader) - - if padded_rows and fmt.linebetweenrows and "linebetweenrows" not in hidden: - # initial rows with a line below - for row, ralign in zip(padded_rows[:-1], rowaligns): - append_row( - lines, row, padded_widths, colaligns, fmt.datarow, rowalign=ralign - ) - _append_line(lines, padded_widths, colaligns, fmt.linebetweenrows) - # the last row without a line below - append_row( - lines, - padded_rows[-1], - padded_widths, - colaligns, - fmt.datarow, - rowalign=rowaligns[-1], - ) - else: - separating_line = ( - fmt.linebetweenrows - or fmt.linebelowheader - or fmt.linebelow - or fmt.lineabove - or Line("", "", "", "") - ) - for row in padded_rows: - # test to see if either the 1st column or the 2nd column (account for showindex) has - # the SEPARATING_LINE flag - if _is_separating_line(row): - _append_line(lines, padded_widths, colaligns, separating_line) - else: - append_row(lines, row, padded_widths, colaligns, fmt.datarow) - - if fmt.linebelow and "linebelow" not in hidden: - _append_line(lines, padded_widths, colaligns, fmt.linebelow) - - if headers or rows: - output = "\n".join(lines) - if fmt.lineabove == _html_begin_table_without_header: - return JupyterHTMLStr(output) - else: - return output - else: # a completely empty table - return "" - - -class _CustomTextWrap(textwrap.TextWrapper): - """A custom implementation of CPython's textwrap.TextWrapper. This supports - both wide characters (Korea, Japanese, Chinese) - including mixed string. - For the most part, the `_handle_long_word` and `_wrap_chunks` functions were - copy pasted out of the CPython baseline, and updated with our custom length - and line appending logic. - """ - - def __init__(self, *args, **kwargs): - self._active_codes = [] - self.max_lines = None # For python2 compatibility - textwrap.TextWrapper.__init__(self, *args, **kwargs) - - @staticmethod - def _len(item): - """Custom len that gets console column width for wide - and non-wide characters as well as ignores color codes""" - stripped = _strip_ansi(item) - if wcwidth: - return wcwidth.wcswidth(stripped) - else: - return len(stripped) - - def _update_lines(self, lines, new_line): - """Adds a new line to the list of lines the text is being wrapped into - This function will also track any ANSI color codes in this string as well - as add any colors from previous lines order to preserve the same formatting - as a single unwrapped string. - """ - code_matches = [x for x in _ansi_codes.finditer(new_line)] - color_codes = [ - code.string[code.span()[0] : code.span()[1]] for code in code_matches - ] - - # Add color codes from earlier in the unwrapped line, and then track any new ones we add. - new_line = "".join(self._active_codes) + new_line - - for code in color_codes: - if code != _ansi_color_reset_code: - self._active_codes.append(code) - else: # A single reset code resets everything - self._active_codes = [] - - # Always ensure each line is color terminted if any colors are - # still active, otherwise colors will bleed into other cells on the console - if len(self._active_codes) > 0: - new_line = new_line + _ansi_color_reset_code - - lines.append(new_line) - - def _handle_long_word(self, reversed_chunks, cur_line, cur_len, width): - """_handle_long_word(chunks : [string], - cur_line : [string], - cur_len : int, width : int) - Handle a chunk of text (most likely a word, not whitespace) that - is too long to fit in any line. - """ - # Figure out when indent is larger than the specified width, and make - # sure at least one character is stripped off on every pass - if width < 1: - space_left = 1 - else: - space_left = width - cur_len - - # If we're allowed to break long words, then do so: put as much - # of the next chunk onto the current line as will fit. - if self.break_long_words: - # Tabulate Custom: Build the string up piece-by-piece in order to - # take each charcter's width into account - chunk = reversed_chunks[-1] - i = 1 - while self._len(chunk[:i]) <= space_left: - i = i + 1 - cur_line.append(chunk[: i - 1]) - reversed_chunks[-1] = chunk[i - 1 :] - - # Otherwise, we have to preserve the long word intact. Only add - # it to the current line if there's nothing already there -- - # that minimizes how much we violate the width constraint. - elif not cur_line: - cur_line.append(reversed_chunks.pop()) - - # If we're not allowed to break long words, and there's already - # text on the current line, do nothing. Next time through the - # main loop of _wrap_chunks(), we'll wind up here again, but - # cur_len will be zero, so the next line will be entirely - # devoted to the long word that we can't handle right now. - - def _wrap_chunks(self, chunks): - """_wrap_chunks(chunks : [string]) -> [string] - Wrap a sequence of text chunks and return a list of lines of - length 'self.width' or less. (If 'break_long_words' is false, - some lines may be longer than this.) Chunks correspond roughly - to words and the whitespace between them: each chunk is - indivisible (modulo 'break_long_words'), but a line break can - come between any two chunks. Chunks should not have internal - whitespace; ie. a chunk is either all whitespace or a "word". - Whitespace chunks will be removed from the beginning and end of - lines, but apart from that whitespace is preserved. - """ - lines = [] - if self.width <= 0: - raise ValueError("invalid width %r (must be > 0)" % self.width) - if self.max_lines is not None: - if self.max_lines > 1: - indent = self.subsequent_indent - else: - indent = self.initial_indent - if self._len(indent) + self._len(self.placeholder.lstrip()) > self.width: - raise ValueError("placeholder too large for max width") - - # Arrange in reverse order so items can be efficiently popped - # from a stack of chucks. - chunks.reverse() - - while chunks: - - # Start the list of chunks that will make up the current line. - # cur_len is just the length of all the chunks in cur_line. - cur_line = [] - cur_len = 0 - - # Figure out which static string will prefix this line. - if lines: - indent = self.subsequent_indent - else: - indent = self.initial_indent - - # Maximum width for this line. - width = self.width - self._len(indent) - - # First chunk on line is whitespace -- drop it, unless this - # is the very beginning of the text (ie. no lines started yet). - if self.drop_whitespace and chunks[-1].strip() == "" and lines: - del chunks[-1] - - while chunks: - chunk_len = self._len(chunks[-1]) - - # Can at least squeeze this chunk onto the current line. - if cur_len + chunk_len <= width: - cur_line.append(chunks.pop()) - cur_len += chunk_len - - # Nope, this line is full. - else: - break - - # The current line is full, and the next chunk is too big to - # fit on *any* line (not just this one). - if chunks and self._len(chunks[-1]) > width: - self._handle_long_word(chunks, cur_line, cur_len, width) - cur_len = sum(map(self._len, cur_line)) - - # If the last chunk on this line is all whitespace, drop it. - if self.drop_whitespace and cur_line and cur_line[-1].strip() == "": - cur_len -= self._len(cur_line[-1]) - del cur_line[-1] - - if cur_line: - if ( - self.max_lines is None - or len(lines) + 1 < self.max_lines - or ( - not chunks - or self.drop_whitespace - and len(chunks) == 1 - and not chunks[0].strip() - ) - and cur_len <= width - ): - # Convert current line back to a string and store it in - # list of all lines (return value). - self._update_lines(lines, indent + "".join(cur_line)) - else: - while cur_line: - if ( - cur_line[-1].strip() - and cur_len + self._len(self.placeholder) <= width - ): - cur_line.append(self.placeholder) - self._update_lines(lines, indent + "".join(cur_line)) - break - cur_len -= self._len(cur_line[-1]) - del cur_line[-1] - else: - if lines: - prev_line = lines[-1].rstrip() - if ( - self._len(prev_line) + self._len(self.placeholder) - <= self.width - ): - lines[-1] = prev_line + self.placeholder - break - self._update_lines(lines, indent + self.placeholder.lstrip()) - break - - return lines - - -def _main(): - """\ - Usage: tabulate [options] [FILE ...] - - Pretty-print tabular data. - See also https://github.com/astanin/python-tabulate - - FILE a filename of the file with tabular data; - if "-" or missing, read data from stdin. - - Options: - - -h, --help show this message - -1, --header use the first row of data as a table header - -o FILE, --output FILE print table to FILE (default: stdout) - -s REGEXP, --sep REGEXP use a custom column separator (default: whitespace) - -F FPFMT, --float FPFMT floating point number format (default: g) - -I INTFMT, --int INTFMT integer point number format (default: "") - -f FMT, --format FMT set output table format; supported formats: - plain, simple, grid, fancy_grid, pipe, orgtbl, - rst, mediawiki, html, latex, latex_raw, - latex_booktabs, latex_longtable, tsv - (default: simple) - """ - import getopt - import sys - import textwrap - - usage = textwrap.dedent(_main.__doc__) - try: - opts, args = getopt.getopt( - sys.argv[1:], - "h1o:s:F:A:f:", - ["help", "header", "output", "sep=", "float=", "int=", "align=", "format="], - ) - except getopt.GetoptError as e: - print(e) - print(usage) - sys.exit(2) - headers = [] - floatfmt = _DEFAULT_FLOATFMT - intfmt = _DEFAULT_INTFMT - colalign = None - tablefmt = "simple" - sep = r"\s+" - outfile = "-" - for opt, value in opts: - if opt in ["-1", "--header"]: - headers = "firstrow" - elif opt in ["-o", "--output"]: - outfile = value - elif opt in ["-F", "--float"]: - floatfmt = value - elif opt in ["-I", "--int"]: - intfmt = value - elif opt in ["-C", "--colalign"]: - colalign = value.split() - elif opt in ["-f", "--format"]: - if value not in tabulate_formats: - print("%s is not a supported table format" % value) - print(usage) - sys.exit(3) - tablefmt = value - elif opt in ["-s", "--sep"]: - sep = value - elif opt in ["-h", "--help"]: - print(usage) - sys.exit(0) - files = [sys.stdin] if not args else args - with (sys.stdout if outfile == "-" else open(outfile, "w")) as out: - for f in files: - if f == "-": - f = sys.stdin - if _is_file(f): - _pprint_file( - f, - headers=headers, - tablefmt=tablefmt, - sep=sep, - floatfmt=floatfmt, - intfmt=intfmt, - file=out, - colalign=colalign, - ) - else: - with open(f) as fobj: - _pprint_file( - fobj, - headers=headers, - tablefmt=tablefmt, - sep=sep, - floatfmt=floatfmt, - intfmt=intfmt, - file=out, - colalign=colalign, - ) - - -def _pprint_file(fobject, headers, tablefmt, sep, floatfmt, intfmt, file, colalign): - rows = fobject.readlines() - table = [re.split(sep, r.rstrip()) for r in rows if r.strip()] - print( - tabulate( - table, - headers, - tablefmt, - floatfmt=floatfmt, - intfmt=intfmt, - colalign=colalign, - ), - file=file, - ) - - -if __name__ == "__main__": - _main() +"""Pretty-print tabular data.""" + +from collections import namedtuple +from collections.abc import Iterable, Sized +from html import escape as htmlescape +from itertools import chain, zip_longest as izip_longest +from functools import reduce, partial +import io +import re +import math +import textwrap +import dataclasses + +try: + import wcwidth # optional wide-character (CJK) support +except ImportError: + wcwidth = None + + +def _is_file(f): + return isinstance(f, io.IOBase) + + +__all__ = ["tabulate", "tabulate_formats", "simple_separated_format"] +try: + from .version import version as __version__ # noqa: F401 +except ImportError: + pass # running __init__.py as a script, AppVeyor pytests + + +# minimum extra space in headers +MIN_PADDING = 2 + +# Whether or not to preserve leading/trailing whitespace in data. +PRESERVE_WHITESPACE = False + +_DEFAULT_FLOATFMT = "g" +_DEFAULT_INTFMT = "" +_DEFAULT_MISSINGVAL = "" +# default align will be overwritten by "left", "center" or "decimal" +# depending on the formatter +_DEFAULT_ALIGN = "default" + + +# if True, enable wide-character (CJK) support +WIDE_CHARS_MODE = wcwidth is not None + +# 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. +# +# with_header_hide: +# +# - 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", + "with_header_hide", + ], +) + + +def _is_separating_line(row): + row_type = type(row) + is_sl = (row_type == list or row_type == str) and ( + (len(row) >= 1 and row[0] == SEPARATING_LINE) + or (len(row) >= 2 and row[1] == SEPARATING_LINE) + ) + return is_sl + + +def _pipe_segment_with_colons(align, colwidth): + """Return a segment of a horizontal line with optional colons which + indicate column's alignment (as in `pipe` output format).""" + w = colwidth + if align in ["right", "decimal"]: + return ("-" * (w - 1)) + ":" + elif align == "center": + return ":" + ("-" * (w - 2)) + ":" + elif align == "left": + return ":" + ("-" * (w - 1)) + else: + return "-" * w + + +def _pipe_line_with_colons(colwidths, colaligns): + """Return a horizontal line with optional colons to indicate column's + alignment (as in `pipe` output format).""" + if not colaligns: # e.g. printing an empty data frame (github issue #15) + colaligns = [""] * len(colwidths) + segments = [_pipe_segment_with_colons(a, w) for a, w in zip(colaligns, colwidths)] + return "|" + "|".join(segments) + "|" + + +def _mediawiki_row_with_attrs(separator, cell_values, colwidths, colaligns): + alignment = { + "left": "", + "right": 'style="text-align: right;"| ', + "center": 'style="text-align: center;"| ', + "decimal": 'style="text-align: right;"| ', + } + # hard-coded padding _around_ align attribute and value together + # rather than padding parameter which affects only the value + values_with_attrs = [ + " " + alignment.get(a, "") + c + " " for c, a in zip(cell_values, colaligns) + ] + colsep = separator * 2 + return (separator + colsep.join(values_with_attrs)).rstrip() + + +def _textile_row_with_attrs(cell_values, colwidths, colaligns): + cell_values[0] += " " + alignment = {"left": "<.", "right": ">.", "center": "=.", "decimal": ">."} + values = (alignment.get(a, "") + v for a, v in zip(colaligns, cell_values)) + return "|" + "|".join(values) + "|" + + +def _html_begin_table_without_header(colwidths_ignore, colaligns_ignore): + # this table header will be suppressed if there is a header row + return "\n" + + +def _html_row_with_attrs(celltag, unsafe, cell_values, colwidths, colaligns): + alignment = { + "left": "", + "right": ' style="text-align: right;"', + "center": ' style="text-align: center;"', + "decimal": ' style="text-align: right;"', + } + if unsafe: + values_with_attrs = [ + "<{0}{1}>{2}".format(celltag, alignment.get(a, ""), c) + for c, a in zip(cell_values, colaligns) + ] + else: + values_with_attrs = [ + "<{0}{1}>{2}".format(celltag, alignment.get(a, ""), htmlescape(c)) + for c, a in zip(cell_values, colaligns) + ] + rowhtml = "{}".format("".join(values_with_attrs).rstrip()) + if celltag == "th": # it's a header row, create a new table header + rowhtml = f"
\n\n{rowhtml}\n\n" + return rowhtml + + +def _moin_row_with_attrs(celltag, cell_values, colwidths, colaligns, header=""): + alignment = { + "left": "", + "right": '', + "center": '', + "decimal": '', + } + values_with_attrs = [ + "{}{} {} ".format(celltag, alignment.get(a, ""), header + c + header) + for c, a in zip(cell_values, colaligns) + ] + return "".join(values_with_attrs) + "||" + + +def _latex_line_begin_tabular(colwidths, colaligns, booktabs=False, longtable=False): + alignment = {"left": "l", "right": "r", "center": "c", "decimal": "r"} + tabular_columns_fmt = "".join([alignment.get(a, "l") for a in colaligns]) + return "\n".join( + [ + ("\\begin{tabular}{" if not longtable else "\\begin{longtable}{") + + tabular_columns_fmt + + "}", + "\\toprule" if booktabs else "\\hline", + ] + ) + + +def _asciidoc_row(is_header, *args): + """handle header and data rows for asciidoc format""" + + def make_header_line(is_header, colwidths, colaligns): + # generate the column specifiers + + alignment = {"left": "<", "right": ">", "center": "^", "decimal": ">"} + # use the column widths generated by tabulate for the asciidoc column width specifiers + asciidoc_alignments = zip( + colwidths, [alignment[colalign] for colalign in colaligns] + ) + asciidoc_column_specifiers = [ + "{:d}{}".format(width, align) for width, align in asciidoc_alignments + ] + header_list = ['cols="' + (",".join(asciidoc_column_specifiers)) + '"'] + + # generate the list of options (currently only "header") + options_list = [] + + if is_header: + options_list.append("header") + + if options_list: + header_list += ['options="' + ",".join(options_list) + '"'] + + # generate the list of entries in the table header field + + return "[{}]\n|====".format(",".join(header_list)) + + if len(args) == 2: + # two arguments are passed if called in the context of aboveline + # print the table header with column widths and optional header tag + return make_header_line(False, *args) + + elif len(args) == 3: + # three arguments are passed if called in the context of dataline or headerline + # print the table line and make the aboveline if it is a header + + cell_values, colwidths, colaligns = args + data_line = "|" + "|".join(cell_values) + + if is_header: + return make_header_line(True, colwidths, colaligns) + "\n" + data_line + else: + return data_line + + else: + raise ValueError( + " _asciidoc_row() requires two (colwidths, colaligns) " + + "or three (cell_values, colwidths, colaligns) arguments) " + ) + + +LATEX_ESCAPE_RULES = { + r"&": r"\&", + r"%": r"\%", + r"$": r"\$", + r"#": r"\#", + r"_": r"\_", + r"^": r"\^{}", + r"{": r"\{", + r"}": r"\}", + r"~": r"\textasciitilde{}", + "\\": r"\textbackslash{}", + r"<": r"\ensuremath{<}", + r">": r"\ensuremath{>}", +} + + +def _latex_row(cell_values, colwidths, colaligns, escrules=LATEX_ESCAPE_RULES): + def escape_char(c): + return escrules.get(c, c) + + escaped_values = ["".join(map(escape_char, cell)) for cell in cell_values] + rowfmt = DataRow("", "&", "\\\\") + return _build_simple_row(escaped_values, rowfmt) + + +def _rst_escape_first_column(rows, headers): + def escape_empty(val): + if isinstance(val, (str, bytes)) and not val.strip(): + return ".." + else: + return val + + new_headers = list(headers) + new_rows = [] + if headers: + new_headers[0] = escape_empty(headers[0]) + for row in rows: + new_row = list(row) + if new_row: + new_row[0] = escape_empty(row[0]) + new_rows.append(new_row) + return new_rows, new_headers + + +_table_formats = { + "simple": TableFormat( + lineabove=Line("", "-", " ", ""), + linebelowheader=Line("", "-", " ", ""), + linebetweenrows=None, + linebelow=Line("", "-", " ", ""), + headerrow=DataRow("", " ", ""), + datarow=DataRow("", " ", ""), + padding=0, + with_header_hide=["lineabove", "linebelow"], + ), + "plain": TableFormat( + lineabove=None, + linebelowheader=None, + linebetweenrows=None, + linebelow=None, + headerrow=DataRow("", " ", ""), + datarow=DataRow("", " ", ""), + padding=0, + with_header_hide=None, + ), + "grid": TableFormat( + lineabove=Line("+", "-", "+", "+"), + linebelowheader=Line("+", "=", "+", "+"), + linebetweenrows=Line("+", "-", "+", "+"), + linebelow=Line("+", "-", "+", "+"), + headerrow=DataRow("|", "|", "|"), + datarow=DataRow("|", "|", "|"), + padding=1, + with_header_hide=None, + ), + "simple_grid": TableFormat( + lineabove=Line("┌", "─", "┬", "┐"), + linebelowheader=Line("├", "─", "┼", "┤"), + linebetweenrows=Line("├", "─", "┼", "┤"), + linebelow=Line("└", "─", "┴", "┘"), + headerrow=DataRow("│", "│", "│"), + datarow=DataRow("│", "│", "│"), + padding=1, + with_header_hide=None, + ), + "rounded_grid": TableFormat( + lineabove=Line("╭", "─", "┬", "╮"), + linebelowheader=Line("├", "─", "┼", "┤"), + linebetweenrows=Line("├", "─", "┼", "┤"), + linebelow=Line("╰", "─", "┴", "╯"), + headerrow=DataRow("│", "│", "│"), + datarow=DataRow("│", "│", "│"), + padding=1, + with_header_hide=None, + ), + "heavy_grid": TableFormat( + lineabove=Line("┏", "━", "┳", "┓"), + linebelowheader=Line("┣", "━", "╋", "┫"), + linebetweenrows=Line("┣", "━", "╋", "┫"), + linebelow=Line("┗", "━", "┻", "┛"), + headerrow=DataRow("┃", "┃", "┃"), + datarow=DataRow("┃", "┃", "┃"), + padding=1, + with_header_hide=None, + ), + "mixed_grid": TableFormat( + lineabove=Line("┍", "━", "┯", "┑"), + linebelowheader=Line("┝", "━", "┿", "┥"), + linebetweenrows=Line("├", "─", "┼", "┤"), + linebelow=Line("┕", "━", "┷", "┙"), + headerrow=DataRow("│", "│", "│"), + datarow=DataRow("│", "│", "│"), + padding=1, + with_header_hide=None, + ), + "double_grid": TableFormat( + lineabove=Line("╔", "═", "╦", "╗"), + linebelowheader=Line("╠", "═", "╬", "╣"), + linebetweenrows=Line("╠", "═", "╬", "╣"), + linebelow=Line("╚", "═", "╩", "╝"), + headerrow=DataRow("║", "║", "║"), + datarow=DataRow("║", "║", "║"), + padding=1, + with_header_hide=None, + ), + "fancy_grid": TableFormat( + lineabove=Line("╒", "═", "╤", "╕"), + linebelowheader=Line("╞", "═", "╪", "╡"), + linebetweenrows=Line("├", "─", "┼", "┤"), + linebelow=Line("╘", "═", "╧", "╛"), + headerrow=DataRow("│", "│", "│"), + datarow=DataRow("│", "│", "│"), + padding=1, + with_header_hide=None, + ), + "outline": TableFormat( + lineabove=Line("+", "-", "+", "+"), + linebelowheader=Line("+", "=", "+", "+"), + linebetweenrows=None, + linebelow=Line("+", "-", "+", "+"), + headerrow=DataRow("|", "|", "|"), + datarow=DataRow("|", "|", "|"), + padding=1, + with_header_hide=None, + ), + "simple_outline": TableFormat( + lineabove=Line("┌", "─", "┬", "┐"), + linebelowheader=Line("├", "─", "┼", "┤"), + linebetweenrows=None, + linebelow=Line("└", "─", "┴", "┘"), + headerrow=DataRow("│", "│", "│"), + datarow=DataRow("│", "│", "│"), + padding=1, + with_header_hide=None, + ), + "rounded_outline": TableFormat( + lineabove=Line("╭", "─", "┬", "╮"), + linebelowheader=Line("├", "─", "┼", "┤"), + linebetweenrows=None, + linebelow=Line("╰", "─", "┴", "╯"), + headerrow=DataRow("│", "│", "│"), + datarow=DataRow("│", "│", "│"), + padding=1, + with_header_hide=None, + ), + "heavy_outline": TableFormat( + lineabove=Line("┏", "━", "┳", "┓"), + linebelowheader=Line("┣", "━", "╋", "┫"), + linebetweenrows=None, + linebelow=Line("┗", "━", "┻", "┛"), + headerrow=DataRow("┃", "┃", "┃"), + datarow=DataRow("┃", "┃", "┃"), + padding=1, + with_header_hide=None, + ), + "mixed_outline": TableFormat( + lineabove=Line("┍", "━", "┯", "┑"), + linebelowheader=Line("┝", "━", "┿", "┥"), + linebetweenrows=None, + linebelow=Line("┕", "━", "┷", "┙"), + headerrow=DataRow("│", "│", "│"), + datarow=DataRow("│", "│", "│"), + padding=1, + with_header_hide=None, + ), + "double_outline": TableFormat( + lineabove=Line("╔", "═", "╦", "╗"), + linebelowheader=Line("╠", "═", "╬", "╣"), + linebetweenrows=None, + linebelow=Line("╚", "═", "╩", "╝"), + headerrow=DataRow("║", "║", "║"), + datarow=DataRow("║", "║", "║"), + padding=1, + with_header_hide=None, + ), + "fancy_outline": TableFormat( + lineabove=Line("╒", "═", "╤", "╕"), + linebelowheader=Line("╞", "═", "╪", "╡"), + linebetweenrows=None, + linebelow=Line("╘", "═", "╧", "╛"), + headerrow=DataRow("│", "│", "│"), + datarow=DataRow("│", "│", "│"), + padding=1, + with_header_hide=None, + ), + "github": TableFormat( + lineabove=Line("|", "-", "|", "|"), + linebelowheader=Line("|", "-", "|", "|"), + linebetweenrows=None, + linebelow=None, + headerrow=DataRow("|", "|", "|"), + datarow=DataRow("|", "|", "|"), + padding=1, + with_header_hide=["lineabove"], + ), + "pipe": TableFormat( + lineabove=_pipe_line_with_colons, + linebelowheader=_pipe_line_with_colons, + linebetweenrows=None, + linebelow=None, + headerrow=DataRow("|", "|", "|"), + datarow=DataRow("|", "|", "|"), + padding=1, + with_header_hide=["lineabove"], + ), + "orgtbl": TableFormat( + lineabove=None, + linebelowheader=Line("|", "-", "+", "|"), + linebetweenrows=None, + linebelow=None, + headerrow=DataRow("|", "|", "|"), + datarow=DataRow("|", "|", "|"), + padding=1, + with_header_hide=None, + ), + "jira": TableFormat( + lineabove=None, + linebelowheader=None, + linebetweenrows=None, + linebelow=None, + headerrow=DataRow("||", "||", "||"), + datarow=DataRow("|", "|", "|"), + padding=1, + with_header_hide=None, + ), + "presto": TableFormat( + lineabove=None, + linebelowheader=Line("", "-", "+", ""), + linebetweenrows=None, + linebelow=None, + headerrow=DataRow("", "|", ""), + datarow=DataRow("", "|", ""), + padding=1, + with_header_hide=None, + ), + "pretty": TableFormat( + lineabove=Line("+", "-", "+", "+"), + linebelowheader=Line("+", "-", "+", "+"), + linebetweenrows=None, + linebelow=Line("+", "-", "+", "+"), + headerrow=DataRow("|", "|", "|"), + datarow=DataRow("|", "|", "|"), + padding=1, + with_header_hide=None, + ), + "psql": TableFormat( + lineabove=Line("+", "-", "+", "+"), + linebelowheader=Line("|", "-", "+", "|"), + linebetweenrows=None, + linebelow=Line("+", "-", "+", "+"), + headerrow=DataRow("|", "|", "|"), + datarow=DataRow("|", "|", "|"), + padding=1, + with_header_hide=None, + ), + "rst": TableFormat( + lineabove=Line("", "=", " ", ""), + linebelowheader=Line("", "=", " ", ""), + linebetweenrows=None, + linebelow=Line("", "=", " ", ""), + headerrow=DataRow("", " ", ""), + datarow=DataRow("", " ", ""), + padding=0, + with_header_hide=None, + ), + "mediawiki": TableFormat( + lineabove=Line( + '{| class="wikitable" style="text-align: left;"', + "", + "", + "\n|+ \n|-", + ), + linebelowheader=Line("|-", "", "", ""), + linebetweenrows=Line("|-", "", "", ""), + linebelow=Line("|}", "", "", ""), + headerrow=partial(_mediawiki_row_with_attrs, "!"), + datarow=partial(_mediawiki_row_with_attrs, "|"), + padding=0, + with_header_hide=None, + ), + "moinmoin": TableFormat( + lineabove=None, + linebelowheader=None, + linebetweenrows=None, + linebelow=None, + headerrow=partial(_moin_row_with_attrs, "||", header="'''"), + datarow=partial(_moin_row_with_attrs, "||"), + padding=1, + with_header_hide=None, + ), + "youtrack": TableFormat( + lineabove=None, + linebelowheader=None, + linebetweenrows=None, + linebelow=None, + headerrow=DataRow("|| ", " || ", " || "), + datarow=DataRow("| ", " | ", " |"), + padding=1, + with_header_hide=None, + ), + "html": TableFormat( + lineabove=_html_begin_table_without_header, + linebelowheader="", + linebetweenrows=None, + linebelow=Line("\n
", "", "", ""), + headerrow=partial(_html_row_with_attrs, "th", False), + datarow=partial(_html_row_with_attrs, "td", False), + padding=0, + with_header_hide=["lineabove"], + ), + "unsafehtml": TableFormat( + lineabove=_html_begin_table_without_header, + linebelowheader="", + linebetweenrows=None, + linebelow=Line("\n", "", "", ""), + headerrow=partial(_html_row_with_attrs, "th", True), + datarow=partial(_html_row_with_attrs, "td", True), + padding=0, + with_header_hide=["lineabove"], + ), + "latex": TableFormat( + lineabove=_latex_line_begin_tabular, + linebelowheader=Line("\\hline", "", "", ""), + linebetweenrows=None, + linebelow=Line("\\hline\n\\end{tabular}", "", "", ""), + headerrow=_latex_row, + datarow=_latex_row, + padding=1, + with_header_hide=None, + ), + "latex_raw": TableFormat( + lineabove=_latex_line_begin_tabular, + linebelowheader=Line("\\hline", "", "", ""), + linebetweenrows=None, + linebelow=Line("\\hline\n\\end{tabular}", "", "", ""), + headerrow=partial(_latex_row, escrules={}), + datarow=partial(_latex_row, escrules={}), + padding=1, + with_header_hide=None, + ), + "latex_booktabs": TableFormat( + lineabove=partial(_latex_line_begin_tabular, booktabs=True), + linebelowheader=Line("\\midrule", "", "", ""), + linebetweenrows=None, + linebelow=Line("\\bottomrule\n\\end{tabular}", "", "", ""), + headerrow=_latex_row, + datarow=_latex_row, + padding=1, + with_header_hide=None, + ), + "latex_longtable": TableFormat( + lineabove=partial(_latex_line_begin_tabular, longtable=True), + linebelowheader=Line("\\hline\n\\endhead", "", "", ""), + linebetweenrows=None, + linebelow=Line("\\hline\n\\end{longtable}", "", "", ""), + headerrow=_latex_row, + datarow=_latex_row, + padding=1, + with_header_hide=None, + ), + "tsv": TableFormat( + lineabove=None, + linebelowheader=None, + linebetweenrows=None, + linebelow=None, + headerrow=DataRow("", "\t", ""), + datarow=DataRow("", "\t", ""), + padding=0, + with_header_hide=None, + ), + "textile": TableFormat( + lineabove=None, + linebelowheader=None, + linebetweenrows=None, + linebelow=None, + headerrow=DataRow("|_. ", "|_.", "|"), + datarow=_textile_row_with_attrs, + padding=1, + with_header_hide=None, + ), + "asciidoc": TableFormat( + lineabove=partial(_asciidoc_row, False), + linebelowheader=None, + linebetweenrows=None, + linebelow=Line("|====", "", "", ""), + headerrow=partial(_asciidoc_row, True), + datarow=partial(_asciidoc_row, False), + padding=1, + with_header_hide=["lineabove"], + ), +} + + +tabulate_formats = list(sorted(_table_formats.keys())) + +# The table formats for which multiline cells will be folded into subsequent +# table rows. The key is the original format specified at the API. The value is +# the format that will be used to represent the original format. +multiline_formats = { + "plain": "plain", + "simple": "simple", + "grid": "grid", + "simple_grid": "simple_grid", + "rounded_grid": "rounded_grid", + "heavy_grid": "heavy_grid", + "mixed_grid": "mixed_grid", + "double_grid": "double_grid", + "fancy_grid": "fancy_grid", + "pipe": "pipe", + "orgtbl": "orgtbl", + "jira": "jira", + "presto": "presto", + "pretty": "pretty", + "psql": "psql", + "rst": "rst", + "outline": "outline", + "simple_outline": "simple_outline", + "rounded_outline": "rounded_outline", + "heavy_outline": "heavy_outline", + "mixed_outline": "mixed_outline", + "double_outline": "double_outline", + "fancy_outline": "fancy_outline", +} + +# TODO: Add multiline support for the remaining table formats: +# - mediawiki: Replace \n with
+# - moinmoin: TBD +# - youtrack: TBD +# - html: Replace \n with
+# - latex*: Use "makecell" package: In header, replace X\nY with +# \thead{X\\Y} and in data row, replace X\nY with \makecell{X\\Y} +# - tsv: TBD +# - textile: Replace \n with
(must be well-formed XML) + +_multiline_codes = re.compile(r"\r|\n|\r\n") +_multiline_codes_bytes = 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}\\" + +_ansi_escape_pat = 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 + ) +""" +_ansi_codes = re.compile(_ansi_escape_pat, re.VERBOSE) +_ansi_codes_bytes = re.compile(_ansi_escape_pat.encode("utf8"), re.VERBOSE) +_ansi_color_reset_code = "\033[0m" + +_float_with_thousands_separators = re.compile( + r"^(([+-]?[0-9]{1,3})(?:,([0-9]{3}))*)?(?(1)\.[0-9]*|\.[0-9]+)?$" +) + + +def simple_separated_format(separator): + """Construct a simple TableFormat with columns separated by a separator. + + >>> tsv = simple_separated_format("\\t") ; \ + tabulate([["foo", 1], ["spam", 23]], tablefmt=tsv) == 'foo \\t 1\\nspam\\t23' + True + + """ + return TableFormat( + None, + None, + None, + None, + headerrow=DataRow("", separator, ""), + datarow=DataRow("", separator, ""), + padding=0, + with_header_hide=None, + ) + + +def _isnumber_with_thousands_separator(string): + """ + >>> _isnumber_with_thousands_separator(".") + False + >>> _isnumber_with_thousands_separator("1") + True + >>> _isnumber_with_thousands_separator("1.") + True + >>> _isnumber_with_thousands_separator(".1") + True + >>> _isnumber_with_thousands_separator("1000") + False + >>> _isnumber_with_thousands_separator("1,000") + True + >>> _isnumber_with_thousands_separator("1,0000") + False + >>> _isnumber_with_thousands_separator("1,000.1234") + True + >>> _isnumber_with_thousands_separator(b"1,000.1234") + True + >>> _isnumber_with_thousands_separator("+1,000.1234") + True + >>> _isnumber_with_thousands_separator("-1,000.1234") + True + """ + try: + string = string.decode() + except (UnicodeDecodeError, AttributeError): + pass + + return bool(re.match(_float_with_thousands_separators, string)) + + +def _isconvertible(conv, string): + try: + conv(string) + return True + except (ValueError, TypeError): + return False + + +def _isnumber(string): + """ + >>> _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): + """ + >>> _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(">> _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, has_invisible=True, numparse=True): + """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 has_invisible and isinstance(string, (str, bytes)): + string = _strip_ansi(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 _isnumber_with_thousands_separator(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: + return -1 # no point + else: + return -1 # not a number + + +def _padleft(width, s): + """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): + """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): + """Center string. + + >>> _padboth(6, '\u044f\u0439\u0446\u0430') == ' \u044f\u0439\u0446\u0430 ' + True + + """ + fmt = "{0:^%ds}" % width + return fmt.format(s) + + +def _padnone(ignore_width, s): + return s + + +def _strip_ansi(s): + r"""Remove ANSI escape sequences, both CSI (color codes, etc) 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(_strip_ansi('\x1B]8;;https://example.com\x1B\\This is a link\x1B]8;;\x1B\\')) + "'This is a link'" + + >>> repr(_strip_ansi('\x1b[31mred\x1b[0m text')) + "'red text'" + + """ + if isinstance(s, str): + return _ansi_codes.sub(r"\4", s) + else: # a bytestring + return _ansi_codes_bytes.sub(r"\4", s) + + +def _visible_width(s): + """Visible width of a printed string. ANSI color codes are removed. + + >>> _visible_width('\x1b[31mhello\x1b[0m'), _visible_width("world") + (5, 5) + + """ + # optional wide-character support + if wcwidth is not None and WIDE_CHARS_MODE: + len_fn = wcwidth.wcswidth + else: + len_fn = len + if isinstance(s, (str, bytes)): + return len_fn(_strip_ansi(s)) + else: + return len_fn(str(s)) + + +def _is_multiline(s): + if isinstance(s, str): + return bool(re.search(_multiline_codes, s)) + else: # a bytestring + return bool(re.search(_multiline_codes_bytes, s)) + + +def _multiline_width(multiline_s, line_width_fn=len): + """Visible width of a potentially multiline content.""" + return max(map(line_width_fn, re.split("[\r\n]", multiline_s))) + + +def _choose_width_fn(has_invisible, enable_widechars, is_multiline): + """Return a function to calculate visible cell width.""" + if has_invisible: + line_width_fn = _visible_width + elif enable_widechars: # optional wide-character support if available + line_width_fn = wcwidth.wcswidth + else: + line_width_fn = len + if is_multiline: + width_fn = lambda s: _multiline_width(s, line_width_fn) # noqa + else: + width_fn = line_width_fn + return width_fn + + +def _align_column_choose_padfn(strings, alignment, has_invisible): + 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 has_invisible: + decimals = [_afterpoint(_strip_ansi(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 _align_column_choose_width_fn(has_invisible, enable_widechars, is_multiline): + if has_invisible: + line_width_fn = _visible_width + elif enable_widechars: # optional wide-character support if available + line_width_fn = wcwidth.wcswidth + else: + line_width_fn = len + if is_multiline: + width_fn = lambda s: _align_column_multiline_width(s, line_width_fn) # noqa + else: + width_fn = line_width_fn + return width_fn + + +def _align_column_multiline_width(multiline_s, line_width_fn=len): + """Visible width of a potentially multiline content.""" + return list(map(line_width_fn, re.split("[\r\n]", multiline_s))) + + +def _flat_list(nested_list): + ret = [] + for item in nested_list: + if isinstance(item, list): + for subitem in item: + ret.append(subitem) + else: + ret.append(item) + return ret + + +def _align_column( + strings, + alignment, + minwidth=0, + has_invisible=True, + enable_widechars=False, + is_multiline=False, +): + """[string] -> [padded_string]""" + strings, padfn = _align_column_choose_padfn(strings, alignment, has_invisible) + width_fn = _align_column_choose_width_fn( + has_invisible, enable_widechars, is_multiline + ) + + s_widths = list(map(width_fn, strings)) + maxwidth = max(max(_flat_list(s_widths)), minwidth) + # TODO: refactor column alignment in single-line and multiline modes + if is_multiline: + if not enable_widechars and not has_invisible: + padded_strings = [ + "\n".join([padfn(maxwidth, s) for s in ms.splitlines()]) + for ms in strings + ] + else: + # enable wide-character width corrections + s_lens = [[len(s) for s in re.split("[\r\n]", ms)] for ms in strings] + visible_widths = [ + [maxwidth - (w - l) for w, l in zip(mw, ml)] + for mw, ml in zip(s_widths, s_lens) + ] + # wcswidth and _visible_width don't count invisible characters; + # padfn doesn't need to apply another correction + padded_strings = [ + "\n".join([padfn(w, s) for s, w in zip((ms.splitlines() or ms), mw)]) + for ms, mw in zip(strings, visible_widths) + ] + else: # single-line cell values + if not enable_widechars and not has_invisible: + padded_strings = [padfn(maxwidth, s) for s in strings] + else: + # enable wide-character width corrections + s_lens = list(map(len, strings)) + visible_widths = [maxwidth - (w - l) for w, l in zip(s_widths, s_lens)] + # wcswidth and _visible_width don't count invisible characters; + # padfn doesn't need to apply another correction + padded_strings = [padfn(w, s) for s, w in zip(strings, visible_widths)] + return padded_strings + + +def _more_generic(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 _column_type(strings, has_invisible=True, numparse=True): + """The least generic type all column values are convertible to. + + >>> _column_type([True, False]) is bool + True + >>> _column_type(["1", "2"]) is int + True + >>> _column_type(["1", "2.3"]) is float + True + >>> _column_type(["1", "2.3", "four"]) is str + True + >>> _column_type(["four", '\u043f\u044f\u0442\u044c']) is str + True + >>> _column_type([None, "brux"]) is str + True + >>> _column_type([1, 2, None]) is int + True + >>> import datetime as dt + >>> _column_type([dt.datetime(1991,2,19), dt.time(17,35)]) is str + True + + """ + types = [_type(s, has_invisible, numparse) for s in strings] + return reduce(_more_generic, types, bool) + + +def _format(val, valtype, floatfmt, intfmt, missingval="", has_invisible=True): + """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: + is_a_colored_number = has_invisible and isinstance(val, (str, bytes)) + if is_a_colored_number: + raw_val = _strip_ansi(val) + formatted_val = format(float(raw_val), floatfmt) + return val.replace(raw_val, formatted_val) + else: + return format(float(val), floatfmt) + else: + return f"{val}" + + +def _align_header( + header, alignment, width, visible_width, is_multiline=False, width_fn=None +): + "Pad string header to width chars given known visible_width of the header." + if is_multiline: + header_lines = re.split(_multiline_codes, header) + padded_lines = [ + _align_header(h, alignment, width, width_fn(h)) for h in header_lines + ] + return "\n".join(padded_lines) + # else: not multiline + ninvisible = len(header) - visible_width + 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 _remove_separating_lines(rows): + if type(rows) == list: + separating_lines = [] + sans_rows = [] + for index, row in enumerate(rows): + if _is_separating_line(row): + separating_lines.append(index) + else: + sans_rows.append(row) + return sans_rows, separating_lines + else: + return rows, None + + +def _reinsert_separating_lines(rows, separating_lines): + if separating_lines: + for index in separating_lines: + rows.insert(index, SEPARATING_LINE) + + +def _prepend_row_index(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)) + ) + sans_rows, separating_lines = _remove_separating_lines(rows) + new_rows = [] + index_iter = iter(index) + for row in sans_rows: + index_v = next(index_iter) + new_rows.append([index_v] + list(row)) + rows = new_rows + _reinsert_separating_lines(rows, separating_lines) + 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 _normalize_tabular_data(tabular_data, headers, showindex="default"): + """Transform a supported data type to a list of lists, and a list of headers, with headers 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") + + * pandas.DataFrame (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="default", show row indices of the pandas.DataFrame. + 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) + is_headers2bool_broken = False # noqa + except ValueError: # numpy.ndarray, pandas.core.index.Index, ... + is_headers2bool_broken = True # noqa + headers = list(headers) + + index = None + if hasattr(tabular_data, "keys") and hasattr(tabular_data, "values"): + # dict-like and pandas.DataFrame? + if hasattr(tabular_data.values, "__call__"): + # likely a conventional dict + keys = tabular_data.keys() + rows = list( + izip_longest(*tabular_data.values()) + ) # columns have to be transposed + elif hasattr(tabular_data, "index"): + # values is a property, has .index => it's likely a pandas.DataFrame (pandas 0.11.0) + keys = list(tabular_data) + if ( + showindex in ["default", "always", True] + and tabular_data.index.name is not None + ): + if isinstance(tabular_data.index.name, list): + keys[:0] = tabular_data.index.name + else: + keys[:0] = [tabular_data.index.name] + vals = tabular_data.values # values matrix doesn't need to be transposed + # for DataFrames add an index per default + index = list(tabular_data.index) + rows = [list(row) for row in vals] + else: + raise ValueError("tabular data doesn't appear to be a dict or a DataFrame") + + if headers == "keys": + headers = list(map(str, keys)) # headers should be strings + + else: # it's a usual iterable of iterables, or a NumPy array, or an iterable of dataclasses + rows = list(tabular_data) + + if headers == "keys" and not rows: + # an empty table (issue #81) + headers = [] + elif ( + headers == "keys" + and hasattr(tabular_data, "dtype") + and getattr(tabular_data.dtype, "names") + ): + # numpy record array + headers = tabular_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 + uniq_keys = set() # implements hashed lookup + keys = [] # storage for set + if headers == "firstrow": + firstdict = rows[0] if len(rows) > 0 else {} + keys.extend(firstdict.keys()) + uniq_keys.update(keys) + rows = rows[1:] + for row in rows: + for k in row.keys(): + # Save unique items in input order + if k not in uniq_keys: + keys.append(k) + uniq_keys.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 ( + headers == "keys" + and hasattr(tabular_data, "description") + and hasattr(tabular_data, "fetchone") + and hasattr(tabular_data, "rowcount") + ): + # Python Database API cursor object (PEP 0249) + # print tabulate(cursor, headers='keys') + headers = [column[0] for column in tabular_data.description] + + elif ( + dataclasses is not None + and len(rows) > 0 + and dataclasses.is_dataclass(rows[0]) + ): + # Python 3.7+'s dataclass + field_names = [field.name for field in dataclasses.fields(rows[0])] + if headers == "keys": + headers = field_names + rows = [[getattr(row, f) for f in field_names] 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(list, rows)) + rows = list(map(lambda r: r if _is_separating_line(r) else list(r), rows)) + + # add or remove an index column + showindex_is_a_str = type(showindex) in [str, bytes] + if showindex == "default" and index is not None: + rows = _prepend_row_index(rows, index) + elif isinstance(showindex, Sized) and not showindex_is_a_str: + rows = _prepend_row_index(rows, list(showindex)) + elif isinstance(showindex, Iterable) and not showindex_is_a_str: + rows = _prepend_row_index(rows, showindex) + elif showindex == "always" or (_bool(showindex) and not showindex_is_a_str): + if index is None: + index = list(range(len(rows))) + rows = _prepend_row_index(rows, index) + elif showindex == "never" or (not _bool(showindex) and not showindex_is_a_str): + pass + + # pad with empty headers for initial columns if necessary + headers_pad = 0 + if headers and len(rows) > 0: + headers_pad = max(0, len(rows[0]) - len(headers)) + headers = [""] * headers_pad + headers + + return rows, headers, headers_pad + + +def _wrap_text_to_colwidths(list_of_lists, colwidths, numparses=True): + if len(list_of_lists): + num_cols = len(list_of_lists[0]) + else: + num_cols = 0 + numparses = _expand_iterable(numparses, num_cols, True) + + result = [] + + for row in list_of_lists: + new_row = [] + for cell, width, numparse in zip(row, colwidths, numparses): + if _isnumber(cell) and numparse: + new_row.append(cell) + continue + + if width is not None: + wrapper = _CustomTextWrap(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 + casted_cell = ( + str(cell) if _isnumber(cell) else _type(cell, numparse)(cell) + ) + wrapped = [ + "\n".join(wrapper.wrap(line)) + for line in casted_cell.splitlines() + if line.strip() != "" + ] + new_row.append("\n".join(wrapped)) + else: + new_row.append(cell) + result.append(new_row) + + return result + + +def _to_str(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(_to_str(b'foo')) + "'foo'" + + >>> repr(_to_str('foo')) + "'foo'" + + >>> repr(_to_str(42)) + "'42'" + + """ + if isinstance(s, bytes): + return s.decode(encoding=encoding, errors=errors) + return str(s) + + +def tabulate( + tabular_data, + headers=(), + tablefmt="simple", + floatfmt=_DEFAULT_FLOATFMT, + intfmt=_DEFAULT_INTFMT, + numalign=_DEFAULT_ALIGN, + stralign=_DEFAULT_ALIGN, + missingval=_DEFAULT_MISSINGVAL, + showindex="default", + disable_numparse=False, + colglobalalign=None, + colalign=None, + maxcolwidths=None, + headersglobalalign=None, + headersalign=None, + rowalign=None, + maxheadercolwidths=None, +): + """Format a fixed width table for pretty printing. + + >>> print(tabulate([[1, 2.34], [-56, "8.999"], ["2", "10001"]])) + --- --------- + 1 2.34 + -56 8.999 + 2 10001 + --- --------- + + The first required argument (`tabular_data`) can be a + list-of-lists (or another iterable of iterables), a list of named + tuples, a dictionary of iterables, an iterable of dictionaries, + an iterable of dataclasses (Python 3.7+), a two-dimensional NumPy array, + NumPy record array, or a Pandas' dataframe. + + + 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 and Pandas' dataframes. + + >>> print(tabulate([["sex","age"],["Alice","F",24],["Bob","M",19]], + ... headers="firstrow")) + sex age + ----- ----- ----- + Alice F 24 + Bob M 19 + + By default, pandas.DataFrame data have an additional column called + row index. To add a similar column to all other types of data, + use `showindex="always"` or `showindex=True`. To suppress row indices + for all types of data, pass `showindex="never" or `showindex=False`. + To add a custom row index column, pass `showindex=some_iterable`. + + >>> print(tabulate([["F",24],["M",19]], showindex="always")) + - - -- + 0 F 24 + 1 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". Other values are treated as "left". + `colalign` allows for column-wise override starting from left-most + column. Possible values are: "global" (no override), "right", + "center", "decimal", "left". Other values are teated as "left". + `headersglobalalign` allows for global headers alignment, before any + specific override from `headersalign`. Possible values are: None + (follow columns alignment), "right", "center", "left". Other + values are treated as "right". + `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". Other + values are teated as "right". + + Note: if column alignment is illegal (treating it as left) and + corresponding header aligns as "same", it will treat it as "right". + Thus, in spite of "same" being specified, alignment will not + visually be the same in the end. + + 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', 'pipe', 'orgtbl', 'rst', 'mediawiki', + 'latex', 'latex_raw', 'latex_booktabs', 'latex_longtable' and tsv. + Variable `tabulate_formats`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 | + +------+----------+ + + "simple_grid" draws a grid using single-line box-drawing + characters: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "simple_grid")) + ┌───────────┬───────────┐ + │ strings │ numbers │ + ├───────────┼───────────┤ + │ spam │ 41.9999 │ + ├───────────┼───────────┤ + │ eggs │ 451 │ + └───────────┴───────────┘ + + "rounded_grid" draws a grid using single-line box-drawing + characters with rounded corners: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "rounded_grid")) + ╭───────────┬───────────╮ + │ strings │ numbers │ + ├───────────┼───────────┤ + │ spam │ 41.9999 │ + ├───────────┼───────────┤ + │ eggs │ 451 │ + ╰───────────┴───────────╯ + + "heavy_grid" draws a grid using bold (thick) single-line box-drawing + characters: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "heavy_grid")) + ┏━━━━━━━━━━━┳━━━━━━━━━━━┓ + ┃ strings ┃ numbers ┃ + ┣━━━━━━━━━━━╋━━━━━━━━━━━┫ + ┃ spam ┃ 41.9999 ┃ + ┣━━━━━━━━━━━╋━━━━━━━━━━━┫ + ┃ eggs ┃ 451 ┃ + ┗━━━━━━━━━━━┻━━━━━━━━━━━┛ + + "mixed_grid" draws a grid using a mix of light (thin) and heavy (thick) lines + box-drawing characters: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "mixed_grid")) + ┍━━━━━━━━━━━┯━━━━━━━━━━━┑ + │ strings │ numbers │ + ┝━━━━━━━━━━━┿━━━━━━━━━━━┥ + │ spam │ 41.9999 │ + ├───────────┼───────────┤ + │ eggs │ 451 │ + ┕━━━━━━━━━━━┷━━━━━━━━━━━┙ + + "double_grid" draws a grid using double-line box-drawing + characters: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "double_grid")) + ╔═══════════╦═══════════╗ + ║ strings ║ numbers ║ + ╠═══════════╬═══════════╣ + ║ spam ║ 41.9999 ║ + ╠═══════════╬═══════════╣ + ║ eggs ║ 451 ║ + ╚═══════════╩═══════════╝ + + "fancy_grid" draws a grid using a mix of single and + double-line box-drawing characters: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "fancy_grid")) + ╒═══════════╤═══════════╕ + │ strings │ numbers │ + ╞═══════════╪═══════════╡ + │ spam │ 41.9999 │ + ├───────────┼───────────┤ + │ eggs │ 451 │ + ╘═══════════╧═══════════╛ + + "outline" is the same as the "grid" format but doesn't draw lines between rows: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "outline")) + +-----------+-----------+ + | strings | numbers | + +===========+===========+ + | spam | 41.9999 | + | eggs | 451 | + +-----------+-----------+ + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], tablefmt="outline")) + +------+----------+ + | spam | 41.9999 | + | eggs | 451 | + +------+----------+ + + "simple_outline" is the same as the "simple_grid" format but doesn't draw lines between rows: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "simple_outline")) + ┌───────────┬───────────┐ + │ strings │ numbers │ + ├───────────┼───────────┤ + │ spam │ 41.9999 │ + │ eggs │ 451 │ + └───────────┴───────────┘ + + "rounded_outline" is the same as the "rounded_grid" format but doesn't draw lines between rows: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "rounded_outline")) + ╭───────────┬───────────╮ + │ strings │ numbers │ + ├───────────┼───────────┤ + │ spam │ 41.9999 │ + │ eggs │ 451 │ + ╰───────────┴───────────╯ + + "heavy_outline" is the same as the "heavy_grid" format but doesn't draw lines between rows: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "heavy_outline")) + ┏━━━━━━━━━━━┳━━━━━━━━━━━┓ + ┃ strings ┃ numbers ┃ + ┣━━━━━━━━━━━╋━━━━━━━━━━━┫ + ┃ spam ┃ 41.9999 ┃ + ┃ eggs ┃ 451 ┃ + ┗━━━━━━━━━━━┻━━━━━━━━━━━┛ + + "mixed_outline" is the same as the "mixed_grid" format but doesn't draw lines between rows: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "mixed_outline")) + ┍━━━━━━━━━━━┯━━━━━━━━━━━┑ + │ strings │ numbers │ + ┝━━━━━━━━━━━┿━━━━━━━━━━━┥ + │ spam │ 41.9999 │ + │ eggs │ 451 │ + ┕━━━━━━━━━━━┷━━━━━━━━━━━┙ + + "double_outline" is the same as the "double_grid" format but doesn't draw lines between rows: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "double_outline")) + ╔═══════════╦═══════════╗ + ║ strings ║ numbers ║ + ╠═══════════╬═══════════╣ + ║ spam ║ 41.9999 ║ + ║ eggs ║ 451 ║ + ╚═══════════╩═══════════╝ + + "fancy_outline" is the same as the "fancy_grid" format but doesn't draw lines between rows: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "fancy_outline")) + ╒═══════════╤═══════════╕ + │ strings │ numbers │ + ╞═══════════╪═══════════╡ + │ spam │ 41.9999 │ + │ eggs │ 451 │ + ╘═══════════╧═══════════╛ + + "pipe" is like tables in PHP Markdown Extra extension or Pandoc + pipe_tables: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "pipe")) + | strings | numbers | + |:----------|----------:| + | spam | 41.9999 | + | eggs | 451 | + + "presto" is like tables produce by the Presto CLI: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "presto")) + strings | numbers + -----------+----------- + spam | 41.9999 + eggs | 451 + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], tablefmt="pipe")) + |:-----|---------:| + | spam | 41.9999 | + | eggs | 451 | + + "orgtbl" is like tables in Emacs org-mode and orgtbl-mode. They + are slightly different from "pipe" format by not using colons to + define column alignment, and using a "+" sign to indicate line + intersections: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "orgtbl")) + | strings | numbers | + |-----------+-----------| + | spam | 41.9999 | + | eggs | 451 | + + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], tablefmt="orgtbl")) + | 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 + ==== ======== + + "mediawiki" produces a table markup used in Wikipedia and on other + MediaWiki-based sites: + + >>> print(tabulate([["strings", "numbers"], ["spam", 41.9999], ["eggs", "451.0"]], + ... headers="firstrow", tablefmt="mediawiki")) + {| class="wikitable" style="text-align: left;" + |+ + |- + ! strings !! style="text-align: right;"| numbers + |- + | spam || style="text-align: right;"| 41.9999 + |- + | eggs || style="text-align: right;"| 451 + |} + + "html" produces HTML markup as an html.escape'd str + with a ._repr_html_ method so that Jupyter Lab and Notebook display the HTML + and a .str property so that the raw HTML remains accessible + the unsafehtml table format can be used if an unescaped HTML format is required: + + >>> print(tabulate([["strings", "numbers"], ["spam", 41.9999], ["eggs", "451.0"]], + ... headers="firstrow", tablefmt="html")) + + + + + + + + +
strings numbers
spam 41.9999
eggs 451
+ + "latex" produces a tabular environment of LaTeX document markup: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], tablefmt="latex")) + \\begin{tabular}{lr} + \\hline + spam & 41.9999 \\\\ + eggs & 451 \\\\ + \\hline + \\end{tabular} + + "latex_raw" is similar to "latex", but doesn't escape special characters, + such as backslash and underscore, so LaTeX commands may embedded into + cells' values: + + >>> print(tabulate([["spam$_9$", 41.9999], ["\\\\emph{eggs}", "451.0"]], tablefmt="latex_raw")) + \\begin{tabular}{lr} + \\hline + spam$_9$ & 41.9999 \\\\ + \\emph{eggs} & 451 \\\\ + \\hline + \\end{tabular} + + "latex_booktabs" produces a tabular environment of LaTeX document markup + using the booktabs.sty package: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], tablefmt="latex_booktabs")) + \\begin{tabular}{lr} + \\toprule + spam & 41.9999 \\\\ + eggs & 451 \\\\ + \\bottomrule + \\end{tabular} + + "latex_longtable" produces a tabular environment that can stretch along + multiple pages, using the longtable package for LaTeX. + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], tablefmt="latex_longtable")) + \\begin{longtable}{lr} + \\hline + spam & 41.9999 \\\\ + eggs & 451 \\\\ + \\hline + \\end{longtable} + + + 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 + `disable_numparse=True`. For more fine grained control, a list column + indices is used to disable number parsing only on those columns + e.g. `disable_numparse=[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` + + """ + + if tabular_data is None: + tabular_data = [] + + list_of_lists, headers, headers_pad = _normalize_tabular_data( + tabular_data, headers, showindex=showindex + ) + list_of_lists, separating_lines = _remove_separating_lines(list_of_lists) + + if maxcolwidths is not None: + if len(list_of_lists): + num_cols = len(list_of_lists[0]) + else: + num_cols = 0 + if isinstance(maxcolwidths, int): # Expand scalar for all columns + maxcolwidths = _expand_iterable(maxcolwidths, num_cols, maxcolwidths) + else: # Ignore col width for any 'trailing' columns + maxcolwidths = _expand_iterable(maxcolwidths, num_cols, None) + + numparses = _expand_numparse(disable_numparse, num_cols) + list_of_lists = _wrap_text_to_colwidths( + list_of_lists, maxcolwidths, numparses=numparses + ) + + if maxheadercolwidths is not None: + num_cols = len(list_of_lists[0]) + if isinstance(maxheadercolwidths, int): # Expand scalar for all columns + maxheadercolwidths = _expand_iterable( + maxheadercolwidths, num_cols, maxheadercolwidths + ) + else: # Ignore col width for any 'trailing' columns + maxheadercolwidths = _expand_iterable(maxheadercolwidths, num_cols, None) + + numparses = _expand_numparse(disable_numparse, num_cols) + headers = _wrap_text_to_colwidths( + [headers], maxheadercolwidths, numparses=numparses + )[0] + + # empty values in the first column of RST tables should be escaped (issue #82) + # "" should be escaped as "\\ " or ".." + if tablefmt == "rst": + list_of_lists, headers = _rst_escape_first_column(list_of_lists, headers) + + # PrettyTable 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. + min_padding = MIN_PADDING + if tablefmt == "pretty": + min_padding = 0 + disable_numparse = 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) + plain_text = "\t".join( + chain( + # headers + map(_to_str, headers), + # rows: chain the rows together into a single iterable after mapping + # the bytestring conversino to each cell value + chain.from_iterable(map(_to_str, row) for row in list_of_lists), + ) + ) + + has_invisible = _ansi_codes.search(plain_text) is not None + + enable_widechars = wcwidth is not None and WIDE_CHARS_MODE + if ( + not isinstance(tablefmt, TableFormat) + and tablefmt in multiline_formats + and _is_multiline(plain_text) + ): + tablefmt = multiline_formats.get(tablefmt, tablefmt) + is_multiline = True + else: + is_multiline = False + width_fn = _choose_width_fn(has_invisible, enable_widechars, is_multiline) + + # format rows and columns, convert numeric values to strings + cols = list(izip_longest(*list_of_lists)) + numparses = _expand_numparse(disable_numparse, len(cols)) + coltypes = [_column_type(col, numparse=np) for col, np in zip(cols, numparses)] + if isinstance(floatfmt, str): # old version + float_formats = len(cols) * [ + floatfmt + ] # just duplicate the string to use in each column + else: # if floatfmt is list, tuple etc we have one per column + float_formats = list(floatfmt) + if len(float_formats) < len(cols): + float_formats.extend((len(cols) - len(float_formats)) * [_DEFAULT_FLOATFMT]) + if isinstance(intfmt, str): # old version + int_formats = len(cols) * [ + intfmt + ] # just duplicate the string to use in each column + else: # if intfmt is list, tuple etc we have one per column + int_formats = list(intfmt) + if len(int_formats) < len(cols): + int_formats.extend((len(cols) - len(int_formats)) * [_DEFAULT_INTFMT]) + if isinstance(missingval, str): + missing_vals = len(cols) * [missingval] + else: + missing_vals = list(missingval) + if len(missing_vals) < len(cols): + missing_vals.extend((len(cols) - len(missing_vals)) * [_DEFAULT_MISSINGVAL]) + cols = [ + [_format(v, ct, fl_fmt, int_fmt, miss_v, has_invisible) for v in c] + for c, ct, fl_fmt, int_fmt, miss_v in zip( + cols, coltypes, float_formats, int_formats, missing_vals + ) + ] + + # 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) + for idx, align in enumerate(colalign): + if align != "global": + aligns[idx] = align + minwidths = ( + [width_fn(h) + min_padding for h in headers] if headers else [0] * len(cols) + ) + cols = [ + _align_column(c, a, minw, has_invisible, enable_widechars, is_multiline) + for c, a, minw in zip(cols, aligns, minwidths) + ] + + aligns_headers = None + if headers: + # align headers and add headers + t_cols = cols or [[""]] * len(headers) + # first set global alignment + if headersglobalalign is not None: # if global alignment provided + aligns_headers = [headersglobalalign] * len(t_cols) + else: # default + aligns_headers = aligns or [stralign] * len(headers) + # then specific header alignements + if headersalign is not None: + assert isinstance(headersalign, Iterable) + for idx, align in enumerate(headersalign[:len(aligns_headers)]): + hidx = headers_pad + idx + if align == "same" and hidx < len(aligns): # same as column align + aligns_headers[hidx] = aligns[hidx] + elif align != "global": + aligns_headers[hidx] = align + minwidths = [ + max(minw, max(width_fn(cl) for cl in c)) + for minw, c in zip(minwidths, t_cols) + ] + headers = [ + _align_header(h, a, minw, width_fn(h), is_multiline, width_fn) + for h, a, minw in zip(headers, aligns_headers, minwidths) + ] + rows = list(zip(*cols)) + else: + minwidths = [max(width_fn(cl) for cl in c) for c in cols] + rows = list(zip(*cols)) + + if not isinstance(tablefmt, TableFormat): + tablefmt = _table_formats.get(tablefmt, _table_formats["simple"]) + + ra_default = rowalign if isinstance(rowalign, str) else None + rowaligns = _expand_iterable(rowalign, len(rows), ra_default) + _reinsert_separating_lines(rows, separating_lines) + + return _format_table( + tablefmt, headers, aligns_headers, rows, minwidths, aligns, is_multiline, rowaligns=rowaligns + ) + + +def _expand_numparse(disable_numparse, column_count): + """ + Return a list of bools of length `column_count` which indicates whether + number parsing should be used on each column. + If `disable_numparse` is a list of indices, each of those indices are False, + and everything else is True. + If `disable_numparse` is a bool, then the returned list is all the same. + """ + if isinstance(disable_numparse, Iterable): + numparses = [True] * column_count + for index in disable_numparse: + numparses[index] = False + return numparses + else: + return [not disable_numparse] * column_count + + +def _expand_iterable(original, num_desired, default): + """ + Expands the `original` argument to return a return a list of + length `num_desired`. If `original` is shorter than `num_desired`, 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 `num_desired` completely populated with `default will be returned + """ + if isinstance(original, Iterable) and not isinstance(original, str): + return original + [default] * (num_desired - len(original)) + else: + return [default] * num_desired + + +def _pad_row(cells, padding): + if cells: + pad = " " * padding + padded_cells = [pad + cell + pad for cell in cells] + return padded_cells + else: + return cells + + +def _build_simple_row(padded_cells, rowfmt): + "Format row according to DataRow format without padding." + begin, sep, end = rowfmt + return (begin + sep.join(padded_cells) + end).rstrip() + + +def _build_row(padded_cells, colwidths, colaligns, rowfmt): + "Return a string which represents a row of data cells." + if not rowfmt: + return None + if hasattr(rowfmt, "__call__"): + return rowfmt(padded_cells, colwidths, colaligns) + else: + return _build_simple_row(padded_cells, rowfmt) + + +def _append_basic_row(lines, padded_cells, colwidths, colaligns, rowfmt, rowalign=None): + # NOTE: rowalign is ignored and exists for api compatibility with _append_multiline_row + lines.append(_build_row(padded_cells, colwidths, colaligns, rowfmt)) + return lines + + +def _align_cell_veritically(text_lines, num_lines, column_width, row_alignment): + delta_lines = num_lines - len(text_lines) + blank = [" " * column_width] + if row_alignment == "bottom": + return blank * delta_lines + text_lines + elif row_alignment == "center": + top_delta = delta_lines // 2 + bottom_delta = delta_lines - top_delta + return top_delta * blank + text_lines + bottom_delta * blank + else: + return text_lines + blank * delta_lines + + +def _append_multiline_row( + lines, padded_multiline_cells, padded_widths, colaligns, rowfmt, pad, rowalign=None +): + colwidths = [w - 2 * pad for w in padded_widths] + cells_lines = [c.splitlines() for c in padded_multiline_cells] + nlines = max(map(len, cells_lines)) # number of lines in the row + # vertically pad cells where some lines are missing + # cells_lines = [ + # (cl + [" " * w] * (nlines - len(cl))) for cl, w in zip(cells_lines, colwidths) + # ] + + cells_lines = [ + _align_cell_veritically(cl, nlines, w, rowalign) + for cl, w in zip(cells_lines, colwidths) + ] + lines_cells = [[cl[i] for cl in cells_lines] for i in range(nlines)] + for ln in lines_cells: + padded_ln = _pad_row(ln, pad) + _append_basic_row(lines, padded_ln, colwidths, colaligns, rowfmt) + return lines + + +def _build_line(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 _build_simple_row(cells, (begin, sep, end)) + + +def _append_line(lines, colwidths, colaligns, linefmt): + lines.append(_build_line(colwidths, colaligns, linefmt)) + return lines + + +class JupyterHTMLStr(str): + """Wrap the string with a _repr_html_ method so that Jupyter + displays the HTML table""" + + def _repr_html_(self): + return self + + @property + def str(self): + """add a .str property so that the raw string is still accessible""" + return self + + +def _format_table(fmt, headers, headersaligns, rows, colwidths, colaligns, is_multiline, rowaligns): + """Produce a plain-text representation of the table.""" + lines = [] + hidden = fmt.with_header_hide if (headers and fmt.with_header_hide) else [] + pad = fmt.padding + headerrow = fmt.headerrow + + padded_widths = [(w + 2 * pad) for w in colwidths] + if is_multiline: + pad_row = lambda row, _: row # noqa do it later, in _append_multiline_row + append_row = partial(_append_multiline_row, pad=pad) + else: + pad_row = _pad_row + append_row = _append_basic_row + + padded_headers = pad_row(headers, pad) + padded_rows = [pad_row(row, pad) for row in rows] + + if fmt.lineabove and "lineabove" not in hidden: + _append_line(lines, padded_widths, colaligns, fmt.lineabove) + + if padded_headers: + append_row(lines, padded_headers, padded_widths, headersaligns, headerrow) + if fmt.linebelowheader and "linebelowheader" not in hidden: + _append_line(lines, padded_widths, colaligns, fmt.linebelowheader) + + if padded_rows and fmt.linebetweenrows and "linebetweenrows" not in hidden: + # initial rows with a line below + for row, ralign in zip(padded_rows[:-1], rowaligns): + append_row( + lines, row, padded_widths, colaligns, fmt.datarow, rowalign=ralign + ) + _append_line(lines, padded_widths, colaligns, fmt.linebetweenrows) + # the last row without a line below + append_row( + lines, + padded_rows[-1], + padded_widths, + colaligns, + fmt.datarow, + rowalign=rowaligns[-1], + ) + else: + separating_line = ( + fmt.linebetweenrows + or fmt.linebelowheader + or fmt.linebelow + or fmt.lineabove + or Line("", "", "", "") + ) + for row in padded_rows: + # test to see if either the 1st column or the 2nd column (account for showindex) has + # the SEPARATING_LINE flag + if _is_separating_line(row): + _append_line(lines, padded_widths, colaligns, separating_line) + else: + append_row(lines, row, padded_widths, colaligns, fmt.datarow) + + if fmt.linebelow and "linebelow" not in hidden: + _append_line(lines, padded_widths, colaligns, fmt.linebelow) + + if headers or rows: + output = "\n".join(lines) + if fmt.lineabove == _html_begin_table_without_header: + return JupyterHTMLStr(output) + else: + return output + else: # a completely empty table + return "" + + +class _CustomTextWrap(textwrap.TextWrapper): + """A custom implementation of CPython's textwrap.TextWrapper. This supports + both wide characters (Korea, Japanese, Chinese) - including mixed string. + For the most part, the `_handle_long_word` and `_wrap_chunks` functions were + copy pasted out of the CPython baseline, and updated with our custom length + and line appending logic. + """ + + def __init__(self, *args, **kwargs): + self._active_codes = [] + self.max_lines = None # For python2 compatibility + textwrap.TextWrapper.__init__(self, *args, **kwargs) + + @staticmethod + def _len(item): + """Custom len that gets console column width for wide + and non-wide characters as well as ignores color codes""" + stripped = _strip_ansi(item) + if wcwidth: + return wcwidth.wcswidth(stripped) + else: + return len(stripped) + + def _update_lines(self, lines, new_line): + """Adds a new line to the list of lines the text is being wrapped into + This function will also track any ANSI color codes in this string as well + as add any colors from previous lines order to preserve the same formatting + as a single unwrapped string. + """ + code_matches = [x for x in _ansi_codes.finditer(new_line)] + color_codes = [ + code.string[code.span()[0] : code.span()[1]] for code in code_matches + ] + + # Add color codes from earlier in the unwrapped line, and then track any new ones we add. + new_line = "".join(self._active_codes) + new_line + + for code in color_codes: + if code != _ansi_color_reset_code: + self._active_codes.append(code) + else: # A single reset code resets everything + self._active_codes = [] + + # Always ensure each line is color terminted if any colors are + # still active, otherwise colors will bleed into other cells on the console + if len(self._active_codes) > 0: + new_line = new_line + _ansi_color_reset_code + + lines.append(new_line) + + def _handle_long_word(self, reversed_chunks, cur_line, cur_len, width): + """_handle_long_word(chunks : [string], + cur_line : [string], + cur_len : int, width : int) + Handle a chunk of text (most likely a word, not whitespace) that + is too long to fit in any line. + """ + # Figure out when indent is larger than the specified width, and make + # sure at least one character is stripped off on every pass + if width < 1: + space_left = 1 + else: + space_left = width - cur_len + + # If we're allowed to break long words, then do so: put as much + # of the next chunk onto the current line as will fit. + if self.break_long_words: + # Tabulate Custom: Build the string up piece-by-piece in order to + # take each charcter's width into account + chunk = reversed_chunks[-1] + i = 1 + while self._len(chunk[:i]) <= space_left: + i = i + 1 + cur_line.append(chunk[: i - 1]) + reversed_chunks[-1] = chunk[i - 1 :] + + # Otherwise, we have to preserve the long word intact. Only add + # it to the current line if there's nothing already there -- + # that minimizes how much we violate the width constraint. + elif not cur_line: + cur_line.append(reversed_chunks.pop()) + + # If we're not allowed to break long words, and there's already + # text on the current line, do nothing. Next time through the + # main loop of _wrap_chunks(), we'll wind up here again, but + # cur_len will be zero, so the next line will be entirely + # devoted to the long word that we can't handle right now. + + def _wrap_chunks(self, chunks): + """_wrap_chunks(chunks : [string]) -> [string] + Wrap a sequence of text chunks and return a list of lines of + length 'self.width' or less. (If 'break_long_words' is false, + some lines may be longer than this.) Chunks correspond roughly + to words and the whitespace between them: each chunk is + indivisible (modulo 'break_long_words'), but a line break can + come between any two chunks. Chunks should not have internal + whitespace; ie. a chunk is either all whitespace or a "word". + Whitespace chunks will be removed from the beginning and end of + lines, but apart from that whitespace is preserved. + """ + lines = [] + if self.width <= 0: + raise ValueError("invalid width %r (must be > 0)" % self.width) + if self.max_lines is not None: + if self.max_lines > 1: + indent = self.subsequent_indent + else: + indent = self.initial_indent + if self._len(indent) + self._len(self.placeholder.lstrip()) > self.width: + raise ValueError("placeholder too large for max width") + + # Arrange in reverse order so items can be efficiently popped + # from a stack of chucks. + chunks.reverse() + + while chunks: + + # Start the list of chunks that will make up the current line. + # cur_len is just the length of all the chunks in cur_line. + cur_line = [] + cur_len = 0 + + # Figure out which static string will prefix this line. + if lines: + indent = self.subsequent_indent + else: + indent = self.initial_indent + + # Maximum width for this line. + width = self.width - self._len(indent) + + # First chunk on line is whitespace -- drop it, unless this + # is the very beginning of the text (ie. no lines started yet). + if self.drop_whitespace and chunks[-1].strip() == "" and lines: + del chunks[-1] + + while chunks: + chunk_len = self._len(chunks[-1]) + + # Can at least squeeze this chunk onto the current line. + if cur_len + chunk_len <= width: + cur_line.append(chunks.pop()) + cur_len += chunk_len + + # Nope, this line is full. + else: + break + + # The current line is full, and the next chunk is too big to + # fit on *any* line (not just this one). + if chunks and self._len(chunks[-1]) > width: + self._handle_long_word(chunks, cur_line, cur_len, width) + cur_len = sum(map(self._len, cur_line)) + + # If the last chunk on this line is all whitespace, drop it. + if self.drop_whitespace and cur_line and cur_line[-1].strip() == "": + cur_len -= self._len(cur_line[-1]) + del cur_line[-1] + + if cur_line: + if ( + self.max_lines is None + or len(lines) + 1 < self.max_lines + or ( + not chunks + or self.drop_whitespace + and len(chunks) == 1 + and not chunks[0].strip() + ) + and cur_len <= width + ): + # Convert current line back to a string and store it in + # list of all lines (return value). + self._update_lines(lines, indent + "".join(cur_line)) + else: + while cur_line: + if ( + cur_line[-1].strip() + and cur_len + self._len(self.placeholder) <= width + ): + cur_line.append(self.placeholder) + self._update_lines(lines, indent + "".join(cur_line)) + break + cur_len -= self._len(cur_line[-1]) + del cur_line[-1] + else: + if lines: + prev_line = lines[-1].rstrip() + if ( + self._len(prev_line) + self._len(self.placeholder) + <= self.width + ): + lines[-1] = prev_line + self.placeholder + break + self._update_lines(lines, indent + self.placeholder.lstrip()) + break + + return lines + + +def _main(): + """\ + Usage: tabulate [options] [FILE ...] + + Pretty-print tabular data. + See also https://github.com/astanin/python-tabulate + + FILE a filename of the file with tabular data; + if "-" or missing, read data from stdin. + + Options: + + -h, --help show this message + -1, --header use the first row of data as a table header + -o FILE, --output FILE print table to FILE (default: stdout) + -s REGEXP, --sep REGEXP use a custom column separator (default: whitespace) + -F FPFMT, --float FPFMT floating point number format (default: g) + -I INTFMT, --int INTFMT integer point number format (default: "") + -f FMT, --format FMT set output table format; supported formats: + plain, simple, grid, fancy_grid, pipe, orgtbl, + rst, mediawiki, html, latex, latex_raw, + latex_booktabs, latex_longtable, tsv + (default: simple) + """ + import getopt + import sys + import textwrap + + usage = textwrap.dedent(_main.__doc__) + try: + opts, args = getopt.getopt( + sys.argv[1:], + "h1o:s:F:A:f:", + ["help", "header", "output", "sep=", "float=", "int=", "align=", "format="], + ) + except getopt.GetoptError as e: + print(e) + print(usage) + sys.exit(2) + headers = [] + floatfmt = _DEFAULT_FLOATFMT + intfmt = _DEFAULT_INTFMT + colalign = None + tablefmt = "simple" + sep = r"\s+" + outfile = "-" + for opt, value in opts: + if opt in ["-1", "--header"]: + headers = "firstrow" + elif opt in ["-o", "--output"]: + outfile = value + elif opt in ["-F", "--float"]: + floatfmt = value + elif opt in ["-I", "--int"]: + intfmt = value + elif opt in ["-C", "--colalign"]: + colalign = value.split() + elif opt in ["-f", "--format"]: + if value not in tabulate_formats: + print("%s is not a supported table format" % value) + print(usage) + sys.exit(3) + tablefmt = value + elif opt in ["-s", "--sep"]: + sep = value + elif opt in ["-h", "--help"]: + print(usage) + sys.exit(0) + files = [sys.stdin] if not args else args + with (sys.stdout if outfile == "-" else open(outfile, "w")) as out: + for f in files: + if f == "-": + f = sys.stdin + if _is_file(f): + _pprint_file( + f, + headers=headers, + tablefmt=tablefmt, + sep=sep, + floatfmt=floatfmt, + intfmt=intfmt, + file=out, + colalign=colalign, + ) + else: + with open(f) as fobj: + _pprint_file( + fobj, + headers=headers, + tablefmt=tablefmt, + sep=sep, + floatfmt=floatfmt, + intfmt=intfmt, + file=out, + colalign=colalign, + ) + + +def _pprint_file(fobject, headers, tablefmt, sep, floatfmt, intfmt, file, colalign): + rows = fobject.readlines() + table = [re.split(sep, r.rstrip()) for r in rows if r.strip()] + print( + tabulate( + table, + headers, + tablefmt, + floatfmt=floatfmt, + intfmt=intfmt, + colalign=colalign, + ), + file=file, + ) + + +if __name__ == "__main__": + _main() diff --git a/test/test_api.py b/test/test_api.py index b3db29c..e658e82 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -1,66 +1,66 @@ -"""API properties. - -""" - -from tabulate import tabulate, tabulate_formats, simple_separated_format -from common import skip - - -try: - from inspect import signature, _empty -except ImportError: - signature = None - _empty = None - - -def test_tabulate_formats(): - "API: tabulate_formats is a list of strings" "" - supported = tabulate_formats - print("tabulate_formats = %r" % supported) - assert type(supported) is list - for fmt in supported: - assert type(fmt) is str # noqa - - -def _check_signature(function, expected_sig): - if not signature: - skip("") - actual_sig = signature(function) - print(f"expected: {expected_sig}\nactual: {str(actual_sig)}\n") - - assert len(actual_sig.parameters) == len(expected_sig) - - for (e, ev), (a, av) in zip(expected_sig, actual_sig.parameters.items()): - assert e == a and ev == av.default - - -def test_tabulate_signature(): - "API: tabulate() type signature is unchanged" "" - assert type(tabulate) is type(lambda: None) # noqa - expected_sig = [ - ("tabular_data", _empty), - ("headers", ()), - ("tablefmt", "simple"), - ("floatfmt", "g"), - ("intfmt", ""), - ("numalign", "default"), - ("stralign", "default"), - ("missingval", ""), - ("showindex", "default"), - ("disable_numparse", False), - ("colglobalalign", None), - ("colalign", None), - ("maxcolwidths", None), - ("headersglobalalign", None), - ("headersalign", None), - ("rowalign", None), - ("maxheadercolwidths", None), - ] - _check_signature(tabulate, expected_sig) - - -def test_simple_separated_format_signature(): - "API: simple_separated_format() type signature is unchanged" "" - assert type(simple_separated_format) is type(lambda: None) # noqa - expected_sig = [("separator", _empty)] - _check_signature(simple_separated_format, expected_sig) +"""API properties. + +""" + +from tabulate import tabulate, tabulate_formats, simple_separated_format +from common import skip + + +try: + from inspect import signature, _empty +except ImportError: + signature = None + _empty = None + + +def test_tabulate_formats(): + "API: tabulate_formats is a list of strings" "" + supported = tabulate_formats + print("tabulate_formats = %r" % supported) + assert type(supported) is list + for fmt in supported: + assert type(fmt) is str # noqa + + +def _check_signature(function, expected_sig): + if not signature: + skip("") + actual_sig = signature(function) + print(f"expected: {expected_sig}\nactual: {str(actual_sig)}\n") + + assert len(actual_sig.parameters) == len(expected_sig) + + for (e, ev), (a, av) in zip(expected_sig, actual_sig.parameters.items()): + assert e == a and ev == av.default + + +def test_tabulate_signature(): + "API: tabulate() type signature is unchanged" "" + assert type(tabulate) is type(lambda: None) # noqa + expected_sig = [ + ("tabular_data", _empty), + ("headers", ()), + ("tablefmt", "simple"), + ("floatfmt", "g"), + ("intfmt", ""), + ("numalign", "default"), + ("stralign", "default"), + ("missingval", ""), + ("showindex", "default"), + ("disable_numparse", False), + ("colglobalalign", None), + ("colalign", None), + ("maxcolwidths", None), + ("headersglobalalign", None), + ("headersalign", None), + ("rowalign", None), + ("maxheadercolwidths", None), + ] + _check_signature(tabulate, expected_sig) + + +def test_simple_separated_format_signature(): + "API: simple_separated_format() type signature is unchanged" "" + assert type(simple_separated_format) is type(lambda: None) # noqa + expected_sig = [("separator", _empty)] + _check_signature(simple_separated_format, expected_sig) From 9258744646b9da062e326a15d23b9359527792ee Mon Sep 17 00:00:00 2001 From: eliegoudout <114467748+eliegoudout@users.noreply.github.com> Date: Sun, 27 Nov 2022 16:16:31 +0100 Subject: [PATCH 08/90] support colalign too long, fix headers too long Support when `colalign`is too long Fixed when `headersalign`is too long Removed wrong parameters behaviour in docs because less predictible than anticipated (difference of treatment for `''` and `'foo'`by `_align_column_choose_padfn`. It would be too heavy to explain detailed behaviour for wrong use cases). --- tabulate/__init__.py | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 81001ef..4b2c4b5 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1651,23 +1651,16 @@ def tabulate( `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". Other values are treated as "left". + "left". `colalign` allows for column-wise override starting from left-most column. Possible values are: "global" (no override), "right", - "center", "decimal", "left". Other values are teated as "left". + "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". Other - values are treated as "right". + (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". Other - values are teated as "right". - - Note: if column alignment is illegal (treating it as left) and - corresponding header aligns as "same", it will treat it as "right". - Thus, in spite of "same" being specified, alignment will not - visually be the same in the end. + (follow column alignment), "right", "center", "left". Table formats ------------- @@ -2212,7 +2205,9 @@ def tabulate( if colalign is not None: assert isinstance(colalign, Iterable) for idx, align in enumerate(colalign): - if align != "global": + if not idx < len(aligns): + break + elif align != "global": aligns[idx] = align minwidths = ( [width_fn(h) + min_padding for h in headers] if headers else [0] * len(cols) @@ -2234,9 +2229,11 @@ def tabulate( # then specific header alignements if headersalign is not None: assert isinstance(headersalign, Iterable) - for idx, align in enumerate(headersalign[:len(aligns_headers)]): + for idx, align in enumerate(headersalign): hidx = headers_pad + idx - if align == "same" and hidx < len(aligns): # same as column align + if not hidx < len(aligns_headers): + break + elif align == "same" and hidx < len(aligns): # same as column align aligns_headers[hidx] = aligns[hidx] elif align != "global": aligns_headers[hidx] = align From 39813e207ce0860a064d6a48556675c1ad4a9bd8 Mon Sep 17 00:00:00 2001 From: eliegoudout <114467748+eliegoudout@users.noreply.github.com> Date: Sun, 27 Nov 2022 17:06:19 +0100 Subject: [PATCH 09/90] Doc when no tabular_data In this case, headers are not related to any column. Hence, no alignmennt can be inferred from `colglobalalign`of `colalign`. --- tabulate/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 4b2c4b5..8ce5a22 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1662,6 +1662,10 @@ def tabulate( given header. Possible values are: "global" (no override), "same" (follow column alignment), "right", "center", "left". + Note on intended behaviour: If there is no `tabular_data`, any column + alignment argument is ignored. Hence, in this case, header + alignment cannot be inferred from column alignment. + Table formats ------------- From 051cac742cd053aaffacc3c270d096acc6ee24fd Mon Sep 17 00:00:00 2001 From: eliegoudout <114467748+eliegoudout@users.noreply.github.com> Date: Sun, 27 Nov 2022 18:50:26 +0100 Subject: [PATCH 10/90] Warning when `colalign` or `headersalign` is a str --- tabulate/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 8ce5a22..f24e8dd 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -2208,6 +2208,9 @@ def tabulate( # then specific alignements if colalign is not None: assert isinstance(colalign, Iterable) + if isinstance(colalign, str): + print( + f"Warning in `tabulate`: As a string, `colalign` is interpreted as {[c for c in colalign]}. Did you mean `colglobalalign = \"{colalign}\"` or `colalign = (\"{colalign}\",)`?") for idx, align in enumerate(colalign): if not idx < len(aligns): break @@ -2233,6 +2236,9 @@ def tabulate( # then specific header alignements if headersalign is not None: assert isinstance(headersalign, Iterable) + if isinstance(headersalign, str): + print( + f"Warning in `tabulate`: As a string, `headersalign` is interpreted as {[c for c in headersalign]}. Did you mean `headersglobalalign = \"{headersalign}\"` or `headersalign = (\"{headersalign}\",)`?") for idx, align in enumerate(headersalign): hidx = headers_pad + idx if not hidx < len(aligns_headers): From 4cb95249676f9302d69ac59a954e74bd65e8490d Mon Sep 17 00:00:00 2001 From: eliegoudout <114467748+eliegoudout@users.noreply.github.com> Date: Mon, 28 Nov 2022 09:58:08 +0100 Subject: [PATCH 11/90] Add tests and better user warnings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Better warnings using (built-in)[https://docs.python.org/3/library/warnings.html] `warnings` module when `colalign` or `headersalign` is a `string`. Warnings are now directed to `stderr`. • Added `check_warning` tool to `test/common.py`. • Added following tests: - `test_column_global_and_specific_alignment` - `test_headers_global_and_specific_alignment` - `test_colalign_or_headersalign_too_long` - `test_warning_when_colalign_or_headersalign_is_string` --- tabulate/__init__.py | 7 +++--- test/common.py | 17 +++++++++++++- test/test_output.py | 56 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 74 insertions(+), 6 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index f24e8dd..11bb865 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1,5 +1,6 @@ """Pretty-print tabular data.""" +import warnings from collections import namedtuple from collections.abc import Iterable, Sized from html import escape as htmlescape @@ -2209,8 +2210,7 @@ def tabulate( if colalign is not None: assert isinstance(colalign, Iterable) if isinstance(colalign, str): - print( - f"Warning in `tabulate`: As a string, `colalign` is interpreted as {[c for c in colalign]}. Did you mean `colglobalalign = \"{colalign}\"` or `colalign = (\"{colalign}\",)`?") + warnings.warn(f"As a string, `colalign` is interpreted as {[c for c in colalign]}. Did you mean `colglobalalign = \"{colalign}\"` or `colalign = (\"{colalign}\",)`?", stacklevel=2) for idx, align in enumerate(colalign): if not idx < len(aligns): break @@ -2237,8 +2237,7 @@ def tabulate( if headersalign is not None: assert isinstance(headersalign, Iterable) if isinstance(headersalign, str): - print( - f"Warning in `tabulate`: As a string, `headersalign` is interpreted as {[c for c in headersalign]}. Did you mean `headersglobalalign = \"{headersalign}\"` or `headersalign = (\"{headersalign}\",)`?") + warnings.warn(f"As a string, `headersalign` is interpreted as {[c for c in headersalign]}. Did you mean `headersglobalalign = \"{headersalign}\"` or `headersalign = (\"{headersalign}\",)`?", stacklevel=2) for idx, align in enumerate(headersalign): hidx = headers_pad + idx if not hidx < len(aligns_headers): diff --git a/test/common.py b/test/common.py index d95e84f..4cd3709 100644 --- a/test/common.py +++ b/test/common.py @@ -1,6 +1,6 @@ import pytest # noqa from pytest import skip, raises # noqa - +import warnings def assert_equal(expected, result): print("Expected:\n%s\n" % expected) @@ -27,3 +27,18 @@ def rows_to_pipe_table_str(rows): lines.append(line) return "\n".join(lines) + +def check_warnings(func_args_kwargs, *, num=None, category=None, contain=None): + func, args, kwargs = func_args_kwargs + with warnings.catch_warnings(record=True) as W: + # Causes all warnings to always be triggered inside here. + warnings.simplefilter("always") + func(*args, **kwargs) + # Checks + if num is not None: + assert len(W) == num + if category is not None: + assert all([issubclass(w.category, category) for w in W]) + if contain is not None: + assert all([contain in str(w.message) for w in W]) + diff --git a/test/test_output.py b/test/test_output.py index 9043aed..d572498 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -1,7 +1,7 @@ """Test output of the various forms of tabular data.""" import tabulate as tabulate_module -from common import assert_equal, raises, skip +from common import assert_equal, raises, skip, check_warnings from tabulate import tabulate, simple_separated_format, SEPARATING_LINE # _test_table shows @@ -2680,6 +2680,60 @@ def test_colalign_multi_with_sep_line(): expected = " one two\n\nthree four" assert_equal(expected, result) +def test_column_global_and_specific_alignment(): + """ Test `colglobalalign` and `"global"` parameter for `colalign`. """ + table = [[1,2,3,4],[111,222,333,444]] + colglobalalign = 'center' + colalign = ('global','left', 'right') + result = tabulate(table, colglobalalign=colglobalalign, colalign=colalign) + expected = '\n'.join([ + "--- --- --- ---", + " 1 2 3 4", + "111 222 333 444", + "--- --- --- ---"]) + assert_equal(expected, result) + +def test_headers_global_and_specific_alignment(): + """ Test `headersglobalalign` and `headersalign`. """ + table = [[1,2,3,4,5,6],[111,222,333,444,555,666]] + colglobalalign = 'center' + colalign = ('left',) + headers = ['h', 'e', 'a', 'd', 'e', 'r'] + headersglobalalign = 'right' + headersalign = ('same', 'same', 'left', 'global', 'center') + result = tabulate(table, headers=headers, colglobalalign=colglobalalign, colalign=colalign, headersglobalalign=headersglobalalign, headersalign=headersalign) + expected = '\n'.join([ + "h e a d e r", + "--- --- --- --- --- ---", + "1 2 3 4 5 6", + "111 222 333 444 555 666"]) + assert_equal(expected, result) + +def test_colalign_or_headersalign_too_long(): + """ Test `colalign` and `headersalign` too long. """ + table = [[1,2],[111,222]] + colalign = ('global', 'left', 'center') + headers = ['h'] + headersalign = ('center', 'right', 'same') + result = tabulate(table, headers=headers, colalign=colalign, headersalign=headersalign) + expected = '\n'.join([ + " h", + "--- ---", + " 1 2", + "111 222"]) + assert_equal(expected, result) + +def test_warning_when_colalign_or_headersalign_is_string(): + """ Test user warnings when `colalign` or `headersalign` is a string. """ + table = [[1,"bar"]] + opt = { + 'colalign': "center", + 'headers': ['foo', '2'], + 'headersalign': "center"} + check_warnings((tabulate, [table], opt), + num = 2, + category = UserWarning, + contain = "As a string") def test_float_conversions(): "Output: float format parsed" From fd3dc7ac9c403c848460f0d0cbd21b9a50cb870e Mon Sep 17 00:00:00 2001 From: eliegoudout <114467748+eliegoudout@users.noreply.github.com> Date: Mon, 28 Nov 2022 11:07:10 +0100 Subject: [PATCH 12/90] Updated README and CHANGELOG --- CHANGELOG | 105 ++++++++++++++++++++++++++++-------------------------- README.md | 35 ++++++++++++------ 2 files changed, 78 insertions(+), 62 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index a18a5a1..23c190d 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,51 +1,54 @@ -- 0.9.1: Future version. -- 0.9.0: Drop support for Python 2.7, 3.5, 3.6. - Migrate to pyproject.toml project layout (PEP 621). - New output formats: `asciidoc`, various `*grid` and `*outline` formats. - New output features: vertical row alignment, separating lines. - New input format: list of dataclasses (Python 3.7 or later). - Support infinite iterables as row indices. - Improve column width options. - Improve support for ANSI escape sequences and document the behavior. - Various bug fixes. -- 0.8.10: Python 3.10 support. Bug fixes. Column width parameter. -- 0.8.9: Bug fix. Revert support of decimal separators. -- 0.8.8: Python 3.9 support, 3.10 ready. - New formats: ``unsafehtml``, ``latex_longtable``, ``fancy_outline``. - Support lists of UserDicts as input. - Support hyperlinks in terminal output. - Improve testing on systems with proxies. - Migrate to pytest. - Various bug fixes and improvements. -- 0.8.7: Bug fixes. New format: `pretty`. HTML escaping. -- 0.8.6: Bug fixes. Stop supporting Python 3.3, 3.4. -- 0.8.5: Fix broken Windows package. Minor documentation updates. -- 0.8.4: Bug fixes. -- 0.8.3: New formats: `github`. Custom column alignment. Bug fixes. -- 0.8.2: Bug fixes. -- 0.8.1: Multiline data in several output formats. - New ``latex_raw`` format. - Column-specific floating point formatting. - Python 3.5 & 3.6 support. Drop support for Python 2.6, 3.2, 3.3 (should still work). -- 0.7.7: Identical to 0.7.6, resolving some PyPI issues. -- 0.7.6: Bug fixes. New table formats (``psql``, ``jira``, ``moinmoin``, ``textile``). - Wide character support. Printing from database cursors. - Option to print row indices. Boolean columns. Ragged rows. - Option to disable number parsing. -- 0.7.5: Bug fixes. ``--float`` format option for the command line utility. -- 0.7.4: Bug fixes. ``fancy_grid`` and ``html`` formats. Command line utility. -- 0.7.3: Bug fixes. Python 3.4 support. Iterables of dicts. ``latex_booktabs`` format. -- 0.7.2: Python 3.2 support. -- 0.7.1: Bug fixes. ``tsv`` format. Column alignment can be disabled. -- 0.7: ``latex`` tables. Printing lists of named tuples and NumPy - record arrays. Fix printing date and time values. Python <= 2.6.4 is supported. -- 0.6: ``mediawiki`` tables, bug fixes. -- 0.5.1: Fix README.rst formatting. Optimize (performance similar to 0.4.4). -- 0.5: ANSI color sequences. Printing dicts of iterables and Pandas' dataframes. -- 0.4.4: Python 2.6 support. -- 0.4.3: Bug fix, None as a missing value. -- 0.4.2: Fix manifest file. -- 0.4.1: Update license and documentation. -- 0.4: Unicode support, Python3 support, ``rst`` tables. -- 0.3: Initial PyPI release. Table formats: ``simple``, ``plain``, - ``grid``, ``pipe``, and ``orgtbl``. +- 0.9.2: Future version. +- 0.9.1: Add headers alignment with `headersglobalalign` and `headersalign`. + Enhance column alignment: add `colglobalalign` and bug fix when `colalign` too long. + Better warning when `colalign` or `headersalign` is a `string`. +- 0.9.0: Drop support for Python 2.7, 3.5, 3.6. + Migrate to pyproject.toml project layout (PEP 621). + New output formats: `asciidoc`, various `*grid` and `*outline` formats. + New output features: vertical row alignment, separating lines. + New input format: list of dataclasses (Python 3.7 or later). + Support infinite iterables as row indices. + Improve column width options. + Improve support for ANSI escape sequences and document the behavior. + Various bug fixes. +- 0.8.10: Python 3.10 support. Bug fixes. Column width parameter. +- 0.8.9: Bug fix. Revert support of decimal separators. +- 0.8.8: Python 3.9 support, 3.10 ready. + New formats: ``unsafehtml``, ``latex_longtable``, ``fancy_outline``. + Support lists of UserDicts as input. + Support hyperlinks in terminal output. + Improve testing on systems with proxies. + Migrate to pytest. + Various bug fixes and improvements. +- 0.8.7: Bug fixes. New format: `pretty`. HTML escaping. +- 0.8.6: Bug fixes. Stop supporting Python 3.3, 3.4. +- 0.8.5: Fix broken Windows package. Minor documentation updates. +- 0.8.4: Bug fixes. +- 0.8.3: New formats: `github`. Custom column alignment. Bug fixes. +- 0.8.2: Bug fixes. +- 0.8.1: Multiline data in several output formats. + New ``latex_raw`` format. + Column-specific floating point formatting. + Python 3.5 & 3.6 support. Drop support for Python 2.6, 3.2, 3.3 (should still work). +- 0.7.7: Identical to 0.7.6, resolving some PyPI issues. +- 0.7.6: Bug fixes. New table formats (``psql``, ``jira``, ``moinmoin``, ``textile``). + Wide character support. Printing from database cursors. + Option to print row indices. Boolean columns. Ragged rows. + Option to disable number parsing. +- 0.7.5: Bug fixes. ``--float`` format option for the command line utility. +- 0.7.4: Bug fixes. ``fancy_grid`` and ``html`` formats. Command line utility. +- 0.7.3: Bug fixes. Python 3.4 support. Iterables of dicts. ``latex_booktabs`` format. +- 0.7.2: Python 3.2 support. +- 0.7.1: Bug fixes. ``tsv`` format. Column alignment can be disabled. +- 0.7: ``latex`` tables. Printing lists of named tuples and NumPy + record arrays. Fix printing date and time values. Python <= 2.6.4 is supported. +- 0.6: ``mediawiki`` tables, bug fixes. +- 0.5.1: Fix README.rst formatting. Optimize (performance similar to 0.4.4). +- 0.5: ANSI color sequences. Printing dicts of iterables and Pandas' dataframes. +- 0.4.4: Python 2.6 support. +- 0.4.3: Bug fix, None as a missing value. +- 0.4.2: Fix manifest file. +- 0.4.1: Update license and documentation. +- 0.4: Unicode support, Python3 support, ``rst`` tables. +- 0.3: Initial PyPI release. Table formats: ``simple``, ``plain``, + ``grid``, ``pipe``, and ``orgtbl``. diff --git a/README.md b/README.md index d64b99a..07ab28c 100644 --- a/README.md +++ b/README.md @@ -666,18 +666,31 @@ Ver2 19.2 ### Custom column alignment -`tabulate` allows a custom column alignment to override the above. The -`colalign` argument can be a list or a tuple of `stralign` named -arguments. Possible column alignments are: `right`, `center`, `left`, -`decimal` (only for numbers), and `None` (to disable alignment). -Omitting an alignment uses the default. For example: +`tabulate` allows a custom column alignment to override the smart alignment described above. +Use `colglobalalign` to define a global setting. Possible alignments are: `right`, `center`, `left`, `decimal` (only for numbers). +Furthermore, you can define `colalign` for column-specific alignment as a list or a tuple. Possible values are `global` (keeps global setting), `right`, `center`, `left`, `decimal` (only for numbers), `None` (to disable alignment). Missing alignments are treated as `global`. ```pycon ->>> print(tabulate([["one", "two"], ["three", "four"]], colalign=("right",)) ------ ---- - one two -three four ------ ---- +>>> print(tabulate([[1,2,3,4],[111,222,333,444]], colglobalalign='center', colalign = ('global','left','right'))) +--- --- --- --- + 1 2 3 4 +111 222 333 444 +--- --- --- --- +``` + +### Custom header alignment + +Headers' alignment can be defined separately from columns'. Like for columns, you can use: +- `headersglobalalign` to define a header-specific global alignment setting. Possible values are `right`, `center`, `left`, `None` (to follow column alignment), +- `headersalign` list or tuple to further specify header-wise alignment. Possible values are `global` (keeps global setting), `same` (follow column alignment), `right`, `center`, `left`, `None` (to disable alignment). Missing alignments are treated as `global`. + +```pycon +>>> print(tabulate([[1,2,3,4,5,6],[111,222,333,444,555,666]], colglobalalign = 'center', colalign = ('left',), headers = ['h','e','a','d','e','r'], headersglobalalign = 'right', headersalign = ('same','same','left','global','center'))) + +h e a d e r +--- --- --- --- --- --- +1 2 3 4 5 6 +111 222 333 444 555 666 ``` ### Number formatting @@ -1123,5 +1136,5 @@ Bart Broere, Vilhelm Prytz, Alexander Gažo, Hugo van Kemenade, jamescooke, Matt Warner, Jérôme Provensal, Kevin Deldycke, Kian-Meng Ang, Kevin Patterson, Shodhan Save, cleoold, KOLANICH, Vijaya Krishna Kasula, Furcy Pin, Christian Fibich, Shaun Duncan, -Dimitri Papadopoulos. +Dimitri Papadopoulos, Élie Goudout. From c1f91dd9c3dbb17802148ede01f0cebb9a5941ff Mon Sep 17 00:00:00 2001 From: eliegoudout <114467748+eliegoudout@users.noreply.github.com> Date: Mon, 28 Nov 2022 11:41:12 +0100 Subject: [PATCH 13/90] Revert CHANGELOG mod, not my place to update it --- CHANGELOG | 105 ++++++++++++++++++++++++++---------------------------- 1 file changed, 51 insertions(+), 54 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 23c190d..a18a5a1 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,54 +1,51 @@ -- 0.9.2: Future version. -- 0.9.1: Add headers alignment with `headersglobalalign` and `headersalign`. - Enhance column alignment: add `colglobalalign` and bug fix when `colalign` too long. - Better warning when `colalign` or `headersalign` is a `string`. -- 0.9.0: Drop support for Python 2.7, 3.5, 3.6. - Migrate to pyproject.toml project layout (PEP 621). - New output formats: `asciidoc`, various `*grid` and `*outline` formats. - New output features: vertical row alignment, separating lines. - New input format: list of dataclasses (Python 3.7 or later). - Support infinite iterables as row indices. - Improve column width options. - Improve support for ANSI escape sequences and document the behavior. - Various bug fixes. -- 0.8.10: Python 3.10 support. Bug fixes. Column width parameter. -- 0.8.9: Bug fix. Revert support of decimal separators. -- 0.8.8: Python 3.9 support, 3.10 ready. - New formats: ``unsafehtml``, ``latex_longtable``, ``fancy_outline``. - Support lists of UserDicts as input. - Support hyperlinks in terminal output. - Improve testing on systems with proxies. - Migrate to pytest. - Various bug fixes and improvements. -- 0.8.7: Bug fixes. New format: `pretty`. HTML escaping. -- 0.8.6: Bug fixes. Stop supporting Python 3.3, 3.4. -- 0.8.5: Fix broken Windows package. Minor documentation updates. -- 0.8.4: Bug fixes. -- 0.8.3: New formats: `github`. Custom column alignment. Bug fixes. -- 0.8.2: Bug fixes. -- 0.8.1: Multiline data in several output formats. - New ``latex_raw`` format. - Column-specific floating point formatting. - Python 3.5 & 3.6 support. Drop support for Python 2.6, 3.2, 3.3 (should still work). -- 0.7.7: Identical to 0.7.6, resolving some PyPI issues. -- 0.7.6: Bug fixes. New table formats (``psql``, ``jira``, ``moinmoin``, ``textile``). - Wide character support. Printing from database cursors. - Option to print row indices. Boolean columns. Ragged rows. - Option to disable number parsing. -- 0.7.5: Bug fixes. ``--float`` format option for the command line utility. -- 0.7.4: Bug fixes. ``fancy_grid`` and ``html`` formats. Command line utility. -- 0.7.3: Bug fixes. Python 3.4 support. Iterables of dicts. ``latex_booktabs`` format. -- 0.7.2: Python 3.2 support. -- 0.7.1: Bug fixes. ``tsv`` format. Column alignment can be disabled. -- 0.7: ``latex`` tables. Printing lists of named tuples and NumPy - record arrays. Fix printing date and time values. Python <= 2.6.4 is supported. -- 0.6: ``mediawiki`` tables, bug fixes. -- 0.5.1: Fix README.rst formatting. Optimize (performance similar to 0.4.4). -- 0.5: ANSI color sequences. Printing dicts of iterables and Pandas' dataframes. -- 0.4.4: Python 2.6 support. -- 0.4.3: Bug fix, None as a missing value. -- 0.4.2: Fix manifest file. -- 0.4.1: Update license and documentation. -- 0.4: Unicode support, Python3 support, ``rst`` tables. -- 0.3: Initial PyPI release. Table formats: ``simple``, ``plain``, - ``grid``, ``pipe``, and ``orgtbl``. +- 0.9.1: Future version. +- 0.9.0: Drop support for Python 2.7, 3.5, 3.6. + Migrate to pyproject.toml project layout (PEP 621). + New output formats: `asciidoc`, various `*grid` and `*outline` formats. + New output features: vertical row alignment, separating lines. + New input format: list of dataclasses (Python 3.7 or later). + Support infinite iterables as row indices. + Improve column width options. + Improve support for ANSI escape sequences and document the behavior. + Various bug fixes. +- 0.8.10: Python 3.10 support. Bug fixes. Column width parameter. +- 0.8.9: Bug fix. Revert support of decimal separators. +- 0.8.8: Python 3.9 support, 3.10 ready. + New formats: ``unsafehtml``, ``latex_longtable``, ``fancy_outline``. + Support lists of UserDicts as input. + Support hyperlinks in terminal output. + Improve testing on systems with proxies. + Migrate to pytest. + Various bug fixes and improvements. +- 0.8.7: Bug fixes. New format: `pretty`. HTML escaping. +- 0.8.6: Bug fixes. Stop supporting Python 3.3, 3.4. +- 0.8.5: Fix broken Windows package. Minor documentation updates. +- 0.8.4: Bug fixes. +- 0.8.3: New formats: `github`. Custom column alignment. Bug fixes. +- 0.8.2: Bug fixes. +- 0.8.1: Multiline data in several output formats. + New ``latex_raw`` format. + Column-specific floating point formatting. + Python 3.5 & 3.6 support. Drop support for Python 2.6, 3.2, 3.3 (should still work). +- 0.7.7: Identical to 0.7.6, resolving some PyPI issues. +- 0.7.6: Bug fixes. New table formats (``psql``, ``jira``, ``moinmoin``, ``textile``). + Wide character support. Printing from database cursors. + Option to print row indices. Boolean columns. Ragged rows. + Option to disable number parsing. +- 0.7.5: Bug fixes. ``--float`` format option for the command line utility. +- 0.7.4: Bug fixes. ``fancy_grid`` and ``html`` formats. Command line utility. +- 0.7.3: Bug fixes. Python 3.4 support. Iterables of dicts. ``latex_booktabs`` format. +- 0.7.2: Python 3.2 support. +- 0.7.1: Bug fixes. ``tsv`` format. Column alignment can be disabled. +- 0.7: ``latex`` tables. Printing lists of named tuples and NumPy + record arrays. Fix printing date and time values. Python <= 2.6.4 is supported. +- 0.6: ``mediawiki`` tables, bug fixes. +- 0.5.1: Fix README.rst formatting. Optimize (performance similar to 0.4.4). +- 0.5: ANSI color sequences. Printing dicts of iterables and Pandas' dataframes. +- 0.4.4: Python 2.6 support. +- 0.4.3: Bug fix, None as a missing value. +- 0.4.2: Fix manifest file. +- 0.4.1: Update license and documentation. +- 0.4: Unicode support, Python3 support, ``rst`` tables. +- 0.3: Initial PyPI release. Table formats: ``simple``, ``plain``, + ``grid``, ``pipe``, and ``orgtbl``. From b50e9ce3bc3087d12821ac89b1cfa917c8877b68 Mon Sep 17 00:00:00 2001 From: Phill Zarfos Date: Thu, 22 Dec 2022 16:42:58 -0500 Subject: [PATCH 14/90] added regression test for maxcolwidths doesn't accept tuple issue --- test/test_regression.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/test/test_regression.py b/test/test_regression.py index 8f60ce7..29f275e 100644 --- a/test/test_regression.py +++ b/test/test_regression.py @@ -488,6 +488,27 @@ def test_preserve_line_breaks_with_maxcolwidths(): assert_equal(expected, result) +def test_maxcolwidths_accepts_list_or_tuple(): + "Regression: maxcolwidths can accept a list or a tuple (github issue #214)" + table = [["lorem ipsum dolor sit amet"]*3] + expected = "\n".join( + [ + "+-------------+----------+----------------------------+", + "| lorem ipsum | lorem | lorem ipsum dolor sit amet |", + "| dolor sit | ipsum | |", + "| amet | dolor | |", + "| | sit amet | |", + "+-------------+----------+----------------------------+", + ] + ) + # test with maxcolwidths as a list + result = tabulate(table, tablefmt="grid", maxcolwidths=[12, 8]) + assert_equal(expected, result) + # test with maxcolwidths as a tuple + result = tabulate(table, tablefmt="grid", maxcolwidths=(12, 8)) + assert_equal(expected, result) + + def test_exception_on_empty_data_with_maxcolwidths(): "Regression: exception on empty data when using maxcolwidths (github issue #180)" result = tabulate([], maxcolwidths=5) From af40a322d6b889c9c1c2e5161a004c389401e754 Mon Sep 17 00:00:00 2001 From: jerome provensal Date: Mon, 26 Dec 2022 19:20:01 -0800 Subject: [PATCH 15/90] Fix bug/issue #231: SEPARATING_LINE feature doesn't work when the requested format pads columns --- tabulate/__init__.py | 9 +++++++-- test/test_output.py | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 3b1a1e1..bf2ed78 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -101,12 +101,17 @@ def _is_file(f): ) +def _is_separating_line_value(value): + return type(value) == str and value.strip() == SEPARATING_LINE + + def _is_separating_line(row): row_type = type(row) is_sl = (row_type == list or row_type == str) and ( - (len(row) >= 1 and row[0] == SEPARATING_LINE) - or (len(row) >= 2 and row[1] == SEPARATING_LINE) + (len(row) >= 1 and _is_separating_line_value(row[0])) + or (len(row) >= 2 and _is_separating_line_value(row[1])) ) + return is_sl diff --git a/test/test_output.py b/test/test_output.py index 9043aed..b58b187 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -2881,6 +2881,27 @@ def test_list_of_lists_with_index_with_sep_line(): assert_equal(expected, result) +def test_with_padded_columns_with_sep_line(): + table = [ + ["1", "one"], # "1" as a str on purpose + [1_000, "one K"], + SEPARATING_LINE, + [1_000_000, "one M"], + ] + expected = "\n".join( + [ + "+---------+-------+", + "| 1 | one |", + "| 1000 | one K |", + "|---------+-------|", + "| 1000000 | one M |", + "+---------+-------+", + ] + ) + result = tabulate(table, tablefmt="psql") + assert_equal(expected, result) + + def test_list_of_lists_with_supplied_index(): "Output: a table with a supplied index" dd = zip(*[list(range(3)), list(range(101, 104))]) From 96761ee53c01418cc347ae43deb829c5bcd6cd28 Mon Sep 17 00:00:00 2001 From: I Bo Date: Wed, 28 Dec 2022 16:52:04 +0300 Subject: [PATCH 16/90] Fix #124. Improve error reporting when non-iterable data provided. --- tabulate/__init__.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 3b1a1e1..0dbea61 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1357,15 +1357,24 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): is_headers2bool_broken = True # noqa headers = list(headers) + err_msg = ( + "\n\nTo build a table python-tabulate requires two-dimensional data " + "like a list of lists or similar." + "\nDid you forget a pair of extra [] or ',' in ()?" + ) index = None if hasattr(tabular_data, "keys") and hasattr(tabular_data, "values"): # dict-like and pandas.DataFrame? if hasattr(tabular_data.values, "__call__"): # likely a conventional dict keys = tabular_data.keys() - rows = list( - izip_longest(*tabular_data.values()) - ) # columns have to be transposed + try: + rows = list( + izip_longest(*tabular_data.values()) + ) # columns have to be transposed + except TypeError: # not iterable + raise TypeError(err_msg) + elif hasattr(tabular_data, "index"): # values is a property, has .index => it's likely a pandas.DataFrame (pandas 0.11.0) keys = list(tabular_data) @@ -1388,7 +1397,10 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): headers = list(map(str, keys)) # headers should be strings else: # it's a usual iterable of iterables, or a NumPy array, or an iterable of dataclasses - rows = list(tabular_data) + try: + rows = list(tabular_data) + except TypeError: # not iterable + raise TypeError(err_msg) if headers == "keys" and not rows: # an empty table (issue #81) From 72ac2ab05cbe6b7a56d6f456e07ed57bd3648483 Mon Sep 17 00:00:00 2001 From: I Bo Date: Thu, 29 Dec 2022 16:18:47 +0300 Subject: [PATCH 17/90] Fix #207. Update README: headers for dictionaries. --- README.md | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d64b99a..ea8df18 100644 --- a/README.md +++ b/README.md @@ -121,10 +121,22 @@ dictionaries or named tuples: ```pycon >>> print(tabulate({"Name": ["Alice", "Bob"], ... "Age": [24, 19]}, headers="keys")) - Age Name ------ ------ - 24 Alice - 19 Bob +Name Age +------ ----- +Alice 24 +Bob 19 +``` + +When data is a list of dictionaries, a dictionary can be passed as `headers` +to replace the keys with other column labels: + +```pycon +>>> print(tabulate([{1: "Alice", 2: 24}, {1: "Bob", 2: 19}], +... headers={1: "Name", 2: "Age"})) +Name Age +------ ----- +Alice 24 +Bob 19 ``` ### Row Indices From 475d0da54b4c1e40c207fbdb022e5e8c15dadf20 Mon Sep 17 00:00:00 2001 From: Arpit Jain <3242828+arpitjain099@users.noreply.github.com> Date: Sat, 29 Apr 2023 17:58:38 +0900 Subject: [PATCH 18/90] Update common.py --- test/common.py | 1 - 1 file changed, 1 deletion(-) diff --git a/test/common.py b/test/common.py index d95e84f..e331e98 100644 --- a/test/common.py +++ b/test/common.py @@ -1,7 +1,6 @@ import pytest # noqa from pytest import skip, raises # noqa - def assert_equal(expected, result): print("Expected:\n%s\n" % expected) print("Got:\n%s\n" % result) From a8672a525e4144840120f275fcafc791728f5e8a Mon Sep 17 00:00:00 2001 From: Arpit Jain Date: Sat, 29 Apr 2023 18:10:53 +0900 Subject: [PATCH 19/90] add codeql file --- test/common.py | 1 - 1 file changed, 1 deletion(-) diff --git a/test/common.py b/test/common.py index e331e98..8cf09cd 100644 --- a/test/common.py +++ b/test/common.py @@ -6,7 +6,6 @@ def assert_equal(expected, result): print("Got:\n%s\n" % result) assert expected == result - def assert_in(result, expected_set): nums = range(1, len(expected_set) + 1) for i, expected in zip(nums, expected_set): From d38740ca77a3b8efed9f8b3fcc82e121bde84205 Mon Sep 17 00:00:00 2001 From: Arpit Jain Date: Sat, 29 Apr 2023 18:16:05 +0900 Subject: [PATCH 20/90] move libraries --- tabulate/__init__.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 3b1a1e1..872e863 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -10,6 +10,7 @@ import math import textwrap import dataclasses +import sys try: import wcwidth # optional wide-character (CJK) support @@ -1352,9 +1353,7 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): try: bool(headers) - is_headers2bool_broken = False # noqa except ValueError: # numpy.ndarray, pandas.core.index.Index, ... - is_headers2bool_broken = True # noqa headers = list(headers) index = None @@ -2646,8 +2645,6 @@ def _main(): (default: simple) """ import getopt - import sys - import textwrap usage = textwrap.dedent(_main.__doc__) try: From b1ed1fda6f62b9069ef7d5871925399159d9dfc9 Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Thu, 20 Jul 2023 14:50:40 -0600 Subject: [PATCH 21/90] Fix support for separating lines --- tabulate/__init__.py | 13 ++++++------- test/test_output.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 11bb865..7bdeabd 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -2414,7 +2414,6 @@ def _format_table(fmt, headers, headersaligns, rows, colwidths, colaligns, is_mu append_row = _append_basic_row padded_headers = pad_row(headers, pad) - padded_rows = [pad_row(row, pad) for row in rows] if fmt.lineabove and "lineabove" not in hidden: _append_line(lines, padded_widths, colaligns, fmt.lineabove) @@ -2424,17 +2423,17 @@ def _format_table(fmt, headers, headersaligns, rows, colwidths, colaligns, is_mu if fmt.linebelowheader and "linebelowheader" not in hidden: _append_line(lines, padded_widths, colaligns, fmt.linebelowheader) - if padded_rows and fmt.linebetweenrows and "linebetweenrows" not in hidden: + if rows and fmt.linebetweenrows and "linebetweenrows" not in hidden: # initial rows with a line below - for row, ralign in zip(padded_rows[:-1], rowaligns): + for row, ralign in zip(rows[:-1], rowaligns): append_row( - lines, row, padded_widths, colaligns, fmt.datarow, rowalign=ralign + lines, pad_row(row, pad), padded_widths, colaligns, fmt.datarow, rowalign=ralign ) _append_line(lines, padded_widths, colaligns, fmt.linebetweenrows) # the last row without a line below append_row( lines, - padded_rows[-1], + pad_row(rows[-1], pad), padded_widths, colaligns, fmt.datarow, @@ -2448,13 +2447,13 @@ def _format_table(fmt, headers, headersaligns, rows, colwidths, colaligns, is_mu or fmt.lineabove or Line("", "", "", "") ) - for row in padded_rows: + for row in rows: # test to see if either the 1st column or the 2nd column (account for showindex) has # the SEPARATING_LINE flag if _is_separating_line(row): _append_line(lines, padded_widths, colaligns, separating_line) else: - append_row(lines, row, padded_widths, colaligns, fmt.datarow) + append_row(lines, pad_row(row, pad), padded_widths, colaligns, fmt.datarow) if fmt.linebelow and "linebelow" not in hidden: _append_line(lines, padded_widths, colaligns, fmt.linebelow) diff --git a/test/test_output.py b/test/test_output.py index d572498..c6e0c4b 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -257,6 +257,21 @@ def test_simple_with_sep_line(): assert_equal(expected, result) +def test_orgtbl_with_sep_line(): + "Output: orgtbl with headers and separating line" + expected = "\n".join( + [ + "| strings | numbers |", + "|-----------+-----------|", + "| spam | 41.9999 |", + "|-----------+-----------|", + "| eggs | 451 |", + ] + ) + result = tabulate(_test_table_with_sep_line, _test_table_headers, tablefmt="orgtbl") + assert_equal(expected, result) + + def test_readme_example_with_sep(): table = [["Earth", 6371], ["Mars", 3390], SEPARATING_LINE, ["Moon", 1737]] expected = "\n".join( @@ -311,6 +326,28 @@ def test_simple_multiline_2_with_sep_line(): assert_equal(expected, result) +def test_orgtbl_multiline_2_with_sep_line(): + "Output: simple with multiline cells" + expected = "\n".join( + [ + "| key | value |", + "|-------+-----------|", + "| foo | bar |", + "|-------+-----------|", + "| spam | multiline |", + "| | world |", + ] + ) + table = [ + ["key", "value"], + ["foo", "bar"], + SEPARATING_LINE, + ["spam", "multiline\nworld"], + ] + result = tabulate(table, headers="firstrow", stralign="center", tablefmt="orgtbl") + assert_equal(expected, result) + + def test_simple_headerless(): "Output: simple without headers" expected = "\n".join( From 6ea2a05552352ea78247ee791e1cf0d8574c13ef Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Wed, 11 Jan 2023 12:10:01 +0100 Subject: [PATCH 22/90] No need to import Unicode from future in Python 3 --- test/test_textwrapper.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/test_textwrapper.py b/test/test_textwrapper.py index f3070b1..88420be 100644 --- a/test/test_textwrapper.py +++ b/test/test_textwrapper.py @@ -1,6 +1,4 @@ """Discretely test functionality of our custom TextWrapper""" -from __future__ import unicode_literals - import datetime from tabulate import _CustomTextWrap as CTW, tabulate From f078669f38e14a44ea85f725e226f7ec7f3093a8 Mon Sep 17 00:00:00 2001 From: Phill Zarfos Date: Sat, 21 Oct 2023 12:48:26 -0400 Subject: [PATCH 23/90] fix merge conflict in README.md file --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3996f7d..0feb58a 100644 --- a/README.md +++ b/README.md @@ -1123,5 +1123,5 @@ Bart Broere, Vilhelm Prytz, Alexander Gažo, Hugo van Kemenade, jamescooke, Matt Warner, Jérôme Provensal, Kevin Deldycke, Kian-Meng Ang, Kevin Patterson, Shodhan Save, cleoold, KOLANICH, Vijaya Krishna Kasula, Furcy Pin, Christian Fibich, Shaun Duncan, -Dimitri Papadopoulos, Racerroar. +Dimitri Papadopoulos, Élie Goudout, Racerroar, Phill Zarfos. From 4a8778008b984b93762f2b6f4cefb9443905a798 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Thu, 21 Dec 2023 12:12:43 +0200 Subject: [PATCH 24/90] Add support for Python 3.12 --- appveyor.yml | 1 + tox.ini | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/appveyor.yml b/appveyor.yml index 4eb2dd8..318a1d6 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -16,6 +16,7 @@ environment: - PYTHON: "C:\\Python39-x64" - PYTHON: "C:\\Python310-x64" - PYTHON: "C:\\Python311-x64" + - PYTHON: "C:\\Python312-x64" install: # Newer setuptools is needed for proper support of pyproject.toml diff --git a/tox.ini b/tox.ini index c6260d2..4c4fe06 100644 --- a/tox.ini +++ b/tox.ini @@ -8,7 +8,7 @@ # for testing and it is disabled by default. [tox] -envlist = lint, py{37, 38, 39, 310, 311} +envlist = lint, py{37, 38, 39, 310, 311, 312} isolated_build = True [testenv] @@ -105,6 +105,21 @@ deps = pandas wcwidth +[testenv:py312] +basepython = python3.12 +commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} +deps = + pytest + +[testenv:py312-extra] +basepython = python3.12 +setenv = PYTHONDEVMODE = 1 +commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} +deps = + pytest + numpy + pandas + wcwidth [flake8] max-complexity = 22 From 733be92058815b4295d8ed5858fa14d7b0aad890 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Thu, 21 Dec 2023 12:18:54 +0200 Subject: [PATCH 25/90] Drop support for EOL Python 3.7 --- HOWTOPUBLISH | 2 +- README.md | 16 ++++++++-------- appveyor.yml | 9 +-------- pyproject.toml | 3 +-- tabulate/__init__.py | 6 +++--- tox.ini | 17 +---------------- 6 files changed, 15 insertions(+), 38 deletions(-) diff --git a/HOWTOPUBLISH b/HOWTOPUBLISH index 5be16ed..795fc73 100644 --- a/HOWTOPUBLISH +++ b/HOWTOPUBLISH @@ -1,7 +1,7 @@ # update contributors and CHANGELOG in README # tag version release python3 benchmark.py # then update README -tox -e py37-extra,py38-extra,py39-extra,py310-extra +tox -e py38-extra,py39-extra,py310-extra,py311-extra,py312-extra python3 -m build -nswx . twine upload --repository-url https://test.pypi.org/legacy/ dist/* twine upload dist/* diff --git a/README.md b/README.md index 07ab28c..944a260 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ 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 (Python 3.7+ only, field names as columns) +- list of dataclasses (field names as columns) - two-dimensional NumPy array - NumPy record arrays (names as columns) - pandas.DataFrame @@ -1074,14 +1074,14 @@ To run tests on all supported Python versions, make sure all Python interpreters, `pytest` and `tox` are installed, then run `tox` in the root of the project source tree. -On Linux `tox` expects to find executables like `python3.7`, `python3.8` etc. -On Windows it looks for `C:\Python37\python.exe`, `C:\Python38\python.exe` etc. respectively. +On Linux `tox` expects to find executables like `python3.11`, `python3.12` etc. +On Windows it looks for `C:\Python311\python.exe`, `C:\Python312\python.exe` etc. respectively. One way to install all the required versions of the Python interpreter is to use [pyenv](https://github.com/pyenv/pyenv). All versions can then be easily installed with something like: - pyenv install 3.7.12 - pyenv install 3.8.12 + pyenv install 3.11.7 + pyenv install 3.12.1 ... Don't forget to change your `PATH` so that `tox` knows how to find all the installed versions. Something like @@ -1089,10 +1089,10 @@ Don't forget to change your `PATH` so that `tox` knows how to find all the insta export PATH="${PATH}:${HOME}/.pyenv/shims" To test only some Python environments, use `-e` option. For example, to -test only against Python 3.7 and Python 3.10, run: +test only against Python 3.11 and Python 3.12, run: ```shell -tox -e py37,py310 +tox -e py311,py312 ``` in the root of the project source tree. @@ -1100,7 +1100,7 @@ in the root of the project source tree. To enable NumPy and Pandas tests, run: ```shell -tox -e py37-extra,py310-extra +tox -e py311-extra,py312-extra ``` (this may take a long time the first time, because NumPy and Pandas will diff --git a/appveyor.yml b/appveyor.yml index 318a1d6..d36b8c9 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -8,10 +8,8 @@ environment: # The list here is complete (excluding Python 2.6, which # isn't covered by this document) at the time of writing. - - PYTHON: "C:\\Python37" - PYTHON: "C:\\Python38" - PYTHON: "C:\\Python39" - - PYTHON: "C:\\Python37-x64" - PYTHON: "C:\\Python38-x64" - PYTHON: "C:\\Python39-x64" - PYTHON: "C:\\Python310-x64" @@ -30,9 +28,6 @@ build: off test_script: # Put your test command here. - # If you don't need to build C extensions on 64-bit Python 3.3 or 3.4, - # you can remove "build.cmd" from the front of the command, as it's - # only needed to support those cases. # Note that you must use the environment variable %PYTHON% to refer to # the interpreter you're using - Appveyor does not do anything special # to put the Python version you want to use on PATH. @@ -41,9 +36,7 @@ test_script: after_test: # This step builds your wheels. - # Again, you only need build.cmd if you're building C extensions for - # 64-bit Python 3.3/3.4. And you need to use %PYTHON% to get the correct - # interpreter + # Again, you need to use %PYTHON% to get the correct interpreter #- "build.cmd %PYTHON%\\python.exe setup.py bdist_wheel" - "%PYTHON%\\python.exe -m build -nswx ." diff --git a/pyproject.toml b/pyproject.toml index 5a8c1fd..837747d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,14 +13,13 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Topic :: Software Development :: Libraries", ] -requires-python = ">=3.7" +requires-python = ">=3.8" dynamic = ["version"] [project.urls] diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 11bb865..dbbe45d 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1331,7 +1331,7 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): * list of OrderedDicts (usually used with headers="keys") - * list of dataclasses (Python 3.7+ only, usually used with headers="keys") + * list of dataclasses (usually used with headers="keys") * 2D NumPy arrays @@ -1457,7 +1457,7 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): and len(rows) > 0 and dataclasses.is_dataclass(rows[0]) ): - # Python 3.7+'s dataclass + # Python's dataclass field_names = [field.name for field in dataclasses.fields(rows[0])] if headers == "keys": headers = field_names @@ -1600,7 +1600,7 @@ def tabulate( The first required argument (`tabular_data`) can be a list-of-lists (or another iterable of iterables), a list of named tuples, a dictionary of iterables, an iterable of dictionaries, - an iterable of dataclasses (Python 3.7+), a two-dimensional NumPy array, + an iterable of dataclasses, a two-dimensional NumPy array, NumPy record array, or a Pandas' dataframe. diff --git a/tox.ini b/tox.ini index 4c4fe06..7fd65a7 100644 --- a/tox.ini +++ b/tox.ini @@ -8,7 +8,7 @@ # for testing and it is disabled by default. [tox] -envlist = lint, py{37, 38, 39, 310, 311, 312} +envlist = lint, py{38, 39, 310, 311, 312} isolated_build = True [testenv] @@ -25,21 +25,6 @@ commands = python -m pre_commit run -a deps = pre-commit -[testenv:py37] -basepython = python3.7 -commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} -deps = - pytest - -[testenv:py37-extra] -basepython = python3.7 -commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} -deps = - pytest - numpy - pandas - wcwidth - [testenv:py38] basepython = python3.8 commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} From 36fb3088a85c0855a0985a2f2206ed872e0c71e1 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Thu, 21 Dec 2023 12:21:58 +0200 Subject: [PATCH 26/90] Fix Black and Flake8 --- tabulate/__init__.py | 41 +++++++++++++----- test/common.py | 3 +- test/test_output.py | 98 +++++++++++++++++++++++++------------------- 3 files changed, 87 insertions(+), 55 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index dbbe45d..4b4cf2c 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1271,7 +1271,7 @@ def _align_header( def _remove_separating_lines(rows): - if type(rows) == list: + if isinstance(rows, list): separating_lines = [] sans_rows = [] for index, row in enumerate(rows): @@ -1319,7 +1319,8 @@ def _bool(val): def _normalize_tabular_data(tabular_data, headers, showindex="default"): - """Transform a supported data type to a list of lists, and a list of headers, with headers padding. + """Transform a supported data type to a list of lists, and a list of headers, + with headers padding. Supported tabular data types: @@ -2202,15 +2203,19 @@ def tabulate( # align columns # first set global alignment - if colglobalalign is not None: # if global alignment provided + if colglobalalign is not None: # if global alignment provided aligns = [colglobalalign] * len(cols) - else: # default + 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): - warnings.warn(f"As a string, `colalign` is interpreted as {[c for c in colalign]}. Did you mean `colglobalalign = \"{colalign}\"` or `colalign = (\"{colalign}\",)`?", stacklevel=2) + warnings.warn( + f"As a string, `colalign` is interpreted as {[c for c in colalign]}. " + f'Did you mean `colglobalalign = "{colalign}"` or `colalign = ("{colalign}",)`?', + stacklevel=2, + ) for idx, align in enumerate(colalign): if not idx < len(aligns): break @@ -2229,20 +2234,25 @@ def tabulate( # align headers and add headers t_cols = cols or [[""]] * len(headers) # first set global alignment - if headersglobalalign is not None: # if global alignment provided + if headersglobalalign is not None: # if global alignment provided aligns_headers = [headersglobalalign] * len(t_cols) - else: # default + else: # default aligns_headers = aligns or [stralign] * len(headers) # then specific header alignements if headersalign is not None: assert isinstance(headersalign, Iterable) if isinstance(headersalign, str): - warnings.warn(f"As a string, `headersalign` is interpreted as {[c for c in headersalign]}. Did you mean `headersglobalalign = \"{headersalign}\"` or `headersalign = (\"{headersalign}\",)`?", stacklevel=2) + warnings.warn( + f"As a string, `headersalign` is interpreted as {[c for c in headersalign]}. " + f'Did you mean `headersglobalalign = "{headersalign}"` ' + f'or `headersalign = ("{headersalign}",)`?', + stacklevel=2, + ) for idx, align in enumerate(headersalign): hidx = headers_pad + idx if not hidx < len(aligns_headers): break - elif align == "same" and hidx < len(aligns): # same as column align + elif align == "same" and hidx < len(aligns): # same as column align aligns_headers[hidx] = aligns[hidx] elif align != "global": aligns_headers[hidx] = align @@ -2267,7 +2277,14 @@ def tabulate( _reinsert_separating_lines(rows, separating_lines) return _format_table( - tablefmt, headers, aligns_headers, rows, minwidths, aligns, is_multiline, rowaligns=rowaligns + tablefmt, + headers, + aligns_headers, + rows, + minwidths, + aligns, + is_multiline, + rowaligns=rowaligns, ) @@ -2398,7 +2415,9 @@ def str(self): return self -def _format_table(fmt, headers, headersaligns, rows, colwidths, colaligns, is_multiline, rowaligns): +def _format_table( + fmt, headers, headersaligns, rows, colwidths, colaligns, is_multiline, rowaligns +): """Produce a plain-text representation of the table.""" lines = [] hidden = fmt.with_header_hide if (headers and fmt.with_header_hide) else [] diff --git a/test/common.py b/test/common.py index 4cd3709..fe33259 100644 --- a/test/common.py +++ b/test/common.py @@ -2,6 +2,7 @@ from pytest import skip, raises # noqa import warnings + def assert_equal(expected, result): print("Expected:\n%s\n" % expected) print("Got:\n%s\n" % result) @@ -28,6 +29,7 @@ def rows_to_pipe_table_str(rows): return "\n".join(lines) + def check_warnings(func_args_kwargs, *, num=None, category=None, contain=None): func, args, kwargs = func_args_kwargs with warnings.catch_warnings(record=True) as W: @@ -41,4 +43,3 @@ def check_warnings(func_args_kwargs, *, num=None, category=None, contain=None): assert all([issubclass(w.category, category) for w in W]) if contain is not None: assert all([contain in str(w.message) for w in W]) - diff --git a/test/test_output.py b/test/test_output.py index d572498..8a60809 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -2680,60 +2680,72 @@ def test_colalign_multi_with_sep_line(): expected = " one two\n\nthree four" assert_equal(expected, result) + def test_column_global_and_specific_alignment(): - """ Test `colglobalalign` and `"global"` parameter for `colalign`. """ - table = [[1,2,3,4],[111,222,333,444]] - colglobalalign = 'center' - colalign = ('global','left', 'right') + """Test `colglobalalign` and `"global"` parameter for `colalign`.""" + table = [[1, 2, 3, 4], [111, 222, 333, 444]] + colglobalalign = "center" + colalign = ("global", "left", "right") result = tabulate(table, colglobalalign=colglobalalign, colalign=colalign) - expected = '\n'.join([ - "--- --- --- ---", - " 1 2 3 4", - "111 222 333 444", - "--- --- --- ---"]) + expected = "\n".join( + [ + "--- --- --- ---", + " 1 2 3 4", + "111 222 333 444", + "--- --- --- ---", + ] + ) assert_equal(expected, result) + def test_headers_global_and_specific_alignment(): - """ Test `headersglobalalign` and `headersalign`. """ - table = [[1,2,3,4,5,6],[111,222,333,444,555,666]] - colglobalalign = 'center' - colalign = ('left',) - headers = ['h', 'e', 'a', 'd', 'e', 'r'] - headersglobalalign = 'right' - headersalign = ('same', 'same', 'left', 'global', 'center') - result = tabulate(table, headers=headers, colglobalalign=colglobalalign, colalign=colalign, headersglobalalign=headersglobalalign, headersalign=headersalign) - expected = '\n'.join([ - "h e a d e r", - "--- --- --- --- --- ---", - "1 2 3 4 5 6", - "111 222 333 444 555 666"]) + """Test `headersglobalalign` and `headersalign`.""" + table = [[1, 2, 3, 4, 5, 6], [111, 222, 333, 444, 555, 666]] + colglobalalign = "center" + colalign = ("left",) + headers = ["h", "e", "a", "d", "e", "r"] + headersglobalalign = "right" + headersalign = ("same", "same", "left", "global", "center") + result = tabulate( + table, + headers=headers, + colglobalalign=colglobalalign, + colalign=colalign, + headersglobalalign=headersglobalalign, + headersalign=headersalign, + ) + expected = "\n".join( + [ + "h e a d e r", + "--- --- --- --- --- ---", + "1 2 3 4 5 6", + "111 222 333 444 555 666", + ] + ) assert_equal(expected, result) + def test_colalign_or_headersalign_too_long(): - """ Test `colalign` and `headersalign` too long. """ - table = [[1,2],[111,222]] - colalign = ('global', 'left', 'center') - headers = ['h'] - headersalign = ('center', 'right', 'same') - result = tabulate(table, headers=headers, colalign=colalign, headersalign=headersalign) - expected = '\n'.join([ - " h", - "--- ---", - " 1 2", - "111 222"]) + """Test `colalign` and `headersalign` too long.""" + table = [[1, 2], [111, 222]] + colalign = ("global", "left", "center") + headers = ["h"] + headersalign = ("center", "right", "same") + result = tabulate( + table, headers=headers, colalign=colalign, headersalign=headersalign + ) + expected = "\n".join([" h", "--- ---", " 1 2", "111 222"]) assert_equal(expected, result) + def test_warning_when_colalign_or_headersalign_is_string(): - """ Test user warnings when `colalign` or `headersalign` is a string. """ - table = [[1,"bar"]] - opt = { - 'colalign': "center", - 'headers': ['foo', '2'], - 'headersalign': "center"} - check_warnings((tabulate, [table], opt), - num = 2, - category = UserWarning, - contain = "As a string") + """Test user warnings when `colalign` or `headersalign` is a string.""" + table = [[1, "bar"]] + opt = {"colalign": "center", "headers": ["foo", "2"], "headersalign": "center"} + check_warnings( + (tabulate, [table], opt), num=2, category=UserWarning, contain="As a string" + ) + def test_float_conversions(): "Output: float format parsed" From a37d34af8ea72a6b3a5afb18862fe2dedb391690 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Thu, 21 Dec 2023 12:22:26 +0200 Subject: [PATCH 27/90] Upgrade Python syntax with pyupgrade --py38-plus --- tabulate/__init__.py | 4 ++-- test/test_input.py | 2 +- test/test_textwrapper.py | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 4b4cf2c..6adacde 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -224,7 +224,7 @@ def make_header_line(is_header, colwidths, colaligns): colwidths, [alignment[colalign] for colalign in colaligns] ) asciidoc_column_specifiers = [ - "{:d}{}".format(width, align) for width, align in asciidoc_alignments + f"{width:d}{align}" for width, align in asciidoc_alignments ] header_list = ['cols="' + (",".join(asciidoc_column_specifiers)) + '"'] @@ -1297,7 +1297,7 @@ def _prepend_row_index(rows, index): 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)) + + f"len(index)={len(index)} len(rows)={len(rows)}" ) sans_rows, separating_lines = _remove_separating_lines(rows) new_rows = [] diff --git a/test/test_input.py b/test/test_input.py index a178bd9..721d03a 100644 --- a/test/test_input.py +++ b/test/test_input.py @@ -522,7 +522,7 @@ def test_py37orlater_list_of_dataclasses_headers(): def test_list_bytes(): "Input: a list of bytes. (issue #192)" - lb = [["你好".encode("utf-8")], ["你好"]] + lb = [["你好".encode()], ["你好"]] expected = "\n".join( ["bytes", "---------------------------", r"b'\xe4\xbd\xa0\xe5\xa5\xbd'", "你好"] ) diff --git a/test/test_textwrapper.py b/test/test_textwrapper.py index f3070b1..c8feded 100644 --- a/test/test_textwrapper.py +++ b/test/test_textwrapper.py @@ -1,5 +1,4 @@ """Discretely test functionality of our custom TextWrapper""" -from __future__ import unicode_literals import datetime From 4a5feba2346b294047222d8db3e7a483f4942cb9 Mon Sep 17 00:00:00 2001 From: Andrew Coffey Date: Tue, 2 Jan 2024 21:19:18 -0600 Subject: [PATCH 28/90] empty aligns no longer gives a KeyError --- tabulate/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 11bb865..0b0d99b 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -2207,7 +2207,7 @@ def tabulate( else: # default aligns = [numalign if ct in [int, float] else stralign for ct in coltypes] # then specific alignements - if colalign is not None: + if colalign is not None and aligns: assert isinstance(colalign, Iterable) if isinstance(colalign, str): warnings.warn(f"As a string, `colalign` is interpreted as {[c for c in colalign]}. Did you mean `colglobalalign = \"{colalign}\"` or `colalign = (\"{colalign}\",)`?", stacklevel=2) From 216a36ea30c37018641f54cc110579bc944c7574 Mon Sep 17 00:00:00 2001 From: Andrew Coffey Date: Tue, 2 Jan 2024 21:33:03 -0600 Subject: [PATCH 29/90] added regression test --- test/test_regression.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/test_regression.py b/test/test_regression.py index 8f60ce7..d2fc3a0 100644 --- a/test/test_regression.py +++ b/test/test_regression.py @@ -512,3 +512,14 @@ def test_numpy_int64_as_integer(): assert_equal(expected, result) except ImportError: raise skip("") + +def test_empty_table_with_colalign(): + "Regression: empty table with colalign kwarg" + table = tabulate([], ["a", "b", "c"], colalign=("center", "left", "left", "center")) + expected = "\n".join( + [ + "a b c", + "--- --- ---", + ] + ) + assert_equal(expected, table) From d19c2fbe1468bc5c4634e90e2823cfa970874930 Mon Sep 17 00:00:00 2001 From: devdanzin <74280297+devdanzin@users.noreply.github.com> Date: Fri, 12 Jan 2024 23:55:55 -0300 Subject: [PATCH 30/90] WIP: Try to fix tabulate._CustomTextWrap._handle_long_word breaking up ANSI escape codes. --- tabulate/__init__.py | 14 +++++++++++--- test/test_textwrapper.py | 38 +++++++++++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 11bb865..8dca8d6 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -2540,10 +2540,18 @@ def _handle_long_word(self, reversed_chunks, cur_line, cur_len, width): # take each charcter's width into account chunk = reversed_chunks[-1] i = 1 - while self._len(chunk[:i]) <= space_left: + while len(_strip_ansi(chunk)[:i]) <= space_left: i = i + 1 - cur_line.append(chunk[: i - 1]) - reversed_chunks[-1] = chunk[i - 1 :] + # Consider escape codes when breaking words up + total_escape_len = 0 + if _ansi_codes.search(chunk) is not None: + for group, _, _, _ in _ansi_codes.findall(chunk): + escape_len = len(group) + # FIXME: Needs to keep track of found groups and search from there + if group in chunk[: i + total_escape_len + escape_len - 1]: + total_escape_len += escape_len + cur_line.append(chunk[: i + total_escape_len - 1]) + reversed_chunks[-1] = chunk[i + total_escape_len - 1 :] # Otherwise, we have to preserve the long word intact. Only add # it to the current line if there's nothing already there -- diff --git a/test/test_textwrapper.py b/test/test_textwrapper.py index f3070b1..b02f5d1 100644 --- a/test/test_textwrapper.py +++ b/test/test_textwrapper.py @@ -3,7 +3,7 @@ import datetime -from tabulate import _CustomTextWrap as CTW, tabulate +from tabulate import _CustomTextWrap as CTW, tabulate, _strip_ansi from textwrap import TextWrapper as OTW from common import skip, assert_equal @@ -158,6 +158,42 @@ def test_wrap_color_line_splillover(): assert_equal(expected, result) +def test_wrap_color_line_longword(): + """TextWrapper: Wrap a line - preserve internal color tags and wrap them to + other lines when required, requires adding the colors tags to other lines as appropriate + and avoiding splitting escape codes.""" + data = "This_is_a_\033[31mtest_string_for_testing_TextWrap\033[0m_with_colors" + + expected = [ + "This_is_a_\033[31mte\033[0m", + "\033[31mst_string_fo\033[0m", + "\033[31mr_testing_Te\033[0m", + "\033[31mxtWrap\033[0m_with_", + "colors", + ] + wrapper = CTW(width=12) + result = wrapper.wrap(data) + assert_equal(expected, result) + + +def test_wrap_color_line_multiple_escapes(): + data = '012345(\x1b[32ma\x1b[0mbc\x1b[32mdefghij\x1b[0m)' + expected = [ + "012345(\x1b[32ma\x1b[0mbc\x1b[32mdefg\x1b[0m", + "\x1b[32mhij\x1b[0m)", + ] + wrapper = CTW(width=10) + result = wrapper.wrap(data) + assert_equal(expected, result) + clean_data = _strip_ansi(data) + for width in range(2, len(clean_data)): + # Currently fails with 14, 15 and 16, because a escape code gets split at the end + wrapper = CTW(width=width) + result = wrapper.wrap(data) + # print(width, result) + # Comparing after stripping ANSI should be enough to catch broken escape codes + assert_equal(clean_data, _strip_ansi("".join(result))) + def test_wrap_datetime(): """TextWrapper: Show that datetimes can be wrapped without crashing""" data = [ From d27d3668f5c06c7bfd85e0c51f88a65d63fbed34 Mon Sep 17 00:00:00 2001 From: devdanzin <74280297+devdanzin@users.noreply.github.com> Date: Sun, 14 Jan 2024 14:58:05 -0300 Subject: [PATCH 31/90] Fix breaking up of long words so it doesn't mess up ANSI escape codes. --- tabulate/__init__.py | 7 +++++-- test/test_textwrapper.py | 10 +++++----- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 8dca8d6..f156177 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -2540,16 +2540,19 @@ def _handle_long_word(self, reversed_chunks, cur_line, cur_len, width): # take each charcter's width into account chunk = reversed_chunks[-1] i = 1 + # Only count printable characters, so strip_ansi first, index later. while len(_strip_ansi(chunk)[:i]) <= space_left: i = i + 1 # Consider escape codes when breaking words up total_escape_len = 0 + last_group = 0 if _ansi_codes.search(chunk) is not None: for group, _, _, _ in _ansi_codes.findall(chunk): escape_len = len(group) - # FIXME: Needs to keep track of found groups and search from there - if group in chunk[: i + total_escape_len + escape_len - 1]: + if group in chunk[last_group: i + total_escape_len + escape_len - 1]: total_escape_len += escape_len + found = _ansi_codes.search(chunk[last_group:]) + last_group += found.end() cur_line.append(chunk[: i + total_escape_len - 1]) reversed_chunks[-1] = chunk[i + total_escape_len - 1 :] diff --git a/test/test_textwrapper.py b/test/test_textwrapper.py index b02f5d1..bdae8c8 100644 --- a/test/test_textwrapper.py +++ b/test/test_textwrapper.py @@ -177,23 +177,23 @@ def test_wrap_color_line_longword(): def test_wrap_color_line_multiple_escapes(): - data = '012345(\x1b[32ma\x1b[0mbc\x1b[32mdefghij\x1b[0m)' + data = "012345(\x1b[32ma\x1b[0mbc\x1b[32mdefghij\x1b[0m)" expected = [ - "012345(\x1b[32ma\x1b[0mbc\x1b[32mdefg\x1b[0m", - "\x1b[32mhij\x1b[0m)", + "012345(\x1b[32ma\x1b[0mbc\x1b[32m\x1b[0m", + "\x1b[32mdefghij\x1b[0m)", ] wrapper = CTW(width=10) result = wrapper.wrap(data) assert_equal(expected, result) + clean_data = _strip_ansi(data) for width in range(2, len(clean_data)): - # Currently fails with 14, 15 and 16, because a escape code gets split at the end wrapper = CTW(width=width) result = wrapper.wrap(data) - # print(width, result) # Comparing after stripping ANSI should be enough to catch broken escape codes assert_equal(clean_data, _strip_ansi("".join(result))) + def test_wrap_datetime(): """TextWrapper: Show that datetimes can be wrapped without crashing""" data = [ From e1c59c325081e10f4ef9db86a357eb9cb5be448e Mon Sep 17 00:00:00 2001 From: devdanzin <74280297+devdanzin@users.noreply.github.com> Date: Mon, 15 Jan 2024 08:08:05 -0300 Subject: [PATCH 32/90] Make assert_equal() print repr() instead of str(). --- test/common.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/common.py b/test/common.py index 4cd3709..75524ec 100644 --- a/test/common.py +++ b/test/common.py @@ -3,8 +3,8 @@ import warnings def assert_equal(expected, result): - print("Expected:\n%s\n" % expected) - print("Got:\n%s\n" % result) + print("Expected:\n%r\n" % expected) + print("Got:\n%r\n" % result) assert expected == result From 92cb7096adaf162de9cbf7da6b432bc72cf68819 Mon Sep 17 00:00:00 2001 From: Israel Roldan Date: Sat, 13 Apr 2024 00:24:13 -0600 Subject: [PATCH 33/90] Fixed the problem with the intfmt argument. --- tabulate/__init__.py | 2 ++ test/test_output.py | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 11bb865..03880ee 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1229,6 +1229,8 @@ def _format(val, valtype, floatfmt, intfmt, missingval="", has_invisible=True): if valtype is str: return f"{val}" elif valtype is int: + if isinstance(val, str): + intfmt = "" return format(val, intfmt) elif valtype is bytes: try: diff --git a/test/test_output.py b/test/test_output.py index d572498..872e274 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -1,4 +1,5 @@ """Test output of the various forms of tabular data.""" +import pytest import tabulate as tabulate_module from common import assert_equal, raises, skip, check_warnings @@ -2638,6 +2639,44 @@ def test_intfmt(): assert_equal(expected, result) +def test_intfmt_with_string_as_integer(): + "Output: integer format" + result = tabulate([[82642], ["1500"], [2463]], intfmt=",", tablefmt="plain") + expected = "82,642\n 1500\n 2,463" + assert_equal(expected, result) + + +@pytest.mark.skip(reason="It detects all values as floats but there are strings and integers.") +def test_intfmt_with_string_with_floats(): + "Output: integer format" + result = tabulate([[82000.38], ["1500.47"], ["2463"], [92165]], intfmt=",", tablefmt="plain") + expected = "82000.4\n 1500.47\n 2463\n92,165" + assert_equal(expected, result) + + +def test_intfmt_with_colors(): + "Regression: Align ANSI-colored values as if they were colorless." + colortable = [ + ("abcd", 42, "\x1b[31m42\x1b[0m"), + ("elfy", 1010, "\x1b[32m1010\x1b[0m"), + ] + colorheaders = ("test", "\x1b[34mtest\x1b[0m", "test") + formatted = tabulate(colortable, colorheaders, "grid", intfmt=",") + expected = "\n".join( + [ + "+--------+--------+--------+", + "| test | \x1b[34mtest\x1b[0m | test |", + "+========+========+========+", + "| abcd | 42 | \x1b[31m42\x1b[0m |", + "+--------+--------+--------+", + "| elfy | 1,010 | \x1b[32m1010\x1b[0m |", + "+--------+--------+--------+", + ] + ) + print(f"expected: {expected!r}\n\ngot: {formatted!r}\n") + assert_equal(expected, formatted) + + def test_empty_data_with_headers(): "Output: table with empty data and headers as firstrow" expected = "" From e45cac16676f0955b2fd05389d97b3e6f71fd66c Mon Sep 17 00:00:00 2001 From: Israel Roldan Date: Sat, 13 Apr 2024 16:09:47 -0600 Subject: [PATCH 34/90] Added validation for integer numbers format when the number is colored. --- tabulate/__init__.py | 9 +++++++++ test/test_output.py | 18 +++++++++--------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 03880ee..9e96606 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1230,6 +1230,15 @@ def _format(val, valtype, floatfmt, intfmt, missingval="", has_invisible=True): return f"{val}" elif valtype is int: if isinstance(val, str): + val_striped = val.encode('unicode_escape').decode('utf-8') + colored = re.search(r'(\\[xX]+[0-9a-fA-F]+\[\d+[mM]+)([0-9.]+)(\\.*)$', val_striped) + if colored: + total_groups = len(colored.groups()) + if total_groups == 3: + digits = colored.group(2) + if digits.isdigit(): + val_new = colored.group(1) + format(int(digits), intfmt) + colored.group(3) + val = val_new.encode('utf-8').decode('unicode_escape') intfmt = "" return format(val, intfmt) elif valtype is bytes: diff --git a/test/test_output.py b/test/test_output.py index 872e274..8d80d95 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -2657,20 +2657,20 @@ def test_intfmt_with_string_with_floats(): def test_intfmt_with_colors(): "Regression: Align ANSI-colored values as if they were colorless." colortable = [ - ("abcd", 42, "\x1b[31m42\x1b[0m"), - ("elfy", 1010, "\x1b[32m1010\x1b[0m"), + ("\x1b[33mabc\x1b[0m", 42, "\x1b[31m42\x1b[0m"), + ("\x1b[35mdef\x1b[0m", 987654321, "\x1b[32m987654321\x1b[0m"), ] colorheaders = ("test", "\x1b[34mtest\x1b[0m", "test") formatted = tabulate(colortable, colorheaders, "grid", intfmt=",") expected = "\n".join( [ - "+--------+--------+--------+", - "| test | \x1b[34mtest\x1b[0m | test |", - "+========+========+========+", - "| abcd | 42 | \x1b[31m42\x1b[0m |", - "+--------+--------+--------+", - "| elfy | 1,010 | \x1b[32m1010\x1b[0m |", - "+--------+--------+--------+", + "+--------+-------------+-------------+", + "| test | \x1b[34mtest\x1b[0m | test |", + "+========+=============+=============+", + "| \x1b[33mabc\x1b[0m | 42 | \x1b[31m42\x1b[0m |", + "+--------+-------------+-------------+", + "| \x1b[35mdef\x1b[0m | 987,654,321 | \x1b[32m987,654,321\x1b[0m |", + "+--------+-------------+-------------+", ] ) print(f"expected: {expected!r}\n\ngot: {formatted!r}\n") From 23b3cc6537d2ca5cabad662a6ebf9dc371873d6c Mon Sep 17 00:00:00 2001 From: Israel Roldan Date: Sun, 14 Apr 2024 09:54:46 -0600 Subject: [PATCH 35/90] Updated the import of the PyTest jump decorator. --- test/test_output.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/test_output.py b/test/test_output.py index 8d80d95..8d0207c 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -1,5 +1,5 @@ """Test output of the various forms of tabular data.""" -import pytest +from pytest import mark import tabulate as tabulate_module from common import assert_equal, raises, skip, check_warnings @@ -2646,7 +2646,7 @@ def test_intfmt_with_string_as_integer(): assert_equal(expected, result) -@pytest.mark.skip(reason="It detects all values as floats but there are strings and integers.") +@mark.skip(reason="It detects all values as floats but there are strings and integers.") def test_intfmt_with_string_with_floats(): "Output: integer format" result = tabulate([[82000.38], ["1500.47"], ["2463"], [92165]], intfmt=",", tablefmt="plain") From 0655054b4115f59607bbbd37e60f0897dd267261 Mon Sep 17 00:00:00 2001 From: cdar Date: Tue, 25 Jun 2024 23:23:16 +0200 Subject: [PATCH 36/90] Fix support for separating lines --- tabulate/__init__.py | 9 ++++++--- test/test_output.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 11bb865..ff8591c 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -2304,6 +2304,8 @@ def _expand_iterable(original, num_desired, default): def _pad_row(cells, padding): if cells: + if cells == SEPARATING_LINE: + return SEPARATING_LINE pad = " " * padding padded_cells = [pad + cell + pad for cell in cells] return padded_cells @@ -2427,9 +2429,10 @@ def _format_table(fmt, headers, headersaligns, rows, colwidths, colaligns, is_mu if padded_rows and fmt.linebetweenrows and "linebetweenrows" not in hidden: # initial rows with a line below for row, ralign in zip(padded_rows[:-1], rowaligns): - append_row( - lines, row, padded_widths, colaligns, fmt.datarow, rowalign=ralign - ) + if row != SEPARATING_LINE: + append_row( + lines, row, padded_widths, colaligns, fmt.datarow, rowalign=ralign + ) _append_line(lines, padded_widths, colaligns, fmt.linebetweenrows) # the last row without a line below append_row( diff --git a/test/test_output.py b/test/test_output.py index d572498..1768720 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -335,6 +335,36 @@ def test_simple_headerless_with_sep_line(): assert_equal(expected, result) +def test_simple_headerless_with_sep_line_with_padding_in_tablefmt(): + "Output: simple without headers with sep line with padding in tablefmt" + expected = "\n".join( + [ + "|------|----------|", + "| spam | 41.9999 |", + "|------|----------|", + "| eggs | 451 |", + ] + ) + result = tabulate(_test_table_with_sep_line, tablefmt="github") + assert_equal(expected, result) + + +def test_simple_headerless_with_sep_line_with_linebetweenrows_in_tablefmt(): + "Output: simple without headers with sep line with linebetweenrows in tablefmt" + expected = "\n".join( + [ + "+------+----------+", + "| spam | 41.9999 |", + "+------+----------+", + "+------+----------+", + "| eggs | 451 |", + "+------+----------+", + ] + ) + result = tabulate(_test_table_with_sep_line, tablefmt="grid") + assert_equal(expected, result) + + def test_simple_multiline_headerless(): "Output: simple with multiline cells without headers" table = [["foo bar\nbaz\nbau", "hello"], ["", "multiline\nworld"]] From ef4a407058db61dd5b8320d55adbeaba6809841f Mon Sep 17 00:00:00 2001 From: Frederik Scheerer <35305292+frsche@users.noreply.github.com> Date: Tue, 9 Jul 2024 09:15:40 +0200 Subject: [PATCH 37/90] fix command line argument parser --- tabulate/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 11bb865..4a11467 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -2701,8 +2701,8 @@ def _main(): try: opts, args = getopt.getopt( sys.argv[1:], - "h1o:s:F:A:f:", - ["help", "header", "output", "sep=", "float=", "int=", "align=", "format="], + "h1o:s:F:I:f:", + ["help", "header", "output=", "sep=", "float=", "int=", "colalign=", "format="], ) except getopt.GetoptError as e: print(e) From b49b98eaa1aaecf44ab2842c30d826e4b17ef184 Mon Sep 17 00:00:00 2001 From: Dan Nicholson Date: Wed, 25 Sep 2024 14:59:28 +0100 Subject: [PATCH 38/90] Add colon_grid table format --- tabulate/__init__.py | 74 ++++++++++++++++++++++++++++++++++++++++++-- test/test_output.py | 13 ++++++++ 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 11bb865..bfc6fa0 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -134,6 +134,30 @@ def _pipe_line_with_colons(colwidths, colaligns): return "|" + "|".join(segments) + "|" +def _grid_segment_with_colons(colwidth, align): + """Return a segment of a horizontal line with optional colons which indicate + column's alignment in a grid table.""" + width = colwidth + if align == "right": + return ("=" * (width - 1)) + ":" + elif align == "center": + return ":" + ("=" * (width - 2)) + ":" + elif align == "left": + return ":" + ("=" * (width - 1)) + else: + return "=" * width + + +def _grid_line_with_colons(colwidths, colaligns): + """Return a horizontal line with optional colons to indicate column's alignment + in a grid table.""" + if not colaligns: + colaligns = [""] * len(colwidths) + segments = [_grid_segment_with_colons(w, a) for a, w in zip(colaligns, colwidths)] + return "+" + "+".join(segments) + "+" + + + def _mediawiki_row_with_attrs(separator, cell_values, colwidths, colaligns): alignment = { "left": "", @@ -400,6 +424,16 @@ def escape_empty(val): padding=1, with_header_hide=None, ), + "colon_grid": TableFormat( + lineabove=Line("+", "-", "+", "+"), + linebelowheader=_grid_line_with_colons, + linebetweenrows=Line("+", "-", "+", "+"), + linebelow=Line("+", "-", "+", "+"), + headerrow=DataRow("|", "|", "|"), + datarow=DataRow("|", "|", "|"), + padding=1, + with_header_hide=None, + ), "outline": TableFormat( lineabove=Line("+", "-", "+", "+"), linebelowheader=Line("+", "=", "+", "+"), @@ -693,6 +727,7 @@ def escape_empty(val): "mixed_grid": "mixed_grid", "double_grid": "double_grid", "fancy_grid": "fancy_grid", + "colon_grid": "colon_grid", "pipe": "pipe", "orgtbl": "orgtbl", "jira": "jira", @@ -1822,6 +1857,32 @@ def tabulate( │ eggs │ 451 │ ╘═══════════╧═══════════╛ + "colon_grid" is similar to "grid" but uses colons only to define + columnwise content alignment, with no whitespace padding: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "colon_grid")) + + +-----------+-----------+ + | strings | numbers | + +:==========+:==========+ + | spam | 41.9999 | + +-----------+-----------+ + | eggs | 451 | + +-----------+-----------+ + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "colon_grid", + ... colalign=["right, left"])) + + +-----------+-----------+ + | strings | numbers | + +==========:+:==========+ + | spam | 41.9999 | + +-----------+-----------+ + | eggs | 451 | + +-----------+-----------+ + "outline" is the same as the "grid" format but doesn't draw lines between rows: >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], @@ -2138,6 +2199,10 @@ def tabulate( numalign = "decimal" if numalign == _DEFAULT_ALIGN else numalign stralign = "left" if stralign == _DEFAULT_ALIGN else stralign + if tablefmt == "colon_grid": + colglobalalign = "left" + headersglobalalign = "left" + # optimization: look for ANSI control codes once, # enable smart width functions only if a control code is found # @@ -2206,7 +2271,7 @@ def tabulate( aligns = [colglobalalign] * len(cols) else: # default aligns = [numalign if ct in [int, float] else stralign for ct in coltypes] - # then specific alignements + # then specific alignments if colalign is not None: assert isinstance(colalign, Iterable) if isinstance(colalign, str): @@ -2219,9 +2284,12 @@ def tabulate( minwidths = ( [width_fn(h) + min_padding for h in headers] if headers else [0] * len(cols) ) + aligns_copy = aligns.copy() + if tablefmt == "colon_grid": + aligns_copy = ["left"] * len(cols) cols = [ _align_column(c, a, minw, has_invisible, enable_widechars, is_multiline) - for c, a, minw in zip(cols, aligns, minwidths) + for c, a, minw in zip(cols, aligns_copy, minwidths) ] aligns_headers = None @@ -2233,7 +2301,7 @@ def tabulate( aligns_headers = [headersglobalalign] * len(t_cols) else: # default aligns_headers = aligns or [stralign] * len(headers) - # then specific header alignements + # then specific header alignments if headersalign is not None: assert isinstance(headersalign, Iterable) if isinstance(headersalign, str): diff --git a/test/test_output.py b/test/test_output.py index d572498..491d260 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -1413,6 +1413,19 @@ def test_fancy_grid_multiline_row_align(): result = tabulate(table, tablefmt="fancy_grid", rowalign=[None, "center", "bottom"]) assert_equal(expected, result) +def test_colon_grid(): + expected = "\n".join( + [ + "+------+------+", + "| H1 | H2 |", + "+=====:+:====:+", + "| 3 | 4 |", + "+------+------+", + ] + ) + result = tabulate([[3, 4]], headers=("H1", "H2"), tablefmt="colon_grid", colalign=["right", "center"]) + assert_equal(expected, result) + def test_outline(): "Output: outline with headers" From 277a900e710aa30864e32b5d91fa4c8d3a42f243 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 26 Sep 2024 10:16:29 +0200 Subject: [PATCH 39/90] Remove Python 3.8 from appveyor.yml, add x86 configurations for Python 3.10, 3.11, 3.12 --- appveyor.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/appveyor.yml b/appveyor.yml index d36b8c9..eb255ed 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -8,9 +8,10 @@ environment: # The list here is complete (excluding Python 2.6, which # isn't covered by this document) at the time of writing. - - PYTHON: "C:\\Python38" - PYTHON: "C:\\Python39" - - PYTHON: "C:\\Python38-x64" + - PYTHON: "C:\\Python310" + - PYTHON: "C:\\Python311" + - PYTHON: "C:\\Python312" - PYTHON: "C:\\Python39-x64" - PYTHON: "C:\\Python310-x64" - PYTHON: "C:\\Python311-x64" From 275ff0f8aa11aca6ae419675e2101807ff5c4551 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 26 Sep 2024 10:20:15 +0200 Subject: [PATCH 40/90] Remove Python 3.8 support from pyproject.toml Python 3.8 EOL is a month from now (31 Oct 2024). --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 837747d..8cb6216 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,13 +13,13 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Topic :: Software Development :: Libraries", ] -requires-python = ">=3.8" +requires-python = ">=3.9" dynamic = ["version"] [project.urls] From dffb4679d8956eac6c78938e5293a008eee3160e Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 26 Sep 2024 10:25:27 +0200 Subject: [PATCH 41/90] Update circleci config to use Python 3.12 image --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index ac9c525..cd8bbc1 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -8,7 +8,7 @@ jobs: docker: # specify the version you desire here # use `-browsers` prefix for selenium tests, e.g. `3.6.1-browsers` - - image: circleci/python:3.8 + - image: circleci/python:3.12 # Specify service dependencies here if necessary # CircleCI maintains a library of pre-built images From fc3f4e8eec4b8989465f737c8d29a60f0900d581 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 26 Sep 2024 10:35:36 +0200 Subject: [PATCH 42/90] appveyor.yml - comment x86 Python 3.9 configuration because it is missing prebuilt Pandas dependencies --- appveyor.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appveyor.yml b/appveyor.yml index eb255ed..e92d9e4 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -8,7 +8,7 @@ environment: # The list here is complete (excluding Python 2.6, which # isn't covered by this document) at the time of writing. - - PYTHON: "C:\\Python39" + #- PYTHON: "C:\\Python39" - PYTHON: "C:\\Python310" - PYTHON: "C:\\Python311" - PYTHON: "C:\\Python312" From b35a36267f27b715758406a1bd38f66c965bb032 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 26 Sep 2024 11:31:09 +0200 Subject: [PATCH 43/90] add .circleci github key fingerprint --- .circleci/config.yml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index cd8bbc1..d5d3647 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,9 +1,16 @@ -# Python CircleCI 2.0 configuration file +# Python CircleCI 2.1 configuration file # -# Check https://circleci.com/docs/2.0/language-python/ for more details +# Check https://circleci.com/docs/language-python/ for more details # -version: 2 +version: 2.1 jobs: + # https://circleci.com/docs/github-integration/#create-additional-github-ssh-keys + deploy-job: + steps: + - add_ssh_keys: + fingerprints: + - "SHA256:f54rX6EuLbjtby+J672u/YhJ4iyTgAqOejxdcvVQf64" + build: docker: # specify the version you desire here From 6dff98017aedca304676436294219a0bd496e81b Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 26 Sep 2024 11:36:25 +0200 Subject: [PATCH 44/90] remove Circle-CI integration At this point it's easier to migrate to GitHub Actions than to rewrite .cicleci/config.yml according to Circle CI 2.1 syntax --- .circleci/config.yml | 67 -------------------------------------- .circleci/requirements.txt | 10 ------ README.md | 2 +- 3 files changed, 1 insertion(+), 78 deletions(-) delete mode 100644 .circleci/config.yml delete mode 100644 .circleci/requirements.txt diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index d5d3647..0000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,67 +0,0 @@ -# Python CircleCI 2.1 configuration file -# -# Check https://circleci.com/docs/language-python/ for more details -# -version: 2.1 -jobs: - # https://circleci.com/docs/github-integration/#create-additional-github-ssh-keys - deploy-job: - steps: - - add_ssh_keys: - fingerprints: - - "SHA256:f54rX6EuLbjtby+J672u/YhJ4iyTgAqOejxdcvVQf64" - - build: - docker: - # specify the version you desire here - # use `-browsers` prefix for selenium tests, e.g. `3.6.1-browsers` - - image: circleci/python:3.12 - - # Specify service dependencies here if necessary - # CircleCI maintains a library of pre-built images - # documented at https://circleci.com/docs/2.0/circleci-images/ - # - image: circleci/postgres:9.4 - - working_directory: ~/repo - - steps: - - checkout - - # Download and cache dependencies - - restore_cache: - keys: - - v1-dependencies-{{ checksum ".circleci/requirements.txt" }} - # fallback to using the latest cache if no exact match is found - - v1-dependencies- - - - run: - name: install dependencies - command: | - python3 -m venv venv - . venv/bin/activate - pip install -r .circleci/requirements.txt - - - save_cache: - paths: - - ./venv - key: v1-dependencies-{{ checksum ".circleci/requirements.txt" }} - - - run: - name: build wheel - command: | - . venv/bin/activate - python -m build -nwx . - - - store_artifacts: - path: dist - destination: dist - - - run: - name: run tests - command: | - . venv/bin/activate - tox -e py38-extra - - - store_artifacts: - path: test-reports - destination: test-reports diff --git a/.circleci/requirements.txt b/.circleci/requirements.txt deleted file mode 100644 index ea5e80c..0000000 --- a/.circleci/requirements.txt +++ /dev/null @@ -1,10 +0,0 @@ -pytest -tox -numpy -pandas -wcwidth -setuptools -pip -build -wheel -setuptools_scm diff --git a/README.md b/README.md index 944a260..12309db 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ pip install tabulate Build status ------------ -[![Build status](https://circleci.com/gh/astanin/python-tabulate.svg?style=svg)](https://circleci.com/gh/astanin/python-tabulate/tree/master) [![Build status](https://ci.appveyor.com/api/projects/status/8745yksvvol7h3d7/branch/master?svg=true)](https://ci.appveyor.com/project/astanin/python-tabulate/branch/master) +[![Build status](https://ci.appveyor.com/api/projects/status/8745yksvvol7h3d7/branch/master?svg=true)](https://ci.appveyor.com/project/astanin/python-tabulate/branch/master) Library usage ------------- From 277e649579842878059464bc32f0e97aff287d29 Mon Sep 17 00:00:00 2001 From: Dan Nicholson Date: Thu, 26 Sep 2024 14:11:24 +0100 Subject: [PATCH 45/90] Correct alignment of text in docstring examples --- tabulate/__init__.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index bfc6fa0..e61e57c 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1864,11 +1864,11 @@ def tabulate( ... ["strings", "numbers"], "colon_grid")) +-----------+-----------+ - | strings | numbers | + | strings | numbers | +:==========+:==========+ - | spam | 41.9999 | + | spam | 41.9999 | +-----------+-----------+ - | eggs | 451 | + | eggs | 451 | +-----------+-----------+ >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], @@ -1876,11 +1876,11 @@ def tabulate( ... colalign=["right, left"])) +-----------+-----------+ - | strings | numbers | + | strings | numbers | +==========:+:==========+ - | spam | 41.9999 | + | spam | 41.9999 | +-----------+-----------+ - | eggs | 451 | + | eggs | 451 | +-----------+-----------+ "outline" is the same as the "grid" format but doesn't draw lines between rows: From 4a17bc776fbc911e2d54f800e1d66bb545fdb314 Mon Sep 17 00:00:00 2001 From: Dan Nicholson Date: Thu, 26 Sep 2024 14:17:10 +0100 Subject: [PATCH 46/90] Document changes required for colon_grid format --- tabulate/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index e61e57c..5acc98f 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -2199,6 +2199,9 @@ def tabulate( numalign = "decimal" if numalign == _DEFAULT_ALIGN else numalign stralign = "left" if stralign == _DEFAULT_ALIGN else stralign + # 'colon_grid' uses colons in the line beneath the header to represent a column's + # alignment instead of literally aligning the text differently. Hence, + # left alignment of the data in the text output is enforced. if tablefmt == "colon_grid": colglobalalign = "left" headersglobalalign = "left" @@ -2285,6 +2288,8 @@ def tabulate( [width_fn(h) + min_padding for h in headers] if headers else [0] * len(cols) ) aligns_copy = aligns.copy() + # Reset alignments in copy of alignments list to "left" for 'colon_grid' format, + # which enforces left alignment in the text output of the data. if tablefmt == "colon_grid": aligns_copy = ["left"] * len(cols) cols = [ From 4440c1ed7842fd3c652b884361dbbead905cca4a Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 26 Sep 2024 15:31:05 +0200 Subject: [PATCH 47/90] add GitHub Actions workflow based on tox-gh-actions, create a map of tox environments --- .github/workflows/github-actions-tox.yml | 25 ++++++++++++++++++++++++ README.md | 2 +- tox.ini | 7 +++++++ 3 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/github-actions-tox.yml diff --git a/.github/workflows/github-actions-tox.yml b/.github/workflows/github-actions-tox.yml new file mode 100644 index 0000000..35be5cd --- /dev/null +++ b/.github/workflows/github-actions-tox.yml @@ -0,0 +1,25 @@ +name: python-tabulate + +on: + - push + - pull_request + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.9', '3.10', '3.11', '3.12'] + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install tox tox-gh-actions + - name: Test with tox + run: tox diff --git a/README.md b/README.md index 12309db..0023806 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ pip install tabulate Build status ------------ -[![Build status](https://ci.appveyor.com/api/projects/status/8745yksvvol7h3d7/branch/master?svg=true)](https://ci.appveyor.com/project/astanin/python-tabulate/branch/master) +[![python-tabulate](https://github.com/astanin/python-tabulate/actions/workflows/github-actions-tox.yml/badge.svg)](https://github.com/astanin/python-tabulate/actions/workflows/github-actions-tox.yml) [![Build status](https://ci.appveyor.com/api/projects/status/8745yksvvol7h3d7/branch/master?svg=true)](https://ci.appveyor.com/project/astanin/python-tabulate/branch/master) Library usage ------------- diff --git a/tox.ini b/tox.ini index 7fd65a7..92939c2 100644 --- a/tox.ini +++ b/tox.ini @@ -11,6 +11,13 @@ envlist = lint, py{38, 39, 310, 311, 312} isolated_build = True +[gh-actions] +python = + 3.9: py39 + 3.10: py310 + 3.11: py311 + 3.12: py312 + [testenv] commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} deps = From 34310479571d2a1d067883bc9b90e4358c1040fe Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 26 Sep 2024 15:39:47 +0200 Subject: [PATCH 48/90] skip all x86 builds on appveyor because they lack dependencies --- appveyor.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/appveyor.yml b/appveyor.yml index e92d9e4..12bd772 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -9,9 +9,9 @@ environment: # isn't covered by this document) at the time of writing. #- PYTHON: "C:\\Python39" - - PYTHON: "C:\\Python310" - - PYTHON: "C:\\Python311" - - PYTHON: "C:\\Python312" + #- PYTHON: "C:\\Python310" + #- PYTHON: "C:\\Python311" + #- PYTHON: "C:\\Python312" - PYTHON: "C:\\Python39-x64" - PYTHON: "C:\\Python310-x64" - PYTHON: "C:\\Python311-x64" From 5a7ab7fb482f990bc7bb2061b0d8c7d579c01188 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 26 Sep 2024 16:05:43 +0200 Subject: [PATCH 49/90] enable extra tests in GitHub Actions --- tox.ini | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tox.ini b/tox.ini index 92939c2..b37e0aa 100644 --- a/tox.ini +++ b/tox.ini @@ -13,10 +13,10 @@ isolated_build = True [gh-actions] python = - 3.9: py39 - 3.10: py310 - 3.11: py311 - 3.12: py312 + 3.9: py39-extra + 3.10: py310-extra + 3.11: py311-extra + 3.12: py312-extra [testenv] commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} From 18d7b50edd4e6d05d8ee77ab5a043717fe1ef966 Mon Sep 17 00:00:00 2001 From: Dan Nicholson Date: Thu, 26 Sep 2024 15:07:50 +0100 Subject: [PATCH 50/90] Expand tests for colon_grid --- test/test_output.py | 78 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/test/test_output.py b/test/test_output.py index 491d260..25b99a9 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -1413,7 +1413,9 @@ def test_fancy_grid_multiline_row_align(): result = tabulate(table, tablefmt="fancy_grid", rowalign=[None, "center", "bottom"]) assert_equal(expected, result) + def test_colon_grid(): + "Output: colon_grid with two columns aligned left and center" expected = "\n".join( [ "+------+------+", @@ -1427,6 +1429,82 @@ def test_colon_grid(): assert_equal(expected, result) +def test_colon_grid_wide_characters(): + "Output: colon_grid with wide chars in header" + try: + import wcwidth # noqa + except ImportError: + skip("test_colon_grid_wide_characters is skipped") + headers = list(_test_table_headers) + headers[1] = "配列" + expected = "\n".join( + [ + "+-----------+---------+", + "| strings | 配列 |", + "+:==========+========:+", + "| spam | 41.9999 |", + "+-----------+---------+", + "| eggs | 451 |", + "+-----------+---------+", + ] + ) + result = tabulate(_test_table, headers, tablefmt="colon_grid", colalign=["left", "right"]) + assert_equal(expected, result) + + +def test_colon_grid_headerless(): + "Output: colon_grid without headers" + expected = "\n".join( + [ + "+------+---------+", + "| spam | 41.9999 |", + "+------+---------+", + "| eggs | 451 |", + "+------+---------+", + ] + ) + result = tabulate(_test_table, tablefmt="colon_grid") + assert_equal(expected, result) + + +def test_colon_grid_multiline(): + "Output: colon_grid with multiline cells" + table = [["Data\n5", "33\n3"]] + headers = ["H1\n1", "H2\n2"] + expected = "\n".join( + [ + "+------+------+", + "| H1 | H2 |", + "| 1 | 2 |", + "+:=====+:=====+", + "| Data | 33 |", + "| 5 | 3 |", + "+------+------+", + ] + ) + result = tabulate(table, headers, tablefmt="colon_grid") + assert_equal(expected, result) + + +def test_colon_grid_with_empty_cells(): + table = [["A", ""], ["", "B"]] + headers = ["H1", "H2"] + alignments = ["center", "right"] + expected = "\n".join( + [ + "+------+------+", + "| H1 | H2 |", + "+:====:+=====:+", + "| A | |", + "+------+------+", + "| | B |", + "+------+------+", + ] + ) + result = tabulate(table, headers, tablefmt="colon_grid", colalign=alignments) + assert_equal(expected, result) + + def test_outline(): "Output: outline with headers" expected = "\n".join( From db5cdd0cc88f2e1a4b29dfa4ea08df07bf24388c Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 26 Sep 2024 16:09:31 +0200 Subject: [PATCH 51/90] install pytest, numpy and pandas in Github Actions workflow --- .github/workflows/github-actions-tox.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/github-actions-tox.yml b/.github/workflows/github-actions-tox.yml index 35be5cd..98c814a 100644 --- a/.github/workflows/github-actions-tox.yml +++ b/.github/workflows/github-actions-tox.yml @@ -20,6 +20,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip + python -m pip install pytest numpy pandas python -m pip install tox tox-gh-actions - name: Test with tox run: tox From bdb639e650d9033a07d17e8eded9a908f08e101d Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 26 Sep 2024 16:21:55 +0200 Subject: [PATCH 52/90] rewrite GitHub Action using explicit reference to tox environment with -extra suffix --- .github/workflows/github-actions-tox.yml | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/.github/workflows/github-actions-tox.yml b/.github/workflows/github-actions-tox.yml index 98c814a..178b212 100644 --- a/.github/workflows/github-actions-tox.yml +++ b/.github/workflows/github-actions-tox.yml @@ -12,15 +12,13 @@ jobs: python-version: ['3.9', '3.10', '3.11', '3.12'] steps: - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install pytest numpy pandas - python -m pip install tox tox-gh-actions - - name: Test with tox - run: tox + - uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - name: Install tox and any other packages + run: pip install tox pytest numpy pandas + - name: Run tox + # Run tox using the version of Python in `PATH` + run: tox -e py${{ matrix.python}}-extra From ec79fe0381d6d89afd0d4d4075282ecb26349f19 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 26 Sep 2024 16:32:00 +0200 Subject: [PATCH 53/90] configure GitHub Action using tox-gh instead of tox-gh-actions --- .github/workflows/github-actions-tox.yml | 22 ++++++++++++---------- tox.ini | 2 +- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/.github/workflows/github-actions-tox.yml b/.github/workflows/github-actions-tox.yml index 178b212..0b89058 100644 --- a/.github/workflows/github-actions-tox.yml +++ b/.github/workflows/github-actions-tox.yml @@ -12,13 +12,15 @@ jobs: python-version: ['3.9', '3.10', '3.11', '3.12'] steps: - - uses: actions/checkout@v4 - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python }} - - name: Install tox and any other packages - run: pip install tox pytest numpy pandas - - name: Run tox - # Run tox using the version of Python in `PATH` - run: tox -e py${{ matrix.python}}-extra + - uses: actions/checkout@v34 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install pytest numpy pandas + python -m pip install tox tox-gh + - name: Test with tox + run: tox diff --git a/tox.ini b/tox.ini index b37e0aa..a7fe513 100644 --- a/tox.ini +++ b/tox.ini @@ -11,7 +11,7 @@ envlist = lint, py{38, 39, 310, 311, 312} isolated_build = True -[gh-actions] +[gh] python = 3.9: py39-extra 3.10: py310-extra From 4bd335c585b9d9445ca3bdc3efb7a41e6defc1c7 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 26 Sep 2024 16:33:16 +0200 Subject: [PATCH 54/90] fix typo in github-actions-tox.yml --- .github/workflows/github-actions-tox.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/github-actions-tox.yml b/.github/workflows/github-actions-tox.yml index 0b89058..e39e44f 100644 --- a/.github/workflows/github-actions-tox.yml +++ b/.github/workflows/github-actions-tox.yml @@ -12,7 +12,7 @@ jobs: python-version: ['3.9', '3.10', '3.11', '3.12'] steps: - - uses: actions/checkout@v34 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: From 8cd28a3a36bae2daacbc3333b19fdbb344a39177 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 26 Sep 2024 16:36:58 +0200 Subject: [PATCH 55/90] rewrite GitHub Action using pytest --- .github/workflows/github-actions-tox.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/github-actions-tox.yml b/.github/workflows/github-actions-tox.yml index e39e44f..9adabb7 100644 --- a/.github/workflows/github-actions-tox.yml +++ b/.github/workflows/github-actions-tox.yml @@ -21,6 +21,6 @@ jobs: run: | python -m pip install --upgrade pip python -m pip install pytest numpy pandas - python -m pip install tox tox-gh - - name: Test with tox - run: tox + - name: Run tests + run: | + pytest -v --doctest-modules --ignore benchmark.py From 968f25851eea82bff2c4f10d46035164ff54418e Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 26 Sep 2024 16:40:59 +0200 Subject: [PATCH 56/90] apply black --- tabulate/__init__.py | 2 +- test/test_cli.py | 1 - test/test_internal.py | 20 +++++++++++++++++--- test/test_output.py | 5 ++++- test/test_textwrapper.py | 3 ++- 5 files changed, 24 insertions(+), 7 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 874ad90..704bc5b 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -2762,7 +2762,7 @@ def _main(): print(usage) sys.exit(0) files = [sys.stdin] if not args else args - with (sys.stdout if outfile == "-" else open(outfile, "w")) as out: + with sys.stdout if outfile == "-" else open(outfile, "w") as out: for f in files: if f == "-": f = sys.stdin diff --git a/test/test_cli.py b/test/test_cli.py index ce85f19..e71572d 100644 --- a/test/test_cli.py +++ b/test/test_cli.py @@ -2,7 +2,6 @@ """ - import os import sys diff --git a/test/test_internal.py b/test/test_internal.py index 64e1d12..17107c6 100644 --- a/test/test_internal.py +++ b/test/test_internal.py @@ -180,9 +180,16 @@ def test_wrap_text_wide_chars(): except ImportError: skip("test_wrap_text_wide_chars is skipped") - rows = [["청자청자청자청자청자", "약간 감싸면 더 잘 보일 수있는 다소 긴 설명입니다"]] + rows = [ + ["청자청자청자청자청자", "약간 감싸면 더 잘 보일 수있는 다소 긴 설명입니다"] + ] widths = [5, 20] - expected = [["청자\n청자\n청자\n청자\n청자", "약간 감싸면 더 잘\n보일 수있는 다소 긴\n설명입니다"]] + expected = [ + [ + "청자\n청자\n청자\n청자\n청자", + "약간 감싸면 더 잘\n보일 수있는 다소 긴\n설명입니다", + ] + ] result = T._wrap_text_to_colwidths(rows, widths) assert_equal(expected, result) @@ -239,7 +246,14 @@ def test_wrap_text_to_colwidths_colors_wide_char(): except ImportError: skip("test_wrap_text_to_colwidths_colors_wide_char is skipped") - data = [[("\033[31m약간 감싸면 더 잘 보일 수있는 다소 긴" " 설명입니다 설명입니다 설명입니다 설명입니다 설명\033[0m")]] + data = [ + [ + ( + "\033[31m약간 감싸면 더 잘 보일 수있는 다소 긴" + " 설명입니다 설명입니다 설명입니다 설명입니다 설명\033[0m" + ) + ] + ] result = T._wrap_text_to_colwidths(data, [30]) expected = [ diff --git a/test/test_output.py b/test/test_output.py index 260ed31..fe9e486 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -136,7 +136,10 @@ def test_plain_maxcolwidth_autowraps_wide_chars(): table = [ ["hdr", "fold"], - ["1", "약간 감싸면 더 잘 보일 수있는 다소 긴 설명입니다 설명입니다 설명입니다 설명입니다 설명"], + [ + "1", + "약간 감싸면 더 잘 보일 수있는 다소 긴 설명입니다 설명입니다 설명입니다 설명입니다 설명", + ], ] expected = "\n".join( [ diff --git a/test/test_textwrapper.py b/test/test_textwrapper.py index c8feded..1f134fd 100644 --- a/test/test_textwrapper.py +++ b/test/test_textwrapper.py @@ -143,7 +143,8 @@ def test_wrap_color_in_single_line(): def test_wrap_color_line_splillover(): """TextWrapper: Wrap a line - preserve internal color tags and wrap them to - other lines when required, requires adding the colors tags to other lines as appropriate""" + other lines when required, requires adding the colors tags to other lines as appropriate + """ # This has both a text color and a background color data = "This is a \033[31mtest string for testing TextWrap\033[0m with colors" From 4e97640cbc53d7dfa7f5a1e635f104852debb2bf Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 26 Sep 2024 16:42:10 +0200 Subject: [PATCH 57/90] fix E721 warning in tabulate/__init__.py --- tabulate/__init__.py | 2 +- test/test_internal.py | 13 ++----------- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 704bc5b..0660072 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -103,7 +103,7 @@ def _is_file(f): def _is_separating_line_value(value): - return type(value) == str and value.strip() == SEPARATING_LINE + return type(value) is str and value.strip() == SEPARATING_LINE def _is_separating_line(row): diff --git a/test/test_internal.py b/test/test_internal.py index 17107c6..e7564d3 100644 --- a/test/test_internal.py +++ b/test/test_internal.py @@ -180,9 +180,7 @@ def test_wrap_text_wide_chars(): except ImportError: skip("test_wrap_text_wide_chars is skipped") - rows = [ - ["청자청자청자청자청자", "약간 감싸면 더 잘 보일 수있는 다소 긴 설명입니다"] - ] + rows = [["청자청자청자청자청자", "약간 감싸면 더 잘 보일 수있는 다소 긴 설명입니다"]] widths = [5, 20] expected = [ [ @@ -246,14 +244,7 @@ def test_wrap_text_to_colwidths_colors_wide_char(): except ImportError: skip("test_wrap_text_to_colwidths_colors_wide_char is skipped") - data = [ - [ - ( - "\033[31m약간 감싸면 더 잘 보일 수있는 다소 긴" - " 설명입니다 설명입니다 설명입니다 설명입니다 설명\033[0m" - ) - ] - ] + data = [[("\033[31m약간 감싸면 더 잘 보일 수있는 다소 긴" " 설명입니다 설명입니다 설명입니다 설명입니다 설명\033[0m")]] result = T._wrap_text_to_colwidths(data, [30]) expected = [ From 10f7b758fdcace9bcce3d14225ddadbe0f17e40e Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 26 Sep 2024 16:43:16 +0200 Subject: [PATCH 58/90] rename GitHub Action workflow --- .github/workflows/{github-actions-tox.yml => tabulate.yml} | 0 README.md | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename .github/workflows/{github-actions-tox.yml => tabulate.yml} (100%) diff --git a/.github/workflows/github-actions-tox.yml b/.github/workflows/tabulate.yml similarity index 100% rename from .github/workflows/github-actions-tox.yml rename to .github/workflows/tabulate.yml diff --git a/README.md b/README.md index 0023806..543576d 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ pip install tabulate Build status ------------ -[![python-tabulate](https://github.com/astanin/python-tabulate/actions/workflows/github-actions-tox.yml/badge.svg)](https://github.com/astanin/python-tabulate/actions/workflows/github-actions-tox.yml) [![Build status](https://ci.appveyor.com/api/projects/status/8745yksvvol7h3d7/branch/master?svg=true)](https://ci.appveyor.com/project/astanin/python-tabulate/branch/master) +[![python-tabulate](https://github.com/astanin/python-tabulate/actions/workflows/tabulate.yml/badge.svg)](https://github.com/astanin/python-tabulate/actions/workflows/tabulate.yml) [![Build status](https://ci.appveyor.com/api/projects/status/8745yksvvol7h3d7/branch/master?svg=true)](https://ci.appveyor.com/project/astanin/python-tabulate/branch/master) Library usage ------------- From 97b67f374871c5d1dc706072573f08f3acad7295 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 26 Sep 2024 16:54:23 +0200 Subject: [PATCH 59/90] add GitHub Action workflow lint which runs pre_commit actions --- .github/workflows/lint.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .github/workflows/lint.yml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..7e7c906 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,26 @@ +name: lint + +on: + - push + - pull_request + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.12'] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install tox tox-gh + - name: Run linters + run: | + tox -e lint From 2855363cf7d8ad088b55a45bd16116f1edef24e8 Mon Sep 17 00:00:00 2001 From: Dan Nicholson Date: Thu, 26 Sep 2024 16:49:11 +0100 Subject: [PATCH 60/90] Fix doctests for colon_grid --- tabulate/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 5acc98f..b0d9612 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1862,7 +1862,6 @@ def tabulate( >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], ... ["strings", "numbers"], "colon_grid")) - +-----------+-----------+ | strings | numbers | +:==========+:==========+ @@ -1873,8 +1872,7 @@ def tabulate( >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], ... ["strings", "numbers"], "colon_grid", - ... colalign=["right, left"])) - + ... colalign=["right", "left"])) +-----------+-----------+ | strings | numbers | +==========:+:==========+ From 049d7d9701d70a0b05c405b574f0b69925ac51fa Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 26 Sep 2024 18:31:44 +0200 Subject: [PATCH 61/90] apply black to the PR branch and fix lint errors --- README.md | 2 +- tabulate/__init__.py | 42 ++++++++++++++++++++++++++++++++--------- test/common.py | 2 ++ test/test_output.py | 16 ++++++++++++---- test/test_regression.py | 1 + 5 files changed, 49 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index b596cf5..52c7644 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ When data is a list of dictionaries, a dictionary can be passed as `headers` to replace the keys with other column labels: ```pycon ->>> print(tabulate([{1: "Alice", 2: 24}, {1: "Bob", 2: 19}], +>>> print(tabulate([{1: "Alice", 2: 24}, {1: "Bob", 2: 19}], ... headers={1: "Name", 2: "Age"})) Name Age ------ ----- diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 63d8d32..598cb4e 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -160,7 +160,6 @@ def _grid_line_with_colons(colwidths, colaligns): return "+" + "+".join(segments) + "+" - def _mediawiki_row_with_attrs(separator, cell_values, colwidths, colaligns): alignment = { "left": "", @@ -1160,10 +1159,12 @@ def _align_column( has_invisible=True, enable_widechars=False, is_multiline=False, - preserve_whitespace=False + preserve_whitespace=False, ): """[string] -> [padded_string]""" - strings, padfn = _align_column_choose_padfn(strings, alignment, has_invisible, preserve_whitespace) + strings, padfn = _align_column_choose_padfn( + strings, alignment, has_invisible, preserve_whitespace + ) width_fn = _align_column_choose_width_fn( has_invisible, enable_widechars, is_multiline ) @@ -1269,15 +1270,21 @@ def _format(val, valtype, floatfmt, intfmt, missingval="", has_invisible=True): return f"{val}" elif valtype is int: if isinstance(val, str): - val_striped = val.encode('unicode_escape').decode('utf-8') - colored = re.search(r'(\\[xX]+[0-9a-fA-F]+\[\d+[mM]+)([0-9.]+)(\\.*)$', val_striped) + val_striped = val.encode("unicode_escape").decode("utf-8") + colored = re.search( + r"(\\[xX]+[0-9a-fA-F]+\[\d+[mM]+)([0-9.]+)(\\.*)$", val_striped + ) if colored: total_groups = len(colored.groups()) if total_groups == 3: digits = colored.group(2) if digits.isdigit(): - val_new = colored.group(1) + format(int(digits), intfmt) + colored.group(3) - val = val_new.encode('utf-8').decode('unicode_escape') + val_new = ( + colored.group(1) + + format(int(digits), intfmt) + + colored.group(3) + ) + val = val_new.encode("utf-8").decode("unicode_escape") intfmt = "" return format(val, intfmt) elif valtype is bytes: @@ -2322,7 +2329,15 @@ def tabulate( if tablefmt == "colon_grid": aligns_copy = ["left"] * len(cols) cols = [ - _align_column(c, a, minw, has_invisible, enable_widechars, is_multiline, preserve_whitespace) + _align_column( + c, + a, + minw, + has_invisible, + enable_widechars, + is_multiline, + preserve_whitespace, + ) for c, a, minw in zip(cols, aligns_copy, minwidths) ] @@ -2819,7 +2834,16 @@ def _main(): opts, args = getopt.getopt( sys.argv[1:], "h1o:s:F:I:f:", - ["help", "header", "output=", "sep=", "float=", "int=", "colalign=", "format="], + [ + "help", + "header", + "output=", + "sep=", + "float=", + "int=", + "colalign=", + "format=", + ], ) except getopt.GetoptError as e: print(e) diff --git a/test/common.py b/test/common.py index ca5a069..fe33259 100644 --- a/test/common.py +++ b/test/common.py @@ -2,11 +2,13 @@ from pytest import skip, raises # noqa import warnings + def assert_equal(expected, result): print("Expected:\n%s\n" % expected) print("Got:\n%s\n" % result) assert expected == result + def assert_in(result, expected_set): nums = range(1, len(expected_set) + 1) for i, expected in zip(nums, expected_set): diff --git a/test/test_output.py b/test/test_output.py index c90f90c..059742c 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -1,7 +1,6 @@ """Test output of the various forms of tabular data.""" from pytest import mark -import tabulate as tabulate_module from common import assert_equal, raises, skip, check_warnings from tabulate import tabulate, simple_separated_format, SEPARATING_LINE @@ -1459,7 +1458,12 @@ def test_colon_grid(): "+------+------+", ] ) - result = tabulate([[3, 4]], headers=("H1", "H2"), tablefmt="colon_grid", colalign=["right", "center"]) + result = tabulate( + [[3, 4]], + headers=("H1", "H2"), + tablefmt="colon_grid", + colalign=["right", "center"], + ) assert_equal(expected, result) @@ -1482,7 +1486,9 @@ def test_colon_grid_wide_characters(): "+-----------+---------+", ] ) - result = tabulate(_test_table, headers, tablefmt="colon_grid", colalign=["left", "right"]) + result = tabulate( + _test_table, headers, tablefmt="colon_grid", colalign=["left", "right"] + ) assert_equal(expected, result) @@ -2773,7 +2779,9 @@ def test_intfmt_with_string_as_integer(): @mark.skip(reason="It detects all values as floats but there are strings and integers.") def test_intfmt_with_string_with_floats(): "Output: integer format" - result = tabulate([[82000.38], ["1500.47"], ["2463"], [92165]], intfmt=",", tablefmt="plain") + result = tabulate( + [[82000.38], ["1500.47"], ["2463"], [92165]], intfmt=",", tablefmt="plain" + ) expected = "82000.4\n 1500.47\n 2463\n92,165" assert_equal(expected, result) diff --git a/test/test_regression.py b/test/test_regression.py index d2fc3a0..5289db0 100644 --- a/test/test_regression.py +++ b/test/test_regression.py @@ -513,6 +513,7 @@ def test_numpy_int64_as_integer(): except ImportError: raise skip("") + def test_empty_table_with_colalign(): "Regression: empty table with colalign kwarg" table = tabulate([], ["a", "b", "c"], colalign=("center", "left", "left", "center")) From 54ac10757ac23a5b9bd4f483f35730a5d9dea437 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 26 Sep 2024 18:33:50 +0200 Subject: [PATCH 62/90] add preserve_whitespace to test_tabulate_signature --- test/test_api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/test_api.py b/test/test_api.py index e658e82..062573c 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -50,6 +50,7 @@ def test_tabulate_signature(): ("disable_numparse", False), ("colglobalalign", None), ("colalign", None), + ("preserve_whitespace", False), ("maxcolwidths", None), ("headersglobalalign", None), ("headersalign", None), From 0d5d30e81a98caed4fc867e76ec5ca10cb5ff20f Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 26 Sep 2024 18:38:36 +0200 Subject: [PATCH 63/90] update README according to the new preserve_whitespace usage --- README.md | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 52c7644..089f20e 100644 --- a/README.md +++ b/README.md @@ -739,13 +739,8 @@ column, in which case every column may have different number formatting: ### Text formatting By default, `tabulate` removes leading and trailing whitespace from text -columns. To disable whitespace removal, set the global module-level flag -`PRESERVE_WHITESPACE`: - -```python -import tabulate -tabulate.PRESERVE_WHITESPACE = True -``` +columns. To disable whitespace removal, pass `preserve_whitespace=True`. +Older versions of the library used a global module-level flag PRESERVE_WHITESPACE. ### Wide (fullwidth CJK) symbols From 09550a7b2eeaf7de723c10bbffe4c941a2d860db Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Thu, 26 Sep 2024 18:44:25 +0200 Subject: [PATCH 64/90] Apply ruff/Perflint rule PERF402 https://docs.astral.sh/ruff/rules/manual-list-copy/ --- tabulate/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 11bb865..9e76a52 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1108,8 +1108,7 @@ def _flat_list(nested_list): ret = [] for item in nested_list: if isinstance(item, list): - for subitem in item: - ret.append(subitem) + ret.extend(item) else: ret.append(item) return ret From e8b511d7bcd547deb8b9d623394a44b8c3960ea7 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 26 Sep 2024 18:47:33 +0200 Subject: [PATCH 65/90] rename unitest workflow --- .github/workflows/tabulate.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tabulate.yml b/.github/workflows/tabulate.yml index 9adabb7..eeb96b7 100644 --- a/.github/workflows/tabulate.yml +++ b/.github/workflows/tabulate.yml @@ -1,4 +1,4 @@ -name: python-tabulate +name: pytest on: - push From 1f49977cce900e9414600ae11a25750bec8e5801 Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Fri, 29 Sep 2023 10:07:58 +0200 Subject: [PATCH 66/90] Fix typos found by codespell --- tabulate/__init__.py | 2 +- test/test_input.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index b7510b2..2ff8f8f 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -2647,7 +2647,7 @@ def _update_lines(self, lines, new_line): else: # A single reset code resets everything self._active_codes = [] - # Always ensure each line is color terminted if any colors are + # Always ensure each line is color terminated if any colors are # still active, otherwise colors will bleed into other cells on the console if len(self._active_codes) > 0: new_line = new_line + _ansi_color_reset_code diff --git a/test/test_input.py b/test/test_input.py index 721d03a..b910a34 100644 --- a/test/test_input.py +++ b/test/test_input.py @@ -11,7 +11,7 @@ def test_iterable_of_iterables(): - "Input: an interable of iterables." + "Input: an iterable of iterables." ii = iter(map(lambda x: iter(x), [range(5), range(5, 0, -1)])) expected = "\n".join( ["- - - - -", "0 1 2 3 4", "5 4 3 2 1", "- - - - -"] @@ -21,7 +21,7 @@ def test_iterable_of_iterables(): def test_iterable_of_iterables_headers(): - "Input: an interable of iterables with headers." + "Input: an iterable of iterables with headers." ii = iter(map(lambda x: iter(x), [range(5), range(5, 0, -1)])) expected = "\n".join( [ @@ -36,7 +36,7 @@ def test_iterable_of_iterables_headers(): def test_iterable_of_iterables_firstrow(): - "Input: an interable of iterables with the first row as headers" + "Input: an iterable of iterables with the first row as headers" ii = iter(map(lambda x: iter(x), ["abcde", range(5), range(5, 0, -1)])) expected = "\n".join( [ From ca385d2ed23a405ea18bc0fb9ce1fe5327867e8a Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Thu, 26 Sep 2024 18:46:29 +0200 Subject: [PATCH 67/90] A round of black to pass CI tests --- test/test_internal.py | 13 +++++++++++-- test/test_output.py | 1 + test/test_regression.py | 2 +- test/test_textwrapper.py | 1 + 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/test/test_internal.py b/test/test_internal.py index e7564d3..17107c6 100644 --- a/test/test_internal.py +++ b/test/test_internal.py @@ -180,7 +180,9 @@ def test_wrap_text_wide_chars(): except ImportError: skip("test_wrap_text_wide_chars is skipped") - rows = [["청자청자청자청자청자", "약간 감싸면 더 잘 보일 수있는 다소 긴 설명입니다"]] + rows = [ + ["청자청자청자청자청자", "약간 감싸면 더 잘 보일 수있는 다소 긴 설명입니다"] + ] widths = [5, 20] expected = [ [ @@ -244,7 +246,14 @@ def test_wrap_text_to_colwidths_colors_wide_char(): except ImportError: skip("test_wrap_text_to_colwidths_colors_wide_char is skipped") - data = [[("\033[31m약간 감싸면 더 잘 보일 수있는 다소 긴" " 설명입니다 설명입니다 설명입니다 설명입니다 설명\033[0m")]] + data = [ + [ + ( + "\033[31m약간 감싸면 더 잘 보일 수있는 다소 긴" + " 설명입니다 설명입니다 설명입니다 설명입니다 설명\033[0m" + ) + ] + ] result = T._wrap_text_to_colwidths(data, [30]) expected = [ diff --git a/test/test_output.py b/test/test_output.py index 059742c..68b5e55 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -1,4 +1,5 @@ """Test output of the various forms of tabular data.""" + from pytest import mark from common import assert_equal, raises, skip, check_warnings diff --git a/test/test_regression.py b/test/test_regression.py index 584ea39..bf26247 100644 --- a/test/test_regression.py +++ b/test/test_regression.py @@ -490,7 +490,7 @@ def test_preserve_line_breaks_with_maxcolwidths(): def test_maxcolwidths_accepts_list_or_tuple(): "Regression: maxcolwidths can accept a list or a tuple (github issue #214)" - table = [["lorem ipsum dolor sit amet"]*3] + table = [["lorem ipsum dolor sit amet"] * 3] expected = "\n".join( [ "+-------------+----------+----------------------------+", diff --git a/test/test_textwrapper.py b/test/test_textwrapper.py index b315a5c..1f134fd 100644 --- a/test/test_textwrapper.py +++ b/test/test_textwrapper.py @@ -1,4 +1,5 @@ """Discretely test functionality of our custom TextWrapper""" + import datetime from tabulate import _CustomTextWrap as CTW, tabulate From de466cb48fc2eaeb033b283d0081093dc51b5dd2 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Thu, 26 Sep 2024 12:20:35 -0700 Subject: [PATCH 68/90] Add support for Python 3.13 --- .github/workflows/tabulate.yml | 3 ++- pyproject.toml | 1 + tox.ini | 19 ++++++++++++++++++- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tabulate.yml b/.github/workflows/tabulate.yml index eeb96b7..e75466b 100644 --- a/.github/workflows/tabulate.yml +++ b/.github/workflows/tabulate.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.9', '3.10', '3.11', '3.12'] + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] steps: - uses: actions/checkout@v4 @@ -17,6 +17,7 @@ jobs: uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/pyproject.toml b/pyproject.toml index 8cb6216..4144f9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Software Development :: Libraries", ] requires-python = ">=3.9" diff --git a/tox.ini b/tox.ini index a7fe513..4ab8244 100644 --- a/tox.ini +++ b/tox.ini @@ -8,7 +8,7 @@ # for testing and it is disabled by default. [tox] -envlist = lint, py{38, 39, 310, 311, 312} +envlist = lint, py{38, 39, 310, 311, 312, 313} isolated_build = True [gh] @@ -17,6 +17,7 @@ python = 3.10: py310-extra 3.11: py311-extra 3.12: py312-extra + 3.13: py313-extra [testenv] commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} @@ -113,6 +114,22 @@ deps = pandas wcwidth +[testenv:py313] +basepython = python3.13 +commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} +deps = + pytest + +[testenv:py313-extra] +basepython = python3.13 +setenv = PYTHONDEVMODE = 1 +commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} +deps = + pytest + numpy + pandas + wcwidth + [flake8] max-complexity = 22 max-line-length = 99 From 571bc370aa22729440194e6fb7dc149cd169d5fa Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Thu, 26 Sep 2024 12:21:41 -0700 Subject: [PATCH 69/90] Bump GitHub Actions --- .github/workflows/lint.yml | 2 +- .github/workflows/tabulate.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 7e7c906..0093303 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,7 +14,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies diff --git a/.github/workflows/tabulate.yml b/.github/workflows/tabulate.yml index e75466b..fd6004b 100644 --- a/.github/workflows/tabulate.yml +++ b/.github/workflows/tabulate.yml @@ -14,7 +14,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} allow-prereleases: true From 29e0b1f97f012cdc97390b714ebc9c2e2c399774 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Thu, 26 Sep 2024 12:22:19 -0700 Subject: [PATCH 70/90] Fix lint --- test/test_regression.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_regression.py b/test/test_regression.py index 584ea39..bf26247 100644 --- a/test/test_regression.py +++ b/test/test_regression.py @@ -490,7 +490,7 @@ def test_preserve_line_breaks_with_maxcolwidths(): def test_maxcolwidths_accepts_list_or_tuple(): "Regression: maxcolwidths can accept a list or a tuple (github issue #214)" - table = [["lorem ipsum dolor sit amet"]*3] + table = [["lorem ipsum dolor sit amet"] * 3] expected = "\n".join( [ "+-------------+----------+----------------------------+", From 813edee611c0b007940c3287a888408f33949ddd Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Fri, 27 Sep 2024 09:55:46 +0200 Subject: [PATCH 71/90] update README and doctest descriptions of colon_grid introduced in pr #341 --- README.md | 16 ++++++++++++++++ tabulate/__init__.py | 3 ++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 666537a..cab553e 100644 --- a/README.md +++ b/README.md @@ -335,6 +335,22 @@ corresponds to the `pipe` format without alignment colons: ╘════════╧═══════╛ ``` +`colon_grid` is similar to `grid` but uses colons only to define +columnwise content alignment , without whitespace padding, +similar the alignment specification of Pandoc `grid_tables`: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "colon_grid", + ... colalign=["right", "left"])) + +-----------+-----------+ + | strings | numbers | + +==========:+:==========+ + | spam | 41.9999 | + +-----------+-----------+ + | eggs | 451 | + +-----------+-----------+ + + `outline` is the same as the `grid` format but doesn't draw lines between rows: >>> print(tabulate(table, headers, tablefmt="outline")) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index b7510b2..125c477 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1891,7 +1891,8 @@ def tabulate( ╘═══════════╧═══════════╛ "colon_grid" is similar to "grid" but uses colons only to define - columnwise content alignment, with no whitespace padding: + columnwise content alignment, without whitespace padding, + similar to the alignment specification of Pandoc `grid_tables`: >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], ... ["strings", "numbers"], "colon_grid")) From 3cee6568c7a2e8dd6379f11bba2c532f6acc5e3d Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Fri, 27 Sep 2024 10:58:47 +0200 Subject: [PATCH 72/90] update the list of contributors in README.md --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index cab553e..3af3caa 100644 --- a/README.md +++ b/README.md @@ -1156,7 +1156,9 @@ endolith, Dominic Davis-Foster, pavlocat, Daniel Aslau, paulc, Felix Yan, Shane Loretz, Frank Busse, Harsh Singh, Derek Weitzel, Vladimir Vrzić, 서승우 (chrd5273), Georgy Frolov, Christian Cwienk, Bart Broere, Vilhelm Prytz, Alexander Gažo, Hugo van Kemenade, -jamescooke, Matt Warner, Jérôme Provensal, Kevin Deldycke, +jamescooke, Matt Warner, Jérôme Provensal, Michał Górny, Kevin Deldycke, Kian-Meng Ang, Kevin Patterson, Shodhan Save, cleoold, KOLANICH, Vijaya Krishna Kasula, Furcy Pin, Christian Fibich, Shaun Duncan, -Dimitri Papadopoulos, Élie Goudout, Racerroar888, Phill Zarfos. +Dimitri Papadopoulos, Élie Goudout, Racerroar888, Phill Zarfos, +Keyacom, Andrew Coffey, Arpit Jain, Israel Roldan, ilya112358, +Dan Nicholson, Frederik Scheerer, cdar07 (cdar), Racerroar888. \ No newline at end of file From 68292bd4c85557551c4f664f42705baa9d70243f Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Fri, 27 Sep 2024 09:57:12 +0200 Subject: [PATCH 73/90] update CHANGELOG for 0.10.0 version --- CHANGELOG | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index a18a5a1..2737441 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,4 +1,10 @@ -- 0.9.1: Future version. +- 0.10.0: Add support for Python 3.11, 3.12, 3.13. + Drop support for Python 3.7, 3.8. + PRESERVE_STERILITY global is replaced with preserve_sterility function argument. + New formatting options: headersglobalalign, headersalign, colglobalalign. + New output format: ``colon_grid`` (Pandoc grid_tables with alignment) + Various bug fixes. + Improved error messages. - 0.9.0: Drop support for Python 2.7, 3.5, 3.6. Migrate to pyproject.toml project layout (PEP 621). New output formats: `asciidoc`, various `*grid` and `*outline` formats. From 22683f924c2afe28533863e70dd9e8c8611b7e49 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Fri, 27 Sep 2024 10:14:02 +0200 Subject: [PATCH 74/90] remove asciitable from benchmark.py (not installable on Python 3.11), add benchmark_requirements.txt --- benchmark.py | 9 --------- benchmark_requirements.txt | 2 ++ 2 files changed, 2 insertions(+), 9 deletions(-) create mode 100644 benchmark_requirements.txt diff --git a/benchmark.py b/benchmark.py index 8422f5c..a89b709 100644 --- a/benchmark.py +++ b/benchmark.py @@ -1,6 +1,5 @@ from timeit import timeit import tabulate -import asciitable import prettytable import texttable import sys @@ -9,7 +8,6 @@ from csv import writer from io import StringIO import tabulate -import asciitable import prettytable import texttable @@ -34,12 +32,6 @@ def run_prettytable(table): return str(pp) -def run_asciitable(table): - buf = StringIO() - asciitable.write(table, output=buf, Writer=asciitable.FixedWidth) - return buf.getvalue() - - def run_texttable(table): pp = texttable.Texttable() pp.set_cols_align(["l"] + ["r"]*9) @@ -61,7 +53,6 @@ def run_tabulate(table, widechars=False): methods = [ ("join with tabs and newlines", "join_table(table)"), ("csv to StringIO", "csv_table(table)"), - ("asciitable (%s)" % asciitable.__version__, "run_asciitable(table)"), ("tabulate (%s)" % tabulate.__version__, "run_tabulate(table)"), ( "tabulate (%s, WIDE_CHARS_MODE)" % tabulate.__version__, diff --git a/benchmark_requirements.txt b/benchmark_requirements.txt new file mode 100644 index 0000000..81086ef --- /dev/null +++ b/benchmark_requirements.txt @@ -0,0 +1,2 @@ +prettytable +texttable \ No newline at end of file From da896a81031fcb558e3e330503d979b57e3a3dd1 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Fri, 27 Sep 2024 10:16:12 +0200 Subject: [PATCH 75/90] move benchmark files to benchmark/ directory --- benchmark.py => benchmark/benchmark.py | 0 benchmark_requirements.txt => benchmark/requirements.txt | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename benchmark.py => benchmark/benchmark.py (100%) rename benchmark_requirements.txt => benchmark/requirements.txt (100%) diff --git a/benchmark.py b/benchmark/benchmark.py similarity index 100% rename from benchmark.py rename to benchmark/benchmark.py diff --git a/benchmark_requirements.txt b/benchmark/requirements.txt similarity index 100% rename from benchmark_requirements.txt rename to benchmark/requirements.txt From dbfd4219da5bb7fccd50014c8afca4e42bab889a Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Fri, 27 Sep 2024 10:16:26 +0200 Subject: [PATCH 76/90] update HOWTOPUBLISH instructions --- HOWTOPUBLISH | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/HOWTOPUBLISH b/HOWTOPUBLISH index 795fc73..ea0bdca 100644 --- a/HOWTOPUBLISH +++ b/HOWTOPUBLISH @@ -1,7 +1,14 @@ # update contributors and CHANGELOG in README +python -m pre_commit run -a # and then commit changes +tox -e py39-extra,py310-extra,py311-extra,py312-extra,py313-extra # tag version release -python3 benchmark.py # then update README -tox -e py38-extra,py39-extra,py310-extra,py311-extra,py312-extra -python3 -m build -nswx . +python -m build -s # this will update tabulate/version.py +python -m pip install . # install tabulate in the current venv +python -m pip install -r benchmark/requirements.txt +python benchmark/benchmark.py # then update README +# move tag to the last commit +python -m build -s # update tabulate/version.py +python -m build -nswx . +git push # wait for all CI builds to succeed twine upload --repository-url https://test.pypi.org/legacy/ dist/* twine upload dist/* From c3fd38802e463686c58534233e7014df9fe684fe Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Fri, 27 Sep 2024 10:24:04 +0200 Subject: [PATCH 77/90] update benchmark table in the README.md for 0.10.0 --- README.md | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 3af3caa..3a75f26 100644 --- a/README.md +++ b/README.md @@ -1059,21 +1059,19 @@ simply joining lists of values with a tab, comma, or other separator. At the same time, `tabulate` is comparable to other table pretty-printers. Given a 10x10 table (a list of lists) of mixed text and -numeric data, `tabulate` appears to be slower than `asciitable`, and -faster than `PrettyTable` and `texttable` The following mini-benchmark -was run in Python 3.9.13 on Windows 10: - - ================================= ========== =========== - Table formatter time, μs rel. time - ================================= ========== =========== - csv to StringIO 12.5 1.0 - join with tabs and newlines 14.6 1.2 - asciitable (0.8.0) 192.0 15.4 - tabulate (0.9.0) 483.5 38.7 - tabulate (0.9.0, WIDE_CHARS_MODE) 637.6 51.1 - PrettyTable (3.4.1) 1080.6 86.6 - texttable (1.6.4) 1390.3 111.4 - ================================= ========== =========== +numeric data, `tabulate` appears to be faster than `PrettyTable` and `texttable`. +The following mini-benchmark was run in Python 3.11.9 on Windows 11 (x64): + + ================================== ========== =========== + Table formatter time, μs rel. time + ================================== ========== =========== + join with tabs and newlines 6.3 1.0 + csv to StringIO 6.6 1.0 + tabulate (0.10.0) 249.2 39.3 + tabulate (0.10.0, WIDE_CHARS_MODE) 325.6 51.4 + texttable (1.7.0) 579.3 91.5 + PrettyTable (3.11.0) 605.5 95.6 + ================================== ========== =========== Version history From 174a16326951fcf0111ab23352c811ac42214e59 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Fri, 27 Sep 2024 11:07:45 +0200 Subject: [PATCH 78/90] update HOWTOPUBLISH (remember to push tags) --- HOWTOPUBLISH | 1 + 1 file changed, 1 insertion(+) diff --git a/HOWTOPUBLISH b/HOWTOPUBLISH index ea0bdca..ce7dcc7 100644 --- a/HOWTOPUBLISH +++ b/HOWTOPUBLISH @@ -10,5 +10,6 @@ python benchmark/benchmark.py # then update README python -m build -s # update tabulate/version.py python -m build -nswx . git push # wait for all CI builds to succeed +git push --tags # if CI builds succeed twine upload --repository-url https://test.pypi.org/legacy/ dist/* twine upload dist/* From 9ef6b8bf9e4176a36d255c34dee25cc8defb6142 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Fri, 27 Sep 2024 11:18:14 +0200 Subject: [PATCH 79/90] ignore benchmark/benchmark.py in all CI tests --- .github/workflows/tabulate.yml | 2 +- appveyor.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tabulate.yml b/.github/workflows/tabulate.yml index fd6004b..64cfbd8 100644 --- a/.github/workflows/tabulate.yml +++ b/.github/workflows/tabulate.yml @@ -24,4 +24,4 @@ jobs: python -m pip install pytest numpy pandas - name: Run tests run: | - pytest -v --doctest-modules --ignore benchmark.py + pytest -v --doctest-modules --ignore benchmark/benchmark.py diff --git a/appveyor.yml b/appveyor.yml index 12bd772..64463bd 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -33,7 +33,7 @@ test_script: # the interpreter you're using - Appveyor does not do anything special # to put the Python version you want to use on PATH. #- "build.cmd %PYTHON%\\python.exe setup.py test" - - "%PYTHON%\\python.exe -m pytest -v --doctest-modules --ignore benchmark.py" + - "%PYTHON%\\python.exe -m pytest -v --doctest-modules --ignore benchmark\benchmark.py" after_test: # This step builds your wheels. From 46b35b44a206bc2e84208fe7159fba632a301ca8 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Fri, 27 Sep 2024 11:25:09 +0200 Subject: [PATCH 80/90] ignore benchmark folder in all tox environments --- tox.ini | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/tox.ini b/tox.ini index 4ab8244..9605e79 100644 --- a/tox.ini +++ b/tox.ini @@ -20,7 +20,7 @@ python = 3.13: py313-extra [testenv] -commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} +commands = pytest -v --doctest-modules --ignore benchmark {posargs} deps = pytest passenv = @@ -35,13 +35,13 @@ deps = [testenv:py38] basepython = python3.8 -commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} +commands = pytest -v --doctest-modules --ignore benchmark {posargs} deps = pytest [testenv:py38-extra] basepython = python3.8 -commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} +commands = pytest -v --doctest-modules --ignore benchmark {posargs} deps = pytest numpy @@ -51,13 +51,13 @@ deps = [testenv:py39] basepython = python3.9 -commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} +commands = pytest -v --doctest-modules --ignore benchmark {posargs} deps = pytest [testenv:py39-extra] basepython = python3.9 -commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} +commands = pytest -v --doctest-modules --ignore benchmark {posargs} deps = pytest numpy @@ -67,14 +67,14 @@ deps = [testenv:py310] basepython = python3.10 -commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} +commands = pytest -v --doctest-modules --ignore benchmark {posargs} deps = pytest [testenv:py310-extra] basepython = python3.10 setenv = PYTHONDEVMODE = 1 -commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} +commands = pytest -v --doctest-modules --ignore benchmark {posargs} deps = pytest numpy @@ -84,14 +84,14 @@ deps = [testenv:py311] basepython = python3.11 -commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} +commands = pytest -v --doctest-modules --ignore benchmark {posargs} deps = pytest [testenv:py311-extra] basepython = python3.11 setenv = PYTHONDEVMODE = 1 -commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} +commands = pytest -v --doctest-modules --ignore benchmark {posargs} deps = pytest numpy @@ -100,14 +100,14 @@ deps = [testenv:py312] basepython = python3.12 -commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} +commands = pytest -v --doctest-modules --ignore benchmark {posargs} deps = pytest [testenv:py312-extra] basepython = python3.12 setenv = PYTHONDEVMODE = 1 -commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} +commands = pytest -v --doctest-modules --ignore benchmark {posargs} deps = pytest numpy @@ -116,14 +116,14 @@ deps = [testenv:py313] basepython = python3.13 -commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} +commands = pytest -v --doctest-modules --ignore benchmark {posargs} deps = pytest [testenv:py313-extra] basepython = python3.13 setenv = PYTHONDEVMODE = 1 -commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} +commands = pytest -v --doctest-modules --ignore benchmark {posargs} deps = pytest numpy From 8c9c9e75c9a7da858e8ecf72236ee021c69e2d36 Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Thu, 20 Jul 2023 14:16:55 -0600 Subject: [PATCH 81/90] Support better type deduction o Empty/None values are ignored for deducing the type of a column o Comma-separated numbers are allowed in for int and float types --- tabulate/__init__.py | 35 ++++++++++++++++++++++++++++------- test/test_output.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index c349a79..8642c8d 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -933,8 +933,13 @@ def _isbool(string): def _type(string, has_invisible=True, numparse=True): """The least generic type (type(None), int, float, str, unicode). + Treats empty string as missing for the purposes of type deduction, so as to not influence + the type of an otherwise complete column; does *not* result in missingval replacement! + >>> _type(None) is type(None) True + >>> _type("") is type(None) + True >>> _type("foo") is type("") True >>> _type("1") is type(1) @@ -949,15 +954,26 @@ def _type(string, has_invisible=True, numparse=True): if has_invisible and isinstance(string, (str, bytes)): string = _strip_ansi(string) - if string is None: + if string is None or (isinstance(string, (bytes, str)) and not string): return type(None) elif hasattr(string, "isoformat"): # datetime.datetime, date, and time return str elif _isbool(string): return bool - elif _isint(string) and numparse: + elif numparse and ( + _isint(string) or ( + isinstance(string, str) + and _isnumber_with_thousands_separator(string) + and '.' not in string + ) + ): return int - elif _isnumber(string) and numparse: + elif numparse and ( + _isnumber(string) or ( + isinstance(string, str) + and _isnumber_with_thousands_separator(string) + ) + ): return float elif isinstance(string, bytes): return bytes @@ -1251,7 +1267,7 @@ def _column_type(strings, has_invisible=True, numparse=True): def _format(val, valtype, floatfmt, intfmt, missingval="", has_invisible=True): - """Format a value according to its type. + """Format a value according to its deduced type. Empty values are deemed valid for any type. Unicode is supported: @@ -1264,6 +1280,8 @@ def _format(val, valtype, floatfmt, intfmt, missingval="", has_invisible=True): """ # noqa if val is None: return missingval + if isinstance(val, (bytes, str)) and not val: + return "" if valtype is str: return f"{val}" @@ -1298,6 +1316,8 @@ def _format(val, valtype, floatfmt, intfmt, missingval="", has_invisible=True): formatted_val = format(float(raw_val), floatfmt) return val.replace(raw_val, formatted_val) else: + if isinstance(val,str) and ',' in val: + val = val.replace(',', '') # handle thousands-separators return format(float(val), floatfmt) else: return f"{val}" @@ -1592,9 +1612,10 @@ def _wrap_text_to_colwidths(list_of_lists, colwidths, numparses=True): if width is not None: wrapper = _CustomTextWrap(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 + # 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. Also doesn't work for + # custom floatfmt/intfmt, nor with any missing/blank cells. casted_cell = ( str(cell) if _isnumber(cell) else _type(cell, numparse)(cell) ) diff --git a/test/test_output.py b/test/test_output.py index 68b5e55..5b94e82 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -2824,6 +2824,13 @@ def test_floatfmt(): assert_equal(expected, result) +def test_floatfmt_thousands(): + "Output: floating point format" + result = tabulate([["1.23456789"], [1.0], ["1,234.56"]], floatfmt=".3f", tablefmt="plain") + expected = " 1.235\n 1.000\n1234.560" + assert_equal(expected, result) + + def test_floatfmt_multi(): "Output: floating point format different for each column" result = tabulate( @@ -2964,6 +2971,32 @@ def test_missingval_multi(): assert_equal(expected, result) +def test_column_emptymissing_deduction(): + "Missing or empty/blank values shouldn't change type deduction of rest of column" + from fractions import Fraction + + test_table = [ + [None, "1.23423515351", Fraction(1, 3)], + [Fraction(56789, 1000000), 12345.1, b"abc"], + ["", b"", None], + [Fraction(10000, 3), None, ""], + ] + result = tabulate( + test_table, + floatfmt=",.5g", + missingval="?", + ) + print(f"\n{result}") + expected = """\ +------------ ----------- --- + ? 1.2342 1/3 + 0.056789 12,345 abc + ? +3,333.3 ? +------------ ----------- ---""" + assert_equal(expected, result) + + def test_column_alignment(): "Output: custom alignment for text and numbers" expected = "\n".join(["----- ---", "Alice 1", " Bob 333", "----- ---"]) From b39a162ecbb2e9898c2f759e01ddd21f4af7f316 Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Mon, 7 Oct 2024 18:01:46 -0600 Subject: [PATCH 82/90] Add back in check for SEPARATING_LINE --- tabulate/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 6cf6ea3..296974d 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -2561,9 +2561,10 @@ def _format_table( if rows and fmt.linebetweenrows and "linebetweenrows" not in hidden: # initial rows with a line below for row, ralign in zip(rows[:-1], rowaligns): - append_row( - lines, pad_row(row, pad), padded_widths, colaligns, fmt.datarow, rowalign=ralign - ) + if row != SEPARATING_LINE: + append_row( + lines, pad_row(row, pad), padded_widths, colaligns, fmt.datarow, rowalign=ralign + ) _append_line(lines, padded_widths, colaligns, fmt.linebetweenrows) # the last row without a line below append_row( From 588f4121f669d3cde4a5eae741f40bd014eac331 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Tue, 8 Oct 2024 11:36:19 +0200 Subject: [PATCH 83/90] fix backslash escaping in appveyor.yml --- appveyor.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appveyor.yml b/appveyor.yml index 64463bd..85881d4 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -33,7 +33,7 @@ test_script: # the interpreter you're using - Appveyor does not do anything special # to put the Python version you want to use on PATH. #- "build.cmd %PYTHON%\\python.exe setup.py test" - - "%PYTHON%\\python.exe -m pytest -v --doctest-modules --ignore benchmark\benchmark.py" + - "%PYTHON%\\python.exe -m pytest -v --doctest-modules --ignore benchmark\\benchmark.py" after_test: # This step builds your wheels. From ae3d485c734e732d9ace6eefc4fb433ef8ae1005 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Tue, 8 Oct 2024 11:53:07 +0200 Subject: [PATCH 84/90] add windows build to github actions job matrix --- .github/workflows/tabulate.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tabulate.yml b/.github/workflows/tabulate.yml index 64cfbd8..9b113c9 100644 --- a/.github/workflows/tabulate.yml +++ b/.github/workflows/tabulate.yml @@ -6,10 +6,11 @@ on: jobs: build: - runs-on: ubuntu-latest strategy: matrix: python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] + os: ["ubuntu-latest", "windows-latest"] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 From cf45298f3f01ad05264b9ab9a2ad4cbd2c32d9aa Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Tue, 8 Oct 2024 11:56:26 +0200 Subject: [PATCH 85/90] apply black to fix linting errors --- HOWTOPUBLISH | 4 ++-- tabulate/__init__.py | 33 +++++++++++++++++++++------------ test/test_internal.py | 13 ++----------- test/test_output.py | 4 +++- 4 files changed, 28 insertions(+), 26 deletions(-) diff --git a/HOWTOPUBLISH b/HOWTOPUBLISH index ce7dcc7..29c4545 100644 --- a/HOWTOPUBLISH +++ b/HOWTOPUBLISH @@ -4,12 +4,12 @@ tox -e py39-extra,py310-extra,py311-extra,py312-extra,py313-extra # tag version release python -m build -s # this will update tabulate/version.py python -m pip install . # install tabulate in the current venv -python -m pip install -r benchmark/requirements.txt +python -m pip install -r benchmark/requirements.txt python benchmark/benchmark.py # then update README # move tag to the last commit python -m build -s # update tabulate/version.py python -m build -nswx . -git push # wait for all CI builds to succeed +git push # wait for all CI builds to succeed git push --tags # if CI builds succeed twine upload --repository-url https://test.pypi.org/legacy/ dist/* twine upload dist/* diff --git a/tabulate/__init__.py b/tabulate/__init__.py index d6a977a..0d249ac 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -961,18 +961,17 @@ def _type(string, has_invisible=True, numparse=True): elif _isbool(string): return bool elif numparse and ( - _isint(string) or ( + _isint(string) + or ( isinstance(string, str) and _isnumber_with_thousands_separator(string) - and '.' not in string + and "." not in string ) ): return int elif numparse and ( - _isnumber(string) or ( - isinstance(string, str) - and _isnumber_with_thousands_separator(string) - ) + _isnumber(string) + or (isinstance(string, str) and _isnumber_with_thousands_separator(string)) ): return float elif isinstance(string, bytes): @@ -1316,8 +1315,8 @@ def _format(val, valtype, floatfmt, intfmt, missingval="", has_invisible=True): formatted_val = format(float(raw_val), floatfmt) return val.replace(raw_val, formatted_val) else: - if isinstance(val,str) and ',' in val: - val = val.replace(',', '') # handle thousands-separators + if isinstance(val, str) and "," in val: + val = val.replace(",", "") # handle thousands-separators return format(float(val), floatfmt) else: return f"{val}" @@ -2584,7 +2583,12 @@ def _format_table( for row, ralign in zip(rows[:-1], rowaligns): if row != SEPARATING_LINE: append_row( - lines, pad_row(row, pad), padded_widths, colaligns, fmt.datarow, rowalign=ralign + lines, + pad_row(row, pad), + padded_widths, + colaligns, + fmt.datarow, + rowalign=ralign, ) _append_line(lines, padded_widths, colaligns, fmt.linebetweenrows) # the last row without a line below @@ -2610,7 +2614,9 @@ def _format_table( if _is_separating_line(row): _append_line(lines, padded_widths, colaligns, separating_line) else: - append_row(lines, pad_row(row, pad), padded_widths, colaligns, fmt.datarow) + append_row( + lines, pad_row(row, pad), padded_widths, colaligns, fmt.datarow + ) if fmt.linebelow and "linebelow" not in hidden: _append_line(lines, padded_widths, colaligns, fmt.linebelow) @@ -2705,11 +2711,14 @@ def _handle_long_word(self, reversed_chunks, cur_line, cur_len, width): if _ansi_codes.search(chunk) is not None: for group, _, _, _ in _ansi_codes.findall(chunk): escape_len = len(group) - if group in chunk[last_group: i + total_escape_len + escape_len - 1]: + if ( + group + in chunk[last_group : i + total_escape_len + escape_len - 1] + ): total_escape_len += escape_len found = _ansi_codes.search(chunk[last_group:]) last_group += found.end() - cur_line.append(chunk[: i + total_escape_len - 1]) + cur_line.append(chunk[: i + total_escape_len - 1]) reversed_chunks[-1] = chunk[i + total_escape_len - 1 :] # Otherwise, we have to preserve the long word intact. Only add diff --git a/test/test_internal.py b/test/test_internal.py index 17107c6..e7564d3 100644 --- a/test/test_internal.py +++ b/test/test_internal.py @@ -180,9 +180,7 @@ def test_wrap_text_wide_chars(): except ImportError: skip("test_wrap_text_wide_chars is skipped") - rows = [ - ["청자청자청자청자청자", "약간 감싸면 더 잘 보일 수있는 다소 긴 설명입니다"] - ] + rows = [["청자청자청자청자청자", "약간 감싸면 더 잘 보일 수있는 다소 긴 설명입니다"]] widths = [5, 20] expected = [ [ @@ -246,14 +244,7 @@ def test_wrap_text_to_colwidths_colors_wide_char(): except ImportError: skip("test_wrap_text_to_colwidths_colors_wide_char is skipped") - data = [ - [ - ( - "\033[31m약간 감싸면 더 잘 보일 수있는 다소 긴" - " 설명입니다 설명입니다 설명입니다 설명입니다 설명\033[0m" - ) - ] - ] + data = [[("\033[31m약간 감싸면 더 잘 보일 수있는 다소 긴" " 설명입니다 설명입니다 설명입니다 설명입니다 설명\033[0m")]] result = T._wrap_text_to_colwidths(data, [30]) expected = [ diff --git a/test/test_output.py b/test/test_output.py index bba7622..e3d369a 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -2863,7 +2863,9 @@ def test_floatfmt(): def test_floatfmt_thousands(): "Output: floating point format" - result = tabulate([["1.23456789"], [1.0], ["1,234.56"]], floatfmt=".3f", tablefmt="plain") + result = tabulate( + [["1.23456789"], [1.0], ["1,234.56"]], floatfmt=".3f", tablefmt="plain" + ) expected = " 1.235\n 1.000\n1234.560" assert_equal(expected, result) From a81f6b0a032f84f9225bdfdf375d255ab8e847d3 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Tue, 8 Oct 2024 11:59:15 +0200 Subject: [PATCH 86/90] update the list of contributors --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3a75f26..28ef166 100644 --- a/README.md +++ b/README.md @@ -1159,4 +1159,5 @@ Kian-Meng Ang, Kevin Patterson, Shodhan Save, cleoold, KOLANICH, Vijaya Krishna Kasula, Furcy Pin, Christian Fibich, Shaun Duncan, Dimitri Papadopoulos, Élie Goudout, Racerroar888, Phill Zarfos, Keyacom, Andrew Coffey, Arpit Jain, Israel Roldan, ilya112358, -Dan Nicholson, Frederik Scheerer, cdar07 (cdar), Racerroar888. \ No newline at end of file +Dan Nicholson, Frederik Scheerer, cdar07 (cdar), Racerroar888, +Perry Kundert. From 3c43178313bc80dc45337e3931c4c0cfdfc032f4 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Tue, 8 Oct 2024 12:00:07 +0200 Subject: [PATCH 87/90] add macos build to github actions job matrix --- .github/workflows/tabulate.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tabulate.yml b/.github/workflows/tabulate.yml index 9b113c9..c459484 100644 --- a/.github/workflows/tabulate.yml +++ b/.github/workflows/tabulate.yml @@ -9,7 +9,7 @@ jobs: strategy: matrix: python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] - os: ["ubuntu-latest", "windows-latest"] + os: ["ubuntu-latest", "windows-latest", "macos-latest"] runs-on: ${{ matrix.os }} steps: From 1de098247fb374521448893765f0a4962cdf3147 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Tue, 8 Oct 2024 12:08:25 +0200 Subject: [PATCH 88/90] remove appveyor.yml and appveyor badge --- README.md | 2 +- appveyor.yml | 51 --------------------------------------------------- 2 files changed, 1 insertion(+), 52 deletions(-) delete mode 100644 appveyor.yml diff --git a/README.md b/README.md index 28ef166..b1b56d5 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ pip install tabulate Build status ------------ -[![python-tabulate](https://github.com/astanin/python-tabulate/actions/workflows/tabulate.yml/badge.svg)](https://github.com/astanin/python-tabulate/actions/workflows/tabulate.yml) [![Build status](https://ci.appveyor.com/api/projects/status/8745yksvvol7h3d7/branch/master?svg=true)](https://ci.appveyor.com/project/astanin/python-tabulate/branch/master) +[![python-tabulate](https://github.com/astanin/python-tabulate/actions/workflows/tabulate.yml/badge.svg)](https://github.com/astanin/python-tabulate/actions/workflows/tabulate.yml) Library usage ------------- diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index 85881d4..0000000 --- a/appveyor.yml +++ /dev/null @@ -1,51 +0,0 @@ -image: Visual Studio 2022 -environment: - - matrix: - - # For Python versions available on Appveyor, see - # https://www.appveyor.com/docs/windows-images-software/#python - # The list here is complete (excluding Python 2.6, which - # isn't covered by this document) at the time of writing. - - #- PYTHON: "C:\\Python39" - #- PYTHON: "C:\\Python310" - #- PYTHON: "C:\\Python311" - #- PYTHON: "C:\\Python312" - - PYTHON: "C:\\Python39-x64" - - PYTHON: "C:\\Python310-x64" - - PYTHON: "C:\\Python311-x64" - - PYTHON: "C:\\Python312-x64" - -install: - # Newer setuptools is needed for proper support of pyproject.toml - - "%PYTHON%\\python.exe -m pip install setuptools --upgrade" - # We need wheel installed to build wheels - - "%PYTHON%\\python.exe -m pip install wheel --upgrade" - - "%PYTHON%\\python.exe -m pip install build setuptools_scm" - - "%PYTHON%\\python.exe -m pip install pytest numpy pandas" - -build: off - -test_script: - # Put your test command here. - # Note that you must use the environment variable %PYTHON% to refer to - # the interpreter you're using - Appveyor does not do anything special - # to put the Python version you want to use on PATH. - #- "build.cmd %PYTHON%\\python.exe setup.py test" - - "%PYTHON%\\python.exe -m pytest -v --doctest-modules --ignore benchmark\\benchmark.py" - -after_test: - # This step builds your wheels. - # Again, you need to use %PYTHON% to get the correct interpreter - #- "build.cmd %PYTHON%\\python.exe setup.py bdist_wheel" - - "%PYTHON%\\python.exe -m build -nswx ." - -artifacts: - # bdist_wheel puts your built wheel in the dist directory - - path: dist\* - -#on_success: -# You can use this step to upload your artifacts to a public website. -# See Appveyor's documentation for more details. Or you can simply -# access your wheels from the Appveyor "artifacts" tab for your build. From c37de756a0ccfa9f23dae5aa2281e813f42fef02 Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Tue, 8 Oct 2024 09:21:09 -0600 Subject: [PATCH 89/90] Correct README.md examples and outputs using doctest --- README.md | 128 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 104 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index b1b56d5..70b6839 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ Earth 6371 5973.6 Moon 1737 73.5 Mars 3390 641.85 ----- ------ ------------- + ``` The following tabular data types are supported: @@ -101,6 +102,7 @@ Sun 696000 1.9891e+09 Earth 6371 5973.6 Moon 1737 73.5 Mars 3390 641.85 + ``` If `headers="firstrow"`, then the first row of data is used: @@ -112,6 +114,7 @@ Name Age ------ ----- Alice 24 Bob 19 + ``` If `headers="keys"`, then the keys of a dictionary/dataframe, or column @@ -125,6 +128,7 @@ Name Age ------ ----- Alice 24 Bob 19 + ``` When data is a list of dictionaries, a dictionary can be passed as `headers` @@ -137,6 +141,7 @@ Name Age ------ ----- Alice 24 Bob 19 + ``` ### Row Indices @@ -154,6 +159,7 @@ or `showindex=False`. To add a custom row index column, pass 0 F 24 1 M 19 - - -- + ``` ### Table format @@ -210,6 +216,7 @@ item qty spam 42 eggs 451 bacon 0 + ``` `simple` is the default format (the default may change in future @@ -223,6 +230,7 @@ item qty spam 42 eggs 451 bacon 0 + ``` `github` follows the conventions of GitHub flavored Markdown. It @@ -230,11 +238,12 @@ corresponds to the `pipe` format without alignment colons: ```pycon >>> print(tabulate(table, headers, tablefmt="github")) -| item | qty | +| item | qty | |--------|-------| -| spam | 42 | -| eggs | 451 | -| bacon | 0 | +| spam | 42 | +| eggs | 451 | +| bacon | 0 | + ``` `grid` is like tables formatted by Emacs' @@ -252,6 +261,7 @@ corresponds to the `pipe` format without alignment colons: +--------+-------+ | bacon | 0 | +--------+-------+ + ``` `simple_grid` draws a grid using single-line box-drawing characters: @@ -333,6 +343,7 @@ corresponds to the `pipe` format without alignment colons: ├────────┼───────┤ │ bacon │ 0 │ ╘════════╧═══════╛ + ``` `colon_grid` is similar to `grid` but uses colons only to define @@ -437,6 +448,7 @@ similar the alignment specification of Pandoc `grid_tables`: spam | 42 eggs | 451 bacon | 0 + ``` `pretty` attempts to be close to the format emitted by the PrettyTables @@ -451,6 +463,7 @@ library: | eggs | 451 | | bacon | 0 | +-------+-----+ + ``` `psql` is like tables formatted by Postgres' psql cli: @@ -464,6 +477,7 @@ library: | eggs | 451 | | bacon | 0 | +--------+-------+ + ``` `pipe` follows the conventions of [PHP Markdown @@ -478,6 +492,7 @@ indicate column alignment: | spam | 42 | | eggs | 451 | | bacon | 0 | + ``` `asciidoc` formats data like a simple table of the @@ -488,11 +503,12 @@ format: >>> print(tabulate(table, headers, tablefmt="asciidoc")) [cols="8<,7>",options="header"] |==== -| item | qty -| spam | 42 -| eggs | 451 -| bacon | 0 +| item | qty +| spam | 42 +| eggs | 451 +| bacon | 0 |==== + ``` `orgtbl` follows the conventions of Emacs @@ -506,6 +522,7 @@ in the minor orgtbl-mode. Hence its name: | spam | 42 | | eggs | 451 | | bacon | 0 | + ``` `jira` follows the conventions of Atlassian Jira markup language: @@ -516,6 +533,7 @@ in the minor orgtbl-mode. Hence its name: | spam | 42 | | eggs | 451 | | bacon | 0 | + ``` `rst` formats data like a simple table of the @@ -531,6 +549,7 @@ spam 42 eggs 451 bacon 0 ====== ===== + ``` `mediawiki` format produces a table markup used in @@ -550,6 +569,7 @@ MediaWiki-based sites: |- | bacon || style="text-align: right;"| 0 |} + ``` `moinmoin` format produces a table markup used in @@ -557,20 +577,22 @@ MediaWiki-based sites: ```pycon >>> print(tabulate(table, headers, tablefmt="moinmoin")) -|| ''' item ''' || ''' quantity ''' || -|| spam || 41.999 || -|| eggs || 451 || -|| bacon || || +|| ''' item ''' || ''' qty ''' || +|| spam || 42 || +|| eggs || 451 || +|| bacon || 0 || + ``` `youtrack` format produces a table markup used in Youtrack tickets: ```pycon >>> print(tabulate(table, headers, tablefmt="youtrack")) -|| item || quantity || -| spam | 41.999 | -| eggs | 451 | -| bacon | | +|| item || qty || +| spam | 42 | +| eggs | 451 | +| bacon | 0 | + ``` `textile` format produces a table markup used in @@ -582,6 +604,7 @@ MediaWiki-based sites: |<. spam |>. 42 | |<. eggs |>. 451 | |<. bacon |>. 0 | + ``` `html` produces standard HTML markup as an html.escape'd str @@ -592,13 +615,16 @@ and a .str property so that the raw HTML remains accessible. ```pycon >>> print(tabulate(table, headers, tablefmt="html")) - + + +
item qty
spam 42
eggs 451
bacon 0
+ ``` `latex` format creates a `tabular` environment for LaTeX markup, @@ -616,6 +642,7 @@ correspondents: bacon & 0 \\ \hline \end{tabular} + ``` `latex_raw` behaves like `latex` but does not escape LaTeX commands and @@ -650,6 +677,7 @@ at a glance: 12345 1234.5 ---------- + ``` Compare this with a more common right alignment: @@ -663,6 +691,7 @@ Compare this with a more common right alignment: 12345 1234.5 ------ + ``` For `tabulate`, anything which can be parsed as a number is a number. @@ -671,7 +700,7 @@ comes in handy when reading a mixed table of text and numbers from a file: ```pycon ->>> import csv ; from StringIO import StringIO +>>> import csv; from io import StringIO >>> table = list(csv.reader(StringIO("spam, 42\neggs, 451\n"))) >>> table [['spam', ' 42'], ['eggs', ' 451']] @@ -680,16 +709,18 @@ file: spam 42 eggs 451 ---- ---- + ``` To disable this feature use `disable_numparse=True`. ```pycon ->>> print(tabulate.tabulate([["Ver1", "18.0"], ["Ver2","19.2"]], tablefmt="simple", disable_numparse=True)) +>>> print(tabulate([["Ver1", "18.0"], ["Ver2","19.2"]], tablefmt="simple", disable_numparse=True)) ---- ---- Ver1 18.0 Ver2 19.2 ---- ---- + ``` ### Custom column alignment @@ -704,6 +735,7 @@ Furthermore, you can define `colalign` for column-specific alignment as a list o 1 2 3 4 111 222 333 444 --- --- --- --- + ``` ### Custom header alignment @@ -714,11 +746,11 @@ Headers' alignment can be defined separately from columns'. Like for columns, yo ```pycon >>> print(tabulate([[1,2,3,4,5,6],[111,222,333,444,555,666]], colglobalalign = 'center', colalign = ('left',), headers = ['h','e','a','d','e','r'], headersglobalalign = 'right', headersalign = ('same','same','left','global','center'))) - h e a d e r --- --- --- --- --- --- 1 2 3 4 5 6 111 222 333 444 555 666 + ``` ### Number formatting @@ -732,6 +764,7 @@ columns of decimal numbers. Use `floatfmt` named argument: pi 3.1416 e 2.7183 -- ------ + ``` `floatfmt` argument can be a list or a tuple of format strings, one per @@ -742,6 +775,7 @@ column, in which case every column may have different number formatting: --- ----- ------- 0.1 0.123 0.12345 --- ----- ------- + ``` `intfmt` works similarly for integers @@ -752,6 +786,33 @@ column, in which case every column may have different number formatting: b 90,000 - ------ + +### Type Deduction and Missing Values + +When `tabulate` sees numerical data (with our without comma separators), it +attempts to align the column on the decimal point. However, if it observes +non-numerical data in the column, it aligns it to the left by default. If +data is missing in a column (either None or empty values), the remaining +data in the column is used to infer the type: + +```pycon +>>> from fractions import Fraction +>>> test_table = [ +... [None, "1.23423515351", Fraction(1, 3)], +... [Fraction(56789, 1000000), 12345.1, b"abc"], +... ["", b"", None], +... [Fraction(10000, 3), None, ""], +... ] +>>> print(tabulate(test_table, floatfmt=",.5g", missingval="?")) +------------ ----------- --- + ? 1.2342 1/3 + 0.056789 12,345 abc + ? +3,333.3 ? +------------ ----------- --- + +``` + ### Text formatting By default, `tabulate` removes leading and trailing whitespace from text @@ -802,6 +863,7 @@ a multiline cell, and headers with a multiline cell: ```pycon >>> table = [["eggs",451],["more\nspam",42]] >>> headers = ["item\nname", "qty"] + ``` `plain` tables: @@ -813,6 +875,7 @@ name eggs 451 more 42 spam + ``` `simple` tables: @@ -825,6 +888,7 @@ name eggs 451 more 42 spam + ``` `grid` tables: @@ -840,6 +904,7 @@ spam | more | 42 | | spam | | +--------+-------+ + ``` `fancy_grid` tables: @@ -855,6 +920,7 @@ spam │ more │ 42 │ │ spam │ │ ╘════════╧═══════╛ + ``` `pipe` tables: @@ -867,6 +933,7 @@ spam | eggs | 451 | | more | 42 | | spam | | + ``` `orgtbl` tables: @@ -879,18 +946,19 @@ spam | eggs | 451 | | more | 42 | | spam | | + ``` `jira` tables: ```pycon >>> print(tabulate(table, headers, tablefmt="jira")) -| item | qty | -| name | | -|:-------|------:| +|| item || qty || +|| name || || | eggs | 451 | | more | 42 | | spam | | + ``` `presto` tables: @@ -903,6 +971,7 @@ spam eggs | 451 more | 42 spam | + ``` `pretty` tables: @@ -917,6 +986,7 @@ spam | more | 42 | | spam | | +------+-----+ + ``` `psql` tables: @@ -931,6 +1001,7 @@ spam | more | 42 | | spam | | +--------+-------+ + ``` `rst` tables: @@ -945,6 +1016,7 @@ eggs 451 more 42 spam ====== ===== + ``` Multiline cells are not well-supported for the other table formats. @@ -974,6 +1046,7 @@ the lines being wrapped would probably be significantly longer than this. | John Smith | Middle | | | Manager | +------------+---------+ + ``` ### Adding Separating lines @@ -1016,7 +1089,7 @@ hyperlinks is that column width will be based on the length of the URL _text_ ra itself (terminals would show this text). For example: >>> len('\x1b]8;;https://example.com\x1b\\example\x1b]8;;\x1b\\') # display length is 7, showing 'example' - 45 + 40 Usage of the command line utility @@ -1136,6 +1209,13 @@ tox -e lint See `tox.ini` file to learn how to use to test individual Python versions. +To test the "doctest" examples and their outputs in `README.md`: + +```shell +python3 -m pip install pytest-doctestplus[md] +python3 -m doctest README.md +``` + Contributors ------------ From 232ed74a4be2806bda886739a20a703b9c1043ea Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Tue, 8 Oct 2024 12:49:37 -0600 Subject: [PATCH 90/90] Begin trying to simplify _isnumber --- README.md | 14 +++++++++++++ tabulate/__init__.py | 48 +++++++++++++++++++++++++++++++++++--------- 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 70b6839..f3d0fa9 100644 --- a/README.md +++ b/README.md @@ -813,6 +813,20 @@ data in the column is used to infer the type: ``` +The deduced type (eg. str, float) influences the rendering of any types +that have alternative representations. For example, since `Fraction` has +methods `__str__` and `__float__` defined (and hence is convertible to a +`float` and also has a `str` representation), the appropriate +representation is selected for the column's deduced type. In order to not +lose precision accidentally, types having both an `__int__` and +`__float__` represention will be considered a `float`. + +Therefore, if your table contains types convertible to int/float but you'd +*prefer* they be represented as strings, or your strings *might* all look +like numbers such as "1e23": either convert them to the desired +representation before you `tabulate`, or ensure that the column always +contains at least one other `str`. + ### Text formatting By default, `tabulate` removes leading and trailing whitespace from text diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 0d249ac..b15d7f0 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -876,25 +876,55 @@ def _isconvertible(conv, string): def _isnumber(string): - """ + """Detects if something *could* be considered a numeric value, vs. just a string. + + This promotes types convertible to both int and float to be considered + a float. Note that, iff *all* values appear to be some form of numeric + value such as eg. "1e2", they would be considered numbers! + + The exception is things that appear to be numbers but overflow to + +/-inf, eg. "1e23456"; we'll have to exclude them explicitly. + + >>> _isnumber(123) + True + >>> _isnumber(123.45) + True >>> _isnumber("123.45") True >>> _isnumber("123") True >>> _isnumber("spam") False - >>> _isnumber("123e45678") + >>> _isnumber("123e45") + True + >>> _isnumber("123e45678") # evaluates equal to 'inf', but ... isn't False >>> _isnumber("inf") True + >>> from fractions import Fraction + >>> _isnumber(Fraction(1,3)) + 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 + return ( + # fast path + type(string) in (float, int) + # covers 'NaN', +/- 'inf', and eg. '1e2', as well as any type + # convertible to int/float. + or ( + _isconvertible(float, string) + and ( + # some other type convertible to float + not isinstance(string, (str, bytes)) + # or, a numeric string eg. "1e1...", "NaN", ..., but isn't + # just an over/underflow + or ( + not (math.isinf(float(string)) or math.isnan(float(string))) + or string.lower() in ["inf", "-inf", "nan"] + ) + ) + ) + ) def _isint(string, inttype=int):