From 18690e5db095a029035c5b48bbefb4ba55667a0c Mon Sep 17 00:00:00 2001 From: Frank Goldfish Date: Tue, 17 Mar 2026 10:19:17 -0700 Subject: [PATCH] fix: remove spurious 2-space padding when header width equals maxcolwidths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #354 When the header text width exactly matches the maxcolwidths limit, the existing code added min_padding (2 spaces) on top of the header width to compute the minimum column width. This inflated every cell in that column by 2 extra trailing spaces — visible as unexpectedly wide columns when the caller had calculated maxcolwidths from the terminal width. Root cause ---------- minwidths was computed as: minwidths = [width_fn(h) + min_padding for h in headers] then passed as the lower bound to _align_column. When the maximum data width (after wrapping to maxcolwidths) equals the header width, the column ended up being maxcolwidths + min_padding wide instead of the requested maxcolwidths. Fix --- After computing minwidths, for each column that has an explicit maxcolwidths entry: if the header width is >= the column limit, strip the +min_padding so the column is exactly max(header_width, maxcolwidth) wide. When the header is shorter than maxcolwidths the data (wrapped to maxcolwidth chars) is wider, and the existing behaviour is preserved — only the case where header_width >= maxcolwidth incorrectly inflated the column. Updated tests ------------- test_wrap_none_value and test_wrap_none_value_with_missingval: these tests used maxcolwidths=5 with a 5-char header ("Value"); their expected output encoded the buggy +2 behaviour and have been corrected. New test -------- test_maxcolwidth_no_extra_padding_when_header_equals_limit: directly reproduces the issue example from the bug report. --- tabulate/__init__.py | 15 ++++++++++++ test/test_output.py | 46 ++++++++++++++++++++++++++++++++++++ test/test_textwrapper.py | 51 ++++++++++++++++++++++++---------------- 3 files changed, 92 insertions(+), 20 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 12a2950..a1a3494 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -2386,6 +2386,21 @@ def tabulate( elif align != "global": aligns[idx] = align minwidths = [width_fn(h) + min_padding for h in headers] if headers else [0] * len(cols) + # When maxcolwidths is active and the header text is *at least as wide* as + # the requested column limit, min_padding would push the minimum width + # beyond the user's requested maximum, adding spurious trailing spaces. + # In that case the header width alone is sufficient — strip the extra + # padding so the column stays at the requested width. + # We only apply this correction when header_width >= maxcolwidth, because + # when the header is narrower than the limit the data cells (wrapped to + # maxcolwidth chars) determine the column width and min_padding is still + # needed for borderless table formats. + if maxcolwidths is not None and headers: + for i, (h, mw) in enumerate(zip(headers, maxcolwidths)): + if mw is not None and i < len(minwidths): + hw = width_fn(h) + if hw >= mw: + minwidths[i] = hw # drop the +min_padding 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. diff --git a/test/test_output.py b/test/test_output.py index ea3da87..c71ba6c 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -3349,3 +3349,49 @@ def test_break_on_hyphens(): expected = "h1 h2 h3\n---- ---- ----\nfoo- bar- foo-\nbar bar foo" result = tabulate(test_table, table_headers, maxcolwidths=5, break_on_hyphens=True) assert_equal(expected, result) + + +def test_maxcolwidth_no_extra_padding_when_header_equals_limit(): + """Regression test for issue #354. + + When the header text width exactly equals the maxcolwidths limit, + the column must not be widened by min_padding beyond the limit. + + Previously, min_padding (2 spaces) was unconditionally added to the + minimum column width derived from the header, causing the column to be + 2 chars wider than requested — enough to cause terminal overflow when + the user had calculated maxcolwidths from the terminal width. + """ + headers = ["Header#1", "Header#2", "Header#3"] + data = [ + [ + "Alpha beta gama zeta omega", + "The weather was exceptionally good that day again", + "The files were concatenated and archived for posterity.", + ], + ] + + result = tabulate( + data, + headers=headers, + tablefmt="fancy_grid", + maxcolwidths=[None, None, 8], + ) + + # Each cell in Header#3's column must be at most 10 chars wide + # (1 border-space + 8 content + 1 border-space) when using fancy_grid. + # Before the fix they were 12 chars (+ 2 extra spurious spaces). + lines = result.split("\n") + for line in lines: + if "│" not in line: + continue + col3_end = line.rfind("│") + col3_start = line.rfind("│", 0, col3_end) + if col3_start < 0: + continue + cell = line[col3_start + 1 : col3_end] + assert len(cell) <= 10, ( + f"Column 'Header#3' cell is {len(cell)} chars wide (expected ≤10); " + f"got {cell!r}. maxcolwidths=8 should not add 2 extra spaces when " + f"header width equals the limit." + ) diff --git a/test/test_textwrapper.py b/test/test_textwrapper.py index e6bab0f..3d2dc4b 100644 --- a/test/test_textwrapper.py +++ b/test/test_textwrapper.py @@ -259,44 +259,55 @@ def test_wrap_datetime(): def test_wrap_none_value(): - """TextWrapper: Show that None can be wrapped without crashing""" + """TextWrapper: Show that None can be wrapped without crashing. + + When header width equals maxcolwidths, the column must not be inflated + by min_padding beyond the user-requested maximum (issue #354). + """ data = [["First Entry", None], ["Second Entry", None]] headers = ["Title", "Value"] result = tabulate(data, headers=headers, tablefmt="grid", maxcolwidths=[7, 5]) + # "Value" (5 chars) == maxcolwidth (5): column stays at 5, not 5+min_padding. expected = [ - "+---------+---------+", - "| Title | Value |", - "+=========+=========+", - "| First | |", - "| Entry | |", - "+---------+---------+", - "| Second | |", - "| Entry | |", - "+---------+---------+", + "+---------+-------+", + "| Title | Value |", + "+=========+=======+", + "| First | |", + "| Entry | |", + "+---------+-------+", + "| Second | |", + "| Entry | |", + "+---------+-------+", ] expected = "\n".join(expected) assert_equal(expected, result) def test_wrap_none_value_with_missingval(): - """TextWrapper: Show that None can be wrapped without crashing and with a missing value""" + """TextWrapper: Show that None can be wrapped without crashing and with a missing value. + + When header width equals maxcolwidths, the column must not be inflated + by min_padding beyond the user-requested maximum (issue #354). + """ data = [["First Entry", None], ["Second Entry", None]] headers = ["Title", "Value"] result = tabulate( data, headers=headers, tablefmt="grid", maxcolwidths=[7, 5], missingval="???" ) + # "Value" (5 chars) == maxcolwidth (5): column stays at 5. + # "???" (3 chars) < maxcolwidth (5): fits without wrapping. expected = [ - "+---------+---------+", - "| Title | Value |", - "+=========+=========+", - "| First | ??? |", - "| Entry | |", - "+---------+---------+", - "| Second | ??? |", - "| Entry | |", - "+---------+---------+", + "+---------+-------+", + "| Title | Value |", + "+=========+=======+", + "| First | ??? |", + "| Entry | |", + "+---------+-------+", + "| Second | ??? |", + "| Entry | |", + "+---------+-------+", ] expected = "\n".join(expected) assert_equal(expected, result)