From 5fb1fcd3ac49f78a2398474b6d1fad9f69ae40e4 Mon Sep 17 00:00:00 2001 From: Derek Weitzel Date: Wed, 15 Apr 2020 19:16:58 -0500 Subject: [PATCH 001/207] Recognize numbers with comma separators --- tabulate.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tabulate.py b/tabulate.py index 5d57167..5fb48f1 100755 --- a/tabulate.py +++ b/tabulate.py @@ -582,8 +582,16 @@ def _isnumber(string): False >>> _isnumber("inf") True + >>> _isnumber("1,234.56") + True + >>> _isnumber("1,234,578.0") + True """ - if not _isconvertible(float, string): + if isinstance(string, (_text_type)) and "," in string and ( + _isconvertible(float, string.replace(",","")) + ): + return True + elif not _isconvertible(float, string): return False elif isinstance(string, (_text_type, _binary_type)) and ( math.isinf(float(string)) or math.isnan(float(string)) From 9a07e572d28838268b66c1bc835685cd515cb55e Mon Sep 17 00:00:00 2001 From: Bart Broere Date: Wed, 6 May 2020 17:20:40 +0200 Subject: [PATCH 002/207] Add longtable support as a separate table format --- tabulate.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tabulate.py b/tabulate.py index 5d57167..95f0aa6 100755 --- a/tabulate.py +++ b/tabulate.py @@ -219,12 +219,13 @@ def _moin_row_with_attrs(celltag, cell_values, colwidths, colaligns, header=""): return "".join(values_with_attrs) + "||" -def _latex_line_begin_tabular(colwidths, colaligns, booktabs=False): +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}{" + tabular_columns_fmt + "}", + ("\\begin{tabular}{" if not longtable + else "\\begin{longtable}{") + tabular_columns_fmt + "}", "\\toprule" if booktabs else "\\hline", ] ) @@ -480,6 +481,16 @@ def escape_empty(val): 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, From df0752f510f54c5d1446569879ba43aca1f0747b Mon Sep 17 00:00:00 2001 From: Bart Broere Date: Wed, 6 May 2020 17:27:17 +0200 Subject: [PATCH 003/207] Follow HOWTOPUBLISH guide --- CHANGELOG | 1 + README.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index 4a489ca..012c4c5 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,4 +1,5 @@ - 0.8.8: Future version. + Add ``latex_longtable`` format. - 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. diff --git a/README.md b/README.md index ce06dad..af6ea29 100644 --- a/README.md +++ b/README.md @@ -744,4 +744,4 @@ Maier, Andy MacKinlay, Thomas Roten, Jue Wang, Joe King, Samuel Phan, Nick Satterly, Daniel Robbins, Dmitry B, Lars Butler, Andreas Maier, Dick Marinus, Sébastien Celles, Yago González, Andrew Gaul, Wim Glenn, Jean Michel Rouly, Tim Gates, John Vandenberg, Sorin Sbarnea, -Wes Turner, Andrew Tija, Marco Gorelli, Sean McGinnis, danja100. +Wes Turner, Andrew Tija, Marco Gorelli, Sean McGinnis, danja100, Bart Broere. From 623540b76bed92ffdfe8c82f943333df4db986eb Mon Sep 17 00:00:00 2001 From: Bart Broere Date: Wed, 6 May 2020 17:33:35 +0200 Subject: [PATCH 004/207] Mention latex_longtable everywhere it's expected --- README.md | 5 ++++- tabulate.py | 18 +++++++++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index af6ea29..0f3fe63 100644 --- a/README.md +++ b/README.md @@ -369,6 +369,9 @@ special characters. `latex_booktabs` creates a `tabular` environment for LaTeX markup using spacing and style from the `booktabs` package. +`latex_longtable` creates a table that can stretch along multiple pages, +using the `longtable` package. + ### Column alignment `tabulate` is smart about column alignment. It detects columns which @@ -651,7 +654,7 @@ Usage of the command line utility -f FMT, --format FMT set output table format; supported formats: plain, simple, github, grid, fancy_grid, pipe, orgtbl, rst, mediawiki, html, latex, latex_raw, - latex_booktabs, tsv + latex_booktabs, latex_longtable, tsv (default: simple) Performance considerations diff --git a/tabulate.py b/tabulate.py index 95f0aa6..961e175 100755 --- a/tabulate.py +++ b/tabulate.py @@ -1248,8 +1248,8 @@ def tabulate( Various plain-text table formats (`tablefmt`) are supported: 'plain', 'simple', 'grid', 'pipe', 'orgtbl', 'rst', 'mediawiki', - 'latex', 'latex_raw' and 'latex_booktabs'. Variable `tabulate_formats` - contains the list of currently supported formats. + '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: @@ -1435,6 +1435,18 @@ def tabulate( \\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. @@ -1716,7 +1728,7 @@ def _main(): -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, tsv + latex_booktabs, latex_longtable, tsv (default: simple) """ import getopt From 917944047a87c7904a8caa8d9c212bb4bbce3ac0 Mon Sep 17 00:00:00 2001 From: Bart Broere Date: Wed, 6 May 2020 17:38:32 +0200 Subject: [PATCH 005/207] Run black --- tabulate.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tabulate.py b/tabulate.py index 961e175..04475bb 100755 --- a/tabulate.py +++ b/tabulate.py @@ -224,8 +224,9 @@ def _latex_line_begin_tabular(colwidths, colaligns, booktabs=False, longtable=Fa 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 + "}", + ("\\begin{tabular}{" if not longtable else "\\begin{longtable}{") + + tabular_columns_fmt + + "}", "\\toprule" if booktabs else "\\hline", ] ) From fef7f4221af2d8f2df48172ff6ec70e1980b37d4 Mon Sep 17 00:00:00 2001 From: Bart Broere <2715782+bartbroere@users.noreply.github.com> Date: Wed, 6 May 2020 17:41:49 +0200 Subject: [PATCH 006/207] Add latex_longtable to a spot where it was missing --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 0f3fe63..79423b7 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,9 @@ Supported table formats are: - "latex" - "latex\_raw" - "latex\_booktabs" +- "latex\_longtable" - "textile" +- "tsv" `plain` tables do not use any pseudo-graphics to draw lines: From 94e6757c0e90cfda5f91d55d4d05b3d4cda46393 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=84=9C=EC=8A=B9=EC=9A=B0?= Date: Fri, 22 May 2020 13:17:06 +0900 Subject: [PATCH 007/207] Fixed broken table when each line of multi-line item contains different number of full-width characters --- tabulate.py | 45 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 6 deletions(-) mode change 100755 => 100644 tabulate.py diff --git a/tabulate.py b/tabulate.py old mode 100755 new mode 100644 index 5d57167..a86383f --- a/tabulate.py +++ b/tabulate.py @@ -802,6 +802,36 @@ def _align_column_choose_padfn(strings, alignment, has_invisible): 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, @@ -812,10 +842,10 @@ def _align_column( ): """[string] -> [padded_string]""" strings, padfn = _align_column_choose_padfn(strings, alignment, has_invisible) - width_fn = _choose_width_fn(has_invisible, enable_widechars, is_multiline) + width_fn = _align_column_choose_width_fn(has_invisible, enable_widechars, is_multiline) s_widths = list(map(width_fn, strings)) - maxwidth = max(max(s_widths), minwidth) + 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: @@ -825,13 +855,16 @@ def _align_column( ] else: # enable wide-character width corrections - s_lens = [max((len(s) for s in re.split("[\r\n]", ms))) for ms in strings] - visible_widths = [maxwidth - (w - l) for w, l in zip(s_widths, s_lens)] + 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 in (ms.splitlines() or ms)]) - for ms, w in zip(strings, visible_widths) + "\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: From 56725b5798e946227bb27caae7481a0cdf06d9ba Mon Sep 17 00:00:00 2001 From: Georgy Frolov Date: Thu, 28 May 2020 21:53:01 +0300 Subject: [PATCH 008/207] allow to specify align in pretty formatter --- tabulate.py | 14 ++++++++++---- test/test_api.py | 4 ++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/tabulate.py b/tabulate.py index 5d57167..d1d39cd 100755 --- a/tabulate.py +++ b/tabulate.py @@ -73,6 +73,9 @@ def _is_file(f): _DEFAULT_FLOATFMT = "g" _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 @@ -1146,8 +1149,8 @@ def tabulate( headers=(), tablefmt="simple", floatfmt=_DEFAULT_FLOATFMT, - numalign="decimal", - stralign="left", + numalign=_DEFAULT_ALIGN, + stralign=_DEFAULT_ALIGN, missingval=_DEFAULT_MISSINGVAL, showindex="default", disable_numparse=False, @@ -1458,8 +1461,11 @@ def tabulate( if tablefmt == "pretty": min_padding = 0 disable_numparse = True - numalign = "center" - stralign = "center" + 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 diff --git a/test/test_api.py b/test/test_api.py index 9b5d74f..bf56e82 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -46,8 +46,8 @@ def test_tabulate_signature(): ("headers", ()), ("tablefmt", "simple"), ("floatfmt", "g"), - ("numalign", "decimal"), - ("stralign", "left"), + ("numalign", "default"), + ("stralign", "default"), ("missingval", ""), ] _check_signature(tabulate, expected_sig) From e5d0cffde702b08b52cf0363b1959c3d07f3b185 Mon Sep 17 00:00:00 2001 From: paulc Date: Sun, 7 Jun 2020 15:14:11 +0100 Subject: [PATCH 009/207] Support list of UserDicts (or other dict-like objects) --- tabulate.py | 4 ++-- test/test_input.py | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/tabulate.py b/tabulate.py index 5d57167..2e10dac 100755 --- a/tabulate.py +++ b/tabulate.py @@ -1058,8 +1058,8 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): ): # namedtuple headers = list(map(_text_type, rows[0]._fields)) - elif len(rows) > 0 and isinstance(rows[0], dict): - # dict or OrderedDict + 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": diff --git a/test/test_input.py b/test/test_input.py index ee00a7b..d651dbb 100644 --- a/test/test_input.py +++ b/test/test_input.py @@ -6,6 +6,7 @@ from __future__ import unicode_literals from tabulate import tabulate from common import assert_equal, assert_in, raises, skip +from collections import UserDict def test_iterable_of_iterables(): @@ -377,6 +378,13 @@ def test_list_of_dicts(): result = tabulate(lod) assert_in(result, [expected1, expected2]) +def test_list_of_userdicts(): + "Input: a list of UserDicts." + lod = [UserDict(foo=1, bar=2), UserDict(foo=3, bar=4)] + expected1 = "\n".join(["- -", "1 2", "3 4", "- -"]) + expected2 = "\n".join(["- -", "2 1", "4 3", "- -"]) + result = tabulate(lod) + assert_in(result, [expected1, expected2]) def test_list_of_dicts_keys(): "Input: a list of dictionaries, with keys as headers." @@ -390,6 +398,17 @@ def test_list_of_dicts_keys(): result = tabulate(lod, headers="keys") assert_in(result, [expected1, expected2]) +def test_list_of_userdicts_keys(): + "Input: a list of UserDicts." + lod = [UserDict(foo=1, bar=2), UserDict(foo=3, bar=4)] + expected1 = "\n".join( + [" foo bar", "----- -----", " 1 2", " 3 4"] + ) + expected2 = "\n".join( + [" bar foo", "----- -----", " 2 1", " 4 3"] + ) + result = tabulate(lod, headers="keys") + assert_in(result, [expected1, expected2]) def test_list_of_dicts_with_missing_keys(): "Input: a list of dictionaries, with missing keys." From eb6f54c73aa84e88388fdd71d52dfe3fe0d4386f Mon Sep 17 00:00:00 2001 From: paulc Date: Tue, 9 Jun 2020 10:27:12 +0100 Subject: [PATCH 010/207] Python2 compatibility for UserDict import --- test/test_input.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/test_input.py b/test/test_input.py index d651dbb..17f5b08 100644 --- a/test/test_input.py +++ b/test/test_input.py @@ -6,7 +6,11 @@ from __future__ import unicode_literals from tabulate import tabulate from common import assert_equal, assert_in, raises, skip -from collections import UserDict +try: + from collections import UserDict +except ImportError: + # Python2 + from UserDict import UserDict def test_iterable_of_iterables(): From 8c7df939ce8993891eed8e7c27c14aaab539a050 Mon Sep 17 00:00:00 2001 From: Christian Cwienk Date: Tue, 9 Jun 2020 18:22:49 +0200 Subject: [PATCH 011/207] fix symlink --- README | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README b/README index b43bf86..42061c0 120000 --- a/README +++ b/README @@ -1 +1 @@ -README.md +README.md \ No newline at end of file From 388188975798da8a8f7af0a864be308c7ee47a56 Mon Sep 17 00:00:00 2001 From: "daniel.aslau" Date: Mon, 3 Aug 2020 23:26:52 +0200 Subject: [PATCH 012/207] ignore hyperlinks --- tabulate.py | 4 +-- test/test_output.py | 71 +++++++++++++++++++++++++++++++++++++++++ test/test_regression.py | 46 ++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 2 deletions(-) diff --git a/tabulate.py b/tabulate.py index 5d57167..cbeb766 100755 --- a/tabulate.py +++ b/tabulate.py @@ -535,10 +535,10 @@ def escape_empty(val): _multiline_codes = re.compile(r"\r|\n|\r\n") _multiline_codes_bytes = re.compile(b"\r|\n|\r\n") _invisible_codes = re.compile( - r"\x1b\[\d+[;\d]*m|\x1b\[\d*\;\d*\;\d*m" + r"\x1b\[\d+[;\d]*m|\x1b\[\d*\;\d*\;\d*m|\x1b\]8;;(.*?)\x1b\\" ) # ANSI color codes _invisible_codes_bytes = re.compile( - b"\x1b\\[\\d+\\[;\\d]*m|\x1b\\[\\d*;\\d*;\\d*m" + b"\x1b\\[\\d+\\[;\\d]*m|\x1b\\[\\d*;\\d*;\\d*m|\\x1b\\]8;;(.*?)\\x1b\\\\" ) # ANSI color codes diff --git a/test/test_output.py b/test/test_output.py index 0e72c71..535a6b0 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -59,6 +59,22 @@ def test_plain_multiline(): assert_equal(expected, result) +def test_plain_multiline_with_links(): + "Output: plain with multiline cells with links and headers" + table = [[2, "foo\nbar"]] + headers = ("more\nspam \x1b]8;;target\x1b\\eggs\x1b]8;;\x1b\\", "more spam\n& eggs") + expected = "\n".join( + [ + " more more spam", + " spam \x1b]8;;target\x1b\\eggs\x1b]8;;\x1b\\ & eggs", + " 2 foo", + " bar", + ] + ) + result = tabulate(table, headers, tablefmt="plain") + assert_equal(expected, result) + + def test_plain_multiline_with_empty_cells(): "Output: plain with multiline cells and empty cells with headers" table = [ @@ -162,6 +178,23 @@ def test_simple_multiline(): assert_equal(expected, result) +def test_simple_multiline_with_links(): + "Output: simple with multiline cells with links and headers" + table = [[2, "foo\nbar"]] + headers = ("more\nspam \x1b]8;;target\x1b\\eggs\x1b]8;;\x1b\\", "more spam\n& eggs") + expected = "\n".join( + [ + " more more spam", + " spam \x1b]8;;target\x1b\\eggs\x1b]8;;\x1b\\ & eggs", + "----------- -----------", + " 2 foo", + " bar", + ] + ) + result = tabulate(table, headers, tablefmt="simple") + assert_equal(expected, result) + + def test_simple_multiline_with_empty_cells(): "Output: simple with multiline cells and empty cells with headers" table = [ @@ -766,6 +799,25 @@ def test_pretty_multiline(): assert_equal(expected, result) +def test_pretty_multiline_with_links(): + "Output: pretty with multiline cells with headers" + table = [[2, "foo\nbar"]] + headers = ("more\nspam \x1b]8;;target\x1b\\eggs\x1b]8;;\x1b\\", "more spam\n& eggs") + expected = "\n".join( + [ + "+-----------+-----------+", + "| more | more spam |", + "| spam \x1b]8;;target\x1b\\eggs\x1b]8;;\x1b\\ | & eggs |", + "+-----------+-----------+", + "| 2 | foo |", + "| | bar |", + "+-----------+-----------+", + ] + ) + result = tabulate(table, headers, tablefmt="pretty") + assert_equal(expected, result) + + def test_pretty_multiline_with_empty_cells(): "Output: pretty with multiline cells and empty cells with headers" table = [ @@ -889,6 +941,25 @@ def test_rst_multiline(): assert_equal(expected, result) +def test_rst_multiline_with_links(): + "Output: rst with multiline cells with headers" + table = [[2, "foo\nbar"]] + headers = ("more\nspam \x1b]8;;target\x1b\\eggs\x1b]8;;\x1b\\", "more spam\n& eggs") + expected = "\n".join( + [ + "=========== ===========", + " more more spam", + " spam \x1b]8;;target\x1b\\eggs\x1b]8;;\x1b\\ & eggs", + "=========== ===========", + " 2 foo", + " bar", + "=========== ===========", + ] + ) + result = tabulate(table, headers, tablefmt="rst") + assert_equal(expected, result) + + def test_rst_multiline_with_empty_cells(): "Output: rst with multiline cells and empty cells with headers" table = [ diff --git a/test/test_regression.py b/test/test_regression.py index 2324c06..955e11f 100644 --- a/test/test_regression.py +++ b/test/test_regression.py @@ -47,6 +47,52 @@ def test_alignment_of_colored_cells(): assert_equal(expected, formatted) +def test_alignment_of_link_cells(): + "Regression: Align links as if they were colorless." + linktable = [ + ("test", 42, "\x1b]8;;target\x1b\\test\x1b]8;;\x1b\\"), + ("test", 101, "\x1b]8;;target\x1b\\test\x1b]8;;\x1b\\"), + ] + linkheaders = ("test", "\x1b]8;;target\x1b\\test\x1b]8;;\x1b\\", "test") + formatted = tabulate(linktable, linkheaders, "grid") + expected = "\n".join( + [ + "+--------+--------+--------+", + "| test | \x1b]8;;target\x1b\\test\x1b]8;;\x1b\\ | test |", + "+========+========+========+", + "| test | 42 | \x1b]8;;target\x1b\\test\x1b]8;;\x1b\\ |", + "+--------+--------+--------+", + "| test | 101 | \x1b]8;;target\x1b\\test\x1b]8;;\x1b\\ |", + "+--------+--------+--------+", + ] + ) + print("expected: %r\n\ngot: %r\n" % (expected, formatted)) + assert_equal(expected, formatted) + + +def test_alignment_of_link_text_cells(): + "Regression: Align links as if they were colorless." + linktable = [ + ("test", 42, "1\x1b]8;;target\x1b\\test\x1b]8;;\x1b\\2"), + ("test", 101, "3\x1b]8;;target\x1b\\test\x1b]8;;\x1b\\4"), + ] + linkheaders = ("test", "5\x1b]8;;target\x1b\\test\x1b]8;;\x1b\\6", "test") + formatted = tabulate(linktable, linkheaders, "grid") + expected = "\n".join( + [ + "+--------+----------+--------+", + "| test | 5\x1b]8;;target\x1b\\test\x1b]8;;\x1b\\6 | test |", + "+========+==========+========+", + "| test | 42 | 1\x1b]8;;target\x1b\\test\x1b]8;;\x1b\\2 |", + "+--------+----------+--------+", + "| test | 101 | 3\x1b]8;;target\x1b\\test\x1b]8;;\x1b\\4 |", + "+--------+----------+--------+", + ] + ) + print("expected: %r\n\ngot: %r\n" % (expected, formatted)) + assert_equal(expected, formatted) + + def test_iter_of_iters_with_headers(): "Regression: Generator of generators with a gen. of headers (issue #9)." From 05502c88ed89c78709071b5cfd1f08fb18408c6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladimir=20Vrzi=C4=87?= Date: Tue, 1 Sep 2020 20:12:54 +0200 Subject: [PATCH 013/207] Add new style 'fancy_outline' --- tabulate.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tabulate.py b/tabulate.py index 5d57167..2ed6371 100755 --- a/tabulate.py +++ b/tabulate.py @@ -315,6 +315,16 @@ def escape_empty(val): 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("|", "-", "|", "|"), From 4707a2f43c69c92d3e569bb0907daa265bd24bee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladimir=20Vrzi=C4=87?= Date: Tue, 1 Sep 2020 20:13:33 +0200 Subject: [PATCH 014/207] Remove trailing newline character from README symlink --- README | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README b/README index b43bf86..42061c0 120000 --- a/README +++ b/README @@ -1 +1 @@ -README.md +README.md \ No newline at end of file From 2599424c67a1380e6ea3ec8e621f175392696387 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vladimir=20Vrzi=C4=87?= Date: Tue, 1 Sep 2020 20:15:03 +0200 Subject: [PATCH 015/207] Update list of contributors --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ce06dad..3aceb01 100644 --- a/README.md +++ b/README.md @@ -744,4 +744,5 @@ Maier, Andy MacKinlay, Thomas Roten, Jue Wang, Joe King, Samuel Phan, Nick Satterly, Daniel Robbins, Dmitry B, Lars Butler, Andreas Maier, Dick Marinus, Sébastien Celles, Yago González, Andrew Gaul, Wim Glenn, Jean Michel Rouly, Tim Gates, John Vandenberg, Sorin Sbarnea, -Wes Turner, Andrew Tija, Marco Gorelli, Sean McGinnis, danja100. +Wes Turner, Andrew Tija, Marco Gorelli, Sean McGinnis, danja100, +Vladimir Vrzić. From ee2de9fc01935967bb05adfc869bd377e501425c Mon Sep 17 00:00:00 2001 From: pavlocat Date: Sat, 10 Oct 2020 23:01:06 +0900 Subject: [PATCH 016/207] Fix a bug that header shifts wrongly when do tabulate(headers='keys', showindex=False) for pandas DataFrame with named index --- tabulate.py | 2 +- test/test_output.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tabulate.py b/tabulate.py index 5d57167..d611dc1 100755 --- a/tabulate.py +++ b/tabulate.py @@ -1022,7 +1022,7 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): 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 tabular_data.index.name is not None: + 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: diff --git a/test/test_output.py b/test/test_output.py index 0e72c71..061d302 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -1363,7 +1363,8 @@ def test_pandas_without_index(): import pandas df = pandas.DataFrame( - [["one", 1], ["two", None]], columns=["string", "number"], index=["a", "b"] + [["one", 1], ["two", None]], columns=["string", "number"], + index=pandas.Index(["a", "b"], name="index") ) expected = "\n".join( [ From 5b7d1ee98631f1909d2f7668746525dcb76e287d Mon Sep 17 00:00:00 2001 From: Dominic Davis-Foster Date: Tue, 15 Dec 2020 10:19:50 +0000 Subject: [PATCH 017/207] Fix DeprecationWarning on Python 3.10 --- tabulate.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tabulate.py b/tabulate.py index 5d57167..1965fcc 100755 --- a/tabulate.py +++ b/tabulate.py @@ -5,17 +5,17 @@ from __future__ import print_function from __future__ import unicode_literals from collections import namedtuple -from platform import python_version_tuple +import sys import re import math -if python_version_tuple() >= ("3", "3", "0"): +if sys.version_info >= (3, 3): from collections.abc import Iterable else: from collections import Iterable -if python_version_tuple()[0] < "3": +if sys.version_info[0] < 3: from itertools import izip_longest from functools import partial From ca2a843098c98b93aa20adaa5b05f054b70f979f Mon Sep 17 00:00:00 2001 From: Dominic Davis-Foster Date: Tue, 15 Dec 2020 10:22:15 +0000 Subject: [PATCH 018/207] Update tox.ini to test on Python 3.9 and 3.10 --- tox.ini | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index c9f4e98..a20b325 100644 --- a/tox.ini +++ b/tox.ini @@ -8,7 +8,7 @@ # for testing and it is disabled by default. [tox] -envlist = lint, py27, py35, py36, py37, py38 +envlist = lint, py27, py35, py36, py37, py38, py39, py310 [testenv] commands = pytest -v --doctest-modules --ignore benchmark.py @@ -97,6 +97,40 @@ deps = pandas wcwidth + +[testenv:py39] +basepython = python3.9 +commands = pytest -v --doctest-modules --ignore benchmark.py +deps = + pytest + +[testenv:py39-extra] +basepython = python3.9 +commands = pytest -v --doctest-modules --ignore benchmark.py +deps = + pytest + numpy + pandas + wcwidth + + +[testenv:py310] +basepython = python3.10 +commands = pytest -v --doctest-modules --ignore benchmark.py +deps = + pytest + +[testenv:py310-extra] +basepython = python3.10 +setenv = PYTHONDEVMODE = 1 +commands = pytest -v --doctest-modules --ignore benchmark.py +deps = + pytest + numpy + pandas + wcwidth + + [flake8] max-complexity = 22 max-line-length = 99 From 88824923f2ec49c6fa11478c333bb647f216c180 Mon Sep 17 00:00:00 2001 From: Shane Loretz Date: Mon, 28 Dec 2020 15:38:33 -0800 Subject: [PATCH 019/207] Strip hyperlinks from text Signed-off-by: Shane Loretz --- tabulate.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tabulate.py b/tabulate.py index 5d57167..03bad7e 100755 --- a/tabulate.py +++ b/tabulate.py @@ -540,6 +540,9 @@ def escape_empty(val): _invisible_codes_bytes = re.compile( b"\x1b\\[\\d+\\[;\\d]*m|\x1b\\[\\d*;\\d*;\\d*m" ) # ANSI color codes +_invisible_codes_link = re.compile( + r"\x1B]8;[a-zA-Z0-9:]*;[^\x1B]+\x1B\\([^\x1b]+)\x1B]8;;\x1B\\" +) # Terminal hyperlinks def simple_separated_format(separator): @@ -724,9 +727,15 @@ def _padnone(ignore_width, s): def _strip_invisible(s): - "Remove invisible ANSI color codes." + r"""Remove invisible ANSI color codes. + + >>> _strip_invisible('\x1B]8;;https://example.com\x1B\\This is a link\x1B]8;;\x1B\\') + 'This is a link' + + """ if isinstance(s, _text_type): - return re.sub(_invisible_codes, "", s) + links_removed = re.sub(_invisible_codes_link, "\\1", s) + return re.sub(_invisible_codes, "", links_removed) else: # a bytestring return re.sub(_invisible_codes_bytes, "", s) @@ -1469,6 +1478,8 @@ def tabulate( ) has_invisible = re.search(_invisible_codes, plain_text) + if not has_invisible: + has_invisible = re.search(_invisible_codes_link, plain_text) enable_widechars = wcwidth is not None and WIDE_CHARS_MODE if ( not isinstance(tablefmt, TableFormat) From 0050198207c14378177d809f840df3c489ea3279 Mon Sep 17 00:00:00 2001 From: Shane Loretz Date: Tue, 29 Dec 2020 16:48:19 -0800 Subject: [PATCH 020/207] Use str to get rid of unicode type in Python 2.7 Signed-off-by: Shane Loretz --- tabulate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tabulate.py b/tabulate.py index 03bad7e..daa67f4 100755 --- a/tabulate.py +++ b/tabulate.py @@ -729,7 +729,7 @@ def _padnone(ignore_width, s): def _strip_invisible(s): r"""Remove invisible ANSI color codes. - >>> _strip_invisible('\x1B]8;;https://example.com\x1B\\This is a link\x1B]8;;\x1B\\') + >>> str(_strip_invisible('\x1B]8;;https://example.com\x1B\\This is a link\x1B]8;;\x1B\\')) 'This is a link' """ From c8feb4a83865c247a7ae14d6d48b5777e785b1ec Mon Sep 17 00:00:00 2001 From: endolith Date: Wed, 17 Feb 2021 07:03:12 -0500 Subject: [PATCH 021/207] README: Fix some typos (pull request #109) --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ce06dad..7125559 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,7 @@ extensions](http://johnmacfarlane.net/pandoc/README.html#tables): eggs 451 bacon 0 -`github` follows the conventions of Github flavored Markdown. It +`github` follows the conventions of GitHub flavored Markdown. It corresponds to the `pipe` format without alignment colons: >>> print(tabulate(table, headers, tablefmt="github")) @@ -668,9 +668,9 @@ as a number imply that `tabulate`: It may not be suitable for serializing really big tables (but who's going to do that, anyway?) or printing tables in performance sensitive applications. `tabulate` is about two orders of magnitude slower than -simply joining lists of values with a tab, coma or other separator. +simply joining lists of values with a tab, comma, or other separator. -In the same time `tabulate` is comparable to other table +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 @@ -714,7 +714,7 @@ On Linux `tox` expects to find executables like `python2.6`, `C:\Python26\python.exe`, `C:\Python27\python.exe` and `C:\Python34\python.exe` respectively. -To test only some Python environements, use `-e` option. For example, to +To test only some Python environments, use `-e` option. For example, to test only against Python 2.7 and Python 3.6, run: tox -e py27,py36 From 3e10b9588ffde86dace6783808e8880458b7a7d7 Mon Sep 17 00:00:00 2001 From: Felix Yan Date: Wed, 17 Feb 2021 20:04:57 +0800 Subject: [PATCH 022/207] Correct a typo in tabulate.py (pull request #98) --- tabulate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tabulate.py b/tabulate.py index 1965fcc..55d5abc 100755 --- a/tabulate.py +++ b/tabulate.py @@ -894,7 +894,7 @@ def _column_type(strings, has_invisible=True, numparse=True): def _format(val, valtype, floatfmt, missingval="", has_invisible=True): - """Format a value accoding to its type. + """Format a value according to its type. Unicode is supported: From 89d16a61e216026110265e09d33e4c5386e837b3 Mon Sep 17 00:00:00 2001 From: Frank Busse Date: Wed, 17 Feb 2021 12:09:37 +0000 Subject: [PATCH 023/207] fix typos in comments (pull request #93) --- tabulate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tabulate.py b/tabulate.py index 5e7a522..b9d2e7c 100755 --- a/tabulate.py +++ b/tabulate.py @@ -91,9 +91,9 @@ def _is_file(f): # headerrow # --- linebelowheader --- # datarow -# --- linebewteenrows --- +# --- linebetweenrows --- # ... (more datarows) ... -# --- linebewteenrows --- +# --- linebetweenrows --- # last datarow # --- linebelow --------- # From 4149f9ae61b70c177a3cf9900016c1ab477e83ea Mon Sep 17 00:00:00 2001 From: Harsh Singh Date: Wed, 17 Feb 2021 17:40:03 +0530 Subject: [PATCH 024/207] Update .gitignore (#92) --- .gitignore | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.gitignore b/.gitignore index 1579287..460933e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,16 @@ dist *~ *.pyc /tabulate.egg-info/ +*.egg* +*.pyc +.* +build/ +.coverage +coverage.xml +dist/ +doc/changelog.rst +venv* +website-build/ +## Unit test / coverage reports +.coverage +.tox From 5656956653e228e0a0f1929532e2fe7fedbde7ba Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 17 Feb 2021 14:46:55 +0100 Subject: [PATCH 025/207] reformat code with black (tox -e lint) --- tabulate.py | 5 ++++- test/test_output.py | 5 +++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/tabulate.py b/tabulate.py index b9d2e7c..10707fa 100755 --- a/tabulate.py +++ b/tabulate.py @@ -1031,7 +1031,10 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): 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 ( + 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: diff --git a/test/test_output.py b/test/test_output.py index 061d302..3dfa45a 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -1363,8 +1363,9 @@ def test_pandas_without_index(): import pandas df = pandas.DataFrame( - [["one", 1], ["two", None]], columns=["string", "number"], - index=pandas.Index(["a", "b"], name="index") + [["one", 1], ["two", None]], + columns=["string", "number"], + index=pandas.Index(["a", "b"], name="index"), ) expected = "\n".join( [ From 0e714f1d0e481930d6d505dc3e9e14619f890fee Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 17 Feb 2021 22:37:52 +0100 Subject: [PATCH 026/207] add a regression test for issue #28, lint and reformat --- README | 2 +- tabulate.py | 4 +++- test/test_regression.py | 18 ++++++++++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/README b/README index 42061c0..b43bf86 120000 --- a/README +++ b/README @@ -1 +1 @@ -README.md \ No newline at end of file +README.md diff --git a/tabulate.py b/tabulate.py index 15b00d4..4de2f84 100644 --- a/tabulate.py +++ b/tabulate.py @@ -861,7 +861,9 @@ def _align_column( ): """[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) + 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) diff --git a/test/test_regression.py b/test/test_regression.py index 955e11f..bf5bd6b 100644 --- a/test/test_regression.py +++ b/test/test_regression.py @@ -322,6 +322,24 @@ def test_mix_normal_and_wide_characters(): skip("test_mix_normal_and_wide_characters is skipped (requires wcwidth lib)") +def test_multiline_with_wide_characters(): + "Regression: multiline tables with varying number of wide characters (github issue #28)" + try: + import wcwidth # noqa + + table = [["가나\n가ab", "가나", "가나"]] + result = tabulate(table, tablefmt="fancy_grid") + expected = "\n".join( + "╒══════╤══════╤══════╕", + "│ 가나 │ 가나 │ 가나 │", + "│ 가ab │ │ │", + "╘══════╧══════╧══════╛", + ) + assert_equal(result, expected) + except ImportError: + skip("test_multiline_with_wide_characters is skipped (requires wcwidth lib)") + + def test_align_long_integers(): "Regression: long integers should be aligned as integers (issue #61)" table = [[_long_type(1)], [_long_type(234)]] From b4e5a146277b7e37a28183adaeab4675398a8b41 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 17 Feb 2021 22:41:57 +0100 Subject: [PATCH 027/207] fixup error in the last regression test definition --- test/test_regression.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/test/test_regression.py b/test/test_regression.py index bf5bd6b..de8b95a 100644 --- a/test/test_regression.py +++ b/test/test_regression.py @@ -330,10 +330,12 @@ def test_multiline_with_wide_characters(): table = [["가나\n가ab", "가나", "가나"]] result = tabulate(table, tablefmt="fancy_grid") expected = "\n".join( - "╒══════╤══════╤══════╕", - "│ 가나 │ 가나 │ 가나 │", - "│ 가ab │ │ │", - "╘══════╧══════╧══════╛", + [ + "╒══════╤══════╤══════╕", + "│ 가나 │ 가나 │ 가나 │", + "│ 가ab │ │ │", + "╘══════╧══════╧══════╛", + ] ) assert_equal(result, expected) except ImportError: From 2619a8134c14f5d997710445cc0a3e5568fcc141 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 17 Feb 2021 23:23:51 +0100 Subject: [PATCH 028/207] disable lint pre-commit hook --- tox.ini | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tox.ini b/tox.ini index a20b325..97d6633 100644 --- a/tox.ini +++ b/tox.ini @@ -19,10 +19,10 @@ passenv = REQUESTS_CA_BUNDLE SSL_CERT_FILE -[testenv:lint] -commands = python -m pre_commit run -a -deps = - pre-commit +#[testenv:lint] +#commands = python -m pre_commit run -a +#deps = +# pre-commit [testenv:py27-extra] basepython = python2.7 From da81abbcbfd4a337e6dd260c5eea677b3ac1c6cf Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 17 Feb 2021 23:31:32 +0100 Subject: [PATCH 029/207] add a regression test for alignment of decimal numbers with commas (PR #51) --- test/test_regression.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/test_regression.py b/test/test_regression.py index de8b95a..f006a20 100644 --- a/test/test_regression.py +++ b/test/test_regression.py @@ -272,6 +272,18 @@ def test_alignment_of_decimal_numbers_with_ansi_color(): assert_equal(result, expected) +def test_alignment_of_decimal_numbers_with_commas(): + "Regression: alignment for decimal numbers with comma separators" + stuff={"first": ["c1r1", "c1r2"], "second": [14502.05, 105]} + result = tabulate(stuff, tablefmt="grid", floatfmt=',.2f') + expected = "\n".join( + ['+------+-----------+', '| c1r1 | 14,502.05 |', + '+------+-----------+', '| c1r2 | 105.00 |', + '+------+-----------+'] + ) + assert_equal(result, expected) + + def test_long_integers(): "Regression: long integers should be printed as integers (issue #48)" table = [[18446744073709551614]] From 41f4b08308a04dc52a58910be0ff4796adb8108b Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 18 Feb 2021 00:39:20 +0100 Subject: [PATCH 030/207] fix possible failure of test_alignment_of_decimal_numbers_with_commas --- test/test_regression.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/test_regression.py b/test/test_regression.py index f006a20..ae9d69b 100644 --- a/test/test_regression.py +++ b/test/test_regression.py @@ -274,8 +274,8 @@ def test_alignment_of_decimal_numbers_with_ansi_color(): def test_alignment_of_decimal_numbers_with_commas(): "Regression: alignment for decimal numbers with comma separators" - stuff={"first": ["c1r1", "c1r2"], "second": [14502.05, 105]} - result = tabulate(stuff, tablefmt="grid", floatfmt=',.2f') + table = [["c1r1", "14502.05"], ["c1r2", 105]] + result = tabulate(table, tablefmt="grid", floatfmt=',.2f') expected = "\n".join( ['+------+-----------+', '| c1r1 | 14,502.05 |', '+------+-----------+', '| c1r2 | 105.00 |', From 822c738684767f9a2de9d7dee04c7be26673edb4 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 18 Feb 2021 01:18:15 +0100 Subject: [PATCH 031/207] update CHANGELOG and README --- CHANGELOG | 9 +++++++-- README.md | 5 ++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 012c4c5..47f1d1e 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,10 @@ -- 0.8.8: Future version. - Add ``latex_longtable`` format. +- 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. diff --git a/README.md b/README.md index 354a480..544d82e 100644 --- a/README.md +++ b/README.md @@ -750,4 +750,7 @@ Nick Satterly, Daniel Robbins, Dmitry B, Lars Butler, Andreas Maier, Dick Marinus, Sébastien Celles, Yago González, Andrew Gaul, Wim Glenn, Jean Michel Rouly, Tim Gates, John Vandenberg, Sorin Sbarnea, Wes Turner, Andrew Tija, Marco Gorelli, Sean McGinnis, danja100, -Vladimir Vrzić, Bart Broere. +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. \ No newline at end of file From 3b3ff4c9c68af2ca10c80d4c972b05252ab6aad4 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 18 Feb 2021 01:35:04 +0100 Subject: [PATCH 032/207] update README: replace nose with pytest --- README.md | 37 +++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 544d82e..b17ab5a 100644 --- a/README.md +++ b/README.md @@ -681,16 +681,17 @@ numeric data, `tabulate` appears to be slower than `asciitable`, and faster than `PrettyTable` and `texttable` The following mini-benchmark was run in Python 3.8.1 in Windows 10 x64: - =========================== ========== =========== - Table formatter time, μs rel. time - =========================== ========== =========== - csv to StringIO 12.4 1.0 - join with tabs and newlines 15.7 1.3 - asciitable (0.8.0) 208.3 16.7 - tabulate (0.8.7) 492.1 39.5 - PrettyTable (0.7.2) 945.5 76.0 - texttable (1.6.2) 1239.5 99.6 - =========================== ========== =========== + ================================= ========== =========== + Table formatter time, μs rel. time + ================================= ========== =========== + csv to StringIO 12.0 1.0 + join with tabs and newlines 14.7 1.2 + asciitable (0.8.0) 189.6 15.8 + tabulate (0.8.8) 480.9 40.1 + tabulate (0.8.8, WIDE_CHARS_MODE) 610.2 50.8 + PrettyTable (2.0.0) 899.8 75.0 + texttable (1.6.3) 1117.3 93.1 + ================================= ========== =========== Version history @@ -705,13 +706,13 @@ Contributions should include tests and an explanation for the changes they propose. Documentation (examples, docstrings, README.md) should be updated accordingly. -This project uses [nose](https://nose.readthedocs.org/) testing +This project uses [pytest](https://docs.pytest.org/) testing framework and [tox](https://tox.readthedocs.io/) to automate testing in different environments. Add tests to one of the files in the `test/` folder. To run tests on all supported Python versions, make sure all Python -interpreters, `nose` and `tox` are installed, then run `tox` in the root +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 `python2.6`, @@ -720,20 +721,24 @@ On Linux `tox` expects to find executables like `python2.6`, `C:\Python34\python.exe` respectively. To test only some Python environments, use `-e` option. For example, to -test only against Python 2.7 and Python 3.6, run: +test only against Python 2.7 and Python 3.8, run: - tox -e py27,py36 + tox -e py27,py38 in the root of the project source tree. To enable NumPy and Pandas tests, run: - tox -e py27-extra,py36-extra + tox -e py27-extra,py38-extra (this may take a long time the first time, because NumPy and Pandas will have to be installed in the new virtual environments) -See `tox.ini` file to learn how to use `nosetests` directly to test +To fix code formatting: + + tox -e lint + +See `tox.ini` file to learn how to use to test individual Python versions. Contributors From f9b321182b3bae6c860e9a57440c825290fea306 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 18 Feb 2021 01:37:57 +0100 Subject: [PATCH 033/207] version bump to 0.8.9 --- setup.py | 3 ++- tabulate.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index ee9fcec..75c3e45 100644 --- a/setup.py +++ b/setup.py @@ -38,7 +38,7 @@ setup( name="tabulate", - version="0.8.8", + version="0.8.9", description="Pretty-print tabular data", long_description=LONG_DESCRIPTION, long_description_content_type="text/markdown", @@ -57,6 +57,7 @@ "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries", ], py_modules=["tabulate"], diff --git a/tabulate.py b/tabulate.py index c8e2fed..7d49311 100644 --- a/tabulate.py +++ b/tabulate.py @@ -62,7 +62,7 @@ def _is_file(f): __all__ = ["tabulate", "tabulate_formats", "simple_separated_format"] -__version__ = "0.8.8" +__version__ = "0.8.9" # minimum extra space in headers From 1811886eaee8810f0b9ab26692fda83e4b524759 Mon Sep 17 00:00:00 2001 From: Vilhelm Prytz Date: Thu, 18 Feb 2021 16:14:12 +0100 Subject: [PATCH 034/207] benchmark: remove redundant assignment and double import (#47) * benchmark: remove redundant assignment and double import assigning "methods = methods" is redundant module "platform" was imported with both "import and import from", merged into one "import from" * Revert to lazy loading for project style consistency --- benchmark.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/benchmark.py b/benchmark.py index d964e15..b79c47b 100644 --- a/benchmark.py +++ b/benchmark.py @@ -96,8 +96,6 @@ def benchmark(n): global methods if "--onlyself" in sys.argv[1:]: methods = [m for m in methods if m[0].startswith("tabulate")] - else: - methods = methods results = [ (desc, timeit(code, setup_code, number=n) / n * 1e6) for desc, code in methods @@ -110,9 +108,9 @@ def benchmark(n): results, ["Table formatter", "time, μs", "rel. time"], "rst", floatfmt=".1f" ) - import platform + from platform import platform - if platform.platform().startswith("Windows"): + if platform().startswith("Windows"): print(table) elif python_version_tuple()[0] < "3": print(codecs.encode(table, "utf-8")) From bfc922cb04aa4a8bfe6360a50ac774d513605043 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 18 Feb 2021 16:16:48 +0100 Subject: [PATCH 035/207] update Contributors list --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b17ab5a..f3aca61 100644 --- a/README.md +++ b/README.md @@ -758,4 +758,4 @@ Wes Turner, Andrew Tija, Marco Gorelli, Sean McGinnis, danja100, 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. \ No newline at end of file +Bart Broere, Vilhelm Prytz. From a17907c8499d6005313329e5e8f907bd0f5b1db9 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Fri, 19 Feb 2021 09:38:04 +0100 Subject: [PATCH 036/207] add a regression test for issue #110 --- test/test_regression.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/test_regression.py b/test/test_regression.py index ae9d69b..db61e53 100644 --- a/test/test_regression.py +++ b/test/test_regression.py @@ -460,3 +460,11 @@ def test_custom_tablefmt(): expected = "\n".join(["A B", "--- ---", "foo bar", "baz qux"]) result = tabulate(rows, headers=["A", "B"], tablefmt=tablefmt) assert_equal(result, expected) + + +def test_string_with_comma_between_digits_without_floatfmt_grouping_option(): + "Regression: accept commas in numbers-as-text when grouping is not defined (github issue #110)" + table = [["126,000"]] + expected = "126,000" + result = tabulate(table, tablefmt="plain") + assert_equal(result, expected) # no exception From db9802368fdf8ec12f8d4dee8b1697b572e6b938 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Fri, 19 Feb 2021 09:43:52 +0100 Subject: [PATCH 037/207] temporarily disable the test of the decimal alignment with group separators --- test/test_regression.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/test/test_regression.py b/test/test_regression.py index db61e53..707f666 100644 --- a/test/test_regression.py +++ b/test/test_regression.py @@ -274,14 +274,15 @@ def test_alignment_of_decimal_numbers_with_ansi_color(): def test_alignment_of_decimal_numbers_with_commas(): "Regression: alignment for decimal numbers with comma separators" - table = [["c1r1", "14502.05"], ["c1r2", 105]] - result = tabulate(table, tablefmt="grid", floatfmt=',.2f') - expected = "\n".join( - ['+------+-----------+', '| c1r1 | 14,502.05 |', - '+------+-----------+', '| c1r2 | 105.00 |', - '+------+-----------+'] - ) - assert_equal(result, expected) + skip("test is temporarily disable until the feature is reimplemented") + #table = [["c1r1", "14502.05"], ["c1r2", 105]] + #result = tabulate(table, tablefmt="grid", floatfmt=',.2f') + #expected = "\n".join( + # ['+------+-----------+', '| c1r1 | 14,502.05 |', + # '+------+-----------+', '| c1r2 | 105.00 |', + # '+------+-----------+'] + #) + #assert_equal(result, expected) def test_long_integers(): From f54543362c8bdef4bccb86b5ebf49d96f8b0551a Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Fri, 19 Feb 2021 08:48:05 +0000 Subject: [PATCH 038/207] Revert "Recognize numbers with comma separators" (#111) --- tabulate.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/tabulate.py b/tabulate.py index 7d49311..431b649 100644 --- a/tabulate.py +++ b/tabulate.py @@ -610,16 +610,8 @@ def _isnumber(string): False >>> _isnumber("inf") True - >>> _isnumber("1,234.56") - True - >>> _isnumber("1,234,578.0") - True """ - if isinstance(string, (_text_type)) and "," in string and ( - _isconvertible(float, string.replace(",","")) - ): - return True - elif not _isconvertible(float, string): + if not _isconvertible(float, string): return False elif isinstance(string, (_text_type, _binary_type)) and ( math.isinf(float(string)) or math.isnan(float(string)) From 4883339c8c14794228ab0a5bbdfcfbb88b460e0d Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Fri, 19 Feb 2021 09:58:49 +0100 Subject: [PATCH 039/207] enable python 3.9 CI tests on appveyor --- appveyor.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/appveyor.yml b/appveyor.yml index 0055e96..97da15a 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -12,11 +12,13 @@ environment: - PYTHON: "C:\\Python36" - PYTHON: "C:\\Python37" - PYTHON: "C:\\Python38" + - PYTHON: "C:\\Python39" - PYTHON: "C:\\Python27-x64" - PYTHON: "C:\\Python35-x64" - PYTHON: "C:\\Python36-x64" - PYTHON: "C:\\Python37-x64" - PYTHON: "C:\\Python38-x64" + - PYTHON: "C:\\Python39-x64" install: # We need wheel installed to build wheels From f329b61c39e51826af98e9ea53ab995513cf27c0 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Fri, 19 Feb 2021 10:10:57 +0100 Subject: [PATCH 040/207] re-enable pre_commit in "tox -e lint", fix InvocationError --- tox.ini | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tox.ini b/tox.ini index 97d6633..1a1a3f8 100644 --- a/tox.ini +++ b/tox.ini @@ -19,10 +19,10 @@ passenv = REQUESTS_CA_BUNDLE SSL_CERT_FILE -#[testenv:lint] -#commands = python -m pre_commit run -a -#deps = -# pre-commit +[testenv:lint] +commands = python3 -m pre_commit run -a +deps = + pre-commit [testenv:py27-extra] basepython = python2.7 From 3b284e8eecd2561b229046607e30a42c36806a6b Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Fri, 19 Feb 2021 10:11:22 +0100 Subject: [PATCH 041/207] reformat with black --- test/test_input.py | 5 +++++ test/test_regression.py | 10 +++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/test/test_input.py b/test/test_input.py index 17f5b08..541b4f7 100644 --- a/test/test_input.py +++ b/test/test_input.py @@ -6,6 +6,7 @@ from __future__ import unicode_literals from tabulate import tabulate from common import assert_equal, assert_in, raises, skip + try: from collections import UserDict except ImportError: @@ -382,6 +383,7 @@ def test_list_of_dicts(): result = tabulate(lod) assert_in(result, [expected1, expected2]) + def test_list_of_userdicts(): "Input: a list of UserDicts." lod = [UserDict(foo=1, bar=2), UserDict(foo=3, bar=4)] @@ -390,6 +392,7 @@ def test_list_of_userdicts(): result = tabulate(lod) assert_in(result, [expected1, expected2]) + def test_list_of_dicts_keys(): "Input: a list of dictionaries, with keys as headers." lod = [{"foo": 1, "bar": 2}, {"foo": 3, "bar": 4}] @@ -402,6 +405,7 @@ def test_list_of_dicts_keys(): result = tabulate(lod, headers="keys") assert_in(result, [expected1, expected2]) + def test_list_of_userdicts_keys(): "Input: a list of UserDicts." lod = [UserDict(foo=1, bar=2), UserDict(foo=3, bar=4)] @@ -414,6 +418,7 @@ def test_list_of_userdicts_keys(): result = tabulate(lod, headers="keys") assert_in(result, [expected1, expected2]) + def test_list_of_dicts_with_missing_keys(): "Input: a list of dictionaries, with missing keys." lod = [{"foo": 1}, {"bar": 2}, {"foo": 4, "baz": 3}] diff --git a/test/test_regression.py b/test/test_regression.py index 707f666..63e3a52 100644 --- a/test/test_regression.py +++ b/test/test_regression.py @@ -275,14 +275,14 @@ def test_alignment_of_decimal_numbers_with_ansi_color(): def test_alignment_of_decimal_numbers_with_commas(): "Regression: alignment for decimal numbers with comma separators" skip("test is temporarily disable until the feature is reimplemented") - #table = [["c1r1", "14502.05"], ["c1r2", 105]] - #result = tabulate(table, tablefmt="grid", floatfmt=',.2f') - #expected = "\n".join( + # table = [["c1r1", "14502.05"], ["c1r2", 105]] + # result = tabulate(table, tablefmt="grid", floatfmt=',.2f') + # expected = "\n".join( # ['+------+-----------+', '| c1r1 | 14,502.05 |', # '+------+-----------+', '| c1r2 | 105.00 |', # '+------+-----------+'] - #) - #assert_equal(result, expected) + # ) + # assert_equal(result, expected) def test_long_integers(): From 27bf79230634c171dff4e9bbec88576b464ee98e Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Fri, 19 Feb 2021 10:16:32 +0100 Subject: [PATCH 042/207] disable Python 3.9 CI tests on appveyor Tests with PYTHON=C:\Python39 fail with %PYTHON%\python.exe -m pip install wheel The system cannot find the path specified. Command exited with code 1 --- appveyor.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/appveyor.yml b/appveyor.yml index 97da15a..0055e96 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -12,13 +12,11 @@ environment: - PYTHON: "C:\\Python36" - PYTHON: "C:\\Python37" - PYTHON: "C:\\Python38" - - PYTHON: "C:\\Python39" - PYTHON: "C:\\Python27-x64" - PYTHON: "C:\\Python35-x64" - PYTHON: "C:\\Python36-x64" - PYTHON: "C:\\Python37-x64" - PYTHON: "C:\\Python38-x64" - - PYTHON: "C:\\Python39-x64" install: # We need wheel installed to build wheels From 40e0a3daa2f673e80472d5c102c350526d1c409a Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Mon, 22 Feb 2021 08:24:01 +0100 Subject: [PATCH 043/207] Update CHANGELOG to 0.8.9 Version 0.8.8 introduced a minor feature which was supposed to properly align decimal numbers with group separators (1,234,567.00). Unfortunately, it broke the library for several users. Version 0.8.9 adds a regression test and disables that feature. It is going to be re-implemented in a more robust way. --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index 47f1d1e..42bb692 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +- 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. From 981a2f65786db997948b7845f1c05773fbf90aa6 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Mon, 22 Feb 2021 08:27:13 +0100 Subject: [PATCH 044/207] use just "python" not "python3" in "tox -e lint"' tox.ini This should make tox.ini more usable across various operating systems. Remember to install python-as-python3 on Linux (e.g. Ubuntu). --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 1a1a3f8..a20b325 100644 --- a/tox.ini +++ b/tox.ini @@ -20,7 +20,7 @@ passenv = SSL_CERT_FILE [testenv:lint] -commands = python3 -m pre_commit run -a +commands = python -m pre_commit run -a deps = pre-commit From ee9daa24d46896d565d3d1262fb59627a0610d0b Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Mon, 22 Feb 2021 08:31:21 +0100 Subject: [PATCH 045/207] update README for 0.8.9 --- README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index f3aca61..9ccb100 100644 --- a/README.md +++ b/README.md @@ -679,18 +679,18 @@ 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.8.1 in Windows 10 x64: +was run in Python 3.8.3 in Windows 10 x64: ================================= ========== =========== Table formatter time, μs rel. time ================================= ========== =========== - csv to StringIO 12.0 1.0 - join with tabs and newlines 14.7 1.2 - asciitable (0.8.0) 189.6 15.8 - tabulate (0.8.8) 480.9 40.1 - tabulate (0.8.8, WIDE_CHARS_MODE) 610.2 50.8 - PrettyTable (2.0.0) 899.8 75.0 - texttable (1.6.3) 1117.3 93.1 + csv to StringIO 12.5 1.0 + join with tabs and newlines 15.6 1.3 + asciitable (0.8.0) 191.4 15.4 + tabulate (0.8.9) 472.8 38.0 + tabulate (0.8.9, WIDE_CHARS_MODE) 789.6 63.4 + PrettyTable (0.7.2) 879.1 70.6 + texttable (1.6.2) 1352.2 108.6 ================================= ========== =========== From b3c0fb028e0c5fd2fbd8d0e451d2715697ed62ca Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Mon, 22 Feb 2021 08:36:15 +0100 Subject: [PATCH 046/207] version bump to 0.8.10 --- CHANGELOG | 1 + setup.py | 2 +- tabulate.py | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 42bb692..8f7e97c 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +- 0.8.10: Future version - 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``. diff --git a/setup.py b/setup.py index 75c3e45..1cb6a84 100644 --- a/setup.py +++ b/setup.py @@ -38,7 +38,7 @@ setup( name="tabulate", - version="0.8.9", + version="0.8.10", description="Pretty-print tabular data", long_description=LONG_DESCRIPTION, long_description_content_type="text/markdown", diff --git a/tabulate.py b/tabulate.py index 431b649..55b896e 100644 --- a/tabulate.py +++ b/tabulate.py @@ -62,7 +62,7 @@ def _is_file(f): __all__ = ["tabulate", "tabulate_formats", "simple_separated_format"] -__version__ = "0.8.9" +__version__ = "0.8.10" # minimum extra space in headers From 62203dea1a5cdea16d70099b94a56d9007cc81e8 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Mon, 22 Feb 2021 08:39:58 +0100 Subject: [PATCH 047/207] disable end-of-file-fixer in .pre-commit-config.yaml This hook keeps corrupting the README symlink when on Windows. If anyone knows how to configure pre_commit_hooks to whitelist a file, feel free to revert this change and submit a PR. --- .pre-commit-config.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 85c07e5..e6f9951 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,6 @@ repos: rev: v2.2.3 hooks: - id: trailing-whitespace - - id: end-of-file-fixer - id: check-yaml - id: debug-statements - id: flake8 From 7d67feedb124385b0a2b50153923149f5abcf714 Mon Sep 17 00:00:00 2001 From: Vijaya Krishna Kasula Date: Tue, 23 Feb 2021 16:48:56 +0530 Subject: [PATCH 048/207] Support intfmt Update README, help and add a new test for verifying the new feature --- README.md | 9 +++++++++ tabulate.py | 38 +++++++++++++++++++++++++++++++------- test/test_api.py | 1 + test/test_output.py | 7 +++++++ 4 files changed, 48 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 9ccb100..690d6f3 100644 --- a/README.md +++ b/README.md @@ -466,6 +466,14 @@ column, in which case every column may have different number formatting: 0.1 0.123 0.12345 --- ----- ------- +`intfmt` works similarly for integers + + >>> print(tabulate([["a",1000],["b",90000]], intfmt=",")) + - ------ + a 1,000 + b 90,000 + - ------ + ### Text formatting By default, `tabulate` removes leading and trailing whitespace from text @@ -653,6 +661,7 @@ Usage of the command line utility -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, github, grid, fancy_grid, pipe, orgtbl, rst, mediawiki, html, latex, latex_raw, diff --git a/tabulate.py b/tabulate.py index 55b896e..484ff23 100644 --- a/tabulate.py +++ b/tabulate.py @@ -72,6 +72,7 @@ def _is_file(f): PRESERVE_WHITESPACE = False _DEFAULT_FLOATFMT = "g" +_DEFAULT_INTFMT = "" _DEFAULT_MISSINGVAL = "" # default align will be overwritten by "left", "center" or "decimal" # depending on the formatter @@ -962,7 +963,7 @@ def _column_type(strings, has_invisible=True, numparse=True): return reduce(_more_generic, types, _bool_type) -def _format(val, valtype, floatfmt, missingval="", has_invisible=True): +def _format(val, valtype, floatfmt, intfmt, missingval="", has_invisible=True): """Format a value according to its type. Unicode is supported: @@ -977,8 +978,10 @@ def _format(val, valtype, floatfmt, missingval="", has_invisible=True): if val is None: return missingval - if valtype in [int, _text_type]: + if valtype is _text_type: return "{0}".format(val) + elif valtype is int: + return format(val, intfmt) elif valtype is _binary_type: try: return _text_type(val, "ascii") @@ -1218,6 +1221,7 @@ def tabulate( headers=(), tablefmt="simple", floatfmt=_DEFAULT_FLOATFMT, + intfmt=_DEFAULT_INTFMT, numalign=_DEFAULT_ALIGN, stralign=_DEFAULT_ALIGN, missingval=_DEFAULT_MISSINGVAL, @@ -1290,6 +1294,10 @@ def tabulate( 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. @@ -1582,6 +1590,14 @@ def tabulate( float_formats = list(floatfmt) if len(float_formats) < len(cols): float_formats.extend((len(cols) - len(float_formats)) * [_DEFAULT_FLOATFMT]) + if isinstance(intfmt, basestring): # 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, basestring): missing_vals = len(cols) * [missingval] else: @@ -1589,8 +1605,10 @@ def tabulate( if len(missing_vals) < len(cols): missing_vals.extend((len(cols) - len(missing_vals)) * [_DEFAULT_MISSINGVAL]) cols = [ - [_format(v, ct, fl_fmt, miss_v, has_invisible) for v in c] - for c, ct, fl_fmt, miss_v in zip(cols, coltypes, float_formats, missing_vals) + [_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 @@ -1791,6 +1809,7 @@ def _main(): -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, @@ -1806,7 +1825,7 @@ def _main(): opts, args = getopt.getopt( sys.argv[1:], "h1o:s:F:A:f:", - ["help", "header", "output", "sep=", "float=", "align=", "format="], + ["help", "header", "output", "sep=", "float=", "int=", "align=", "format="], ) except getopt.GetoptError as e: print(e) @@ -1814,6 +1833,7 @@ def _main(): sys.exit(2) headers = [] floatfmt = _DEFAULT_FLOATFMT + intfmt = _DEFAULT_INTFMT colalign = None tablefmt = "simple" sep = r"\s+" @@ -1825,6 +1845,8 @@ def _main(): 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"]: @@ -1850,6 +1872,7 @@ def _main(): tablefmt=tablefmt, sep=sep, floatfmt=floatfmt, + intfmt=intfmt, file=out, colalign=colalign, ) @@ -1861,16 +1884,17 @@ def _main(): tablefmt=tablefmt, sep=sep, floatfmt=floatfmt, + intfmt=intfmt, file=out, colalign=colalign, ) -def _pprint_file(fobject, headers, tablefmt, sep, floatfmt, file, 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, colalign=colalign), + tabulate(table, headers, tablefmt, floatfmt=floatfmt, intfmt=intfmt, colalign=colalign), file=file, ) diff --git a/test/test_api.py b/test/test_api.py index bf56e82..3f3a947 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -46,6 +46,7 @@ def test_tabulate_signature(): ("headers", ()), ("tablefmt", "simple"), ("floatfmt", "g"), + ("intfmt", ""), ("numalign", "default"), ("stralign", "default"), ("missingval", ""), diff --git a/test/test_output.py b/test/test_output.py index abab439..41c16ec 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -1315,6 +1315,13 @@ def test_empty_data_without_headers(): assert_equal(expected, result) +def test_intfmt(): + "Output: integer format" + result = tabulate([[10000], [10]], intfmt=",", tablefmt="plain") + expected = "10,000\n 10" + assert_equal(expected, result) + + def test_floatfmt(): "Output: floating point format" result = tabulate([["1.23456789"], [1.0]], floatfmt=".3f", tablefmt="plain") 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 049/207] 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 b9a7135d50570ab3ea89647f598ff47a0c7effe7 Mon Sep 17 00:00:00 2001 From: magelisk Date: Tue, 2 Mar 2021 11:19:37 -0500 Subject: [PATCH 050/207] This enables setting maximum width for any columns. When required, text will be automatically wrapped to fit within these bounds, and this wrapping will honor wide characters and ANSI color codes --- README.md | 27 ++++ tabulate.py | 277 +++++++++++++++++++++++++++++++++++++++ test/test_api.py | 7 + test/test_internal.py | 166 ++++++++++++++++++++++- test/test_output.py | 97 ++++++++++++++ test/test_textwrapper.py | 152 +++++++++++++++++++++ 6 files changed, 725 insertions(+), 1 deletion(-) create mode 100644 test/test_textwrapper.py diff --git a/README.md b/README.md index 9ccb100..5c4b62a 100644 --- a/README.md +++ b/README.md @@ -638,6 +638,33 @@ a multiline cell, and headers with a multiline cell: Multiline cells are not well supported for the other table formats. +### Automating Multilines +While tabulate supports data passed in with multiines entries explicitly provided, +it also provides some support to help manage this work internally. + +The `maxcolwidths` argument is a list where each entry specifies the max width for +it's respective column. Any cell that will exceed this will automatically wrap the content. +To assign the same max width for all columns, a singular int scaler can be used. + +Use `None` for any columns where an explicit maximum does not need to be provided, +and thus no automate multiline wrapping will take place. + +The wraping uses the python standard [textwrap.wrap](https://docs.python.org/3/library/textwrap.html#textwrap.wrap) +function with default parameters - aside from width. + +This example demonstrates usagage of automatic multiline wrapping, though typically +the lines being wrapped would probably be significantly longer than this. + + >>> print(tabulate([["John Smith", "Middle Manager"]], headers=["Name", "Title"], tablefmt="grid", maxcolwidths=[None, 8])) + +------------+---------+ + | Name | Title | + +============+=========+ + | John Smith | Middle | + | | Manager | + +------------+---------+ + + + Usage of the command line utility --------------------------------- diff --git a/tabulate.py b/tabulate.py index 55b896e..9b98bbc 100644 --- a/tabulate.py +++ b/tabulate.py @@ -8,6 +8,7 @@ import sys import re import math +import textwrap if sys.version_info >= (3, 3): @@ -569,6 +570,8 @@ def escape_empty(val): r"\x1B]8;[a-zA-Z0-9:]*;[^\x1B]+\x1B\\([^\x1b]+)\x1B]8;;\x1B\\" ) # Terminal hyperlinks +_ansi_color_reset_code = "\033[0m" + def simple_separated_format(separator): """Construct a simple TableFormat with columns separated by a separator. @@ -1213,6 +1216,29 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): return rows, headers +def _wrap_text_to_colwidths(list_of_lists, colwidths, numparses=True): + numparses = _expand_iterable(numparses, len(list_of_lists[0]), 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) + wrapped = wrapper.wrap(cell) + new_row.append("\n".join(wrapped)) + else: + new_row.append(cell) + result.append(new_row) + + return result + + def tabulate( tabular_data, headers=(), @@ -1224,6 +1250,7 @@ def tabulate( showindex="default", disable_numparse=False, colalign=None, + maxcolwidths=None, ): """Format a fixed width table for pretty printing. @@ -1521,6 +1548,31 @@ def tabulate( 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 | + +------------+------------+-------------------------------+ + + """ if tabular_data is None: @@ -1529,6 +1581,18 @@ def tabulate( tabular_data, headers, showindex=showindex ) + if maxcolwidths is not None: + num_cols = len(list_of_lists[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 + ) + # empty values in the first column of RST tables should be escaped (issue #82) # "" should be escaped as "\\ " or ".." if tablefmt == "rst": @@ -1647,6 +1711,20 @@ def _expand_numparse(disable_numparse, column_count): 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): + return original + [default] * (num_desired - len(original)) + else: + return [default] * num_desired + + def _pad_row(cells, padding): if cells: pad = " " * padding @@ -1774,6 +1852,205 @@ def _format_table(fmt, headers, rows, colwidths, colaligns, is_multiline): 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 = [] + super(_CustomTextWrap, self).__init__(*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_invisible(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 re.finditer(_invisible_codes, 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 ...] diff --git a/test/test_api.py b/test/test_api.py index bf56e82..5b77c0e 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -34,6 +34,9 @@ def _check_signature(function, expected_sig): skip("") actual_sig = signature(function) print("expected: %s\nactual: %s\n" % (expected_sig, str(actual_sig))) + + 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 @@ -49,6 +52,10 @@ def test_tabulate_signature(): ("numalign", "default"), ("stralign", "default"), ("missingval", ""), + ("showindex", "default"), + ("disable_numparse", False), + ("colalign", None), + ("maxcolwidths", None), ] _check_signature(tabulate, expected_sig) diff --git a/test/test_internal.py b/test/test_internal.py index 035558f..44765cf 100644 --- a/test/test_internal.py +++ b/test/test_internal.py @@ -5,7 +5,7 @@ from __future__ import print_function from __future__ import unicode_literals import tabulate as T -from common import assert_equal +from common import assert_equal, skip def test_multiline_width(): @@ -45,3 +45,167 @@ def test_align_column_multiline(): output = T._align_column(column, "center", is_multiline=True) expected = [" 1 ", " 123 ", "12345" + "\n" + " 6 "] assert_equal(output, expected) + + +def test_wrap_text_to_colwidths(): + "Internal: Test _wrap_text_to_colwidths to show it will wrap text based on colwidths" + rows = [ + ["mini", "medium", "decently long", "wrap will be ignored"], + [ + "small", + "JustOneWordThatIsWayTooLong", + "this is unreasonably long for a single cell length", + "also ignored here", + ], + ] + widths = [10, 10, 20, None] + expected = [ + ["mini", "medium", "decently long", "wrap will be ignored"], + [ + "small", + "JustOneWor\ndThatIsWay\nTooLong", + "this is unreasonably\nlong for a single\ncell length", + "also ignored here", + ], + ] + result = T._wrap_text_to_colwidths(rows, widths) + + assert_equal(result, expected) + + +def test_wrap_text_wide_chars(): + "Internal: Wrap wide characters based on column width" + try: + import wcwidth # noqa + except ImportError: + skip("test_wrap_text_wide_chars is skipped") + + rows = [["청자청자청자청자청자", "약간 감싸면 더 잘 보일 수있는 다소 긴 설명입니다"]] + widths = [5, 20] + expected = [["청자\n청자\n청자\n청자\n청자", "약간 감싸면 더 잘\n보일 수있는 다소 긴\n설명입니다"]] + result = T._wrap_text_to_colwidths(rows, widths) + + assert_equal(result, expected) + + +def test_wrap_text_to_numbers(): + """Internal: Test _wrap_text_to_colwidths force ignores numbers by + default so as not to break alignment behaviors""" + rows = [ + ["first number", 123.456789, "123.456789"], + ["second number", "987654.123", "987654.123"], + ] + widths = [6, 6, 6] + expected = [ + ["first\nnumber", 123.456789, "123.45\n6789"], + ["second\nnumber", "987654.123", "987654\n.123"], + ] + + result = T._wrap_text_to_colwidths(rows, widths, numparses=[True, True, False]) + assert_equal(result, expected) + + +def test_wrap_text_to_colwidths_single_ansi_colors_full_cell(): + """Internal: autowrapped text can retain a single ANSI colors + when it is at the beginning and end of full cell""" + data = [ + [ + ( + "\033[31mThis is a rather long description that might" + " look better if it is wrapped a bit\033[0m" + ) + ] + ] + result = T._wrap_text_to_colwidths(data, [30]) + + expected = [ + [ + "\n".join( + [ + "\033[31mThis is a rather long\033[0m", + "\033[31mdescription that might look\033[0m", + "\033[31mbetter if it is wrapped a bit\033[0m", + ] + ) + ] + ] + assert_equal(expected, result) + + +def test_wrap_text_to_colwidths_colors_wide_char(): + """Internal: autowrapped text can retain a ANSI colors with wide chars""" + try: + import wcwidth # noqa + except ImportError: + skip("test_wrap_text_to_colwidths_colors_wide_char is skipped") + + data = [[("\033[31m약간 감싸면 더 잘 보일 수있는 다소 긴" " 설명입니다 설명입니다 설명입니다 설명입니다 설명\033[0m")]] + result = T._wrap_text_to_colwidths(data, [30]) + + expected = [ + [ + "\n".join( + [ + "\033[31m약간 감싸면 더 잘 보일 수있는\033[0m", + "\033[31m다소 긴 설명입니다 설명입니다\033[0m", + "\033[31m설명입니다 설명입니다 설명\033[0m", + ] + ) + ] + ] + assert_equal(expected, result) + + +def test_wrap_text_to_colwidths_multi_ansi_colors_full_cell(): + """Internal: autowrapped text can retain multiple ANSI colors + when they are at the beginning and end of full cell + (e.g. text and background colors)""" + data = [ + [ + ( + "\033[31m\033[43mThis is a rather long description that" + " might look better if it is wrapped a bit\033[0m" + ) + ] + ] + result = T._wrap_text_to_colwidths(data, [30]) + + expected = [ + [ + "\n".join( + [ + "\033[31m\033[43mThis is a rather long\033[0m", + "\033[31m\033[43mdescription that might look\033[0m", + "\033[31m\033[43mbetter if it is wrapped a bit\033[0m", + ] + ) + ] + ] + assert_equal(expected, result) + + +def test_wrap_text_to_colwidths_multi_ansi_colors_in_subset(): + """Internal: autowrapped text can retain multiple ANSI colors + when they are around subsets of the cell""" + data = [ + [ + ( + "This is a rather \033[31mlong description\033[0m that" + " might look better \033[93mif it is wrapped\033[0m a bit" + ) + ] + ] + result = T._wrap_text_to_colwidths(data, [30]) + + expected = [ + [ + "\n".join( + [ + "This is a rather \033[31mlong\033[0m", + "\033[31mdescription\033[0m that might look", + "better \033[93mif it is wrapped\033[0m a bit", + ] + ) + ] + ] + assert_equal(expected, result) diff --git a/test/test_output.py b/test/test_output.py index abab439..eed4489 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -104,6 +104,103 @@ def test_plain_multiline_with_empty_cells_headerless(): assert_equal(expected, result) +def test_plain_maxcolwidth_autowraps(): + "Output: maxcolwidth will result in autowrapping longer cells" + table = [["hdr", "fold"], ["1", "very long data"]] + expected = "\n".join([" hdr fold", " 1 very long", " data"]) + result = tabulate( + table, headers="firstrow", tablefmt="plain", maxcolwidths=[10, 10] + ) + assert_equal(expected, result) + + +def test_plain_maxcolwidth_autowraps_wide_chars(): + "Output: maxcolwidth and autowrapping functions with wide characters" + try: + import wcwidth # noqa + except ImportError: + skip("test_wrap_text_wide_chars is skipped") + + table = [ + ["hdr", "fold"], + ["1", "약간 감싸면 더 잘 보일 수있는 다소 긴 설명입니다 설명입니다 설명입니다 설명입니다 설명"], + ] + expected = "\n".join( + [ + " hdr fold", + " 1 약간 감싸면 더 잘 보일 수있는", + " 다소 긴 설명입니다 설명입니다", + " 설명입니다 설명입니다 설명", + ] + ) + result = tabulate( + table, headers="firstrow", tablefmt="plain", maxcolwidths=[10, 30] + ) + assert_equal(expected, result) + + +def test_maxcolwidth_single_value(): + "Output: maxcolwidth can be specified as a single number that works for each column" + table = [ + ["hdr", "fold1", "fold2"], + ["mini", "this is short", "this is a bit longer"], + ] + expected = "\n".join( + [ + "hdr fold1 fold2", + "mini this this", + " is is a", + " short bit", + " longer", + ] + ) + result = tabulate(table, headers="firstrow", tablefmt="plain", maxcolwidths=6) + assert_equal(expected, result) + + +def test_maxcolwidth_pad_tailing_widths(): + "Output: maxcolwidth, if only partly specified, pads tailing cols with None" + table = [ + ["hdr", "fold1", "fold2"], + ["mini", "this is short", "this is a bit longer"], + ] + expected = "\n".join( + [ + "hdr fold1 fold2", + "mini this this is a bit longer", + " is", + " short", + ] + ) + result = tabulate( + table, headers="firstrow", tablefmt="plain", maxcolwidths=[None, 6] + ) + assert_equal(expected, result) + + +def test_maxcolwidth_honor_disable_parsenum(): + "Output: Using maxcolwidth in conjunction with disable_parsenum is honored" + table = [ + ["first number", 123.456789, "123.456789"], + ["second number", "987654321.123", "987654321.123"], + ] + expected = "\n".join( + [ + "+--------+---------------+--------+", + "| first | 123.457 | 123.45 |", + "| number | | 6789 |", + "+--------+---------------+--------+", + "| second | 9.87654e+08 | 987654 |", + "| number | | 321.12 |", + "| | | 3 |", + "+--------+---------------+--------+", + ] + ) + # Grid makes showing the alignment difference a little easier + result = tabulate(table, tablefmt="grid", maxcolwidths=6, disable_numparse=[2]) + assert_equal(expected, result) + + def test_simple(): "Output: simple with headers" expected = "\n".join( diff --git a/test/test_textwrapper.py b/test/test_textwrapper.py new file mode 100644 index 0000000..27b0590 --- /dev/null +++ b/test/test_textwrapper.py @@ -0,0 +1,152 @@ +# -*- coding: utf-8 -*- + +"""Discretely test functionality of our custom TextWrapper""" + +from tabulate import _CustomTextWrap as CTW +from textwrap import TextWrapper as OTW + +from .common import skip, assert_equal + + +def test_wrap_multiword_non_wide(): + """TextWrapper: non-wide character regression tests""" + data = "this is a test string for regression spiltting" + for width in range(1, len(data)): + orig = OTW(width=width) + cust = CTW(width=width) + + assert orig.wrap(data) == cust.wrap( + data + ), "Failure on non-wide char multiword regression check for width " + str(width) + + +def test_wrap_multiword_non_wide_with_hypens(): + """TextWrapper: non-wide character regression tests that contain hypens""" + data = "how should-we-split-this non-sense string that-has-lots-of-hypens" + for width in range(1, len(data)): + orig = OTW(width=width) + cust = CTW(width=width) + + assert orig.wrap(data) == cust.wrap( + data + ), "Failure on non-wide char hyphen regression check for width " + str(width) + + +def test_wrap_longword_non_wide(): + """TextWrapper: Some non-wide character regression tests""" + data = "ThisIsASingleReallyLongWordThatWeNeedToSplit" + for width in range(1, len(data)): + orig = OTW(width=width) + cust = CTW(width=width) + + assert orig.wrap(data) == cust.wrap( + data + ), "Failure on non-wide char longword regression check for width " + str(width) + + +def test_wrap_wide_char_multiword(): + """TextWrapper: wrapping support for wide characters with mulitple words""" + try: + import wcwidth # noqa + except ImportError: + skip("test_wrap_wide_char is skipped") + + data = "약간 감싸면 더 잘 보일 수있는 다소 긴 설명입니다" + + expected = ["약간 감싸면 더", "잘 보일 수있는", "다소 긴", "설명입니다"] + + wrapper = CTW(width=15) + result = wrapper.wrap(data) + assert_equal(expected, result) + + +def test_wrap_wide_char_longword(): + """TextWrapper: wrapping wide char word that needs to be broken up""" + try: + import wcwidth # noqa + except ImportError: + skip("test_wrap_wide_char_longword is skipped") + + data = "약간감싸면더잘보일수있" + + expected = ["약간", "감싸", "면더", "잘보", "일수", "있"] + + # Explicit odd number to ensure the 2 width is taken into account + wrapper = CTW(width=5) + result = wrapper.wrap(data) + assert_equal(expected, result) + + +def test_wrap_mixed_string(): + """TextWrapper: wrapping string with mix of wide and non-wide chars""" + data = ( + "This content of this string (この文字列のこの内容) contains " + "mulitple character types (複数の文字タイプが含まれています)" + ) + + expected = [ + "This content of this", + "string (この文字列の", + "この内容) contains", + "mulitple character", + "types (複数の文字タイ", + "プが含まれています)", + ] + wrapper = CTW(width=21) + result = wrapper.wrap(data) + assert_equal(expected, result) + + +def test_wrapper_len_ignores_color_chars(): + data = "\033[31m\033[104mtenletters\033[0m" + result = CTW._len(data) + assert_equal(10, result) + + +def test_wrap_full_line_color(): + """TextWrapper: Wrap a line when the full thing is enclosed in color tags""" + # This has both a text color and a background color + data = ( + "\033[31m\033[104mThis is a test string for testing TextWrap with colors\033[0m" + ) + + expected = [ + "\033[31m\033[104mThis is a test\033[0m", + "\033[31m\033[104mstring for testing\033[0m", + "\033[31m\033[104mTextWrap with colors\033[0m", + ] + wrapper = CTW(width=20) + result = wrapper.wrap(data) + assert_equal(expected, result) + + +def test_wrap_color_in_single_line(): + """TextWrapper: Wrap a line - preserve internal color tags, and don't + propogate them to other lines when they don't need to be""" + # This has both a text color and a background color + data = "This is a test string for testing \033[31mTextWrap\033[0m with colors" + + expected = [ + "This is a test string for", + "testing \033[31mTextWrap\033[0m with", + "colors", + ] + wrapper = CTW(width=25) + result = wrapper.wrap(data) + assert_equal(expected, result) + + +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""" + # This has both a text color and a background color + data = "This is a \033[31mtest string for testing TextWrap\033[0m with colors" + + expected = [ + "This is a \033[31mtest string for\033[0m", + "\033[31mtesting TextWrap\033[0m with", + "colors", + ] + wrapper = CTW(width=25) + result = wrapper.wrap(data) + assert_equal(expected, result) From 51ad34f810aa488fd0bceaefc97b21372571b0c7 Mon Sep 17 00:00:00 2001 From: magelisk Date: Wed, 3 Mar 2021 17:27:57 -0500 Subject: [PATCH 051/207] Removed python3 relative import --- test/test_textwrapper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_textwrapper.py b/test/test_textwrapper.py index 27b0590..cc97af3 100644 --- a/test/test_textwrapper.py +++ b/test/test_textwrapper.py @@ -5,7 +5,7 @@ from tabulate import _CustomTextWrap as CTW from textwrap import TextWrapper as OTW -from .common import skip, assert_equal +from common import skip, assert_equal def test_wrap_multiword_non_wide(): From bce1cd590430808d937575d82b8011083cc353a5 Mon Sep 17 00:00:00 2001 From: magelisk Date: Wed, 3 Mar 2021 18:03:12 -0500 Subject: [PATCH 052/207] Fixed python inheritence __init__ call --- tabulate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tabulate.py b/tabulate.py index 9b98bbc..19c716e 100644 --- a/tabulate.py +++ b/tabulate.py @@ -1862,7 +1862,7 @@ class _CustomTextWrap(textwrap.TextWrapper): def __init__(self, *args, **kwargs): self._active_codes = [] - super(_CustomTextWrap, self).__init__(*args, **kwargs) + textwrap.TextWrapper.__init__(self, *args, **kwargs) @staticmethod def _len(item): From bbb83176267a32bd506e0ff09b6568ea057b5344 Mon Sep 17 00:00:00 2001 From: magelisk Date: Wed, 3 Mar 2021 18:08:46 -0500 Subject: [PATCH 053/207] Skip mixed string test --- test/test_textwrapper.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/test_textwrapper.py b/test/test_textwrapper.py index cc97af3..a657bf9 100644 --- a/test/test_textwrapper.py +++ b/test/test_textwrapper.py @@ -79,6 +79,11 @@ def test_wrap_wide_char_longword(): def test_wrap_mixed_string(): """TextWrapper: wrapping string with mix of wide and non-wide chars""" + try: + import wcwidth # noqa + except ImportError: + skip("test_wrap_wide_char is skipped") + data = ( "This content of this string (この文字列のこの内容) contains " "mulitple character types (複数の文字タイプが含まれています)" From cc2e7b2dd56e97d8e562f0e839c3a82fd124d0cb Mon Sep 17 00:00:00 2001 From: magelisk Date: Wed, 3 Mar 2021 19:10:56 -0500 Subject: [PATCH 054/207] python2 unicode support --- tabulate.py | 1 + test/test_textwrapper.py | 1 + 2 files changed, 2 insertions(+) diff --git a/tabulate.py b/tabulate.py index 19c716e..0579fb3 100644 --- a/tabulate.py +++ b/tabulate.py @@ -1862,6 +1862,7 @@ class _CustomTextWrap(textwrap.TextWrapper): def __init__(self, *args, **kwargs): self._active_codes = [] + self.max_lines = None # For python2 compatibility textwrap.TextWrapper.__init__(self, *args, **kwargs) @staticmethod diff --git a/test/test_textwrapper.py b/test/test_textwrapper.py index a657bf9..e8e1c09 100644 --- a/test/test_textwrapper.py +++ b/test/test_textwrapper.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- """Discretely test functionality of our custom TextWrapper""" +from __future__ import unicode_literals from tabulate import _CustomTextWrap as CTW from textwrap import TextWrapper as OTW From b09392f6665fe22584d71285e9560bc4c58c0d38 Mon Sep 17 00:00:00 2001 From: magelisk Date: Thu, 4 Mar 2021 20:06:40 -0500 Subject: [PATCH 055/207] Rows can specifiy top, bottom, or center alignment for their cell text in multiline rows --- tabulate.py | 52 ++++++++++++++++++++++++------ test/test_api.py | 1 + test/test_internal.py | 73 +++++++++++++++++++++++++++++++++++++++++++ test/test_output.py | 31 ++++++++++++++++++ 4 files changed, 148 insertions(+), 9 deletions(-) diff --git a/tabulate.py b/tabulate.py index 0579fb3..56408d8 100644 --- a/tabulate.py +++ b/tabulate.py @@ -1251,6 +1251,7 @@ def tabulate( disable_numparse=False, colalign=None, maxcolwidths=None, + rowalign=None, ): """Format a fixed width table for pretty printing. @@ -1691,7 +1692,12 @@ def tabulate( if not isinstance(tablefmt, TableFormat): tablefmt = _table_formats.get(tablefmt, _table_formats["simple"]) - return _format_table(tablefmt, headers, rows, minwidths, aligns, is_multiline) + ra_default = rowalign if isinstance(rowalign, str) else None + rowaligns = _expand_iterable(rowalign, len(rows), ra_default) + + return _format_table( + tablefmt, headers, rows, minwidths, aligns, is_multiline, rowaligns=rowaligns + ) def _expand_numparse(disable_numparse, column_count): @@ -1719,7 +1725,7 @@ def _expand_iterable(original, num_desired, 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): + if isinstance(original, Iterable) and not isinstance(original, str): return original + [default] * (num_desired - len(original)) else: return [default] * num_desired @@ -1750,20 +1756,39 @@ def _build_row(padded_cells, colwidths, colaligns, rowfmt): return _build_simple_row(padded_cells, rowfmt) -def _append_basic_row(lines, padded_cells, colwidths, colaligns, 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 + 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 = [ - (cl + [" " * w] * (nlines - len(cl))) for cl, w in zip(cells_lines, colwidths) + _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: @@ -1802,7 +1827,7 @@ def str(self): return self -def _format_table(fmt, headers, rows, colwidths, colaligns, is_multiline): +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 [] @@ -1830,11 +1855,20 @@ def _format_table(fmt, headers, rows, colwidths, colaligns, is_multiline): if padded_rows and fmt.linebetweenrows and "linebetweenrows" not in hidden: # initial rows with a line below - for row in padded_rows[:-1]: - append_row(lines, row, padded_widths, colaligns, fmt.datarow) + 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) + append_row( + lines, + padded_rows[-1], + padded_widths, + colaligns, + fmt.datarow, + rowalign=rowaligns[-1], + ) else: for row in padded_rows: append_row(lines, row, padded_widths, colaligns, fmt.datarow) diff --git a/test/test_api.py b/test/test_api.py index 5b77c0e..5729d56 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -56,6 +56,7 @@ def test_tabulate_signature(): ("disable_numparse", False), ("colalign", None), ("maxcolwidths", None), + ("rowalign", None), ] _check_signature(tabulate, expected_sig) diff --git a/test/test_internal.py b/test/test_internal.py index 44765cf..925936c 100644 --- a/test/test_internal.py +++ b/test/test_internal.py @@ -47,6 +47,79 @@ def test_align_column_multiline(): assert_equal(output, expected) +def test_align_cell_veritically_one_line_only(): + "Internal: Aligning a single height cell is same regardless of alignment value" + lines = ["one line"] + column_width = 8 + + top = T._align_cell_veritically(lines, 1, column_width, "top") + center = T._align_cell_veritically(lines, 1, column_width, "center") + bottom = T._align_cell_veritically(lines, 1, column_width, "bottom") + none = T._align_cell_veritically(lines, 1, column_width, None) + + expected = ["one line"] + assert top == center == bottom == none == expected + + +def test_align_cell_veritically_top_single_text_multiple_pad(): + "Internal: Align single cell text to top" + result = T._align_cell_veritically(["one line"], 3, 8, "top") + + expected = ["one line", " ", " "] + + assert_equal(expected, result) + + +def test_align_cell_veritically_center_single_text_multiple_pad(): + "Internal: Align single cell text to center" + result = T._align_cell_veritically(["one line"], 3, 8, "center") + + expected = [" ", "one line", " "] + + assert_equal(expected, result) + + +def test_align_cell_veritically_bottom_single_text_multiple_pad(): + "Internal: Align single cell text to bottom" + result = T._align_cell_veritically(["one line"], 3, 8, "bottom") + + expected = [" ", " ", "one line"] + + assert_equal(expected, result) + + +def test_align_cell_veritically_top_multi_text_multiple_pad(): + "Internal: Align multiline celltext text to top" + text = ["just", "one ", "cell"] + result = T._align_cell_veritically(text, 6, 4, "top") + + expected = ["just", "one ", "cell", " ", " ", " "] + + assert_equal(expected, result) + + +def test_align_cell_veritically_center_multi_text_multiple_pad(): + "Internal: Align multiline celltext text to center" + text = ["just", "one ", "cell"] + result = T._align_cell_veritically(text, 6, 4, "center") + + # Even number of rows, can't perfectly center, but we pad less + # at top when required to do make a judgement + expected = [" ", "just", "one ", "cell", " ", " "] + + assert_equal(expected, result) + + +def test_align_cell_veritically_bottom_multi_text_multiple_pad(): + "Internal: Align multiline celltext text to bottom" + text = ["just", "one ", "cell"] + result = T._align_cell_veritically(text, 6, 4, "bottom") + + expected = [" ", " ", " ", "just", "one ", "cell"] + + assert_equal(expected, result) + + def test_wrap_text_to_colwidths(): "Internal: Test _wrap_text_to_colwidths to show it will wrap text based on colwidths" rows = [ diff --git a/test/test_output.py b/test/test_output.py index eed4489..8f7885c 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -590,6 +590,37 @@ def test_fancy_grid_multiline_with_empty_cells_headerless(): assert_equal(expected, result) +def test_fancy_grid_multiline_row_align(): + "Output: fancy_grid with multiline cells aligning some text not to top of cell" + table = [ + ["0", "some\ndefault\ntext", "up\ntop"], + ["1", "very\nlong\ndata\ncell", "mid\ntest"], + ["2", "also\nvery\nlong\ndata\ncell", "fold\nthis"], + ] + expected = "\n".join( + [ + "╒═══╤═════════╤══════╕", + "│ 0 │ some │ up │", + "│ │ default │ top │", + "│ │ text │ │", + "├───┼─────────┼──────┤", + "│ │ very │ │", + "│ 1 │ long │ mid │", + "│ │ data │ test │", + "│ │ cell │ │", + "├───┼─────────┼──────┤", + "│ │ also │ │", + "│ │ very │ │", + "│ │ long │ │", + "│ │ data │ fold │", + "│ 2 │ cell │ this │", + "╘═══╧═════════╧══════╛", + ] + ) + result = tabulate(table, tablefmt="fancy_grid", rowalign=[None, "center", "bottom"]) + assert_equal(expected, result) + + def test_pipe(): "Output: pipe with headers" expected = "\n".join( From e8e3091d46966f462735f227d5ef4effdfaa0582 Mon Sep 17 00:00:00 2001 From: magelisk Date: Wed, 3 Mar 2021 19:10:56 -0500 Subject: [PATCH 056/207] Wordwrapping should now work when provided with a datetime a cell to wrap --- tabulate.py | 9 ++++++++- test/test_textwrapper.py | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/tabulate.py b/tabulate.py index 19c716e..e189104 100644 --- a/tabulate.py +++ b/tabulate.py @@ -1230,7 +1230,13 @@ def _wrap_text_to_colwidths(list_of_lists, colwidths, numparses=True): if width is not None: wrapper = _CustomTextWrap(width=width) - wrapped = wrapper.wrap(cell) + # Cast based on our internal type handling + # Any future custom formatting of types (such as datetimes) + # may need to be more explict than just `str` of the object + casted_cell = ( + str(cell) if _isnumber(cell) else _type(cell, numparse)(cell) + ) + wrapped = wrapper.wrap(casted_cell) new_row.append("\n".join(wrapped)) else: new_row.append(cell) @@ -1862,6 +1868,7 @@ class _CustomTextWrap(textwrap.TextWrapper): def __init__(self, *args, **kwargs): self._active_codes = [] + self.max_lines = None # For python2 compatibility textwrap.TextWrapper.__init__(self, *args, **kwargs) @staticmethod diff --git a/test/test_textwrapper.py b/test/test_textwrapper.py index a657bf9..4ef2276 100644 --- a/test/test_textwrapper.py +++ b/test/test_textwrapper.py @@ -1,8 +1,11 @@ # -*- coding: utf-8 -*- """Discretely test functionality of our custom TextWrapper""" +from __future__ import unicode_literals -from tabulate import _CustomTextWrap as CTW +import datetime + +from tabulate import _CustomTextWrap as CTW, tabulate from textwrap import TextWrapper as OTW from common import skip, assert_equal @@ -155,3 +158,31 @@ def test_wrap_color_line_splillover(): wrapper = CTW(width=25) result = wrapper.wrap(data) assert_equal(expected, result) + + +def test_wrap_datetime(): + """TextWrapper: Show that datetimes can be wrapped without crashing""" + data = [ + ["First Entry", datetime.datetime(2020, 1, 1, 5, 6, 7)], + ["Second Entry", datetime.datetime(2021, 2, 2, 0, 0, 0)], + ] + headers = ["Title", "When"] + result = tabulate(data, headers=headers, tablefmt="grid", maxcolwidths=[7, 5]) + + expected = [ + "+---------+--------+", + "| Title | When |", + "+=========+========+", + "| First | 2020- |", + "| Entry | 01-01 |", + "| | 05:06 |", + "| | :07 |", + "+---------+--------+", + "| Second | 2021- |", + "| Entry | 02-02 |", + "| | 00:00 |", + "| | :00 |", + "+---------+--------+", + ] + expected = "\n".join(expected) + assert_equal(expected, result) From d6763664349c14c65b8d7eb83440e9db63a278e8 Mon Sep 17 00:00:00 2001 From: cleoold Date: Sun, 16 May 2021 05:44:11 -0400 Subject: [PATCH 057/207] py37 dataclass support --- README.md | 1 + tabulate.py | 24 +++++++++++++++++++++--- test/test_input.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5c4b62a..f92bc7c 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,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) - two-dimensional NumPy array - NumPy record arrays (names as columns) - pandas.DataFrame diff --git a/tabulate.py b/tabulate.py index 0579fb3..1f3c814 100644 --- a/tabulate.py +++ b/tabulate.py @@ -16,6 +16,11 @@ else: from collections import Iterable +if sys.version_info >= (3, 7, 0): + import dataclasses +else: + dataclasses = None + if sys.version_info[0] < 3: from itertools import izip_longest from functools import partial @@ -1057,6 +1062,8 @@ 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") + * 2D NumPy arrays * NumPy record arrays (usually used with headers="keys") @@ -1112,7 +1119,7 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): if headers == "keys": headers = list(map(_text_type, keys)) # headers should be strings - else: # it's a usual an iterable of iterables, or a NumPy array + 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: @@ -1176,6 +1183,17 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): # 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(_text_type, range(len(rows[0])))) @@ -1264,8 +1282,8 @@ 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, - a two-dimensional NumPy array, NumPy record array, or a Pandas' - dataframe. + an iterable of dataclasses (Python 3.7+), a two-dimensional NumPy array, + NumPy record array, or a Pandas' dataframe. Table headers diff --git a/test/test_input.py b/test/test_input.py index 541b4f7..bc67c64 100644 --- a/test/test_input.py +++ b/test/test_input.py @@ -480,3 +480,45 @@ def test_py27orlater_list_of_ordereddicts(): expected = "\n".join([" b a", "--- ---", " 1 2", " 1 2"]) result = tabulate(lod, headers="keys") assert_equal(expected, result) + + +def test_py37orlater_list_of_dataclasses_keys(): + "Input: a list of dataclasses with first item's fields as keys and headers" + try: + from dataclasses import make_dataclass + + Person = make_dataclass("Person", ["name", "age", "height"]) + ld = [Person("Alice", 23, 169.5), Person("Bob", 27, 175.0)] + result = tabulate(ld, headers="keys") + expected = "\n".join( + [ + "name age height", + "------ ----- --------", + "Alice 23 169.5", + "Bob 27 175", + ] + ) + assert_equal(expected, result) + except ImportError: + skip("test_py37orlater_list_of_dataclasses_keys is skipped") + + +def test_py37orlater_list_of_dataclasses_headers(): + "Input: a list of dataclasses with user-supplied headers" + try: + from dataclasses import make_dataclass + + Person = make_dataclass("Person", ["name", "age", "height"]) + ld = [Person("Alice", 23, 169.5), Person("Bob", 27, 175.0)] + result = tabulate(ld, headers=["person", "years", "cm"]) + expected = "\n".join( + [ + "person years cm", + "-------- ------- -----", + "Alice 23 169.5", + "Bob 27 175", + ] + ) + assert_equal(expected, result) + except ImportError: + skip("test_py37orlater_list_of_dataclasses_headers is skipped") From 293ff59a4ed816db31423eeadb5135e9fcda3cac Mon Sep 17 00:00:00 2001 From: Shodhan Save Date: Thu, 19 Aug 2021 20:04:08 +0530 Subject: [PATCH 058/207] fix headers for empty list --- tabulate.py | 2 ++ test/test_input.py | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/tabulate.py b/tabulate.py index 0579fb3..0a23f3c 100644 --- a/tabulate.py +++ b/tabulate.py @@ -1189,6 +1189,8 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): headers = rows[0] headers = list(map(_text_type, headers)) # headers should be strings rows = rows[1:] + elif headers == "firstrow": + headers = [] headers = list(map(_text_type, headers)) rows = list(map(list, rows)) diff --git a/test/test_input.py b/test/test_input.py index 541b4f7..1b72819 100644 --- a/test/test_input.py +++ b/test/test_input.py @@ -480,3 +480,11 @@ def test_py27orlater_list_of_ordereddicts(): expected = "\n".join([" b a", "--- ---", " 1 2", " 1 2"]) result = tabulate(lod, headers="keys") assert_equal(expected, result) + + +def test_blank_list_firstrow(): + "Input: a blank list with the first row as headers." + ll = [] + expected = "" + result = tabulate(ll, headers="firstrow") + assert_equal(expected, result) From 46e985e2dbf91e1fcf86fa8fde0c3625f9b4863b Mon Sep 17 00:00:00 2001 From: Shodhan Save Date: Fri, 20 Aug 2021 15:34:43 +0530 Subject: [PATCH 059/207] Shifting test case to another file --- test/test_input.py | 8 -------- test/test_output.py | 7 +++++++ 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/test/test_input.py b/test/test_input.py index 1b72819..541b4f7 100644 --- a/test/test_input.py +++ b/test/test_input.py @@ -480,11 +480,3 @@ def test_py27orlater_list_of_ordereddicts(): expected = "\n".join([" b a", "--- ---", " 1 2", " 1 2"]) result = tabulate(lod, headers="keys") assert_equal(expected, result) - - -def test_blank_list_firstrow(): - "Input: a blank list with the first row as headers." - ll = [] - expected = "" - result = tabulate(ll, headers="firstrow") - assert_equal(expected, result) diff --git a/test/test_output.py b/test/test_output.py index eed4489..b751e5e 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -1412,6 +1412,13 @@ def test_empty_data_without_headers(): assert_equal(expected, result) +def test_empty_data_with_headers(): + "Output: table with empty data and headers as firstrow" + expected = "" + result = tabulate([], headers="firstrow") + assert_equal(expected, result) + + def test_floatfmt(): "Output: floating point format" result = tabulate([["1.23456789"], [1.0]], floatfmt=".3f", tablefmt="plain") From 35c14736839f3a9d01cce4e81fe067f339d74689 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Tue, 12 Oct 2021 11:53:13 +0300 Subject: [PATCH 060/207] Add support for Python 3.10 --- setup.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/setup.py b/setup.py index 1cb6a84..00be096 100644 --- a/setup.py +++ b/setup.py @@ -46,6 +46,7 @@ author_email="s.astanin@gmail.com", url="https://github.com/astanin/python-tabulate", license="MIT", + python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*", classifiers=[ "Development Status :: 4 - Beta", "License :: OSI Approved :: MIT License", @@ -58,6 +59,7 @@ "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", "Topic :: Software Development :: Libraries", ], py_modules=["tabulate"], From ecb2a1db6c3622e72f8922d25649363b6a4c3700 Mon Sep 17 00:00:00 2001 From: alexander Date: Thu, 14 Oct 2021 19:35:51 +0200 Subject: [PATCH 061/207] fix afterpoint of numbers with thousand separators resulting in correct decimal alignment --- tabulate.py | 44 ++++++++++++++++++++++++++++++++++++++++++- test/test_internal.py | 30 +++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/tabulate.py b/tabulate.py index 0579fb3..33a4880 100644 --- a/tabulate.py +++ b/tabulate.py @@ -572,6 +572,10 @@ def escape_empty(val): _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. @@ -593,6 +597,42 @@ def simple_separated_format(separator): ) +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 + + if re.match(_float_with_thousands_separators, string): + return True + + return False + + def _isconvertible(conv, string): try: conv(string) @@ -701,9 +741,11 @@ def _afterpoint(string): -1 >>> _afterpoint("123e45") 2 + >>> _afterpoint("123,456.78") + 2 """ - if _isnumber(string): + if _isnumber(string) or _isnumber_with_thousands_separator(string): if _isint(string): return -1 else: diff --git a/test/test_internal.py b/test/test_internal.py index 44765cf..681ee7b 100644 --- a/test/test_internal.py +++ b/test/test_internal.py @@ -31,6 +31,36 @@ def test_align_column_decimal(): assert_equal(output, expected) +def test_align_column_decimal_with_thousand_separators(): + "Internal: _align_column(..., 'decimal')" + column = ["12.345", "-1234.5", "1.23", "1,234.5", "1e+234", "1.0e234"] + output = T._align_column(column, "decimal") + expected = [ + " 12.345 ", + "-1234.5 ", + " 1.23 ", + "1,234.5 ", + " 1e+234 ", + " 1.0e234", + ] + assert_equal(output, expected) + + +def test_align_column_decimal_with_incorrect_thousand_separators(): + "Internal: _align_column(..., 'decimal')" + column = ["12.345", "-1234.5", "1.23", "12,34.5", "1e+234", "1.0e234"] + output = T._align_column(column, "decimal") + expected = [ + " 12.345 ", + " -1234.5 ", + " 1.23 ", + "12,34.5 ", + " 1e+234 ", + " 1.0e234", + ] + assert_equal(output, expected) + + def test_align_column_none(): "Internal: _align_column(..., None)" column = ["123.4", "56.7890"] From d81c20d69c8bb84f16ee13ddd4a27e7af6d8893d Mon Sep 17 00:00:00 2001 From: Kevin Patterson Date: Wed, 10 Nov 2021 18:05:57 -0600 Subject: [PATCH 062/207] provide a parameter that allows for controlling header maxcolwidth --- tabulate.py | 15 +++++++++++++++ test/test_api.py | 1 + test/test_output.py | 8 ++++++++ 3 files changed, 24 insertions(+) diff --git a/tabulate.py b/tabulate.py index 0579fb3..c7f6eeb 100644 --- a/tabulate.py +++ b/tabulate.py @@ -1251,6 +1251,7 @@ def tabulate( disable_numparse=False, colalign=None, maxcolwidths=None, + maxheadercolwidths=None, ): """Format a fixed width table for pretty printing. @@ -1572,6 +1573,7 @@ def tabulate( | | | better if it is wrapped a bit | +------------+------------+-------------------------------+ + Header column width can be specified in a similar way using `maxheadercolwidth` """ @@ -1593,6 +1595,19 @@ def tabulate( 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": diff --git a/test/test_api.py b/test/test_api.py index 5b77c0e..693c23c 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -56,6 +56,7 @@ def test_tabulate_signature(): ("disable_numparse", False), ("colalign", None), ("maxcolwidths", None), + ("maxheadercolwidths", None), ] _check_signature(tabulate, expected_sig) diff --git a/test/test_output.py b/test/test_output.py index eed4489..4133564 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -200,6 +200,14 @@ def test_maxcolwidth_honor_disable_parsenum(): result = tabulate(table, tablefmt="grid", maxcolwidths=6, disable_numparse=[2]) assert_equal(expected, result) +def test_plain_maxheadercolwidths_autowraps(): + "Output: maxheadercolwidths will result in autowrapping header cell" + table = [["hdr", "fold"], ["1", "very long data"]] + expected = "\n".join([" hdr fo"," ld", " 1 very long", " data"]) + result = tabulate( + table, headers="firstrow", tablefmt="plain", maxcolwidths=[10, 10], maxheadercolwidths=[None, 2] + ) + assert_equal(expected, result) def test_simple(): "Output: simple with headers" From 674dea81720f6f712c48a0283c26660236e63afc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Ga=C5=BEo?= <37304453+alexandergazo@users.noreply.github.com> Date: Sat, 4 Dec 2021 16:14:58 +0100 Subject: [PATCH 063/207] fix antipattern Co-authored-by: James Cooke --- tabulate.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tabulate.py b/tabulate.py index 33a4880..4afcd0d 100644 --- a/tabulate.py +++ b/tabulate.py @@ -627,10 +627,7 @@ def _isnumber_with_thousands_separator(string): except (UnicodeDecodeError, AttributeError): pass - if re.match(_float_with_thousands_separators, string): - return True - - return False + return bool(re.match(_float_with_thousands_separators, string)) def _isconvertible(conv, string): From 8e2e6ff186ff7ba28d8bf5ca0abbcafb687c4d68 Mon Sep 17 00:00:00 2001 From: jerome provensal Date: Mon, 3 Jan 2022 09:29:08 -0800 Subject: [PATCH 064/207] First stab at allowing to add separating lines in the rows provided to tabulate --- tabulate.py | 51 ++++++++++++++++++++--- test/common.py | 12 ++++++ test/test_internal.py | 21 +++++++++- test/test_output.py | 94 +++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 168 insertions(+), 10 deletions(-) diff --git a/tabulate.py b/tabulate.py index 0579fb3..13b2522 100644 --- a/tabulate.py +++ b/tabulate.py @@ -82,6 +82,9 @@ def _is_file(f): # 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"]) @@ -1023,6 +1026,26 @@ def _align_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): + row_type = type(row) + if (row_type == list or row_type == str) and row[0] == SEPARATING_LINE: + 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.""" @@ -1032,7 +1055,9 @@ def _prepend_row_index(rows, index): print("index=", index) print("rows=", rows) raise ValueError("index must be as long as the number of data rows") - rows = [[v] + list(row) for v, row in zip(index, rows)] + sans_rows, separating_lines = _remove_separating_lines(rows) + rows = [[v] + list(row) for v, row in zip(index, sans_rows)] + _reinsert_separating_lines(rows, separating_lines) return rows @@ -1191,7 +1216,8 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): rows = rows[1:] headers = list(map(_text_type, headers)) - rows = list(map(list, rows)) +# rows = list(map(list, rows)) + rows = list(map(lambda r: r if r == SEPARATING_LINE else list(r), rows)) # add or remove an index column showindex_is_a_str = type(showindex) in [_text_type, _binary_type] @@ -1577,9 +1603,9 @@ def tabulate( if tabular_data is None: tabular_data = [] - list_of_lists, headers = _normalize_tabular_data( - tabular_data, headers, showindex=showindex - ) + + 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: num_cols = len(list_of_lists[0]) @@ -1691,6 +1717,7 @@ def tabulate( if not isinstance(tablefmt, TableFormat): tablefmt = _table_formats.get(tablefmt, _table_formats["simple"]) + _reinsert_separating_lines(rows, separating_lines) return _format_table(tablefmt, headers, rows, minwidths, aligns, is_multiline) @@ -1836,8 +1863,20 @@ def _format_table(fmt, headers, rows, colwidths, colaligns, is_multiline): # the last row without a line below append_row(lines, padded_rows[-1], padded_widths, colaligns, fmt.datarow) else: + separating_line = ( + fmt.linebetweenrows + or fmt.linebelowheader + or fmt.linebelow + or fmt.lineabove + or Line("", "", "", "") + ) for row in padded_rows: - append_row(lines, row, padded_widths, colaligns, fmt.datarow) + # test to see if the either the 1st column or the 2nd column (account for showindex) has + # the SEPARATING_LINE flag + if row[0].strip() == SEPARATING_LINE or (len(row) > 1 and row[1].strip() == SEPARATING_LINE): + _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) diff --git a/test/common.py b/test/common.py index 10fa89e..134fdbd 100644 --- a/test/common.py +++ b/test/common.py @@ -14,3 +14,15 @@ def assert_in(result, expected_set): print("Expected %d:\n%s\n" % (i, expected)) print("Got:\n%s\n" % result) assert result in expected_set + +def cols_to_pipe_str(cols): + return "|".join([str(col) for col in cols]) + + +def rows_to_pipe_table_str(rows): + lines = [] + for row in rows: + line = cols_to_pipe_str(row) + lines.append(line) + + return '\n'.join(lines) diff --git a/test/test_internal.py b/test/test_internal.py index 44765cf..4dda2ad 100644 --- a/test/test_internal.py +++ b/test/test_internal.py @@ -5,7 +5,8 @@ from __future__ import print_function from __future__ import unicode_literals import tabulate as T -from common import assert_equal, skip + +from common import assert_equal, skip, rows_to_pipe_table_str, cols_to_pipe_str def test_multiline_width(): @@ -209,3 +210,21 @@ def test_wrap_text_to_colwidths_multi_ansi_colors_in_subset(): ] ] assert_equal(expected, result) + + +def test__remove_separating_lines(): + with_rows = [[0, 'a'], [1, 'b'], T.SEPARATING_LINE, [2, 'c'], T.SEPARATING_LINE, [3, 'c'], T.SEPARATING_LINE] + result, sep_lines = T._remove_separating_lines(with_rows) + expected = rows_to_pipe_table_str([[0, 'a'], [1, 'b'], [2, 'c'], [3, 'c']]) + + assert_equal(expected, rows_to_pipe_table_str(result)) + assert_equal("2|4|6", cols_to_pipe_str(sep_lines)) + +def test__reinsert_separating_lines(): + with_rows = [[0, 'a'], [1, 'b'], T.SEPARATING_LINE, [2, 'c'], T.SEPARATING_LINE, [3, 'c'], T.SEPARATING_LINE] + sans_rows, sep_lines = T._remove_separating_lines(with_rows) + T._reinsert_separating_lines(sans_rows, sep_lines) + expected = rows_to_pipe_table_str(with_rows) + + assert_equal(expected, rows_to_pipe_table_str(sans_rows)) + diff --git a/test/test_output.py b/test/test_output.py index eed4489..ad623f8 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -5,7 +5,7 @@ from __future__ import print_function from __future__ import unicode_literals import tabulate as tabulate_module -from tabulate import tabulate, simple_separated_format +from tabulate import tabulate, simple_separated_format, SEPARATING_LINE from common import assert_equal, raises, skip @@ -14,6 +14,7 @@ # - left alignment of text, # - decimal point alignment of numbers _test_table = [["spam", 41.9999], ["eggs", "451.0"]] +_test_table_with_sep_line = [["spam", 41.9999], SEPARATING_LINE, ["eggs", "451.0"]] _test_table_headers = ["strings", "numbers"] @@ -107,13 +108,23 @@ def test_plain_multiline_with_empty_cells_headerless(): def test_plain_maxcolwidth_autowraps(): "Output: maxcolwidth will result in autowrapping longer cells" table = [["hdr", "fold"], ["1", "very long data"]] - expected = "\n".join([" hdr fold", " 1 very long", " data"]) + expected = "\n".join([" hdr fold", "----- ---------, " 1 very long", " data"]) result = tabulate( table, headers="firstrow", tablefmt="plain", maxcolwidths=[10, 10] ) assert_equal(expected, result) +def test_plain_maxcolwidth_autowraps_with_sep(): + "Output: maxcolwidth will result in autowrapping longer cells and separating line" + table = [["hdr", "fold"], ["1", "very long data"], SEPARATING_LINE, ["2", "last line"]] + expected = "\n".join([" hdr fold", " 1 very long", " data"]) + result = tabulate( + table, headers="firstrow", tablefmt="", maxcolwidths=[10, 10] + ) + assert_equal(expected, result) + + def test_plain_maxcolwidth_autowraps_wide_chars(): "Output: maxcolwidth and autowrapping functions with wide characters" try: @@ -215,6 +226,21 @@ def test_simple(): assert_equal(expected, result) +def test_simple_with_sep_line(): + "Output: simple 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="simple") + assert_equal(expected, result) + + def test_simple_multiline_2(): "Output: simple with multiline cells" expected = "\n".join( @@ -230,6 +256,22 @@ def test_simple_multiline_2(): result = tabulate(table, headers="firstrow", stralign="center", tablefmt="simple") assert_equal(expected, result) +def test_simple_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="simple") + assert_equal(expected, result) + def test_simple_headerless(): "Output: simple without headers" @@ -240,6 +282,20 @@ def test_simple_headerless(): assert_equal(expected, result) +def test_simple_headerless_with_sep_line(): + "Output: simple without headers" + expected = "\n".join( + ["---- --------", + "spam 41.9999", + "---- --------", + "eggs 451", + "---- --------" + ] + ) + result = tabulate(_test_table_with_sep_line, tablefmt="simple") + assert_equal(expected, result) + + def test_simple_multiline_headerless(): "Output: simple with multiline cells without headers" table = [["foo bar\nbaz\nbau", "hello"], ["", "multiline\nworld"]] @@ -1437,6 +1493,15 @@ def test_colalign_multi(): assert_equal(expected, result) +def test_colalign_multi_with_sep_line(): + "Output: string columns with custom colalign" + result = tabulate( + [["one", "two"], SEPARATING_LINE, ["three", "four"]], colalign=("right",), tablefmt="plain" + ) + expected = " one two\n\nthree four" + assert_equal(expected, result) + + def test_float_conversions(): "Output: float format parsed" test_headers = ["str", "bad_float", "just_float", "with_inf", "with_nan", "neg_inf"] @@ -1612,7 +1677,30 @@ def test_list_of_lists_with_index(): # keys' order (hence columns' order) is not deterministic in Python 3 # => we have to consider both possible results as valid expected = "\n".join( - [" a b", "-- --- ---", " 0 0 101", " 1 1 102", " 2 2 103"] + [" a b", + "-- --- ---", + " 0 0 101", + " 1 1 102", + " 2 2 103" + ] + ) + result = tabulate(dd, headers=["a", "b"], showindex=True) + assert_equal(result, expected) + + +def test_list_of_lists_with_index_with_sep_line(): + "Output: a table with a running index" + dd = [(0, 101), SEPARATING_LINE, (1, 102), (2, 103)] + # keys' order (hence columns' order) is not deterministic in Python 3 + # => we have to consider both possible results as valid + expected = "\n".join( + [" a b", + "-- --- ---", + " 0 0 101", + "-- --- ---", + " 1 1 102", + " 2 2 103" + ] ) result = tabulate(dd, headers=["a", "b"], showindex=True) assert_equal(result, expected) From aecac744e13d428d1dab1ed8d6527cd055dfd632 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Fri, 7 Jan 2022 16:01:51 +0200 Subject: [PATCH 065/207] Drop support for EOL Python <= 3.6 --- .pre-commit-config.yaml | 2 +- README.md | 2 +- appveyor.yml | 10 +-- benchmark.py | 24 +----- setup.py | 21 ++---- tabulate.py | 162 +++++++++++++++------------------------- test/test_api.py | 7 +- test/test_input.py | 2 +- test/test_regression.py | 26 ++----- tox.ini | 63 +++------------- 10 files changed, 94 insertions(+), 225 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e6f9951..5165ce5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/python/black - rev: 19.3b0 + rev: 21.12b0 hooks: - id: black args: [--safe] diff --git a/README.md b/README.md index 5c4b62a..47d9239 100644 --- a/README.md +++ b/README.md @@ -785,4 +785,4 @@ Wes Turner, Andrew Tija, Marco Gorelli, Sean McGinnis, danja100, 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. +Bart Broere, Vilhelm Prytz, Hugo van Kemenade. diff --git a/appveyor.yml b/appveyor.yml index 0055e96..0dbfaaa 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -7,16 +7,14 @@ environment: # The list here is complete (excluding Python 2.6, which # isn't covered by this document) at the time of writing. - - PYTHON: "C:\\Python27" - - PYTHON: "C:\\Python35" - - PYTHON: "C:\\Python36" - PYTHON: "C:\\Python37" - PYTHON: "C:\\Python38" - - PYTHON: "C:\\Python27-x64" - - PYTHON: "C:\\Python35-x64" - - PYTHON: "C:\\Python36-x64" + - PYTHON: "C:\\Python39" + - PYTHON: "C:\\Python310" - PYTHON: "C:\\Python37-x64" - PYTHON: "C:\\Python38-x64" + - PYTHON: "C:\\Python39-x64" + - PYTHON: "C:\\Python310-x64" install: # We need wheel installed to build wheels diff --git a/benchmark.py b/benchmark.py index b79c47b..6b85559 100644 --- a/benchmark.py +++ b/benchmark.py @@ -7,29 +7,16 @@ import prettytable import texttable import sys -import codecs -from platform import python_version_tuple setup_code = r""" from csv import writer -try: # Python 2 - from StringIO import StringIO -except: # Python 3 - from io import StringIO +from io import StringIO import tabulate import asciitable import prettytable import texttable -import platform -if platform.platform().startswith("Windows") \ - and \ - platform.python_version_tuple() < ('3','6','0'): - import win_unicode_console - win_unicode_console.enable() - - table=[["some text"]+list(range(i,i+9)) for i in range(10)] @@ -108,14 +95,7 @@ def benchmark(n): results, ["Table formatter", "time, μs", "rel. time"], "rst", floatfmt=".1f" ) - from platform import platform - - if platform().startswith("Windows"): - print(table) - elif python_version_tuple()[0] < "3": - print(codecs.encode(table, "utf-8")) - else: - print(table) + print(table) if __name__ == "__main__": diff --git a/setup.py b/setup.py index 00be096..727cced 100644 --- a/setup.py +++ b/setup.py @@ -6,21 +6,17 @@ from distutils.core import setup -from platform import python_version_tuple, python_implementation +from platform import python_implementation import os import re -# strip links from the descripton on the PyPI -if python_version_tuple()[0] >= "3": - LONG_DESCRIPTION = open("README.md", "r", encoding="utf-8").read() -else: - LONG_DESCRIPTION = open("README.md", "r").read() +# strip links from the description on the PyPI +LONG_DESCRIPTION = open("README.md", "r", encoding="utf-8").read() # strip Build Status from the PyPI package try: - if python_version_tuple()[:2] >= ("2", "7"): - status_re = "^Build status\n(.*\n){7}" - LONG_DESCRIPTION = re.sub(status_re, "", LONG_DESCRIPTION, flags=re.M) + status_re = "^Build status\n(.*\n){7}" + LONG_DESCRIPTION = re.sub(status_re, "", LONG_DESCRIPTION, flags=re.M) except TypeError: if python_implementation() == "IronPython": # IronPython doesn't support flags in re.sub (IronPython issue #923) @@ -46,20 +42,17 @@ author_email="s.astanin@gmail.com", url="https://github.com/astanin/python-tabulate", license="MIT", - python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*", + python_requires=">=3.7", classifiers=[ "Development Status :: 4 - Beta", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", - "Programming Language :: Python :: 2", - "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.5", - "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3 :: Only", "Topic :: Software Development :: Libraries", ], py_modules=["tabulate"], diff --git a/tabulate.py b/tabulate.py index 0579fb3..758634e 100644 --- a/tabulate.py +++ b/tabulate.py @@ -5,61 +5,23 @@ from __future__ import print_function from __future__ import unicode_literals from collections import namedtuple -import sys +from collections.abc import Iterable +from html import escape as htmlescape +from itertools import zip_longest as izip_longest +from functools import reduce, partial +import io import re import math import textwrap - -if sys.version_info >= (3, 3): - from collections.abc import Iterable -else: - from collections import Iterable - -if sys.version_info[0] < 3: - from itertools import izip_longest - from functools import partial - - _none_type = type(None) - _bool_type = bool - _int_type = int - _long_type = long # noqa - _float_type = float - _text_type = unicode # noqa - _binary_type = str - - def _is_file(f): - return hasattr(f, "read") - - -else: - from itertools import zip_longest as izip_longest - from functools import reduce, partial - - _none_type = type(None) - _bool_type = bool - _int_type = int - _long_type = int - _float_type = float - _text_type = str - _binary_type = bytes - basestring = str - - import io - - def _is_file(f): - return isinstance(f, io.IOBase) - - try: import wcwidth # optional wide-character (CJK) support except ImportError: wcwidth = None -try: - from html import escape as htmlescape -except ImportError: - from cgi import escape as htmlescape + +def _is_file(f): + return isinstance(f, io.IOBase) __all__ = ["tabulate", "tabulate_formats", "simple_separated_format"] @@ -89,7 +51,7 @@ def _is_file(f): DataRow = namedtuple("DataRow", ["begin", "sep", "end"]) -# A table structure is suppposed to be: +# A table structure is supposed to be: # # --- lineabove --------- # headerrow @@ -263,7 +225,7 @@ def escape_char(c): def _rst_escape_first_column(rows, headers): def escape_empty(val): - if isinstance(val, (_text_type, _binary_type)) and not val.strip(): + if isinstance(val, (str, bytes)) and not val.strip(): return ".." else: return val @@ -616,7 +578,7 @@ def _isnumber(string): """ if not _isconvertible(float, string): return False - elif isinstance(string, (_text_type, _binary_type)) and ( + elif isinstance(string, (str, bytes)) and ( math.isinf(float(string)) or math.isnan(float(string)) ): return string.lower() in ["inf", "-inf", "nan"] @@ -632,7 +594,7 @@ def _isint(string, inttype=int): """ return ( type(string) is inttype - or (isinstance(string, _binary_type) or isinstance(string, _text_type)) + or (isinstance(string, bytes) or isinstance(string, str)) and _isconvertible(inttype, string) ) @@ -646,8 +608,8 @@ def _isbool(string): >>> _isbool(1) False """ - return type(string) is _bool_type or ( - isinstance(string, (_binary_type, _text_type)) and string in ("True", "False") + return type(string) is bool or ( + isinstance(string, (bytes, str)) and string in ("True", "False") ) @@ -667,27 +629,23 @@ def _type(string, has_invisible=True, numparse=True): """ - if has_invisible and ( - isinstance(string, _text_type) or isinstance(string, _binary_type) - ): + if has_invisible and (isinstance(string, str) or isinstance(string, bytes)): string = _strip_invisible(string) if string is None: - return _none_type + return type(None) elif hasattr(string, "isoformat"): # datetime.datetime, date, and time - return _text_type + return str elif _isbool(string): - return _bool_type + return bool elif _isint(string) and numparse: return int - elif _isint(string, _long_type) and numparse: - return int elif _isnumber(string) and numparse: return float - elif isinstance(string, _binary_type): - return _binary_type + elif isinstance(string, bytes): + return bytes else: - return _text_type + return str def _afterpoint(string): @@ -761,7 +719,7 @@ def _strip_invisible(s): 'This is a link' """ - if isinstance(s, _text_type): + if isinstance(s, str): links_removed = re.sub(_invisible_codes_link, "\\1", s) return re.sub(_invisible_codes, "", links_removed) else: # a bytestring @@ -780,14 +738,14 @@ def _visible_width(s): len_fn = wcwidth.wcswidth else: len_fn = len - if isinstance(s, _text_type) or isinstance(s, _binary_type): + if isinstance(s, str) or isinstance(s, bytes): return len_fn(_strip_invisible(s)) else: - return len_fn(_text_type(s)) + return len_fn(str(s)) def _is_multiline(s): - if isinstance(s, _text_type): + if isinstance(s, str): return bool(re.search(_multiline_codes, s)) else: # a bytestring return bool(re.search(_multiline_codes_bytes, s)) @@ -920,20 +878,20 @@ def _align_column( def _more_generic(type1, type2): types = { - _none_type: 0, - _bool_type: 1, + type(None): 0, + bool: 1, int: 2, float: 3, - _binary_type: 4, - _text_type: 5, + bytes: 4, + str: 5, } invtypes = { - 5: _text_type, - 4: _binary_type, + 5: str, + 4: bytes, 3: float, 2: int, - 1: _bool_type, - 0: _none_type, + 1: bool, + 0: type(None), } moregeneric = max(types.get(type1, 5), types.get(type2, 5)) return invtypes[moregeneric] @@ -942,27 +900,27 @@ def _more_generic(type1, type2): 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_type + >>> _column_type([True, False]) is bool True - >>> _column_type(["1", "2"]) is _int_type + >>> _column_type(["1", "2"]) is int True - >>> _column_type(["1", "2.3"]) is _float_type + >>> _column_type(["1", "2.3"]) is float True - >>> _column_type(["1", "2.3", "four"]) is _text_type + >>> _column_type(["1", "2.3", "four"]) is str True - >>> _column_type(["four", '\u043f\u044f\u0442\u044c']) is _text_type + >>> _column_type(["four", '\u043f\u044f\u0442\u044c']) is str True - >>> _column_type([None, "brux"]) is _text_type + >>> _column_type([None, "brux"]) is str True - >>> _column_type([1, 2, None]) is _int_type + >>> _column_type([1, 2, None]) is int True >>> import datetime as dt - >>> _column_type([dt.datetime(1991,2,19), dt.time(17,35)]) is _text_type + >>> _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_type) + return reduce(_more_generic, types, bool) def _format(val, valtype, floatfmt, missingval="", has_invisible=True): @@ -980,17 +938,15 @@ def _format(val, valtype, floatfmt, missingval="", has_invisible=True): if val is None: return missingval - if valtype in [int, _text_type]: + if valtype in [int, str]: return "{0}".format(val) - elif valtype is _binary_type: + elif valtype is bytes: try: - return _text_type(val, "ascii") + return str(val, "ascii") except TypeError: - return _text_type(val) + return str(val) elif valtype is float: - is_a_colored_number = has_invisible and isinstance( - val, (_text_type, _binary_type) - ) + is_a_colored_number = has_invisible and isinstance(val, (str, bytes)) if is_a_colored_number: raw_val = _strip_invisible(val) formatted_val = format(float(raw_val), floatfmt) @@ -1110,7 +1066,7 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): raise ValueError("tabular data doesn't appear to be a dict or a DataFrame") if headers == "keys": - headers = list(map(_text_type, keys)) # headers should be strings + headers = list(map(str, keys)) # headers should be strings else: # it's a usual an iterable of iterables, or a NumPy array rows = list(tabular_data) @@ -1132,7 +1088,7 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): and hasattr(rows[0], "_fields") ): # namedtuple - headers = list(map(_text_type, rows[0]._fields)) + 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 @@ -1153,11 +1109,11 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): 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(_text_type, headers)) + headers = list(map(str, headers)) elif headers == "firstrow": if len(rows) > 0: headers = [firstdict.get(k, k) for k in keys] - headers = list(map(_text_type, headers)) + headers = list(map(str, headers)) else: headers = [] elif headers: @@ -1178,7 +1134,7 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): elif headers == "keys" and len(rows) > 0: # keys are column indices - headers = list(map(_text_type, range(len(rows[0])))) + headers = list(map(str, range(len(rows[0])))) # take headers from the first row if necessary if headers == "firstrow" and len(rows) > 0: @@ -1187,14 +1143,14 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): index = index[1:] else: headers = rows[0] - headers = list(map(_text_type, headers)) # headers should be strings + headers = list(map(str, headers)) # headers should be strings rows = rows[1:] - headers = list(map(_text_type, headers)) + headers = list(map(str, headers)) rows = list(map(list, rows)) # add or remove an index column - showindex_is_a_str = type(showindex) in [_text_type, _binary_type] + 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, Iterable) and not showindex_is_a_str: @@ -1615,8 +1571,8 @@ def tabulate( # optimization: look for ANSI control codes once, # enable smart width functions only if a control code is found plain_text = "\t".join( - ["\t".join(map(_text_type, headers))] - + ["\t".join(map(_text_type, row)) for row in list_of_lists] + ["\t".join(map(str, headers))] + + ["\t".join(map(str, row)) for row in list_of_lists] ) has_invisible = re.search(_invisible_codes, plain_text) @@ -1638,7 +1594,7 @@ def tabulate( 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, basestring): # old version + if isinstance(floatfmt, str): # old version float_formats = len(cols) * [ floatfmt ] # just duplicate the string to use in each column @@ -1646,7 +1602,7 @@ def tabulate( float_formats = list(floatfmt) if len(float_formats) < len(cols): float_formats.extend((len(cols) - len(float_formats)) * [_DEFAULT_FLOATFMT]) - if isinstance(missingval, basestring): + if isinstance(missingval, str): missing_vals = len(cols) * [missingval] else: missing_vals = list(missingval) diff --git a/test/test_api.py b/test/test_api.py index 5b77c0e..851b984 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -5,16 +5,11 @@ from __future__ import print_function from __future__ import unicode_literals from tabulate import tabulate, tabulate_formats, simple_separated_format -from platform import python_version_tuple from common import skip try: - if python_version_tuple() >= ("3", "3", "0"): - from inspect import signature, _empty - else: - signature = None - _empty = None + from inspect import signature, _empty except ImportError: signature = None _empty = None diff --git a/test/test_input.py b/test/test_input.py index 541b4f7..4f18c17 100644 --- a/test/test_input.py +++ b/test/test_input.py @@ -471,7 +471,7 @@ def test_list_of_dicts_with_list_of_headers(): tabulate(table, headers=headers) -def test_py27orlater_list_of_ordereddicts(): +def test_list_of_ordereddicts(): "Input: a list of OrderedDicts." from collections import OrderedDict diff --git a/test/test_regression.py b/test/test_regression.py index 63e3a52..8ca3689 100644 --- a/test/test_regression.py +++ b/test/test_regression.py @@ -4,8 +4,8 @@ from __future__ import print_function from __future__ import unicode_literals -from tabulate import tabulate, _text_type, _long_type, TableFormat, Line, DataRow -from common import assert_equal, assert_in, skip +from tabulate import tabulate, TableFormat, Line, DataRow +from common import assert_equal, skip def test_ansi_color_in_table_cells(): @@ -152,15 +152,6 @@ def test_simple_separated_format(): assert_equal(expected, formatted) -def py3test_require_py3(): - "Regression: py33 tests should actually use Python 3 (issue #13)" - from platform import python_version_tuple - - print("Expected Python version: 3.x.x") - print("Python version used for tests: %s.%s.%s" % python_version_tuple()) - assert_equal(python_version_tuple()[0], "3") - - def test_simple_separated_format_with_headers(): "Regression: simple_separated_format() on tables with headers (issue #15)" from tabulate import simple_separated_format @@ -174,10 +165,10 @@ def test_simple_separated_format_with_headers(): def test_column_type_of_bytestring_columns(): "Regression: column type for columns of bytestrings (issue #16)" - from tabulate import _column_type, _binary_type + from tabulate import _column_type result = _column_type([b"foo", b"bar"]) - expected = _binary_type + expected = bytes assert_equal(result, expected) @@ -246,10 +237,9 @@ def test_latex_escape_special_chars(): def test_isconvertible_on_set_values(): "Regression: don't fail with TypeError on set values (issue #35)" - expected_py2 = "\n".join(["a b", "--- -------", "Foo set([])"]) - expected_py3 = "\n".join(["a b", "--- -----", "Foo set()"]) + expected = "\n".join(["a b", "--- -----", "Foo set()"]) result = tabulate([["Foo", set()]], headers=["a", "b"]) - assert_in(result, [expected_py2, expected_py3]) + assert_equal(result, expected) def test_ansi_color_for_decimal_numbers(): @@ -304,7 +294,7 @@ def test_colorclass_colors(): assert_equal(result, expected) except ImportError: - class textclass(_text_type): + class textclass(str): pass s = textclass("\x1b[35m3.14\x1b[39m") @@ -357,7 +347,7 @@ def test_multiline_with_wide_characters(): def test_align_long_integers(): "Regression: long integers should be aligned as integers (issue #61)" - table = [[_long_type(1)], [_long_type(234)]] + table = [[int(1)], [int(234)]] result = tabulate(table, tablefmt="plain") expected = "\n".join([" 1", "234"]) assert_equal(result, expected) diff --git a/tox.ini b/tox.ini index a20b325..891912a 100644 --- a/tox.ini +++ b/tox.ini @@ -8,10 +8,10 @@ # for testing and it is disabled by default. [tox] -envlist = lint, py27, py35, py36, py37, py38, py39, py310 +envlist = lint, py{37, 38, 39, 310} [testenv] -commands = pytest -v --doctest-modules --ignore benchmark.py +commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} deps = pytest passenv = @@ -24,58 +24,15 @@ commands = python -m pre_commit run -a deps = pre-commit -[testenv:py27-extra] -basepython = python2.7 -commands = pytest -v --doctest-modules --ignore benchmark.py -deps = - pytest - numpy - pandas - wcwidth - - -[testenv:py35] -basepython = python3.5 -commands = pytest -v --doctest-modules --ignore benchmark.py -deps = - pytest - - -[testenv:py35-extra] -basepython = python3.5 -commands = pytest -v --doctest-modules --ignore benchmark.py -deps = - pytest - numpy - pandas - wcwidth - - -[testenv:py36] -basepython = python3.6 -commands = pytest -v --doctest-modules --ignore benchmark.py -deps = - pytest - - -[testenv:py36-extra] -basepython = python3.6 -commands = pytest -v --doctest-modules --ignore benchmark.py -deps = - pytest - numpy - pandas - wcwidth - [testenv:py37] basepython = python3.7 -commands = pytest -v --doctest-modules --ignore benchmark.py +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 +commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} deps = pytest numpy @@ -84,13 +41,13 @@ deps = [testenv:py38] basepython = python3.8 -commands = pytest -v --doctest-modules --ignore benchmark.py +commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} deps = pytest [testenv:py38-extra] basepython = python3.8 -commands = pytest -v --doctest-modules --ignore benchmark.py +commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} deps = pytest numpy @@ -100,13 +57,13 @@ deps = [testenv:py39] basepython = python3.9 -commands = pytest -v --doctest-modules --ignore benchmark.py +commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} deps = pytest [testenv:py39-extra] basepython = python3.9 -commands = pytest -v --doctest-modules --ignore benchmark.py +commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} deps = pytest numpy @@ -116,14 +73,14 @@ deps = [testenv:py310] basepython = python3.10 -commands = pytest -v --doctest-modules --ignore benchmark.py +commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} deps = pytest [testenv:py310-extra] basepython = python3.10 setenv = PYTHONDEVMODE = 1 -commands = pytest -v --doctest-modules --ignore benchmark.py +commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} deps = pytest numpy From 0d4c66aefedf008d3146fa1e43e21c62b2be7786 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Fri, 7 Jan 2022 16:09:30 +0200 Subject: [PATCH 066/207] Combine repeated isinstance instances --- tabulate.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tabulate.py b/tabulate.py index 758634e..1f42999 100644 --- a/tabulate.py +++ b/tabulate.py @@ -594,7 +594,7 @@ def _isint(string, inttype=int): """ return ( type(string) is inttype - or (isinstance(string, bytes) or isinstance(string, str)) + or isinstance(string, (bytes, str)) and _isconvertible(inttype, string) ) @@ -629,7 +629,7 @@ def _type(string, has_invisible=True, numparse=True): """ - if has_invisible and (isinstance(string, str) or isinstance(string, bytes)): + if has_invisible and isinstance(string, (str, bytes)): string = _strip_invisible(string) if string is None: @@ -738,7 +738,7 @@ def _visible_width(s): len_fn = wcwidth.wcswidth else: len_fn = len - if isinstance(s, str) or isinstance(s, bytes): + if isinstance(s, (str, bytes)): return len_fn(_strip_invisible(s)) else: return len_fn(str(s)) From e47747fee8f30df6089b3f7395f6ca54f69c30ce Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Fri, 7 Jan 2022 16:12:29 +0200 Subject: [PATCH 067/207] Upgrade Python syntax with pyupgrade --py37-plus --- benchmark.py | 3 --- setup.py | 4 ++-- tabulate.py | 14 +++++--------- test/test_api.py | 6 ++---- test/test_cli.py | 6 ++---- test/test_input.py | 4 ---- test/test_internal.py | 4 ---- test/test_output.py | 4 ---- test/test_regression.py | 28 +++++++++++----------------- test/test_textwrapper.py | 3 --- 10 files changed, 22 insertions(+), 54 deletions(-) diff --git a/benchmark.py b/benchmark.py index 6b85559..8422f5c 100644 --- a/benchmark.py +++ b/benchmark.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals -from __future__ import print_function from timeit import timeit import tabulate import asciitable diff --git a/setup.py b/setup.py index 727cced..dd2323f 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ import re # strip links from the description on the PyPI -LONG_DESCRIPTION = open("README.md", "r", encoding="utf-8").read() +LONG_DESCRIPTION = open("README.md", encoding="utf-8").read() # strip Build Status from the PyPI package try: @@ -25,7 +25,7 @@ raise install_options = os.environ.get("TABULATE_INSTALL", "").split(",") -libonly_flags = set(["lib-only", "libonly", "no-cli", "without-cli"]) +libonly_flags = {"lib-only", "libonly", "no-cli", "without-cli"} if libonly_flags.intersection(install_options): console_scripts = [] else: diff --git a/tabulate.py b/tabulate.py index 1f42999..94c8f82 100644 --- a/tabulate.py +++ b/tabulate.py @@ -1,9 +1,5 @@ -# -*- coding: utf-8 -*- - """Pretty-print tabular data.""" -from __future__ import print_function -from __future__ import unicode_literals from collections import namedtuple from collections.abc import Iterable from html import escape as htmlescape @@ -167,7 +163,7 @@ def _html_row_with_attrs(celltag, unsafe, cell_values, colwidths, colaligns): ] rowhtml = "{}".format("".join(values_with_attrs).rstrip()) if celltag == "th": # it's a header row, create a new table header - rowhtml = "\n\n{}\n\n".format(rowhtml) + rowhtml = f"
\n\n{rowhtml}\n\n" return rowhtml @@ -179,7 +175,7 @@ def _moin_row_with_attrs(celltag, cell_values, colwidths, colaligns, header=""): "decimal": '', } values_with_attrs = [ - "{0}{1} {2} ".format(celltag, alignment.get(a, ""), header + c + header) + "{}{} {} ".format(celltag, alignment.get(a, ""), header + c + header) for c, a in zip(cell_values, colaligns) ] return "".join(values_with_attrs) + "||" @@ -939,7 +935,7 @@ def _format(val, valtype, floatfmt, missingval="", has_invisible=True): return missingval if valtype in [int, str]: - return "{0}".format(val) + return f"{val}" elif valtype is bytes: try: return str(val, "ascii") @@ -954,7 +950,7 @@ def _format(val, valtype, floatfmt, missingval="", has_invisible=True): else: return format(float(val), floatfmt) else: - return "{0}".format(val) + return f"{val}" def _align_header( @@ -975,7 +971,7 @@ def _align_header( elif alignment == "center": return _padboth(width, header) elif not alignment: - return "{0}".format(header) + return f"{header}" else: return _padleft(width, header) diff --git a/test/test_api.py b/test/test_api.py index 851b984..ed06129 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -2,8 +2,6 @@ """ -from __future__ import print_function -from __future__ import unicode_literals from tabulate import tabulate, tabulate_formats, simple_separated_format from common import skip @@ -21,14 +19,14 @@ def test_tabulate_formats(): print("tabulate_formats = %r" % supported) assert type(supported) is list for fmt in supported: - assert type(fmt) is type("") # noqa + assert type(fmt) is str # noqa def _check_signature(function, expected_sig): if not signature: skip("") actual_sig = signature(function) - print("expected: %s\nactual: %s\n" % (expected_sig, str(actual_sig))) + print(f"expected: {expected_sig}\nactual: {str(actual_sig)}\n") assert len(actual_sig.parameters) == len(expected_sig) diff --git a/test/test_cli.py b/test/test_cli.py index 36853fd..8c0c9da 100644 --- a/test/test_cli.py +++ b/test/test_cli.py @@ -3,8 +3,6 @@ """ -from __future__ import print_function -from __future__ import unicode_literals import os import sys @@ -93,11 +91,11 @@ def run_and_capture_stdout(cmd, input=None): out, err = x.communicate(input=input_buf) out = out.decode("utf-8") if x.returncode != 0: - raise IOError(err) + raise OSError(err) return out -class TemporaryTextFile(object): +class TemporaryTextFile: def __init__(self): self.tmpfile = None diff --git a/test/test_input.py b/test/test_input.py index 4f18c17..f1fb3e6 100644 --- a/test/test_input.py +++ b/test/test_input.py @@ -1,9 +1,5 @@ -# -*- coding: utf-8 -*- - """Test support of the various forms of tabular data.""" -from __future__ import print_function -from __future__ import unicode_literals from tabulate import tabulate from common import assert_equal, assert_in, raises, skip diff --git a/test/test_internal.py b/test/test_internal.py index 44765cf..b020d52 100644 --- a/test/test_internal.py +++ b/test/test_internal.py @@ -1,9 +1,5 @@ -# -*- coding: utf-8 -*- - """Tests of the internal tabulate functions.""" -from __future__ import print_function -from __future__ import unicode_literals import tabulate as T from common import assert_equal, skip diff --git a/test/test_output.py b/test/test_output.py index eed4489..ba06425 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -1,9 +1,5 @@ -# -*- coding: utf-8 -*- - """Test output of the various forms of tabular data.""" -from __future__ import print_function -from __future__ import unicode_literals import tabulate as tabulate_module from tabulate import tabulate, simple_separated_format from common import assert_equal, raises, skip diff --git a/test/test_regression.py b/test/test_regression.py index 8ca3689..e07a584 100644 --- a/test/test_regression.py +++ b/test/test_regression.py @@ -1,9 +1,5 @@ -# -*- coding: utf-8 -*- - """Regression tests.""" -from __future__ import print_function -from __future__ import unicode_literals from tabulate import tabulate, TableFormat, Line, DataRow from common import assert_equal, skip @@ -20,7 +16,7 @@ def test_ansi_color_in_table_cells(): "| test | \x1b[31mtest\x1b[0m | \x1b[32mtest\x1b[0m |", ] ) - print("expected: %r\n\ngot: %r\n" % (expected, formatted)) + print(f"expected: {expected!r}\n\ngot: {formatted!r}\n") assert_equal(expected, formatted) @@ -43,7 +39,7 @@ def test_alignment_of_colored_cells(): "+--------+--------+--------+", ] ) - print("expected: %r\n\ngot: %r\n" % (expected, formatted)) + print(f"expected: {expected!r}\n\ngot: {formatted!r}\n") assert_equal(expected, formatted) @@ -66,7 +62,7 @@ def test_alignment_of_link_cells(): "+--------+--------+--------+", ] ) - print("expected: %r\n\ngot: %r\n" % (expected, formatted)) + print(f"expected: {expected!r}\n\ngot: {formatted!r}\n") assert_equal(expected, formatted) @@ -89,7 +85,7 @@ def test_alignment_of_link_text_cells(): "+--------+----------+--------+", ] ) - print("expected: %r\n\ngot: %r\n" % (expected, formatted)) + print(f"expected: {expected!r}\n\ngot: {formatted!r}\n") assert_equal(expected, formatted) @@ -98,15 +94,13 @@ def test_iter_of_iters_with_headers(): def mk_iter_of_iters(): def mk_iter(): - for i in range(3): - yield i + yield from range(3) for r in range(3): yield mk_iter() def mk_headers(): - for h in ["a", "b", "c"]: - yield h + yield from ["a", "b", "c"] formatted = tabulate(mk_iter_of_iters(), headers=mk_headers()) expected = "\n".join( @@ -118,7 +112,7 @@ def mk_headers(): " 0 1 2", ] ) - print("expected: %r\n\ngot: %r\n" % (expected, formatted)) + print(f"expected: {expected!r}\n\ngot: {formatted!r}\n") assert_equal(expected, formatted) @@ -137,7 +131,7 @@ def test_datetime_values(): "------------------- ---------- --------", ] ) - print("expected: %r\n\ngot: %r\n" % (expected, formatted)) + print(f"expected: {expected!r}\n\ngot: {formatted!r}\n") assert_equal(expected, formatted) @@ -148,7 +142,7 @@ def test_simple_separated_format(): fmt = simple_separated_format("!") expected = "spam!eggs" formatted = tabulate([["spam", "eggs"]], tablefmt=fmt) - print("expected: %r\n\ngot: %r\n" % (expected, formatted)) + print(f"expected: {expected!r}\n\ngot: {formatted!r}\n") assert_equal(expected, formatted) @@ -178,7 +172,7 @@ def test_numeric_column_headers(): expected = " 42\n----\n 1\n 2" assert_equal(result, expected) - lod = [dict((p, i) for p in range(5)) for i in range(5)] + lod = [{p: i for p in range(5)} for i in range(5)] result = tabulate(lod, "keys") expected = "\n".join( [ @@ -206,7 +200,7 @@ def test_88_256_ANSI_color_codes(): "| \x1b[48;5;196mred\x1b[49m | \x1b[38;5;196mred\x1b[39m |", ] ) - print("expected: %r\n\ngot: %r\n" % (expected, formatted)) + print(f"expected: {expected!r}\n\ngot: {formatted!r}\n") assert_equal(expected, formatted) diff --git a/test/test_textwrapper.py b/test/test_textwrapper.py index e8e1c09..94e7bbd 100644 --- a/test/test_textwrapper.py +++ b/test/test_textwrapper.py @@ -1,7 +1,4 @@ -# -*- coding: utf-8 -*- - """Discretely test functionality of our custom TextWrapper""" -from __future__ import unicode_literals from tabulate import _CustomTextWrap as CTW from textwrap import TextWrapper as OTW From bb7adfff067aa0f8f292e6d8d1d0d220a69d38ca Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Fri, 7 Jan 2022 16:23:38 +0200 Subject: [PATCH 068/207] Add Markdown formatting for readability --- README.md | 759 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 431 insertions(+), 328 deletions(-) diff --git a/README.md b/README.md index 5c4b62a..975a4d6 100644 --- a/README.md +++ b/README.md @@ -19,16 +19,20 @@ Installation To install the Python library and the command line utility, run: - pip install tabulate +```shell +pip install tabulate +``` The command line utility will be installed as `tabulate` to `bin` on Linux (e.g. `/usr/bin`); or as `tabulate.exe` to `Scripts` in your Python installation on Windows (e.g. -`C:\Python27\Scripts\tabulate.exe`). +`C:\Python39\Scripts\tabulate.exe`). You may consider installing the library only for the current user: - pip install tabulate --user +```shell +pip install tabulate --user +``` In this case the command line utility will be installed to `~/.local/bin/tabulate` on Linux and to @@ -36,12 +40,16 @@ In this case the command line utility will be installed to To install just the library on Unix-like operating systems: - TABULATE_INSTALL=lib-only pip install tabulate +```shell +TABULATE_INSTALL=lib-only pip install tabulate +``` On Windows: - set TABULATE_INSTALL=lib-only - pip install tabulate +```shell +set TABULATE_INSTALL=lib-only +pip install tabulate +``` Build status ------------ @@ -55,17 +63,19 @@ The module provides just one function, `tabulate`, which takes a list of lists or another tabular data type as the first argument, and outputs a nicely formatted plain-text table: - >>> from tabulate import tabulate +```pycon +>>> from tabulate import tabulate - >>> table = [["Sun",696000,1989100000],["Earth",6371,5973.6], - ... ["Moon",1737,73.5],["Mars",3390,641.85]] - >>> print(tabulate(table)) - ----- ------ ------------- - Sun 696000 1.9891e+09 - Earth 6371 5973.6 - Moon 1737 73.5 - Mars 3390 641.85 - ----- ------ ------------- +>>> table = [["Sun",696000,1989100000],["Earth",6371,5973.6], +... ["Moon",1737,73.5],["Mars",3390,641.85]] +>>> print(tabulate(table)) +----- ------ ------------- +Sun 696000 1.9891e+09 +Earth 6371 5973.6 +Moon 1737 73.5 +Mars 3390 641.85 +----- ------ ------------- +``` The following tabular data types are supported: @@ -83,33 +93,39 @@ Examples in this file use Python2. Tabulate supports Python3 too. The second optional argument named `headers` defines a list of column headers to be used: - >>> print(tabulate(table, headers=["Planet","R (km)", "mass (x 10^29 kg)"])) - Planet R (km) mass (x 10^29 kg) - -------- -------- ------------------- - Sun 696000 1.9891e+09 - Earth 6371 5973.6 - Moon 1737 73.5 - Mars 3390 641.85 +```pycon +>>> print(tabulate(table, headers=["Planet","R (km)", "mass (x 10^29 kg)"])) +Planet R (km) mass (x 10^29 kg) +-------- -------- ------------------- +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: - >>> print(tabulate([["Name","Age"],["Alice",24],["Bob",19]], - ... headers="firstrow")) - Name Age - ------ ----- - Alice 24 - Bob 19 +```pycon +>>> print(tabulate([["Name","Age"],["Alice",24],["Bob",19]], +... headers="firstrow")) +Name Age +------ ----- +Alice 24 +Bob 19 +``` If `headers="keys"`, then the keys of a dictionary/dataframe, or column indices are used. It also works for NumPy record arrays and lists of dictionaries or named tuples: - >>> print(tabulate({"Name": ["Alice", "Bob"], - ... "Age": [24, 19]}, headers="keys")) - Age Name - ----- ------ - 24 Alice - 19 Bob +```pycon +>>> print(tabulate({"Name": ["Alice", "Bob"], +... "Age": [24, 19]}, headers="keys")) + Age Name +----- ------ + 24 Alice + 19 Bob +``` ### Row Indices @@ -120,11 +136,13 @@ To suppress row indices for all types of data, pass `showindex="never"` or `showindex=False`. To add a custom row index column, pass `showindex=rowIDs`, where `rowIDs` is some iterable: - >>> print(tabulate([["F",24],["M",19]], showindex="always")) - - - -- - 0 F 24 - 1 M 19 - - - -- +```pycon +>>> print(tabulate([["F",24],["M",19]], showindex="always")) +- - -- +0 F 24 +1 M 19 +- - -- +``` ### Table format @@ -159,211 +177,247 @@ Supported table formats are: `plain` tables do not use any pseudo-graphics to draw lines: - >>> table = [["spam",42],["eggs",451],["bacon",0]] - >>> headers = ["item", "qty"] - >>> print(tabulate(table, headers, tablefmt="plain")) - item qty - spam 42 - eggs 451 - bacon 0 +```pycon +>>> table = [["spam",42],["eggs",451],["bacon",0]] +>>> headers = ["item", "qty"] +>>> print(tabulate(table, headers, tablefmt="plain")) +item qty +spam 42 +eggs 451 +bacon 0 +``` `simple` is the default format (the default may change in future versions). It corresponds to `simple_tables` in [Pandoc Markdown extensions](http://johnmacfarlane.net/pandoc/README.html#tables): - >>> print(tabulate(table, headers, tablefmt="simple")) - item qty - ------ ----- - spam 42 - eggs 451 - bacon 0 +```pycon +>>> print(tabulate(table, headers, tablefmt="simple")) +item qty +------ ----- +spam 42 +eggs 451 +bacon 0 +``` `github` follows the conventions of GitHub flavored Markdown. It corresponds to the `pipe` format without alignment colons: - >>> print(tabulate(table, headers, tablefmt="github")) - | item | qty | - |--------|-------| - | spam | 42 | - | eggs | 451 | - | bacon | 0 | +```pycon +>>> print(tabulate(table, headers, tablefmt="github")) +| item | qty | +|--------|-------| +| spam | 42 | +| eggs | 451 | +| bacon | 0 | +``` `grid` is like tables formatted by Emacs' [table.el](http://table.sourceforge.net/) package. It corresponds to `grid_tables` in Pandoc Markdown extensions: - >>> print(tabulate(table, headers, tablefmt="grid")) - +--------+-------+ - | item | qty | - +========+=======+ - | spam | 42 | - +--------+-------+ - | eggs | 451 | - +--------+-------+ - | bacon | 0 | - +--------+-------+ +```pycon +>>> print(tabulate(table, headers, tablefmt="grid")) ++--------+-------+ +| item | qty | ++========+=======+ +| spam | 42 | ++--------+-------+ +| eggs | 451 | ++--------+-------+ +| bacon | 0 | ++--------+-------+ +``` `fancy_grid` draws a grid using box-drawing characters: - >>> print(tabulate(table, headers, tablefmt="fancy_grid")) - ╒════════╤═══════╕ - │ item │ qty │ - ╞════════╪═══════╡ - │ spam │ 42 │ - ├────────┼───────┤ - │ eggs │ 451 │ - ├────────┼───────┤ - │ bacon │ 0 │ - ╘════════╧═══════╛ +```pycon +>>> print(tabulate(table, headers, tablefmt="fancy_grid")) +╒════════╤═══════╕ +│ item │ qty │ +╞════════╪═══════╡ +│ spam │ 42 │ +├────────┼───────┤ +│ eggs │ 451 │ +├────────┼───────┤ +│ bacon │ 0 │ +╘════════╧═══════╛ +``` `presto` is like tables formatted by Presto cli: - >>> print(tabulate(table, headers, tablefmt="presto")) - item | qty - --------+------- - spam | 42 - eggs | 451 - bacon | 0 +```pycon +>>> print(tabulate(table, headers, tablefmt="presto")) + item | qty +--------+------- + spam | 42 + eggs | 451 + bacon | 0 +``` `pretty` attempts to be close to the format emitted by the PrettyTables library: - >>> print(tabulate(table, headers, tablefmt="pretty")) - +-------+-----+ - | item | qty | - +-------+-----+ - | spam | 42 | - | eggs | 451 | - | bacon | 0 | - +-------+-----+ +```pycon +>>> print(tabulate(table, headers, tablefmt="pretty")) ++-------+-----+ +| item | qty | ++-------+-----+ +| spam | 42 | +| eggs | 451 | +| bacon | 0 | ++-------+-----+ +``` `psql` is like tables formatted by Postgres' psql cli: - >>> print(tabulate(table, headers, tablefmt="psql")) - +--------+-------+ - | item | qty | - |--------+-------| - | spam | 42 | - | eggs | 451 | - | bacon | 0 | - +--------+-------+ +```pycon +>>> print(tabulate(table, headers, tablefmt="psql")) ++--------+-------+ +| item | qty | +|--------+-------| +| spam | 42 | +| eggs | 451 | +| bacon | 0 | ++--------+-------+ +``` `pipe` follows the conventions of [PHP Markdown Extra](http://michelf.ca/projects/php-markdown/extra/#table) extension. It corresponds to `pipe_tables` in Pandoc. This format uses colons to indicate column alignment: - >>> print(tabulate(table, headers, tablefmt="pipe")) - | item | qty | - |:-------|------:| - | spam | 42 | - | eggs | 451 | - | bacon | 0 | +```pycon +>>> print(tabulate(table, headers, tablefmt="pipe")) +| item | qty | +|:-------|------:| +| spam | 42 | +| eggs | 451 | +| bacon | 0 | +``` `orgtbl` follows the conventions of Emacs [org-mode](http://orgmode.org/manual/Tables.html), and is editable also in the minor orgtbl-mode. Hence its name: - >>> print(tabulate(table, headers, tablefmt="orgtbl")) - | item | qty | - |--------+-------| - | spam | 42 | - | eggs | 451 | - | bacon | 0 | +```pycon +>>> print(tabulate(table, headers, tablefmt="orgtbl")) +| item | qty | +|--------+-------| +| spam | 42 | +| eggs | 451 | +| bacon | 0 | +``` `jira` follows the conventions of Atlassian Jira markup language: - >>> print(tabulate(table, headers, tablefmt="jira")) - || item || qty || - | spam | 42 | - | eggs | 451 | - | bacon | 0 | +```pycon +>>> print(tabulate(table, headers, tablefmt="jira")) +|| item || qty || +| spam | 42 | +| eggs | 451 | +| bacon | 0 | +``` `rst` formats data like a simple table of the [reStructuredText](http://docutils.sourceforge.net/docs/user/rst/quickref.html#tables) format: - >>> print(tabulate(table, headers, tablefmt="rst")) - ====== ===== - item qty - ====== ===== - spam 42 - eggs 451 - bacon 0 - ====== ===== +```pycon +>>> print(tabulate(table, headers, tablefmt="rst")) +====== ===== +item qty +====== ===== +spam 42 +eggs 451 +bacon 0 +====== ===== +``` `mediawiki` format produces a table markup used in [Wikipedia](http://www.mediawiki.org/wiki/Help:Tables) and on other MediaWiki-based sites: - >>> print(tabulate(table, headers, tablefmt="mediawiki")) - {| class="wikitable" style="text-align: left;" - |+ - |- - ! item !! align="right"| qty - |- - | spam || align="right"| 42 - |- - | eggs || align="right"| 451 - |- - | bacon || align="right"| 0 - |} + ```pycon +>>> print(tabulate(table, headers, tablefmt="mediawiki")) +{| class="wikitable" style="text-align: left;" +|+ +|- +! item !! align="right"| qty +|- +| spam || align="right"| 42 +|- +| eggs || align="right"| 451 +|- +| bacon || align="right"| 0 +|} +``` `moinmoin` format produces a table markup used in [MoinMoin](https://moinmo.in/) wikis: - >>> print(tabulate(table, headers, tablefmt="moinmoin")) - || ''' item ''' || ''' quantity ''' || - || spam || 41.999 || - || eggs || 451 || - || bacon || || +```pycon +>>> print(tabulate(table, headers, tablefmt="moinmoin")) +|| ''' item ''' || ''' quantity ''' || +|| spam || 41.999 || +|| eggs || 451 || +|| bacon || || +``` `youtrack` format produces a table markup used in Youtrack tickets: - >>> print(tabulate(table, headers, tablefmt="youtrack")) - || item || quantity || - | spam | 41.999 | - | eggs | 451 | - | bacon | | +```pycon +>>> print(tabulate(table, headers, tablefmt="youtrack")) +|| item || quantity || +| spam | 41.999 | +| eggs | 451 | +| bacon | | +``` `textile` format produces a table markup used in [Textile](http://redcloth.org/hobix.com/textile/) format: - >>> print(tabulate(table, headers, tablefmt="textile")) - |_. item |_. qty | - |<. spam |>. 42 | - |<. eggs |>. 451 | - |<. bacon |>. 0 | +```pycon +>>> print(tabulate(table, headers, tablefmt="textile")) +|_. item |_. qty | +|<. spam |>. 42 | +|<. eggs |>. 451 | +|<. bacon |>. 0 | +``` `html` produces standard 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. `unsafehtml` table format can be used if an unescaped HTML is required: - >>> print(tabulate(table, headers, tablefmt="html")) -
- - - - - - -
item qty
spam 42
eggs 451
bacon 0
+```pycon +>>> print(tabulate(table, headers, tablefmt="html")) + + + + + + + +
item qty
spam 42
eggs 451
bacon 0
+``` `latex` format creates a `tabular` environment for LaTeX markup, replacing special characters like `_` or `\` to their LaTeX correspondents: - >>> print(tabulate(table, headers, tablefmt="latex")) - \begin{tabular}{lr} - \hline - item & qty \\ - \hline - spam & 42 \\ - eggs & 451 \\ - bacon & 0 \\ - \hline - \end{tabular} +```pycon +>>> print(tabulate(table, headers, tablefmt="latex")) +\begin{tabular}{lr} +\hline + item & qty \\ +\hline + spam & 42 \\ + eggs & 451 \\ + bacon & 0 \\ +\hline +\end{tabular} +``` `latex_raw` behaves like `latex` but does not escape LaTeX commands and special characters. @@ -388,50 +442,56 @@ named arguments. Possible column alignments are: `right`, `center`, Aligning by a decimal point works best when you need to compare numbers at a glance: - >>> print(tabulate([[1.2345],[123.45],[12.345],[12345],[1234.5]])) - ---------- - 1.2345 - 123.45 - 12.345 - 12345 - 1234.5 - ---------- +```pycon +>>> print(tabulate([[1.2345],[123.45],[12.345],[12345],[1234.5]])) +---------- + 1.2345 + 123.45 + 12.345 +12345 + 1234.5 +---------- +``` Compare this with a more common right alignment: - >>> print(tabulate([[1.2345],[123.45],[12.345],[12345],[1234.5]], numalign="right")) - ------ - 1.2345 - 123.45 - 12.345 - 12345 - 1234.5 - ------ +```pycon +>>> print(tabulate([[1.2345],[123.45],[12.345],[12345],[1234.5]], numalign="right")) +------ +1.2345 +123.45 +12.345 + 12345 +1234.5 +------ +``` For `tabulate`, anything which can be parsed as a number is a number. Even numbers represented as strings are aligned properly. This feature comes in handy when reading a mixed table of text and numbers from a file: - >>> import csv ; from StringIO import StringIO - >>> table = list(csv.reader(StringIO("spam, 42\neggs, 451\n"))) - >>> table - [['spam', ' 42'], ['eggs', ' 451']] - >>> print(tabulate(table)) - ---- ---- - spam 42 - eggs 451 - ---- ---- - +```pycon +>>> import csv ; from StringIO import StringIO +>>> table = list(csv.reader(StringIO("spam, 42\neggs, 451\n"))) +>>> table +[['spam', ' 42'], ['eggs', ' 451']] +>>> print(tabulate(table)) +---- ---- +spam 42 +eggs 451 +---- ---- +``` To disable this feature use `disable_numparse=True`. - >>> print(tabulate.tabulate([["Ver1", "18.0"], ["Ver2","19.2"]], tablefmt="simple", disable_numparse=True)) - ---- ---- - Ver1 18.0 - Ver2 19.2 - ---- ---- - +```pycon +>>> print(tabulate.tabulate([["Ver1", "18.0"], ["Ver2","19.2"]], tablefmt="simple", disable_numparse=True)) +---- ---- +Ver1 18.0 +Ver2 19.2 +---- ---- +``` ### Custom column alignment @@ -441,30 +501,36 @@ 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: - >>> print(tabulate([["one", "two"], ["three", "four"]], colalign=("right",)) - ----- ---- - one two - three four - ----- ---- +```pycon +>>> print(tabulate([["one", "two"], ["three", "four"]], colalign=("right",)) +----- ---- + one two +three four +----- ---- +``` ### Number formatting `tabulate` allows to define custom number formatting applied to all columns of decimal numbers. Use `floatfmt` named argument: - >>> print(tabulate([["pi",3.141593],["e",2.718282]], floatfmt=".4f")) - -- ------ - pi 3.1416 - e 2.7183 - -- ------ +```pycon +>>> print(tabulate([["pi",3.141593],["e",2.718282]], floatfmt=".4f")) +-- ------ +pi 3.1416 +e 2.7183 +-- ------ +``` `floatfmt` argument can be a list or a tuple of format strings, one per column, in which case every column may have different number formatting: - >>> print(tabulate([[0.12345, 0.12345, 0.12345]], floatfmt=(".1f", ".3f"))) - --- ----- ------- - 0.1 0.123 0.12345 - --- ----- ------- +```pycon +>>> print(tabulate([[0.12345, 0.12345, 0.12345]], floatfmt=(".1f", ".3f"))) +--- ----- ------- +0.1 0.123 0.12345 +--- ----- ------- +``` ### Text formatting @@ -472,8 +538,10 @@ By default, `tabulate` removes leading and trailing whitespace from text columns. To disable whitespace removal, set the global module-level flag `PRESERVE_WHITESPACE`: - import tabulate - tabulate.PRESERVE_WHITESPACE = True +```python +import tabulate +tabulate.PRESERVE_WHITESPACE = True +``` ### Wide (fullwidth CJK) symbols @@ -482,15 +550,19 @@ fullwidth glyphs from Chinese, Japanese or Korean languages), the user should install `wcwidth` library. To install it together with `tabulate`: - pip install tabulate[widechars] +```shell +pip install tabulate[widechars] +``` Wide character support is enabled automatically if `wcwidth` library is already installed. To disable wide characters support without uninstalling `wcwidth`, set the global module-level flag `WIDE_CHARS_MODE`: - import tabulate - tabulate.WIDE_CHARS_MODE = False +```python +import tabulate +tabulate.WIDE_CHARS_MODE = False +``` ### Multiline cells @@ -512,134 +584,158 @@ formats may be ambiguous to the reader. The following examples of formatted output use the following table with a multiline cell, and headers with a multiline cell: - >>> table = [["eggs",451],["more\nspam",42]] - >>> headers = ["item\nname", "qty"] +```pycon +>>> table = [["eggs",451],["more\nspam",42]] +>>> headers = ["item\nname", "qty"] +``` `plain` tables: - >>> print(tabulate(table, headers, tablefmt="plain")) - item qty - name - eggs 451 - more 42 - spam +```pycon +>>> print(tabulate(table, headers, tablefmt="plain")) +item qty +name +eggs 451 +more 42 +spam +``` `simple` tables: - >>> print(tabulate(table, headers, tablefmt="simple")) - item qty - name - ------ ----- - eggs 451 - more 42 - spam +```pycon +>>> print(tabulate(table, headers, tablefmt="simple")) +item qty +name +------ ----- +eggs 451 +more 42 +spam +``` `grid` tables: - >>> print(tabulate(table, headers, tablefmt="grid")) - +--------+-------+ - | item | qty | - | name | | - +========+=======+ - | eggs | 451 | - +--------+-------+ - | more | 42 | - | spam | | - +--------+-------+ +```pycon +>>> print(tabulate(table, headers, tablefmt="grid")) ++--------+-------+ +| item | qty | +| name | | ++========+=======+ +| eggs | 451 | ++--------+-------+ +| more | 42 | +| spam | | ++--------+-------+ +``` `fancy_grid` tables: - >>> print(tabulate(table, headers, tablefmt="fancy_grid")) - ╒════════╤═══════╕ - │ item │ qty │ - │ name │ │ - ╞════════╪═══════╡ - │ eggs │ 451 │ - ├────────┼───────┤ - │ more │ 42 │ - │ spam │ │ - ╘════════╧═══════╛ +```pycon +>>> print(tabulate(table, headers, tablefmt="fancy_grid")) +╒════════╤═══════╕ +│ item │ qty │ +│ name │ │ +╞════════╪═══════╡ +│ eggs │ 451 │ +├────────┼───────┤ +│ more │ 42 │ +│ spam │ │ +╘════════╧═══════╛ +``` `pipe` tables: - >>> print(tabulate(table, headers, tablefmt="pipe")) - | item | qty | - | name | | - |:-------|------:| - | eggs | 451 | - | more | 42 | - | spam | | +```pycon +>>> print(tabulate(table, headers, tablefmt="pipe")) +| item | qty | +| name | | +|:-------|------:| +| eggs | 451 | +| more | 42 | +| spam | | +``` `orgtbl` tables: - >>> print(tabulate(table, headers, tablefmt="orgtbl")) - | item | qty | - | name | | - |--------+-------| - | eggs | 451 | - | more | 42 | - | spam | | +```pycon +>>> print(tabulate(table, headers, tablefmt="orgtbl")) +| item | qty | +| name | | +|--------+-------| +| eggs | 451 | +| more | 42 | +| spam | | +``` `jira` tables: - >>> print(tabulate(table, headers, tablefmt="jira")) - | item | qty | - | name | | - |:-------|------:| - | eggs | 451 | - | more | 42 | - | spam | | +```pycon +>>> print(tabulate(table, headers, tablefmt="jira")) +| item | qty | +| name | | +|:-------|------:| +| eggs | 451 | +| more | 42 | +| spam | | +``` `presto` tables: - >>> print(tabulate(table, headers, tablefmt="presto")) - item | qty - name | - --------+------- - eggs | 451 - more | 42 - spam | +```pycon +>>> print(tabulate(table, headers, tablefmt="presto")) + item | qty + name | +--------+------- + eggs | 451 + more | 42 + spam | +``` `pretty` tables: - >>> print(tabulate(table, headers, tablefmt="pretty")) - +------+-----+ - | item | qty | - | name | | - +------+-----+ - | eggs | 451 | - | more | 42 | - | spam | | - +------+-----+ +```pycon +>>> print(tabulate(table, headers, tablefmt="pretty")) ++------+-----+ +| item | qty | +| name | | ++------+-----+ +| eggs | 451 | +| more | 42 | +| spam | | ++------+-----+ +``` `psql` tables: - >>> print(tabulate(table, headers, tablefmt="psql")) - +--------+-------+ - | item | qty | - | name | | - |--------+-------| - | eggs | 451 | - | more | 42 | - | spam | | - +--------+-------+ +```pycon +>>> print(tabulate(table, headers, tablefmt="psql")) ++--------+-------+ +| item | qty | +| name | | +|--------+-------| +| eggs | 451 | +| more | 42 | +| spam | | ++--------+-------+ +``` `rst` tables: - >>> print(tabulate(table, headers, tablefmt="rst")) - ====== ===== - item qty - name - ====== ===== - eggs 451 - more 42 - spam - ====== ===== +```pycon +>>> print(tabulate(table, headers, tablefmt="rst")) +====== ===== +item qty +name +====== ===== +eggs 451 +more 42 +spam +====== ===== +``` -Multiline cells are not well supported for the other table formats. +Multiline cells are not well-supported for the other table formats. ### Automating Multilines -While tabulate supports data passed in with multiines entries explicitly provided, +While tabulate supports data passed in with multilines entries explicitly provided, it also provides some support to help manage this work internally. The `maxcolwidths` argument is a list where each entry specifies the max width for @@ -652,17 +748,18 @@ and thus no automate multiline wrapping will take place. The wraping uses the python standard [textwrap.wrap](https://docs.python.org/3/library/textwrap.html#textwrap.wrap) function with default parameters - aside from width. -This example demonstrates usagage of automatic multiline wrapping, though typically +This example demonstrates usage of automatic multiline wrapping, though typically the lines being wrapped would probably be significantly longer than this. - >>> print(tabulate([["John Smith", "Middle Manager"]], headers=["Name", "Title"], tablefmt="grid", maxcolwidths=[None, 8])) - +------------+---------+ - | Name | Title | - +============+=========+ - | John Smith | Middle | - | | Manager | - +------------+---------+ - +```pycon +>>> print(tabulate([["John Smith", "Middle Manager"]], headers=["Name", "Title"], tablefmt="grid", maxcolwidths=[None, 8])) ++------------+---------+ +| Name | Title | ++============+=========+ +| John Smith | Middle | +| | Manager | ++------------+---------+ +``` Usage of the command line utility @@ -748,22 +845,28 @@ On Linux `tox` expects to find executables like `python2.6`, `C:\Python34\python.exe` respectively. To test only some Python environments, use `-e` option. For example, to -test only against Python 2.7 and Python 3.8, run: +test only against Python 3.7 and Python 3.10, run: - tox -e py27,py38 +```shell +tox -e py37,py310 +``` in the root of the project source tree. To enable NumPy and Pandas tests, run: - tox -e py27-extra,py38-extra +```shell +tox -e py37-extra,py310-extra +``` (this may take a long time the first time, because NumPy and Pandas will have to be installed in the new virtual environments) To fix code formatting: - tox -e lint +```shell +tox -e lint +``` See `tox.ini` file to learn how to use to test individual Python versions. From 5ebeed4f7f05d0936393e2a43221bc0fd6f9dd24 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Fri, 7 Jan 2022 16:27:07 +0200 Subject: [PATCH 069/207] Update AppVeyor image to include newer Python versions --- appveyor.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/appveyor.yml b/appveyor.yml index 0dbfaaa..2eacee7 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,3 +1,4 @@ +image: Visual Studio 2022 environment: matrix: From 29eefa9e023c30c6ad75876350967c6d921c6e6f Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Fri, 7 Jan 2022 16:38:22 +0200 Subject: [PATCH 070/207] Skip 32-bit Python 3.10, NumPy does not provide wheels --- appveyor.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/appveyor.yml b/appveyor.yml index 2eacee7..ddf7b3c 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -11,7 +11,6 @@ environment: - PYTHON: "C:\\Python37" - PYTHON: "C:\\Python38" - PYTHON: "C:\\Python39" - - PYTHON: "C:\\Python310" - PYTHON: "C:\\Python37-x64" - PYTHON: "C:\\Python38-x64" - PYTHON: "C:\\Python39-x64" From da5ce42e3a50cd951bb6f214f9a5a53c7324c149 Mon Sep 17 00:00:00 2001 From: jerome provensal Date: Mon, 17 Jan 2022 11:51:08 -0800 Subject: [PATCH 071/207] Added test for autowrap with sep --- tabulate.py | 4 ++-- test/test_output.py | 12 ++++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/tabulate.py b/tabulate.py index 13b2522..bbc1869 100644 --- a/tabulate.py +++ b/tabulate.py @@ -1871,9 +1871,9 @@ def _format_table(fmt, headers, rows, colwidths, colaligns, is_multiline): or Line("", "", "", "") ) for row in padded_rows: - # test to see if the either the 1st column or the 2nd column (account for showindex) has + # test to see if either the 1st column or the 2nd column (account for showindex) has # the SEPARATING_LINE flag - if row[0].strip() == SEPARATING_LINE or (len(row) > 1 and row[1].strip() == SEPARATING_LINE): + if row[0].strip() == SEPARATING_LINE or (len(row) > 1 and row[1].strip() == SEPARATING_LINE): _append_line(lines, padded_widths, colaligns, separating_line) else: append_row(lines, row, padded_widths, colaligns, fmt.datarow) diff --git a/test/test_output.py b/test/test_output.py index ad623f8..31d4571 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -108,19 +108,23 @@ def test_plain_multiline_with_empty_cells_headerless(): def test_plain_maxcolwidth_autowraps(): "Output: maxcolwidth will result in autowrapping longer cells" table = [["hdr", "fold"], ["1", "very long data"]] - expected = "\n".join([" hdr fold", "----- ---------, " 1 very long", " data"]) + expected = "\n".join([" hdr fold", " 1 very long", " data"]) result = tabulate( table, headers="firstrow", tablefmt="plain", maxcolwidths=[10, 10] ) assert_equal(expected, result) - def test_plain_maxcolwidth_autowraps_with_sep(): "Output: maxcolwidth will result in autowrapping longer cells and separating line" table = [["hdr", "fold"], ["1", "very long data"], SEPARATING_LINE, ["2", "last line"]] - expected = "\n".join([" hdr fold", " 1 very long", " data"]) + expected = "\n".join([" hdr fold", + " 1 very long", + " data", + "", + " 2 last line", + ]) result = tabulate( - table, headers="firstrow", tablefmt="", maxcolwidths=[10, 10] + table, headers="firstrow", tablefmt="plain", maxcolwidths=[10, 10] ) assert_equal(expected, result) From 9031320e5fa0be8f73240f1839bd057464a18e9a Mon Sep 17 00:00:00 2001 From: jerome provensal Date: Mon, 17 Jan 2022 11:51:08 -0800 Subject: [PATCH 072/207] Added test for autowrap with sep --- tabulate.py | 4 ++-- test/test_output.py | 11 ++++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/tabulate.py b/tabulate.py index 13b2522..bbc1869 100644 --- a/tabulate.py +++ b/tabulate.py @@ -1871,9 +1871,9 @@ def _format_table(fmt, headers, rows, colwidths, colaligns, is_multiline): or Line("", "", "", "") ) for row in padded_rows: - # test to see if the either the 1st column or the 2nd column (account for showindex) has + # test to see if either the 1st column or the 2nd column (account for showindex) has # the SEPARATING_LINE flag - if row[0].strip() == SEPARATING_LINE or (len(row) > 1 and row[1].strip() == SEPARATING_LINE): + if row[0].strip() == SEPARATING_LINE or (len(row) > 1 and row[1].strip() == SEPARATING_LINE): _append_line(lines, padded_widths, colaligns, separating_line) else: append_row(lines, row, padded_widths, colaligns, fmt.datarow) diff --git a/test/test_output.py b/test/test_output.py index ad623f8..e8b5f54 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -108,7 +108,7 @@ def test_plain_multiline_with_empty_cells_headerless(): def test_plain_maxcolwidth_autowraps(): "Output: maxcolwidth will result in autowrapping longer cells" table = [["hdr", "fold"], ["1", "very long data"]] - expected = "\n".join([" hdr fold", "----- ---------, " 1 very long", " data"]) + expected = "\n".join([" hdr fold", " 1 very long", " data"]) result = tabulate( table, headers="firstrow", tablefmt="plain", maxcolwidths=[10, 10] ) @@ -118,9 +118,14 @@ def test_plain_maxcolwidth_autowraps(): def test_plain_maxcolwidth_autowraps_with_sep(): "Output: maxcolwidth will result in autowrapping longer cells and separating line" table = [["hdr", "fold"], ["1", "very long data"], SEPARATING_LINE, ["2", "last line"]] - expected = "\n".join([" hdr fold", " 1 very long", " data"]) + expected = "\n".join([" hdr fold", + " 1 very long", + " data", + "", + " 2 last line", + ]) result = tabulate( - table, headers="firstrow", tablefmt="", maxcolwidths=[10, 10] + table, headers="firstrow", tablefmt="plain", maxcolwidths=[10, 10] ) assert_equal(expected, result) From 80b3eefb16842cbb2c1eb2b461d7074ff74c3e8d Mon Sep 17 00:00:00 2001 From: jerome provensal Date: Mon, 17 Jan 2022 15:12:30 -0800 Subject: [PATCH 073/207] Added more test Update README.md --- README.md | 33 ++++++++++++++++- tabulate.py | 11 ++++-- test/common.py | 3 +- test/test_internal.py | 24 ++++++++++--- test/test_output.py | 83 +++++++++++++++++++++++++++---------------- 5 files changed, 115 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 5c4b62a..a46456d 100644 --- a/README.md +++ b/README.md @@ -663,6 +663,26 @@ the lines being wrapped would probably be significantly longer than this. | | Manager | +------------+---------+ +### Adding Separating lines +One might want to add one or more separating lines to highlight different sections in a table. + +The separating lines will be of the same type as the one defined by the specified formatter as either the +linebetweenrows, linebelowheader, linebelow, lineabove or just a simple empty line when none is defined for the formatter + + + >>> from tabulate import tabulate, SEPARATING_LINE + + table = [["Earth",6371], + ["Mars",3390], + SEPARATING_LINE, + ["Moon",1737]] + print(tabulate(table, tablefmt="simple")) + ----- ---- + Earth 6371 + Mars 3390 + ----- ---- + Moon 1737 + ----- ---- Usage of the command line utility @@ -747,6 +767,17 @@ On Linux `tox` expects to find executables like `python2.6`, `C:\Python26\python.exe`, `C:\Python27\python.exe` and `C:\Python34\python.exe` 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 + ... + +Don't forget to change your `PATH` so that `tox` knows how to find all the installed versions. Something like + + export PATH="${PATH}:${HOME}/.pyenv/shims" + To test only some Python environments, use `-e` option. For example, to test only against Python 2.7 and Python 3.8, run: @@ -785,4 +816,4 @@ Wes Turner, Andrew Tija, Marco Gorelli, Sean McGinnis, danja100, 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. +Bart Broere, Vilhelm Prytz, Jérôme Provensal. diff --git a/tabulate.py b/tabulate.py index bbc1869..1a773ba 100644 --- a/tabulate.py +++ b/tabulate.py @@ -1026,6 +1026,7 @@ def _align_header( else: return _padleft(width, header) + def _remove_separating_lines(rows): if type(rows) == list: separating_lines = [] @@ -1216,7 +1217,7 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): rows = rows[1:] headers = list(map(_text_type, headers)) -# rows = list(map(list, rows)) + # rows = list(map(list, rows)) rows = list(map(lambda r: r if r == SEPARATING_LINE else list(r), rows)) # add or remove an index column @@ -1604,7 +1605,9 @@ def tabulate( if tabular_data is None: tabular_data = [] - list_of_lists, headers = _normalize_tabular_data(tabular_data, headers, showindex=showindex) + 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: @@ -1873,7 +1876,9 @@ def _format_table(fmt, headers, rows, colwidths, colaligns, is_multiline): 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 row[0].strip() == SEPARATING_LINE or (len(row) > 1 and row[1].strip() == SEPARATING_LINE): + if row[0].strip() == SEPARATING_LINE or ( + len(row) > 1 and row[1].strip() == SEPARATING_LINE + ): _append_line(lines, padded_widths, colaligns, separating_line) else: append_row(lines, row, padded_widths, colaligns, fmt.datarow) diff --git a/test/common.py b/test/common.py index 134fdbd..d95e84f 100644 --- a/test/common.py +++ b/test/common.py @@ -15,6 +15,7 @@ def assert_in(result, expected_set): print("Got:\n%s\n" % result) assert result in expected_set + def cols_to_pipe_str(cols): return "|".join([str(col) for col in cols]) @@ -25,4 +26,4 @@ def rows_to_pipe_table_str(rows): line = cols_to_pipe_str(row) lines.append(line) - return '\n'.join(lines) + return "\n".join(lines) diff --git a/test/test_internal.py b/test/test_internal.py index 4dda2ad..4d6390e 100644 --- a/test/test_internal.py +++ b/test/test_internal.py @@ -213,18 +213,34 @@ def test_wrap_text_to_colwidths_multi_ansi_colors_in_subset(): def test__remove_separating_lines(): - with_rows = [[0, 'a'], [1, 'b'], T.SEPARATING_LINE, [2, 'c'], T.SEPARATING_LINE, [3, 'c'], T.SEPARATING_LINE] + with_rows = [ + [0, "a"], + [1, "b"], + T.SEPARATING_LINE, + [2, "c"], + T.SEPARATING_LINE, + [3, "c"], + T.SEPARATING_LINE, + ] result, sep_lines = T._remove_separating_lines(with_rows) - expected = rows_to_pipe_table_str([[0, 'a'], [1, 'b'], [2, 'c'], [3, 'c']]) + expected = rows_to_pipe_table_str([[0, "a"], [1, "b"], [2, "c"], [3, "c"]]) assert_equal(expected, rows_to_pipe_table_str(result)) assert_equal("2|4|6", cols_to_pipe_str(sep_lines)) + def test__reinsert_separating_lines(): - with_rows = [[0, 'a'], [1, 'b'], T.SEPARATING_LINE, [2, 'c'], T.SEPARATING_LINE, [3, 'c'], T.SEPARATING_LINE] + with_rows = [ + [0, "a"], + [1, "b"], + T.SEPARATING_LINE, + [2, "c"], + T.SEPARATING_LINE, + [3, "c"], + T.SEPARATING_LINE, + ] sans_rows, sep_lines = T._remove_separating_lines(with_rows) T._reinsert_separating_lines(sans_rows, sep_lines) expected = rows_to_pipe_table_str(with_rows) assert_equal(expected, rows_to_pipe_table_str(sans_rows)) - diff --git a/test/test_output.py b/test/test_output.py index e8b5f54..5e85d93 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -4,10 +4,10 @@ from __future__ import print_function from __future__ import unicode_literals + import tabulate as tabulate_module -from tabulate import tabulate, simple_separated_format, SEPARATING_LINE from common import assert_equal, raises, skip - +from tabulate import tabulate, simple_separated_format, SEPARATING_LINE # _test_table shows # - coercion of a string to a number, @@ -117,13 +117,15 @@ def test_plain_maxcolwidth_autowraps(): def test_plain_maxcolwidth_autowraps_with_sep(): "Output: maxcolwidth will result in autowrapping longer cells and separating line" - table = [["hdr", "fold"], ["1", "very long data"], SEPARATING_LINE, ["2", "last line"]] - expected = "\n".join([" hdr fold", - " 1 very long", - " data", - "", - " 2 last line", - ]) + table = [ + ["hdr", "fold"], + ["1", "very long data"], + SEPARATING_LINE, + ["2", "last line"], + ] + expected = "\n".join( + [" hdr fold", " 1 very long", " data", "", " 2 last line"] + ) result = tabulate( table, headers="firstrow", tablefmt="plain", maxcolwidths=[10, 10] ) @@ -246,6 +248,22 @@ def test_simple_with_sep_line(): assert_equal(expected, result) +def test_readme_example_with_sep(): + table = [["Earth", 6371], ["Mars", 3390], SEPARATING_LINE, ["Moon", 1737]] + expected = "\n".join( + [ + "----- ----", + "Earth 6371", + "Mars 3390", + "----- ----", + "Moon 1737", + "----- ----", + ] + ) + result = tabulate(table, tablefmt="simple") + assert_equal(expected, result) + + def test_simple_multiline_2(): "Output: simple with multiline cells" expected = "\n".join( @@ -261,6 +279,7 @@ def test_simple_multiline_2(): result = tabulate(table, headers="firstrow", stralign="center", tablefmt="simple") assert_equal(expected, result) + def test_simple_multiline_2_with_sep_line(): "Output: simple with multiline cells" expected = "\n".join( @@ -273,7 +292,12 @@ def test_simple_multiline_2_with_sep_line(): " world", ] ) - table = [["key", "value"], ["foo", "bar"], SEPARATING_LINE, ["spam", "multiline\nworld"]] + table = [ + ["key", "value"], + ["foo", "bar"], + SEPARATING_LINE, + ["spam", "multiline\nworld"], + ] result = tabulate(table, headers="firstrow", stralign="center", tablefmt="simple") assert_equal(expected, result) @@ -290,12 +314,13 @@ def test_simple_headerless(): def test_simple_headerless_with_sep_line(): "Output: simple without headers" expected = "\n".join( - ["---- --------", - "spam 41.9999", - "---- --------", - "eggs 451", - "---- --------" - ] + [ + "---- --------", + "spam 41.9999", + "---- --------", + "eggs 451", + "---- --------", + ] ) result = tabulate(_test_table_with_sep_line, tablefmt="simple") assert_equal(expected, result) @@ -1501,7 +1526,9 @@ def test_colalign_multi(): def test_colalign_multi_with_sep_line(): "Output: string columns with custom colalign" result = tabulate( - [["one", "two"], SEPARATING_LINE, ["three", "four"]], colalign=("right",), tablefmt="plain" + [["one", "two"], SEPARATING_LINE, ["three", "four"]], + colalign=("right",), + tablefmt="plain", ) expected = " one two\n\nthree four" assert_equal(expected, result) @@ -1682,12 +1709,7 @@ def test_list_of_lists_with_index(): # keys' order (hence columns' order) is not deterministic in Python 3 # => we have to consider both possible results as valid expected = "\n".join( - [" a b", - "-- --- ---", - " 0 0 101", - " 1 1 102", - " 2 2 103" - ] + [" a b", "-- --- ---", " 0 0 101", " 1 1 102", " 2 2 103"] ) result = tabulate(dd, headers=["a", "b"], showindex=True) assert_equal(result, expected) @@ -1699,13 +1721,14 @@ def test_list_of_lists_with_index_with_sep_line(): # keys' order (hence columns' order) is not deterministic in Python 3 # => we have to consider both possible results as valid expected = "\n".join( - [" a b", - "-- --- ---", - " 0 0 101", - "-- --- ---", - " 1 1 102", - " 2 2 103" - ] + [ + " a b", + "-- --- ---", + " 0 0 101", + "-- --- ---", + " 1 1 102", + " 2 2 103", + ] ) result = tabulate(dd, headers=["a", "b"], showindex=True) assert_equal(result, expected) From cd32c62c187f6fa3697e0e5882b8784ba6b6b0c9 Mon Sep 17 00:00:00 2001 From: Kian-Meng Ang Date: Fri, 28 Jan 2022 08:25:52 +0800 Subject: [PATCH 074/207] Fix typos --- CHANGELOG | 2 +- README.md | 2 +- setup.py | 2 +- test/test_textwrapper.py | 12 ++++++------ 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 8f7e97c..a965188 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -11,7 +11,7 @@ - 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 colum alignment. 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. diff --git a/README.md b/README.md index 5c4b62a..182c407 100644 --- a/README.md +++ b/README.md @@ -649,7 +649,7 @@ To assign the same max width for all columns, a singular int scaler can be used. Use `None` for any columns where an explicit maximum does not need to be provided, and thus no automate multiline wrapping will take place. -The wraping uses the python standard [textwrap.wrap](https://docs.python.org/3/library/textwrap.html#textwrap.wrap) +The wrapping uses the python standard [textwrap.wrap](https://docs.python.org/3/library/textwrap.html#textwrap.wrap) function with default parameters - aside from width. This example demonstrates usagage of automatic multiline wrapping, though typically diff --git a/setup.py b/setup.py index 00be096..0a4e176 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ import os import re -# strip links from the descripton on the PyPI +# strip links from the description on the PyPI if python_version_tuple()[0] >= "3": LONG_DESCRIPTION = open("README.md", "r", encoding="utf-8").read() else: diff --git a/test/test_textwrapper.py b/test/test_textwrapper.py index e8e1c09..23b3938 100644 --- a/test/test_textwrapper.py +++ b/test/test_textwrapper.py @@ -11,7 +11,7 @@ def test_wrap_multiword_non_wide(): """TextWrapper: non-wide character regression tests""" - data = "this is a test string for regression spiltting" + data = "this is a test string for regression splitting" for width in range(1, len(data)): orig = OTW(width=width) cust = CTW(width=width) @@ -22,7 +22,7 @@ def test_wrap_multiword_non_wide(): def test_wrap_multiword_non_wide_with_hypens(): - """TextWrapper: non-wide character regression tests that contain hypens""" + """TextWrapper: non-wide character regression tests that contain hyphens""" data = "how should-we-split-this non-sense string that-has-lots-of-hypens" for width in range(1, len(data)): orig = OTW(width=width) @@ -46,7 +46,7 @@ def test_wrap_longword_non_wide(): def test_wrap_wide_char_multiword(): - """TextWrapper: wrapping support for wide characters with mulitple words""" + """TextWrapper: wrapping support for wide characters with multiple words""" try: import wcwidth # noqa except ImportError: @@ -87,14 +87,14 @@ def test_wrap_mixed_string(): data = ( "This content of this string (この文字列のこの内容) contains " - "mulitple character types (複数の文字タイプが含まれています)" + "multiple character types (複数の文字タイプが含まれています)" ) expected = [ "This content of this", "string (この文字列の", "この内容) contains", - "mulitple character", + "multiple character", "types (複数の文字タイ", "プが含まれています)", ] @@ -128,7 +128,7 @@ def test_wrap_full_line_color(): def test_wrap_color_in_single_line(): """TextWrapper: Wrap a line - preserve internal color tags, and don't - propogate them to other lines when they don't need to be""" + propagate them to other lines when they don't need to be""" # This has both a text color and a background color data = "This is a test string for testing \033[31mTextWrap\033[0m with colors" From f966d08ce46e3ed6e40aa1bc53a1c95dba0dad72 Mon Sep 17 00:00:00 2001 From: KOLANICH Date: Fri, 20 May 2022 13:22:03 +0300 Subject: [PATCH 075/207] Move the metadata into `setup.cfg`. Add `pyproject.toml`. Fetch version from git tags using `setuptools_scm`. --- .gitignore | 2 + HOWTOPUBLISH | 5 +-- pyproject.toml | 6 +++ setup.cfg | 33 ++++++++++++++ setup.py | 68 ----------------------------- tabulate.py => tabulate/__init__.py | 2 +- 6 files changed, 44 insertions(+), 72 deletions(-) create mode 100644 pyproject.toml create mode 100644 setup.cfg delete mode 100644 setup.py rename tabulate.py => tabulate/__init__.py (99%) diff --git a/.gitignore b/.gitignore index 460933e..0495ac7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +/tabulate/version.py + build dist .tox diff --git a/HOWTOPUBLISH b/HOWTOPUBLISH index 6730e8a..6be79c0 100644 --- a/HOWTOPUBLISH +++ b/HOWTOPUBLISH @@ -1,8 +1,7 @@ # update contributors and CHANGELOG in README +# tag version release python3 benchmark.py # then update README tox -e py33,py34,py36-extra -python3 setup.py sdist bdist_wheel +python3 -m build -nswx . twine upload --repository-url https://test.pypi.org/legacy/ dist/* twine upload dist/* -# tag version release -# bump version number in setup.py in tabulate.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..66f9550 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,6 @@ +[build-system] +requires = ["setuptools>=42.2", "wheel", "setuptools_scm[toml]>=3.4.3"] +build-backend = "setuptools.build_meta" + +[tool.setuptools_scm] +write_to = "tabulate/version.py" diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..4c5b66b --- /dev/null +++ b/setup.cfg @@ -0,0 +1,33 @@ +[metadata] +name = tabulate +author = Sergey Astanin +author_email = s.astanin@gmail.com +license = MIT +description = Pretty-print tabular data +url = https://github.com/astanin/python-tabulate +long_description = file: README.md +long_description_content_type = text/markdown +classifiers = + Development Status :: 4 - Beta + License :: OSI Approved :: MIT License + Operating System :: OS Independent + Programming Language :: Python :: 2 + Programming Language :: Python :: 2.7 + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.5 + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 + Topic :: Software Development :: Libraries + +[options] +packages = tabulate +python_requires = >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.* + +[options.entry_points] +console_scripts = tabulate = tabulate:_main + +[options.extras_require] +widechars = wcwidth diff --git a/setup.py b/setup.py deleted file mode 100644 index 00be096..0000000 --- a/setup.py +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env python - -try: - from setuptools import setup -except ImportError: - from distutils.core import setup - - -from platform import python_version_tuple, python_implementation -import os -import re - -# strip links from the descripton on the PyPI -if python_version_tuple()[0] >= "3": - LONG_DESCRIPTION = open("README.md", "r", encoding="utf-8").read() -else: - LONG_DESCRIPTION = open("README.md", "r").read() - -# strip Build Status from the PyPI package -try: - if python_version_tuple()[:2] >= ("2", "7"): - status_re = "^Build status\n(.*\n){7}" - LONG_DESCRIPTION = re.sub(status_re, "", LONG_DESCRIPTION, flags=re.M) -except TypeError: - if python_implementation() == "IronPython": - # IronPython doesn't support flags in re.sub (IronPython issue #923) - pass - else: - raise - -install_options = os.environ.get("TABULATE_INSTALL", "").split(",") -libonly_flags = set(["lib-only", "libonly", "no-cli", "without-cli"]) -if libonly_flags.intersection(install_options): - console_scripts = [] -else: - console_scripts = ["tabulate = tabulate:_main"] - - -setup( - name="tabulate", - version="0.8.10", - description="Pretty-print tabular data", - long_description=LONG_DESCRIPTION, - long_description_content_type="text/markdown", - author="Sergey Astanin", - author_email="s.astanin@gmail.com", - url="https://github.com/astanin/python-tabulate", - license="MIT", - python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*", - classifiers=[ - "Development Status :: 4 - Beta", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - "Programming Language :: Python :: 2", - "Programming Language :: Python :: 2.7", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.5", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Topic :: Software Development :: Libraries", - ], - py_modules=["tabulate"], - entry_points={"console_scripts": console_scripts}, - extras_require={"widechars": ["wcwidth"]}, -) diff --git a/tabulate.py b/tabulate/__init__.py similarity index 99% rename from tabulate.py rename to tabulate/__init__.py index 0579fb3..9b5363d 100644 --- a/tabulate.py +++ b/tabulate/__init__.py @@ -63,7 +63,7 @@ def _is_file(f): __all__ = ["tabulate", "tabulate_formats", "simple_separated_format"] -__version__ = "0.8.10" +from .version import version as __version__ # minimum extra space in headers From f809d36e0db2a8d8948f4e26638c926132f53557 Mon Sep 17 00:00:00 2001 From: KOLANICH Date: Fri, 20 May 2022 14:23:26 +0300 Subject: [PATCH 076/207] Migrated the metadata into `PEP 621`-compliant `pyproject.toml`. --- pyproject.toml | 38 +++++++++++++++++++++++++++++++++++++- setup.cfg | 33 --------------------------------- 2 files changed, 37 insertions(+), 34 deletions(-) delete mode 100644 setup.cfg diff --git a/pyproject.toml b/pyproject.toml index 66f9550..cebd23c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,42 @@ [build-system] -requires = ["setuptools>=42.2", "wheel", "setuptools_scm[toml]>=3.4.3"] +requires = ["setuptools>=61.2.0", "wheel", "setuptools_scm[toml]>=3.4.3"] build-backend = "setuptools.build_meta" +[project] +name = "tabulate" +authors = [{name = "Sergey Astanin", email = "s.astanin@gmail.com"}] +license = {text = "MIT"} +description = "Pretty-print tabular data" +readme = "README.md" +classifiers = [ + "Development Status :: 4 - Beta", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 2", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Topic :: Software Development :: Libraries", +] +requires-python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +dynamic = ["version"] + +[project.urls] +Homepage = "https://github.com/astanin/python-tabulate" + +[project.optional-dependencies] +widechars = ["wcwidth"] + +[project.scripts] +tabulate = "tabulate:_main" + +[tool.setuptools] +packages = ["tabulate"] + [tool.setuptools_scm] write_to = "tabulate/version.py" diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 4c5b66b..0000000 --- a/setup.cfg +++ /dev/null @@ -1,33 +0,0 @@ -[metadata] -name = tabulate -author = Sergey Astanin -author_email = s.astanin@gmail.com -license = MIT -description = Pretty-print tabular data -url = https://github.com/astanin/python-tabulate -long_description = file: README.md -long_description_content_type = text/markdown -classifiers = - Development Status :: 4 - Beta - License :: OSI Approved :: MIT License - Operating System :: OS Independent - Programming Language :: Python :: 2 - Programming Language :: Python :: 2.7 - Programming Language :: Python :: 3 - Programming Language :: Python :: 3.5 - Programming Language :: Python :: 3.6 - Programming Language :: Python :: 3.7 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Programming Language :: Python :: 3.10 - Topic :: Software Development :: Libraries - -[options] -packages = tabulate -python_requires = >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.* - -[options.entry_points] -console_scripts = tabulate = tabulate:_main - -[options.extras_require] -widechars = wcwidth From 9d32480b13d39546b51f2fc5f848a644abb62f2d Mon Sep 17 00:00:00 2001 From: KOLANICH Date: Fri, 20 May 2022 14:21:17 +0300 Subject: [PATCH 077/207] Enabled building and storing artifacts on CircleCI --- .circleci/config.yml | 10 ++++++++++ .circleci/requirements.txt | 5 +++++ 2 files changed, 15 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index bc19353..546bb26 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -39,6 +39,16 @@ jobs: - ./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: | diff --git a/.circleci/requirements.txt b/.circleci/requirements.txt index 650d583..ea5e80c 100644 --- a/.circleci/requirements.txt +++ b/.circleci/requirements.txt @@ -3,3 +3,8 @@ tox numpy pandas wcwidth +setuptools +pip +build +wheel +setuptools_scm From 85b26ac2c9028d25df1bd3195e9a0883c7d22517 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Tue, 21 Jun 2022 15:22:23 +0200 Subject: [PATCH 078/207] reformat with black --- tabulate.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tabulate.py b/tabulate.py index 4afcd0d..5e153b5 100644 --- a/tabulate.py +++ b/tabulate.py @@ -31,7 +31,6 @@ def _is_file(f): return hasattr(f, "read") - else: from itertools import zip_longest as izip_longest from functools import reduce, partial From 4892c6e9a79638c7897ccea68b602040da9cc7a7 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Tue, 21 Jun 2022 15:23:09 +0200 Subject: [PATCH 079/207] update README and CHANGELOG for 0.8.10 --- CHANGELOG | 3 ++- README.md | 27 ++++++++++++++------------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 8f7e97c..ddea671 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,4 +1,5 @@ -- 0.8.10: Future version +- 0.8.11: Future version +- 0.8.10: Python 3.10 support. Bug fixes. - 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``. diff --git a/README.md b/README.md index 5c4b62a..7378a76 100644 --- a/README.md +++ b/README.md @@ -706,19 +706,19 @@ 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.8.3 in Windows 10 x64: +was run in Python 3.8.2 in Ubuntu 20.04: - ================================= ========== =========== - Table formatter time, μs rel. time - ================================= ========== =========== - csv to StringIO 12.5 1.0 - join with tabs and newlines 15.6 1.3 - asciitable (0.8.0) 191.4 15.4 - tabulate (0.8.9) 472.8 38.0 - tabulate (0.8.9, WIDE_CHARS_MODE) 789.6 63.4 - PrettyTable (0.7.2) 879.1 70.6 - texttable (1.6.2) 1352.2 108.6 - ================================= ========== =========== + ================================== ========== =========== + Table formatter time, μs rel. time + ================================== ========== =========== + csv to StringIO 9.0 1.0 + join with tabs and newlines 10.7 1.2 + asciitable (0.8.0) 174.6 19.4 + tabulate (0.8.10) 385.0 42.8 + tabulate (0.8.10, WIDE_CHARS_MODE) 509.1 56.5 + PrettyTable (3.3.0) 827.7 91.9 + texttable (1.6.4) 952.1 105.7 + ================================== ========== =========== Version history @@ -785,4 +785,5 @@ Wes Turner, Andrew Tija, Marco Gorelli, Sean McGinnis, danja100, 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. +Bart Broere, Vilhelm Prytz, Alexander Gažo, Hugo van Kemenade, +jamescooke, Matt Warner. From 7efa1a6f4554e318ab32c352001c177eabcb76fb Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Tue, 21 Jun 2022 18:18:45 +0200 Subject: [PATCH 080/207] disable linting in circleci builds --- .circleci/config.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index bc19353..1904e2a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -45,12 +45,6 @@ jobs: . venv/bin/activate tox -e py38-extra - - run: - name: run linting - command: | - . venv/bin/activate - tox -e lint - - store_artifacts: path: test-reports destination: test-reports From 7ae0b1c85567fdc389c8636f95bffc9049748199 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Tue, 21 Jun 2022 18:22:19 +0200 Subject: [PATCH 081/207] add Python 3.9 and Python 3.10 to appveyor matrix --- appveyor.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/appveyor.yml b/appveyor.yml index 0055e96..8145246 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -12,11 +12,15 @@ environment: - PYTHON: "C:\\Python36" - PYTHON: "C:\\Python37" - PYTHON: "C:\\Python38" + - PYTHON: "C:\\Python39" + - PYTHON: "C:\\Python310" - PYTHON: "C:\\Python27-x64" - PYTHON: "C:\\Python35-x64" - PYTHON: "C:\\Python36-x64" - PYTHON: "C:\\Python37-x64" - PYTHON: "C:\\Python38-x64" + - PYTHON: "C:\\Python39-x64" + - PYTHON: "C:\\Python310-x64" install: # We need wheel installed to build wheels From dc9490b91fc74244a5e13286ba724cf63edd06af Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Tue, 21 Jun 2022 18:29:10 +0200 Subject: [PATCH 082/207] version bump to 0.8.11 --- setup.py | 2 +- tabulate.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 00be096..df033a1 100644 --- a/setup.py +++ b/setup.py @@ -38,7 +38,7 @@ setup( name="tabulate", - version="0.8.10", + version="0.8.11", description="Pretty-print tabular data", long_description=LONG_DESCRIPTION, long_description_content_type="text/markdown", diff --git a/tabulate.py b/tabulate.py index 5e153b5..a0b2dc2 100644 --- a/tabulate.py +++ b/tabulate.py @@ -62,7 +62,7 @@ def _is_file(f): __all__ = ["tabulate", "tabulate_formats", "simple_separated_format"] -__version__ = "0.8.10" +__version__ = "0.8.11" # minimum extra space in headers From 792bfdd1557d99fda5bc99453ccb3b96889083a1 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Tue, 21 Jun 2022 18:31:53 +0200 Subject: [PATCH 083/207] Revert "add Python 3.9 and Python 3.10 to appveyor matrix" This reverts commit 7ae0b1c85567fdc389c8636f95bffc9049748199. --- appveyor.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/appveyor.yml b/appveyor.yml index 8145246..0055e96 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -12,15 +12,11 @@ environment: - PYTHON: "C:\\Python36" - PYTHON: "C:\\Python37" - PYTHON: "C:\\Python38" - - PYTHON: "C:\\Python39" - - PYTHON: "C:\\Python310" - PYTHON: "C:\\Python27-x64" - PYTHON: "C:\\Python35-x64" - PYTHON: "C:\\Python36-x64" - PYTHON: "C:\\Python37-x64" - PYTHON: "C:\\Python38-x64" - - PYTHON: "C:\\Python39-x64" - - PYTHON: "C:\\Python310-x64" install: # We need wheel installed to build wheels From 24ce942b8a588e1152497f321c8e5a9f28bb7b99 Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Thu, 14 Oct 2021 14:03:19 +0400 Subject: [PATCH 084/207] Add new {simple,rounded,double}_{grid,outline} formats --- README.md | 105 ++++++- tabulate.py | 176 +++++++++++- test/test_output.py | 688 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 967 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7378a76..37fbdbe 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,15 @@ Supported table formats are: - "simple" - "github" - "grid" +- "simple\_grid" +- "rounded\_grid" +- "double\_grid" - "fancy\_grid" +- "outline" +- "simple\_outline" +- "rounded\_outline" +- "double\_outline" +- "fancy\_outline" - "pipe" - "orgtbl" - "jira" @@ -203,7 +211,47 @@ corresponds to the `pipe` format without alignment colons: | bacon | 0 | +--------+-------+ -`fancy_grid` draws a grid using box-drawing characters: +`simple_grid` draws a grid using single-line box-drawing characters: + + >>> print(tabulate(table, headers, tablefmt="simple_grid")) + ┌────────┬───────┐ + │ item │ qty │ + ├────────┼───────┤ + │ spam │ 42 │ + ├────────┼───────┤ + │ eggs │ 451 │ + ├────────┼───────┤ + │ bacon │ 0 │ + └────────┴───────┘ + +`rounded_grid` draws a grid using single-line box-drawing characters with rounded corners: + + >>> print(tabulate(table, headers, tablefmt="rounded_grid")) + ╭────────┬───────╮ + │ item │ qty │ + ├────────┼───────┤ + │ spam │ 42 │ + ├────────┼───────┤ + │ eggs │ 451 │ + ├────────┼───────┤ + │ bacon │ 0 │ + ╰────────┴───────╯ + +`double_grid` draws a grid using double-line box-drawing characters: + + >>> print(tabulate(table, headers, tablefmt="double_grid")) + ╔════════╦═══════╗ + ║ item ║ qty ║ + ╠════════╬═══════╣ + ║ spam ║ 42 ║ + ╠════════╬═══════╣ + ║ eggs ║ 451 ║ + ╠════════╬═══════╣ + ║ bacon ║ 0 ║ + ╚════════╩═══════╝ + +`fancy_grid` draws a grid using a mix of single and + double-line box-drawing characters: >>> print(tabulate(table, headers, tablefmt="fancy_grid")) ╒════════╤═══════╕ @@ -216,6 +264,61 @@ corresponds to the `pipe` format without alignment colons: │ bacon │ 0 │ ╘════════╧═══════╛ +`outline` is the same as the `grid` format but doesn't draw lines between rows: + + >>> print(tabulate(table, headers, tablefmt="outline")) + +--------+-------+ + | item | qty | + +========+=======+ + | spam | 42 | + | eggs | 451 | + | bacon | 0 | + +--------+-------+ + +`simple_outline` is the same as the `simple_grid` format but doesn't draw lines between rows: + + >>> print(tabulate(table, headers, tablefmt="simple_outline")) + ┌────────┬───────┐ + │ item │ qty │ + ├────────┼───────┤ + │ spam │ 42 │ + │ eggs │ 451 │ + │ bacon │ 0 │ + └────────┴───────┘ + +`rounded_outline` is the same as the `rounded_grid` format but doesn't draw lines between rows: + + >>> print(tabulate(table, headers, tablefmt="rounded_outline")) + ╭────────┬───────╮ + │ item │ qty │ + ├────────┼───────┤ + │ spam │ 42 │ + │ eggs │ 451 │ + │ bacon │ 0 │ + ╰────────┴───────╯ + +`double_outline` is the same as the `double_grid` format but doesn't draw lines between rows: + + >>> print(tabulate(table, headers, tablefmt="double_outline")) + ╔════════╦═══════╗ + ║ item ║ qty ║ + ╠════════╬═══════╣ + ║ spam ║ 42 ║ + ║ eggs ║ 451 ║ + ║ bacon ║ 0 ║ + ╚════════╩═══════╝ + +`fancy_outline` is the same as the `fancy_grid` format but doesn't draw lines between rows: + + >>> print(tabulate(table, headers, tablefmt="fancy_outline")) + ╒════════╤═══════╕ + │ item │ qty │ + ╞════════╪═══════╡ + │ spam │ 42 │ + │ eggs │ 451 │ + │ bacon │ 0 │ + ╘════════╧═══════╛ + `presto` is like tables formatted by Presto cli: >>> print(tabulate(table, headers, tablefmt="presto")) diff --git a/tabulate.py b/tabulate.py index a0b2dc2..f23a1a8 100644 --- a/tabulate.py +++ b/tabulate.py @@ -310,6 +310,36 @@ def escape_empty(val): 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, + ), + "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("╞", "═", "╪", "╡"), @@ -320,6 +350,46 @@ def escape_empty(val): 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, + ), + "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("╞", "═", "╪", "╡"), @@ -537,6 +607,9 @@ def escape_empty(val): "plain": "plain", "simple": "simple", "grid": "grid", + "simple_grid": "simple_grid", + "rounded_grid": "rounded_grid", + "double_grid": "double_grid", "fancy_grid": "fancy_grid", "pipe": "pipe", "orgtbl": "orgtbl", @@ -1425,7 +1498,47 @@ def tabulate( | eggs | 451 | +------+----------+ - "fancy_grid" draws a grid using box-drawing characters: + "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 │ + ╰───────────┴───────────╯ + + "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")) @@ -1437,6 +1550,67 @@ def tabulate( │ 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 │ + ╰───────────┴───────────╯ + + "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: diff --git a/test/test_output.py b/test/test_output.py index eed4489..ef27a91 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -478,6 +478,411 @@ def test_grid_multiline_with_empty_cells_headerless(): assert_equal(expected, result) +def test_simple_grid(): + "Output: simple_grid with headers" + expected = "\n".join( + [ + "┌───────────┬───────────┐", + "│ strings │ numbers │", + "├───────────┼───────────┤", + "│ spam │ 41.9999 │", + "├───────────┼───────────┤", + "│ eggs │ 451 │", + "└───────────┴───────────┘", + ] + ) + result = tabulate(_test_table, _test_table_headers, tablefmt="simple_grid") + assert_equal(expected, result) + + +def test_simple_grid_wide_characters(): + "Output: simple_grid with wide characters in headers" + try: + import wcwidth # noqa + except ImportError: + skip("test_simple_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="simple_grid") + assert_equal(expected, result) + + +def test_simple_grid_headerless(): + "Output: simple_grid without headers" + expected = "\n".join( + [ + "┌──────┬──────────┐", + "│ spam │ 41.9999 │", + "├──────┼──────────┤", + "│ eggs │ 451 │", + "└──────┴──────────┘", + ] + ) + result = tabulate(_test_table, tablefmt="simple_grid") + assert_equal(expected, result) + + +def test_simple_grid_multiline_headerless(): + "Output: simple_grid with multiline cells without headers" + table = [["foo bar\nbaz\nbau", "hello"], ["", "multiline\nworld"]] + expected = "\n".join( + [ + "┌─────────┬───────────┐", + "│ foo bar │ hello │", + "│ baz │ │", + "│ bau │ │", + "├─────────┼───────────┤", + "│ │ multiline │", + "│ │ world │", + "└─────────┴───────────┘", + ] + ) + result = tabulate(table, stralign="center", tablefmt="simple_grid") + assert_equal(expected, result) + + +def test_simple_grid_multiline(): + "Output: simple_grid with multiline cells with headers" + table = [[2, "foo\nbar"]] + headers = ("more\nspam \x1b[31meggs\x1b[0m", "more spam\n& eggs") + expected = "\n".join( + [ + "┌─────────────┬─────────────┐", + "│ more │ more spam │", + "│ spam \x1b[31meggs\x1b[0m │ & eggs │", + "├─────────────┼─────────────┤", + "│ 2 │ foo │", + "│ │ bar │", + "└─────────────┴─────────────┘", + ] + ) + result = tabulate(table, headers, tablefmt="simple_grid") + assert_equal(expected, result) + + +def test_simple_grid_multiline_with_empty_cells(): + "Output: simple_grid with multiline cells and empty cells with headers" + table = [ + ["hdr", "data", "fold"], + ["1", "", ""], + ["2", "very long data", "fold\nthis"], + ] + expected = "\n".join( + [ + "┌───────┬────────────────┬────────┐", + "│ hdr │ data │ fold │", + "├───────┼────────────────┼────────┤", + "│ 1 │ │ │", + "├───────┼────────────────┼────────┤", + "│ 2 │ very long data │ fold │", + "│ │ │ this │", + "└───────┴────────────────┴────────┘", + ] + ) + result = tabulate(table, headers="firstrow", tablefmt="simple_grid") + assert_equal(expected, result) + + +def test_simple_grid_multiline_with_empty_cells_headerless(): + "Output: simple_grid with multiline cells and empty cells without headers" + table = [["0", "", ""], ["1", "", ""], ["2", "very long data", "fold\nthis"]] + expected = "\n".join( + [ + "┌───┬────────────────┬──────┐", + "│ 0 │ │ │", + "├───┼────────────────┼──────┤", + "│ 1 │ │ │", + "├───┼────────────────┼──────┤", + "│ 2 │ very long data │ fold │", + "│ │ │ this │", + "└───┴────────────────┴──────┘", + ] + ) + result = tabulate(table, tablefmt="simple_grid") + assert_equal(expected, result) + + +def test_rounded_grid(): + "Output: rounded_grid with headers" + expected = "\n".join( + [ + "╭───────────┬───────────╮", + "│ strings │ numbers │", + "├───────────┼───────────┤", + "│ spam │ 41.9999 │", + "├───────────┼───────────┤", + "│ eggs │ 451 │", + "╰───────────┴───────────╯", + ] + ) + result = tabulate(_test_table, _test_table_headers, tablefmt="rounded_grid") + assert_equal(expected, result) + + +def test_rounded_grid_wide_characters(): + "Output: rounded_grid with wide characters in headers" + try: + import wcwidth # noqa + except ImportError: + skip("test_rounded_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="rounded_grid") + assert_equal(expected, result) + + +def test_rounded_grid_headerless(): + "Output: rounded_grid without headers" + expected = "\n".join( + [ + "╭──────┬──────────╮", + "│ spam │ 41.9999 │", + "├──────┼──────────┤", + "│ eggs │ 451 │", + "╰──────┴──────────╯", + ] + ) + result = tabulate(_test_table, tablefmt="rounded_grid") + assert_equal(expected, result) + + +def test_rounded_grid_multiline_headerless(): + "Output: rounded_grid with multiline cells without headers" + table = [["foo bar\nbaz\nbau", "hello"], ["", "multiline\nworld"]] + expected = "\n".join( + [ + "╭─────────┬───────────╮", + "│ foo bar │ hello │", + "│ baz │ │", + "│ bau │ │", + "├─────────┼───────────┤", + "│ │ multiline │", + "│ │ world │", + "╰─────────┴───────────╯", + ] + ) + result = tabulate(table, stralign="center", tablefmt="rounded_grid") + assert_equal(expected, result) + + +def test_rounded_grid_multiline(): + "Output: rounded_grid with multiline cells with headers" + table = [[2, "foo\nbar"]] + headers = ("more\nspam \x1b[31meggs\x1b[0m", "more spam\n& eggs") + expected = "\n".join( + [ + "╭─────────────┬─────────────╮", + "│ more │ more spam │", + "│ spam \x1b[31meggs\x1b[0m │ & eggs │", + "├─────────────┼─────────────┤", + "│ 2 │ foo │", + "│ │ bar │", + "╰─────────────┴─────────────╯", + ] + ) + result = tabulate(table, headers, tablefmt="rounded_grid") + assert_equal(expected, result) + + +def test_rounded_grid_multiline_with_empty_cells(): + "Output: rounded_grid with multiline cells and empty cells with headers" + table = [ + ["hdr", "data", "fold"], + ["1", "", ""], + ["2", "very long data", "fold\nthis"], + ] + expected = "\n".join( + [ + "╭───────┬────────────────┬────────╮", + "│ hdr │ data │ fold │", + "├───────┼────────────────┼────────┤", + "│ 1 │ │ │", + "├───────┼────────────────┼────────┤", + "│ 2 │ very long data │ fold │", + "│ │ │ this │", + "╰───────┴────────────────┴────────╯", + ] + ) + result = tabulate(table, headers="firstrow", tablefmt="rounded_grid") + assert_equal(expected, result) + + +def test_rounded_grid_multiline_with_empty_cells_headerless(): + "Output: rounded_grid with multiline cells and empty cells without headers" + table = [["0", "", ""], ["1", "", ""], ["2", "very long data", "fold\nthis"]] + expected = "\n".join( + [ + "╭───┬────────────────┬──────╮", + "│ 0 │ │ │", + "├───┼────────────────┼──────┤", + "│ 1 │ │ │", + "├───┼────────────────┼──────┤", + "│ 2 │ very long data │ fold │", + "│ │ │ this │", + "╰───┴────────────────┴──────╯", + ] + ) + result = tabulate(table, tablefmt="rounded_grid") + assert_equal(expected, result) + + +def test_double_grid(): + "Output: double_grid with headers" + expected = "\n".join( + [ + "╔═══════════╦═══════════╗", + "║ strings ║ numbers ║", + "╠═══════════╬═══════════╣", + "║ spam ║ 41.9999 ║", + "╠═══════════╬═══════════╣", + "║ eggs ║ 451 ║", + "╚═══════════╩═══════════╝", + ] + ) + result = tabulate(_test_table, _test_table_headers, tablefmt="double_grid") + assert_equal(expected, result) + + +def test_double_grid_wide_characters(): + "Output: double_grid with wide characters in headers" + try: + import wcwidth # noqa + except ImportError: + skip("test_double_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="double_grid") + assert_equal(expected, result) + + +def test_double_grid_headerless(): + "Output: double_grid without headers" + expected = "\n".join( + [ + "╔══════╦══════════╗", + "║ spam ║ 41.9999 ║", + "╠══════╬══════════╣", + "║ eggs ║ 451 ║", + "╚══════╩══════════╝", + ] + ) + result = tabulate(_test_table, tablefmt="double_grid") + assert_equal(expected, result) + + +def test_double_grid_multiline_headerless(): + "Output: double_grid with multiline cells without headers" + table = [["foo bar\nbaz\nbau", "hello"], ["", "multiline\nworld"]] + expected = "\n".join( + [ + "╔═════════╦═══════════╗", + "║ foo bar ║ hello ║", + "║ baz ║ ║", + "║ bau ║ ║", + "╠═════════╬═══════════╣", + "║ ║ multiline ║", + "║ ║ world ║", + "╚═════════╩═══════════╝", + ] + ) + result = tabulate(table, stralign="center", tablefmt="double_grid") + assert_equal(expected, result) + + +def test_double_grid_multiline(): + "Output: double_grid with multiline cells with headers" + table = [[2, "foo\nbar"]] + headers = ("more\nspam \x1b[31meggs\x1b[0m", "more spam\n& eggs") + expected = "\n".join( + [ + "╔═════════════╦═════════════╗", + "║ more ║ more spam ║", + "║ spam \x1b[31meggs\x1b[0m ║ & eggs ║", + "╠═════════════╬═════════════╣", + "║ 2 ║ foo ║", + "║ ║ bar ║", + "╚═════════════╩═════════════╝", + ] + ) + result = tabulate(table, headers, tablefmt="double_grid") + assert_equal(expected, result) + + +def test_double_grid_multiline_with_empty_cells(): + "Output: double_grid with multiline cells and empty cells with headers" + table = [ + ["hdr", "data", "fold"], + ["1", "", ""], + ["2", "very long data", "fold\nthis"], + ] + expected = "\n".join( + [ + "╔═══════╦════════════════╦════════╗", + "║ hdr ║ data ║ fold ║", + "╠═══════╬════════════════╬════════╣", + "║ 1 ║ ║ ║", + "╠═══════╬════════════════╬════════╣", + "║ 2 ║ very long data ║ fold ║", + "║ ║ ║ this ║", + "╚═══════╩════════════════╩════════╝", + ] + ) + result = tabulate(table, headers="firstrow", tablefmt="double_grid") + assert_equal(expected, result) + + +def test_double_grid_multiline_with_empty_cells_headerless(): + "Output: double_grid with multiline cells and empty cells without headers" + table = [["0", "", ""], ["1", "", ""], ["2", "very long data", "fold\nthis"]] + expected = "\n".join( + [ + "╔═══╦════════════════╦══════╗", + "║ 0 ║ ║ ║", + "╠═══╬════════════════╬══════╣", + "║ 1 ║ ║ ║", + "╠═══╬════════════════╬══════╣", + "║ 2 ║ very long data ║ fold ║", + "║ ║ ║ this ║", + "╚═══╩════════════════╩══════╝", + ] + ) + result = tabulate(table, tablefmt="double_grid") + assert_equal(expected, result) + + def test_fancy_grid(): "Output: fancy_grid with headers" expected = "\n".join( @@ -495,6 +900,29 @@ def test_fancy_grid(): assert_equal(expected, result) +def test_fancy_grid_wide_characters(): + "Output: fancy_grid with wide characters in headers" + try: + import wcwidth # noqa + except ImportError: + skip("test_fancy_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="fancy_grid") + assert_equal(expected, result) + + def test_fancy_grid_headerless(): "Output: fancy_grid without headers" expected = "\n".join( @@ -590,6 +1018,266 @@ def test_fancy_grid_multiline_with_empty_cells_headerless(): assert_equal(expected, result) +def test_outline(): + "Output: outline with headers" + expected = "\n".join( + [ + "+-----------+-----------+", + "| strings | numbers |", + "+===========+===========+", + "| spam | 41.9999 |", + "| eggs | 451 |", + "+-----------+-----------+", + ] + ) + result = tabulate(_test_table, _test_table_headers, tablefmt="outline") + assert_equal(expected, result) + + +def test_outline_wide_characters(): + "Output: outline with wide characters in headers" + try: + import wcwidth # noqa + except ImportError: + skip("test_outline_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="outline") + assert_equal(expected, result) + + +def test_outline_headerless(): + "Output: outline without headers" + expected = "\n".join( + [ + "+------+----------+", + "| spam | 41.9999 |", + "| eggs | 451 |", + "+------+----------+", + ] + ) + result = tabulate(_test_table, tablefmt="outline") + assert_equal(expected, result) + + +def test_simple_outline(): + "Output: simple_outline with headers" + expected = "\n".join( + [ + "┌───────────┬───────────┐", + "│ strings │ numbers │", + "├───────────┼───────────┤", + "│ spam │ 41.9999 │", + "│ eggs │ 451 │", + "└───────────┴───────────┘", + ] + ) + result = tabulate(_test_table, _test_table_headers, tablefmt="simple_outline") + assert_equal(expected, result) + + +def test_simple_outline_wide_characters(): + "Output: simple_outline with wide characters in headers" + try: + import wcwidth # noqa + except ImportError: + skip("test_simple_outline_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="simple_outline") + assert_equal(expected, result) + + +def test_simple_outline_headerless(): + "Output: simple_outline without headers" + expected = "\n".join( + [ + "┌──────┬──────────┐", + "│ spam │ 41.9999 │", + "│ eggs │ 451 │", + "└──────┴──────────┘", + ] + ) + result = tabulate(_test_table, tablefmt="simple_outline") + assert_equal(expected, result) + + +def test_rounded_outline(): + "Output: rounded_outline with headers" + expected = "\n".join( + [ + "╭───────────┬───────────╮", + "│ strings │ numbers │", + "├───────────┼───────────┤", + "│ spam │ 41.9999 │", + "│ eggs │ 451 │", + "╰───────────┴───────────╯", + ] + ) + result = tabulate(_test_table, _test_table_headers, tablefmt="rounded_outline") + assert_equal(expected, result) + + +def test_rounded_outline_wide_characters(): + "Output: rounded_outline with wide characters in headers" + try: + import wcwidth # noqa + except ImportError: + skip("test_rounded_outline_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="rounded_outline") + assert_equal(expected, result) + + +def test_rounded_outline_headerless(): + "Output: rounded_outline without headers" + expected = "\n".join( + [ + "╭──────┬──────────╮", + "│ spam │ 41.9999 │", + "│ eggs │ 451 │", + "╰──────┴──────────╯", + ] + ) + result = tabulate(_test_table, tablefmt="rounded_outline") + assert_equal(expected, result) + + +def test_double_outline(): + "Output: double_outline with headers" + expected = "\n".join( + [ + "╔═══════════╦═══════════╗", + "║ strings ║ numbers ║", + "╠═══════════╬═══════════╣", + "║ spam ║ 41.9999 ║", + "║ eggs ║ 451 ║", + "╚═══════════╩═══════════╝", + ] + ) + result = tabulate(_test_table, _test_table_headers, tablefmt="double_outline") + assert_equal(expected, result) + + +def test_double_outline_wide_characters(): + "Output: double_outline with wide characters in headers" + try: + import wcwidth # noqa + except ImportError: + skip("test_double_outline_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="double_outline") + assert_equal(expected, result) + + +def test_double_outline_headerless(): + "Output: double_outline without headers" + expected = "\n".join( + [ + "╔══════╦══════════╗", + "║ spam ║ 41.9999 ║", + "║ eggs ║ 451 ║", + "╚══════╩══════════╝", + ] + ) + result = tabulate(_test_table, tablefmt="double_outline") + assert_equal(expected, result) + + +def test_fancy_outline(): + "Output: fancy_outline with headers" + expected = "\n".join( + [ + "╒═══════════╤═══════════╕", + "│ strings │ numbers │", + "╞═══════════╪═══════════╡", + "│ spam │ 41.9999 │", + "│ eggs │ 451 │", + "╘═══════════╧═══════════╛", + ] + ) + result = tabulate(_test_table, _test_table_headers, tablefmt="fancy_outline") + assert_equal(expected, result) + + +def test_fancy_outline_wide_characters(): + "Output: fancy_outline with wide characters in headers" + try: + import wcwidth # noqa + except ImportError: + skip("test_fancy_outline_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="fancy_outline") + assert_equal(expected, result) + + +def test_fancy_outline_headerless(): + "Output: fancy_outline without headers" + expected = "\n".join( + [ + "╒══════╤══════════╕", + "│ spam │ 41.9999 │", + "│ eggs │ 451 │", + "╘══════╧══════════╛", + ] + ) + result = tabulate(_test_table, tablefmt="fancy_outline") + assert_equal(expected, result) + + def test_pipe(): "Output: pipe with headers" expected = "\n".join( From d8810bd366050e2cb23046eeb4df1108b5bbf11c Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 22 Jun 2022 09:53:41 +0200 Subject: [PATCH 085/207] fix README spelling as in PR#166 --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 37fbdbe..8ea7d21 100644 --- a/README.md +++ b/README.md @@ -752,10 +752,10 @@ To assign the same max width for all columns, a singular int scaler can be used. Use `None` for any columns where an explicit maximum does not need to be provided, and thus no automate multiline wrapping will take place. -The wraping uses the python standard [textwrap.wrap](https://docs.python.org/3/library/textwrap.html#textwrap.wrap) +The wrapping uses the python standard [textwrap.wrap](https://docs.python.org/3/library/textwrap.html#textwrap.wrap) function with default parameters - aside from width. -This example demonstrates usagage of automatic multiline wrapping, though typically +This example demonstrates usage of automatic multiline wrapping, though typically the lines being wrapped would probably be significantly longer than this. >>> print(tabulate([["John Smith", "Middle Manager"]], headers=["Name", "Title"], tablefmt="grid", maxcolwidths=[None, 8])) From 46307a531dc616fc6af75628f811727105339ca4 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 22 Jun 2022 10:36:33 +0200 Subject: [PATCH 086/207] update CHANGELOG (remove EOL versions) --- CHANGELOG | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 93ac37a..658e798 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,5 @@ -- 0.8.11: Future version -- 0.8.10: Python 3.10 support. Bug fixes. +- 0.8.11: Future version. Drop support for Python 2.7, 3.5, 3.6. New formats. Improve column width options. +- 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``. From 09a25218bf2161962bbea804a3b6fc120793dab7 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 22 Jun 2022 11:00:53 +0200 Subject: [PATCH 087/207] remove Python 2 mentions from the README file --- README.md | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 8ea7d21..26a4f37 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,7 @@ To install the Python library and the command line utility, run: The command line utility will be installed as `tabulate` to `bin` on Linux (e.g. `/usr/bin`); or as `tabulate.exe` to `Scripts` in your -Python installation on Windows (e.g. -`C:\Python27\Scripts\tabulate.exe`). +Python installation on Windows (e.g. `C:\Python37\Scripts\tabulate.exe`). You may consider installing the library only for the current user: @@ -845,21 +844,19 @@ 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 `python2.6`, -`python2.7`, `python3.4` etc. On Windows it looks for -`C:\Python26\python.exe`, `C:\Python27\python.exe` and -`C:\Python34\python.exe` respectively. +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. To test only some Python environments, use `-e` option. For example, to -test only against Python 2.7 and Python 3.8, run: +test only against Python 3.7 and Python 3.8, run: - tox -e py27,py38 + tox -e py37,py38 in the root of the project source tree. To enable NumPy and Pandas tests, run: - tox -e py27-extra,py38-extra + tox -e py37-extra,py38-extra (this may take a long time the first time, because NumPy and Pandas will have to be installed in the new virtual environments) From 9a5b032ff9a5f1cd534f077d4cdd2b666af227b2 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 22 Jun 2022 15:33:22 +0200 Subject: [PATCH 088/207] update black version .pre-commit-config.yaml --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5165ce5..7349858 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/python/black - rev: 21.12b0 + rev: 22.3.0 hooks: - id: black args: [--safe] From b3c2c2adc13b1950080e3beed6f90e78cd661f7b Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 22 Jun 2022 15:35:13 +0200 Subject: [PATCH 089/207] reformat with black --- tabulate.py | 5 +++-- test/test_output.py | 10 ++++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/tabulate.py b/tabulate.py index fc66838..8633a8d 100644 --- a/tabulate.py +++ b/tabulate.py @@ -1779,7 +1779,9 @@ def tabulate( 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) + maxheadercolwidths = _expand_iterable( + maxheadercolwidths, num_cols, maxheadercolwidths + ) else: # Ignore col width for any 'trailing' columns maxheadercolwidths = _expand_iterable(maxheadercolwidths, num_cols, None) @@ -1788,7 +1790,6 @@ def tabulate( [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": diff --git a/test/test_output.py b/test/test_output.py index eeb8cd8..c034084 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -196,15 +196,21 @@ def test_maxcolwidth_honor_disable_parsenum(): result = tabulate(table, tablefmt="grid", maxcolwidths=6, disable_numparse=[2]) assert_equal(expected, result) + def test_plain_maxheadercolwidths_autowraps(): "Output: maxheadercolwidths will result in autowrapping header cell" table = [["hdr", "fold"], ["1", "very long data"]] - expected = "\n".join([" hdr fo"," ld", " 1 very long", " data"]) + expected = "\n".join([" hdr fo", " ld", " 1 very long", " data"]) result = tabulate( - table, headers="firstrow", tablefmt="plain", maxcolwidths=[10, 10], maxheadercolwidths=[None, 2] + table, + headers="firstrow", + tablefmt="plain", + maxcolwidths=[10, 10], + maxheadercolwidths=[None, 2], ) assert_equal(expected, result) + def test_simple(): "Output: simple with headers" expected = "\n".join( From 38d345b4fad79ad3b8e19345e0d0c3bdd01ad132 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 22 Jun 2022 18:36:21 +0200 Subject: [PATCH 090/207] fix #175 - support infinite Iterable as row indices --- tabulate.py | 34 +++++++++++++++++++++++++--------- test/test_regression.py | 17 +++++++++++++++++ 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/tabulate.py b/tabulate.py index b928ae8..3aff5f9 100644 --- a/tabulate.py +++ b/tabulate.py @@ -1,7 +1,7 @@ """Pretty-print tabular data.""" from collections import namedtuple -from collections.abc import Iterable +from collections.abc import Iterable, Sized from html import escape as htmlescape from itertools import zip_longest as izip_longest from functools import reduce, partial @@ -1120,12 +1120,18 @@ def _prepend_row_index(rows, index): """Add a left-most index column.""" if index is None or index is False: return rows - if len(index) != len(rows): - print("index=", index) - print("rows=", rows) - raise ValueError("index must be as long as the number of data 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) - rows = [[v] + list(row) for v, row in zip(index, sans_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 @@ -1307,8 +1313,10 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): 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, Iterable) and not showindex_is_a_str: + 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))) @@ -2132,7 +2140,8 @@ def _format_table(fmt, headers, rows, colwidths, colaligns, is_multiline, rowali # test to see if either the 1st column or the 2nd column (account for showindex) has # the SEPARATING_LINE flag if row[0].strip() == SEPARATING_LINE or ( - len(row) > 1 and row[1].strip() == SEPARATING_LINE ): + len(row) > 1 and row[1].strip() == SEPARATING_LINE + ): _append_line(lines, padded_widths, colaligns, separating_line) else: append_row(lines, row, padded_widths, colaligns, fmt.datarow) @@ -2452,7 +2461,14 @@ def _pprint_file(fobject, headers, tablefmt, sep, floatfmt, intfmt, file, colali 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), + tabulate( + table, + headers, + tablefmt, + floatfmt=floatfmt, + intfmt=intfmt, + colalign=colalign, + ), file=file, ) diff --git a/test/test_regression.py b/test/test_regression.py index e07a584..22e544a 100644 --- a/test/test_regression.py +++ b/test/test_regression.py @@ -453,3 +453,20 @@ def test_string_with_comma_between_digits_without_floatfmt_grouping_option(): expected = "126,000" result = tabulate(table, tablefmt="plain") assert_equal(result, expected) # no exception + + +def test_iterable_row_index(): + "Regression: accept 'infinite' row indices (github issue #175)" + table = [["a"], ["b"], ["c"]] + + def count(start, step=1): + n = start + while True: + yield n + n += step + if n >= 10: # safety valve + raise IndexError("consuming too many values from the count iterator") + + expected = "1 a\n2 b\n3 c" + result = tabulate(table, showindex=count(1), tablefmt="plain") + assert_equal(result, expected) From 3f0757e117ed2ca1171bbf84b61793f353d67282 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 22 Jun 2022 18:57:16 +0200 Subject: [PATCH 091/207] fix FutureWarning about SEPARATING_LINE --- README.md | 2 +- tabulate.py | 18 ++++++++++++------ test/test_output.py | 3 ++- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 767b690..ba65384 100644 --- a/README.md +++ b/README.md @@ -984,7 +984,7 @@ All versions can then be easily installed with something like: Don't forget to change your `PATH` so that `tox` knows how to find all the installed versions. Something like - export PATH="${PATH}:${HOME}/.pyenv/shims" + 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: diff --git a/tabulate.py b/tabulate.py index 3aff5f9..a611658 100644 --- a/tabulate.py +++ b/tabulate.py @@ -98,6 +98,15 @@ def _is_file(f): ) +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).""" @@ -1100,8 +1109,7 @@ def _remove_separating_lines(rows): separating_lines = [] sans_rows = [] for index, row in enumerate(rows): - row_type = type(row) - if (row_type == list or row_type == str) and row[0] == SEPARATING_LINE: + if _is_separating_line(row): separating_lines.append(index) else: sans_rows.append(row) @@ -1307,7 +1315,7 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): headers = list(map(str, headers)) # rows = list(map(list, rows)) - rows = list(map(lambda r: r if r == SEPARATING_LINE else list(r), 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] @@ -2139,9 +2147,7 @@ def _format_table(fmt, headers, rows, colwidths, colaligns, is_multiline, rowali 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 row[0].strip() == SEPARATING_LINE or ( - len(row) > 1 and row[1].strip() == SEPARATING_LINE - ): + if _is_separating_line(row): _append_line(lines, padded_widths, colaligns, separating_line) else: append_row(lines, row, padded_widths, colaligns, fmt.datarow) diff --git a/test/test_output.py b/test/test_output.py index a9f6f6a..82ecfb7 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -109,6 +109,7 @@ def test_plain_maxcolwidth_autowraps(): ) assert_equal(expected, result) + def test_plain_maxcolwidth_autowraps_with_sep(): "Output: maxcolwidth will result in autowrapping longer cells and separating line" table = [ @@ -1142,7 +1143,7 @@ def test_fancy_grid_multiline_row_align(): result = tabulate(table, tablefmt="fancy_grid", rowalign=[None, "center", "bottom"]) assert_equal(expected, result) - + def test_outline(): "Output: outline with headers" expected = "\n".join( From 1309bdc2f81b5569824f106176ea28c52eec0ef1 Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Thu, 23 Jun 2022 11:13:49 +0400 Subject: [PATCH 092/207] Implements {heavy,mixed}_{grid,outline}. Closes #155. --- README.md | 52 ++++++ tabulate.py | 90 +++++++++++ test/test_output.py | 374 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 516 insertions(+) diff --git a/README.md b/README.md index ba65384..1475d75 100644 --- a/README.md +++ b/README.md @@ -157,11 +157,15 @@ Supported table formats are: - "grid" - "simple\_grid" - "rounded\_grid" +- "heavy\_grid" +- "mixed\_grid" - "double\_grid" - "fancy\_grid" - "outline" - "simple\_outline" - "rounded\_outline" +- "heavy\_outline" +- "mixed\_outline" - "double\_outline" - "fancy\_outline" - "pipe" @@ -263,6 +267,32 @@ corresponds to the `pipe` format without alignment colons: │ bacon │ 0 │ ╰────────┴───────╯ +`heavy_grid` draws a grid using bold (thick) single-line box-drawing characters: + + >>> print(tabulate(table, headers, tablefmt="heavy_grid")) + ┏━━━━━━━━┳━━━━━━━┓ + ┃ item ┃ qty ┃ + ┣━━━━━━━━╋━━━━━━━┫ + ┃ spam ┃ 42 ┃ + ┣━━━━━━━━╋━━━━━━━┫ + ┃ eggs ┃ 451 ┃ + ┣━━━━━━━━╋━━━━━━━┫ + ┃ bacon ┃ 0 ┃ + ┗━━━━━━━━┻━━━━━━━┛ + +`mixed_grid` draws a grid using a mix of light (thin) and heavy (thick) lines box-drawing characters: + + >>> print(tabulate(table, headers, tablefmt="mixed_grid")) + ┍━━━━━━━━┯━━━━━━━┑ + │ item │ qty │ + ┝━━━━━━━━┿━━━━━━━┥ + │ spam │ 42 │ + ├────────┼───────┤ + │ eggs │ 451 │ + ├────────┼───────┤ + │ bacon │ 0 │ + ┕━━━━━━━━┷━━━━━━━┙ + `double_grid` draws a grid using double-line box-drawing characters: >>> print(tabulate(table, headers, tablefmt="double_grid")) @@ -325,6 +355,28 @@ corresponds to the `pipe` format without alignment colons: │ bacon │ 0 │ ╰────────┴───────╯ +`heavy_outline` is the same as the `heavy_grid` format but doesn't draw lines between rows: + + >>> print(tabulate(table, headers, tablefmt="heavy_outline")) + ┏━━━━━━━━┳━━━━━━━┓ + ┃ item ┃ qty ┃ + ┣━━━━━━━━╋━━━━━━━┫ + ┃ spam ┃ 42 ┃ + ┃ eggs ┃ 451 ┃ + ┃ bacon ┃ 0 ┃ + ┗━━━━━━━━┻━━━━━━━┛ + +`mixed_outline` is the same as the `mixed_grid` format but doesn't draw lines between rows: + + >>> print(tabulate(table, headers, tablefmt="mixed_outline")) + ┍━━━━━━━━┯━━━━━━━┑ + │ item │ qty │ + ┝━━━━━━━━┿━━━━━━━┥ + │ spam │ 42 │ + │ eggs │ 451 │ + │ bacon │ 0 │ + ┕━━━━━━━━┷━━━━━━━┙ + `double_outline` is the same as the `double_grid` format but doesn't draw lines between rows: >>> print(tabulate(table, headers, tablefmt="double_outline")) diff --git a/tabulate.py b/tabulate.py index a611658..ff61339 100644 --- a/tabulate.py +++ b/tabulate.py @@ -303,6 +303,26 @@ def escape_empty(val): 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("╠", "═", "╬", "╣"), @@ -353,6 +373,26 @@ def escape_empty(val): 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("╠", "═", "╬", "╣"), @@ -582,6 +622,8 @@ def escape_empty(val): "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", @@ -1552,6 +1594,32 @@ def tabulate( │ 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: + 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: @@ -1617,6 +1685,28 @@ def tabulate( │ 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"]], diff --git a/test/test_output.py b/test/test_output.py index 82ecfb7..cd8e016 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -843,6 +843,276 @@ def test_rounded_grid_multiline_with_empty_cells_headerless(): assert_equal(expected, result) +def test_heavy_grid(): + "Output: heavy_grid with headers" + expected = "\n".join( + [ + "┏━━━━━━━━━━━┳━━━━━━━━━━━┓", + "┃ strings ┃ numbers ┃", + "┣━━━━━━━━━━━╋━━━━━━━━━━━┫", + "┃ spam ┃ 41.9999 ┃", + "┣━━━━━━━━━━━╋━━━━━━━━━━━┫", + "┃ eggs ┃ 451 ┃", + "┗━━━━━━━━━━━┻━━━━━━━━━━━┛", + ] + ) + result = tabulate(_test_table, _test_table_headers, tablefmt="heavy_grid") + assert_equal(expected, result) + + +def test_heavy_grid_wide_characters(): + "Output: heavy_grid with wide characters in headers" + try: + import wcwidth # noqa + except ImportError: + skip("test_heavy_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="heavy_grid") + assert_equal(expected, result) + + +def test_heavy_grid_headerless(): + "Output: heavy_grid without headers" + expected = "\n".join( + [ + "┏━━━━━━┳━━━━━━━━━━┓", + "┃ spam ┃ 41.9999 ┃", + "┣━━━━━━╋━━━━━━━━━━┫", + "┃ eggs ┃ 451 ┃", + "┗━━━━━━┻━━━━━━━━━━┛", + ] + ) + result = tabulate(_test_table, tablefmt="heavy_grid") + assert_equal(expected, result) + + +def test_heavy_grid_multiline_headerless(): + "Output: heavy_grid with multiline cells without headers" + table = [["foo bar\nbaz\nbau", "hello"], ["", "multiline\nworld"]] + expected = "\n".join( + [ + "┏━━━━━━━━━┳━━━━━━━━━━━┓", + "┃ foo bar ┃ hello ┃", + "┃ baz ┃ ┃", + "┃ bau ┃ ┃", + "┣━━━━━━━━━╋━━━━━━━━━━━┫", + "┃ ┃ multiline ┃", + "┃ ┃ world ┃", + "┗━━━━━━━━━┻━━━━━━━━━━━┛", + ] + ) + result = tabulate(table, stralign="center", tablefmt="heavy_grid") + assert_equal(expected, result) + + +def test_heavy_grid_multiline(): + "Output: heavy_grid with multiline cells with headers" + table = [[2, "foo\nbar"]] + headers = ("more\nspam \x1b[31meggs\x1b[0m", "more spam\n& eggs") + expected = "\n".join( + [ + "┏━━━━━━━━━━━━━┳━━━━━━━━━━━━━┓", + "┃ more ┃ more spam ┃", + "┃ spam \x1b[31meggs\x1b[0m ┃ & eggs ┃", + "┣━━━━━━━━━━━━━╋━━━━━━━━━━━━━┫", + "┃ 2 ┃ foo ┃", + "┃ ┃ bar ┃", + "┗━━━━━━━━━━━━━┻━━━━━━━━━━━━━┛", + ] + ) + result = tabulate(table, headers, tablefmt="heavy_grid") + assert_equal(expected, result) + + +def test_heavy_grid_multiline_with_empty_cells(): + "Output: heavy_grid with multiline cells and empty cells with headers" + table = [ + ["hdr", "data", "fold"], + ["1", "", ""], + ["2", "very long data", "fold\nthis"], + ] + expected = "\n".join( + [ + "┏━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━┓", + "┃ hdr ┃ data ┃ fold ┃", + "┣━━━━━━━╋━━━━━━━━━━━━━━━━╋━━━━━━━━┫", + "┃ 1 ┃ ┃ ┃", + "┣━━━━━━━╋━━━━━━━━━━━━━━━━╋━━━━━━━━┫", + "┃ 2 ┃ very long data ┃ fold ┃", + "┃ ┃ ┃ this ┃", + "┗━━━━━━━┻━━━━━━━━━━━━━━━━┻━━━━━━━━┛", + ] + ) + result = tabulate(table, headers="firstrow", tablefmt="heavy_grid") + assert_equal(expected, result) + + +def test_heavy_grid_multiline_with_empty_cells_headerless(): + "Output: heavy_grid with multiline cells and empty cells without headers" + table = [["0", "", ""], ["1", "", ""], ["2", "very long data", "fold\nthis"]] + expected = "\n".join( + [ + "┏━━━┳━━━━━━━━━━━━━━━━┳━━━━━━┓", + "┃ 0 ┃ ┃ ┃", + "┣━━━╋━━━━━━━━━━━━━━━━╋━━━━━━┫", + "┃ 1 ┃ ┃ ┃", + "┣━━━╋━━━━━━━━━━━━━━━━╋━━━━━━┫", + "┃ 2 ┃ very long data ┃ fold ┃", + "┃ ┃ ┃ this ┃", + "┗━━━┻━━━━━━━━━━━━━━━━┻━━━━━━┛", + ] + ) + result = tabulate(table, tablefmt="heavy_grid") + assert_equal(expected, result) + + +def test_mixed_grid(): + "Output: mixed_grid with headers" + expected = "\n".join( + [ + "┍━━━━━━━━━━━┯━━━━━━━━━━━┑", + "│ strings │ numbers │", + "┝━━━━━━━━━━━┿━━━━━━━━━━━┥", + "│ spam │ 41.9999 │", + "├───────────┼───────────┤", + "│ eggs │ 451 │", + "┕━━━━━━━━━━━┷━━━━━━━━━━━┙", + ] + ) + result = tabulate(_test_table, _test_table_headers, tablefmt="mixed_grid") + assert_equal(expected, result) + + +def test_mixed_grid_wide_characters(): + "Output: mixed_grid with wide characters in headers" + try: + import wcwidth # noqa + except ImportError: + skip("test_mixed_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="mixed_grid") + assert_equal(expected, result) + + +def test_mixed_grid_headerless(): + "Output: mixed_grid without headers" + expected = "\n".join( + [ + "┍━━━━━━┯━━━━━━━━━━┑", + "│ spam │ 41.9999 │", + "├──────┼──────────┤", + "│ eggs │ 451 │", + "┕━━━━━━┷━━━━━━━━━━┙", + ] + ) + result = tabulate(_test_table, tablefmt="mixed_grid") + assert_equal(expected, result) + + +def test_mixed_grid_multiline_headerless(): + "Output: mixed_grid with multiline cells without headers" + table = [["foo bar\nbaz\nbau", "hello"], ["", "multiline\nworld"]] + expected = "\n".join( + [ + "┍━━━━━━━━━┯━━━━━━━━━━━┑", + "│ foo bar │ hello │", + "│ baz │ │", + "│ bau │ │", + "├─────────┼───────────┤", + "│ │ multiline │", + "│ │ world │", + "┕━━━━━━━━━┷━━━━━━━━━━━┙", + ] + ) + result = tabulate(table, stralign="center", tablefmt="mixed_grid") + assert_equal(expected, result) + + +def test_mixed_grid_multiline(): + "Output: mixed_grid with multiline cells with headers" + table = [[2, "foo\nbar"]] + headers = ("more\nspam \x1b[31meggs\x1b[0m", "more spam\n& eggs") + expected = "\n".join( + [ + "┍━━━━━━━━━━━━━┯━━━━━━━━━━━━━┑", + "│ more │ more spam │", + "│ spam \x1b[31meggs\x1b[0m │ & eggs │", + "┝━━━━━━━━━━━━━┿━━━━━━━━━━━━━┥", + "│ 2 │ foo │", + "│ │ bar │", + "┕━━━━━━━━━━━━━┷━━━━━━━━━━━━━┙", + ] + ) + result = tabulate(table, headers, tablefmt="mixed_grid") + assert_equal(expected, result) + + +def test_mixed_grid_multiline_with_empty_cells(): + "Output: mixed_grid with multiline cells and empty cells with headers" + table = [ + ["hdr", "data", "fold"], + ["1", "", ""], + ["2", "very long data", "fold\nthis"], + ] + expected = "\n".join( + [ + "┍━━━━━━━┯━━━━━━━━━━━━━━━━┯━━━━━━━━┑", + "│ hdr │ data │ fold │", + "┝━━━━━━━┿━━━━━━━━━━━━━━━━┿━━━━━━━━┥", + "│ 1 │ │ │", + "├───────┼────────────────┼────────┤", + "│ 2 │ very long data │ fold │", + "│ │ │ this │", + "┕━━━━━━━┷━━━━━━━━━━━━━━━━┷━━━━━━━━┙", + ] + ) + result = tabulate(table, headers="firstrow", tablefmt="mixed_grid") + assert_equal(expected, result) + + +def test_mixed_grid_multiline_with_empty_cells_headerless(): + "Output: mixed_grid with multiline cells and empty cells without headers" + table = [["0", "", ""], ["1", "", ""], ["2", "very long data", "fold\nthis"]] + expected = "\n".join( + [ + "┍━━━┯━━━━━━━━━━━━━━━━┯━━━━━━┑", + "│ 0 │ │ │", + "├───┼────────────────┼──────┤", + "│ 1 │ │ │", + "├───┼────────────────┼──────┤", + "│ 2 │ very long data │ fold │", + "│ │ │ this │", + "┕━━━┷━━━━━━━━━━━━━━━━┷━━━━━━┙", + ] + ) + result = tabulate(table, tablefmt="mixed_grid") + assert_equal(expected, result) + + def test_double_grid(): "Output: double_grid with headers" expected = "\n".join( @@ -1300,6 +1570,110 @@ def test_rounded_outline_headerless(): assert_equal(expected, result) +def test_heavy_outline(): + "Output: heavy_outline with headers" + expected = "\n".join( + [ + "┏━━━━━━━━━━━┳━━━━━━━━━━━┓", + "┃ strings ┃ numbers ┃", + "┣━━━━━━━━━━━╋━━━━━━━━━━━┫", + "┃ spam ┃ 41.9999 ┃", + "┃ eggs ┃ 451 ┃", + "┗━━━━━━━━━━━┻━━━━━━━━━━━┛", + ] + ) + result = tabulate(_test_table, _test_table_headers, tablefmt="heavy_outline") + assert_equal(expected, result) + + +def test_heavy_outline_wide_characters(): + "Output: heavy_outline with wide characters in headers" + try: + import wcwidth # noqa + except ImportError: + skip("test_heavy_outline_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="heavy_outline") + assert_equal(expected, result) + + +def test_heavy_outline_headerless(): + "Output: heavy_outline without headers" + expected = "\n".join( + [ + "┏━━━━━━┳━━━━━━━━━━┓", + "┃ spam ┃ 41.9999 ┃", + "┃ eggs ┃ 451 ┃", + "┗━━━━━━┻━━━━━━━━━━┛", + ] + ) + result = tabulate(_test_table, tablefmt="heavy_outline") + assert_equal(expected, result) + + +def test_mixed_outline(): + "Output: mixed_outline with headers" + expected = "\n".join( + [ + "┍━━━━━━━━━━━┯━━━━━━━━━━━┑", + "│ strings │ numbers │", + "┝━━━━━━━━━━━┿━━━━━━━━━━━┥", + "│ spam │ 41.9999 │", + "│ eggs │ 451 │", + "┕━━━━━━━━━━━┷━━━━━━━━━━━┙", + ] + ) + result = tabulate(_test_table, _test_table_headers, tablefmt="mixed_outline") + assert_equal(expected, result) + + +def test_mixed_outline_wide_characters(): + "Output: mixed_outline with wide characters in headers" + try: + import wcwidth # noqa + except ImportError: + skip("test_mixed_outline_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="mixed_outline") + assert_equal(expected, result) + + +def test_mixed_outline_headerless(): + "Output: mixed_outline without headers" + expected = "\n".join( + [ + "┍━━━━━━┯━━━━━━━━━━┑", + "│ spam │ 41.9999 │", + "│ eggs │ 451 │", + "┕━━━━━━┷━━━━━━━━━━┙", + ] + ) + result = tabulate(_test_table, tablefmt="mixed_outline") + assert_equal(expected, result) + + def test_double_outline(): "Output: double_outline with headers" expected = "\n".join( From 7cc0f2d3fb8c0d5a48e20cdc72d813c2ab3874c8 Mon Sep 17 00:00:00 2001 From: Shaun Duncan Date: Tue, 28 Jun 2022 10:50:14 -0400 Subject: [PATCH 093/207] Improve support for ANSI escape sequences and document the behavior --- README.md | 22 +++++++++ tabulate.py | 133 +++++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 127 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index ba65384..b74faeb 100644 --- a/README.md +++ b/README.md @@ -893,6 +893,28 @@ linebetweenrows, linebelowheader, linebelow, lineabove or just a simple empty li Moon 1737 ----- ---- +### ANSI support +ANSI escape codes are non-printable byte sequences usually used for terminal operations like setting +color output or modifying cursor positions. Because multi-byte ANSI sequences are inherently non-printable, +they can still introduce unwanted length calculating string length. For example: + + >>> len('\033[31mthis text is red\033[0m') # printable length is 16 + 25 + +To deal with this, string lengths are calculated after first remove all ANSI escape sequences. This ensures +that the actual printable length is used for column widths, rather than the byte length. In the final, printable +table, however, ANSI escape sequences are not removed. This ensures that any output, particularly within the +context of a terminal, retains its original styling. + +Some terminals support a special grouping of ANSI escape sequences that are intended to display hyperlinks +in terminal displays much in the same why they are shown in browsers. These are handled in the same way +as mentioned before: non-printable ANSI escape sequences are removed prior to string length calculation. +The only diifference with escaped hyperlinks is that column width will be based on the length of the URL +_text_ rather than the URL 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 + Usage of the command line utility --------------------------------- diff --git a/tabulate.py b/tabulate.py index a611658..6d9f7dc 100644 --- a/tabulate.py +++ b/tabulate.py @@ -3,7 +3,7 @@ from collections import namedtuple from collections.abc import Iterable, Sized from html import escape as htmlescape -from itertools import zip_longest as izip_longest +from itertools import chain, zip_longest as izip_longest from functools import reduce, partial import io import re @@ -605,16 +605,55 @@ def escape_empty(val): _multiline_codes = re.compile(r"\r|\n|\r\n") _multiline_codes_bytes = re.compile(b"\r|\n|\r\n") -_invisible_codes = re.compile( - r"\x1b\[\d+[;\d]*m|\x1b\[\d*\;\d*\;\d*m|\x1b\]8;;(.*?)\x1b\\" -) # ANSI color codes -_invisible_codes_bytes = re.compile( - b"\x1b\\[\\d+\\[;\\d]*m|\x1b\\[\\d*;\\d*;\\d*m|\\x1b\\]8;;(.*?)\\x1b\\\\" -) # ANSI color codes -_invisible_codes_link = re.compile( - r"\x1B]8;[a-zA-Z0-9:]*;[^\x1B]+\x1B\\([^\x1b]+)\x1B]8;;\x1B\\" -) # Terminal hyperlinks +# 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 (0x5b) +# 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( @@ -750,7 +789,7 @@ def _type(string, has_invisible=True, numparse=True): """ if has_invisible and isinstance(string, (str, bytes)): - string = _strip_invisible(string) + string = _strip_ansi(string) if string is None: return type(None) @@ -834,18 +873,24 @@ def _padnone(ignore_width, s): return s -def _strip_invisible(s): - r"""Remove invisible ANSI color codes. +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. - >>> str(_strip_invisible('\x1B]8;;https://example.com\x1B\\This is a link\x1B]8;;\x1B\\')) - 'This is a link' + >>> 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): - links_removed = re.sub(_invisible_codes_link, "\\1", s) - return re.sub(_invisible_codes, "", links_removed) + return _ansi_codes.sub(r"\4", s) else: # a bytestring - return re.sub(_invisible_codes_bytes, "", s) + return _ansi_codes_bytes.sub(r"\4", s) def _visible_width(s): @@ -861,7 +906,7 @@ def _visible_width(s): else: len_fn = len if isinstance(s, (str, bytes)): - return len_fn(_strip_invisible(s)) + return len_fn(_strip_ansi(s)) else: return len_fn(str(s)) @@ -904,7 +949,7 @@ def _align_column_choose_padfn(strings, alignment, has_invisible): padfn = _padboth elif alignment == "decimal": if has_invisible: - decimals = [_afterpoint(_strip_invisible(s)) for s in strings] + decimals = [_afterpoint(_strip_ansi(s)) for s in strings] else: decimals = [_afterpoint(s) for s in strings] maxdecimals = max(decimals) @@ -1072,7 +1117,7 @@ def _format(val, valtype, floatfmt, intfmt, missingval="", has_invisible=True): elif valtype is float: is_a_colored_number = has_invisible and isinstance(val, (str, bytes)) if is_a_colored_number: - raw_val = _strip_invisible(val) + raw_val = _strip_ansi(val) formatted_val = format(float(raw_val), floatfmt) return val.replace(raw_val, formatted_val) else: @@ -1371,6 +1416,31 @@ def _wrap_text_to_colwidths(list_of_lists, colwidths, numparses=True): 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=(), @@ -1871,14 +1941,21 @@ def tabulate( # 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( - ["\t".join(map(str, headers))] - + ["\t".join(map(str, row)) for row in list_of_lists] + 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 = re.search(_invisible_codes, plain_text) - if not has_invisible: - has_invisible = re.search(_invisible_codes_link, plain_text) + 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) @@ -2182,7 +2259,7 @@ def __init__(self, *args, **kwargs): def _len(item): """Custom len that gets console column width for wide and non-wide characters as well as ignores color codes""" - stripped = _strip_invisible(item) + stripped = _strip_ansi(item) if wcwidth: return wcwidth.wcswidth(stripped) else: @@ -2194,7 +2271,7 @@ def _update_lines(self, lines, new_line): as add any colors from previous lines order to preserve the same formatting as a single unwrapped string. """ - code_matches = [x for x in re.finditer(_invisible_codes, new_line)] + 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 ] From aa2b1b57d305272377d52add78c8c54a84f56465 Mon Sep 17 00:00:00 2001 From: Shaun Duncan Date: Fri, 1 Jul 2022 09:11:56 -0400 Subject: [PATCH 094/207] Correct some doc wording and typos --- README.md | 15 +++++++-------- tabulate.py | 2 +- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index b74faeb..792e5ee 100644 --- a/README.md +++ b/README.md @@ -896,21 +896,20 @@ linebetweenrows, linebelowheader, linebelow, lineabove or just a simple empty li ### ANSI support ANSI escape codes are non-printable byte sequences usually used for terminal operations like setting color output or modifying cursor positions. Because multi-byte ANSI sequences are inherently non-printable, -they can still introduce unwanted length calculating string length. For example: +they can still introduce unwanted extra length to strings. For example: >>> len('\033[31mthis text is red\033[0m') # printable length is 16 25 -To deal with this, string lengths are calculated after first remove all ANSI escape sequences. This ensures +To deal with this, string lengths are calculated after first removing all ANSI escape sequences. This ensures that the actual printable length is used for column widths, rather than the byte length. In the final, printable -table, however, ANSI escape sequences are not removed. This ensures that any output, particularly within the -context of a terminal, retains its original styling. +table, however, ANSI escape sequences are not removed so the original styling is preserved. Some terminals support a special grouping of ANSI escape sequences that are intended to display hyperlinks -in terminal displays much in the same why they are shown in browsers. These are handled in the same way -as mentioned before: non-printable ANSI escape sequences are removed prior to string length calculation. -The only diifference with escaped hyperlinks is that column width will be based on the length of the URL -_text_ rather than the URL itself (terminals would show this text). For example: +much in the same way they are shown in browsers. These are handled just as mentioned before: non-printable +ANSI escape sequences are removed prior to string length calculation. The only diifference with escaped +hyperlinks is that column width will be based on the length of the URL _text_ rather than the URL +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 diff --git a/tabulate.py b/tabulate.py index 6d9f7dc..53da7ea 100644 --- a/tabulate.py +++ b/tabulate.py @@ -625,7 +625,7 @@ def escape_empty(val): # Example: \x1b]8;;https://example.com\x5ctext to show\x1b]8;;\x5c # # Where: -# OSC: ESC followed by the ']' character (0x5b) +# 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) From ac6b5f5aae9c30ad24853513471593551beb140e Mon Sep 17 00:00:00 2001 From: Christian Fibich Date: Thu, 1 Sep 2022 14:44:30 +0200 Subject: [PATCH 095/207] Simple asciidoc support --- README.md | 16 +++++++++++++ tabulate.py | 58 +++++++++++++++++++++++++++++++++++++++++++++ test/test_output.py | 29 +++++++++++++++++++++++ 3 files changed, 103 insertions(+) diff --git a/README.md b/README.md index ba65384..b71a420 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,7 @@ Supported table formats are: - "fancy\_outline" - "pipe" - "orgtbl" +- "asciidoc" - "jira" - "presto" - "pretty" @@ -403,6 +404,21 @@ indicate column alignment: [org-mode](http://orgmode.org/manual/Tables.html), and is editable also in the minor orgtbl-mode. Hence its name: +`asciidoc` formats data like a simple table of the +[AsciiDoctor](https://docs.asciidoctor.org/asciidoc/latest/syntax-quick-reference/#tables) +format: + +```pycon +>>> print(tabulate(table, headers, tablefmt="asciidoc")) +[cols="8<,7>",options="header"] +|==== +| item | qty +| spam | 42 +| eggs | 451 +| bacon | 0 +|==== +``` + ```pycon >>> print(tabulate(table, headers, tablefmt="orgtbl")) | item | qty | diff --git a/tabulate.py b/tabulate.py index a611658..fcc97ca 100644 --- a/tabulate.py +++ b/tabulate.py @@ -207,6 +207,54 @@ def _latex_line_begin_tabular(colwidths, colaligns, booktabs=False, longtable=Fa ] ) +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"\&", @@ -568,6 +616,16 @@ def escape_empty(val): 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"], + ), } diff --git a/test/test_output.py b/test/test_output.py index 82ecfb7..d7be88a 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -1536,6 +1536,35 @@ def test_orgtbl_headerless(): result = tabulate(_test_table, tablefmt="orgtbl") assert_equal(expected, result) +def test_asciidoc(): + "Output: asciidoc with headers" + expected = "\n".join( + [ + '[cols="11<,11>",options="header"]', + '|====', + '| strings | numbers ', + '| spam | 41.9999 ', + '| eggs | 451 ', + '|====' + ] + ) + result = tabulate(_test_table, _test_table_headers, tablefmt="asciidoc") + assert_equal(expected, result) + + +def test_asciidoc_headerless(): + "Output: asciidoc without headers" + expected = "\n".join( + [ + '[cols="6<,10>"]', + '|====', + '| spam | 41.9999 ', + '| eggs | 451 ', + '|====' + ] + ) + result = tabulate(_test_table, tablefmt="asciidoc") + assert_equal(expected, result) def test_psql(): "Output: psql with headers" From 7b634d38d7b31a52a0286198e6a6763e741a7fec Mon Sep 17 00:00:00 2001 From: Christian Fibich Date: Thu, 1 Sep 2022 15:02:32 +0200 Subject: [PATCH 096/207] Fix README --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b71a420..2b1c27d 100644 --- a/README.md +++ b/README.md @@ -400,10 +400,6 @@ indicate column alignment: | bacon | 0 | ``` -`orgtbl` follows the conventions of Emacs -[org-mode](http://orgmode.org/manual/Tables.html), and is editable also -in the minor orgtbl-mode. Hence its name: - `asciidoc` formats data like a simple table of the [AsciiDoctor](https://docs.asciidoctor.org/asciidoc/latest/syntax-quick-reference/#tables) format: @@ -419,6 +415,10 @@ format: |==== ``` +`orgtbl` follows the conventions of Emacs +[org-mode](http://orgmode.org/manual/Tables.html), and is editable also +in the minor orgtbl-mode. Hence its name: + ```pycon >>> print(tabulate(table, headers, tablefmt="orgtbl")) | item | qty | From 170723430e43bac692b048ca3328c99c74be640d Mon Sep 17 00:00:00 2001 From: fpin Date: Mon, 5 Sep 2022 08:37:21 +0200 Subject: [PATCH 097/207] fix #192 - Can't print bytes with non-ascii encoding --- tabulate.py | 2 +- test/test_input.py | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/tabulate.py b/tabulate.py index a611658..371f35f 100644 --- a/tabulate.py +++ b/tabulate.py @@ -1067,7 +1067,7 @@ def _format(val, valtype, floatfmt, intfmt, missingval="", has_invisible=True): elif valtype is bytes: try: return str(val, "ascii") - except TypeError: + except (TypeError, UnicodeDecodeError): return str(val) elif valtype is float: is_a_colored_number = has_invisible and isinstance(val, (str, bytes)) diff --git a/test/test_input.py b/test/test_input.py index 40b40bc..a178bd9 100644 --- a/test/test_input.py +++ b/test/test_input.py @@ -518,3 +518,13 @@ def test_py37orlater_list_of_dataclasses_headers(): assert_equal(expected, result) except ImportError: skip("test_py37orlater_list_of_dataclasses_headers is skipped") + + +def test_list_bytes(): + "Input: a list of bytes. (issue #192)" + lb = [["你好".encode("utf-8")], ["你好"]] + expected = "\n".join( + ["bytes", "---------------------------", r"b'\xe4\xbd\xa0\xe5\xa5\xbd'", "你好"] + ) + result = tabulate(lb, headers=["bytes"]) + assert_equal(expected, result) From 4f3ebad4e99f1b4eb74876a17156f1757dc30542 Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Mon, 19 Sep 2022 16:04:25 +0200 Subject: [PATCH 098/207] Fix typo found by codespell --- tabulate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tabulate.py b/tabulate.py index a611658..06a853c 100644 --- a/tabulate.py +++ b/tabulate.py @@ -1358,7 +1358,7 @@ def _wrap_text_to_colwidths(list_of_lists, colwidths, numparses=True): 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 explict than just `str` of the object + # may need to be more explicit than just `str` of the object casted_cell = ( str(cell) if _isnumber(cell) else _type(cell, numparse)(cell) ) From 930a943887b8a269b7445fa89d4a6c88281b824b Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 6 Oct 2022 12:21:18 +0200 Subject: [PATCH 099/207] reformat with black, fix flake warnings --- README.md | 8 ++++---- tabulate.py | 51 ++++++++++++++++++++++++++++----------------------- 2 files changed, 32 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 73b223c..89ebc63 100644 --- a/README.md +++ b/README.md @@ -460,10 +460,10 @@ 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 |==== ``` diff --git a/tabulate.py b/tabulate.py index 9e17ece..46f2e0d 100644 --- a/tabulate.py +++ b/tabulate.py @@ -207,52 +207,57 @@ def _latex_line_begin_tabular(colwidths, colaligns, booktabs=False, longtable=Fa ] ) + def _asciidoc_row(is_header, *args): - """ handle header and data rows for asciidoc format """ + """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))+'"'] - + 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') - + options_list.append("header") + if options_list: - header_list += ['options="' + ','.join(options_list) + '"'] - - # generate the list of entries in the table header field + header_list += ['options="' + ",".join(options_list) + '"'] - return '[{}]\n|===='.format(','.join(header_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) + 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) - + data_line = "|" + "|".join(cell_values) + if is_header: - return make_header_line(True, colwidths, colaligns) + '\n' + data_line + 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) " + " _asciidoc_row() requires two (colwidths, colaligns) " + + "or three (cell_values, colwidths, colaligns) arguments) " ) @@ -660,9 +665,9 @@ def escape_empty(val): lineabove=partial(_asciidoc_row, False), linebelowheader=None, linebetweenrows=None, - linebelow=Line("|====","","",""), + linebelow=Line("|====", "", "", ""), headerrow=partial(_asciidoc_row, True), - datarow=partial(_asciidoc_row, False), + datarow=partial(_asciidoc_row, False), padding=1, with_header_hide=["lineabove"], ), @@ -1735,8 +1740,8 @@ def tabulate( ┃ eggs ┃ 451 ┃ ┗━━━━━━━━━━━┻━━━━━━━━━━━┛ - "mixed_grid" draws a grid using a mix of light (thin) and heavy (thick) lines box-drawing characters: - characters: + "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")) From 91723785940dfbf33f47d7094bc0e19437f05d2a Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 6 Oct 2022 12:28:53 +0200 Subject: [PATCH 100/207] fix tests failing after PR#183 (remove 1 space from the expected values) --- test/test_output.py | 72 +++++++++++++++++++++++---------------------- 1 file changed, 37 insertions(+), 35 deletions(-) diff --git a/test/test_output.py b/test/test_output.py index c22dc51..55ff7e3 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -870,13 +870,13 @@ def test_heavy_grid_wide_characters(): headers[1] = "配列" expected = "\n".join( [ - "┏━━━━━━━━━━━┳━━━━━━━━━━━┓", - "┃ strings ┃ 配列 ┃", - "┣━━━━━━━━━━━╋━━━━━━━━━━━┫", - "┃ spam ┃ 41.9999 ┃", - "┣━━━━━━━━━━━╋━━━━━━━━━━━┫", - "┃ eggs ┃ 451 ┃", - "┗━━━━━━━━━━━┻━━━━━━━━━━━┛", + "┏━━━━━━━━━━━┳━━━━━━━━━━┓", + "┃ strings ┃ 配列 ┃", + "┣━━━━━━━━━━━╋━━━━━━━━━━┫", + "┃ spam ┃ 41.9999 ┃", + "┣━━━━━━━━━━━╋━━━━━━━━━━┫", + "┃ eggs ┃ 451 ┃", + "┗━━━━━━━━━━━┻━━━━━━━━━━┛", ] ) result = tabulate(_test_table, headers, tablefmt="heavy_grid") @@ -1005,13 +1005,13 @@ def test_mixed_grid_wide_characters(): headers[1] = "配列" expected = "\n".join( [ - "┍━━━━━━━━━━━┯━━━━━━━━━━━┑", - "│ strings │ 配列 │", - "┝━━━━━━━━━━━┿━━━━━━━━━━━┥", - "│ spam │ 41.9999 │", - "├───────────┼───────────┤", - "│ eggs │ 451 │", - "┕━━━━━━━━━━━┷━━━━━━━━━━━┙", + "┍━━━━━━━━━━━┯━━━━━━━━━━┑", + "│ strings │ 配列 │", + "┝━━━━━━━━━━━┿━━━━━━━━━━┥", + "│ spam │ 41.9999 │", + "├───────────┼──────────┤", + "│ eggs │ 451 │", + "┕━━━━━━━━━━━┷━━━━━━━━━━┙", ] ) result = tabulate(_test_table, headers, tablefmt="mixed_grid") @@ -1596,12 +1596,12 @@ def test_heavy_outline_wide_characters(): headers[1] = "配列" expected = "\n".join( [ - "┏━━━━━━━━━━━┳━━━━━━━━━━━┓", - "┃ strings ┃ 配列 ┃", - "┣━━━━━━━━━━━╋━━━━━━━━━━━┫", - "┃ spam ┃ 41.9999 ┃", - "┃ eggs ┃ 451 ┃", - "┗━━━━━━━━━━━┻━━━━━━━━━━━┛", + "┏━━━━━━━━━━━┳━━━━━━━━━━┓", + "┃ strings ┃ 配列 ┃", + "┣━━━━━━━━━━━╋━━━━━━━━━━┫", + "┃ spam ┃ 41.9999 ┃", + "┃ eggs ┃ 451 ┃", + "┗━━━━━━━━━━━┻━━━━━━━━━━┛", ] ) result = tabulate(_test_table, headers, tablefmt="heavy_outline") @@ -1648,12 +1648,12 @@ def test_mixed_outline_wide_characters(): headers[1] = "配列" expected = "\n".join( [ - "┍━━━━━━━━━━━┯━━━━━━━━━━━┑", - "│ strings │ 配列 │", - "┝━━━━━━━━━━━┿━━━━━━━━━━━┥", - "│ spam │ 41.9999 │", - "│ eggs │ 451 │", - "┕━━━━━━━━━━━┷━━━━━━━━━━━┙", + "┍━━━━━━━━━━━┯━━━━━━━━━━┑", + "│ strings │ 配列 │", + "┝━━━━━━━━━━━┿━━━━━━━━━━┥", + "│ spam │ 41.9999 │", + "│ eggs │ 451 │", + "┕━━━━━━━━━━━┷━━━━━━━━━━┙", ] ) result = tabulate(_test_table, headers, tablefmt="mixed_outline") @@ -1910,16 +1910,17 @@ def test_orgtbl_headerless(): result = tabulate(_test_table, tablefmt="orgtbl") assert_equal(expected, result) + def test_asciidoc(): "Output: asciidoc with headers" expected = "\n".join( [ '[cols="11<,11>",options="header"]', - '|====', - '| strings | numbers ', - '| spam | 41.9999 ', - '| eggs | 451 ', - '|====' + "|====", + "| strings | numbers ", + "| spam | 41.9999 ", + "| eggs | 451 ", + "|====", ] ) result = tabulate(_test_table, _test_table_headers, tablefmt="asciidoc") @@ -1931,15 +1932,16 @@ def test_asciidoc_headerless(): expected = "\n".join( [ '[cols="6<,10>"]', - '|====', - '| spam | 41.9999 ', - '| eggs | 451 ', - '|====' + "|====", + "| spam | 41.9999 ", + "| eggs | 451 ", + "|====", ] ) result = tabulate(_test_table, tablefmt="asciidoc") assert_equal(expected, result) + def test_psql(): "Output: psql with headers" expected = "\n".join( From 4e2eeb1fcee4c3408c07c4255ef46f81c03a169c Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 6 Oct 2022 13:16:02 +0200 Subject: [PATCH 101/207] update tox.ini - use a virtual environment to build a source dist from the source tree https://tox.wiki/en/latest/example/package.html --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index 891912a..619f447 100644 --- a/tox.ini +++ b/tox.ini @@ -9,6 +9,7 @@ [tox] envlist = lint, py{37, 38, 39, 310} +isolated_build = True [testenv] commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} From 05e88d20cc0449ecc413dde2344f1ae5013b76cb Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 6 Oct 2022 14:35:17 +0200 Subject: [PATCH 102/207] fix test_cli - change script path, do not import .version if __init__.py is run as a script --- tabulate/__init__.py | 3 ++- test/test_cli.py | 21 ++++++++++++++------- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 4752585..5af4ba3 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -22,7 +22,8 @@ def _is_file(f): __all__ = ["tabulate", "tabulate_formats", "simple_separated_format"] -from .version import version as __version__ # noqa: F401 +if __name__ != "__main__": + from .version import version as __version__ # noqa: F401 # minimum extra space in headers diff --git a/test/test_cli.py b/test/test_cli.py index 8c0c9da..ce85f19 100644 --- a/test/test_cli.py +++ b/test/test_cli.py @@ -113,7 +113,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): def test_script_from_stdin_to_stdout(): """Command line utility: read from stdin, print to stdout""" - cmd = [sys.executable, "tabulate.py"] + cmd = [sys.executable, "tabulate/__init__.py"] out = run_and_capture_stdout(cmd, input=sample_input()) expected = SAMPLE_SIMPLE_FORMAT print("got: ", repr(out)) @@ -126,7 +126,7 @@ def test_script_from_file_to_stdout(): with TemporaryTextFile() as tmpfile: tmpfile.write(sample_input()) tmpfile.seek(0) - cmd = [sys.executable, "tabulate.py", tmpfile.name] + cmd = [sys.executable, "tabulate/__init__.py", tmpfile.name] out = run_and_capture_stdout(cmd) expected = SAMPLE_SIMPLE_FORMAT print("got: ", repr(out)) @@ -142,7 +142,7 @@ def test_script_from_file_to_file(): input_file.seek(0) cmd = [ sys.executable, - "tabulate.py", + "tabulate/__init__.py", "-o", output_file.name, input_file.name, @@ -165,7 +165,7 @@ def test_script_from_file_to_file(): def test_script_header_option(): """Command line utility: -1, --header option""" for option in ["-1", "--header"]: - cmd = [sys.executable, "tabulate.py", option] + cmd = [sys.executable, "tabulate/__init__.py", option] raw_table = sample_input(with_headers=True) out = run_and_capture_stdout(cmd, input=raw_table) expected = SAMPLE_SIMPLE_FORMAT_WITH_HEADERS @@ -178,7 +178,7 @@ def test_script_header_option(): def test_script_sep_option(): """Command line utility: -s, --sep option""" for option in ["-s", "--sep"]: - cmd = [sys.executable, "tabulate.py", option, ","] + cmd = [sys.executable, "tabulate/__init__.py", option, ","] raw_table = sample_input(sep=",") out = run_and_capture_stdout(cmd, input=raw_table) expected = SAMPLE_SIMPLE_FORMAT @@ -190,7 +190,14 @@ def test_script_sep_option(): def test_script_floatfmt_option(): """Command line utility: -F, --float option""" for option in ["-F", "--float"]: - cmd = [sys.executable, "tabulate.py", option, ".1e", "--format", "grid"] + cmd = [ + sys.executable, + "tabulate/__init__.py", + option, + ".1e", + "--format", + "grid", + ] raw_table = sample_input() out = run_and_capture_stdout(cmd, input=raw_table) expected = SAMPLE_GRID_FORMAT_WITH_DOT1E_FLOATS @@ -202,7 +209,7 @@ def test_script_floatfmt_option(): def test_script_format_option(): """Command line utility: -f, --format option""" for option in ["-f", "--format"]: - cmd = [sys.executable, "tabulate.py", "-1", option, "grid"] + cmd = [sys.executable, "tabulate/__init__.py", "-1", option, "grid"] raw_table = sample_input(with_headers=True) out = run_and_capture_stdout(cmd, input=raw_table) expected = SAMPLE_GRID_FORMAT_WITH_HEADERS From 3e45eaccf471355ec47eb633b575086693700ecd Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 6 Oct 2022 15:09:44 +0200 Subject: [PATCH 103/207] update appveyor.yml to use pyproject.toml instead of setup.py --- appveyor.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/appveyor.yml b/appveyor.yml index ddf7b3c..6300477 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -18,7 +18,7 @@ environment: install: # We need wheel installed to build wheels - - "%PYTHON%\\python.exe -m pip install wheel" + - "%PYTHON%\\python.exe -m pip install wheel build setuptools setuptools_scm" - "%PYTHON%\\python.exe -m pip install pytest numpy pandas" build: off @@ -40,7 +40,7 @@ after_test: # 64-bit Python 3.3/3.4. And you need to use %PYTHON% to get the correct # interpreter #- "build.cmd %PYTHON%\\python.exe setup.py bdist_wheel" - - "%PYTHON%\\python.exe setup.py sdist bdist_wheel" + - "%PYTHON%\\python.exe -m build -nswx ." artifacts: # bdist_wheel puts your built wheel in the dist directory From d99d9ae4957e60d210f4e76635999bef5fdbc947 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 6 Oct 2022 16:43:29 +0200 Subject: [PATCH 104/207] ignore ImportError when importing __version__ number --- tabulate/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 5af4ba3..503df34 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -22,8 +22,10 @@ def _is_file(f): __all__ = ["tabulate", "tabulate_formats", "simple_separated_format"] -if __name__ != "__main__": +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 From 0a6554e868c7cc692d65ef80c1ca1d90ea44a610 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 6 Oct 2022 16:43:48 +0200 Subject: [PATCH 105/207] appveyor: upgrade setuptools before build (should fix UNKNOWN package name) --- appveyor.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/appveyor.yml b/appveyor.yml index 6300477..e7aae8e 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -17,8 +17,11 @@ environment: - PYTHON: "C:\\Python310-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 build setuptools setuptools_scm" + - "%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 From bf58e37e6b35e3cc9a0bd740f752abfd32b6e6f8 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 6 Oct 2022 18:01:36 +0200 Subject: [PATCH 106/207] version bump to 0.9.0, update README (Benchmark, Contributors), CHANGELOG --- CHANGELOG | 11 ++++++++++- README.md | 32 ++++++++++++++++++-------------- tox.ini | 2 +- 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 658e798..a18a5a1 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,4 +1,13 @@ -- 0.8.11: Future version. Drop support for Python 2.7, 3.5, 3.6. New formats. Improve column width options. +- 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. diff --git a/README.md b/README.md index 89ebc63..6e30546 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ The following tabular data types are supported: - NumPy record arrays (names as columns) - pandas.DataFrame -Examples in this file use Python2. Tabulate supports Python3 too. +Tabulate is a Python3 library. ### Headers @@ -1025,19 +1025,19 @@ 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.8.2 in Ubuntu 20.04: +was run in Python 3.9.13 on Windows 10: - ================================== ========== =========== - Table formatter time, μs rel. time - ================================== ========== =========== - csv to StringIO 9.0 1.0 - join with tabs and newlines 10.7 1.2 - asciitable (0.8.0) 174.6 19.4 - tabulate (0.8.10) 385.0 42.8 - tabulate (0.8.10, WIDE_CHARS_MODE) 509.1 56.5 - PrettyTable (3.3.0) 827.7 91.9 - texttable (1.6.4) 952.1 105.7 - ================================== ========== =========== + ================================= ========== =========== + 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 + ================================= ========== =========== Version history @@ -1120,4 +1120,8 @@ 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. +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. + diff --git a/tox.ini b/tox.ini index 619f447..6f48bf4 100644 --- a/tox.ini +++ b/tox.ini @@ -3,7 +3,7 @@ # test suite on all supported python versions. To use it, "pip install tox" # and then run "tox" from this directory. # -# To run tests against numpy and pandas, run "tox -e py27-extra,py33-extra" +# To run tests against numpy and pandas, run "tox -e py39-extra,py310-extra" # from this directory. This will create a much bigger virtual environments # for testing and it is disabled by default. From 855fa3104493d7efe814c09a85a5f764e39b1659 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20G=C3=B3rny?= Date: Fri, 7 Oct 2022 08:48:34 +0200 Subject: [PATCH 107/207] Remove redundant wheel dep from pyproject.toml Remove the redundant `wheel` dependency, as it is added by the backend automatically. Listing it explicitly in the documentation was a historical mistake and has been fixed since, see: https://github.com/pypa/setuptools/commit/f7d30a9529378cf69054b5176249e5457aaf640a --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 85ede02..ce2b5bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=61.2.0", "wheel", "setuptools_scm[toml]>=3.4.3"] +requires = ["setuptools>=61.2.0", "setuptools_scm[toml]>=3.4.3"] build-backend = "setuptools.build_meta" [project] From 6c48142c0b57ea6108cb1d304b69b90b18748e45 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 6 Oct 2022 20:22:59 +0200 Subject: [PATCH 108/207] fix #190 - preserve line breaks when using maxcolwidths --- tabulate/__init__.py | 6 +++++- test/test_regression.py | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 503df34..b7b9d37 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1515,7 +1515,11 @@ def _wrap_text_to_colwidths(list_of_lists, colwidths, numparses=True): casted_cell = ( str(cell) if _isnumber(cell) else _type(cell, numparse)(cell) ) - wrapped = wrapper.wrap(casted_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) diff --git a/test/test_regression.py b/test/test_regression.py index 22e544a..3fbfc88 100644 --- a/test/test_regression.py +++ b/test/test_regression.py @@ -470,3 +470,19 @@ def count(start, step=1): expected = "1 a\n2 b\n3 c" result = tabulate(table, showindex=count(1), tablefmt="plain") assert_equal(result, expected) + + +def test_preserve_line_breaks_with_maxcolwidths(): + "Regression: preserve line breaks when using maxcolwidths (github issue #190)" + table = [["123456789 bbb\nccc"]] + expected = "\n".join( + [ + "+-----------+", + "| 123456789 |", + "| bbb |", + "| ccc |", + "+-----------+", + ] + ) + result = tabulate(table, tablefmt="grid", maxcolwidths=10) + assert_equal(result, expected) From a5e70e526a694640e3631c489370872574a62155 Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Fri, 7 Oct 2022 13:19:41 +0400 Subject: [PATCH 109/207] Fix rendering of multiline cells in outline format family. Closes #203. Refs #80. --- tabulate/__init__.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index b7b9d37..5270e72 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -699,6 +699,13 @@ def escape_empty(val): "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: From 20c6370d5da2dae89b305bfb6c7f12a0f8b7236c Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Mon, 10 Oct 2022 10:58:33 +0200 Subject: [PATCH 110/207] fix #180 - exception on empty data with maxcolwidths option --- tabulate/__init__.py | 11 +++++++++-- test/test_regression.py | 6 ++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 5270e72..8348ddf 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1503,7 +1503,11 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): def _wrap_text_to_colwidths(list_of_lists, colwidths, numparses=True): - numparses = _expand_iterable(numparses, len(list_of_lists[0]), 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 = [] @@ -2062,7 +2066,10 @@ def tabulate( list_of_lists, separating_lines = _remove_separating_lines(list_of_lists) if maxcolwidths is not None: - num_cols = len(list_of_lists[0]) + 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 diff --git a/test/test_regression.py b/test/test_regression.py index 3fbfc88..e4ddf1b 100644 --- a/test/test_regression.py +++ b/test/test_regression.py @@ -486,3 +486,9 @@ def test_preserve_line_breaks_with_maxcolwidths(): ) result = tabulate(table, tablefmt="grid", maxcolwidths=10) assert_equal(result, expected) + + +def test_exception_on_empty_data_with_maxcolwidths(): + "Regression: exception on empty data when using maxcolwidths (github issue #180)" + result = tabulate([], maxcolwidths=5) + assert_equal(result, "") From e52c5ba9f42c98d7a574b7699e0595e0d424231f Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 19 Oct 2022 17:42:11 +0200 Subject: [PATCH 111/207] fix #18 - display np.int64 numbers as integers, do not apply 'floatfmt' --- tabulate/__init__.py | 9 +++++++-- test/test_regression.py | 20 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 8348ddf..c59702e 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -868,8 +868,13 @@ def _isint(string, inttype=int): """ return ( type(string) is inttype - or isinstance(string, (bytes, str)) - and _isconvertible(inttype, string) + or ( + hasattr(string, "is_integer") + and str(type(string)).startswith(" Date: Wed, 19 Oct 2022 18:00:14 +0200 Subject: [PATCH 112/207] fix the order of arguments of assert_equal in tests --- test/test_internal.py | 18 +++++++------- test/test_output.py | 10 ++++---- test/test_regression.py | 52 ++++++++++++++++++++--------------------- 3 files changed, 40 insertions(+), 40 deletions(-) diff --git a/test/test_internal.py b/test/test_internal.py index 00208c7..64e1d12 100644 --- a/test/test_internal.py +++ b/test/test_internal.py @@ -16,7 +16,7 @@ def test_multiline_width(): def test_align_column_decimal(): "Internal: _align_column(..., 'decimal')" column = ["12.345", "-1234.5", "1.23", "1234.5", "1e+234", "1.0e234"] - output = T._align_column(column, "decimal") + result = T._align_column(column, "decimal") expected = [ " 12.345 ", "-1234.5 ", @@ -25,7 +25,7 @@ def test_align_column_decimal(): " 1e+234 ", " 1.0e234", ] - assert_equal(output, expected) + assert_equal(expected, result) def test_align_column_decimal_with_thousand_separators(): @@ -40,7 +40,7 @@ def test_align_column_decimal_with_thousand_separators(): " 1e+234 ", " 1.0e234", ] - assert_equal(output, expected) + assert_equal(expected, output) def test_align_column_decimal_with_incorrect_thousand_separators(): @@ -55,7 +55,7 @@ def test_align_column_decimal_with_incorrect_thousand_separators(): " 1e+234 ", " 1.0e234", ] - assert_equal(output, expected) + assert_equal(expected, output) def test_align_column_none(): @@ -63,7 +63,7 @@ def test_align_column_none(): column = ["123.4", "56.7890"] output = T._align_column(column, None) expected = ["123.4", "56.7890"] - assert_equal(output, expected) + assert_equal(expected, output) def test_align_column_multiline(): @@ -71,7 +71,7 @@ def test_align_column_multiline(): column = ["1", "123", "12345\n6"] output = T._align_column(column, "center", is_multiline=True) expected = [" 1 ", " 123 ", "12345" + "\n" + " 6 "] - assert_equal(output, expected) + assert_equal(expected, output) def test_align_cell_veritically_one_line_only(): @@ -170,7 +170,7 @@ def test_wrap_text_to_colwidths(): ] result = T._wrap_text_to_colwidths(rows, widths) - assert_equal(result, expected) + assert_equal(expected, result) def test_wrap_text_wide_chars(): @@ -185,7 +185,7 @@ def test_wrap_text_wide_chars(): expected = [["청자\n청자\n청자\n청자\n청자", "약간 감싸면 더 잘\n보일 수있는 다소 긴\n설명입니다"]] result = T._wrap_text_to_colwidths(rows, widths) - assert_equal(result, expected) + assert_equal(expected, result) def test_wrap_text_to_numbers(): @@ -202,7 +202,7 @@ def test_wrap_text_to_numbers(): ] result = T._wrap_text_to_colwidths(rows, widths, numparses=[True, True, False]) - assert_equal(result, expected) + assert_equal(expected, result) def test_wrap_text_to_colwidths_single_ansi_colors_full_cell(): diff --git a/test/test_output.py b/test/test_output.py index 55ff7e3..9a9a452 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -2847,7 +2847,7 @@ def test_dict_like_with_index(): dd = {"b": range(101, 104)} expected = "\n".join([" b", "-- ---", " 0 101", " 1 102", " 2 103"]) result = tabulate(dd, "keys", showindex=True) - assert_equal(result, expected) + assert_equal(expected, result) def test_list_of_lists_with_index(): @@ -2859,7 +2859,7 @@ def test_list_of_lists_with_index(): [" a b", "-- --- ---", " 0 0 101", " 1 1 102", " 2 2 103"] ) result = tabulate(dd, headers=["a", "b"], showindex=True) - assert_equal(result, expected) + assert_equal(expected, result) def test_list_of_lists_with_index_with_sep_line(): @@ -2878,7 +2878,7 @@ def test_list_of_lists_with_index_with_sep_line(): ] ) result = tabulate(dd, headers=["a", "b"], showindex=True) - assert_equal(result, expected) + assert_equal(expected, result) def test_list_of_lists_with_supplied_index(): @@ -2888,7 +2888,7 @@ def test_list_of_lists_with_supplied_index(): [" a b", "-- --- ---", " 1 0 101", " 2 1 102", " 3 2 103"] ) result = tabulate(dd, headers=["a", "b"], showindex=[1, 2, 3]) - assert_equal(result, expected) + assert_equal(expected, result) # TODO: make it a separate test case # the index must be as long as the number of rows with raises(ValueError): @@ -2902,7 +2902,7 @@ def test_list_of_lists_with_index_firstrow(): [" a b", "-- --- ---", " 0 0 101", " 1 1 102", " 2 2 103"] ) result = tabulate(dd, headers="firstrow", showindex=True) - assert_equal(result, expected) + assert_equal(expected, result) # TODO: make it a separate test case # the index must be as long as the number of rows with raises(ValueError): diff --git a/test/test_regression.py b/test/test_regression.py index db89ab8..8f60ce7 100644 --- a/test/test_regression.py +++ b/test/test_regression.py @@ -163,14 +163,14 @@ def test_column_type_of_bytestring_columns(): result = _column_type([b"foo", b"bar"]) expected = bytes - assert_equal(result, expected) + assert_equal(expected, result) def test_numeric_column_headers(): "Regression: numbers as column headers (issue #22)" result = tabulate([[1], [2]], [42]) expected = " 42\n----\n 1\n 2" - assert_equal(result, expected) + assert_equal(expected, result) lod = [{p: i for p in range(5)} for i in range(5)] result = tabulate(lod, "keys") @@ -185,7 +185,7 @@ def test_numeric_column_headers(): " 4 4 4 4 4", ] ) - assert_equal(result, expected) + assert_equal(expected, result) def test_88_256_ANSI_color_codes(): @@ -226,14 +226,14 @@ def test_latex_escape_special_chars(): ] ) result = tabulate([["&%^_$#{}<>~"]], ["foo^bar"], tablefmt="latex") - assert_equal(result, expected) + assert_equal(expected, result) def test_isconvertible_on_set_values(): "Regression: don't fail with TypeError on set values (issue #35)" expected = "\n".join(["a b", "--- -----", "Foo set()"]) result = tabulate([["Foo", set()]], headers=["a", "b"]) - assert_equal(result, expected) + assert_equal(expected, result) def test_ansi_color_for_decimal_numbers(): @@ -243,7 +243,7 @@ def test_ansi_color_for_decimal_numbers(): ["------- ---", "Magenta \x1b[95m1.1\x1b[0m", "------- ---"] ) result = tabulate(table) - assert_equal(result, expected) + assert_equal(expected, result) def test_alignment_of_decimal_numbers_with_ansi_color(): @@ -253,7 +253,7 @@ def test_alignment_of_decimal_numbers_with_ansi_color(): table = [[v1], [v2]] expected = "\n".join(["\x1b[95m12.34\x1b[0m", " \x1b[95m1.23456\x1b[0m"]) result = tabulate(table, tablefmt="plain") - assert_equal(result, expected) + assert_equal(expected, result) def test_alignment_of_decimal_numbers_with_commas(): @@ -266,7 +266,7 @@ def test_alignment_of_decimal_numbers_with_commas(): # '+------+-----------+', '| c1r2 | 105.00 |', # '+------+-----------+'] # ) - # assert_equal(result, expected) + # assert_equal(expected, result) def test_long_integers(): @@ -274,7 +274,7 @@ def test_long_integers(): table = [[18446744073709551614]] result = tabulate(table, tablefmt="plain") expected = "18446744073709551614" - assert_equal(result, expected) + assert_equal(expected, result) def test_colorclass_colors(): @@ -285,7 +285,7 @@ def test_colorclass_colors(): s = colorclass.Color("{magenta}3.14{/magenta}") result = tabulate([[s]], tablefmt="plain") expected = "\x1b[35m3.14\x1b[39m" - assert_equal(result, expected) + assert_equal(expected, result) except ImportError: class textclass(str): @@ -294,7 +294,7 @@ class textclass(str): s = textclass("\x1b[35m3.14\x1b[39m") result = tabulate([[s]], tablefmt="plain") expected = "\x1b[35m3.14\x1b[39m" - assert_equal(result, expected) + assert_equal(expected, result) def test_mix_normal_and_wide_characters(): @@ -314,7 +314,7 @@ def test_mix_normal_and_wide_characters(): "+--------+", ] ) - assert_equal(result, expected) + assert_equal(expected, result) except ImportError: skip("test_mix_normal_and_wide_characters is skipped (requires wcwidth lib)") @@ -334,7 +334,7 @@ def test_multiline_with_wide_characters(): "╘══════╧══════╧══════╛", ] ) - assert_equal(result, expected) + assert_equal(expected, result) except ImportError: skip("test_multiline_with_wide_characters is skipped (requires wcwidth lib)") @@ -344,7 +344,7 @@ def test_align_long_integers(): table = [[int(1)], [int(234)]] result = tabulate(table, tablefmt="plain") expected = "\n".join([" 1", "234"]) - assert_equal(result, expected) + assert_equal(expected, result) def test_numpy_array_as_headers(): @@ -355,7 +355,7 @@ def test_numpy_array_as_headers(): headers = np.array(["foo", "bar"]) result = tabulate([], headers, tablefmt="plain") expected = "foo bar" - assert_equal(result, expected) + assert_equal(expected, result) except ImportError: raise skip("") @@ -365,7 +365,7 @@ def test_boolean_columns(): xortable = [[False, True], [True, False]] expected = "\n".join(["False True", "True False"]) result = tabulate(xortable, tablefmt="plain") - assert_equal(result, expected) + assert_equal(expected, result) def test_ansi_color_bold_and_fgcolor(): @@ -383,14 +383,14 @@ def test_ansi_color_bold_and_fgcolor(): "+---+---+---+", ] ) - assert_equal(result, expected) + assert_equal(expected, result) def test_empty_table_with_keys_as_header(): "Regression: headers='keys' on an empty table (issue #81)" result = tabulate([], headers="keys") expected = "" - assert_equal(result, expected) + assert_equal(expected, result) def test_escape_empty_cell_in_first_column_in_rst(): @@ -409,7 +409,7 @@ def test_escape_empty_cell_in_first_column_in_rst(): ] ) result = tabulate(table, headers, tablefmt="rst") - assert_equal(result, expected) + assert_equal(expected, result) def test_ragged_rows(): @@ -417,7 +417,7 @@ def test_ragged_rows(): table = [[1, 2, 3], [1, 2], [1, 2, 3, 4]] expected = "\n".join(["- - - -", "1 2 3", "1 2", "1 2 3 4", "- - - -"]) result = tabulate(table) - assert_equal(result, expected) + assert_equal(expected, result) def test_empty_pipe_table_with_columns(): @@ -426,7 +426,7 @@ def test_empty_pipe_table_with_columns(): headers = ["Col1", "Col2"] expected = "\n".join(["| Col1 | Col2 |", "|--------|--------|"]) result = tabulate(table, headers, tablefmt="pipe") - assert_equal(result, expected) + assert_equal(expected, result) def test_custom_tablefmt(): @@ -444,7 +444,7 @@ def test_custom_tablefmt(): rows = [["foo", "bar"], ["baz", "qux"]] expected = "\n".join(["A B", "--- ---", "foo bar", "baz qux"]) result = tabulate(rows, headers=["A", "B"], tablefmt=tablefmt) - assert_equal(result, expected) + assert_equal(expected, result) def test_string_with_comma_between_digits_without_floatfmt_grouping_option(): @@ -452,7 +452,7 @@ def test_string_with_comma_between_digits_without_floatfmt_grouping_option(): table = [["126,000"]] expected = "126,000" result = tabulate(table, tablefmt="plain") - assert_equal(result, expected) # no exception + assert_equal(expected, result) # no exception def test_iterable_row_index(): @@ -469,7 +469,7 @@ def count(start, step=1): expected = "1 a\n2 b\n3 c" result = tabulate(table, showindex=count(1), tablefmt="plain") - assert_equal(result, expected) + assert_equal(expected, result) def test_preserve_line_breaks_with_maxcolwidths(): @@ -485,7 +485,7 @@ def test_preserve_line_breaks_with_maxcolwidths(): ] ) result = tabulate(table, tablefmt="grid", maxcolwidths=10) - assert_equal(result, expected) + assert_equal(expected, result) def test_exception_on_empty_data_with_maxcolwidths(): @@ -509,6 +509,6 @@ def test_numpy_int64_as_integer(): "| 1 | 3.14 |", ] ) - assert_equal(result, expected) + assert_equal(expected, result) except ImportError: raise skip("") From 69dc8ed3633ef8221df50bed90d32911da24c9df Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 19 Oct 2022 18:21:04 +0200 Subject: [PATCH 113/207] fix #18 - update int64 recognition heuristics for Python 3.7 --- tabulate/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index c59702e..9cf6991 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -869,7 +869,7 @@ def _isint(string, inttype=int): return ( type(string) is inttype or ( - hasattr(string, "is_integer") + (hasattr(string, "is_integer") or hasattr(string, "__array__")) and str(type(string)).startswith(" Date: Wed, 26 Oct 2022 18:30:27 +0200 Subject: [PATCH 114/207] replace deprecated HTML --- tabulate/__init__.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 9cf6991..3b1a1e1 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -136,9 +136,9 @@ def _pipe_line_with_colons(colwidths, colaligns): def _mediawiki_row_with_attrs(separator, cell_values, colwidths, colaligns): alignment = { "left": "", - "right": 'align="right"| ', - "center": 'align="center"| ', - "decimal": 'align="right"| ', + "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 @@ -1952,11 +1952,11 @@ def tabulate( {| class="wikitable" style="text-align: left;" |+ |- - ! strings !! align="right"| numbers + ! strings !! style="text-align: right;"| numbers |- - | spam || align="right"| 41.9999 + | spam || style="text-align: right;"| 41.9999 |- - | eggs || align="right"| 451 + | eggs || style="text-align: right;"| 451 |} "html" produces HTML markup as an html.escape'd str From fb9a89d583247b13e013fbbe4d9084deb3dd5e41 Mon Sep 17 00:00:00 2001 From: Keyacom <70766223+Keyacom@users.noreply.github.com> Date: Wed, 26 Oct 2022 18:58:09 +0200 Subject: [PATCH 115/207] AppVeyor showed me build errors :( --- test/test_output.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/test_output.py b/test/test_output.py index 9a9a452..9043aed 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -2322,11 +2322,11 @@ def test_mediawiki(): '{| class="wikitable" style="text-align: left;"', "|+ ", "|-", - '! strings !! align="right"| numbers', + '! strings !! style="text-align: right;"| numbers', "|-", - '| spam || align="right"| 41.9999', + '| spam || style="text-align: right;"| 41.9999', "|-", - '| eggs || align="right"| 451', + '| eggs || style="text-align: right;"| 451', "|}", ] ) @@ -2341,9 +2341,9 @@ def test_mediawiki_headerless(): '{| class="wikitable" style="text-align: left;"', "|+ ", "|-", - '| spam || align="right"| 41.9999', + '| spam || style="text-align: right;"| 41.9999', "|-", - '| eggs || align="right"| 451', + '| eggs || style="text-align: right;"| 451', "|}", ] ) From 0bbce65168704aab7a64062acb6e01fc90903a40 Mon Sep 17 00:00:00 2001 From: Keyacom <70766223+Keyacom@users.noreply.github.com> Date: Thu, 27 Oct 2022 14:36:10 +0200 Subject: [PATCH 116/207] update README.md --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6e30546..d64b99a 100644 --- a/README.md +++ b/README.md @@ -514,13 +514,13 @@ MediaWiki-based sites: {| class="wikitable" style="text-align: left;" |+ |- -! item !! align="right"| qty +! item !! style="text-align: right;"| qty |- -| spam || align="right"| 42 +| spam || style="text-align: right;"| 42 |- -| eggs || align="right"| 451 +| eggs || style="text-align: right;"| 451 |- -| bacon || align="right"| 0 +| bacon || style="text-align: right;"| 0 |} ``` From fbdb1fd702d9c5f7c03dbd091d5208141697683e Mon Sep 17 00:00:00 2001 From: Racerroar888 Date: Sat, 5 Nov 2022 08:40:39 -0600 Subject: [PATCH 117/207] 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 dfd2e07751e74900364594d0bb62a3d6ad1d6763 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Sun, 6 Nov 2022 13:24:15 +0200 Subject: [PATCH 118/207] Add support for Python 3.11 --- pyproject.toml | 1 + tox.ini | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ce2b5bd..5a8c1fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ classifiers = [ "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" diff --git a/tox.ini b/tox.ini index 6f48bf4..c6260d2 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} +envlist = lint, py{37, 38, 39, 310, 311} isolated_build = True [testenv] @@ -89,6 +89,23 @@ deps = wcwidth +[testenv:py311] +basepython = python3.11 +commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} +deps = + pytest + +[testenv:py311-extra] +basepython = python3.11 +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 87b3431a831f8c61e097a40d6db7e560d05bf6a0 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Tue, 8 Nov 2022 09:45:21 +0200 Subject: [PATCH 119/207] Add 3.11 to AppVeyor --- appveyor.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/appveyor.yml b/appveyor.yml index e7aae8e..4eb2dd8 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -15,6 +15,7 @@ environment: - PYTHON: "C:\\Python38-x64" - PYTHON: "C:\\Python39-x64" - PYTHON: "C:\\Python310-x64" + - PYTHON: "C:\\Python311-x64" install: # Newer setuptools is needed for proper support of pyproject.toml 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 120/207] 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 121/207] 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 122/207] 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 123/207] 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 124/207] 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 125/207] 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 126/207] 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 127/207] 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 128/207] 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 129/207] 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 130/207] 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 131/207] 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 132/207] 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 133/207] 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 134/207] 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 135/207] 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 136/207] 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 137/207] 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 138/207] 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 139/207] 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 140/207] 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 141/207] 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 142/207] 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 143/207] 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 144/207] 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 145/207] 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 146/207] 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 147/207] 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 148/207] 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 149/207] 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 150/207] 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 151/207] 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 152/207] 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 153/207] 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 154/207] 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 155/207] 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 156/207] 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 157/207] 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 158/207] 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 159/207] 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 160/207] 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 161/207] 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 162/207] 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 163/207] 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 164/207] 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 165/207] 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 166/207] 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 167/207] 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 168/207] 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 169/207] 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 170/207] 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 171/207] 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 172/207] 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 173/207] 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 174/207] 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 175/207] 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 176/207] 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 177/207] 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 178/207] 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 179/207] 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 180/207] 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 181/207] 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 182/207] 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 183/207] 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 184/207] 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 185/207] 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 186/207] 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 187/207] 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 188/207] 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 189/207] 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 190/207] 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 191/207] 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 192/207] 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 193/207] 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 194/207] 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 195/207] 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 196/207] 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 197/207] 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 198/207] 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 199/207] 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 200/207] 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 201/207] 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 202/207] 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 203/207] 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 204/207] 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 205/207] 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 206/207] 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 207/207] 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):