From 3eb1f35e8de628822d2a3bf9b062fb8e12dcc1c8 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Tue, 10 Mar 2026 12:43:57 +0100 Subject: [PATCH 1/5] add tests for usage without wcwidth library --- tabulate/__init__.py | 6 +- test/test_no_wcwidth.py | 172 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 test/test_no_wcwidth.py diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 24e9fa5..69392c6 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -17,12 +17,12 @@ try: import wcwidth # optional wide-character (CJK) support -except ImportError: +except ImportError: # pragma: no cover wcwidth = None try: __version__ = version("tabulate") # installed package -except PackageNotFoundError: +except PackageNotFoundError: # pragma: no cover try: from ._version import version as __version__ # editable / source checkout except ImportError: @@ -2894,7 +2894,7 @@ def _wrap_chunks(self, chunks): return lines -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no cover from .cli import _main _main() diff --git a/test/test_no_wcwidth.py b/test/test_no_wcwidth.py new file mode 100644 index 0000000..7f81626 --- /dev/null +++ b/test/test_no_wcwidth.py @@ -0,0 +1,172 @@ +"""Tests for code paths executed when the wcwidth library is not available. + +These tests mock wcwidth as None to cover branches that would otherwise only +run in environments without wcwidth installed. +""" + +from unittest.mock import patch + +import tabulate as T +from tabulate import tabulate + +from common import assert_equal + + +def _patch_no_wcwidth(): + """Return a context manager that simulates wcwidth being unavailable.""" + return ( + patch.object(T, "wcwidth", None), + patch.object(T, "WIDE_CHARS_MODE", False), + ) + + +# --------------------------------------------------------------------------- +# _visible_width() fallback paths +# --------------------------------------------------------------------------- + + +def test_visible_width_str_no_wcwidth(): + "Internal: _visible_width() falls back to len() for str when wcwidth is None" + with patch.object(T, "wcwidth", None), patch.object(T, "WIDE_CHARS_MODE", False): + assert T._visible_width("hello") == 5 + # ANSI codes must still be stripped + assert T._visible_width("\x1b[31mhello\x1b[0m") == 5 + # Wide chars are counted as 1 each (no wcwidth) + assert T._visible_width("配列") == 2 + + +def test_visible_width_bytes_no_wcwidth(): + "Internal: _visible_width() falls back to len() for bytes when wcwidth is None" + with patch.object(T, "wcwidth", None), patch.object(T, "WIDE_CHARS_MODE", False): + assert T._visible_width(b"hello") == 5 + + +def test_visible_width_non_string_no_wcwidth(): + "Internal: _visible_width() falls back to len(str(...)) for non-strings when wcwidth is None" + with patch.object(T, "wcwidth", None), patch.object(T, "WIDE_CHARS_MODE", False): + assert T._visible_width(12345) == 5 + assert T._visible_width(3.14) == 4 + + +# --------------------------------------------------------------------------- +# _choose_width_fn() and _align_column_choose_width_fn() fallback paths +# --------------------------------------------------------------------------- + + +def test_choose_width_fn_no_wcwidth_no_invisible(): + "Internal: _choose_width_fn() returns len when wcwidth is None and no invisible chars" + with patch.object(T, "wcwidth", None), patch.object(T, "WIDE_CHARS_MODE", False): + fn = T._choose_width_fn(has_invisible=False, enable_widechars=False, is_multiline=False) + assert fn is len + + +def test_choose_width_fn_no_wcwidth_multiline(): + "Internal: _choose_width_fn() wraps len in multiline handler when wcwidth is None" + with patch.object(T, "wcwidth", None), patch.object(T, "WIDE_CHARS_MODE", False): + fn = T._choose_width_fn(has_invisible=False, enable_widechars=False, is_multiline=True) + # The result is a lambda, not len directly, but it should compute max line length + assert fn("foo\nbarbaz") == 6 + + +def test_align_column_choose_width_fn_no_wcwidth(): + "Internal: _align_column_choose_width_fn() returns len when wcwidth is None" + with patch.object(T, "wcwidth", None), patch.object(T, "WIDE_CHARS_MODE", False): + fn = T._align_column_choose_width_fn( + has_invisible=False, enable_widechars=False, is_multiline=False + ) + assert fn is len + + +def test_align_column_choose_width_fn_no_wcwidth_multiline(): + "Internal: _align_column_choose_width_fn() returns per-line widths list for multiline when wcwidth is None" + with patch.object(T, "wcwidth", None), patch.object(T, "WIDE_CHARS_MODE", False): + fn = T._align_column_choose_width_fn( + has_invisible=False, enable_widechars=False, is_multiline=True + ) + # _align_column_multiline_width returns a list of widths per line + assert fn("foo\nbarbaz") == [3, 6] + + +# --------------------------------------------------------------------------- +# _CustomTextWrap._len() fallback path +# --------------------------------------------------------------------------- + + +def test_textwrapper_len_no_wcwidth(): + "Internal: _CustomTextWrap._len() falls back to len() when wcwidth is None" + with patch.object(T, "wcwidth", None), patch.object(T, "WIDE_CHARS_MODE", False): + assert T._CustomTextWrap._len("hello") == 5 + assert T._CustomTextWrap._len("\x1b[31mhello\x1b[0m") == 5 + + +# --------------------------------------------------------------------------- +# End-to-end tabulate() with wide characters, no wcwidth +# --------------------------------------------------------------------------- + + +def test_tabulate_wide_chars_no_wcwidth_grid(): + "Output: grid with wide characters treats them as width-1 when wcwidth is None" + with patch.object(T, "wcwidth", None), patch.object(T, "WIDE_CHARS_MODE", False): + table = [["spam", 41.9999], ["eggs", "451.0"]] + headers = ["strings", "配列"] + result = tabulate(table, headers, tablefmt="grid") + # With no wcwidth, "配列" is treated as 2 chars wide (not 4), + # so column width matches len("配列") == 2, padded to fit content. + # We only assert the result is a non-empty string and doesn't crash; + # the exact layout depends on len()-based widths. + assert len(result) > 0 + assert "配列" in result + assert "spam" in result + expected = "\n".join( + [ + "+-----------+----------+", + "| strings | 配列 |", + "+===========+==========+", + "| spam | 41.9999 |", + "+-----------+----------+", + "| eggs | 451 |", + "+-----------+----------+", + ] + ) + assert_equal(expected.splitlines(), result.splitlines()) + + +def test_tabulate_wide_chars_no_wcwidth_plain(): + "Output: plain with wide characters uses len() when wcwidth is None" + with patch.object(T, "wcwidth", None), patch.object(T, "WIDE_CHARS_MODE", False): + table = [["привет", 1], ["你好", 2]] + result = tabulate(table, tablefmt="plain") + assert "привет" in result + assert "你好" in result + + +def test_tabulate_wide_chars_no_wcwidth_simple_grid(): + "Output: simple_grid with wide characters uses len() when wcwidth is None" + with patch.object(T, "wcwidth", None), patch.object(T, "WIDE_CHARS_MODE", False): + table = [["가나", "abc"], ["de", "fgh"]] + result = tabulate(table, tablefmt="simple_grid") + assert "가나" in result + assert len(result) > 0 + + +# --------------------------------------------------------------------------- +# maxcolwidths path through CustomTextWrapper when wcwidth is None +# --------------------------------------------------------------------------- + + +def test_maxcolwidths_no_wcwidth(): + "Output: maxcolwidths autowrap uses len() when wcwidth is None" + with patch.object(T, "wcwidth", None), patch.object(T, "WIDE_CHARS_MODE", False): + table = [["hdr", "fold"], ["1", "very long data"]] + expected = "\n".join([" hdr fold", " 1 very long", " data"]) + result = tabulate(table, headers="firstrow", tablefmt="plain", maxcolwidths=[10, 10]) + assert_equal(expected, result) + + +def test_maxcolwidths_wide_chars_no_wcwidth(): + "Output: maxcolwidths with wide chars wraps by byte-len when wcwidth is None" + with patch.object(T, "wcwidth", None), patch.object(T, "WIDE_CHARS_MODE", False): + table = [["hdr", "fold"], ["1", "약간 감싸면 더 잘 보일 수있는 긴 설명"]] + result = tabulate(table, headers="firstrow", tablefmt="plain", maxcolwidths=[10, 10]) + assert "hdr" in result + assert len(result) > 0 From b14c1f1e673365c839cf30ef21f6491ae8979645 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Tue, 10 Mar 2026 18:05:10 +0100 Subject: [PATCH 2/5] add tests for missing colon_grid and pipe format test cases --- test/test_internal.py | 14 ++++++++++++++ test/test_output.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/test/test_internal.py b/test/test_internal.py index 49ae0ba..9f4e095 100644 --- a/test/test_internal.py +++ b/test/test_internal.py @@ -340,6 +340,20 @@ def test__remove_separating_lines(): assert_equal("2|4|6", cols_to_pipe_str(sep_lines)) +def test_pipe_segment_with_colons_center(): + "Internal: _pipe_segment_with_colons() center branch returns ':' + dashes + ':'" + assert T._pipe_segment_with_colons("center", 6) == ":----:" + assert T._pipe_segment_with_colons("center", 4) == ":--:" + assert T._pipe_segment_with_colons("center", 3) == ":-:" + + +def test_grid_segment_with_colons_else(): + "Internal: _grid_segment_with_colons() else branch returns '=' * width for non-standard alignment" + assert T._grid_segment_with_colons(6, "decimal") == "======" + assert T._grid_segment_with_colons(5, "") == "=====" + assert T._grid_segment_with_colons(4, "decimal") == "====" + + def test__reinsert_separating_lines(): with_rows = [ [0, "a"], diff --git a/test/test_output.py b/test/test_output.py index ea3da87..8baffc3 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -1626,6 +1626,23 @@ def test_colon_grid_with_empty_cells(): assert_equal(expected, result) +def test_colon_grid_with_decimal_colalign(): + "Output: colon_grid with decimal alignment uses '======' separator segments (else branch)" + expected = "\n".join( + [ + "+------+------+", + "| H1 | H2 |", + "+======+======+", + "| 3 | 4 |", + "+------+------+", + ] + ) + result = tabulate( + [[3, 4]], headers=("H1", "H2"), tablefmt="colon_grid", colalign=["decimal", "decimal"] + ) + assert_equal(expected, result) + + def test_outline(): "Output: outline with headers" expected = "\n".join( @@ -2011,6 +2028,21 @@ def test_pipe_headerless(): assert_equal(expected, result) +def test_pipe_with_center_colalign(): + "Output: pipe with center column alignment uses ':--:' separator segments" + expected = "\n".join( + [ + "| H1 | H2 |", + "|:----:|:----:|", + "| 3 | 4 |", + ] + ) + result = tabulate( + [[3, 4]], headers=("H1", "H2"), tablefmt="pipe", colalign=["center", "center"] + ) + assert_equal(expected, result) + + def test_presto(): "Output: presto with headers" expected = "\n".join( From 5e0031c452148e0b697fd0418529090907bb4f56 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 11 Mar 2026 09:25:08 +0100 Subject: [PATCH 3/5] add test for _is_multiline with bytes input --- test/test_internal.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/test_internal.py b/test/test_internal.py index 9f4e095..0410a41 100644 --- a/test/test_internal.py +++ b/test/test_internal.py @@ -340,6 +340,14 @@ def test__remove_separating_lines(): assert_equal("2|4|6", cols_to_pipe_str(sep_lines)) +def test_is_multiline_bytes(): + "Internal: _is_multiline() bytes branch detects newlines in bytestrings" + assert T._is_multiline(b"foo\nbar") is True + assert T._is_multiline(b"foo\rbar") is True + assert T._is_multiline(b"foo\r\nbar") is True + assert T._is_multiline(b"foobar") is False + + def test_pipe_segment_with_colons_center(): "Internal: _pipe_segment_with_colons() center branch returns ':' + dashes + ':'" assert T._pipe_segment_with_colons("center", 6) == ":----:" From 443874bcb03ca912aa438f0e2c461147c7ca78d7 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 11 Mar 2026 09:32:44 +0100 Subject: [PATCH 4/5] add pragma: no cover in __main__.py --- tabulate/__main__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tabulate/__main__.py b/tabulate/__main__.py index c6efd79..73c9de4 100644 --- a/tabulate/__main__.py +++ b/tabulate/__main__.py @@ -1,3 +1,3 @@ -from tabulate.cli import _main +from tabulate.cli import _main # pragma: no cover -_main() +_main() # pragma: no cover From 38c502972536fe0edd30ea3b327041dcb151aff0 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 11 Mar 2026 09:45:25 +0100 Subject: [PATCH 5/5] add tests for untested branches of _CustomTextWrap._wrap_chunks --- test/test_textwrapper.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/test/test_textwrapper.py b/test/test_textwrapper.py index e6bab0f..b7e797d 100644 --- a/test/test_textwrapper.py +++ b/test/test_textwrapper.py @@ -346,6 +346,41 @@ def test_wrap_wide_char_no_column_overflow(): ) +def test_wrap_max_lines_placeholder_on_current_line(): + """TextWrapper: max_lines truncation appends placeholder to current line after + popping trailing words that don't leave room for it (while-loop A.inner branch)""" + # Line 2 has "four five six" but "six" gets popped, then "five [...]" fits in 15 + wrapper = CTW(width=15, max_lines=2) + result = wrapper.wrap("one two three four five six seven eight") + assert_equal(["one two three", "four five [...]"], result) + + +def test_wrap_max_lines_placeholder_appended_to_previous_line(): + """TextWrapper: max_lines truncation appends placeholder to the previous line + when the current line is entirely too long (while-loop else, B.1.a branch)""" + # "toolong" alone overflows with placeholder, but prev line "ab" has room for " [...]" + wrapper = CTW(width=8, max_lines=2) + result = wrapper.wrap("ab toolong extra") + assert_equal(["ab [...]"], result) + + +def test_wrap_max_lines_placeholder_alone_no_previous_lines(): + """TextWrapper: max_lines=1 with an unbreakable word emits placeholder alone + when there are no previous lines (while-loop else, B.2 branch)""" + wrapper = CTW(width=5, max_lines=1, break_long_words=False) + result = wrapper.wrap("toolong extra") + assert_equal(["[...]"], result) + + +def test_wrap_max_lines_placeholder_alone_previous_line_too_full(): + """TextWrapper: max_lines truncation emits placeholder as a new line when + the previous line has no room for it either (while-loop else, B.1.b branch)""" + # prev line "hello"(5) + " [...]"(6) = 11 > width(5), so placeholder becomes its own line + wrapper = CTW(width=5, max_lines=2, break_long_words=False) + result = wrapper.wrap("hello toolong extra") + assert_equal(["hello", "[...]"], result) + + def test_wrap_wide_char_narrower_than_char_width(): """TextWrapper: column width smaller than a single wide char must not hang (issue #399).