Repository: astanin/python-tabulate
Pull request #163 (2022-01-17 23:51:35)

New Feature Suggestion: Allow to add a separating line in the list of rows #160
Change related to this suggestion: https://github.com/astanin/python-tabulate/issues/160 


Patch:
diff --git a/README.md b/README.md
index c6286e7..767b690 100644
--- a/README.md
+++ b/README.md
@@ -872,6 +872,27 @@ the lines being wrapped would probably be significantly longer than this.
 +------------+---------+
 ```
 
+### 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
 ---------------------------------
@@ -954,6 +975,17 @@ 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.
 
+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 3.7 and Python 3.10, run:
 
@@ -999,4 +1031,4 @@ 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.
+jamescooke, Matt Warner, Jérôme Provensal.
diff --git a/tabulate.py b/tabulate.py
index 8988687..b928ae8 100644
--- a/tabulate.py
+++ b/tabulate.py
@@ -42,6 +42,9 @@ _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"])
 
@@ -1092,6 +1095,27 @@ def _align_header(
         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."""
     if index is None or index is False:
@@ -1100,7 +1124,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
 
 
@@ -1274,7 +1300,8 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"):
         headers = []
 
     headers = list(map(str, 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 [str, bytes]
@@ -1775,9 +1802,11 @@ def tabulate(
 
     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:
         num_cols = len(list_of_lists[0])
@@ -1915,6 +1944,7 @@ def tabulate(
 
     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
@@ -2091,8 +2121,21 @@ def _format_table(fmt, headers, rows, colwidths, colaligns, is_multiline, rowali
             rowalign=rowaligns[-1],
         )
     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 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)


Test patch:
diff --git a/test/common.py b/test/common.py
index 10fa89e..d95e84f 100644
--- a/test/common.py
+++ b/test/common.py
@@ -14,3 +14,16 @@ 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 a8fbd04..00208c7 100644
--- a/test/test_internal.py
+++ b/test/test_internal.py
@@ -1,7 +1,8 @@
 """Tests of the internal tabulate functions."""
 
 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():
@@ -308,3 +309,37 @@ 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 af0f50e..a9f6f6a 100644
--- a/test/test_output.py
+++ b/test/test_output.py
@@ -1,15 +1,15 @@
 """Test output of the various forms of tabular data."""
 
 import tabulate as tabulate_module
-from tabulate import tabulate, simple_separated_format
 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,
 #  - 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"]
 
 
@@ -109,6 +109,22 @@ 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 = [
+        ["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]
+    )
+    assert_equal(expected, result)
+
 
 def test_plain_maxcolwidth_autowraps_wide_chars():
     "Output: maxcolwidth and autowrapping functions with wide characters"
@@ -225,6 +241,37 @@ 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_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(
@@ -241,6 +288,28 @@ def test_simple_multiline_2():
     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"
     expected = "\n".join(
@@ -250,6 +319,21 @@ 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"]]
@@ -2180,6 +2264,17 @@ 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"]
@@ -2361,6 +2456,25 @@ def test_list_of_lists_with_index():
     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)
+
+
 def test_list_of_lists_with_supplied_index():
     "Output: a table with a supplied index"
     dd = zip(*[list(range(3)), list(range(101, 104))])