From 0a0451a02515bfa55825895987ce5749f1ea5dec Mon Sep 17 00:00:00 2001 From: gaoflow Date: Wed, 24 Jun 2026 22:15:42 +0200 Subject: [PATCH] Fix trailing None/empty cell silently dropped by rstrip in plain-format rows When a row ends with a None value (formatted as "" by the default missingval), _build_simple_row's rstrip() call strips the trailing padding and makes the column visually disappear. For example: tabulate([["a", "b", None]], headers=["A", "B", "C"]) previously rendered the data row as "a b" instead of "a b ", hiding the fact that column C exists at all. The fix adds a preserve_trailing_empty flag that _format_table passes as True for non-multiline data rows. When the flag is set and the row has no closing delimiter and the last escaped cell is purely whitespace, rstrip is skipped so the column spacing is preserved. Multiline continuation rows (where trailing empty cells are cosmetic) continue to be rstripped as before. Update affected test expectations to reflect the corrected behavior. --- tabulate/__init__.py | 23 +++++++++++++++++------ test/test_input.py | 10 +++++----- test/test_output.py | 4 ++-- test/test_regression.py | 4 ++-- 4 files changed, 26 insertions(+), 15 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 12a2950..a743000 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -2504,7 +2504,7 @@ def _pad_row(cells, padding): return cells -def _build_simple_row(padded_cells: list[list], rowfmt: DataRow) -> str: +def _build_simple_row(padded_cells: list[list], rowfmt: DataRow, preserve_trailing_empty: bool = False) -> str: "Format row according to DataRow format without padding." begin = rowfmt.begin sep = rowfmt.sep @@ -2520,7 +2520,15 @@ def escape_char(c): else: escaped_cells = padded_cells - return (begin + sep.join(escaped_cells) + end).rstrip() + row = begin + sep.join(escaped_cells) + end + # When there is no closing delimiter and the last cell is purely whitespace + # (i.e. a missing/None value formatted as ""), rstrip() silently drops the + # trailing column separator and makes that column invisible. Skip rstrip() + # only for primary data rows (preserve_trailing_empty=True) so that + # multiline continuation lines are still stripped as before. + if preserve_trailing_empty and not end and escaped_cells and not escaped_cells[-1].strip(): + return row + return row.rstrip() def _build_row( @@ -2528,6 +2536,7 @@ def _build_row( colwidths: list[int], colaligns: list[str], rowfmt: DataRow | Callable, + preserve_trailing_empty: bool = False, ) -> str: "Return a string which represents a row of data cells." if not rowfmt: @@ -2535,12 +2544,12 @@ def _build_row( if callable(rowfmt): return rowfmt(padded_cells, colwidths, colaligns) else: - return _build_simple_row(padded_cells, rowfmt) + return _build_simple_row(padded_cells, rowfmt, preserve_trailing_empty) -def _append_basic_row(lines, padded_cells, colwidths, colaligns, rowfmt, rowalign=None): +def _append_basic_row(lines, padded_cells, colwidths, colaligns, rowfmt, rowalign=None, preserve_trailing_empty=False): # NOTE: rowalign is ignored and exists for api compatibility with _append_multiline_row - lines.append(_build_row(padded_cells, colwidths, colaligns, rowfmt)) + lines.append(_build_row(padded_cells, colwidths, colaligns, rowfmt, preserve_trailing_empty)) return lines @@ -2624,7 +2633,9 @@ def _format_table( append_row = partial(_append_multiline_row, pad=pad) else: pad_row = _pad_row - append_row = _append_basic_row + # preserve_trailing_empty=True so that a trailing None/empty cell (which + # formats to "") is not silently rstripped away and the column disappears. + append_row = partial(_append_basic_row, preserve_trailing_empty=True) padded_headers = pad_row(headers, pad) diff --git a/test/test_input.py b/test/test_input.py index 3cc3237..e55e8fe 100644 --- a/test/test_input.py +++ b/test/test_input.py @@ -57,7 +57,7 @@ def test_list_of_lists(): " string number", "-- -------- --------", "a one 1", - "b two", + "b two ", ] ) result = tabulate(ll, headers=["string", "number"]) @@ -72,7 +72,7 @@ def test_list_of_lists_firstrow(): " string number", "-- -------- --------", "a one 1", - "b two", + "b two ", ] ) result = tabulate(ll, headers="firstrow") @@ -82,7 +82,7 @@ def test_list_of_lists_firstrow(): def test_list_of_lists_keys(): "Input: a list of lists with column indices as headers." ll = [["a", "one", 1], ["b", "two", None]] - expected = "\n".join(["0 1 2", "--- --- ---", "a one 1", "b two"]) + expected = "\n".join(["0 1 2", "--- --- ---", "a one 1", "b two "]) result = tabulate(ll, headers="keys") assert_equal(expected, result) @@ -405,8 +405,8 @@ def test_list_of_dicts_with_missing_keys(): [ " foo bar baz", "----- ----- -----", - " 1", - " 2", + " 1 ", + " 2 ", " 4 3", ] ) diff --git a/test/test_output.py b/test/test_output.py index ea3da87..8f6f333 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -3028,7 +3028,7 @@ def test_missingval_multi(): missingval=("n/a", "?"), tablefmt="plain", ) - expected = "Alice Bob Charlie\nn/a ?" + expected = "Alice Bob Charlie\nn/a ? " assert_equal(expected, result) @@ -3053,7 +3053,7 @@ def test_column_emptymissing_deduction(): ? 1.2342 1/3 0.056789 12,345 abc ? -3,333.3 ? +3,333.3 ? ------------ ----------- ---""" assert_equal(expected, result) diff --git a/test/test_regression.py b/test/test_regression.py index 9555676..1ed2ef6 100644 --- a/test/test_regression.py +++ b/test/test_regression.py @@ -205,7 +205,7 @@ def test_88_256_ANSI_color_codes(): def test_column_with_mixed_value_types(): "Regression: mixed value types in the same column (issue #31)" - expected = "\n".join(["-----", "", "a", "я", "0", "False", "-----"]) + expected = "\n".join(["-----", " ", "a", "я", "0", "False", "-----"]) data = [[None], ["a"], ["\u044f"], [0], [False]] table = tabulate(data) assert_equal(table, expected) @@ -415,7 +415,7 @@ def test_escape_empty_cell_in_first_column_in_rst(): def test_ragged_rows(): "Regression: allow rows with different number of columns (issue #85)" table = [[1, 2, 3], [1, 2], [1, 2, 3, 4]] - expected = "\n".join(["- - - -", "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(expected, result)