Repository: astanin/python-tabulate
Pull request #118 (2021-03-05 14:27:53)

Rows can specify top, bottom, or center alignment for their cell text
This is to satisfy Issue #94: it allows setting a `rowalign` argument to `tabulate` that can be `top`, `bottom`, or `center` (with `top` being default if `None` is provided).

I replicated the table from the issue report to demonstrate:

```
import tabulate
from tabulate import Line, DataRow, TableFormat

mini_data = [
    ["r2", 0.7028],
    ["D'", 0.9229],
    ["abs_dist", 76864],
]
mini_table = tabulate.tabulate(mini_data, tablefmt="fancy_grid")
print(mini_table)

data = [
    [mini_table, "rs3135388", "rs3129934"],
    ["chrom", "6", "6"],
    ["hg38_pos", "32445274", "32368418"],
    ["alleles", "A/G", "A/G"],
    ["alt_freq", "0.8797", "0.8579"]
]

result = tabulate.tabulate(data, tablefmt="fancy_grid", rowalign="bottom")
print(result)
```

gives:

```
╒═══════════════════════════╤═══════════╤═══════════╕
│ ╒══════════╤════════════╕ │           │           │
│ │ r2       │     0.7028 │ │           │           │
│ ├──────────┼────────────┤ │           │           │
│ │ D'       │     0.9229 │ │           │           │
│ ├──────────┼────────────┤ │           │           │
│ │ abs_dist │ 76864      │ │           │           │
│ ╘══════════╧════════════╛ │ rs3135388 │ rs3129934 │
├───────────────────────────┼───────────┼───────────┤
│ chrom                     │ 6         │ 6         │
├───────────────────────────┼───────────┼───────────┤
│ hg38_pos                  │ 32445274  │ 32368418  │
├───────────────────────────┼───────────┼───────────┤
│ alleles                   │ A/G       │ A/G       │
├───────────────────────────┼───────────┼───────────┤
│ alt_freq                  │ 0.8797    │ 0.8579    │
╘═══════════════════════════╧═══════════╧═══════════╛
```


Patch:
diff --git a/tabulate.py b/tabulate.py
index 43df1a3..8988687 100644
--- a/tabulate.py
+++ b/tabulate.py
@@ -1341,6 +1341,7 @@ def tabulate(
     disable_numparse=False,
     colalign=None,
     maxcolwidths=None,
+    rowalign=None,
     maxheadercolwidths=None,
 ):
     """Format a fixed width table for pretty printing.
@@ -1912,7 +1913,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):
@@ -1940,7 +1946,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
@@ -1971,20 +1977,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:
@@ -2023,7 +2048,7 @@ class JupyterHTMLStr(str):
         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 []
@@ -2051,11 +2076,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)


Test patch:
diff --git a/test/test_api.py b/test/test_api.py
index 55279f2..046d752 100644
--- a/test/test_api.py
+++ b/test/test_api.py
@@ -50,6 +50,7 @@ def test_tabulate_signature():
         ("disable_numparse", False),
         ("colalign", None),
         ("maxcolwidths", None),
+        ("rowalign", None),
         ("maxheadercolwidths", None),
     ]
     _check_signature(tabulate, expected_sig)
diff --git a/test/test_internal.py b/test/test_internal.py
index 2a0090e..a8fbd04 100644
--- a/test/test_internal.py
+++ b/test/test_internal.py
@@ -73,6 +73,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 ebd0466..af0f50e 100644
--- a/test/test_output.py
+++ b/test/test_output.py
@@ -1028,6 +1028,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_outline():
     "Output: outline with headers"
     expected = "\n".join(