From 0978de5bb3a72d9b285d0e305aaf4574e9ffd5bd Mon Sep 17 00:00:00 2001 From: Bjorn Olsen Date: Tue, 23 May 2023 21:55:13 +0200 Subject: [PATCH 001/116] Fix separating line with dataclasses --- tabulate/__init__.py | 7 ++++++- test/test_input.py | 24 +++++++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 11bb8655..f95da8e6 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1461,7 +1461,12 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): field_names = [field.name for field in dataclasses.fields(rows[0])] if headers == "keys": headers = field_names - rows = [[getattr(row, f) for f in field_names] for row in rows] + rows = [ + [getattr(row, f) for f in field_names] + if not _is_separating_line(row) + else row + for row in rows + ] elif headers == "keys" and len(rows) > 0: # keys are column indices diff --git a/test/test_input.py b/test/test_input.py index a178bd9d..d52f10bc 100644 --- a/test/test_input.py +++ b/test/test_input.py @@ -1,6 +1,6 @@ """Test support of the various forms of tabular data.""" -from tabulate import tabulate +from tabulate import tabulate, SEPARATING_LINE from common import assert_equal, assert_in, raises, skip try: @@ -520,6 +520,28 @@ def test_py37orlater_list_of_dataclasses_headers(): skip("test_py37orlater_list_of_dataclasses_headers is skipped") +def test_py37orlater_list_of_dataclasses_with_separating_line(): + "Input: a list of dataclasses with a separating line" + try: + from dataclasses import make_dataclass + + Person = make_dataclass("Person", ["name", "age", "height"]) + ld = [Person("Alice", 23, 169.5), SEPARATING_LINE, Person("Bob", 27, 175.0)] + result = tabulate(ld, headers="keys") + expected = "\n".join( + [ + "name age height", + "------ ----- --------", + "Alice 23 169.5", + "------ ----- --------", + "Bob 27 175", + ] + ) + assert_equal(expected, result) + except ImportError: + skip("test_py37orlater_list_of_dataclasses_keys is skipped") + + def test_list_bytes(): "Input: a list of bytes. (issue #192)" lb = [["你好".encode("utf-8")], ["你好"]] From 392be0f2f02116baac48838b06970829b46c1cd7 Mon Sep 17 00:00:00 2001 From: Adam Lugowski Date: Sun, 27 Aug 2023 23:34:46 -0700 Subject: [PATCH 002/116] Fix separating line detection with ndarray values This code: ``` import numpy as np from tabulate import tabulate data = [[np.ones(1)]] print(tabulate(data)) ``` Throws a `FutureWarning` or a `ValueError` due to the comparison with `SEPARATING_LINE`. Fixes https://github.com/astanin/python-tabulate/issues/287 --- tabulate/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 11bb8655..22e12882 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -105,8 +105,8 @@ def _is_file(f): def _is_separating_line(row): row_type = type(row) is_sl = (row_type == list or row_type == str) and ( - (len(row) >= 1 and row[0] == SEPARATING_LINE) - or (len(row) >= 2 and row[1] == SEPARATING_LINE) + (len(row) >= 1 and row[0] is SEPARATING_LINE) + or (len(row) >= 2 and row[1] is SEPARATING_LINE) ) return is_sl From d29909b4fb17e2bbf62dde8bc730e9f29240155f Mon Sep 17 00:00:00 2001 From: Hashem Nasarat Date: Mon, 17 Mar 2025 16:47:49 -0400 Subject: [PATCH 003/116] Fix handling "True"/"False" bool str and None calling _type is incorrect here; the subsequent lines need a `str`. Also you can't reconstruct `None` with `type(None)(None)` anyway. This regressed in e8e3091d46966f462735f227d5ef4effdfaa0582 Fixes #305 --- tabulate/__init__.py | 8 +------- test/test_textwrapper.py | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index b15d7f02..b937d6cb 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1641,13 +1641,7 @@ def _wrap_text_to_colwidths(list_of_lists, colwidths, numparses=True): if width is not None: wrapper = _CustomTextWrap(width=width) - # Cast based on our internal type handling. Any future custom - # formatting of types (such as datetimes) may need to be more - # explicit than just `str` of the object. Also doesn't work for - # custom floatfmt/intfmt, nor with any missing/blank cells. - casted_cell = ( - str(cell) if _isnumber(cell) else _type(cell, numparse)(cell) - ) + casted_cell = str(cell) wrapped = [ "\n".join(wrapper.wrap(line)) for line in casted_cell.splitlines() diff --git a/test/test_textwrapper.py b/test/test_textwrapper.py index 8c0a6cc7..46dd818d 100644 --- a/test/test_textwrapper.py +++ b/test/test_textwrapper.py @@ -220,3 +220,27 @@ def test_wrap_datetime(): ] expected = "\n".join(expected) assert_equal(expected, result) + + +def test_wrap_optional_bool_strs(): + """TextWrapper: Show that str bools and None can be wrapped without crashing""" + data = [ + ["First Entry", "True"], + ["Second Entry", None], + ] + headers = ["Title", "When"] + result = tabulate(data, headers=headers, tablefmt="grid", maxcolwidths=[7, 5]) + + expected = [ + "+---------+--------+", + "| Title | When |", + "+=========+========+", + "| First | True |", + "| Entry | |", + "+---------+--------+", + "| Second | None |", + "| Entry | |", + "+---------+--------+", + ] + expected = "\n".join(expected) + assert_equal(expected, result) From 9dedd450439610e326b79b15cb1ece36ee968766 Mon Sep 17 00:00:00 2001 From: Mikhail Korobkin Date: Tue, 6 May 2025 15:05:05 +0300 Subject: [PATCH 004/116] Add missed multiline support for github --- tabulate/__init__.py | 1 + test/test_output.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index b15d7f02..d418e96f 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -737,6 +737,7 @@ def escape_empty(val): "pretty": "pretty", "psql": "psql", "rst": "rst", + "github": "github", "outline": "outline", "simple_outline": "simple_outline", "rounded_outline": "rounded_outline", diff --git a/test/test_output.py b/test/test_output.py index e3d369ae..2a1415b2 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -509,6 +509,23 @@ def test_github(): assert_equal(expected, result) +def test_github_multiline(): + "Output: github with multiline cells with headers" + table = [[2, "foo\nbar"]] + headers = ("more\nspam eggs", "more spam\n& eggs") + expected = "\n".join( + [ + "| more | more spam |", + "| spam eggs | & eggs |", + "|-------------|-------------|", + "| 2 | foo |", + "| | bar |", + ] + ) + result = tabulate(table, headers, tablefmt="github") + assert_equal(expected, result) + + def test_grid(): "Output: grid with headers" expected = "\n".join( From 3c9d404c0189f4cdaf1a58e3413cd88675d5ae35 Mon Sep 17 00:00:00 2001 From: "Jun, Koo" Date: Mon, 26 May 2025 04:00:34 +0900 Subject: [PATCH 005/116] fix(docs): Correct typo in ANSI support section --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f3d0fa92..dedbf729 100644 --- a/README.md +++ b/README.md @@ -1098,7 +1098,7 @@ table, however, ANSI escape sequences are not removed so the original styling is Some terminals support a special grouping of ANSI escape sequences that are intended to display hyperlinks much in the same way they are shown in browsers. These are handled just as mentioned before: non-printable -ANSI escape sequences are removed prior to string length calculation. The only diifference with escaped +ANSI escape sequences are removed prior to string length calculation. The only difference with escaped hyperlinks is that column width will be based on the length of the URL _text_ rather than the URL itself (terminals would show this text). For example: From 7a19c30763c8f6f52161ac77bb233353d7b2d1a9 Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Wed, 23 Jul 2025 18:58:33 +0200 Subject: [PATCH 006/116] PEP 639 compliance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Declare licenses using only these two fields, as per PEP 639: * license: SPDX license expression consisting of one or more license identifiers * license-files: list of license file glob patterns Supported by setuptools ≥ 77.0, or perhaps setuptools ≥ 77.0.3 which irons out some bugs: https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#license-and-license-files --- pyproject.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4144f9b9..d13e92d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,16 +1,16 @@ [build-system] -requires = ["setuptools>=61.2.0", "setuptools_scm[toml]>=3.4.3"] +requires = ["setuptools>=77.0.3", "setuptools_scm[toml]>=3.4.3"] build-backend = "setuptools.build_meta" [project] name = "tabulate" authors = [{name = "Sergey Astanin", email = "s.astanin@gmail.com"}] -license = {text = "MIT"} +license = "MIT" +license-files = ["LICENSE"] description = "Pretty-print tabular data" readme = "README.md" classifiers = [ "Development Status :: 4 - Beta", - "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.9", From 34bd63aaca3e6b68a11f508f82e47cc4d35ee54b Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Wed, 23 Jul 2025 19:07:10 +0200 Subject: [PATCH 007/116] Fix typo found by codespell --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6c09f0ec..223a85a2 100644 --- a/README.md +++ b/README.md @@ -819,7 +819,7 @@ methods `__str__` and `__float__` defined (and hence is convertible to a `float` and also has a `str` representation), the appropriate representation is selected for the column's deduced type. In order to not lose precision accidentally, types having both an `__int__` and -`__float__` represention will be considered a `float`. +`__float__` representation will be considered a `float`. Therefore, if your table contains types convertible to int/float but you'd *prefer* they be represented as strings, or your strings *might* all look From f967017d7d09d8aa7e91150946131543aeaf5db4 Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Sat, 13 Sep 2025 16:08:16 +0200 Subject: [PATCH 008/116] Robust test for callable objects - If the object implements a custom `__getattr__`, or if its `__call__` is itself not callable, you may get misleading results. - Instead use the built-in `callable` function. Not only is it more robust, but it makes the intent clear and the code more readable. --- tabulate/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index e100c097..e1756f84 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1480,7 +1480,7 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): index = None if hasattr(tabular_data, "keys") and hasattr(tabular_data, "values"): # dict-like and pandas.DataFrame? - if hasattr(tabular_data.values, "__call__"): + if callable(tabular_data.values): # likely a conventional dict keys = tabular_data.keys() try: @@ -2515,7 +2515,7 @@ def _build_row(padded_cells, colwidths, colaligns, rowfmt): "Return a string which represents a row of data cells." if not rowfmt: return None - if hasattr(rowfmt, "__call__"): + if callable(rowfmt): return rowfmt(padded_cells, colwidths, colaligns) else: return _build_simple_row(padded_cells, rowfmt) @@ -2566,7 +2566,7 @@ def _build_line(colwidths, colaligns, linefmt): "Return a string which represents a horizontal line." if not linefmt: return None - if hasattr(linefmt, "__call__"): + if callable(linefmt): return linefmt(colwidths, colaligns) else: begin, fill, sep, end = linefmt From 3fdc1302ecbb5d28458ddef389f5ff839c474ff8 Mon Sep 17 00:00:00 2001 From: J08nY Date: Thu, 19 Oct 2023 13:10:57 +0200 Subject: [PATCH 009/116] Fix asciidoc alignment Fixes #256. --- tabulate/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index e100c097..5816e058 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -259,7 +259,7 @@ def make_header_line(is_header, colwidths, colaligns): colwidths, [alignment[colalign] for colalign in colaligns] ) asciidoc_column_specifiers = [ - f"{width:d}{align}" for width, align in asciidoc_alignments + f"{align}{width:d}" for width, align in asciidoc_alignments ] header_list = ['cols="' + (",".join(asciidoc_column_specifiers)) + '"'] From 7a2e5f3dc4b08a7ca85e0ca065daee216b0f0e34 Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Sat, 13 Sep 2025 18:19:26 +0200 Subject: [PATCH 010/116] Fix asciidoc tests and docs accordingly --- README.md | 2 +- test/test_output.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 223a85a2..5bbe2521 100644 --- a/README.md +++ b/README.md @@ -501,7 +501,7 @@ format: ```pycon >>> print(tabulate(table, headers, tablefmt="asciidoc")) -[cols="8<,7>",options="header"] +[cols="<8,>7",options="header"] |==== | item | qty | spam | 42 diff --git a/test/test_output.py b/test/test_output.py index 12dfc3a3..58373795 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -2101,7 +2101,7 @@ def test_asciidoc(): "Output: asciidoc with headers" expected = "\n".join( [ - '[cols="11<,11>",options="header"]', + '[cols="<11,>11",options="header"]', "|====", "| strings | numbers ", "| spam | 41.9999 ", @@ -2117,7 +2117,7 @@ def test_asciidoc_headerless(): "Output: asciidoc without headers" expected = "\n".join( [ - '[cols="6<,10>"]', + '[cols="<6,>10"]', "|====", "| spam | 41.9999 ", "| eggs | 451 ", From bb1bf9f409788057f3355f034f90013858270a12 Mon Sep 17 00:00:00 2001 From: George Schizas Date: Wed, 5 Nov 2025 15:53:18 +0200 Subject: [PATCH 011/116] Fix crash when cell is empty --- tabulate/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index a7826832..0137f837 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1646,7 +1646,7 @@ def _wrap_text_to_colwidths(list_of_lists, colwidths, numparses=True, missingval # explicit than just `str` of the object. Also doesn't work for # custom floatfmt/intfmt, nor with any missing/blank cells. casted_cell = ( - missingval if cell is None else str(cell) if _isnumber(cell) else _type(cell, numparse)(cell) + missingval if cell is None else str(cell) if cell == '' or _isnumber(cell) else _type(cell, numparse)(cell) ) wrapped = [ "\n".join(wrapper.wrap(line)) From c59a5ff3ef898464df25f8bb97a611c9c4ff5013 Mon Sep 17 00:00:00 2001 From: Kadir Can Ozden <101993364+bysiber@users.noreply.github.com> Date: Sun, 22 Feb 2026 02:03:39 +0300 Subject: [PATCH 012/116] Handle ValueError in _format when float conversion fails When a column contains both bool-like strings ('True'/'False') and numeric strings, the column type is detected as float. Formatting then crashes with ValueError on float('False'). Catch ValueError and TypeError in the float branch of _format and fall back to string formatting. Fixes #209 --- tabulate/__init__.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index e100c097..7c180155 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1352,12 +1352,18 @@ def _format(val, valtype, floatfmt, intfmt, missingval="", has_invisible=True): is_a_colored_number = has_invisible and isinstance(val, (str, bytes)) if is_a_colored_number: raw_val = _strip_ansi(val) - formatted_val = format(float(raw_val), floatfmt) + try: + formatted_val = format(float(raw_val), floatfmt) + except (ValueError, TypeError): + return f"{val}" return val.replace(raw_val, formatted_val) else: if isinstance(val, str) and "," in val: val = val.replace(",", "") # handle thousands-separators - return format(float(val), floatfmt) + try: + return format(float(val), floatfmt) + except (ValueError, TypeError): + return f"{val}" else: return f"{val}" From 886e2ed87b499e8485882da0ea5f5b122ca1d55a Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 4 Mar 2026 17:26:37 +0100 Subject: [PATCH 013/116] fix conflicting wrapping behavior for missing (None) values --- tabulate/__init__.py | 2 +- test/test_textwrapper.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index a1bdfa21..7d63ac89 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1661,7 +1661,7 @@ def _wrap_text_to_colwidths(list_of_lists, colwidths, numparses=True, missingval # explicit than just `str` of the object. Also doesn't work for # custom floatfmt/intfmt, nor with any missing/blank cells. casted_cell = ( - missingval if cell is None else str(cell) if cell == '' or _isnumber(cell) else _type(cell, numparse)(cell) + missingval if cell is None else str(cell) if cell == '' or _isnumber(cell) else str(_type(cell, numparse)(cell)) ) wrapped = [ "\n".join(wrapper.wrap(line)) diff --git a/test/test_textwrapper.py b/test/test_textwrapper.py index ea07c8bf..8c43bb21 100644 --- a/test/test_textwrapper.py +++ b/test/test_textwrapper.py @@ -282,7 +282,7 @@ def test_wrap_optional_bool_strs(): "| First | True |", "| Entry | |", "+---------+--------+", - "| Second | None |", + "| Second | |", "| Entry | |", "+---------+--------+", ] From 5a579fc79bcb40f7ee6c655bd3a98af9df5a6558 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 4 Mar 2026 17:26:37 +0100 Subject: [PATCH 014/116] fix "DeprecationWarning: Data type alias 'a' was deprecated in NumPy 2.0. Use the 'S' alias instead." --- test/test_input.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/test_input.py b/test/test_input.py index 8368770e..49f44173 100644 --- a/test/test_input.py +++ b/test/test_input.py @@ -173,7 +173,7 @@ def test_numpy_record_array(): [("Alice", 23, 169.5), ("Bob", 27, 175.0)], dtype={ "names": ["name", "age", "height"], - "formats": ["a32", "uint8", "float32"], + "formats": ["S32", "uint8", "float32"], }, ) expected = "\n".join( @@ -199,7 +199,7 @@ def test_numpy_record_array_keys(): [("Alice", 23, 169.5), ("Bob", 27, 175.0)], dtype={ "names": ["name", "age", "height"], - "formats": ["a32", "uint8", "float32"], + "formats": ["S32", "uint8", "float32"], }, ) expected = "\n".join( @@ -225,7 +225,7 @@ def test_numpy_record_array_headers(): [("Alice", 23, 169.5), ("Bob", 27, 175.0)], dtype={ "names": ["name", "age", "height"], - "formats": ["a32", "uint8", "float32"], + "formats": ["S32", "uint8", "float32"], }, ) expected = "\n".join( From f0a5df6ee72fa1bb27596e43b64ff2746fee284b Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 4 Mar 2026 17:26:37 +0100 Subject: [PATCH 015/116] apply pre_commit hooks (black) --- README.md | 12 ++++++------ tabulate/__init__.py | 35 ++++++++++++++++++++++++++++++----- test/test_output.py | 2 ++ test/test_textwrapper.py | 4 ++-- 4 files changed, 40 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 223a85a2..5c69cd93 100644 --- a/README.md +++ b/README.md @@ -503,10 +503,10 @@ format: >>> print(tabulate(table, headers, tablefmt="asciidoc")) [cols="8<,7>",options="header"] |==== -| item | qty -| spam | 42 -| eggs | 451 -| bacon | 0 +| item | qty +| spam | 42 +| eggs | 451 +| bacon | 0 |==== ``` @@ -1065,11 +1065,11 @@ the lines being wrapped would probably be significantly longer than this. Text is preferably wrapped on whitespaces and right after the hyphens in hyphenated words. -break_long_words (default: True) If true, then words longer than width will be broken in order to ensure that no lines are longer than width. +break_long_words (default: True) If true, then words longer than width will be broken in order to ensure that no lines are longer than width. If it is false, long words will not be broken, and some lines may be longer than width. (Long words will be put on a line by themselves, in order to minimize the amount by which width is exceeded.) -break_on_hyphens (default: True) If true, wrapping will occur preferably on whitespaces and right after hyphens in compound words, as it is customary in English. +break_on_hyphens (default: True) If true, wrapping will occur preferably on whitespaces and right after hyphens in compound words, as it is customary in English. If false, only whitespaces will be considered as potentially good places for line breaks. ```pycon diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 7d63ac89..ee646ad7 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1638,7 +1638,14 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): return rows, headers, headers_pad -def _wrap_text_to_colwidths(list_of_lists, colwidths, numparses=True, missingval=_DEFAULT_MISSINGVAL, break_long_words=_BREAK_LONG_WORDS, break_on_hyphens=_BREAK_ON_HYPHENS): +def _wrap_text_to_colwidths( + list_of_lists, + colwidths, + numparses=True, + missingval=_DEFAULT_MISSINGVAL, + break_long_words=_BREAK_LONG_WORDS, + break_on_hyphens=_BREAK_ON_HYPHENS, +): if len(list_of_lists): num_cols = len(list_of_lists[0]) else: @@ -1655,13 +1662,21 @@ def _wrap_text_to_colwidths(list_of_lists, colwidths, numparses=True, missingval continue if width is not None: - wrapper = _CustomTextWrap(width=width, break_long_words=break_long_words, break_on_hyphens=break_on_hyphens) + wrapper = _CustomTextWrap( + width=width, + break_long_words=break_long_words, + break_on_hyphens=break_on_hyphens, + ) # Cast based on our internal type handling. Any future custom # formatting of types (such as datetimes) may need to be more # explicit than just `str` of the object. Also doesn't work for # custom floatfmt/intfmt, nor with any missing/blank cells. casted_cell = ( - missingval if cell is None else str(cell) if cell == '' or _isnumber(cell) else str(_type(cell, numparse)(cell)) + missingval + if cell is None + else str(cell) + if cell == "" or _isnumber(cell) + else str(_type(cell, numparse)(cell)) ) wrapped = [ "\n".join(wrapper.wrap(line)) @@ -2264,7 +2279,12 @@ def tabulate( numparses = _expand_numparse(disable_numparse, num_cols) list_of_lists = _wrap_text_to_colwidths( - list_of_lists, maxcolwidths, numparses=numparses, missingval=missingval, break_long_words=break_long_words, break_on_hyphens=break_on_hyphens + list_of_lists, + maxcolwidths, + numparses=numparses, + missingval=missingval, + break_long_words=break_long_words, + break_on_hyphens=break_on_hyphens, ) if maxheadercolwidths is not None: @@ -2278,7 +2298,12 @@ def tabulate( numparses = _expand_numparse(disable_numparse, num_cols) headers = _wrap_text_to_colwidths( - [headers], maxheadercolwidths, numparses=numparses, missingval=missingval, break_long_words=break_long_words, break_on_hyphens=break_on_hyphens + [headers], + maxheadercolwidths, + numparses=numparses, + missingval=missingval, + break_long_words=break_long_words, + break_on_hyphens=break_on_hyphens, )[0] # empty values in the first column of RST tables should be escaped (issue #82) diff --git a/test/test_output.py b/test/test_output.py index 12dfc3a3..d7c225b4 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -3320,6 +3320,7 @@ def test_preserve_whitespace(): result = tabulate(test_table, table_headers, preserve_whitespace=False) assert_equal(expected, result) + def test_break_long_words(): "Output: Default table output, with breakwords true." table_headers = ["h1", "h2", "h3"] @@ -3335,6 +3336,7 @@ def test_break_long_words(): result = tabulate(test_table, table_headers, maxcolwidths=3, break_long_words=True) assert_equal(expected, result) + def test_break_on_hyphens(): "Output: Default table output, with break on hyphens true." table_headers = ["h1", "h2", "h3"] diff --git a/test/test_textwrapper.py b/test/test_textwrapper.py index 8c43bb21..fa6eb00e 100644 --- a/test/test_textwrapper.py +++ b/test/test_textwrapper.py @@ -263,9 +263,9 @@ def test_wrap_none_value_with_missingval(): "+---------+---------+", ] expected = "\n".join(expected) - assert_equal(expected, result) + assert_equal(expected, result) + - def test_wrap_optional_bool_strs(): """TextWrapper: Show that str bools and None can be wrapped without crashing""" data = [ From 38b7333b1f52bdc30960f351dc85823f4a766894 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 4 Mar 2026 17:26:38 +0100 Subject: [PATCH 016/116] uncomment [tool.setuptools_scm] to detect package version --- pyproject.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0abfebe9..3962cb92 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,5 +35,4 @@ tabulate = "tabulate:_main" # [tool.setuptools] # packages = ["tabulate"] -# [tool.setuptools_scm] -# write_to = "tabulate/version.py" +[tool.setuptools_scm] From d42acbe85c0dba1d70cd02aa1f754480938c76bc Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 4 Mar 2026 17:26:38 +0100 Subject: [PATCH 017/116] fix test_internal being stuck on test_wrap_text_wide_chars (fix _handle_long_word) --- tabulate/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index ee646ad7..f6a7c833 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -2775,7 +2775,8 @@ def _handle_long_word(self, reversed_chunks, cur_line, cur_len, width): chunk = reversed_chunks[-1] i = 1 # Only count printable characters, so strip_ansi first, index later. - while len(_strip_ansi(chunk)[:i]) <= space_left: + stripped_chunk = _strip_ansi(chunk) + while i <= len(stripped_chunk) and self._len(stripped_chunk[:i]) <= space_left: i = i + 1 # Consider escape codes when breaking words up total_escape_len = 0 From 95c815d3995febb489fe7b489ab15afa0dd9374c Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 4 Mar 2026 17:26:38 +0100 Subject: [PATCH 018/116] re-enable test_alignment_of_decimal_numbers_with_commas --- test/test_regression.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/test/test_regression.py b/test/test_regression.py index bf262470..573172de 100644 --- a/test/test_regression.py +++ b/test/test_regression.py @@ -258,15 +258,14 @@ def test_alignment_of_decimal_numbers_with_ansi_color(): def test_alignment_of_decimal_numbers_with_commas(): "Regression: alignment for decimal numbers with comma separators" - skip("test is temporarily disable until the feature is reimplemented") - # table = [["c1r1", "14502.05"], ["c1r2", 105]] - # result = tabulate(table, tablefmt="grid", floatfmt=',.2f') - # expected = "\n".join( - # ['+------+-----------+', '| c1r1 | 14,502.05 |', - # '+------+-----------+', '| c1r2 | 105.00 |', - # '+------+-----------+'] - # ) - # assert_equal(expected, result) + table = [["c1r1", "14502.05"], ["c1r2", 105]] + result = tabulate(table, tablefmt="grid", floatfmt=',.2f') + expected = "\n".join( + ['+------+-----------+', '| c1r1 | 14,502.05 |', + '+------+-----------+', '| c1r2 | 105.00 |', + '+------+-----------+'] + ) + assert_equal(expected, result) def test_long_integers(): From a3223ed1ca64b6f937aaa90c0f4efc6a0a800cb9 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 4 Mar 2026 17:26:38 +0100 Subject: [PATCH 019/116] run pre_commit hooks (black) --- tabulate/__init__.py | 4 +++- test/test_regression.py | 12 ++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index f6a7c833..7cf521c7 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -2776,7 +2776,9 @@ def _handle_long_word(self, reversed_chunks, cur_line, cur_len, width): i = 1 # Only count printable characters, so strip_ansi first, index later. stripped_chunk = _strip_ansi(chunk) - while i <= len(stripped_chunk) and self._len(stripped_chunk[:i]) <= space_left: + while ( + i <= len(stripped_chunk) and self._len(stripped_chunk[:i]) <= space_left + ): i = i + 1 # Consider escape codes when breaking words up total_escape_len = 0 diff --git a/test/test_regression.py b/test/test_regression.py index 573172de..f56f48e6 100644 --- a/test/test_regression.py +++ b/test/test_regression.py @@ -259,11 +259,15 @@ def test_alignment_of_decimal_numbers_with_ansi_color(): def test_alignment_of_decimal_numbers_with_commas(): "Regression: alignment for decimal numbers with comma separators" table = [["c1r1", "14502.05"], ["c1r2", 105]] - result = tabulate(table, tablefmt="grid", floatfmt=',.2f') + result = tabulate(table, tablefmt="grid", floatfmt=",.2f") expected = "\n".join( - ['+------+-----------+', '| c1r1 | 14,502.05 |', - '+------+-----------+', '| c1r2 | 105.00 |', - '+------+-----------+'] + [ + "+------+-----------+", + "| c1r1 | 14,502.05 |", + "+------+-----------+", + "| c1r2 | 105.00 |", + "+------+-----------+", + ] ) assert_equal(expected, result) From e0a4311bcf6b930e24180c784c08293566b72304 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 4 Mar 2026 17:26:38 +0100 Subject: [PATCH 020/116] add py314-extra (full Python 3.14 tests) to tox.ini --- HOWTOPUBLISH | 2 +- tox.ini | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/HOWTOPUBLISH b/HOWTOPUBLISH index 29c4545c..148190e5 100644 --- a/HOWTOPUBLISH +++ b/HOWTOPUBLISH @@ -1,6 +1,6 @@ # update contributors and CHANGELOG in README python -m pre_commit run -a # and then commit changes -tox -e py39-extra,py310-extra,py311-extra,py312-extra,py313-extra +tox -e py39-extra,py310-extra,py311-extra,py312-extra,py313-extra,py314-extra # tag version release python -m build -s # this will update tabulate/version.py python -m pip install . # install tabulate in the current venv diff --git a/tox.ini b/tox.ini index 9605e79b..7df9f0bb 100644 --- a/tox.ini +++ b/tox.ini @@ -8,7 +8,7 @@ # for testing and it is disabled by default. [tox] -envlist = lint, py{38, 39, 310, 311, 312, 313} +envlist = lint, py{38, 39, 310, 311, 312, 313, 314} isolated_build = True [gh] @@ -18,6 +18,7 @@ python = 3.11: py311-extra 3.12: py312-extra 3.13: py313-extra + 3.14: py314-extra [testenv] commands = pytest -v --doctest-modules --ignore benchmark {posargs} @@ -130,6 +131,22 @@ deps = pandas wcwidth +[testenv:py314] +basepython = python3.14 +commands = pytest -v --doctest-modules --ignore benchmark {posargs} +deps = + pytest + +[testenv:py314-extra] +basepython = python3.14 +setenv = PYTHONDEVMODE = 1 +commands = pytest -v --doctest-modules --ignore benchmark {posargs} +deps = + pytest + numpy + pandas + wcwidth + [flake8] max-complexity = 22 max-line-length = 99 From 27727c2d2327c755e6e6e2cd379ca44eb8b64e3a Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 4 Mar 2026 17:26:38 +0100 Subject: [PATCH 021/116] fix warnings in test_sqlite3 and test_sqlite3_keys --- test/test_input.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/test_input.py b/test/test_input.py index 49f44173..073dead2 100644 --- a/test/test_input.py +++ b/test/test_input.py @@ -313,6 +313,7 @@ def test_sqlite3(): cursor.execute("INSERT INTO people VALUES (?, ?, ?)", values) cursor.execute("SELECT name, age, height FROM people ORDER BY name") result = tabulate(cursor, headers=["whom", "how old", "how tall"]) + conn.close() expected = """\ whom how old how tall ------ --------- ---------- @@ -337,6 +338,7 @@ def test_sqlite3_keys(): 'SELECT name "whom", age "how old", height "how tall" FROM people ORDER BY name' ) result = tabulate(cursor, headers="keys") + conn.close() expected = """\ whom how old how tall ------ --------- ---------- From e13a4d0dd292cade200e653eb9155a1ca0f1dbea Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 4 Mar 2026 17:26:38 +0100 Subject: [PATCH 022/116] update _CustomTextWrap to make it compatible with Python 3.14 add `and space_left > 0` to prevent appending an empty string to `cur_line` when the line is already full. --- tabulate/__init__.py | 2 +- test/test_textwrapper.py | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 7cf521c7..0a321848 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -2769,7 +2769,7 @@ def _handle_long_word(self, reversed_chunks, cur_line, cur_len, width): # If we're allowed to break long words, then do so: put as much # of the next chunk onto the current line as will fit. - if self.break_long_words: + if self.break_long_words and space_left > 0: # Tabulate Custom: Build the string up piece-by-piece in order to # take each charcter's width into account chunk = reversed_chunks[-1] diff --git a/test/test_textwrapper.py b/test/test_textwrapper.py index fa6eb00e..485fa30e 100644 --- a/test/test_textwrapper.py +++ b/test/test_textwrapper.py @@ -15,9 +15,9 @@ def test_wrap_multiword_non_wide(): orig = OTW(width=width) cust = CTW(width=width) - assert orig.wrap(data) == cust.wrap( - data - ), "Failure on non-wide char multiword regression check for width " + str(width) + assert [line.rstrip() for line in orig.wrap(data)] == [ + line.rstrip() for line in cust.wrap(data) + ], "Failure on non-wide char multiword regression check for width " + str(width) def test_wrap_multiword_non_wide_with_hypens(): @@ -27,9 +27,9 @@ def test_wrap_multiword_non_wide_with_hypens(): orig = OTW(width=width) cust = CTW(width=width) - assert orig.wrap(data) == cust.wrap( - data - ), "Failure on non-wide char hyphen regression check for width " + str(width) + assert [line.rstrip() for line in orig.wrap(data)] == [ + line.rstrip() for line in cust.wrap(data) + ], "Failure on non-wide char hyphen regression check for width " + str(width) def test_wrap_longword_non_wide(): From 87a9a4e07a5efb39b81fdb6ac513b1d345bb21fb Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 4 Mar 2026 17:26:38 +0100 Subject: [PATCH 023/116] fix #365 - added a regression test and a fix based on pull request #393 --- tabulate/__init__.py | 2 +- test/test_regression.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 0a321848..fb6e6352 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -2288,7 +2288,7 @@ def tabulate( ) if maxheadercolwidths is not None: - num_cols = len(list_of_lists[0]) + num_cols = len(list_of_lists[0]) if list_of_lists else len(headers) if isinstance(maxheadercolwidths, int): # Expand scalar for all columns maxheadercolwidths = _expand_iterable( maxheadercolwidths, num_cols, maxheadercolwidths diff --git a/test/test_regression.py b/test/test_regression.py index f56f48e6..113e9105 100644 --- a/test/test_regression.py +++ b/test/test_regression.py @@ -548,3 +548,15 @@ def test_empty_table_with_colalign(): ] ) assert_equal(expected, table) + + +def test_empty_table_with_maxheadercolwidths(): + "Regression: empty table with maxheadercolwidths kwarg (issue #365)" + result = tabulate([], headers=["one", "two", "three"], maxheadercolwidths=5) + expected = "\n".join( + [ + "one two three", + "----- ----- -------", + ] + ) + assert_equal(expected, result) From 4582b63010eb60e777ab3433df4a422599ef0afa Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 4 Mar 2026 17:26:38 +0100 Subject: [PATCH 024/116] drop Python 3.9 support, add Python 3.14 support --- HOWTOPUBLISH | 2 +- pyproject.toml | 4 ++-- tox.ini | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/HOWTOPUBLISH b/HOWTOPUBLISH index 148190e5..5a072d10 100644 --- a/HOWTOPUBLISH +++ b/HOWTOPUBLISH @@ -1,6 +1,6 @@ # update contributors and CHANGELOG in README python -m pre_commit run -a # and then commit changes -tox -e py39-extra,py310-extra,py311-extra,py312-extra,py313-extra,py314-extra +tox -e py310-extra,py311-extra,py312-extra,py313-extra,py314-extra # tag version release python -m build -s # this will update tabulate/version.py python -m pip install . # install tabulate in the current venv diff --git a/pyproject.toml b/pyproject.toml index 3962cb92..b602919e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,14 +13,14 @@ classifiers = [ "Development Status :: 4 - Beta", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Software Development :: Libraries", ] -requires-python = ">=3.9" +requires-python = ">=3.10" dynamic = ["version"] [project.urls] diff --git a/tox.ini b/tox.ini index 7df9f0bb..77b5f2e7 100644 --- a/tox.ini +++ b/tox.ini @@ -13,7 +13,6 @@ isolated_build = True [gh] python = - 3.9: py39-extra 3.10: py310-extra 3.11: py311-extra 3.12: py312-extra From 9d930e8847b6e10baacaf23ccb46aeedbb499e3c Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 4 Mar 2026 17:55:01 +0100 Subject: [PATCH 025/116] add Python 3.14 to github workflow, upgrade lint workflow to Python 3.13 --- .github/workflows/lint.yml | 2 +- .github/workflows/tabulate.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 0093303c..3bf20ee2 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.12'] + python-version: ['3.13'] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/tabulate.yml b/.github/workflows/tabulate.yml index c4594846..141a686f 100644 --- a/.github/workflows/tabulate.yml +++ b/.github/workflows/tabulate.yml @@ -8,7 +8,7 @@ jobs: build: strategy: matrix: - python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] os: ["ubuntu-latest", "windows-latest", "macos-latest"] runs-on: ${{ matrix.os }} From 197a8036dc01a0645fafa72dcce329d37e9369a4 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 4 Mar 2026 18:12:45 +0100 Subject: [PATCH 026/116] add regression test for issue #209 - test_mixed_bool_strings_and_numeric_strings --- test/test_regression.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/test_regression.py b/test/test_regression.py index 113e9105..c2e95838 100644 --- a/test/test_regression.py +++ b/test/test_regression.py @@ -560,3 +560,10 @@ def test_empty_table_with_maxheadercolwidths(): ] ) assert_equal(expected, result) + + +def test_mixed_bool_strings_and_numeric_strings(): + "Regression: column with bool-like strings and numeric strings should not crash (issue #209)" + result = tabulate([["False"], ["1."]]) + expected = "\n".join(["-----", "False", " 1", "-----"]) + assert_equal(expected, result) From 4fa86395681e8502e1ee8ea7fcc5402adf53773b Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Sat, 13 Sep 2025 18:06:01 +0200 Subject: [PATCH 027/116] Improve string concatenation Avoid temporary array. --- tabulate/__init__.py | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 43bcbc6f..ddcf5f9e 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -142,8 +142,10 @@ def _pipe_line_with_colons(colwidths, colaligns): alignment (as in `pipe` output format).""" if not colaligns: # e.g. printing an empty data frame (github issue #15) colaligns = [""] * len(colwidths) - segments = [_pipe_segment_with_colons(a, w) for a, w in zip(colaligns, colwidths)] - return "|" + "|".join(segments) + "|" + segments = "|".join( + _pipe_segment_with_colons(a, w) for a, w in zip(colaligns, colwidths) + ) + return f"|{segments}|" def _grid_segment_with_colons(colwidth, align): @@ -165,8 +167,10 @@ def _grid_line_with_colons(colwidths, colaligns): in a grid table.""" if not colaligns: colaligns = [""] * len(colwidths) - segments = [_grid_segment_with_colons(w, a) for a, w in zip(colaligns, colwidths)] - return "+" + "+".join(segments) + "+" + segments = "+".join( + _grid_segment_with_colons(w, a) for a, w in zip(colaligns, colwidths) + ) + return f"+{segments}+" def _mediawiki_row_with_attrs(separator, cell_values, colwidths, colaligns): @@ -188,8 +192,8 @@ def _mediawiki_row_with_attrs(separator, cell_values, colwidths, colaligns): def _textile_row_with_attrs(cell_values, colwidths, colaligns): cell_values[0] += " " alignment = {"left": "<.", "right": ">.", "center": "=.", "decimal": ">."} - values = (alignment.get(a, "") + v for a, v in zip(colaligns, cell_values)) - return "|" + "|".join(values) + "|" + values = "|".join(alignment.get(a, "") + v for a, v in zip(colaligns, cell_values)) + return f"|{values}|" def _html_begin_table_without_header(colwidths_ignore, colaligns_ignore): @@ -258,10 +262,10 @@ def make_header_line(is_header, colwidths, colaligns): asciidoc_alignments = zip( colwidths, [alignment[colalign] for colalign in colaligns] ) - asciidoc_column_specifiers = [ + asciidoc_column_specifiers = ",".join( f"{width:d}{align}" for width, align in asciidoc_alignments - ] - header_list = ['cols="' + (",".join(asciidoc_column_specifiers)) + '"'] + ) + header_list = [f'cols="{asciidoc_column_specifiers}"'] # generate the list of options (currently only "header") options_list = [] @@ -270,7 +274,8 @@ def make_header_line(is_header, colwidths, colaligns): options_list.append("header") if options_list: - header_list += ['options="' + ",".join(options_list) + '"'] + options_list = ",".join(options_list) + header_list.append(f'options="{options_list}"') # generate the list of entries in the table header field From 8ca340f59e079e3b513068c76be9654205350542 Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Sat, 13 Sep 2025 17:44:29 +0200 Subject: [PATCH 028/116] Fix typo in docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit maxcolwidth → maxcolwidths maxheadercolwidth → maxheadercolwidths --- tabulate/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index ddcf5f9e..7a9ae625 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -2246,8 +2246,8 @@ def tabulate( Tabulate will, by default, set the width of each column to the length of the longest element in that column. However, in situations where fields are expected to reasonably be too long to look good as a single line, tabulate can help automate - word wrapping long fields for you. Use the parameter `maxcolwidth` to provide a - list of maximal column widths + word wrapping long fields for you. Use the parameter `maxcolwidths` to provide a + list of maximal column widths: >>> print(tabulate( \ [('1', 'John Smith', \ @@ -2264,7 +2264,7 @@ def tabulate( | | | better if it is wrapped a bit | +------------+------------+-------------------------------+ - Header column width can be specified in a similar way using `maxheadercolwidth` + Header column width can be specified in a similar way using `maxheadercolwidths`. """ From 5c10c81b76a473c52fa1575cf43c5ade9da26da0 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 4 Mar 2026 18:38:51 +0100 Subject: [PATCH 029/116] update pre-commit hooks as suggested by pull request #373 --- .pre-commit-config.yaml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7349858e..28c2f8c8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,15 +1,19 @@ repos: - repo: https://github.com/python/black - rev: 22.3.0 + rev: 25.1.0 hooks: - id: black args: [--safe] language_version: python3 - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.2.3 + rev: v6.0.0 hooks: - id: trailing-whitespace - id: check-yaml - id: debug-statements - - id: flake8 language_version: python3 +- repo: https://github.com/pycqa/flake8 + rev: 7.3.0 + hooks: + - id: flake8 + language_version: python3 \ No newline at end of file From b96c59a6168a654d8c99ffdcaaa22c96d63102bb Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 4 Mar 2026 18:39:13 +0100 Subject: [PATCH 030/116] run pre-commit hooks (black) --- tabulate/__init__.py | 16 ++++++++++------ test/test_api.py | 4 +--- test/test_cli.py | 4 +--- test/test_internal.py | 13 +++++++++++-- 4 files changed, 23 insertions(+), 14 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 00d6e55b..f4147ad5 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1599,9 +1599,11 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): if headers == "keys": headers = field_names rows = [ - [getattr(row, f) for f in field_names] - if not _is_separating_line(row) - else row + ( + [getattr(row, f) for f in field_names] + if not _is_separating_line(row) + else row + ) for row in rows ] @@ -1685,9 +1687,11 @@ def _wrap_text_to_colwidths( casted_cell = ( missingval if cell is None - else str(cell) - if cell == "" or _isnumber(cell) - else str(_type(cell, numparse)(cell)) + else ( + str(cell) + if cell == "" or _isnumber(cell) + else str(_type(cell, numparse)(cell)) + ) ) wrapped = [ "\n".join(wrapper.wrap(line)) diff --git a/test/test_api.py b/test/test_api.py index f35d09ad..9401a303 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -1,6 +1,4 @@ -"""API properties. - -""" +"""API properties.""" from tabulate import tabulate, tabulate_formats, simple_separated_format from common import skip diff --git a/test/test_cli.py b/test/test_cli.py index e71572d3..573c99e7 100644 --- a/test/test_cli.py +++ b/test/test_cli.py @@ -1,6 +1,4 @@ -"""Command-line interface. - -""" +"""Command-line interface.""" import os import sys diff --git a/test/test_internal.py b/test/test_internal.py index e7564d37..17107c6d 100644 --- a/test/test_internal.py +++ b/test/test_internal.py @@ -180,7 +180,9 @@ def test_wrap_text_wide_chars(): except ImportError: skip("test_wrap_text_wide_chars is skipped") - rows = [["청자청자청자청자청자", "약간 감싸면 더 잘 보일 수있는 다소 긴 설명입니다"]] + rows = [ + ["청자청자청자청자청자", "약간 감싸면 더 잘 보일 수있는 다소 긴 설명입니다"] + ] widths = [5, 20] expected = [ [ @@ -244,7 +246,14 @@ def test_wrap_text_to_colwidths_colors_wide_char(): except ImportError: skip("test_wrap_text_to_colwidths_colors_wide_char is skipped") - data = [[("\033[31m약간 감싸면 더 잘 보일 수있는 다소 긴" " 설명입니다 설명입니다 설명입니다 설명입니다 설명\033[0m")]] + data = [ + [ + ( + "\033[31m약간 감싸면 더 잘 보일 수있는 다소 긴" + " 설명입니다 설명입니다 설명입니다 설명입니다 설명\033[0m" + ) + ] + ] result = T._wrap_text_to_colwidths(data, [30]) expected = [ From 69f67cd72de4c7990bc7a25200b89ab9b2bdd870 Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Sat, 13 Sep 2025 15:49:24 +0200 Subject: [PATCH 031/116] Do not call `getattr` with a constant value It is not any safer than normal property access. It feels like the intent might have been `hasattr()` instead of `getattr()`, but the initial commit e2086c3 appears to : - assume any attribute `dtype` is of type `numpy.dtype`, - test whether `numpy.dtype.names` is a list of fields names or `None`. https://numpy.org/doc/stable/reference/generated/numpy.dtype.names.html --- tabulate/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index f4147ad5..df2e634c 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1534,7 +1534,7 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): elif ( headers == "keys" and hasattr(tabular_data, "dtype") - and getattr(tabular_data.dtype, "names") + and tabular_data.dtype.names ): # numpy record array headers = tabular_data.dtype.names From 13508e7a75783af650a2b76065dac28e06d1fa34 Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Sat, 13 Sep 2025 15:44:03 +0200 Subject: [PATCH 032/116] Unnecessary `list` call The `sorted()` function returns a list: https://docs.python.org/3/library/functions.html#sorted --- tabulate/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index df2e634c..a63f0837 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -728,7 +728,7 @@ def escape_empty(val): } -tabulate_formats = list(sorted(_table_formats.keys())) +tabulate_formats = sorted(_table_formats.keys()) # The table formats for which multiline cells will be folded into subsequent # table rows. The key is the original format specified at the API. The value is From 76c3d37a5f51546b2bfba8d3e0648ebcda05b436 Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Sat, 13 Sep 2025 14:46:09 +0200 Subject: [PATCH 033/116] Remove spurious space from error message --- tabulate/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index a63f0837..41b53b30 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -300,7 +300,7 @@ def make_header_line(is_header, colwidths, colaligns): else: raise ValueError( - " _asciidoc_row() requires two (colwidths, colaligns) " + "_asciidoc_row() requires two (colwidths, colaligns) " + "or three (cell_values, colwidths, colaligns) arguments) " ) From 4d535af12d2de46ee5a87329d2bc5810a386ef89 Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Sat, 13 Sep 2025 15:26:25 +0200 Subject: [PATCH 034/116] Use specific noqa directives --- tabulate/__init__.py | 8 ++++---- test/common.py | 4 ++-- test/test_internal.py | 4 ++-- test/test_output.py | 32 ++++++++++++++++---------------- test/test_regression.py | 4 ++-- test/test_textwrapper.py | 4 ++-- 6 files changed, 28 insertions(+), 28 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 41b53b30..db1f18ce 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1150,7 +1150,7 @@ def _choose_width_fn(has_invisible, enable_widechars, is_multiline): else: line_width_fn = len if is_multiline: - width_fn = lambda s: _multiline_width(s, line_width_fn) # noqa + width_fn = lambda s: _multiline_width(s, line_width_fn) # noqa: E731 else: width_fn = line_width_fn return width_fn @@ -1190,7 +1190,7 @@ def _align_column_choose_width_fn(has_invisible, enable_widechars, is_multiline) else: line_width_fn = len if is_multiline: - width_fn = lambda s: _align_column_multiline_width(s, line_width_fn) # noqa + width_fn = lambda s: _align_column_multiline_width(s, line_width_fn) # noqa: E731 else: width_fn = line_width_fn return width_fn @@ -1321,7 +1321,7 @@ def _format(val, valtype, floatfmt, intfmt, missingval="", has_invisible=True): tabulate(tbl, headers=hrow) == good_result True - """ # noqa + """ # noqa: E501 if val is None: return missingval if isinstance(val, (bytes, str)) and not val: @@ -2649,7 +2649,7 @@ def _format_table( padded_widths = [(w + 2 * pad) for w in colwidths] if is_multiline: - pad_row = lambda row, _: row # noqa do it later, in _append_multiline_row + pad_row = lambda row, _: row # noqa: E731 # do it later, in _append_multiline_row append_row = partial(_append_multiline_row, pad=pad) else: pad_row = _pad_row diff --git a/test/common.py b/test/common.py index ec2fb351..31d6b82c 100644 --- a/test/common.py +++ b/test/common.py @@ -1,5 +1,5 @@ -import pytest # noqa -from pytest import skip, raises # noqa +import pytest # noqa: F401 +from pytest import skip, raises # noqa: F401 import warnings diff --git a/test/test_internal.py b/test/test_internal.py index 17107c6d..fb2cec8b 100644 --- a/test/test_internal.py +++ b/test/test_internal.py @@ -176,7 +176,7 @@ def test_wrap_text_to_colwidths(): def test_wrap_text_wide_chars(): "Internal: Wrap wide characters based on column width" try: - import wcwidth # noqa + import wcwidth # noqa: F401 except ImportError: skip("test_wrap_text_wide_chars is skipped") @@ -242,7 +242,7 @@ def test_wrap_text_to_colwidths_single_ansi_colors_full_cell(): def test_wrap_text_to_colwidths_colors_wide_char(): """Internal: autowrapped text can retain a ANSI colors with wide chars""" try: - import wcwidth # noqa + import wcwidth # noqa: F401 except ImportError: skip("test_wrap_text_to_colwidths_colors_wide_char is skipped") diff --git a/test/test_output.py b/test/test_output.py index 84a4cc41..8871f220 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -131,7 +131,7 @@ def test_plain_maxcolwidth_autowraps_with_sep(): def test_plain_maxcolwidth_autowraps_wide_chars(): "Output: maxcolwidth and autowrapping functions with wide characters" try: - import wcwidth # noqa + import wcwidth # noqa: F401 except ImportError: skip("test_wrap_text_wide_chars is skipped") @@ -546,7 +546,7 @@ def test_grid(): def test_grid_wide_characters(): "Output: grid with wide characters in headers" try: - import wcwidth # noqa + import wcwidth # noqa: F401 except ImportError: skip("test_grid_wide_characters is skipped") headers = list(_test_table_headers) @@ -681,7 +681,7 @@ def test_simple_grid(): def test_simple_grid_wide_characters(): "Output: simple_grid with wide characters in headers" try: - import wcwidth # noqa + import wcwidth # noqa: F401 except ImportError: skip("test_simple_grid_wide_characters is skipped") headers = list(_test_table_headers) @@ -816,7 +816,7 @@ def test_rounded_grid(): def test_rounded_grid_wide_characters(): "Output: rounded_grid with wide characters in headers" try: - import wcwidth # noqa + import wcwidth # noqa: F401 except ImportError: skip("test_rounded_grid_wide_characters is skipped") headers = list(_test_table_headers) @@ -951,7 +951,7 @@ def test_heavy_grid(): def test_heavy_grid_wide_characters(): "Output: heavy_grid with wide characters in headers" try: - import wcwidth # noqa + import wcwidth # noqa: F401 except ImportError: skip("test_heavy_grid_wide_characters is skipped") headers = list(_test_table_headers) @@ -1086,7 +1086,7 @@ def test_mixed_grid(): def test_mixed_grid_wide_characters(): "Output: mixed_grid with wide characters in headers" try: - import wcwidth # noqa + import wcwidth # noqa: F401 except ImportError: skip("test_mixed_grid_wide_characters is skipped") headers = list(_test_table_headers) @@ -1221,7 +1221,7 @@ def test_double_grid(): def test_double_grid_wide_characters(): "Output: double_grid with wide characters in headers" try: - import wcwidth # noqa + import wcwidth # noqa: F401 except ImportError: skip("test_double_grid_wide_characters is skipped") headers = list(_test_table_headers) @@ -1356,7 +1356,7 @@ def test_fancy_grid(): def test_fancy_grid_wide_characters(): "Output: fancy_grid with wide characters in headers" try: - import wcwidth # noqa + import wcwidth # noqa: F401 except ImportError: skip("test_fancy_grid_wide_characters is skipped") headers = list(_test_table_headers) @@ -1525,7 +1525,7 @@ def test_colon_grid(): def test_colon_grid_wide_characters(): "Output: colon_grid with wide chars in header" try: - import wcwidth # noqa + import wcwidth # noqa: F401 except ImportError: skip("test_colon_grid_wide_characters is skipped") headers = list(_test_table_headers) @@ -1619,7 +1619,7 @@ def test_outline(): def test_outline_wide_characters(): "Output: outline with wide characters in headers" try: - import wcwidth # noqa + import wcwidth # noqa: F401 except ImportError: skip("test_outline_wide_characters is skipped") headers = list(_test_table_headers) @@ -1671,7 +1671,7 @@ def test_simple_outline(): def test_simple_outline_wide_characters(): "Output: simple_outline with wide characters in headers" try: - import wcwidth # noqa + import wcwidth # noqa: F401 except ImportError: skip("test_simple_outline_wide_characters is skipped") headers = list(_test_table_headers) @@ -1723,7 +1723,7 @@ def test_rounded_outline(): def test_rounded_outline_wide_characters(): "Output: rounded_outline with wide characters in headers" try: - import wcwidth # noqa + import wcwidth # noqa: F401 except ImportError: skip("test_rounded_outline_wide_characters is skipped") headers = list(_test_table_headers) @@ -1775,7 +1775,7 @@ def test_heavy_outline(): def test_heavy_outline_wide_characters(): "Output: heavy_outline with wide characters in headers" try: - import wcwidth # noqa + import wcwidth # noqa: F401 except ImportError: skip("test_heavy_outline_wide_characters is skipped") headers = list(_test_table_headers) @@ -1827,7 +1827,7 @@ def test_mixed_outline(): def test_mixed_outline_wide_characters(): "Output: mixed_outline with wide characters in headers" try: - import wcwidth # noqa + import wcwidth # noqa: F401 except ImportError: skip("test_mixed_outline_wide_characters is skipped") headers = list(_test_table_headers) @@ -1879,7 +1879,7 @@ def test_double_outline(): def test_double_outline_wide_characters(): "Output: double_outline with wide characters in headers" try: - import wcwidth # noqa + import wcwidth # noqa: F401 except ImportError: skip("test_double_outline_wide_characters is skipped") headers = list(_test_table_headers) @@ -1931,7 +1931,7 @@ def test_fancy_outline(): def test_fancy_outline_wide_characters(): "Output: fancy_outline with wide characters in headers" try: - import wcwidth # noqa + import wcwidth # noqa: F401 except ImportError: skip("test_fancy_outline_wide_characters is skipped") headers = list(_test_table_headers) diff --git a/test/test_regression.py b/test/test_regression.py index c2e95838..35fa42ae 100644 --- a/test/test_regression.py +++ b/test/test_regression.py @@ -303,7 +303,7 @@ class textclass(str): def test_mix_normal_and_wide_characters(): "Regression: wide characters in a grid format (issue #51)" try: - import wcwidth # noqa + import wcwidth # noqa: F401 ru_text = "\u043f\u0440\u0438\u0432\u0435\u0442" cn_text = "\u4f60\u597d" @@ -325,7 +325,7 @@ def test_mix_normal_and_wide_characters(): def test_multiline_with_wide_characters(): "Regression: multiline tables with varying number of wide characters (github issue #28)" try: - import wcwidth # noqa + import wcwidth # noqa: F401 table = [["가나\n가ab", "가나", "가나"]] result = tabulate(table, tablefmt="fancy_grid") diff --git a/test/test_textwrapper.py b/test/test_textwrapper.py index 485fa30e..5e86b5c9 100644 --- a/test/test_textwrapper.py +++ b/test/test_textwrapper.py @@ -47,7 +47,7 @@ def test_wrap_longword_non_wide(): def test_wrap_wide_char_multiword(): """TextWrapper: wrapping support for wide characters with multiple words""" try: - import wcwidth # noqa + import wcwidth # noqa: F401 except ImportError: skip("test_wrap_wide_char is skipped") @@ -63,7 +63,7 @@ def test_wrap_wide_char_multiword(): def test_wrap_wide_char_longword(): """TextWrapper: wrapping wide char word that needs to be broken up""" try: - import wcwidth # noqa + import wcwidth # noqa: F401 except ImportError: skip("test_wrap_wide_char_longword is skipped") From 1b69704b12b13c70b1fa42f942e84d9c134a5763 Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Sat, 13 Sep 2025 15:37:02 +0200 Subject: [PATCH 035/116] Remove spurious cast in string interpolation String interpolation is about converting arguments to strings. Explicitly converting to string is useless and hurts readability. --- test/test_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_api.py b/test/test_api.py index 9401a303..e1864426 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -24,7 +24,7 @@ def _check_signature(function, expected_sig): if not signature: skip("") actual_sig = signature(function) - print(f"expected: {expected_sig}\nactual: {str(actual_sig)}\n") + print(f"expected: {expected_sig}\nactual: {actual_sig}\n") assert len(actual_sig.parameters) == len(expected_sig) From 7bacc90422f416f61034c6aeb45be32dc67bafdf Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 4 Mar 2026 18:48:58 +0100 Subject: [PATCH 036/116] run pre-commit hooks (black) --- tabulate/__init__.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index db1f18ce..9e2925e3 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1190,7 +1190,9 @@ def _align_column_choose_width_fn(has_invisible, enable_widechars, is_multiline) else: line_width_fn = len if is_multiline: - width_fn = lambda s: _align_column_multiline_width(s, line_width_fn) # noqa: E731 + width_fn = lambda s: _align_column_multiline_width( + s, line_width_fn + ) # noqa: E731 else: width_fn = line_width_fn return width_fn @@ -2649,7 +2651,9 @@ def _format_table( padded_widths = [(w + 2 * pad) for w in colwidths] if is_multiline: - pad_row = lambda row, _: row # noqa: E731 # do it later, in _append_multiline_row + pad_row = ( + lambda row, _: row + ) # noqa: E731 # do it later, in _append_multiline_row append_row = partial(_append_multiline_row, pad=pad) else: pad_row = _pad_row From 37e1ed089f779ed5a962e287b12dc9c15e61c7ff Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 4 Mar 2026 18:50:29 +0100 Subject: [PATCH 037/116] move # nowa: E931 to a different line (flake8) --- tabulate/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 9e2925e3..bc953410 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1190,9 +1190,9 @@ def _align_column_choose_width_fn(has_invisible, enable_widechars, is_multiline) else: line_width_fn = len if is_multiline: - width_fn = lambda s: _align_column_multiline_width( + width_fn = lambda s: _align_column_multiline_width( # noqa: E731 s, line_width_fn - ) # noqa: E731 + ) else: width_fn = line_width_fn return width_fn From 37ac76e9365fa3694e92c8ab651a2cc694438983 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 4 Mar 2026 19:01:39 +0100 Subject: [PATCH 038/116] restore tabulate.__version__ --- tabulate/__init__.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index bc953410..fb157f43 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1,5 +1,15 @@ """Pretty-print tabular data.""" +from importlib.metadata import ( + version as _version, + PackageNotFoundError as _PackageNotFoundError, +) + +try: + __version__ = _version("tabulate") +except _PackageNotFoundError: + __version__ = "unknown" + import warnings from collections import namedtuple from collections.abc import Iterable, Sized From 35ee0f61544b53067247cfe8fd8b7547b7e1c1e2 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 4 Mar 2026 19:03:40 +0100 Subject: [PATCH 039/116] update mini-benchmark table --- README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 4f4ed5c5..4772c5b2 100644 --- a/README.md +++ b/README.md @@ -1172,17 +1172,17 @@ simply joining lists of values with a tab, comma, or other separator. At the same time, `tabulate` is comparable to other table pretty-printers. Given a 10x10 table (a list of lists) of mixed text and numeric data, `tabulate` appears to be faster than `PrettyTable` and `texttable`. -The following mini-benchmark was run in Python 3.11.9 on Windows 11 (x64): +The following mini-benchmark was run in Python 3.13.7 on Windows 11 (x64): ================================== ========== =========== Table formatter time, μs rel. time ================================== ========== =========== - join with tabs and newlines 6.3 1.0 - csv to StringIO 6.6 1.0 - tabulate (0.10.0) 249.2 39.3 - tabulate (0.10.0, WIDE_CHARS_MODE) 325.6 51.4 - texttable (1.7.0) 579.3 91.5 - PrettyTable (3.11.0) 605.5 95.6 + csv to StringIO 11.9 1.0 + join with tabs and newlines 12.1 1.0 + PrettyTable (3.17.0) 468.0 39.3 + tabulate (0.10.0) 553.4 46.5 + tabulate (0.10.0, WIDE_CHARS_MODE) 612.2 51.4 + texttable (1.7.0) 1071.4 90.0 ================================== ========== =========== From 3b4cd509820e4c45cd2aaba833aa585ea6308b94 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 4 Mar 2026 19:10:09 +0100 Subject: [PATCH 040/116] update HOWTOPUBLISH --- HOWTOPUBLISH | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/HOWTOPUBLISH b/HOWTOPUBLISH index 5a072d10..880ab19c 100644 --- a/HOWTOPUBLISH +++ b/HOWTOPUBLISH @@ -1,7 +1,8 @@ # update contributors and CHANGELOG in README python -m pre_commit run -a # and then commit changes tox -e py310-extra,py311-extra,py312-extra,py313-extra,py314-extra -# tag version release +# tag version release (vX.Y.Z) +python -m pip install build twine python -m build -s # this will update tabulate/version.py python -m pip install . # install tabulate in the current venv python -m pip install -r benchmark/requirements.txt @@ -13,3 +14,4 @@ git push # wait for all CI builds to succeed git push --tags # if CI builds succeed twine upload --repository-url https://test.pypi.org/legacy/ dist/* twine upload dist/* +# use __token__ as username andPyPI API token as password (generate at pypi.org → Account settings → API tokens) \ No newline at end of file From 36c8b07515a67231785c23c6a952e03195c3e986 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 4 Mar 2026 22:20:06 +0100 Subject: [PATCH 041/116] add ruff, build, twine, tox as dev dependencies --- pyproject.toml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b602919e..cbb680f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,12 @@ widechars = ["wcwidth"] [project.scripts] tabulate = "tabulate:_main" -# [tool.setuptools] -# packages = ["tabulate"] - [tool.setuptools_scm] + +[dependency-groups] +dev = [ + "build>=1.4.0", + "ruff>=0.15.4", + "tox>=4.47.3", + "twine>=6.2.0", +] From 75640e2b5c362016860521840b01caac18b510f6 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 4 Mar 2026 22:28:54 +0100 Subject: [PATCH 042/116] use ruff instead of black and flake8 fix #396 --- .pre-commit-config.yaml | 15 +++++---------- pyproject.toml | 10 ++++++++++ tox.ini | 10 ++++------ 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 28c2f8c8..e9808666 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,10 +1,10 @@ repos: -- repo: https://github.com/python/black - rev: 25.1.0 +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.4 hooks: - - id: black - args: [--safe] - language_version: python3 + - id: ruff + args: [--fix] + - id: ruff-format - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: @@ -12,8 +12,3 @@ repos: - id: check-yaml - id: debug-statements language_version: python3 -- repo: https://github.com/pycqa/flake8 - rev: 7.3.0 - hooks: - - id: flake8 - language_version: python3 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index cbb680f7..2ae0586c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,3 +41,13 @@ dev = [ "tox>=4.47.3", "twine>=6.2.0", ] + +[tool.ruff] +line-length = 99 + +[tool.ruff.lint] +select = ["E", "F", "W", "C90"] +ignore = ["E203", "E402", "E721", "C901"] + +[tool.ruff.lint.mccabe] +max-complexity = 22 diff --git a/tox.ini b/tox.ini index 77b5f2e7..3baea31a 100644 --- a/tox.ini +++ b/tox.ini @@ -29,9 +29,11 @@ passenv = SSL_CERT_FILE [testenv:lint] -commands = python -m pre_commit run -a +commands = + ruff check . + ruff format --check . deps = - pre-commit + ruff [testenv:py38] basepython = python3.8 @@ -146,7 +148,3 @@ deps = pandas wcwidth -[flake8] -max-complexity = 22 -max-line-length = 99 -ignore = E203, W503, C901, E402, B011 From 4a95974490cde56f81f97aa85dc94958ef309c60 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 4 Mar 2026 23:00:29 +0100 Subject: [PATCH 043/116] check and format everything with ruff fix #396 --- benchmark/benchmark.py | 8 +-- tabulate/__init__.py | 131 +++++++++------------------------------ test/test_api.py | 6 +- test/test_cli.py | 4 +- test/test_input.py | 56 +++++------------ test/test_internal.py | 4 +- test/test_output.py | 82 +++++++----------------- test/test_regression.py | 8 +-- test/test_textwrapper.py | 10 ++- 9 files changed, 79 insertions(+), 230 deletions(-) diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index a89b709e..03dfa66c 100644 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -72,13 +72,9 @@ def benchmark(n): if "--onlyself" in sys.argv[1:]: methods = [m for m in methods if m[0].startswith("tabulate")] - results = [ - (desc, timeit(code, setup_code, number=n) / n * 1e6) for desc, code in methods - ] + results = [(desc, timeit(code, setup_code, number=n) / n * 1e6) for desc, code in methods] mintime = min(map(lambda x: x[1], results)) - results = [ - (desc, t, t / mintime) for desc, t in sorted(results, key=lambda x: x[1]) - ] + results = [(desc, t, t / mintime) for desc, t in sorted(results, key=lambda x: x[1])] table = tabulate.tabulate( results, ["Table formatter", "time, μs", "rel. time"], "rst", floatfmt=".1f" ) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index fb157f43..8a4b41ce 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -152,9 +152,7 @@ def _pipe_line_with_colons(colwidths, colaligns): alignment (as in `pipe` output format).""" if not colaligns: # e.g. printing an empty data frame (github issue #15) colaligns = [""] * len(colwidths) - segments = "|".join( - _pipe_segment_with_colons(a, w) for a, w in zip(colaligns, colwidths) - ) + segments = "|".join(_pipe_segment_with_colons(a, w) for a, w in zip(colaligns, colwidths)) return f"|{segments}|" @@ -177,9 +175,7 @@ def _grid_line_with_colons(colwidths, colaligns): in a grid table.""" if not colaligns: colaligns = [""] * len(colwidths) - segments = "+".join( - _grid_segment_with_colons(w, a) for a, w in zip(colaligns, colwidths) - ) + segments = "+".join(_grid_segment_with_colons(w, a) for a, w in zip(colaligns, colwidths)) return f"+{segments}+" @@ -269,12 +265,8 @@ def make_header_line(is_header, colwidths, colaligns): alignment = {"left": "<", "right": ">", "center": "^", "decimal": ">"} # use the column widths generated by tabulate for the asciidoc column width specifiers - asciidoc_alignments = zip( - colwidths, [alignment[colalign] for colalign in colaligns] - ) - asciidoc_column_specifiers = [ - f"{align}{width:d}" for width, align in asciidoc_alignments - ] + asciidoc_alignments = zip(colwidths, [alignment[colalign] for colalign in colaligns]) + asciidoc_column_specifiers = [f"{align}{width:d}" for width, align in asciidoc_alignments] header_list = ['cols="' + (",".join(asciidoc_column_specifiers)) + '"'] # generate the list of options (currently only "header") @@ -1236,9 +1228,7 @@ def _align_column( strings, padfn = _align_column_choose_padfn( strings, alignment, has_invisible, preserve_whitespace ) - width_fn = _align_column_choose_width_fn( - has_invisible, enable_widechars, is_multiline - ) + width_fn = _align_column_choose_width_fn(has_invisible, enable_widechars, is_multiline) s_widths = list(map(width_fn, strings)) maxwidth = max(max(_flat_list(s_widths)), minwidth) @@ -1246,15 +1236,13 @@ def _align_column( if is_multiline: if not enable_widechars and not has_invisible: padded_strings = [ - "\n".join([padfn(maxwidth, s) for s in ms.splitlines()]) - for ms in strings + "\n".join([padfn(maxwidth, s) for s in ms.splitlines()]) for ms in strings ] else: # enable wide-character width corrections s_lens = [[len(s) for s in re.split("[\r\n]", ms)] for ms in strings] visible_widths = [ - [maxwidth - (w - l) for w, l in zip(mw, ml)] - for mw, ml in zip(s_widths, s_lens) + [maxwidth - (w - ln) for w, ln in zip(mw, ml)] for mw, ml in zip(s_widths, s_lens) ] # wcswidth and _visible_width don't count invisible characters; # padfn doesn't need to apply another correction @@ -1268,7 +1256,7 @@ def _align_column( else: # enable wide-character width corrections s_lens = list(map(len, strings)) - visible_widths = [maxwidth - (w - l) for w, l in zip(s_widths, s_lens)] + visible_widths = [maxwidth - (w - ln) for w, ln in zip(s_widths, s_lens)] # wcswidth and _visible_width don't count invisible characters; # padfn doesn't need to apply another correction padded_strings = [padfn(w, s) for s, w in zip(strings, visible_widths)] @@ -1344,19 +1332,13 @@ def _format(val, valtype, floatfmt, intfmt, missingval="", has_invisible=True): elif valtype is int: if isinstance(val, str): val_striped = val.encode("unicode_escape").decode("utf-8") - colored = re.search( - r"(\\[xX]+[0-9a-fA-F]+\[\d+[mM]+)([0-9.]+)(\\.*)$", val_striped - ) + colored = re.search(r"(\\[xX]+[0-9a-fA-F]+\[\d+[mM]+)([0-9.]+)(\\.*)$", val_striped) if colored: total_groups = len(colored.groups()) if total_groups == 3: digits = colored.group(2) if digits.isdigit(): - val_new = ( - colored.group(1) - + format(int(digits), intfmt) - + colored.group(3) - ) + val_new = colored.group(1) + format(int(digits), intfmt) + colored.group(3) val = val_new.encode("utf-8").decode("unicode_escape") intfmt = "" return format(val, intfmt) @@ -1385,15 +1367,11 @@ def _format(val, valtype, floatfmt, intfmt, missingval="", has_invisible=True): return f"{val}" -def _align_header( - header, alignment, width, visible_width, is_multiline=False, width_fn=None -): +def _align_header(header, alignment, width, visible_width, is_multiline=False, width_fn=None): "Pad string header to width chars given known visible_width of the header." if is_multiline: header_lines = re.split(_multiline_codes, header) - padded_lines = [ - _align_header(h, alignment, width, width_fn(h)) for h in header_lines - ] + padded_lines = [_align_header(h, alignment, width, width_fn(h)) for h in header_lines] return "\n".join(padded_lines) # else: not multiline ninvisible = len(header) - visible_width @@ -1507,19 +1485,14 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): # likely a conventional dict keys = tabular_data.keys() try: - rows = list( - izip_longest(*tabular_data.values()) - ) # columns have to be transposed + rows = list(izip_longest(*tabular_data.values())) # columns have to be transposed except TypeError: # not iterable raise TypeError(err_msg) elif hasattr(tabular_data, "index"): # values is a property, has .index => it's likely a pandas.DataFrame (pandas 0.11.0) keys = list(tabular_data) - if ( - showindex in ["default", "always", True] - and tabular_data.index.name is not None - ): + if showindex in ["default", "always", True] and tabular_data.index.name is not None: if isinstance(tabular_data.index.name, list): keys[:0] = tabular_data.index.name else: @@ -1543,11 +1516,7 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): if headers == "keys" and not rows: # an empty table (issue #81) headers = [] - elif ( - headers == "keys" - and hasattr(tabular_data, "dtype") - and tabular_data.dtype.names - ): + elif headers == "keys" and hasattr(tabular_data, "dtype") and tabular_data.dtype.names: # numpy record array headers = tabular_data.dtype.names elif ( @@ -1586,9 +1555,7 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): else: headers = [] elif headers: - raise ValueError( - "headers for a list of dicts is not a dict or a keyword" - ) + raise ValueError("headers for a list of dicts is not a dict or a keyword") rows = [[row.get(k) for k in keys] for row in rows] elif ( @@ -1601,21 +1568,13 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): # print tabulate(cursor, headers='keys') headers = [column[0] for column in tabular_data.description] - elif ( - dataclasses is not None - and len(rows) > 0 - and dataclasses.is_dataclass(rows[0]) - ): + elif dataclasses is not None and len(rows) > 0 and dataclasses.is_dataclass(rows[0]): # Python's dataclass field_names = [field.name for field in dataclasses.fields(rows[0])] if headers == "keys": headers = field_names rows = [ - ( - [getattr(row, f) for f in field_names] - if not _is_separating_line(row) - else row - ) + ([getattr(row, f) for f in field_names] if not _is_separating_line(row) else row) for row in rows ] @@ -2317,9 +2276,7 @@ def tabulate( if maxheadercolwidths is not None: num_cols = len(list_of_lists[0]) if list_of_lists else len(headers) if isinstance(maxheadercolwidths, int): # Expand scalar for all columns - maxheadercolwidths = _expand_iterable( - maxheadercolwidths, num_cols, maxheadercolwidths - ) + maxheadercolwidths = _expand_iterable(maxheadercolwidths, num_cols, maxheadercolwidths) else: # Ignore col width for any 'trailing' columns maxheadercolwidths = _expand_iterable(maxheadercolwidths, num_cols, None) @@ -2393,17 +2350,13 @@ def tabulate( numparses = _expand_numparse(disable_numparse, len(cols)) coltypes = [_column_type(col, numparse=np) for col, np in zip(cols, numparses)] if isinstance(floatfmt, str): # old version - float_formats = len(cols) * [ - floatfmt - ] # just duplicate the string to use in each column + float_formats = len(cols) * [floatfmt] # just duplicate the string to use in each column else: # if floatfmt is list, tuple etc we have one per column float_formats = list(floatfmt) if len(float_formats) < len(cols): float_formats.extend((len(cols) - len(float_formats)) * [_DEFAULT_FLOATFMT]) if isinstance(intfmt, str): # old version - int_formats = len(cols) * [ - intfmt - ] # just duplicate the string to use in each column + int_formats = len(cols) * [intfmt] # just duplicate the string to use in each column else: # if intfmt is list, tuple etc we have one per column int_formats = list(intfmt) if len(int_formats) < len(cols): @@ -2441,9 +2394,7 @@ def tabulate( break elif align != "global": aligns[idx] = align - minwidths = ( - [width_fn(h) + min_padding for h in headers] if headers else [0] * len(cols) - ) + minwidths = [width_fn(h) + min_padding for h in headers] if headers else [0] * len(cols) 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. @@ -2490,8 +2441,7 @@ def tabulate( elif align != "global": aligns_headers[hidx] = align minwidths = [ - max(minw, max(width_fn(cl) for cl in c)) - for minw, c in zip(minwidths, t_cols) + max(minw, max(width_fn(cl) for cl in c)) for minw, c in zip(minwidths, t_cols) ] headers = [ _align_header(h, a, minw, width_fn(h), is_multiline, width_fn) @@ -2610,8 +2560,7 @@ def _append_multiline_row( # ] cells_lines = [ - _align_cell_veritically(cl, nlines, w, rowalign) - 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: @@ -2661,9 +2610,7 @@ def _format_table( padded_widths = [(w + 2 * pad) for w in colwidths] if is_multiline: - pad_row = ( - lambda row, _: row - ) # noqa: E731 # do it later, in _append_multiline_row + pad_row = lambda row, _: row # noqa: E731 # do it later, in _append_multiline_row append_row = partial(_append_multiline_row, pad=pad) else: pad_row = _pad_row @@ -2715,9 +2662,7 @@ def _format_table( if _is_separating_line(row): _append_line(lines, padded_widths, colaligns, separating_line) else: - append_row( - lines, pad_row(row, pad), padded_widths, colaligns, fmt.datarow - ) + append_row(lines, pad_row(row, pad), padded_widths, colaligns, fmt.datarow) if fmt.linebelow and "linebelow" not in hidden: _append_line(lines, padded_widths, colaligns, fmt.linebelow) @@ -2762,9 +2707,7 @@ def _update_lines(self, lines, new_line): as a single unwrapped string. """ code_matches = [x for x in _ansi_codes.finditer(new_line)] - color_codes = [ - code.string[code.span()[0] : code.span()[1]] for code in code_matches - ] + color_codes = [code.string[code.span()[0] : code.span()[1]] for code in code_matches] # Add color codes from earlier in the unwrapped line, and then track any new ones we add. new_line = "".join(self._active_codes) + new_line @@ -2805,9 +2748,7 @@ def _handle_long_word(self, reversed_chunks, cur_line, cur_len, width): i = 1 # Only count printable characters, so strip_ansi first, index later. stripped_chunk = _strip_ansi(chunk) - while ( - i <= len(stripped_chunk) and self._len(stripped_chunk[:i]) <= space_left - ): + while i <= len(stripped_chunk) and self._len(stripped_chunk[:i]) <= space_left: i = i + 1 # Consider escape codes when breaking words up total_escape_len = 0 @@ -2815,10 +2756,7 @@ def _handle_long_word(self, reversed_chunks, cur_line, cur_len, width): if _ansi_codes.search(chunk) is not None: for group, _, _, _ in _ansi_codes.findall(chunk): escape_len = len(group) - if ( - group - in chunk[last_group : i + total_escape_len + escape_len - 1] - ): + if group in chunk[last_group : i + total_escape_len + escape_len - 1]: total_escape_len += escape_len found = _ansi_codes.search(chunk[last_group:]) last_group += found.end() @@ -2865,7 +2803,6 @@ def _wrap_chunks(self, chunks): chunks.reverse() while chunks: - # Start the list of chunks that will make up the current line. # cur_len is just the length of all the chunks in cur_line. cur_line = [] @@ -2925,10 +2862,7 @@ def _wrap_chunks(self, chunks): self._update_lines(lines, indent + "".join(cur_line)) else: while cur_line: - if ( - cur_line[-1].strip() - and cur_len + self._len(self.placeholder) <= width - ): + if cur_line[-1].strip() and cur_len + self._len(self.placeholder) <= width: cur_line.append(self.placeholder) self._update_lines(lines, indent + "".join(cur_line)) break @@ -2937,10 +2871,7 @@ def _wrap_chunks(self, chunks): else: if lines: prev_line = lines[-1].rstrip() - if ( - self._len(prev_line) + self._len(self.placeholder) - <= self.width - ): + if self._len(prev_line) + self._len(self.placeholder) <= self.width: lines[-1] = prev_line + self.placeholder break self._update_lines(lines, indent + self.placeholder.lstrip()) diff --git a/test/test_api.py b/test/test_api.py index e1864426..20493ec0 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -12,7 +12,7 @@ def test_tabulate_formats(): - "API: tabulate_formats is a list of strings" "" + "API: tabulate_formats is a list of strings" supported = tabulate_formats print("tabulate_formats = %r" % supported) assert type(supported) is list @@ -33,7 +33,7 @@ def _check_signature(function, expected_sig): def test_tabulate_signature(): - "API: tabulate() type signature is unchanged" "" + "API: tabulate() type signature is unchanged" assert type(tabulate) is type(lambda: None) # noqa expected_sig = [ ("tabular_data", _empty), @@ -61,7 +61,7 @@ def test_tabulate_signature(): def test_simple_separated_format_signature(): - "API: simple_separated_format() type signature is unchanged" "" + "API: simple_separated_format() type signature is unchanged" assert type(simple_separated_format) is type(lambda: None) # noqa expected_sig = [("separator", _empty)] _check_signature(simple_separated_format, expected_sig) diff --git a/test/test_cli.py b/test/test_cli.py index 573c99e7..2f7faa81 100644 --- a/test/test_cli.py +++ b/test/test_cli.py @@ -97,9 +97,7 @@ def __init__(self): self.tmpfile = None def __enter__(self): - self.tmpfile = tempfile.NamedTemporaryFile( - "w+", prefix="tabulate-test-tmp-", delete=False - ) + self.tmpfile = tempfile.NamedTemporaryFile("w+", prefix="tabulate-test-tmp-", delete=False) return self.tmpfile def __exit__(self, exc_type, exc_val, exc_tb): diff --git a/test/test_input.py b/test/test_input.py index 073dead2..fca67506 100644 --- a/test/test_input.py +++ b/test/test_input.py @@ -13,9 +13,7 @@ def test_iterable_of_iterables(): "Input: an iterable of iterables." ii = iter(map(lambda x: iter(x), [range(5), range(5, 0, -1)])) - expected = "\n".join( - ["- - - - -", "0 1 2 3 4", "5 4 3 2 1", "- - - - -"] - ) + expected = "\n".join(["- - - - -", "0 1 2 3 4", "5 4 3 2 1", "- - - - -"]) result = tabulate(ii) assert_equal(expected, result) @@ -83,9 +81,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) @@ -96,12 +92,8 @@ def test_dict_like(): dd = {"a": range(3), "b": range(101, 105)} # keys' order (hence columns' order) is not deterministic in Python 3 # => we have to consider both possible results as valid - expected1 = "\n".join( - [" a b", "--- ---", " 0 101", " 1 102", " 2 103", " 104"] - ) - expected2 = "\n".join( - [" b a", "--- ---", "101 0", "102 1", "103 2", "104"] - ) + expected1 = "\n".join([" a b", "--- ---", " 0 101", " 1 102", " 2 103", " 104"]) + expected2 = "\n".join([" b a", "--- ---", "101 0", "102 1", "103 2", "104"]) result = tabulate(dd, "keys") print("Keys' order: %s" % dd.keys()) assert_in(result, [expected1, expected2]) @@ -270,9 +262,7 @@ def test_pandas_firstrow(): df = pandas.DataFrame( [["one", 1], ["two", None]], columns=["string", "number"], index=["a", "b"] ) - expected = "\n".join( - ["a one 1.0", "--- ----- -----", "b two nan"] - ) + expected = "\n".join(["a one 1.0", "--- ----- -----", "b two nan"]) result = tabulate(df, headers="firstrow") assert_equal(expected, result) except ImportError: @@ -366,9 +356,7 @@ def test_list_of_namedtuples_keys(): NT = namedtuple("NT", ["foo", "bar"]) lt = [NT(1, 2), NT(3, 4)] - expected = "\n".join( - [" foo bar", "----- -----", " 1 2", " 3 4"] - ) + expected = "\n".join([" foo bar", "----- -----", " 1 2", " 3 4"]) result = tabulate(lt, headers="keys") assert_equal(expected, result) @@ -394,12 +382,8 @@ def test_list_of_userdicts(): def test_list_of_dicts_keys(): "Input: a list of dictionaries, with keys as headers." lod = [{"foo": 1, "bar": 2}, {"foo": 3, "bar": 4}] - expected1 = "\n".join( - [" foo bar", "----- -----", " 1 2", " 3 4"] - ) - expected2 = "\n".join( - [" bar foo", "----- -----", " 2 1", " 4 3"] - ) + expected1 = "\n".join([" foo bar", "----- -----", " 1 2", " 3 4"]) + expected2 = "\n".join([" bar foo", "----- -----", " 2 1", " 4 3"]) result = tabulate(lod, headers="keys") assert_in(result, [expected1, expected2]) @@ -407,12 +391,8 @@ def test_list_of_dicts_keys(): def test_list_of_userdicts_keys(): "Input: a list of UserDicts." lod = [UserDict(foo=1, bar=2), UserDict(foo=3, bar=4)] - expected1 = "\n".join( - [" foo bar", "----- -----", " 1 2", " 3 4"] - ) - expected2 = "\n".join( - [" bar foo", "----- -----", " 2 1", " 4 3"] - ) + expected1 = "\n".join([" foo bar", "----- -----", " 1 2", " 3 4"]) + expected2 = "\n".join([" bar foo", "----- -----", " 2 1", " 4 3"]) result = tabulate(lod, headers="keys") assert_in(result, [expected1, expected2]) @@ -437,12 +417,8 @@ def test_list_of_dicts_firstrow(): "Input: a list of dictionaries, with the first dict as headers." lod = [{"foo": "FOO", "bar": "BAR"}, {"foo": 3, "bar": 4, "baz": 5}] # if some key is missing in the first dict, use the key name instead - expected1 = "\n".join( - [" FOO BAR baz", "----- ----- -----", " 3 4 5"] - ) - expected2 = "\n".join( - [" BAR FOO baz", "----- ----- -----", " 4 3 5"] - ) + expected1 = "\n".join([" FOO BAR baz", "----- ----- -----", " 3 4 5"]) + expected2 = "\n".join([" BAR FOO baz", "----- ----- -----", " 4 3 5"]) result = tabulate(lod, headers="firstrow") assert_in(result, [expected1, expected2]) @@ -451,12 +427,8 @@ def test_list_of_dicts_with_dict_of_headers(): "Input: a dict of user headers for a list of dicts (issue #23)" table = [{"letters": "ABCDE", "digits": 12345}] headers = {"digits": "DIGITS", "letters": "LETTERS"} - expected1 = "\n".join( - [" DIGITS LETTERS", "-------- ---------", " 12345 ABCDE"] - ) - expected2 = "\n".join( - ["LETTERS DIGITS", "--------- --------", "ABCDE 12345"] - ) + expected1 = "\n".join([" DIGITS LETTERS", "-------- ---------", " 12345 ABCDE"]) + expected2 = "\n".join(["LETTERS DIGITS", "--------- --------", "ABCDE 12345"]) result = tabulate(table, headers=headers) assert_in(result, [expected1, expected2]) diff --git a/test/test_internal.py b/test/test_internal.py index fb2cec8b..5d521d04 100644 --- a/test/test_internal.py +++ b/test/test_internal.py @@ -180,9 +180,7 @@ def test_wrap_text_wide_chars(): except ImportError: skip("test_wrap_text_wide_chars is skipped") - rows = [ - ["청자청자청자청자청자", "약간 감싸면 더 잘 보일 수있는 다소 긴 설명입니다"] - ] + rows = [["청자청자청자청자청자", "약간 감싸면 더 잘 보일 수있는 다소 긴 설명입니다"]] widths = [5, 20] expected = [ [ diff --git a/test/test_output.py b/test/test_output.py index 8871f220..68a7905e 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -16,9 +16,7 @@ def test_plain(): "Output: plain with headers" - expected = "\n".join( - ["strings numbers", "spam 41.9999", "eggs 451"] - ) + expected = "\n".join(["strings numbers", "spam 41.9999", "eggs 451"]) result = tabulate(_test_table, _test_table_headers, tablefmt="plain") assert_equal(expected, result) @@ -94,9 +92,7 @@ def test_plain_multiline_with_empty_cells(): def test_plain_multiline_with_empty_cells_headerless(): "Output: plain with multiline cells and empty cells without headers" table = [["0", "", ""], ["1", "", ""], ["2", "very long data", "fold\nthis"]] - expected = "\n".join( - ["0", "1", "2 very long data fold", " this"] - ) + expected = "\n".join(["0", "1", "2 very long data fold", " this"]) result = tabulate(table, tablefmt="plain") assert_equal(expected, result) @@ -105,9 +101,7 @@ def test_plain_maxcolwidth_autowraps(): "Output: maxcolwidth will result in autowrapping longer cells" 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] - ) + result = tabulate(table, headers="firstrow", tablefmt="plain", maxcolwidths=[10, 10]) assert_equal(expected, result) @@ -122,9 +116,7 @@ def test_plain_maxcolwidth_autowraps_with_sep(): expected = "\n".join( [" hdr fold", " 1 very long", " data", "", " 2 last line"] ) - result = tabulate( - table, headers="firstrow", tablefmt="plain", maxcolwidths=[10, 10] - ) + result = tabulate(table, headers="firstrow", tablefmt="plain", maxcolwidths=[10, 10]) assert_equal(expected, result) @@ -139,7 +131,7 @@ def test_plain_maxcolwidth_autowraps_wide_chars(): ["hdr", "fold"], [ "1", - "약간 감싸면 더 잘 보일 수있는 다소 긴 설명입니다 설명입니다 설명입니다 설명입니다 설명", + "약간 감싸면 더 잘 보일 수있는 다소 긴 설명입니다 설명입니다 설명입니다 설명입니다 설명", # noqa: E501 ], ] expected = "\n".join( @@ -150,9 +142,7 @@ def test_plain_maxcolwidth_autowraps_wide_chars(): " 설명입니다 설명입니다 설명", ] ) - result = tabulate( - table, headers="firstrow", tablefmt="plain", maxcolwidths=[10, 30] - ) + result = tabulate(table, headers="firstrow", tablefmt="plain", maxcolwidths=[10, 30]) assert_equal(expected, result) @@ -189,9 +179,7 @@ def test_maxcolwidth_pad_tailing_widths(): " short", ] ) - result = tabulate( - table, headers="firstrow", tablefmt="plain", maxcolwidths=[None, 6] - ) + result = tabulate(table, headers="firstrow", tablefmt="plain", maxcolwidths=[None, 6]) assert_equal(expected, result) @@ -354,9 +342,7 @@ def test_orgtbl_multiline_2_with_sep_line(): def test_simple_headerless(): "Output: simple without headers" - expected = "\n".join( - ["---- --------", "spam 41.9999", "eggs 451", "---- --------"] - ) + expected = "\n".join(["---- --------", "spam 41.9999", "eggs 451", "---- --------"]) result = tabulate(_test_table, tablefmt="simple") assert_equal(expected, result) @@ -1541,9 +1527,7 @@ def test_colon_grid_wide_characters(): "+-----------+---------+", ] ) - result = tabulate( - _test_table, headers, tablefmt="colon_grid", colalign=["left", "right"] - ) + result = tabulate(_test_table, headers, tablefmt="colon_grid", colalign=["left", "right"]) assert_equal(expected, result) @@ -1980,9 +1964,7 @@ def test_pipe(): def test_pipe_headerless(): "Output: pipe without headers" - expected = "\n".join( - ["|:-----|---------:|", "| spam | 41.9999 |", "| eggs | 451 |"] - ) + expected = "\n".join(["|:-----|---------:|", "| spam | 41.9999 |", "| eggs | 451 |"]) result = tabulate(_test_table, tablefmt="pipe") assert_equal(expected, result) @@ -2417,9 +2399,7 @@ def test_rst_with_empty_values_in_first_column(): def test_rst_headerless(): "Output: rst without headers" - expected = "\n".join( - ["==== ========", "spam 41.9999", "eggs 451", "==== ========"] - ) + expected = "\n".join(["==== ========", "spam 41.9999", "eggs 451", "==== ========"]) result = tabulate(_test_table, tablefmt="rst") assert_equal(expected, result) @@ -2834,9 +2814,7 @@ def test_intfmt_with_string_as_integer(): @mark.skip(reason="It detects all values as floats but there are strings and integers.") def test_intfmt_with_string_with_floats(): "Output: integer format" - result = tabulate( - [[82000.38], ["1500.47"], ["2463"], [92165]], intfmt=",", tablefmt="plain" - ) + result = tabulate([[82000.38], ["1500.47"], ["2463"], [92165]], intfmt=",", tablefmt="plain") expected = "82000.4\n 1500.47\n 2463\n92,165" assert_equal(expected, result) @@ -2880,27 +2858,21 @@ def test_floatfmt(): def test_floatfmt_thousands(): "Output: floating point format" - result = tabulate( - [["1.23456789"], [1.0], ["1,234.56"]], floatfmt=".3f", tablefmt="plain" - ) + result = tabulate([["1.23456789"], [1.0], ["1,234.56"]], floatfmt=".3f", tablefmt="plain") expected = " 1.235\n 1.000\n1234.560" assert_equal(expected, result) def test_floatfmt_multi(): "Output: floating point format different for each column" - result = tabulate( - [[0.12345, 0.12345, 0.12345]], floatfmt=(".1f", ".3f"), tablefmt="plain" - ) + result = tabulate([[0.12345, 0.12345, 0.12345]], floatfmt=(".1f", ".3f"), tablefmt="plain") expected = "0.1 0.123 0.12345" assert_equal(expected, result) def test_colalign_multi(): "Output: string columns with custom colalign" - result = tabulate( - [["one", "two"], ["three", "four"]], colalign=("right",), tablefmt="plain" - ) + result = tabulate([["one", "two"], ["three", "four"]], colalign=("right",), tablefmt="plain") expected = " one two\nthree four" assert_equal(expected, result) @@ -2966,9 +2938,7 @@ def test_colalign_or_headersalign_too_long(): colalign = ("global", "left", "center") headers = ["h"] headersalign = ("center", "right", "same") - result = tabulate( - table, headers=headers, colalign=colalign, headersalign=headersalign - ) + result = tabulate(table, headers=headers, colalign=colalign, headersalign=headersalign) expected = "\n".join([" h", "--- ---", " 1 2", "111 222"]) assert_equal(expected, result) @@ -2977,9 +2947,7 @@ def test_warning_when_colalign_or_headersalign_is_string(): """Test user warnings when `colalign` or `headersalign` is a string.""" table = [[1, "bar"]] opt = {"colalign": "center", "headers": ["foo", "2"], "headersalign": "center"} - check_warnings( - (tabulate, [table], opt), num=2, category=UserWarning, contain="As a string" - ) + check_warnings((tabulate, [table], opt), num=2, category=UserWarning, contain="As a string") def test_float_conversions(): @@ -3009,9 +2977,7 @@ def test_float_conversions(): def test_missingval(): "Output: substitution of missing values" - result = tabulate( - [["Alice", 10], ["Bob", None]], missingval="n/a", tablefmt="plain" - ) + result = tabulate([["Alice", 10], ["Bob", None]], missingval="n/a", tablefmt="plain") expected = "Alice 10\nBob n/a" assert_equal(expected, result) @@ -3291,15 +3257,11 @@ def test_disable_numparse_list(): "Output: Default table output, but with number parsing selectively disabled" table_headers = ["h1", "h2", "h3"] test_table = [["foo", "bar", "42992e1"]] - expected = "\n".join( - ["h1 h2 h3", "---- ---- -------", "foo bar 42992e1"] - ) + expected = "\n".join(["h1 h2 h3", "---- ---- -------", "foo bar 42992e1"]) result = tabulate(test_table, table_headers, disable_numparse=[2]) assert_equal(expected, result) - expected = "\n".join( - ["h1 h2 h3", "---- ---- ------", "foo bar 429920"] - ) + expected = "\n".join(["h1 h2 h3", "---- ---- ------", "foo bar 429920"]) result = tabulate(test_table, table_headers, disable_numparse=[0, 1]) assert_equal(expected, result) @@ -3308,9 +3270,7 @@ def test_preserve_whitespace(): "Output: Default table output, but with preserved leading whitespace." table_headers = ["h1", "h2", "h3"] test_table = [[" foo", " bar ", "foo"]] - expected = "\n".join( - ["h1 h2 h3", "----- ------- ----", " foo bar foo"] - ) + expected = "\n".join(["h1 h2 h3", "----- ------- ----", " foo bar foo"]) result = tabulate(test_table, table_headers, preserve_whitespace=True) assert_equal(expected, result) diff --git a/test/test_regression.py b/test/test_regression.py index 35fa42ae..5edaf226 100644 --- a/test/test_regression.py +++ b/test/test_regression.py @@ -151,9 +151,7 @@ def test_simple_separated_format_with_headers(): from tabulate import simple_separated_format expected = " a| b\n 1| 2" - formatted = tabulate( - [[1, 2]], headers=["a", "b"], tablefmt=simple_separated_format("|") - ) + formatted = tabulate([[1, 2]], headers=["a", "b"], tablefmt=simple_separated_format("|")) assert_equal(expected, formatted) @@ -239,9 +237,7 @@ def test_isconvertible_on_set_values(): def test_ansi_color_for_decimal_numbers(): "Regression: ANSI colors for decimal numbers (issue #36)" table = [["Magenta", "\033[95m" + "1.1" + "\033[0m"]] - expected = "\n".join( - ["------- ---", "Magenta \x1b[95m1.1\x1b[0m", "------- ---"] - ) + expected = "\n".join(["------- ---", "Magenta \x1b[95m1.1\x1b[0m", "------- ---"]) result = tabulate(table) assert_equal(expected, result) diff --git a/test/test_textwrapper.py b/test/test_textwrapper.py index 5e86b5c9..6f77cb7c 100644 --- a/test/test_textwrapper.py +++ b/test/test_textwrapper.py @@ -39,9 +39,9 @@ def test_wrap_longword_non_wide(): orig = OTW(width=width) cust = CTW(width=width) - assert orig.wrap(data) == cust.wrap( - data - ), "Failure on non-wide char longword regression check for width " + str(width) + assert orig.wrap(data) == cust.wrap(data), ( + "Failure on non-wide char longword regression check for width " + str(width) + ) def test_wrap_wide_char_multiword(): @@ -111,9 +111,7 @@ def test_wrapper_len_ignores_color_chars(): def test_wrap_full_line_color(): """TextWrapper: Wrap a line when the full thing is enclosed in color tags""" # This has both a text color and a background color - data = ( - "\033[31m\033[104mThis is a test string for testing TextWrap with colors\033[0m" - ) + data = "\033[31m\033[104mThis is a test string for testing TextWrap with colors\033[0m" expected = [ "\033[31m\033[104mThis is a test\033[0m", From 0e581068eefe852ba357d6c25202be2160159d0d Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 4 Mar 2026 23:08:31 +0100 Subject: [PATCH 044/116] add tox-uv plugin as a dev dependency --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 2ae0586c..27596a6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ dev = [ "build>=1.4.0", "ruff>=0.15.4", "tox>=4.47.3", + "tox-uv>=1.0", "twine>=6.2.0", ] From f4f06884eb37c9eff0a30f1b345a4e36376d2474 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 4 Mar 2026 23:12:35 +0100 Subject: [PATCH 045/116] upgrade github actions' versions --- .github/workflows/lint.yml | 4 ++-- .github/workflows/tabulate.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3bf20ee2..be87d66e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,9 +12,9 @@ jobs: python-version: ['3.13'] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Install dependencies diff --git a/.github/workflows/tabulate.yml b/.github/workflows/tabulate.yml index 141a686f..691613c1 100644 --- a/.github/workflows/tabulate.yml +++ b/.github/workflows/tabulate.yml @@ -13,9 +13,9 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} allow-prereleases: true From 6a1ee349545e4ea36bd7fba4165e37d11e81a7b6 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 4 Mar 2026 23:29:40 +0100 Subject: [PATCH 046/116] update the list of contributors --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4772c5b2..ad9fcff2 100644 --- a/README.md +++ b/README.md @@ -1279,4 +1279,5 @@ Vijaya Krishna Kasula, Furcy Pin, Christian Fibich, Shaun Duncan, Dimitri Papadopoulos, Élie Goudout, Racerroar888, Phill Zarfos, Keyacom, Andrew Coffey, Arpit Jain, Israel Roldan, ilya112358, Dan Nicholson, Frederik Scheerer, cdar07 (cdar), Racerroar888, -Perry Kundert. +Perry Kundert, Hnasar, Jun Koo, Jo2234, Bjorn Olsen, George Schizas, +Kadir Can Ozden. From 106b77e86320a15d002998e2243e5decbf62f959 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 4 Mar 2026 23:34:56 +0100 Subject: [PATCH 047/116] update CHANGELOG --- CHANGELOG | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 27374413..178695cd 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,7 @@ -- 0.10.0: Add support for Python 3.11, 3.12, 3.13. - Drop support for Python 3.7, 3.8. +- 0.11.0: + Drop support of the legacy `youtrack` format. +- 0.10.0: Add support for Python 3.11, 3.12, 3.13, 3.14. + Drop support for Python 3.7, 3.8, 3.9. PRESERVE_STERILITY global is replaced with preserve_sterility function argument. New formatting options: headersglobalalign, headersalign, colglobalalign. New output format: ``colon_grid`` (Pandoc grid_tables with alignment) From 5768953f2edb5a5d51c83b5d67f244194b762fd2 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 4 Mar 2026 23:35:36 +0100 Subject: [PATCH 048/116] [breaking] remove support of the legacy `youtrack` format --- tabulate/__init__.py | 11 ----------- test/test_output.py | 13 ------------- 2 files changed, 24 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 8a4b41ce..b9f9f568 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -627,16 +627,6 @@ def escape_empty(val): padding=1, with_header_hide=None, ), - "youtrack": TableFormat( - lineabove=None, - linebelowheader=None, - linebetweenrows=None, - linebelow=None, - headerrow=DataRow("|| ", " || ", " || "), - datarow=DataRow("| ", " | ", " |"), - padding=1, - with_header_hide=None, - ), "html": TableFormat( lineabove=_html_begin_table_without_header, linebelowheader="", @@ -766,7 +756,6 @@ def escape_empty(val): # TODO: Add multiline support for the remaining table formats: # - mediawiki: Replace \n with
# - moinmoin: TBD -# - youtrack: TBD # - html: Replace \n with
# - latex*: Use "makecell" package: In header, replace X\nY with # \thead{X\\Y} and in data row, replace X\nY with \makecell{X\\Y} diff --git a/test/test_output.py b/test/test_output.py index 68a7905e..d9405a64 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -2530,19 +2530,6 @@ def test_moinmoin(): assert_equal(expected, result) -def test_youtrack(): - "Output: youtrack with headers" - expected = "\n".join( - [ - "|| strings || numbers ||", - "| spam | 41.9999 |", - "| eggs | 451 |", - ] - ) - result = tabulate(_test_table, _test_table_headers, tablefmt="youtrack") - assert_equal(expected, result) - - def test_moinmoin_headerless(): "Output: moinmoin without headers" expected = "\n".join( From 1818a3b4ce66b69b08e8f76cfd25c32a67dcdd53 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Wed, 4 Mar 2026 23:51:36 +0100 Subject: [PATCH 049/116] require wcwidth>=0.6.0 (optional dependency for widechars) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 27596a6c..e4d75224 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ dynamic = ["version"] Homepage = "https://github.com/astanin/python-tabulate" [project.optional-dependencies] -widechars = ["wcwidth"] +widechars = ["wcwidth>=0.6.0"] [project.scripts] tabulate = "tabulate:_main" From 032dc0d107fedd4ce7921c1a494a76a2910f49ff Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 5 Mar 2026 00:19:11 +0100 Subject: [PATCH 050/116] fix #399 - avoid infinite loop in _CustomTextWrap on wide characters with width=1 --- tabulate/__init__.py | 4 ++++ test/test_textwrapper.py | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index b9f9f568..ae184f17 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -2739,6 +2739,10 @@ def _handle_long_word(self, reversed_chunks, cur_line, cur_len, width): stripped_chunk = _strip_ansi(chunk) while i <= len(stripped_chunk) and self._len(stripped_chunk[:i]) <= space_left: i = i + 1 + # Always consume at least one character so _wrap_chunks makes + # progress even when the first character is wider than space_left + # (e.g. a 2-column CJK char in a 1-column-wide slot). + i = max(i, 2) # Consider escape codes when breaking words up total_escape_len = 0 last_group = 0 diff --git a/test/test_textwrapper.py b/test/test_textwrapper.py index 6f77cb7c..03574c2c 100644 --- a/test/test_textwrapper.py +++ b/test/test_textwrapper.py @@ -286,3 +286,41 @@ def test_wrap_optional_bool_strs(): ] expected = "\n".join(expected) assert_equal(expected, result) + + +def test_wrap_wide_char_no_column_overflow(): + "TextWrapper: wide chars must not overflow the requested column width." + try: + import wcwidth + except ImportError: + skip("test_wrap_wide_char_no_column_overflow is skipped") + + # Each Korean character occupies 2 display columns. + data = "\ud55c\uae00\ud14c\uc2a4\ud2b8" # 한글테스트 + for width in [2, 3, 4, 5, 6]: + wrapper = CTW(width=width) + lines = wrapper.wrap(data) + for line in lines: + display_width = wcwidth.wcswidth(line) + assert display_width <= width, ( + f"Line {repr(line)} has display width {display_width} " + f"which exceeds requested column width {width}" + ) + + +def test_wrap_wide_char_narrower_than_char_width(): + """TextWrapper: column width smaller than a single wide char must not hang (issue #399). + + When the requested width is 1 but every character is 2 display columns + wide, _handle_long_word must still make progress (one character per line) + rather than looping forever. + """ + try: + import wcwidth # noqa: F401 + except ImportError: + skip("test_wrap_wide_char_narrower_than_char_width is skipped") + + data = "\ud55c\uae00" # 한글 -- each char is 2 display cols wide + # width=1 is narrower than any character; each char should still get its own line + result = CTW(width=1).wrap(data) + assert result == ["\ud55c", "\uae00"] From 897875659611d88673fbac9a9f6333624417d85c Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 5 Mar 2026 00:31:13 +0100 Subject: [PATCH 051/116] add test_grapheme_clusters.py from pull request #391 --- test/test_grapheme_clusters.py | 239 +++++++++++++++++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 test/test_grapheme_clusters.py diff --git a/test/test_grapheme_clusters.py b/test/test_grapheme_clusters.py new file mode 100644 index 00000000..af380b45 --- /dev/null +++ b/test/test_grapheme_clusters.py @@ -0,0 +1,239 @@ +"""Tests for Unicode grapheme cluster handling in tabulate.""" + +import pytest + +from tabulate import tabulate + +try: + import wcwidth + + HAS_WCWIDTH = True + HAS_WCWIDTH_030 = hasattr(wcwidth, "wrap") +except ImportError: + wcwidth = None + HAS_WCWIDTH = False + HAS_WCWIDTH_030 = False + +requires_wcwidth = pytest.mark.skipif(not HAS_WCWIDTH, reason="requires wcwidth") + +requires_wcwidth_030 = pytest.mark.skipif(not HAS_WCWIDTH_030, reason="requires wcwidth >= 0.3.0") + + +class TestGraphemeClusterWidth: + """Tests for correct width calculation of grapheme clusters.""" + + @requires_wcwidth + def test_zwj_family_emoji_width(self): + """ZWJ family emoji has display width 2.""" + family = "\U0001f468\u200d\U0001f469\u200d\U0001f467" + assert wcwidth.wcswidth(family) == 2 + + @requires_wcwidth + def test_regional_indicator_flag_width(self): + """Regional indicator pair (flag) has display width 2.""" + us_flag = "\U0001f1fa\U0001f1f8" + assert wcwidth.wcswidth(us_flag) == 2 + + @requires_wcwidth + def test_vs16_emoji_width(self): + """VS16 variation selector creates wide emoji.""" + heart = "\u2764\ufe0f" + assert wcwidth.wcswidth(heart) == 2 + + +class TestGraphemeClusterAlignment: + """Tests for correct alignment of cells containing grapheme clusters.""" + + @requires_wcwidth + def test_zwj_alignment_in_grid(self): + """ZWJ emoji aligns correctly in grid format.""" + family = "\U0001f468\u200d\U0001f469\u200d\U0001f467" + data = [ + ["ABC", "text"], + [family, "emoji"], + ] + result = tabulate(data, headers=["col", "desc"], tablefmt="grid") + lines = result.split("\n") + + border_width = len(lines[0]) + for line in lines: + from tabulate import _visible_width + + assert _visible_width(line) == border_width + + @requires_wcwidth + def test_flag_alignment_in_grid(self): + """Regional indicator flags align correctly in grid format.""" + us_flag = "\U0001f1fa\U0001f1f8" + data = [ + ["AB", "text"], + [us_flag, "flag"], + ] + result = tabulate(data, headers=["col", "desc"], tablefmt="grid") + lines = result.split("\n") + + border_width = len(lines[0]) + for line in lines: + from tabulate import _visible_width + + assert _visible_width(line) == border_width + + +class TestGraphemeClusterWrapping: + """Tests for grapheme cluster preservation during text wrapping. + + These tests require wcwidth >= 0.3.0 for iter_graphemes and wrap() APIs. + """ + + @requires_wcwidth_030 + def test_zwj_not_broken_during_wrap(self): + """ZWJ sequence preserved as single unit during wrap.""" + family = "\U0001f468\u200d\U0001f469\u200d\U0001f467" + data = [[f"A{family}B"]] + result = tabulate(data, tablefmt="plain", maxcolwidths=3) + + graphemes_in_result = [] + for line in result.split("\n"): + graphemes_in_result.extend(list(wcwidth.iter_graphemes(line.strip()))) + + assert family in graphemes_in_result + + @requires_wcwidth_030 + def test_flag_not_broken_during_wrap(self): + """Regional indicator flag preserved as single unit during wrap.""" + us_flag = "\U0001f1fa\U0001f1f8" + gb_flag = "\U0001f1ec\U0001f1e7" + fr_flag = "\U0001f1eb\U0001f1f7" + flags = us_flag + gb_flag + fr_flag + + data = [[flags]] + result = tabulate(data, tablefmt="plain", maxcolwidths=5) + + graphemes_in_result = [] + for line in result.split("\n"): + graphemes_in_result.extend(list(wcwidth.iter_graphemes(line.strip()))) + + assert us_flag in graphemes_in_result + assert gb_flag in graphemes_in_result + assert fr_flag in graphemes_in_result + + @requires_wcwidth_030 + def test_vs16_not_broken_during_wrap(self): + """VS16 variation selector kept with base character during wrap.""" + heart = "\u2764\ufe0f" + data = [[heart * 3]] + result = tabulate(data, tablefmt="plain", maxcolwidths=4) + + graphemes_in_result = [] + for line in result.split("\n"): + graphemes_in_result.extend(list(wcwidth.iter_graphemes(line.strip()))) + + heart_count = sum(1 for g in graphemes_in_result if g == heart) + assert heart_count == 3 + + @requires_wcwidth_030 + def test_skin_tone_modifier_not_broken(self): + """Skin tone modifier preserved with emoji during wrap.""" + wave_light = "\U0001f44b\U0001f3fb" + data = [[f"Hi{wave_light}there"]] + result = tabulate(data, tablefmt="plain", maxcolwidths=5) + + graphemes_in_result = [] + for line in result.split("\n"): + graphemes_in_result.extend(list(wcwidth.iter_graphemes(line.strip()))) + + assert wave_light in graphemes_in_result + + +class TestComplexGraphemeClusters: + """Tests for complex grapheme cluster scenarios. + + These tests require wcwidth >= 0.3.0 for iter_graphemes API. + """ + + @requires_wcwidth_030 + def test_multiple_zwj_sequences_in_cell(self): + """Multiple ZWJ sequences in single cell handled correctly.""" + family = "\U0001f468\u200d\U0001f469\u200d\U0001f467" + technologist = "\U0001f468\U0001f3fb\u200d\U0001f4bb" + data = [[f"{family} and {technologist}"]] + result = tabulate(data, tablefmt="plain", maxcolwidths=15) + + graphemes_in_result = [] + for line in result.split("\n"): + graphemes_in_result.extend(list(wcwidth.iter_graphemes(line.strip()))) + + assert family in graphemes_in_result + assert technologist in graphemes_in_result + + @requires_wcwidth_030 + def test_flags_with_text_wrap(self): + """Flags interspersed with text wrap correctly.""" + us_flag = "\U0001f1fa\U0001f1f8" + data = [[f"Visit {us_flag} USA today!"]] + result = tabulate(data, tablefmt="plain", maxcolwidths=10) + + graphemes_in_result = [] + for line in result.split("\n"): + graphemes_in_result.extend(list(wcwidth.iter_graphemes(line.strip()))) + + assert us_flag in graphemes_in_result + + @requires_wcwidth_030 + def test_combining_marks_preserved(self): + """Combining diacritical marks stay with base character.""" + e_acute = "e\u0301" + data = [[f"caf{e_acute} au lait"]] + result = tabulate(data, tablefmt="plain", maxcolwidths=5) + + graphemes_in_result = [] + for line in result.split("\n"): + graphemes_in_result.extend(list(wcwidth.iter_graphemes(line.strip()))) + + assert e_acute in graphemes_in_result + + +class TestAnsiWithGraphemeClusters: + """Tests for ANSI escape codes combined with grapheme clusters.""" + + @requires_wcwidth + def test_ansi_colored_zwj_width(self): + """ANSI colored ZWJ emoji has correct width.""" + family = "\U0001f468\u200d\U0001f469\u200d\U0001f467" + colored = f"\x1b[31m{family}\x1b[0m" + + from tabulate import _visible_width + + assert _visible_width(colored) == 2 + + @requires_wcwidth + def test_ansi_colored_zwj_alignment(self): + """ANSI colored ZWJ emoji aligns correctly.""" + family = "\U0001f468\u200d\U0001f469\u200d\U0001f467" + colored = f"\x1b[31m{family}\x1b[0m" + data = [ + ["AB", "text"], + [colored, "emoji"], + ] + result = tabulate(data, headers=["col", "desc"], tablefmt="grid") + lines = result.split("\n") + + from tabulate import _visible_width + + border_width = _visible_width(lines[0]) + for line in lines: + assert _visible_width(line) == border_width + + @requires_wcwidth_030 + def test_ansi_colored_flag_wrap(self): + """ANSI colored flag not broken during wrap.""" + us_flag = "\U0001f1fa\U0001f1f8" + colored = f"\x1b[34m{us_flag}\x1b[0m" + data = [[f"A{colored}B"]] + result = tabulate(data, tablefmt="plain", maxcolwidths=4) + + assert "\U0001f1fa" in result + assert "\U0001f1f8" in result + lines = [line.strip() for line in result.split("\n") if line.strip()] + flag_parts_same_line = any("\U0001f1fa" in line and "\U0001f1f8" in line for line in lines) + assert flag_parts_same_line From 4c9a049790c336bf02dcd4eb6aab36777687d4ff Mon Sep 17 00:00:00 2001 From: Jeff Quast Date: Thu, 5 Mar 2026 01:09:00 +0100 Subject: [PATCH 052/116] add fallback for measuring visible width with wcwidth < 0.3 based on pull request #391 --- README.md | 2 +- tabulate/__init__.py | 18 ++++++++++---- test/test_grapheme_clusters.py | 43 ++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ad9fcff2..405736e9 100644 --- a/README.md +++ b/README.md @@ -1280,4 +1280,4 @@ Dimitri Papadopoulos, Élie Goudout, Racerroar888, Phill Zarfos, Keyacom, Andrew Coffey, Arpit Jain, Israel Roldan, ilya112358, Dan Nicholson, Frederik Scheerer, cdar07 (cdar), Racerroar888, Perry Kundert, Hnasar, Jun Koo, Jo2234, Bjorn Olsen, George Schizas, -Kadir Can Ozden. +Kadir Can Ozden, Jeff Quast. diff --git a/tabulate/__init__.py b/tabulate/__init__.py index ae184f17..bafe51fd 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1111,13 +1111,21 @@ def _visible_width(s): """ # optional wide-character support if wcwidth is not None and WIDE_CHARS_MODE: - len_fn = wcwidth.wcswidth - else: - len_fn = len + # when already a string, it could contain terminal sequences, + # wcwidth >= 0.3.0 handles ANSI codes internally, + if hasattr(wcwidth, "width"): + return wcwidth.width(str(s)) + # while previous versions need them stripped first. + if isinstance(s, (str, bytes)): + return wcwidth.wcswidth(_strip_ansi(str(s))) + + # Otherwise, coerce to string, guaranteed to be without any control codes, + # we can use wcswidth() directly. + return wcwidth.wcswidth(str(s)) if isinstance(s, (str, bytes)): - return len_fn(_strip_ansi(s)) + return len(_strip_ansi(s)) else: - return len_fn(str(s)) + return len(str(s)) def _is_multiline(s): diff --git a/test/test_grapheme_clusters.py b/test/test_grapheme_clusters.py index af380b45..0a032b0d 100644 --- a/test/test_grapheme_clusters.py +++ b/test/test_grapheme_clusters.py @@ -1,5 +1,7 @@ """Tests for Unicode grapheme cluster handling in tabulate.""" +import unittest.mock as mock + import pytest from tabulate import tabulate @@ -9,15 +11,21 @@ HAS_WCWIDTH = True HAS_WCWIDTH_030 = hasattr(wcwidth, "wrap") + HAS_WCWIDTH_WIDTH = hasattr(wcwidth, "width") except ImportError: wcwidth = None HAS_WCWIDTH = False HAS_WCWIDTH_030 = False + HAS_WCWIDTH_WIDTH = False requires_wcwidth = pytest.mark.skipif(not HAS_WCWIDTH, reason="requires wcwidth") requires_wcwidth_030 = pytest.mark.skipif(not HAS_WCWIDTH_030, reason="requires wcwidth >= 0.3.0") +requires_wcwidth_width = pytest.mark.skipif( + not HAS_WCWIDTH_WIDTH, reason="requires wcwidth with width() API" +) + class TestGraphemeClusterWidth: """Tests for correct width calculation of grapheme clusters.""" @@ -237,3 +245,38 @@ def test_ansi_colored_flag_wrap(self): lines = [line.strip() for line in result.split("\n") if line.strip()] flag_parts_same_line = any("\U0001f1fa" in line and "\U0001f1f8" in line for line in lines) assert flag_parts_same_line + + +class TestVisibleWidthFallback: + """Tests for _visible_width wcwidth version compatibility. + + Covers both the modern wcwidth.width() path (>= 0.3.0) and the legacy + wcswidth() path used when width() is not available. + """ + + @requires_wcwidth_width + def test_visible_width_new_api_strips_ansi(self): + """_visible_width returns correct width via wcwidth.width() with ANSI codes.""" + from tabulate import _visible_width + + # Two Korean chars (each 2 cols wide) wrapped in ANSI color codes. + # wcwidth.width() handles ANSI internally, so no explicit stripping needed. + colored_wide = "\x1b[31m한글\x1b[0m" + assert _visible_width(colored_wide) == 4 + + @requires_wcwidth + def test_visible_width_legacy_api_strips_ansi(self): + """_visible_width strips ANSI before wcswidth() when width() is unavailable.""" + import tabulate as tabulate_module + from tabulate import _visible_width + + # Build a mock wcwidth that exposes only wcswidth(), not width(). + # spec= limits auto-created attributes, so hasattr(mock, "width") is False. + legacy_wcwidth = mock.MagicMock(spec=["wcswidth"]) + legacy_wcwidth.wcswidth.side_effect = wcwidth.wcswidth + + colored_wide = "\x1b[31m한글\x1b[0m" + with mock.patch.object(tabulate_module, "wcwidth", legacy_wcwidth): + result = _visible_width(colored_wide) + + assert result == 4 From 553ecc0e7ecb1d04ca52bbbce188e57112d5032a Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Thu, 5 Mar 2026 00:08:10 +0100 Subject: [PATCH 053/116] Prefer f-strings --- test/test_textwrapper.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/test_textwrapper.py b/test/test_textwrapper.py index 03574c2c..caf85e29 100644 --- a/test/test_textwrapper.py +++ b/test/test_textwrapper.py @@ -17,7 +17,7 @@ def test_wrap_multiword_non_wide(): assert [line.rstrip() for line in orig.wrap(data)] == [ line.rstrip() for line in cust.wrap(data) - ], "Failure on non-wide char multiword regression check for width " + str(width) + ], f"Failure on non-wide char multiword regression check for width {width}" def test_wrap_multiword_non_wide_with_hypens(): @@ -29,7 +29,7 @@ def test_wrap_multiword_non_wide_with_hypens(): assert [line.rstrip() for line in orig.wrap(data)] == [ line.rstrip() for line in cust.wrap(data) - ], "Failure on non-wide char hyphen regression check for width " + str(width) + ], f"Failure on non-wide char hyphen regression check for width {width}" def test_wrap_longword_non_wide(): @@ -40,7 +40,7 @@ def test_wrap_longword_non_wide(): cust = CTW(width=width) assert orig.wrap(data) == cust.wrap(data), ( - "Failure on non-wide char longword regression check for width " + str(width) + f"Failure on non-wide char longword regression check for width {width}" ) From 0e842e1b08abc848f9bad2208c5a78f0d7743941 Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Wed, 4 Mar 2026 23:46:00 +0100 Subject: [PATCH 054/116] A few ruff / pre-commit details - The proper hook for the linter is `ruff-check` nowadays. - Use `extend-select` and let the linter choose its default rule sets - compatible with formatters. - No need to specify the directory to check, ruff operates on `.` by default. - The `debug-statements` hook can be replaced by Ruff debugger (T100) rules if needed. - Add the `end-of-file-fixer` hook. --- .pre-commit-config.yaml | 7 +++---- HOWTOPUBLISH | 2 +- benchmark/requirements.txt | 2 +- pyproject.toml | 4 ++-- tox.ini | 5 ++--- 5 files changed, 9 insertions(+), 11 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e9808666..9d940f05 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,13 +2,12 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.15.4 hooks: - - id: ruff - args: [--fix] + - id: ruff-check + args: ["--fix", "--show-fixes"] - id: ruff-format - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: + - id: end-of-file-fixer - id: trailing-whitespace - id: check-yaml - - id: debug-statements - language_version: python3 diff --git a/HOWTOPUBLISH b/HOWTOPUBLISH index 880ab19c..a5b0fb40 100644 --- a/HOWTOPUBLISH +++ b/HOWTOPUBLISH @@ -14,4 +14,4 @@ git push # wait for all CI builds to succeed git push --tags # if CI builds succeed twine upload --repository-url https://test.pypi.org/legacy/ dist/* twine upload dist/* -# use __token__ as username andPyPI API token as password (generate at pypi.org → Account settings → API tokens) \ No newline at end of file +# use __token__ as username andPyPI API token as password (generate at pypi.org → Account settings → API tokens) diff --git a/benchmark/requirements.txt b/benchmark/requirements.txt index 81086efe..861b7eea 100644 --- a/benchmark/requirements.txt +++ b/benchmark/requirements.txt @@ -1,2 +1,2 @@ prettytable -texttable \ No newline at end of file +texttable diff --git a/pyproject.toml b/pyproject.toml index e4d75224..95fddcaf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,8 +47,8 @@ dev = [ line-length = 99 [tool.ruff.lint] -select = ["E", "F", "W", "C90"] -ignore = ["E203", "E402", "E721", "C901"] +extend-select = ["W", "C90"] +ignore = ["E721", "C901"] [tool.ruff.lint.mccabe] max-complexity = 22 diff --git a/tox.ini b/tox.ini index 3baea31a..8eb0a182 100644 --- a/tox.ini +++ b/tox.ini @@ -30,8 +30,8 @@ passenv = [testenv:lint] commands = - ruff check . - ruff format --check . + ruff check + ruff format --check deps = ruff @@ -147,4 +147,3 @@ deps = numpy pandas wcwidth - From 3381cfcabe361feb3973945aebb74a310ca1008b Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Thu, 5 Mar 2026 00:16:04 +0100 Subject: [PATCH 055/116] Apply ruff rule RUF100 Unused blanket `noqa` directive --- tabulate/__init__.py | 2 +- test/test_api.py | 6 +++--- test/test_output.py | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index bafe51fd..2ced035a 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1318,7 +1318,7 @@ def _format(val, valtype, floatfmt, intfmt, missingval="", has_invisible=True): tabulate(tbl, headers=hrow) == good_result True - """ # noqa: E501 + """ if val is None: return missingval if isinstance(val, (bytes, str)) and not val: diff --git a/test/test_api.py b/test/test_api.py index 20493ec0..512a84d6 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -17,7 +17,7 @@ def test_tabulate_formats(): print("tabulate_formats = %r" % supported) assert type(supported) is list for fmt in supported: - assert type(fmt) is str # noqa + assert type(fmt) is str def _check_signature(function, expected_sig): @@ -34,7 +34,7 @@ def _check_signature(function, expected_sig): def test_tabulate_signature(): "API: tabulate() type signature is unchanged" - assert type(tabulate) is type(lambda: None) # noqa + assert type(tabulate) is type(lambda: None) expected_sig = [ ("tabular_data", _empty), ("headers", ()), @@ -62,6 +62,6 @@ def test_tabulate_signature(): def test_simple_separated_format_signature(): "API: simple_separated_format() type signature is unchanged" - assert type(simple_separated_format) is type(lambda: None) # noqa + assert type(simple_separated_format) is type(lambda: None) expected_sig = [("separator", _empty)] _check_signature(simple_separated_format, expected_sig) diff --git a/test/test_output.py b/test/test_output.py index d9405a64..7a56ad9a 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -131,7 +131,7 @@ def test_plain_maxcolwidth_autowraps_wide_chars(): ["hdr", "fold"], [ "1", - "약간 감싸면 더 잘 보일 수있는 다소 긴 설명입니다 설명입니다 설명입니다 설명입니다 설명", # noqa: E501 + "약간 감싸면 더 잘 보일 수있는 다소 긴 설명입니다 설명입니다 설명입니다 설명입니다 설명", ], ] expected = "\n".join( @@ -2557,7 +2557,7 @@ def test_html(): [ "", "", - '', # noqa + '', "", "", '', @@ -2578,7 +2578,7 @@ def test_unsafehtml(): [ "
<strings> <&numbers&>
<strings> <&numbers&>
spam > 41.9999
", "", - "", # noqa + "", "", "", '', From 8f32086665760bcb2bd729623dc58a8da259a68b Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Thu, 5 Mar 2026 00:20:22 +0100 Subject: [PATCH 056/116] Apply ruff/Pylint rule PLW0108 Lambda may be unnecessary; consider inlining inner function --- test/test_input.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/test_input.py b/test/test_input.py index fca67506..c2d19c5d 100644 --- a/test/test_input.py +++ b/test/test_input.py @@ -12,7 +12,7 @@ def test_iterable_of_iterables(): "Input: an iterable of iterables." - ii = iter(map(lambda x: iter(x), [range(5), range(5, 0, -1)])) + ii = iter(map(iter, [range(5), range(5, 0, -1)])) expected = "\n".join(["- - - - -", "0 1 2 3 4", "5 4 3 2 1", "- - - - -"]) result = tabulate(ii) assert_equal(expected, result) @@ -20,7 +20,7 @@ def test_iterable_of_iterables(): def test_iterable_of_iterables_headers(): "Input: an iterable of iterables with headers." - ii = iter(map(lambda x: iter(x), [range(5), range(5, 0, -1)])) + ii = iter(map(iter, [range(5), range(5, 0, -1)])) expected = "\n".join( [ " a b c d e", @@ -35,7 +35,7 @@ def test_iterable_of_iterables_headers(): def test_iterable_of_iterables_firstrow(): "Input: an iterable of iterables with the first row as headers" - ii = iter(map(lambda x: iter(x), ["abcde", range(5), range(5, 0, -1)])) + ii = iter(map(iter, ["abcde", range(5), range(5, 0, -1)])) expected = "\n".join( [ " a b c d e", From 5373ebfa9568db65cf6b9a4edfe22662d028ef12 Mon Sep 17 00:00:00 2001 From: Mayukha Vadari Date: Wed, 4 Mar 2026 20:31:15 -0400 Subject: [PATCH 057/116] fix #176 - implement Decimal fixed-point support * fix decimal precision issue * fix other float to Decimal * fix float precision * add test * clean up * fix bug * expand test * fix test to get around weird precision issues in python2.7 * lint * respond to comments --------- Co-authored-by: Sergey Astanin --- tabulate/__init__.py | 3 +++ test/test_output.py | 11 +++++++++++ 2 files changed, 14 insertions(+) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 2ced035a..da88b04f 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -13,6 +13,7 @@ import warnings from collections import namedtuple from collections.abc import Iterable, Sized +from decimal import Decimal from html import escape as htmlescape from itertools import chain, zip_longest as izip_longest from functools import reduce, partial @@ -1356,6 +1357,8 @@ def _format(val, valtype, floatfmt, intfmt, missingval="", has_invisible=True): else: if isinstance(val, str) and "," in val: val = val.replace(",", "") # handle thousands-separators + if isinstance(val, Decimal): + return format(val, floatfmt) try: return format(float(val), floatfmt) except (ValueError, TypeError): diff --git a/test/test_output.py b/test/test_output.py index 7a56ad9a..7b44dddc 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -1,5 +1,6 @@ """Test output of the various forms of tabular data.""" +from decimal import Decimal from pytest import mark from common import assert_equal, raises, skip, check_warnings @@ -2857,6 +2858,16 @@ def test_floatfmt_multi(): assert_equal(expected, result) +def test_floatfmt_decimal(): + result = tabulate( + [[Decimal("99999998999.999980"), 1234.5, 1.2345678, "inf"]], + floatfmt=".6f", + tablefmt="plain", + ) + expected = "99999998999.999980 1234.500000 1.234568 inf" + assert_equal(expected, result) + + def test_colalign_multi(): "Output: string columns with custom colalign" result = tabulate([["one", "two"], ["three", "four"]], colalign=("right",), tablefmt="plain") From 877c0206130816534267b2b9b765afa47d990417 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 5 Mar 2026 01:34:56 +0100 Subject: [PATCH 058/116] update contributors list and CHANGELOG - add Decimal format support --- CHANGELOG | 4 +++- README.md | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 178695cd..a82abc0a 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,7 @@ - 0.11.0: - Drop support of the legacy `youtrack` format. + Drop support of the legacy `youtrack` format. + Add support of the `Decimal` data type. + Various bug fixes. - 0.10.0: Add support for Python 3.11, 3.12, 3.13, 3.14. Drop support for Python 3.7, 3.8, 3.9. PRESERVE_STERILITY global is replaced with preserve_sterility function argument. diff --git a/README.md b/README.md index 405736e9..10b54c84 100644 --- a/README.md +++ b/README.md @@ -1280,4 +1280,4 @@ Dimitri Papadopoulos, Élie Goudout, Racerroar888, Phill Zarfos, Keyacom, Andrew Coffey, Arpit Jain, Israel Roldan, ilya112358, Dan Nicholson, Frederik Scheerer, cdar07 (cdar), Racerroar888, Perry Kundert, Hnasar, Jun Koo, Jo2234, Bjorn Olsen, George Schizas, -Kadir Can Ozden, Jeff Quast. +Kadir Can Ozden, Jeff Quast, Mayukha Vadari. From c576b072819bb5010125dd6e746e0c9ac69fa8f9 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 5 Mar 2026 01:44:45 +0100 Subject: [PATCH 059/116] add test case from pull request #387 - test_wrap_color_line_longword_zerowidth --- test/test_textwrapper.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/test/test_textwrapper.py b/test/test_textwrapper.py index caf85e29..7cd1581c 100644 --- a/test/test_textwrapper.py +++ b/test/test_textwrapper.py @@ -174,6 +174,44 @@ def test_wrap_color_line_longword(): assert_equal(expected, result) +def test_wrap_color_line_longword_zerowidth(): + """Lines with zero-width symbols (accents) must include those symbols with the prior symbol. + Let's exercise the calculation where the available symbols never satisfy the available width, + and ensure chunk calculation succeeds and ANSI colors are maintained. + + Most combining marks combine with the preceding character (even in right-to-left alphabets): + - "e\u0301" → "é" (e + combining acute accent) + - "a\u0308" → "ä" (a + combining diaeresis) + - "n\u0303" → "ñ" (n + combining tilde) + Enclosing Marks: Some combining marks enclose the base character: + - "A\u20dd" → Ⓐ Combining enclosing circle + Multiple Combining Marks: You can stack multiple combining marks on a single base character: + - "e\u0301\u0308" → e with both acute accent and diaeresis + Zero width space → "ab" with a : + - "a\u200bb" + + """ + try: + import wcwidth # noqa + except ImportError: + skip("test_wrap_wide_char is skipped") + + # Exactly filled, with a green zero-width segment at the end. + data = ( + "This_is_A\u20dd_\033[31mte\u0301st_string_\u200b" + "to_te\u0301\u0308st_a\u0308ccent\033[32m\u200b\033[0m" + ) + + expected = [ + "This_is_A\u20dd_\033[31mte\u0301\033[0m", + "\033[31mst_string_\u200bto\033[0m", + "\033[31m_te\u0301\u0308st_a\u0308ccent\033[32m\u200b\033[0m", + ] + wrapper = CTW(width=12) + result = wrapper.wrap(data) + assert_equal(expected, result) + + def test_wrap_color_line_multiple_escapes(): data = "012345(\x1b[32ma\x1b[0mbc\x1b[32mdefghij\x1b[0m)" expected = [ From b577975155c3e27bbc3d440794f445e917e479e0 Mon Sep 17 00:00:00 2001 From: Rebecca Jean Herman Date: Tue, 20 Aug 2024 15:15:35 +0200 Subject: [PATCH 060/116] minor change to fix issue preventing passing in an interable for showindex --- tabulate/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index da88b04f..c45c6aa2 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1600,7 +1600,7 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): # add or remove an index column showindex_is_a_str = type(showindex) in [str, bytes] - if showindex == "default" and index is not None: + if showindex_is_a_str and showindex == "default" and index is not None: rows = _prepend_row_index(rows, index) elif isinstance(showindex, Sized) and not showindex_is_a_str: rows = _prepend_row_index(rows, list(showindex)) From 067271664fe1d2845550ca3e86739bc237785eec Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 5 Mar 2026 02:16:42 +0100 Subject: [PATCH 061/116] add test case and update contributors for pull request #337 --- README.md | 2 +- test/test_regression.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 10b54c84..77a243e1 100644 --- a/README.md +++ b/README.md @@ -1280,4 +1280,4 @@ Dimitri Papadopoulos, Élie Goudout, Racerroar888, Phill Zarfos, Keyacom, Andrew Coffey, Arpit Jain, Israel Roldan, ilya112358, Dan Nicholson, Frederik Scheerer, cdar07 (cdar), Racerroar888, Perry Kundert, Hnasar, Jun Koo, Jo2234, Bjorn Olsen, George Schizas, -Kadir Can Ozden, Jeff Quast, Mayukha Vadari. +Kadir Can Ozden, Jeff Quast, Mayukha Vadari, Rebecca Jean Herman. diff --git a/test/test_regression.py b/test/test_regression.py index 5edaf226..de950b30 100644 --- a/test/test_regression.py +++ b/test/test_regression.py @@ -471,6 +471,22 @@ def count(start, step=1): assert_equal(expected, result) +def test_numpy_array_as_showindex(): + "Regression: numpy array as showindex must not raise ValueError on == comparison" + try: + import numpy as np + except ImportError: + raise skip("") + + table = [["a"], ["b"], ["c"]] + # np.array([...]) == "default" returns an element-wise boolean array whose + # truth value is ambiguous; the fix short-circuits the comparison when + # showindex is not a string. + expected = "10 a\n20 b\n30 c" + result = tabulate(table, showindex=np.array([10, 20, 30]), tablefmt="plain") + assert_equal(expected, result) + + def test_preserve_line_breaks_with_maxcolwidths(): "Regression: preserve line breaks when using maxcolwidths (github issue #190)" table = [["123456789 bbb\nccc"]] From ab790a6a160889e6505554f9829cbe43efb25a94 Mon Sep 17 00:00:00 2001 From: J08nY Date: Thu, 19 Oct 2023 13:09:56 +0200 Subject: [PATCH 062/116] Fix colalign in HTML when default is right. --- tabulate/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index c45c6aa2..4d01db9c 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -210,7 +210,7 @@ def _html_begin_table_without_header(colwidths_ignore, colaligns_ignore): def _html_row_with_attrs(celltag, unsafe, cell_values, colwidths, colaligns): alignment = { - "left": "", + "left": ' style="text-align: left;"', "right": ' style="text-align: right;"', "center": ' style="text-align: center;"', "decimal": ' style="text-align: right;"', @@ -233,7 +233,7 @@ def _html_row_with_attrs(celltag, unsafe, cell_values, colwidths, colaligns): def _moin_row_with_attrs(celltag, cell_values, colwidths, colaligns, header=""): alignment = { - "left": "", + "left": ' style="text-align: left;"', "right": '', "center": '', "decimal": '', From ebd50a75e116f367680f01fb28ef1f67ad3189f5 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 5 Mar 2026 02:36:16 +0100 Subject: [PATCH 063/116] update html and moinmoin output tests - add explicit text-align: left style related: pull request #295 --- tabulate/__init__.py | 8 ++++---- test/test_output.py | 30 +++++++++++++++--------------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 4d01db9c..249b3ff8 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -233,7 +233,7 @@ def _html_row_with_attrs(celltag, unsafe, cell_values, colwidths, colaligns): def _moin_row_with_attrs(celltag, cell_values, colwidths, colaligns, header=""): alignment = { - "left": ' style="text-align: left;"', + "left": '', "right": '', "center": '', "decimal": '', @@ -2149,11 +2149,11 @@ def tabulate( ... headers="firstrow", tablefmt="html"))
strings numbers
strings numbers
spam 41.9999
- + - - + +
strings numbers
strings numbers
spam 41.9999
eggs 451
spam 41.9999
eggs 451
diff --git a/test/test_output.py b/test/test_output.py index 7b44dddc..310ad124 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -2522,9 +2522,9 @@ def test_moinmoin(): "Output: moinmoin with headers" expected = "\n".join( [ - "|| ''' strings ''' || ''' numbers ''' ||", - '|| spam || 41.9999 ||', - '|| eggs || 451 ||', + "|| ''' strings ''' || ''' numbers ''' ||", + '|| spam || 41.9999 ||', + '|| eggs || 451 ||', ] ) result = tabulate(_test_table, _test_table_headers, tablefmt="moinmoin") @@ -2535,8 +2535,8 @@ def test_moinmoin_headerless(): "Output: moinmoin without headers" expected = "\n".join( [ - '|| spam || 41.9999 ||', - '|| eggs || 451 ||', + '|| spam || 41.9999 ||', + '|| eggs || 451 ||', ] ) result = tabulate(_test_table, tablefmt="moinmoin") @@ -2558,11 +2558,11 @@ def test_html(): [ "", "", - '', + '', "", "", - '', - '', + '', + '', "", "
<strings> <&numbers&>
<strings> <&numbers&>
spam > 41.9999
eggs & 451
spam > 41.9999
eggs & 451
", ] @@ -2579,11 +2579,11 @@ def test_unsafehtml(): [ "", "", - "", + "", "", "", - '', - '', + '', + '', "", "
strings numbers
strings numbers
spam 41.9999
eggs 451.0
spam 41.9999
eggs 451.0
", ] @@ -2602,8 +2602,8 @@ def test_html_headerless(): [ "", "", - '', - '', + '', + '', "", "
spam > 41.9999
eggs &451
spam > 41.9999
eggs &451
", ] @@ -2620,8 +2620,8 @@ def test_unsafehtml_headerless(): [ "", "", - '', - '', + '', + '', "", "
spam41.9999
eggs451.0
spam41.9999
eggs451.0
", ] From caec0c32bae0091f086d5ee4d11b8766820ef4ac Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 5 Mar 2026 02:37:54 +0100 Subject: [PATCH 064/116] ruff format --- test/test_output.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_output.py b/test/test_output.py index 310ad124..93de112e 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -2579,7 +2579,7 @@ def test_unsafehtml(): [ "", "", - "", + '', "", "", '', From 1c7bbd83c04e0d6b3fdabfc0c78d077439c0b74c Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 5 Mar 2026 02:50:56 +0100 Subject: [PATCH 065/116] update Contributors --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 77a243e1..cefbe720 100644 --- a/README.md +++ b/README.md @@ -1280,4 +1280,5 @@ Dimitri Papadopoulos, Élie Goudout, Racerroar888, Phill Zarfos, Keyacom, Andrew Coffey, Arpit Jain, Israel Roldan, ilya112358, Dan Nicholson, Frederik Scheerer, cdar07 (cdar), Racerroar888, Perry Kundert, Hnasar, Jun Koo, Jo2234, Bjorn Olsen, George Schizas, -Kadir Can Ozden, Jeff Quast, Mayukha Vadari, Rebecca Jean Herman. +Kadir Can Ozden, Jeff Quast, Mayukha Vadari, Rebecca Jean Herman, +Ján Jančár (J08nY). From b7dced48b168e71c50f390bb1c5b06ecfe9e4fde Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 5 Mar 2026 10:05:45 +0100 Subject: [PATCH 066/116] remove py38 and py39 from the env_list in tox.ini --- tox.ini | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index 8eb0a182..476c57b8 100644 --- a/tox.ini +++ b/tox.ini @@ -1,4 +1,4 @@ -# Tox (http://tox.testrun.org/) is a tool for running tests +# Tox (http://tox.readthedocs.org/) is a tool for running tests # in multiple virtualenvs. This configuration file will run the # test suite on all supported python versions. To use it, "pip install tox" # and then run "tox" from this directory. @@ -8,7 +8,7 @@ # for testing and it is disabled by default. [tox] -envlist = lint, py{38, 39, 310, 311, 312, 313, 314} +envlist = lint, py{310, 311, 312, 313, 314} isolated_build = True [gh] From 71282a68dc3a5ab1e42449dd196cb97b0d5a88c9 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 5 Mar 2026 10:33:25 +0100 Subject: [PATCH 067/116] add wcwidth>=0.6.0 as a dependency of the github workflows --- .github/workflows/tabulate.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tabulate.yml b/.github/workflows/tabulate.yml index 691613c1..7e808322 100644 --- a/.github/workflows/tabulate.yml +++ b/.github/workflows/tabulate.yml @@ -22,7 +22,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install pytest numpy pandas + python -m pip install pytest numpy pandas "wcwidth>=0.6.0" - name: Run tests run: | - pytest -v --doctest-modules --ignore benchmark/benchmark.py + pytest -v --doctest-modules --ignore benchmark From 9794747bdc02e71b034b2031f5644e5d61f86891 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 5 Mar 2026 10:38:11 +0100 Subject: [PATCH 068/116] add codecov to github actions --- .github/workflows/tabulate.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tabulate.yml b/.github/workflows/tabulate.yml index 7e808322..50472508 100644 --- a/.github/workflows/tabulate.yml +++ b/.github/workflows/tabulate.yml @@ -8,6 +8,7 @@ jobs: build: strategy: matrix: + # Python 3.9 is not supported anymore, but it still works... python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] os: ["ubuntu-latest", "windows-latest", "macos-latest"] runs-on: ${{ matrix.os }} @@ -25,4 +26,8 @@ jobs: python -m pip install pytest numpy pandas "wcwidth>=0.6.0" - name: Run tests run: | - pytest -v --doctest-modules --ignore benchmark + pytest -v --doctest-modules --ignore benchmark --cov=tabulate --cov-branch --cov-report=xml + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} From 28ca4d473bdac2d33d6072d9f426ce0d31ddd0bd Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 5 Mar 2026 10:39:25 +0100 Subject: [PATCH 069/116] add pytest-cov to the list of github actions' dependencies --- .github/workflows/tabulate.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tabulate.yml b/.github/workflows/tabulate.yml index 50472508..5458c382 100644 --- a/.github/workflows/tabulate.yml +++ b/.github/workflows/tabulate.yml @@ -23,7 +23,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install pytest numpy pandas "wcwidth>=0.6.0" + python -m pip install pytest pytest-cov numpy pandas "wcwidth>=0.6.0" - name: Run tests run: | pytest -v --doctest-modules --ignore benchmark --cov=tabulate --cov-branch --cov-report=xml From c26c2ef3ffac0507ce9d285d966af1a2540ae73a Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 5 Mar 2026 10:43:55 +0100 Subject: [PATCH 070/116] add code coverage badge to README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cefbe720..69fbebb3 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ pip install tabulate Build status ------------ -[![python-tabulate](https://github.com/astanin/python-tabulate/actions/workflows/tabulate.yml/badge.svg)](https://github.com/astanin/python-tabulate/actions/workflows/tabulate.yml) +[![python-tabulate](https://github.com/astanin/python-tabulate/actions/workflows/tabulate.yml/badge.svg)](https://github.com/astanin/python-tabulate/actions/workflows/tabulate.yml) [![codecov](https://codecov.io/github/astanin/python-tabulate/graph/badge.svg?token=Aa6wexP5wq)](https://codecov.io/github/astanin/python-tabulate) Library usage ------------- From 40d9cb5b99b165064b7b0f7c88f1fe807a0dcdfb Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 5 Mar 2026 10:47:58 +0100 Subject: [PATCH 071/116] remove `youtrack` examples from the documentation --- README.md | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/README.md b/README.md index 69fbebb3..c939d48a 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,6 @@ Supported table formats are: - "rst" - "mediawiki" - "moinmoin" -- "youtrack" - "html" - "unsafehtml" - "latex" @@ -584,17 +583,6 @@ MediaWiki-based sites: ``` -`youtrack` format produces a table markup used in Youtrack tickets: - -```pycon ->>> print(tabulate(table, headers, tablefmt="youtrack")) -|| item || qty || -| spam | 42 | -| eggs | 451 | -| bacon | 0 | - -``` - `textile` format produces a table markup used in [Textile](http://redcloth.org/hobix.com/textile/) format: From 9019f2fbd7f2878c7ea94ee632e06476d851b866 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 5 Mar 2026 10:52:59 +0100 Subject: [PATCH 072/116] add style="text-align: left;" to the examples of the `html` format output --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c939d48a..5d2e0f9b 100644 --- a/README.md +++ b/README.md @@ -604,12 +604,12 @@ and a .str property so that the raw HTML remains accessible. >>> print(tabulate(table, headers, tablefmt="html"))
strings numbers
strings numbers
spam 41.9999
- + - - - + + +
item qty
item qty
spam 42
eggs 451
bacon 0
spam 42
eggs 451
bacon 0
From 0c7d5012ed9c66137d6c0c6c83a552339098e57e Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 5 Mar 2026 11:15:46 +0100 Subject: [PATCH 073/116] fix examples in README.md to pass doctests --- README.md | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 5d2e0f9b..b6bb8eba 100644 --- a/README.md +++ b/README.md @@ -502,10 +502,10 @@ format: >>> print(tabulate(table, headers, tablefmt="asciidoc")) [cols="<8,>7",options="header"] |==== -| item | qty -| spam | 42 -| eggs | 451 -| bacon | 0 +| item | qty +| spam | 42 +| eggs | 451 +| bacon | 0 |==== ``` @@ -576,10 +576,10 @@ MediaWiki-based sites: ```pycon >>> print(tabulate(table, headers, tablefmt="moinmoin")) -|| ''' item ''' || ''' qty ''' || -|| spam || 42 || -|| eggs || 451 || -|| bacon || 0 || +|| ''' item ''' || ''' qty ''' || +|| spam || 42 || +|| eggs || 451 || +|| bacon || 0 || ``` @@ -1068,12 +1068,17 @@ If false, only whitespaces will be considered as potentially good places for lin | John Smith | Middle- | | | Manager | +------------+---------+ + +``` + +```pycon >>> print(tabulate([["John Smith", "Middle-Manager"]], headers=["Name", "Title"], tablefmt="grid", maxcolwidths=[None, 5], break_long_words=False, break_on_hyphens=False)) +------------+----------------+ | Name | Title | +============+================+ | John Smith | Middle-Manager | +------------+----------------+ + ``` ### Adding Separating lines From 26ccb780f4ae05995485e6f3199bcc5f7214decf Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 5 Mar 2026 11:16:12 +0100 Subject: [PATCH 074/116] add README doctests to tox tests and github actions --- .github/workflows/tabulate.yml | 2 +- tox.ini | 30 +++++++++++++++--------------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/tabulate.yml b/.github/workflows/tabulate.yml index 5458c382..1e2577bb 100644 --- a/.github/workflows/tabulate.yml +++ b/.github/workflows/tabulate.yml @@ -26,7 +26,7 @@ jobs: python -m pip install pytest pytest-cov numpy pandas "wcwidth>=0.6.0" - name: Run tests run: | - pytest -v --doctest-modules --ignore benchmark --cov=tabulate --cov-branch --cov-report=xml + pytest -v --doctest-modules --ignore benchmark --doctest-glob="README.md" --cov=tabulate --cov-branch --cov-report=xml - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v5 with: diff --git a/tox.ini b/tox.ini index 476c57b8..3dcc7300 100644 --- a/tox.ini +++ b/tox.ini @@ -20,7 +20,7 @@ python = 3.14: py314-extra [testenv] -commands = pytest -v --doctest-modules --ignore benchmark {posargs} +commands = pytest -v --doctest-modules --ignore benchmark --doctest-glob="README.md" {posargs} deps = pytest passenv = @@ -37,13 +37,13 @@ deps = [testenv:py38] basepython = python3.8 -commands = pytest -v --doctest-modules --ignore benchmark {posargs} +commands = pytest -v --doctest-modules --ignore benchmark --doctest-glob="README.md" {posargs} deps = pytest [testenv:py38-extra] basepython = python3.8 -commands = pytest -v --doctest-modules --ignore benchmark {posargs} +commands = pytest -v --doctest-modules --ignore benchmark --doctest-glob="README.md" {posargs} deps = pytest numpy @@ -53,13 +53,13 @@ deps = [testenv:py39] basepython = python3.9 -commands = pytest -v --doctest-modules --ignore benchmark {posargs} +commands = pytest -v --doctest-modules --ignore benchmark --doctest-glob="README.md" {posargs} deps = pytest [testenv:py39-extra] basepython = python3.9 -commands = pytest -v --doctest-modules --ignore benchmark {posargs} +commands = pytest -v --doctest-modules --ignore benchmark --doctest-glob="README.md" {posargs} deps = pytest numpy @@ -69,14 +69,14 @@ deps = [testenv:py310] basepython = python3.10 -commands = pytest -v --doctest-modules --ignore benchmark {posargs} +commands = pytest -v --doctest-modules --ignore benchmark --doctest-glob="README.md" {posargs} deps = pytest [testenv:py310-extra] basepython = python3.10 setenv = PYTHONDEVMODE = 1 -commands = pytest -v --doctest-modules --ignore benchmark {posargs} +commands = pytest -v --doctest-modules --ignore benchmark --doctest-glob="README.md" {posargs} deps = pytest numpy @@ -86,14 +86,14 @@ deps = [testenv:py311] basepython = python3.11 -commands = pytest -v --doctest-modules --ignore benchmark {posargs} +commands = pytest -v --doctest-modules --ignore benchmark --doctest-glob="README.md" {posargs} deps = pytest [testenv:py311-extra] basepython = python3.11 setenv = PYTHONDEVMODE = 1 -commands = pytest -v --doctest-modules --ignore benchmark {posargs} +commands = pytest -v --doctest-modules --ignore benchmark --doctest-glob="README.md" {posargs} deps = pytest numpy @@ -102,14 +102,14 @@ deps = [testenv:py312] basepython = python3.12 -commands = pytest -v --doctest-modules --ignore benchmark {posargs} +commands = pytest -v --doctest-modules --ignore benchmark --doctest-glob="README.md" {posargs} deps = pytest [testenv:py312-extra] basepython = python3.12 setenv = PYTHONDEVMODE = 1 -commands = pytest -v --doctest-modules --ignore benchmark {posargs} +commands = pytest -v --doctest-modules --ignore benchmark --doctest-glob="README.md" {posargs} deps = pytest numpy @@ -118,14 +118,14 @@ deps = [testenv:py313] basepython = python3.13 -commands = pytest -v --doctest-modules --ignore benchmark {posargs} +commands = pytest -v --doctest-modules --ignore benchmark --doctest-glob="README.md" {posargs} deps = pytest [testenv:py313-extra] basepython = python3.13 setenv = PYTHONDEVMODE = 1 -commands = pytest -v --doctest-modules --ignore benchmark {posargs} +commands = pytest -v --doctest-modules --ignore benchmark --doctest-glob="README.md" {posargs} deps = pytest numpy @@ -134,14 +134,14 @@ deps = [testenv:py314] basepython = python3.14 -commands = pytest -v --doctest-modules --ignore benchmark {posargs} +commands = pytest -v --doctest-modules --ignore benchmark --doctest-glob="README.md" {posargs} deps = pytest [testenv:py314-extra] basepython = python3.14 setenv = PYTHONDEVMODE = 1 -commands = pytest -v --doctest-modules --ignore benchmark {posargs} +commands = pytest -v --doctest-modules --ignore benchmark --doctest-glob="README.md" {posargs} deps = pytest numpy From d4c0ef0422ea7fbf5c4cdd5905ae2e0860a8abda Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Thu, 5 Mar 2026 09:39:27 +0100 Subject: [PATCH 075/116] Enforce ruff/isort rules (I) --- pyproject.toml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 95fddcaf..2ba8dade 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,8 +47,13 @@ dev = [ line-length = 99 [tool.ruff.lint] -extend-select = ["W", "C90"] +extend-select = ["W", "I", "C90"] ignore = ["E721", "C901"] [tool.ruff.lint.mccabe] max-complexity = 22 + +[tool.ruff.lint.isort] +combine-as-imports = true +force-sort-within-sections = true +known-local-folder = ["common"] From f1a64ecd81a1c5c31f4d5899bb7060acd1a5e499 Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Thu, 5 Mar 2026 09:40:21 +0100 Subject: [PATCH 076/116] Apply ruff/isort rule I001 --- benchmark/benchmark.py | 6 ++++-- tabulate/__init__.py | 14 +++++++------- test/common.py | 5 +++-- test/test_api.py | 6 +++--- test/test_cli.py | 6 +----- test/test_input.py | 3 ++- test/test_internal.py | 2 +- test/test_output.py | 6 ++++-- test/test_regression.py | 3 ++- test/test_textwrapper.py | 6 +++--- 10 files changed, 30 insertions(+), 27 deletions(-) diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index 03dfa66c..2fd1e3a7 100644 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -1,8 +1,10 @@ +import sys from timeit import timeit -import tabulate + import prettytable import texttable -import sys + +import tabulate setup_code = r""" from csv import writer diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 249b3ff8..9e487857 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1,8 +1,8 @@ """Pretty-print tabular data.""" from importlib.metadata import ( - version as _version, PackageNotFoundError as _PackageNotFoundError, + version as _version, ) try: @@ -10,19 +10,19 @@ except _PackageNotFoundError: __version__ = "unknown" -import warnings from collections import namedtuple from collections.abc import Iterable, Sized +import dataclasses from decimal import Decimal +from functools import partial, reduce from html import escape as htmlescape -from itertools import chain, zip_longest as izip_longest -from functools import reduce, partial import io -import re +from itertools import chain, zip_longest as izip_longest import math -import textwrap -import dataclasses +import re import sys +import textwrap +import warnings try: import wcwidth # optional wide-character (CJK) support diff --git a/test/common.py b/test/common.py index 31d6b82c..b557b850 100644 --- a/test/common.py +++ b/test/common.py @@ -1,7 +1,8 @@ -import pytest # noqa: F401 -from pytest import skip, raises # noqa: F401 import warnings +import pytest # noqa: F401 +from pytest import raises, skip # noqa: F401 + def assert_equal(expected, result): print("Expected:\n%r\n" % expected) diff --git a/test/test_api.py b/test/test_api.py index 512a84d6..3e0963a2 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -1,11 +1,11 @@ """API properties.""" -from tabulate import tabulate, tabulate_formats, simple_separated_format -from common import skip +from tabulate import simple_separated_format, tabulate, tabulate_formats +from common import skip try: - from inspect import signature, _empty + from inspect import _empty, signature except ImportError: signature = None _empty = None diff --git a/test/test_cli.py b/test/test_cli.py index 2f7faa81..625eea11 100644 --- a/test/test_cli.py +++ b/test/test_cli.py @@ -1,16 +1,12 @@ """Command-line interface.""" import os -import sys - - import subprocess +import sys import tempfile - from common import assert_equal - SAMPLE_SIMPLE_FORMAT = "\n".join( [ "----- ------ -------------", diff --git a/test/test_input.py b/test/test_input.py index c2d19c5d..908d8d7e 100644 --- a/test/test_input.py +++ b/test/test_input.py @@ -1,6 +1,7 @@ """Test support of the various forms of tabular data.""" -from tabulate import tabulate, SEPARATING_LINE +from tabulate import SEPARATING_LINE, tabulate + from common import assert_equal, assert_in, raises, skip try: diff --git a/test/test_internal.py b/test/test_internal.py index 5d521d04..49ae0ba6 100644 --- a/test/test_internal.py +++ b/test/test_internal.py @@ -2,7 +2,7 @@ import tabulate as T -from common import assert_equal, skip, rows_to_pipe_table_str, cols_to_pipe_str +from common import assert_equal, cols_to_pipe_str, rows_to_pipe_table_str, skip def test_multiline_width(): diff --git a/test/test_output.py b/test/test_output.py index 93de112e..088dff7e 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -1,10 +1,12 @@ """Test output of the various forms of tabular data.""" from decimal import Decimal + from pytest import mark -from common import assert_equal, raises, skip, check_warnings -from tabulate import tabulate, simple_separated_format, SEPARATING_LINE +from tabulate import SEPARATING_LINE, simple_separated_format, tabulate + +from common import assert_equal, check_warnings, raises, skip # _test_table shows # - coercion of a string to a number, diff --git a/test/test_regression.py b/test/test_regression.py index de950b30..c23d34dc 100644 --- a/test/test_regression.py +++ b/test/test_regression.py @@ -1,6 +1,7 @@ """Regression tests.""" -from tabulate import tabulate, TableFormat, Line, DataRow +from tabulate import DataRow, Line, TableFormat, tabulate + from common import assert_equal, skip diff --git a/test/test_textwrapper.py b/test/test_textwrapper.py index 7cd1581c..41f523cf 100644 --- a/test/test_textwrapper.py +++ b/test/test_textwrapper.py @@ -1,11 +1,11 @@ """Discretely test functionality of our custom TextWrapper""" import datetime - -from tabulate import _CustomTextWrap as CTW, tabulate, _strip_ansi from textwrap import TextWrapper as OTW -from common import skip, assert_equal +from tabulate import _CustomTextWrap as CTW, _strip_ansi, tabulate + +from common import assert_equal, skip def test_wrap_multiword_non_wide(): From 47e97ae44ccfe909a7c7553f2bbc1a1d2adce32f Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Thu, 5 Mar 2026 09:59:28 +0100 Subject: [PATCH 077/116] Proper initialization for `__version__` Also, AppVeyor is not used any more, is it? --- tabulate/__init__.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 9e487857..f4ba6ac5 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -8,7 +8,10 @@ try: __version__ = _version("tabulate") except _PackageNotFoundError: - __version__ = "unknown" + try: + from .version import version as __version__ # noqa: F401 + except ImportError: + __version__ = "unknown" from collections import namedtuple from collections.abc import Iterable, Sized @@ -35,11 +38,6 @@ def _is_file(f): __all__ = ["tabulate", "tabulate_formats", "simple_separated_format"] -try: - from .version import version as __version__ # noqa: F401 -except ImportError: - pass # running __init__.py as a script, AppVeyor pytests - # minimum extra space in headers MIN_PADDING = 2 From f19e02ba196fc7f67709312d00723ecd152aa532 Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:16:15 +0100 Subject: [PATCH 078/116] Apply ruff preview rule RUF010 Use explicit conversion flag --- test/test_textwrapper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_textwrapper.py b/test/test_textwrapper.py index 41f523cf..e6bab0f5 100644 --- a/test/test_textwrapper.py +++ b/test/test_textwrapper.py @@ -341,7 +341,7 @@ def test_wrap_wide_char_no_column_overflow(): for line in lines: display_width = wcwidth.wcswidth(line) assert display_width <= width, ( - f"Line {repr(line)} has display width {display_width} " + f"Line {line!r} has display width {display_width} " f"which exceeds requested column width {width}" ) From 0dc72a90939d0f8355b10a1472db522db95cce66 Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:21:34 +0100 Subject: [PATCH 079/116] Apply ruff/Pylint preview rule PLR0402 Use `from ... import ...` in lieu of alias --- test/test_grapheme_clusters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_grapheme_clusters.py b/test/test_grapheme_clusters.py index 0a032b0d..ef6d4e7a 100644 --- a/test/test_grapheme_clusters.py +++ b/test/test_grapheme_clusters.py @@ -1,6 +1,6 @@ """Tests for Unicode grapheme cluster handling in tabulate.""" -import unittest.mock as mock +from unittest import mock import pytest From 3fcf88e36c13073c2157ca435c0125c4a662dcf0 Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:28:42 +0100 Subject: [PATCH 080/116] Use `is` to compare types This is consistent with other similar checks in the code base. --- tabulate/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index f4ba6ac5..c3f60d34 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -124,13 +124,11 @@ def _is_separating_line_value(value): def _is_separating_line(row): row_type = type(row) - is_sl = (row_type == list or row_type == str) and ( + return (row_type is list or row_type is str) and ( (len(row) >= 1 and _is_separating_line_value(row[0])) or (len(row) >= 2 and _is_separating_line_value(row[1])) ) - return is_sl - def _pipe_segment_with_colons(align, colwidth): """Return a segment of a horizontal line with optional colons which From ffac028f30000ee2d308b0457a0682d05eca14a7 Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:10:30 +0100 Subject: [PATCH 081/116] Apply ruff/flake8-implicit-str-concat rule ISC003 Explicitly concatenated string should be implicitly concatenated --- tabulate/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index c3f60d34..b0810a95 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -300,7 +300,7 @@ def make_header_line(is_header, colwidths, colaligns): else: raise ValueError( "_asciidoc_row() requires two (colwidths, colaligns) " - + "or three (cell_values, colwidths, colaligns) arguments) " + "or three (cell_values, colwidths, colaligns) arguments) " ) @@ -1409,7 +1409,7 @@ def _prepend_row_index(rows, index): if isinstance(index, Sized) and len(index) != len(rows): raise ValueError( "index must be as long as the number of data rows: " - + f"len(index)={len(index)} len(rows)={len(rows)}" + f"len(index)={len(index)} len(rows)={len(rows)}" ) sans_rows, separating_lines = _remove_separating_lines(rows) new_rows = [] From ce6758741f33a38a1807affae1a1e3dc544cc56c Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:11:55 +0100 Subject: [PATCH 082/116] Enforce ruff/flake8-implicit-str-concat rules (ISC) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2ba8dade..600114b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dev = [ line-length = 99 [tool.ruff.lint] -extend-select = ["W", "I", "C90"] +extend-select = ["W", "ISC", "I", "C90"] ignore = ["E721", "C901"] [tool.ruff.lint.mccabe] From 6050befd1f8dc7981b378cdf1eb04dcb67ebb9f9 Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:43:55 +0100 Subject: [PATCH 083/116] =?UTF-8?q?setuptools=20=E2=86=92=20flit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also: - Write package version to `_version.py` instead of `version.py`. - Get rid of the `README` symlink, `README.md` is good enough nowadays. - I have deliberately removed from the sdist: hidden files, `HOWTOPUBLISH` and `benchmark`. --- HOWTOPUBLISH | 4 ++-- MANIFEST.in | 6 ------ README | 1 - pyproject.toml | 10 ++++++++-- 4 files changed, 10 insertions(+), 11 deletions(-) delete mode 100644 MANIFEST.in delete mode 120000 README diff --git a/HOWTOPUBLISH b/HOWTOPUBLISH index a5b0fb40..24203ded 100644 --- a/HOWTOPUBLISH +++ b/HOWTOPUBLISH @@ -3,12 +3,12 @@ python -m pre_commit run -a # and then commit changes tox -e py310-extra,py311-extra,py312-extra,py313-extra,py314-extra # tag version release (vX.Y.Z) python -m pip install build twine -python -m build -s # this will update tabulate/version.py +python -m build -s # this will update tabulate/_version.py python -m pip install . # install tabulate in the current venv python -m pip install -r benchmark/requirements.txt python benchmark/benchmark.py # then update README # move tag to the last commit -python -m build -s # update tabulate/version.py +python -m build -s # update tabulate/_version.py python -m build -nswx . git push # wait for all CI builds to succeed git push --tags # if CI builds succeed diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 90c057b7..00000000 --- a/MANIFEST.in +++ /dev/null @@ -1,6 +0,0 @@ -include LICENSE -include README -include README.md -include CHANGELOG -include test/common.py -include benchmark.py diff --git a/README b/README deleted file mode 120000 index 42061c01..00000000 --- a/README +++ /dev/null @@ -1 +0,0 @@ -README.md \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 600114b2..2a80daa5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [build-system] -requires = ["setuptools>=77.0.3", "setuptools_scm[toml]>=3.4.3"] -build-backend = "setuptools.build_meta" +requires = ["flit>=3.12", "flit_scm"] +build-backend = "flit_scm:buildapi" [project] name = "tabulate" @@ -32,7 +32,12 @@ widechars = ["wcwidth>=0.6.0"] [project.scripts] tabulate = "tabulate:_main" +[tool.flit.sdist] +include = ["CHANGELOG", "test/", "tox.ini"] +exclude = ["tabulate/_version.py"] + [tool.setuptools_scm] +write_to = "tabulate/_version.py" [dependency-groups] dev = [ @@ -45,6 +50,7 @@ dev = [ [tool.ruff] line-length = 99 +exclude = ["tabulate/_version.py"] [tool.ruff.lint] extend-select = ["W", "ISC", "I", "C90"] From 2939dc1d17e13a71c256042d9ad46bfd9d8a07b8 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 5 Mar 2026 14:42:03 +0100 Subject: [PATCH 084/116] add pre_commit to dev dependencies --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 2a80daa5..b328c93f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ write_to = "tabulate/_version.py" dev = [ "build>=1.4.0", "ruff>=0.15.4", + "pre_commit>=4.5.1", "tox>=4.47.3", "tox-uv>=1.0", "twine>=6.2.0", From 3aa568c66987dbc97c079c6a2e4bd0ad362a949f Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 5 Mar 2026 14:52:01 +0100 Subject: [PATCH 085/116] fix #408 - remove trailing whitespace from asciidoc output --- README.md | 8 ++++---- tabulate/__init__.py | 11 +++++++---- test/test_output.py | 10 +++++----- test/test_regression.py | 11 +++++++++++ 4 files changed, 27 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index b6bb8eba..e8f47d73 100644 --- a/README.md +++ b/README.md @@ -502,10 +502,10 @@ format: >>> print(tabulate(table, headers, tablefmt="asciidoc")) [cols="<8,>7",options="header"] |==== -| item | qty -| spam | 42 -| eggs | 451 -| bacon | 0 +| item | qty +| spam | 42 +| eggs | 451 +| bacon | 0 |==== ``` diff --git a/tabulate/__init__.py b/tabulate/__init__.py index b0810a95..0d2d59d4 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -278,12 +278,14 @@ def make_header_line(is_header, colwidths, colaligns): # generate the list of entries in the table header field - return "[{}]\n|====".format(",".join(header_list)) + line = "[{}]\n|====".format(",".join(header_list)) + return line.rstrip() if len(args) == 2: # two arguments are passed if called in the context of aboveline # print the table header with column widths and optional header tag - return make_header_line(False, *args) + line = make_header_line(False, *args) + return line.rstrip() elif len(args) == 3: # three arguments are passed if called in the context of dataline or headerline @@ -293,9 +295,10 @@ def make_header_line(is_header, colwidths, colaligns): data_line = "|" + "|".join(cell_values) if is_header: - return make_header_line(True, colwidths, colaligns) + "\n" + data_line + line = make_header_line(True, colwidths, colaligns) + "\n" + data_line + return line.rstrip() else: - return data_line + return data_line.rstrip() else: raise ValueError( diff --git a/test/test_output.py b/test/test_output.py index 088dff7e..e49674f9 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -2088,9 +2088,9 @@ def test_asciidoc(): [ '[cols="<11,>11",options="header"]', "|====", - "| strings | numbers ", - "| spam | 41.9999 ", - "| eggs | 451 ", + "| strings | numbers", + "| spam | 41.9999", + "| eggs | 451", "|====", ] ) @@ -2104,8 +2104,8 @@ def test_asciidoc_headerless(): [ '[cols="<6,>10"]', "|====", - "| spam | 41.9999 ", - "| eggs | 451 ", + "| spam | 41.9999", + "| eggs | 451", "|====", ] ) diff --git a/test/test_regression.py b/test/test_regression.py index c23d34dc..67ef59f4 100644 --- a/test/test_regression.py +++ b/test/test_regression.py @@ -580,3 +580,14 @@ def test_mixed_bool_strings_and_numeric_strings(): result = tabulate([["False"], ["1."]]) expected = "\n".join(["-----", "False", " 1", "-----"]) assert_equal(expected, result) + + +def test_asciidoc_without_trailing_whitespace(): + "Regression: asciidoc format output must not generate trailing whitespace (issue #408)" + result = tabulate([["foo"]], headers=("longheader",), tablefmt="asciidoc") + expected = '[cols="<14",options="header"]\n|====\n| longheader\n| foo\n|====' + assert_equal(expected, result) + + result = tabulate([["longtext"]], headers=("bar",), tablefmt="asciidoc") + expected = '[cols="<10",options="header"]\n|====\n| bar\n| longtext\n|====' + assert_equal(expected, result) From 7c70bf451bc00ba64c3d8587909fa08d16a958e0 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 5 Mar 2026 14:59:13 +0100 Subject: [PATCH 086/116] update CHANGELOG --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index a82abc0a..940582a1 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,7 @@ - 0.11.0: Drop support of the legacy `youtrack` format. Add support of the `Decimal` data type. + Improve output of `github` and `asciidoc` formats. Various bug fixes. - 0.10.0: Add support for Python 3.11, 3.12, 3.13, 3.14. Drop support for Python 3.7, 3.8, 3.9. From 8014ec66820c89307005b26f878119baa5bf6932 Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Thu, 5 Mar 2026 16:02:56 +0400 Subject: [PATCH 087/116] Fix alignments in `github` tables Closes #53, follow-up on #261 --- README.md | 4 ++-- tabulate/__init__.py | 14 ++++--------- test/test_output.py | 47 ++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 49 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index e8f47d73..d4123284 100644 --- a/README.md +++ b/README.md @@ -233,12 +233,12 @@ bacon 0 ``` `github` follows the conventions of GitHub flavored Markdown. It -corresponds to the `pipe` format without alignment colons: +corresponds to the `pipe` format with the same alignment colons: ```pycon >>> print(tabulate(table, headers, tablefmt="github")) | item | qty | -|--------|-------| +|:-------|------:| | spam | 42 | | eggs | 451 | | bacon | 0 | diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 0d2d59d4..d83dbc4e 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -522,16 +522,6 @@ def escape_empty(val): padding=1, with_header_hide=None, ), - "github": TableFormat( - lineabove=Line("|", "-", "|", "|"), - linebelowheader=Line("|", "-", "|", "|"), - linebetweenrows=None, - linebelow=None, - headerrow=DataRow("|", "|", "|"), - datarow=DataRow("|", "|", "|"), - padding=1, - with_header_hide=["lineabove"], - ), "pipe": TableFormat( lineabove=_pipe_line_with_colons, linebelowheader=_pipe_line_with_colons, @@ -719,6 +709,10 @@ def escape_empty(val): ), } +# "github" is an alias for "pipe": both produce GitHub-flavored Markdown with +# alignment colons in the separator row. +_table_formats["github"] = _table_formats["pipe"] + tabulate_formats = sorted(_table_formats.keys()) diff --git a/test/test_output.py b/test/test_output.py index e49674f9..ea3da87f 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -369,9 +369,9 @@ def test_simple_headerless_with_sep_line_with_padding_in_tablefmt(): "Output: simple without headers with sep line with padding in tablefmt" expected = "\n".join( [ - "|------|----------|", + "|:-----|---------:|", "| spam | 41.9999 |", - "|------|----------|", + "|:-----|---------:|", "| eggs | 451 |", ] ) @@ -489,7 +489,7 @@ def test_github(): expected = "\n".join( [ "| strings | numbers |", - "|-----------|-----------|", + "|:----------|----------:|", "| spam | 41.9999 |", "| eggs | 451 |", ] @@ -506,7 +506,7 @@ def test_github_multiline(): [ "| more | more spam |", "| spam eggs | & eggs |", - "|-------------|-------------|", + "|------------:|:------------|", "| 2 | foo |", "| | bar |", ] @@ -515,6 +515,45 @@ def test_github_multiline(): assert_equal(expected, result) +def test_github_with_colalign(): + "Output: github with explicit column alignment" + expected = "\n".join( + [ + "| Name | Age |", + "|:-------|------:|", + "| Alice | 24 |", + "| Bob | 19 |", + ] + ) + result = tabulate( + [["Alice", 24], ["Bob", 19]], + ["Name", "Age"], + tablefmt="github", + colalign=("left", "right"), + ) + assert_equal(expected, result) + + +def test_github_no_alignment(): + "Output: github without alignment hints when numalign/stralign are disabled" + expected = "\n".join( + [ + "| strings | numbers |", + "|-----------|-----------|", + "| spam | 41.9999 |", + "| eggs | 451 |", + ] + ) + result = tabulate( + _test_table, + _test_table_headers, + tablefmt="github", + numalign=None, + stralign=None, + ) + assert_equal(expected, result) + + def test_grid(): "Output: grid with headers" expected = "\n".join( From ce67960bdaa0ba13fc8648dd5ae21a259d155459 Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:56:30 +0100 Subject: [PATCH 088/116] Apply ruff/flake8-comprehensions C416 Unnecessary list comprehension (rewrite using `list()`) --- tabulate/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index d83dbc4e..0115579c 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -2378,7 +2378,7 @@ def tabulate( assert isinstance(colalign, Iterable) if isinstance(colalign, str): warnings.warn( - f"As a string, `colalign` is interpreted as {[c for c in colalign]}. " + f"As a string, `colalign` is interpreted as {list(colalign)}. " f'Did you mean `colglobalalign = "{colalign}"` or `colalign = ("{colalign}",)`?', stacklevel=2, ) @@ -2420,7 +2420,7 @@ def tabulate( assert isinstance(headersalign, Iterable) if isinstance(headersalign, str): warnings.warn( - f"As a string, `headersalign` is interpreted as {[c for c in headersalign]}. " + f"As a string, `headersalign` is interpreted as {list(headersalign)}. " f'Did you mean `headersglobalalign = "{headersalign}"` ' f'or `headersalign = ("{headersalign}",)`?', stacklevel=2, @@ -2699,7 +2699,7 @@ def _update_lines(self, lines, new_line): as add any colors from previous lines order to preserve the same formatting as a single unwrapped string. """ - code_matches = [x for x in _ansi_codes.finditer(new_line)] + code_matches = list(_ansi_codes.finditer(new_line)) color_codes = [code.string[code.span()[0] : code.span()[1]] for code in code_matches] # Add color codes from earlier in the unwrapped line, and then track any new ones we add. From e9a56a653046d906ad7ac070618d5006d61b90d7 Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:41:07 +0100 Subject: [PATCH 089/116] Apply ruff/flake8-comprehensions rule C417 Unnecessary `map()` usage (rewrite using a list comprehension) --- benchmark/benchmark.py | 2 +- tabulate/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index 2fd1e3a7..d405f5c7 100644 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -75,7 +75,7 @@ def benchmark(n): methods = [m for m in methods if m[0].startswith("tabulate")] results = [(desc, timeit(code, setup_code, number=n) / n * 1e6) for desc, code in methods] - mintime = min(map(lambda x: x[1], results)) + mintime = min(x[1] for x in results) results = [(desc, t, t / mintime) for desc, t in sorted(results, key=lambda x: x[1])] table = tabulate.tabulate( results, ["Table formatter", "time, μs", "rel. time"], "rst", floatfmt=".1f" diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 0115579c..de774e86 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1589,7 +1589,7 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): headers = list(map(str, headers)) # rows = list(map(list, rows)) - rows = list(map(lambda r: r if _is_separating_line(r) else list(r), rows)) + rows = [r if _is_separating_line(r) else list(r) for r in rows] # add or remove an index column showindex_is_a_str = type(showindex) in [str, bytes] From 57e39b13160cf7fcc0373b14f2df86c1e7826fae Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:41:53 +0100 Subject: [PATCH 090/116] Apply ruff/flake8-comprehensions rule C419 Unnecessary list comprehension --- test/common.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/common.py b/test/common.py index b557b850..5f00852a 100644 --- a/test/common.py +++ b/test/common.py @@ -41,6 +41,6 @@ def check_warnings(func_args_kwargs, *, num=None, category=None, contain=None): if num is not None: assert len(W) == num if category is not None: - assert all([issubclass(w.category, category) for w in W]) + assert all(issubclass(w.category, category) for w in W) if contain is not None: - assert all([contain in str(w.message) for w in W]) + assert all(contain in str(w.message) for w in W) From 26269e6b3c9362340845488715fcd3bebf2876b2 Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:57:48 +0100 Subject: [PATCH 091/116] Apply ruff/flake8-comprehensions rule C420 Unnecessary dict comprehension for iterable; use `dict.fromkeys` instead --- test/test_regression.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_regression.py b/test/test_regression.py index 67ef59f4..c3dfac9d 100644 --- a/test/test_regression.py +++ b/test/test_regression.py @@ -171,7 +171,7 @@ def test_numeric_column_headers(): expected = " 42\n----\n 1\n 2" assert_equal(expected, result) - lod = [{p: i for p in range(5)} for i in range(5)] + lod = [dict.fromkeys(range(5), i) for i in range(5)] result = tabulate(lod, "keys") expected = "\n".join( [ From 527d31ed0c5a71418ecc8fc34dcfe6453acd35f1 Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:45:28 +0100 Subject: [PATCH 092/116] Enforce ruff/flake8-comprehensions rules (C4) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b328c93f..d510943e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,7 @@ line-length = 99 exclude = ["tabulate/_version.py"] [tool.ruff.lint] -extend-select = ["W", "ISC", "I", "C90"] +extend-select = ["W", "C4", "ISC", "I", "C90"] ignore = ["E721", "C901"] [tool.ruff.lint.mccabe] From 86112e6de997a9eb3dc1bf4e9a004bcf25ad680a Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 5 Mar 2026 15:11:41 +0100 Subject: [PATCH 093/116] add [too.pytest.ini_options] to pyproject.toml --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index d510943e..0c9e04c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,9 @@ dev = [ "twine>=6.2.0", ] +[tool.pytest.ini_options] +addopts = "-v --doctest-modules --ignore=benchmark --doctest-glob=README.md" + [tool.ruff] line-length = 99 exclude = ["tabulate/_version.py"] From a783de20125f1168e8870d700f24f4bf0249ae4b Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 5 Mar 2026 15:17:42 +0100 Subject: [PATCH 094/116] add regression test for unescaped pipe character in github output (issue #241) --- test/test_regression.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/test_regression.py b/test/test_regression.py index c3dfac9d..a0b8c57b 100644 --- a/test/test_regression.py +++ b/test/test_regression.py @@ -591,3 +591,10 @@ def test_asciidoc_without_trailing_whitespace(): result = tabulate([["longtext"]], headers=("bar",), tablefmt="asciidoc") expected = '[cols="<10",options="header"]\n|====\n| bar\n| longtext\n|====' assert_equal(expected, result) + + +def test_github_escape_pipe_character(): + "Regression: github format must escape pipe character with a backslash (issue #241)" + result = tabulate([["foo|bar"]], headers=("spam|eggs",), tablefmt="github") + expected = '| spam\\|eggs |\n|-------------|\n| foo\\|bar |' + assert_equal(expected, result) From 8abc5c92c618aa78ac4e8aca488402cbfcf03801 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 5 Mar 2026 15:41:02 +0100 Subject: [PATCH 095/116] ignore _version.py --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 0495ac79..8ba99796 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ website-build/ ## Unit test / coverage reports .coverage .tox +/tabulate/_version.py From 46c9fe38d36e1f185271884ace2e80415389216c Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 5 Mar 2026 15:42:52 +0100 Subject: [PATCH 096/116] replace DataRow namedtuple with a dataclass with the same name and an optional escape_map generalize LaTeX escaping function, move escaping to _build_simple_row --- tabulate/__init__.py | 43 ++++++++++++++++++++++++++++--------------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index de774e86..4c358a90 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -4,6 +4,7 @@ PackageNotFoundError as _PackageNotFoundError, version as _version, ) +from typing import Callable, Union try: __version__ = _version("tabulate") @@ -17,6 +18,7 @@ from collections.abc import Iterable, Sized import dataclasses from decimal import Decimal +from dataclasses import dataclass from functools import partial, reduce from html import escape as htmlescape import io @@ -69,7 +71,12 @@ def _is_file(f): Line = namedtuple("Line", ["begin", "hline", "sep", "end"]) -DataRow = namedtuple("DataRow", ["begin", "sep", "end"]) +@dataclass +class DataRow: + begin: str + sep: str + end: str + escape_map: dict = None # A table structure is supposed to be: @@ -323,13 +330,7 @@ def make_header_line(is_header, colwidths, colaligns): } -def _latex_row(cell_values, colwidths, colaligns, escrules=LATEX_ESCAPE_RULES): - def escape_char(c): - return escrules.get(c, c) - - escaped_values = ["".join(map(escape_char, cell)) for cell in cell_values] - rowfmt = DataRow("", "&", "\\\\") - return _build_simple_row(escaped_values, rowfmt) +_latex_row = DataRow("", "&", "\\\\", LATEX_ESCAPE_RULES) def _rst_escape_first_column(rows, headers): @@ -652,8 +653,8 @@ def escape_empty(val): linebelowheader=Line("\\hline", "", "", ""), linebetweenrows=None, linebelow=Line("\\hline\n\\end{tabular}", "", "", ""), - headerrow=partial(_latex_row, escrules={}), - datarow=partial(_latex_row, escrules={}), + headerrow=DataRow("", "&", "\\\\", {}), + datarow=DataRow("", "&", "\\\\", {}), padding=1, with_header_hide=None, ), @@ -2506,13 +2507,24 @@ def _pad_row(cells, padding): return cells -def _build_simple_row(padded_cells, rowfmt): +def _build_simple_row(padded_cells: list[list], rowfmt: DataRow) -> str: "Format row according to DataRow format without padding." - begin, sep, end = rowfmt - return (begin + sep.join(padded_cells) + end).rstrip() + begin = rowfmt.begin + sep = rowfmt.sep + end = rowfmt.end + escape_map: dict = rowfmt.escape_map + + if escape_map: + def escape_char(c): + return escape_map.get(c, c) + escaped_cells = ["".join(map(escape_char, cell)) for cell in padded_cells] + else: + escaped_cells = padded_cells + + return (begin + sep.join(escaped_cells) + end).rstrip() -def _build_row(padded_cells, colwidths, colaligns, rowfmt): +def _build_row(padded_cells: list[list], colwidths: list[int], colaligns: list[str], rowfmt: Union[DataRow, Callable]) -> str: "Return a string which represents a row of data cells." if not rowfmt: return None @@ -2571,7 +2583,8 @@ def _build_line(colwidths, colaligns, linefmt): else: begin, fill, sep, end = linefmt cells = [fill * w for w in colwidths] - return _build_simple_row(cells, (begin, sep, end)) + rowfmt = DataRow(begin, sep, end) + return _build_simple_row(cells, rowfmt) def _append_line(lines, colwidths, colaligns, linefmt): From e6a24aa6e00ca8ce9a1987f28c11ca08c6f19383 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Thu, 5 Mar 2026 15:46:56 +0100 Subject: [PATCH 097/116] fix #241 - escape pipe character in github and pipe formats --- tabulate/__init__.py | 18 ++++++++++++++---- test/test_regression.py | 2 +- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 4c358a90..43146d66 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -17,8 +17,8 @@ from collections import namedtuple from collections.abc import Iterable, Sized import dataclasses -from decimal import Decimal from dataclasses import dataclass +from decimal import Decimal from functools import partial, reduce from html import escape as htmlescape import io @@ -333,6 +333,9 @@ def make_header_line(is_header, colwidths, colaligns): _latex_row = DataRow("", "&", "\\\\", LATEX_ESCAPE_RULES) +GITHUB_ESCAPE_RULES = {r"|": r"\|"} + + def _rst_escape_first_column(rows, headers): def escape_empty(val): if isinstance(val, (str, bytes)) and not val.strip(): @@ -528,8 +531,8 @@ def escape_empty(val): linebelowheader=_pipe_line_with_colons, linebetweenrows=None, linebelow=None, - headerrow=DataRow("|", "|", "|"), - datarow=DataRow("|", "|", "|"), + headerrow=DataRow("|", "|", "|", GITHUB_ESCAPE_RULES), + datarow=DataRow("|", "|", "|", GITHUB_ESCAPE_RULES), padding=1, with_header_hide=["lineabove"], ), @@ -2515,8 +2518,10 @@ def _build_simple_row(padded_cells: list[list], rowfmt: DataRow) -> str: escape_map: dict = rowfmt.escape_map if escape_map: + def escape_char(c): return escape_map.get(c, c) + escaped_cells = ["".join(map(escape_char, cell)) for cell in padded_cells] else: escaped_cells = padded_cells @@ -2524,7 +2529,12 @@ def escape_char(c): return (begin + sep.join(escaped_cells) + end).rstrip() -def _build_row(padded_cells: list[list], colwidths: list[int], colaligns: list[str], rowfmt: Union[DataRow, Callable]) -> str: +def _build_row( + padded_cells: list[list], + colwidths: list[int], + colaligns: list[str], + rowfmt: Union[DataRow, Callable], +) -> str: "Return a string which represents a row of data cells." if not rowfmt: return None diff --git a/test/test_regression.py b/test/test_regression.py index a0b8c57b..051bf031 100644 --- a/test/test_regression.py +++ b/test/test_regression.py @@ -596,5 +596,5 @@ def test_asciidoc_without_trailing_whitespace(): def test_github_escape_pipe_character(): "Regression: github format must escape pipe character with a backslash (issue #241)" result = tabulate([["foo|bar"]], headers=("spam|eggs",), tablefmt="github") - expected = '| spam\\|eggs |\n|-------------|\n| foo\\|bar |' + expected = "| spam\\|eggs |\n|:------------|\n| foo\\|bar |" assert_equal(expected, result) From 4552d6efcd17fd937e64d4851f0ca8cf7a64c7b8 Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:43:55 +0100 Subject: [PATCH 098/116] A few fixes for flit builds The build backend should be `flit_core`, not `flit`: https://flit.pypa.io/en/stable/pyproject_toml.html#build-system-section File `_version.py` should be part of sdist and wheel distributions. --- .gitignore | 2 +- pyproject.toml | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 8ba99796..61d0c83e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -/tabulate/version.py +/tabulate/_version.py build dist diff --git a/pyproject.toml b/pyproject.toml index 0c9e04c1..fb782b8b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["flit>=3.12", "flit_scm"] +requires = ["flit_core>=3.12", "flit_scm"] build-backend = "flit_scm:buildapi" [project] @@ -34,7 +34,6 @@ tabulate = "tabulate:_main" [tool.flit.sdist] include = ["CHANGELOG", "test/", "tox.ini"] -exclude = ["tabulate/_version.py"] [tool.setuptools_scm] write_to = "tabulate/_version.py" From 315f6c17252c9e1d2f81a4a4e55e7c68ec55938b Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:43:55 +0100 Subject: [PATCH 099/116] One last fix for flit builds It's `_version.py`, not `version.py` any more. --- tabulate/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 43146d66..e2df82d1 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -7,10 +7,10 @@ from typing import Callable, Union try: - __version__ = _version("tabulate") + __version__ = _version("tabulate") # installed package except _PackageNotFoundError: try: - from .version import version as __version__ # noqa: F401 + from ._version import version as __version__ # editable / source checkout except ImportError: __version__ = "unknown" From 7e3925ec413389892757a2c3acd403b5bd5f5376 Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Thu, 5 Mar 2026 18:33:11 +0100 Subject: [PATCH 100/116] Stop testing unsupported Python 3.8 and 3.9 Either support Python 3.8 and 3.9 officially and keep testing these versions, or stop testing. This grey area where you don't support versions but still test them just in case doesn't help. For example, ruff targets `requires-python` and generates code that may not be compatible with earlier versions. Such code will break CI tests with unsupported versions of Python. --- tox.ini | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/tox.ini b/tox.ini index 3dcc7300..4a0ae289 100644 --- a/tox.ini +++ b/tox.ini @@ -35,38 +35,6 @@ commands = deps = ruff -[testenv:py38] -basepython = python3.8 -commands = pytest -v --doctest-modules --ignore benchmark --doctest-glob="README.md" {posargs} -deps = - pytest - -[testenv:py38-extra] -basepython = python3.8 -commands = pytest -v --doctest-modules --ignore benchmark --doctest-glob="README.md" {posargs} -deps = - pytest - numpy - pandas - wcwidth - - -[testenv:py39] -basepython = python3.9 -commands = pytest -v --doctest-modules --ignore benchmark --doctest-glob="README.md" {posargs} -deps = - pytest - -[testenv:py39-extra] -basepython = python3.9 -commands = pytest -v --doctest-modules --ignore benchmark --doctest-glob="README.md" {posargs} -deps = - pytest - numpy - pandas - wcwidth - - [testenv:py310] basepython = python3.10 commands = pytest -v --doctest-modules --ignore benchmark --doctest-glob="README.md" {posargs} From 382a933132b0767fcb1624ebc143d4171c9366fc Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Thu, 5 Mar 2026 19:42:24 +0100 Subject: [PATCH 101/116] Simplify version and imports --- tabulate/__init__.py | 30 +++++++++++++----------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index e2df82d1..9ee72fe6 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1,19 +1,5 @@ """Pretty-print tabular data.""" -from importlib.metadata import ( - PackageNotFoundError as _PackageNotFoundError, - version as _version, -) -from typing import Callable, Union - -try: - __version__ = _version("tabulate") # installed package -except _PackageNotFoundError: - try: - from ._version import version as __version__ # editable / source checkout - except ImportError: - __version__ = "unknown" - from collections import namedtuple from collections.abc import Iterable, Sized import dataclasses @@ -21,12 +7,14 @@ from decimal import Decimal from functools import partial, reduce from html import escape as htmlescape +from importlib.metadata import PackageNotFoundError, version import io from itertools import chain, zip_longest as izip_longest import math import re import sys import textwrap +from typing import Callable, Union import warnings try: @@ -34,9 +22,13 @@ except ImportError: wcwidth = None - -def _is_file(f): - return isinstance(f, io.IOBase) +try: + __version__ = version("tabulate") # installed package +except PackageNotFoundError: + try: + from ._version import version as __version__ # editable / source checkout + except ImportError: + __version__ = "unknown" __all__ = ["tabulate", "tabulate_formats", "simple_separated_format"] @@ -125,6 +117,10 @@ class DataRow: ) +def _is_file(f): + return isinstance(f, io.IOBase) + + def _is_separating_line_value(value): return type(value) is str and value.strip() == SEPARATING_LINE From 200b7844c9695b5bd80f207875676b55a73a2ad2 Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Thu, 5 Mar 2026 19:30:20 +0100 Subject: [PATCH 102/116] Use enumerate The resulting code is faster, simpler and more readable. --- test/common.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/common.py b/test/common.py index 5f00852a..6afb4be0 100644 --- a/test/common.py +++ b/test/common.py @@ -11,8 +11,7 @@ def assert_equal(expected, result): def assert_in(result, expected_set): - nums = range(1, len(expected_set) + 1) - for i, expected in zip(nums, expected_set): + for i, expected in enumerate(expected_set, start=1): print("Expected %d:\n%s\n" % (i, expected)) print("Got:\n%s\n" % result) assert result in expected_set From 94b7491f7df6fd103f7801a47d527fdf18e13633 Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:05:37 +0100 Subject: [PATCH 103/116] Stop testing unsupported Python 3.8 and 3.9 (again) Either support Python 3.8 and 3.9 officially and keep testing these versions, or stop testing. This grey area where you don't support versions but still test them just in case doesn't help. For example, ruff targets `requires-python` and generates code that may not be compatible with earlier versions. Such code will break CI tests with unsupported versions of Python. --- .github/workflows/tabulate.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/tabulate.yml b/.github/workflows/tabulate.yml index 1e2577bb..6913ba3e 100644 --- a/.github/workflows/tabulate.yml +++ b/.github/workflows/tabulate.yml @@ -8,8 +8,7 @@ jobs: build: strategy: matrix: - # Python 3.9 is not supported anymore, but it still works... - python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] + python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] os: ["ubuntu-latest", "windows-latest", "macos-latest"] runs-on: ${{ matrix.os }} From 164b36740fccadd5bcccbdedc88e9b109d9a9bdd Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Mon, 9 Mar 2026 15:25:15 +0100 Subject: [PATCH 104/116] fix GH103 and GH103 of sp-repo-review recommendations GH102: Auto-cancel on repeated PRs GH103: At least one workflow with manual dispatch trigger --- .github/workflows/tabulate.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/tabulate.yml b/.github/workflows/tabulate.yml index 6913ba3e..81069dc2 100644 --- a/.github/workflows/tabulate.yml +++ b/.github/workflows/tabulate.yml @@ -3,6 +3,7 @@ name: pytest on: - push - pull_request + - workflow_dispatch jobs: build: @@ -30,3 +31,7 @@ jobs: uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true From c327d6c41981d3668b96caeb4bab10478c263030 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Mon, 9 Mar 2026 17:06:48 +0100 Subject: [PATCH 105/116] implement support of JSONL and CSV input formats in command line utility fix #173 New CLI parameter: --headers HEADERS where HEADERS can be "firstrow", "keys", "HEADER1,HEADER2,...", "KEY1:HEADER1,KEY2:HEADER2,..." --read FILEFORMAT where FILEFORMAT can be "rsv" (default), "csv", "jsonl" Deprecated CLI parameter: --header Usage examples: cat ./examples/people.csv | tabulate -r csv --headers firstrow id name email "favorite" fruit ---- ------ ----------------- ------------------ 1 Alice alice@example.com apple, kiwi 2 Bob bob@example.com banana, orange, lychee 3 Carol pear cat ./examples/people.jsonl | tabulate -r jsonl -f grid --headers 'id:ID,name:First Name,email:Email' +------+--------------+-------------------+ | ID | First Name | Email | +======+==============+===================+ | 1 | Alice | alice@example.com | +------+--------------+-------------------+ | 2 | Bob | bob@example.com | +------+--------------+-------------------+ --- examples/people.csv | 6 ++ examples/people.jsonl | 2 + tabulate/__init__.py | 132 ++++++++++++++++++++++++++++++++---------- 3 files changed, 109 insertions(+), 31 deletions(-) create mode 100644 examples/people.csv create mode 100644 examples/people.jsonl diff --git a/examples/people.csv b/examples/people.csv new file mode 100644 index 00000000..dfbf6dc5 --- /dev/null +++ b/examples/people.csv @@ -0,0 +1,6 @@ +id,name,email,"""favorite"" fruit" +1,Alice,alice@example.com,"apple, kiwi" +2,Bob,bob@example.com,"banana, +orange, +lychee" +3,Carol,,pear diff --git a/examples/people.jsonl b/examples/people.jsonl new file mode 100644 index 00000000..7f3e1138 --- /dev/null +++ b/examples/people.jsonl @@ -0,0 +1,2 @@ +{"id": 1, "name": "Alice", "email": "alice@example.com"} +{"id": 2, "name": "Bob", "email": "bob@example.com"} \ No newline at end of file diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 9ee72fe6..8911685e 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -2900,8 +2900,7 @@ def _main(): """\ Usage: tabulate [options] [FILE ...] - Pretty-print tabular data. - See also https://github.com/astanin/python-tabulate + Pretty-print tabular data. Use Python module for more features. FILE a filename of the file with tabular data; if "-" or missing, read data from stdin. @@ -2909,16 +2908,35 @@ def _main(): Options: -h, --help show this message - -1, --header use the first row of data as a table header - -o FILE, --output FILE print table to FILE (default: stdout) - -s REGEXP, --sep REGEXP use a custom column separator (default: whitespace) + + INPUT: + -r, --read FILEFORMAT parse input FILEs as: + rsv (REGEXP-separated values, default), + csv (comma-separated valued, Excel dialect), + jsonl (one JSON object per line) + -s REGEXP, --sep REGEXP column separator for rsv data (default: whitespace) + + FORMAT: + --headers HEADERS HEADERS can be one of: + "firstrow" (for csv and rsv data), + "keys" (for jsonl data), + "HEADER1,HEADER2,..." (for csv and rsv data), + "KEY1:HEADER1,KEY2:HEADER2,..." (for jsonl data) + -1 use the first row of input data as a table header + (the same as --headers firstrow) -F FPFMT, --float FPFMT floating point number format (default: g) -I INTFMT, --int INTFMT integer point number format (default: "") - -f FMT, --format FMT set output table format; supported formats: - plain, simple, grid, fancy_grid, pipe, orgtbl, - rst, mediawiki, html, latex, latex_raw, - latex_booktabs, latex_longtable, tsv - (default: simple) + -f FMT, --format FMT set output table format (default: simple) + + Supported output formats: asciidoc, colon_grid, double_grid, double_outline, + fancy_grid, fancy_outline, github, grid, heavy_grid, heavy_outline, html, jira, + latex, latex_booktabs, latex_longtable, latex_raw, mediawiki, mixed_grid, mixed_outline, + moinmoin, orgtbl, outline, pipe, plain, presto, pretty, psql, rounded_grid, + rounded_outline, rst, simple, simple_grid, simple_outline, textile, tsv, unsafehtml. + + OUTPUT: + -o FILE, --output FILE print table to FILE (default: stdout) + """ import getopt @@ -2926,10 +2944,12 @@ def _main(): try: opts, args = getopt.getopt( sys.argv[1:], - "h1o:s:F:I:f:", + "h1H:r:o:s:F:I:f:", [ "help", - "header", + "header", # deprecated in CLI > 0.10 + "headers=", # CLI > 0.10 + "read=", # CLI > 0.10 "output=", "sep=", "float=", @@ -2939,7 +2959,7 @@ def _main(): ], ) except getopt.GetoptError as e: - print(e) + print(e, file=sys.stderr) print(usage) sys.exit(2) headers = [] @@ -2947,11 +2967,20 @@ def _main(): intfmt = _DEFAULT_INTFMT colalign = None tablefmt = "simple" + fileformat = "rsv" sep = r"\s+" outfile = "-" + special_headers_values = ["firstrow", "keys"] for opt, value in opts: if opt in ["-1", "--header"]: + # "header" option is for backwards compatibility with CLI <= 0.10 + # CLI >= 0.11 should user --headers headers = "firstrow" + if opt in ["-H", "--headers"]: + if value in special_headers_values: + headers = value + else: + headers = value # may need to be processed elif opt in ["-o", "--output"]: outfile = value elif opt in ["-F", "--float"]: @@ -2960,9 +2989,11 @@ def _main(): intfmt = value elif opt in ["-C", "--colalign"]: colalign = value.split() + elif opt in ["-r", "--read"]: + fileformat = value.lower() elif opt in ["-f", "--format"]: if value not in tabulate_formats: - print("%s is not a supported table format" % value) + print(f"{value} is not a supported output format", file=sys.stderr) print(usage) sys.exit(3) tablefmt = value @@ -2971,39 +3002,78 @@ def _main(): elif opt in ["-h", "--help"]: print(usage) sys.exit(0) + # choose a reader and parse headers option + if fileformat == "rsv": + reader = partial(_read_rsv_file, sep=sep) + if type(headers) is str and headers not in special_headers_values: + # parse as CSV values + headers = headers.split(",") + elif fileformat == "csv": + reader = _read_csv_file + if type(headers) is str and headers not in special_headers_values: + # parse as CSV values + headers = headers.split(",") + elif fileformat == "jsonl": + reader = _read_jsonl_file + if not headers: + headers = "keys" # reasonable default + if type(headers) is str and headers not in special_headers_values: + # "," and ":" in header titles are not supported in CLI + try: + headers2 = dict(tuple(hh.split(":",2)) for hh in headers.split(",")) + except: + print(f"cannot parse headers parameter: {headers}", file=sys.stderr) + headers2 = [] + headers = headers2 + else: + print(f"{fileformat} is not a supported file format") + sys.exit(3) + # format all input files files = [sys.stdin] if not args else args with sys.stdout if outfile == "-" else open(outfile, "w") as out: for f in files: if f == "-": f = sys.stdin - if _is_file(f): - _pprint_file( - f, + _open_and_pprint_file(reader, f, headers=headers, tablefmt=tablefmt, - sep=sep, floatfmt=floatfmt, intfmt=intfmt, file=out, colalign=colalign, ) - else: - with open(f) as fobj: - _pprint_file( - fobj, - headers=headers, - tablefmt=tablefmt, - sep=sep, - floatfmt=floatfmt, - intfmt=intfmt, - file=out, - colalign=colalign, - ) -def _pprint_file(fobject, headers, tablefmt, sep, floatfmt, intfmt, file, colalign): +def _read_rsv_file(fobject, sep): rows = fobject.readlines() table = [re.split(sep, r.rstrip()) for r in rows if r.strip()] + return table + + +def _read_jsonl_file(fobject): + import json + rows:list[str] = fobject.readlines() + table = [json.loads(row) for row in rows] + return table + + +def _read_csv_file(fobject): + import csv + reader = csv.reader(fobject, dialect="excel") + table = [list(row) for row in reader] + return table + + +def _open_and_pprint_file(reader, f, *args, **kwargs): + if _is_file(f): + _pprint_file(reader, f, *args, **kwargs) + else: + with open(f) as fobj: + _pprint_file(reader, fobj, *args, **kwargs) + + +def _pprint_file(reader, fobject, headers, tablefmt, floatfmt, intfmt, file, colalign): + table = reader(fobject) print( tabulate( table, From 696c5eb25ea2d9d4cb396be04fa3fae22a0620e3 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Mon, 9 Mar 2026 17:20:37 +0100 Subject: [PATCH 106/116] update README.md and CHANGELOG with new CLI usage (JSONL format, --headers option) --- CHANGELOG | 2 ++ README.md | 58 ++++++++++++++++++++++++++++++++++++++----------------- 2 files changed, 42 insertions(+), 18 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 940582a1..ce390bb2 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -2,6 +2,8 @@ Drop support of the legacy `youtrack` format. Add support of the `Decimal` data type. Improve output of `github` and `asciidoc` formats. + Add support of CSV and JSONL input data in CLI utility. + Always add `text-align: left` in HTML output. Various bug fixes. - 0.10.0: Add support for Python 3.11, 3.12, 3.13, 3.14. Drop support for Python 3.7, 3.8, 3.9. diff --git a/README.md b/README.md index d4123284..0283a0c3 100644 --- a/README.md +++ b/README.md @@ -1127,24 +1127,46 @@ itself (terminals would show this text). For example: Usage of the command line utility --------------------------------- - Usage: tabulate [options] [FILE ...] - - FILE a filename of the file with tabular data; - if "-" or missing, read data from stdin. - - Options: - - -h, --help show this message - -1, --header use the first row of data as a table header - -o FILE, --output FILE print table to FILE (default: stdout) - -s REGEXP, --sep REGEXP use a custom column separator (default: whitespace) - -F FPFMT, --float FPFMT floating point number format (default: g) - -I INTFMT, --int INTFMT integer point number format (default: "") - -f FMT, --format FMT set output table format; supported formats: - plain, simple, github, grid, fancy_grid, pipe, - orgtbl, rst, mediawiki, html, latex, latex_raw, - latex_booktabs, latex_longtable, tsv - (default: simple) +``` +Usage: tabulate [options] [FILE ...] + +Pretty-print tabular data. Use Python module for more features. + +FILE a filename of the file with tabular data; + if "-" or missing, read data from stdin. + +Options: + +-h, --help show this message + +INPUT: +-r, --read FILEFORMAT parse input FILEs as: + rsv (REGEXP-separated values, default), + csv (comma-separated valued, Excel dialect), + jsonl (one JSON object per line) +-s REGEXP, --sep REGEXP column separator for rsv data (default: whitespace) + +FORMAT: +--headers HEADERS HEADERS can be one of: + "firstrow" (for csv and rsv data), + "keys" (for jsonl data), + "HEADER1,HEADER2,..." (for csv and rsv data), + "KEY1:HEADER1,KEY2:HEADER2,..." (for jsonl data) +-1 use the first row of input data as a table header + (the same as --headers firstrow) +-F FPFMT, --float FPFMT floating point number format (default: g) +-I INTFMT, --int INTFMT integer point number format (default: "") +-f FMT, --format FMT set output table format (default: simple) + +Supported output formats: asciidoc, colon_grid, double_grid, double_outline, +fancy_grid, fancy_outline, github, grid, heavy_grid, heavy_outline, html, jira, +latex, latex_booktabs, latex_longtable, latex_raw, mediawiki, mixed_grid, mixed_outline, +moinmoin, orgtbl, outline, pipe, plain, presto, pretty, psql, rounded_grid, +rounded_outline, rst, simple, simple_grid, simple_outline, textile, tsv, unsafehtml. + +OUTPUT: +-o FILE, --output FILE print table to FILE (default: stdout) +``` Performance considerations -------------------------- From b47acc1b80b8d15d65f589396169e6f2fc1d3f38 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Mon, 9 Mar 2026 17:22:17 +0100 Subject: [PATCH 107/116] move implementation of the CLI utility to tabulate/cli.py --- pyproject.toml | 2 +- tabulate/__init__.py | 193 +------------------------------------ tabulate/__main__.py | 3 + tabulate/cli.py | 222 +++++++++++++++++++++++++++++++++++++++++++ test/test_cli.py | 14 +-- 5 files changed, 234 insertions(+), 200 deletions(-) create mode 100644 tabulate/__main__.py create mode 100644 tabulate/cli.py diff --git a/pyproject.toml b/pyproject.toml index fb782b8b..126ef55b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ Homepage = "https://github.com/astanin/python-tabulate" widechars = ["wcwidth>=0.6.0"] [project.scripts] -tabulate = "tabulate:_main" +tabulate = "tabulate.cli:_main" [tool.flit.sdist] include = ["CHANGELOG", "test/", "tox.ini"] diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 8911685e..109c1d36 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -12,7 +12,6 @@ from itertools import chain, zip_longest as izip_longest import math import re -import sys import textwrap from typing import Callable, Union import warnings @@ -2896,196 +2895,6 @@ def _wrap_chunks(self, chunks): return lines -def _main(): - """\ - Usage: tabulate [options] [FILE ...] - - Pretty-print tabular data. Use Python module for more features. - - FILE a filename of the file with tabular data; - if "-" or missing, read data from stdin. - - Options: - - -h, --help show this message - - INPUT: - -r, --read FILEFORMAT parse input FILEs as: - rsv (REGEXP-separated values, default), - csv (comma-separated valued, Excel dialect), - jsonl (one JSON object per line) - -s REGEXP, --sep REGEXP column separator for rsv data (default: whitespace) - - FORMAT: - --headers HEADERS HEADERS can be one of: - "firstrow" (for csv and rsv data), - "keys" (for jsonl data), - "HEADER1,HEADER2,..." (for csv and rsv data), - "KEY1:HEADER1,KEY2:HEADER2,..." (for jsonl data) - -1 use the first row of input data as a table header - (the same as --headers firstrow) - -F FPFMT, --float FPFMT floating point number format (default: g) - -I INTFMT, --int INTFMT integer point number format (default: "") - -f FMT, --format FMT set output table format (default: simple) - - Supported output formats: asciidoc, colon_grid, double_grid, double_outline, - fancy_grid, fancy_outline, github, grid, heavy_grid, heavy_outline, html, jira, - latex, latex_booktabs, latex_longtable, latex_raw, mediawiki, mixed_grid, mixed_outline, - moinmoin, orgtbl, outline, pipe, plain, presto, pretty, psql, rounded_grid, - rounded_outline, rst, simple, simple_grid, simple_outline, textile, tsv, unsafehtml. - - OUTPUT: - -o FILE, --output FILE print table to FILE (default: stdout) - - """ - import getopt - - usage = textwrap.dedent(_main.__doc__) - try: - opts, args = getopt.getopt( - sys.argv[1:], - "h1H:r:o:s:F:I:f:", - [ - "help", - "header", # deprecated in CLI > 0.10 - "headers=", # CLI > 0.10 - "read=", # CLI > 0.10 - "output=", - "sep=", - "float=", - "int=", - "colalign=", - "format=", - ], - ) - except getopt.GetoptError as e: - print(e, file=sys.stderr) - print(usage) - sys.exit(2) - headers = [] - floatfmt = _DEFAULT_FLOATFMT - intfmt = _DEFAULT_INTFMT - colalign = None - tablefmt = "simple" - fileformat = "rsv" - sep = r"\s+" - outfile = "-" - special_headers_values = ["firstrow", "keys"] - for opt, value in opts: - if opt in ["-1", "--header"]: - # "header" option is for backwards compatibility with CLI <= 0.10 - # CLI >= 0.11 should user --headers - headers = "firstrow" - if opt in ["-H", "--headers"]: - if value in special_headers_values: - headers = value - else: - headers = value # may need to be processed - elif opt in ["-o", "--output"]: - outfile = value - elif opt in ["-F", "--float"]: - floatfmt = value - elif opt in ["-I", "--int"]: - intfmt = value - elif opt in ["-C", "--colalign"]: - colalign = value.split() - elif opt in ["-r", "--read"]: - fileformat = value.lower() - elif opt in ["-f", "--format"]: - if value not in tabulate_formats: - print(f"{value} is not a supported output format", file=sys.stderr) - print(usage) - sys.exit(3) - tablefmt = value - elif opt in ["-s", "--sep"]: - sep = value - elif opt in ["-h", "--help"]: - print(usage) - sys.exit(0) - # choose a reader and parse headers option - if fileformat == "rsv": - reader = partial(_read_rsv_file, sep=sep) - if type(headers) is str and headers not in special_headers_values: - # parse as CSV values - headers = headers.split(",") - elif fileformat == "csv": - reader = _read_csv_file - if type(headers) is str and headers not in special_headers_values: - # parse as CSV values - headers = headers.split(",") - elif fileformat == "jsonl": - reader = _read_jsonl_file - if not headers: - headers = "keys" # reasonable default - if type(headers) is str and headers not in special_headers_values: - # "," and ":" in header titles are not supported in CLI - try: - headers2 = dict(tuple(hh.split(":",2)) for hh in headers.split(",")) - except: - print(f"cannot parse headers parameter: {headers}", file=sys.stderr) - headers2 = [] - headers = headers2 - else: - print(f"{fileformat} is not a supported file format") - sys.exit(3) - # format all input files - files = [sys.stdin] if not args else args - with sys.stdout if outfile == "-" else open(outfile, "w") as out: - for f in files: - if f == "-": - f = sys.stdin - _open_and_pprint_file(reader, f, - headers=headers, - tablefmt=tablefmt, - floatfmt=floatfmt, - intfmt=intfmt, - file=out, - colalign=colalign, - ) - - -def _read_rsv_file(fobject, sep): - rows = fobject.readlines() - table = [re.split(sep, r.rstrip()) for r in rows if r.strip()] - return table - - -def _read_jsonl_file(fobject): - import json - rows:list[str] = fobject.readlines() - table = [json.loads(row) for row in rows] - return table - - -def _read_csv_file(fobject): - import csv - reader = csv.reader(fobject, dialect="excel") - table = [list(row) for row in reader] - return table - - -def _open_and_pprint_file(reader, f, *args, **kwargs): - if _is_file(f): - _pprint_file(reader, f, *args, **kwargs) - else: - with open(f) as fobj: - _pprint_file(reader, fobj, *args, **kwargs) - - -def _pprint_file(reader, fobject, headers, tablefmt, floatfmt, intfmt, file, colalign): - table = reader(fobject) - print( - tabulate( - table, - headers, - tablefmt, - floatfmt=floatfmt, - intfmt=intfmt, - colalign=colalign, - ), - file=file, - ) - - if __name__ == "__main__": + from .cli import _main _main() diff --git a/tabulate/__main__.py b/tabulate/__main__.py new file mode 100644 index 00000000..c6efd79c --- /dev/null +++ b/tabulate/__main__.py @@ -0,0 +1,3 @@ +from tabulate.cli import _main + +_main() diff --git a/tabulate/cli.py b/tabulate/cli.py new file mode 100644 index 00000000..0ccec5d0 --- /dev/null +++ b/tabulate/cli.py @@ -0,0 +1,222 @@ +"""Command-line interface for tabulate.""" + +import re +import sys +import textwrap +from functools import partial + +try: + from . import ( + _DEFAULT_FLOATFMT, + _DEFAULT_INTFMT, + _is_file, + tabulate, + tabulate_formats, + ) +except ImportError: + # running as a script: python tabulate/cli.py + import sys as _sys + import os as _os + _sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))) + from tabulate import ( + _DEFAULT_FLOATFMT, + _DEFAULT_INTFMT, + _is_file, + tabulate, + tabulate_formats, + ) + + +def _main(): + """\ + Usage: tabulate [options] [FILE ...] + + Pretty-print tabular data. Use Python module for more features. + + FILE a filename of the file with tabular data; + if "-" or missing, read data from stdin. + + Options: + + -h, --help show this message + + INPUT: + -r, --read FILEFORMAT parse input FILEs as: + rsv (REGEXP-separated values, default), + csv (comma-separated valued, Excel dialect), + jsonl (one JSON object per line) + -s REGEXP, --sep REGEXP column separator for rsv data (default: whitespace) + + FORMAT: + --headers HEADERS HEADERS can be one of: + "firstrow" (for csv and rsv data), + "keys" (for jsonl data), + "HEADER1,HEADER2,..." (for csv and rsv data), + "KEY1:HEADER1,KEY2:HEADER2,..." (for jsonl data) + -1 use the first row of input data as a table header + (the same as --headers firstrow) + -F FPFMT, --float FPFMT floating point number format (default: g) + -I INTFMT, --int INTFMT integer point number format (default: "") + -f FMT, --format FMT set output table format (default: simple) + + Supported output formats: asciidoc, colon_grid, double_grid, double_outline, + fancy_grid, fancy_outline, github, grid, heavy_grid, heavy_outline, html, jira, + latex, latex_booktabs, latex_longtable, latex_raw, mediawiki, mixed_grid, mixed_outline, + moinmoin, orgtbl, outline, pipe, plain, presto, pretty, psql, rounded_grid, + rounded_outline, rst, simple, simple_grid, simple_outline, textile, tsv, unsafehtml. + + OUTPUT: + -o FILE, --output FILE print table to FILE (default: stdout) + + """ + import getopt + + usage = textwrap.dedent(_main.__doc__) + try: + opts, args = getopt.getopt( + sys.argv[1:], + "h1H:r:o:s:F:I:f:", + [ + "help", + "header", # deprecated in CLI > 0.10 + "headers=", # CLI > 0.10 + "read=", # CLI > 0.10 + "output=", + "sep=", + "float=", + "int=", + "colalign=", + "format=", + ], + ) + except getopt.GetoptError as e: + print(e, file=sys.stderr) + print(usage) + sys.exit(2) + headers = [] + floatfmt = _DEFAULT_FLOATFMT + intfmt = _DEFAULT_INTFMT + colalign = None + tablefmt = "simple" + fileformat = "rsv" + sep = r"\s+" + outfile = "-" + special_headers_values = ["firstrow", "keys"] + for opt, value in opts: + if opt in ["-1", "--header"]: + # "header" option is for backwards compatibility with CLI <= 0.10 + # CLI >= 0.11 should user --headers + headers = "firstrow" + if opt in ["-H", "--headers"]: + if value in special_headers_values: + headers = value + else: + headers = value # may need to be processed + elif opt in ["-o", "--output"]: + outfile = value + elif opt in ["-F", "--float"]: + floatfmt = value + elif opt in ["-I", "--int"]: + intfmt = value + elif opt in ["-C", "--colalign"]: + colalign = value.split() + elif opt in ["-r", "--read"]: + fileformat = value.lower() + elif opt in ["-f", "--format"]: + if value not in tabulate_formats: + print(f"{value} is not a supported output format", file=sys.stderr) + print(usage) + sys.exit(3) + tablefmt = value + elif opt in ["-s", "--sep"]: + sep = value + elif opt in ["-h", "--help"]: + print(usage) + sys.exit(0) + # choose a reader and parse headers option + if fileformat == "rsv": + reader = partial(_read_rsv_file, sep=sep) + if type(headers) is str and headers not in special_headers_values: + # parse as CSV values + headers = headers.split(",") + elif fileformat == "csv": + reader = _read_csv_file + if type(headers) is str and headers not in special_headers_values: + # parse as CSV values + headers = headers.split(",") + elif fileformat == "jsonl": + reader = _read_jsonl_file + if not headers: + headers = "keys" # reasonable default + if type(headers) is str and headers not in special_headers_values: + # "," and ":" in header titles are not supported in CLI + try: + headers2 = dict(tuple(hh.split(":",2)) for hh in headers.split(",")) + except: + print(f"cannot parse headers parameter: {headers}", file=sys.stderr) + headers2 = [] + headers = headers2 + else: + print(f"{fileformat} is not a supported file format") + sys.exit(3) + # format all input files + files = [sys.stdin] if not args else args + with sys.stdout if outfile == "-" else open(outfile, "w") as out: + for f in files: + if f == "-": + f = sys.stdin + _open_and_pprint_file(reader, f, + headers=headers, + tablefmt=tablefmt, + floatfmt=floatfmt, + intfmt=intfmt, + file=out, + colalign=colalign, + ) + + +def _read_rsv_file(fobject, sep): + rows = fobject.readlines() + table = [re.split(sep, r.rstrip()) for r in rows if r.strip()] + return table + + +def _read_jsonl_file(fobject): + import json + rows:list[str] = fobject.readlines() + table = [json.loads(row) for row in rows] + return table + + +def _read_csv_file(fobject): + import csv + reader = csv.reader(fobject, dialect="excel") + table = [list(row) for row in reader] + return table + + +def _open_and_pprint_file(reader, f, *args, **kwargs): + if _is_file(f): + _pprint_file(reader, f, *args, **kwargs) + else: + with open(f) as fobj: + _pprint_file(reader, fobj, *args, **kwargs) + + +def _pprint_file(reader, fobject, headers, tablefmt, floatfmt, intfmt, file, colalign): + table = reader(fobject) + print( + tabulate( + table, + headers, + tablefmt, + floatfmt=floatfmt, + intfmt=intfmt, + colalign=colalign, + ), + file=file, + ) + + +if __name__ == "__main__": + _main() diff --git a/test/test_cli.py b/test/test_cli.py index 625eea11..3cba9647 100644 --- a/test/test_cli.py +++ b/test/test_cli.py @@ -104,7 +104,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): def test_script_from_stdin_to_stdout(): """Command line utility: read from stdin, print to stdout""" - cmd = [sys.executable, "tabulate/__init__.py"] + cmd = [sys.executable, "tabulate/cli.py"] out = run_and_capture_stdout(cmd, input=sample_input()) expected = SAMPLE_SIMPLE_FORMAT print("got: ", repr(out)) @@ -117,7 +117,7 @@ def test_script_from_file_to_stdout(): with TemporaryTextFile() as tmpfile: tmpfile.write(sample_input()) tmpfile.seek(0) - cmd = [sys.executable, "tabulate/__init__.py", tmpfile.name] + cmd = [sys.executable, "tabulate/cli.py", tmpfile.name] out = run_and_capture_stdout(cmd) expected = SAMPLE_SIMPLE_FORMAT print("got: ", repr(out)) @@ -133,7 +133,7 @@ def test_script_from_file_to_file(): input_file.seek(0) cmd = [ sys.executable, - "tabulate/__init__.py", + "tabulate/cli.py", "-o", output_file.name, input_file.name, @@ -156,7 +156,7 @@ def test_script_from_file_to_file(): def test_script_header_option(): """Command line utility: -1, --header option""" for option in ["-1", "--header"]: - cmd = [sys.executable, "tabulate/__init__.py", option] + cmd = [sys.executable, "tabulate/cli.py", option] raw_table = sample_input(with_headers=True) out = run_and_capture_stdout(cmd, input=raw_table) expected = SAMPLE_SIMPLE_FORMAT_WITH_HEADERS @@ -169,7 +169,7 @@ def test_script_header_option(): def test_script_sep_option(): """Command line utility: -s, --sep option""" for option in ["-s", "--sep"]: - cmd = [sys.executable, "tabulate/__init__.py", option, ","] + cmd = [sys.executable, "tabulate/cli.py", option, ","] raw_table = sample_input(sep=",") out = run_and_capture_stdout(cmd, input=raw_table) expected = SAMPLE_SIMPLE_FORMAT @@ -183,7 +183,7 @@ def test_script_floatfmt_option(): for option in ["-F", "--float"]: cmd = [ sys.executable, - "tabulate/__init__.py", + "tabulate/cli.py", option, ".1e", "--format", @@ -200,7 +200,7 @@ def test_script_floatfmt_option(): def test_script_format_option(): """Command line utility: -f, --format option""" for option in ["-f", "--format"]: - cmd = [sys.executable, "tabulate/__init__.py", "-1", option, "grid"] + cmd = [sys.executable, "tabulate/cli.py", "-1", option, "grid"] raw_table = sample_input(with_headers=True) out = run_and_capture_stdout(cmd, input=raw_table) expected = SAMPLE_GRID_FORMAT_WITH_HEADERS From 791c4bf2fa3e06a8e21d01efee65f6649b51e5a1 Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Mon, 9 Mar 2026 17:34:32 +0100 Subject: [PATCH 108/116] add tests for the new CLI utility options --- test/test_cli.py | 84 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/test/test_cli.py b/test/test_cli.py index 3cba9647..1ca64b06 100644 --- a/test/test_cli.py +++ b/test/test_cli.py @@ -208,3 +208,87 @@ def test_script_format_option(): print("got: ", repr(out)) print("expected:", repr(expected)) assert_equal(out.splitlines(), expected.splitlines()) + + +SAMPLE_INPUT_JSONL = "\n".join( + [ + '{"id": 1, "name": "Alice", "email": "alice@example.com"}', + '{"id": 2, "name": "Bob", "email": "bob@example.com"}', + ] +) + +SAMPLE_GRID_FORMAT = "\n".join( + [ + "+------+--------+-------------------+", + "| id | name | email |", + "+======+========+===================+", + "| 1 | Alice | alice@example.com |", + "+------+--------+-------------------+", + "| 2 | Bob | bob@example.com |", + "+------+--------+-------------------+", + ] +) + + +def test_module_jsonl_from_stdin(): + """Command line utility: python -m tabulate with JSONL input from stdin""" + cmd = [sys.executable, "-m", "tabulate", "-r", "jsonl", "-f", "grid"] + out = run_and_capture_stdout(cmd, input=SAMPLE_INPUT_JSONL) + expected = SAMPLE_GRID_FORMAT + print("got: ", repr(out)) + print("expected:", repr(expected)) + assert_equal(out.splitlines(), expected.splitlines()) + + +SAMPLE_REMAPPED_HEADERS = "\n".join( + [ + " ID First Name Email", + "---- ------------ -----------------", + " 1 Alice alice@example.com", + " 2 Bob bob@example.com", + ] +) + + +SAMPLE_INPUT_CSV = ( + 'id,name,email,"""favorite"" fruit"\n' + '1,Alice,alice@example.com,"apple, kiwi"\n' + '2,Bob,bob@example.com,"banana,\norange,\nlychee"\n' + "3,Carol,,pear\n" +) + +SAMPLE_CSV_FORMAT = "\n".join( + [ + "-- ----- ----------------- ----------------", + "id name email \"favorite\" fruit", + "1 Alice alice@example.com apple, kiwi", + "2 Bob bob@example.com banana,", + " orange,", + " lychee", + "3 Carol pear", + "-- ----- ----------------- ----------------", + ] +) + +def test_module_csv_from_stdin(): + """Command line utility: python -m tabulate with CSV input from stdin""" + cmd = [sys.executable, "-m", "tabulate", "-r", "csv"] + out = run_and_capture_stdout(cmd, input=SAMPLE_INPUT_CSV) + expected = SAMPLE_CSV_FORMAT + print("got: ", repr(out)) + print("expected:", repr(expected)) + assert_equal(out.splitlines(), expected.splitlines()) + + +def test_module_jsonl_remapped_headers(): + """Command line utility: --headers with key:header remapping for JSONL input""" + cmd = [ + sys.executable, "-m", "tabulate", + "-r", "jsonl", + "--headers", "id:ID,name:First Name,email:Email", + ] + out = run_and_capture_stdout(cmd, input=SAMPLE_INPUT_JSONL) + expected = SAMPLE_REMAPPED_HEADERS + print("got: ", repr(out)) + print("expected:", repr(expected)) + assert_equal(out.splitlines(), expected.splitlines()) From 17bf1cf0b03390090fe26d858eee0eddbd8598fd Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Tue, 10 Mar 2026 09:32:47 +0100 Subject: [PATCH 109/116] run all CLI tests also in-process for coverage report - old tests: launch tabulate via subprocess (still necessary) - new tests: the same checks for coverage reports --- tabulate/cli.py | 4 +- test/test_cli.py | 180 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+), 2 deletions(-) diff --git a/tabulate/cli.py b/tabulate/cli.py index 0ccec5d0..ab7d571c 100644 --- a/tabulate/cli.py +++ b/tabulate/cli.py @@ -13,7 +13,7 @@ tabulate, tabulate_formats, ) -except ImportError: +except ImportError: # pragma: no cover # running as a script: python tabulate/cli.py import sys as _sys import os as _os @@ -218,5 +218,5 @@ def _pprint_file(reader, fobject, headers, tablefmt, floatfmt, intfmt, file, col ) -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no cover _main() diff --git a/test/test_cli.py b/test/test_cli.py index 1ca64b06..01624482 100644 --- a/test/test_cli.py +++ b/test/test_cli.py @@ -1,11 +1,32 @@ """Command-line interface.""" +import contextlib +import io import os import subprocess import sys import tempfile +from unittest.mock import patch from common import assert_equal +from tabulate.cli import _main + + +class _UnclosableStringIO(io.StringIO): + """StringIO that ignores close() so getvalue() works after a 'with' block.""" + def close(self): + pass # _main does `with sys.stdout as out:`, which would close a plain StringIO + + +def run_main_in_process(args, input_text=None): + """Call _main() in-process, capturing stdout. Returns the captured output.""" + stdin = io.StringIO(input_text) if input_text is not None else sys.stdin + stdout = _UnclosableStringIO() + with patch("sys.argv", ["tabulate"] + args), \ + patch("sys.stdin", stdin), \ + contextlib.redirect_stdout(stdout): + _main() + return stdout.getvalue() SAMPLE_SIMPLE_FORMAT = "\n".join( [ @@ -292,3 +313,162 @@ def test_module_jsonl_remapped_headers(): print("got: ", repr(out)) print("expected:", repr(expected)) assert_equal(out.splitlines(), expected.splitlines()) + + +# --------------------------------------------------------------------------- +# In-process tests: same scenarios as above but calling _main() directly so +# that coverage.py can instrument the code in tabulate/cli.py. +# --------------------------------------------------------------------------- + +def test_inprocess_stdin_to_stdout(): + """In-process: read RSV from stdin, print to stdout""" + out = run_main_in_process([], input_text=sample_input()) + assert_equal(out.splitlines(), SAMPLE_SIMPLE_FORMAT.splitlines()) + + +def test_inprocess_header_option(): + """In-process: -1 / --header / --headers firstrow""" + for args in [["-1"], ["--header"], ["--headers", "firstrow"]]: + out = run_main_in_process(args, input_text=sample_input(with_headers=True)) + assert_equal(out.splitlines(), SAMPLE_SIMPLE_FORMAT_WITH_HEADERS.splitlines()) + + +def test_inprocess_sep_option(): + """In-process: -s / --sep""" + for opt in ["-s", "--sep"]: + out = run_main_in_process([opt, ","], input_text=sample_input(sep=",")) + assert_equal(out.splitlines(), SAMPLE_SIMPLE_FORMAT.splitlines()) + + +def test_inprocess_floatfmt_option(): + """In-process: -F / --float""" + for opt in ["-F", "--float"]: + out = run_main_in_process([opt, ".1e", "--format", "grid"], input_text=sample_input()) + assert_equal(out.splitlines(), SAMPLE_GRID_FORMAT_WITH_DOT1E_FLOATS.splitlines()) + + +def test_inprocess_format_option(): + """In-process: -f / --format""" + for opt in ["-f", "--format"]: + out = run_main_in_process(["-1", opt, "grid"], input_text=sample_input(with_headers=True)) + assert_equal(out.splitlines(), SAMPLE_GRID_FORMAT_WITH_HEADERS.splitlines()) + + +def test_inprocess_file_to_file(): + """In-process: read from file, write to file (-o)""" + with TemporaryTextFile() as input_file: + with TemporaryTextFile() as output_file: + input_file.write(sample_input()) + input_file.flush() + run_main_in_process(["-o", output_file.name, input_file.name]) + output_file.seek(0) + out = output_file.file.read() + assert_equal(out.splitlines(), SAMPLE_SIMPLE_FORMAT.splitlines()) + + +def test_inprocess_jsonl_from_stdin(): + """In-process: JSONL input from stdin, grid format""" + out = run_main_in_process(["-r", "jsonl", "-f", "grid"], input_text=SAMPLE_INPUT_JSONL) + assert_equal(out.splitlines(), SAMPLE_GRID_FORMAT.splitlines()) + + +def test_inprocess_jsonl_remapped_headers(): + """In-process: JSONL input with key:header remapping""" + out = run_main_in_process( + ["-r", "jsonl", "--headers", "id:ID,name:First Name,email:Email"], + input_text=SAMPLE_INPUT_JSONL, + ) + assert_equal(out.splitlines(), SAMPLE_REMAPPED_HEADERS.splitlines()) + + +def test_inprocess_csv_from_stdin(): + """In-process: CSV input from stdin""" + out = run_main_in_process(["-r", "csv"], input_text=SAMPLE_INPUT_CSV) + assert_equal(out.splitlines(), SAMPLE_CSV_FORMAT.splitlines()) + + +def test_inprocess_invalid_option(): + """In-process: unrecognised option exits with code 2""" + import pytest + with pytest.raises(SystemExit) as exc_info: + run_main_in_process(["--no-such-option"], input_text="a b\n1 2\n") + assert exc_info.value.code == 2 + + +def test_inprocess_help_option(): + """In-process: --help / -h exits with code 0""" + import pytest + for opt in ["-h", "--help"]: + with pytest.raises(SystemExit) as exc_info: + run_main_in_process([opt], input_text="") + assert exc_info.value.code == 0 + + +def test_inprocess_invalid_format(): + """In-process: unknown --format value exits with code 3""" + import pytest + with pytest.raises(SystemExit) as exc_info: + run_main_in_process(["-f", "nosuchformat"], input_text="a b\n1 2\n") + assert exc_info.value.code == 3 + + +def test_inprocess_invalid_fileformat(): + """In-process: unknown --read value exits with code 3""" + import pytest + with pytest.raises(SystemExit) as exc_info: + run_main_in_process(["-r", "xml"], input_text="") + assert exc_info.value.code == 3 + + +def test_inprocess_int_option(): + """In-process: -I / --int option""" + jsonl_ints = '{"n": 1000000}\n{"n": 2000000}\n' + for opt in ["-I", "--int"]: + out = run_main_in_process( + ["-r", "jsonl", opt, "_"], input_text=jsonl_ints + ) + assert "1_000_000" in out + + +def test_inprocess_colalign_option(): + """In-process: --colalign option""" + out = run_main_in_process( + ["--colalign", "left left left", "-1"], + input_text=sample_input(with_headers=True), + ) + assert "Planet" in out + + +def test_inprocess_rsv_custom_headers(): + """In-process: --headers with custom column names for RSV input""" + out = run_main_in_process( + ["--headers", "Planet,Radius,Mass"], input_text=sample_input() + ) + assert_equal(out.splitlines(), SAMPLE_SIMPLE_FORMAT_WITH_HEADERS.splitlines()) + + +def test_inprocess_csv_custom_headers(): + """In-process: --headers with custom column names overrides CSV first row""" + csv_data = "Sun,696000,1.9891e9\nEarth,6371,5973.6\n" + out = run_main_in_process( + ["-r", "csv", "--headers", "Planet,Radius,Mass"], input_text=csv_data + ) + assert "Planet" in out and "Radius" in out and "Mass" in out + + +def test_inprocess_stdin_dash_arg(): + """In-process: '-' as filename reads from stdin""" + out = run_main_in_process(["-"], input_text=sample_input()) + assert_equal(out.splitlines(), SAMPLE_SIMPLE_FORMAT.splitlines()) + + +def test_inprocess_jsonl_malformed_headers(): + """In-process: malformed key:header mapping falls back to no headers""" + # A header spec without ':' can't be parsed into key-value pairs; + # _main catches the ValueError and proceeds with an empty headers list. + out = run_main_in_process( + ["-r", "jsonl", "--headers", "no_colon_here"], + input_text=SAMPLE_INPUT_JSONL, + ) + # output should still be produced (graceful fallback), with raw keys as headers + assert out.strip() != "" From cf15727ee7b12bd40eb08ac57de79974de11505e Mon Sep 17 00:00:00 2001 From: Sergey Astanin Date: Tue, 10 Mar 2026 11:13:08 +0100 Subject: [PATCH 110/116] ruff format & ruff check --- examples/people.jsonl | 2 +- tabulate/__init__.py | 1 + tabulate/cli.py | 35 ++++++++++++++++++++--------------- test/test_cli.py | 39 +++++++++++++++++++++++++-------------- 4 files changed, 47 insertions(+), 30 deletions(-) diff --git a/examples/people.jsonl b/examples/people.jsonl index 7f3e1138..8dd35b43 100644 --- a/examples/people.jsonl +++ b/examples/people.jsonl @@ -1,2 +1,2 @@ {"id": 1, "name": "Alice", "email": "alice@example.com"} -{"id": 2, "name": "Bob", "email": "bob@example.com"} \ No newline at end of file +{"id": 2, "name": "Bob", "email": "bob@example.com"} diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 109c1d36..4aa34ee5 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -2897,4 +2897,5 @@ def _wrap_chunks(self, chunks): if __name__ == "__main__": from .cli import _main + _main() diff --git a/tabulate/cli.py b/tabulate/cli.py index ab7d571c..cfae6fea 100644 --- a/tabulate/cli.py +++ b/tabulate/cli.py @@ -1,9 +1,9 @@ """Command-line interface for tabulate.""" +from functools import partial import re import sys import textwrap -from functools import partial try: from . import ( @@ -15,8 +15,9 @@ ) except ImportError: # pragma: no cover # running as a script: python tabulate/cli.py - import sys as _sys import os as _os + import sys as _sys + _sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))) from tabulate import ( _DEFAULT_FLOATFMT, @@ -79,8 +80,8 @@ def _main(): [ "help", "header", # deprecated in CLI > 0.10 - "headers=", # CLI > 0.10 - "read=", # CLI > 0.10 + "headers=", # CLI > 0.10 + "read=", # CLI > 0.10 "output=", "sep=", "float=", @@ -151,8 +152,8 @@ def _main(): if type(headers) is str and headers not in special_headers_values: # "," and ":" in header titles are not supported in CLI try: - headers2 = dict(tuple(hh.split(":",2)) for hh in headers.split(",")) - except: + headers2 = dict(tuple(hh.split(":", 2)) for hh in headers.split(",")) + except Exception: print(f"cannot parse headers parameter: {headers}", file=sys.stderr) headers2 = [] headers = headers2 @@ -165,14 +166,16 @@ def _main(): for f in files: if f == "-": f = sys.stdin - _open_and_pprint_file(reader, f, - headers=headers, - tablefmt=tablefmt, - floatfmt=floatfmt, - intfmt=intfmt, - file=out, - colalign=colalign, - ) + _open_and_pprint_file( + reader, + f, + headers=headers, + tablefmt=tablefmt, + floatfmt=floatfmt, + intfmt=intfmt, + file=out, + colalign=colalign, + ) def _read_rsv_file(fobject, sep): @@ -183,13 +186,15 @@ def _read_rsv_file(fobject, sep): def _read_jsonl_file(fobject): import json - rows:list[str] = fobject.readlines() + + rows: list[str] = fobject.readlines() table = [json.loads(row) for row in rows] return table def _read_csv_file(fobject): import csv + reader = csv.reader(fobject, dialect="excel") table = [list(row) for row in reader] return table diff --git a/test/test_cli.py b/test/test_cli.py index 01624482..154f1a2b 100644 --- a/test/test_cli.py +++ b/test/test_cli.py @@ -8,12 +8,14 @@ import tempfile from unittest.mock import patch -from common import assert_equal from tabulate.cli import _main +from common import assert_equal + class _UnclosableStringIO(io.StringIO): """StringIO that ignores close() so getvalue() works after a 'with' block.""" + def close(self): pass # _main does `with sys.stdout as out:`, which would close a plain StringIO @@ -22,12 +24,15 @@ def run_main_in_process(args, input_text=None): """Call _main() in-process, capturing stdout. Returns the captured output.""" stdin = io.StringIO(input_text) if input_text is not None else sys.stdin stdout = _UnclosableStringIO() - with patch("sys.argv", ["tabulate"] + args), \ - patch("sys.stdin", stdin), \ - contextlib.redirect_stdout(stdout): + with ( + patch("sys.argv", ["tabulate"] + args), + patch("sys.stdin", stdin), + contextlib.redirect_stdout(stdout), + ): _main() return stdout.getvalue() + SAMPLE_SIMPLE_FORMAT = "\n".join( [ "----- ------ -------------", @@ -281,7 +286,7 @@ def test_module_jsonl_from_stdin(): SAMPLE_CSV_FORMAT = "\n".join( [ "-- ----- ----------------- ----------------", - "id name email \"favorite\" fruit", + 'id name email "favorite" fruit', "1 Alice alice@example.com apple, kiwi", "2 Bob bob@example.com banana,", " orange,", @@ -291,6 +296,7 @@ def test_module_jsonl_from_stdin(): ] ) + def test_module_csv_from_stdin(): """Command line utility: python -m tabulate with CSV input from stdin""" cmd = [sys.executable, "-m", "tabulate", "-r", "csv"] @@ -304,9 +310,13 @@ def test_module_csv_from_stdin(): def test_module_jsonl_remapped_headers(): """Command line utility: --headers with key:header remapping for JSONL input""" cmd = [ - sys.executable, "-m", "tabulate", - "-r", "jsonl", - "--headers", "id:ID,name:First Name,email:Email", + sys.executable, + "-m", + "tabulate", + "-r", + "jsonl", + "--headers", + "id:ID,name:First Name,email:Email", ] out = run_and_capture_stdout(cmd, input=SAMPLE_INPUT_JSONL) expected = SAMPLE_REMAPPED_HEADERS @@ -320,6 +330,7 @@ def test_module_jsonl_remapped_headers(): # that coverage.py can instrument the code in tabulate/cli.py. # --------------------------------------------------------------------------- + def test_inprocess_stdin_to_stdout(): """In-process: read RSV from stdin, print to stdout""" out = run_main_in_process([], input_text=sample_input()) @@ -390,6 +401,7 @@ def test_inprocess_csv_from_stdin(): def test_inprocess_invalid_option(): """In-process: unrecognised option exits with code 2""" import pytest + with pytest.raises(SystemExit) as exc_info: run_main_in_process(["--no-such-option"], input_text="a b\n1 2\n") assert exc_info.value.code == 2 @@ -398,6 +410,7 @@ def test_inprocess_invalid_option(): def test_inprocess_help_option(): """In-process: --help / -h exits with code 0""" import pytest + for opt in ["-h", "--help"]: with pytest.raises(SystemExit) as exc_info: run_main_in_process([opt], input_text="") @@ -407,6 +420,7 @@ def test_inprocess_help_option(): def test_inprocess_invalid_format(): """In-process: unknown --format value exits with code 3""" import pytest + with pytest.raises(SystemExit) as exc_info: run_main_in_process(["-f", "nosuchformat"], input_text="a b\n1 2\n") assert exc_info.value.code == 3 @@ -415,6 +429,7 @@ def test_inprocess_invalid_format(): def test_inprocess_invalid_fileformat(): """In-process: unknown --read value exits with code 3""" import pytest + with pytest.raises(SystemExit) as exc_info: run_main_in_process(["-r", "xml"], input_text="") assert exc_info.value.code == 3 @@ -424,9 +439,7 @@ def test_inprocess_int_option(): """In-process: -I / --int option""" jsonl_ints = '{"n": 1000000}\n{"n": 2000000}\n' for opt in ["-I", "--int"]: - out = run_main_in_process( - ["-r", "jsonl", opt, "_"], input_text=jsonl_ints - ) + out = run_main_in_process(["-r", "jsonl", opt, "_"], input_text=jsonl_ints) assert "1_000_000" in out @@ -441,9 +454,7 @@ def test_inprocess_colalign_option(): def test_inprocess_rsv_custom_headers(): """In-process: --headers with custom column names for RSV input""" - out = run_main_in_process( - ["--headers", "Planet,Radius,Mass"], input_text=sample_input() - ) + out = run_main_in_process(["--headers", "Planet,Radius,Mass"], input_text=sample_input()) assert_equal(out.splitlines(), SAMPLE_SIMPLE_FORMAT_WITH_HEADERS.splitlines()) From 2f7f69d6f8c82363d6e6053746dba4b836057721 Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Thu, 5 Mar 2026 16:35:16 +0100 Subject: [PATCH 111/116] Apply ruff/pyupgrade rule UP007 Use `X | Y` for type annotations --- tabulate/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 9ee72fe6..1cb052b6 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -14,7 +14,7 @@ import re import sys import textwrap -from typing import Callable, Union +from typing import Callable import warnings try: @@ -2529,7 +2529,7 @@ def _build_row( padded_cells: list[list], colwidths: list[int], colaligns: list[str], - rowfmt: Union[DataRow, Callable], + rowfmt: DataRow | Callable, ) -> str: "Return a string which represents a row of data cells." if not rowfmt: From 63c74acd805c35b864f5d49d23120ec8c6381a9c Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Thu, 5 Mar 2026 09:05:34 +0100 Subject: [PATCH 112/116] Apply ruff/pyupgrade rule UP018 Unnecessary `int` call (rewrite as a literal) --- test/test_regression.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_regression.py b/test/test_regression.py index 051bf031..98eefba3 100644 --- a/test/test_regression.py +++ b/test/test_regression.py @@ -341,7 +341,7 @@ def test_multiline_with_wide_characters(): def test_align_long_integers(): "Regression: long integers should be aligned as integers (issue #61)" - table = [[int(1)], [int(234)]] + table = [[1], [234]] result = tabulate(table, tablefmt="plain") expected = "\n".join([" 1", "234"]) assert_equal(expected, result) From 4c39df9755109ddfe73ef4fd4acd17d9bd8ef2e7 Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Thu, 5 Mar 2026 09:11:28 +0100 Subject: [PATCH 113/116] Apply ruff/pyupgrade rule UP031 Use format specifiers instead of percent format --- benchmark/benchmark.py | 8 ++++---- tabulate/__init__.py | 10 +++++----- test/common.py | 8 ++++---- test/test_api.py | 2 +- test/test_input.py | 2 +- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index d405f5c7..f632a04b 100644 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -55,13 +55,13 @@ def run_tabulate(table, widechars=False): methods = [ ("join with tabs and newlines", "join_table(table)"), ("csv to StringIO", "csv_table(table)"), - ("tabulate (%s)" % tabulate.__version__, "run_tabulate(table)"), + (f"tabulate ({tabulate.__version__})", "run_tabulate(table)"), ( - "tabulate (%s, WIDE_CHARS_MODE)" % tabulate.__version__, + f"tabulate ({tabulate.__version__}, WIDE_CHARS_MODE)", "run_tabulate(table, widechars=True)", ), - ("PrettyTable (%s)" % prettytable.__version__, "run_prettytable(table)"), - ("texttable (%s)" % texttable.__version__, "run_texttable(table)"), + (f"PrettyTable ({prettytable.__version__})", "run_prettytable(table)"), + (f"texttable ({texttable.__version__})", "run_texttable(table)"), ] diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 1cb052b6..13c06b21 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1046,7 +1046,7 @@ def _padleft(width, s): True """ - fmt = "{0:>%ds}" % width + fmt = f"{{0:>{width}s}}" return fmt.format(s) @@ -1057,7 +1057,7 @@ def _padright(width, s): True """ - fmt = "{0:<%ds}" % width + fmt = f"{{0:<{width}s}}" return fmt.format(s) @@ -1068,7 +1068,7 @@ def _padboth(width, s): True """ - fmt = "{0:^%ds}" % width + fmt = f"{{0:^{width}s}}" return fmt.format(s) @@ -2805,7 +2805,7 @@ def _wrap_chunks(self, chunks): """ lines = [] if self.width <= 0: - raise ValueError("invalid width %r (must be > 0)" % self.width) + raise ValueError(f"invalid width {self.width!r} (must be > 0)") if self.max_lines is not None: if self.max_lines > 1: indent = self.subsequent_indent @@ -2962,7 +2962,7 @@ def _main(): colalign = value.split() elif opt in ["-f", "--format"]: if value not in tabulate_formats: - print("%s is not a supported table format" % value) + print(f"{value} is not a supported table format") print(usage) sys.exit(3) tablefmt = value diff --git a/test/common.py b/test/common.py index 6afb4be0..fc994003 100644 --- a/test/common.py +++ b/test/common.py @@ -5,15 +5,15 @@ def assert_equal(expected, result): - print("Expected:\n%r\n" % expected) - print("Got:\n%r\n" % result) + print(f"Expected:\n{expected!r}\n") + print(f"Got:\n{result!r}\n") assert expected == result def assert_in(result, expected_set): for i, expected in enumerate(expected_set, start=1): - print("Expected %d:\n%s\n" % (i, expected)) - print("Got:\n%s\n" % result) + print(f"Expected {i}:\n{expected}\n") + print(f"Got:\n{result}\n") assert result in expected_set diff --git a/test/test_api.py b/test/test_api.py index 3e0963a2..cb59836e 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -14,7 +14,7 @@ def test_tabulate_formats(): "API: tabulate_formats is a list of strings" supported = tabulate_formats - print("tabulate_formats = %r" % supported) + print(f"tabulate_formats = {supported!r}") assert type(supported) is list for fmt in supported: assert type(fmt) is str diff --git a/test/test_input.py b/test/test_input.py index 908d8d7e..3cc32374 100644 --- a/test/test_input.py +++ b/test/test_input.py @@ -96,7 +96,7 @@ def test_dict_like(): expected1 = "\n".join([" a b", "--- ---", " 0 101", " 1 102", " 2 103", " 104"]) expected2 = "\n".join([" b a", "--- ---", "101 0", "102 1", "103 2", "104"]) result = tabulate(dd, "keys") - print("Keys' order: %s" % dd.keys()) + print(f"Keys' order: {dd.keys()}") assert_in(result, [expected1, expected2]) From 5f877749b6c9a7de90efaea63644f13d6b6d7dab Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Thu, 5 Mar 2026 16:36:13 +0100 Subject: [PATCH 114/116] Apply ruff/pyupgrade rule UP035 Import from `collections.abc` instead --- tabulate/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 13c06b21..449f78c8 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1,7 +1,7 @@ """Pretty-print tabular data.""" from collections import namedtuple -from collections.abc import Iterable, Sized +from collections.abc import Callable, Iterable, Sized import dataclasses from dataclasses import dataclass from decimal import Decimal @@ -14,7 +14,6 @@ import re import sys import textwrap -from typing import Callable import warnings try: From 2070736ffe87cae8941e905974e28c539f32d7e3 Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Thu, 5 Mar 2026 09:12:38 +0100 Subject: [PATCH 115/116] Enforce ruff/pyupgrade rules (UP) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index fb782b8b..6a564771 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,7 @@ line-length = 99 exclude = ["tabulate/_version.py"] [tool.ruff.lint] -extend-select = ["W", "C4", "ISC", "I", "C90"] +extend-select = ["W", "C4", "ISC", "I", "C90", "UP"] ignore = ["E721", "C901"] [tool.ruff.lint.mccabe] From 3681fa565d4001d2d23e168e15d207afe9e19c1a Mon Sep 17 00:00:00 2001 From: Dimitri Papadopoulos <3234522+DimitriPapadopoulos@users.noreply.github.com> Date: Tue, 10 Mar 2026 22:16:44 +0100 Subject: [PATCH 116/116] Enforce ruff/flake8-bugbear rules (B) --- pyproject.toml | 4 ++-- tabulate/__init__.py | 8 ++++---- test/test_regression.py | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6a564771..99325f37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,8 +56,8 @@ line-length = 99 exclude = ["tabulate/_version.py"] [tool.ruff.lint] -extend-select = ["W", "C4", "ISC", "I", "C90", "UP"] -ignore = ["E721", "C901"] +extend-select = ["W", "B", "C4", "ISC", "I", "C90", "UP"] +ignore = ["B905", "E721", "C901"] [tool.ruff.lint.mccabe] max-complexity = 22 diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 449f78c8..d45b246b 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1478,8 +1478,8 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): keys = tabular_data.keys() try: rows = list(izip_longest(*tabular_data.values())) # columns have to be transposed - except TypeError: # not iterable - raise TypeError(err_msg) + except TypeError as e: # not iterable + raise TypeError(err_msg) from e elif hasattr(tabular_data, "index"): # values is a property, has .index => it's likely a pandas.DataFrame (pandas 0.11.0) @@ -1502,8 +1502,8 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): else: # it's a usual iterable of iterables, or a NumPy array, or an iterable of dataclasses try: rows = list(tabular_data) - except TypeError: # not iterable - raise TypeError(err_msg) + except TypeError as e: # not iterable + raise TypeError(err_msg) from e if headers == "keys" and not rows: # an empty table (issue #81) diff --git a/test/test_regression.py b/test/test_regression.py index 98eefba3..95556769 100644 --- a/test/test_regression.py +++ b/test/test_regression.py @@ -97,7 +97,7 @@ def mk_iter_of_iters(): def mk_iter(): yield from range(3) - for r in range(3): + for _ in range(3): yield mk_iter() def mk_headers(): @@ -357,7 +357,7 @@ def test_numpy_array_as_headers(): expected = "foo bar" assert_equal(expected, result) except ImportError: - raise skip("") + raise skip("") from None def test_boolean_columns(): @@ -477,7 +477,7 @@ def test_numpy_array_as_showindex(): try: import numpy as np except ImportError: - raise skip("") + raise skip("") from None table = [["a"], ["b"], ["c"]] # np.array([...]) == "default" returns an element-wise boolean array whose @@ -548,7 +548,7 @@ def test_numpy_int64_as_integer(): ) assert_equal(expected, result) except ImportError: - raise skip("") + raise skip("") from None def test_empty_table_with_colalign():