From b85395f9607ead228e7b2a68b0862602d3df1700 Mon Sep 17 00:00:00 2001 From: AlexXuz Date: Tue, 27 Oct 2026 07:57:25 -0700 Subject: [PATCH 1/2] Fix per-column missing values during text wrapping --- tabulate/__init__.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 12a2950..29c147f 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1626,12 +1626,18 @@ def _wrap_text_to_colwidths( else: num_cols = 0 numparses = _expand_iterable(numparses, num_cols, True) + if isinstance(missingval, str): + missing_vals = [missingval] * num_cols + else: + missing_vals = list(missingval) + if len(missing_vals) < num_cols: + missing_vals.extend([_DEFAULT_MISSINGVAL] * (num_cols - len(missing_vals))) result = [] for row in list_of_lists: new_row = [] - for cell, width, numparse in zip(row, colwidths, numparses): + for cell, width, numparse, miss_v in zip(row, colwidths, numparses, missing_vals): if _isnumber(cell) and numparse: new_row.append(cell) continue @@ -1647,7 +1653,7 @@ def _wrap_text_to_colwidths( # explicit than just `str` of the object. Also doesn't work for # custom floatfmt/intfmt, nor with any missing/blank cells. casted_cell = ( - missingval + miss_v if cell is None else ( str(cell) @@ -2242,6 +2248,10 @@ def tabulate( ) list_of_lists, separating_lines = _remove_separating_lines(list_of_lists) + # Reuse per-column markers across cell wrapping, header wrapping, and formatting. + if not isinstance(missingval, str): + missingval = list(missingval) + if maxcolwidths is not None: if type(maxcolwidths) is tuple: # Check if tuple, convert to list if so maxcolwidths = list(maxcolwidths) From a424e40032f145a9967efdae2426390ac8d48b8b Mon Sep 17 00:00:00 2001 From: AlexXuz Date: Tue, 27 Oct 2026 07:57:28 -0700 Subject: [PATCH 2/2] Cover per-column missing markers and iterators when wrapping --- test/test_textwrapper.py | 66 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/test/test_textwrapper.py b/test/test_textwrapper.py index e6bab0f..8abed81 100644 --- a/test/test_textwrapper.py +++ b/test/test_textwrapper.py @@ -3,6 +3,8 @@ import datetime from textwrap import TextWrapper as OTW +import pytest + from tabulate import _CustomTextWrap as CTW, _strip_ansi, tabulate from common import assert_equal, skip @@ -302,6 +304,70 @@ def test_wrap_none_value_with_missingval(): assert_equal(expected, result) +@pytest.mark.parametrize("container", [list, tuple, iter]) +@pytest.mark.parametrize( + "markers, widths, expected_data", + [ + (["?", "N/A"], [10, 10], [["?", "N/A"], ["Alice", "N/A"]]), + (["?"], [10, 10], [["?", ""], ["Alice", ""]]), + ([], [10, 10], [["", ""], ["Alice", ""]]), + ( + ["not available", "missing value"], + [7, 6], + [["not available", "missing value"], ["Alice", "missing value"]], + ), + ( + ["not available", "missing value"], + [None, 6], + [["not available", "missing value"], ["Alice", "missing value"]], + ), + ], +) +def test_wrap_none_value_with_per_column_missingval(container, markers, widths, expected_data): + """Wrapping uses each column's marker, padding short sequences with empty strings.""" + result = tabulate( + [[None, None], ["Alice", None]], + tablefmt="plain", + maxcolwidths=widths, + missingval=container(markers), + ) + expected = tabulate(expected_data, tablefmt="plain", maxcolwidths=widths) + assert_equal(expected, result) + + +@pytest.mark.parametrize( + "width_options", + [ + {"maxcolwidths": [None, None]}, + {"maxheadercolwidths": 5}, + {"maxcolwidths": [5, 5], "maxheadercolwidths": 5}, + {}, + ], +) +def test_wrap_none_value_with_missingval_iterator(width_options): + """Column markers remain available after wrapping cells and/or headers.""" + data = [[None, "present"]] + headers = ["First column", "Second column"] + result = tabulate( + data, + headers=headers, + tablefmt="plain", + missingval=iter(["?", "N/A"]), + **width_options, + ) + expected = tabulate([["?", "present"]], headers=headers, tablefmt="plain", **width_options) + assert_equal(expected, result) + + +@pytest.mark.parametrize("missingval", ["?", ["?", "N/A"], ("?", "N/A")]) +def test_wrap_per_column_missingval_without_missing_cells(missingval): + """Missing-value markers do not affect non-missing cells.""" + data = [["First Entry", "present"], ["Second Entry", "available"]] + result = tabulate(data, tablefmt="plain", maxcolwidths=[7, 5], missingval=missingval) + expected = tabulate(data, tablefmt="plain", maxcolwidths=[7, 5]) + assert_equal(expected, result) + + def test_wrap_optional_bool_strs(): """TextWrapper: Show that str bools and None can be wrapped without crashing""" data = [