From 38167b81d58fe408e178c2f80503e9df17927936 Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Wed, 20 Nov 2024 07:41:50 -0700 Subject: [PATCH 1/9] Automate release to PyPI, remove Python 3.8 testing --- .github/workflows/release.yml | 29 +++++++++++++++++++++++++++++ HOWTOPUBLISH | 6 +++--- README.md | 14 ++++++++++---- pyproject.toml | 2 +- tox.ini | 8 +------- 5 files changed, 44 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..0187705e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,29 @@ +name: Release + +on: + push: + tags: + - '*' + +jobs: + pypi-publish: + if: startsWith(github.ref, 'refs/tags') + name: Upload release to PyPI + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/tabulate-slip39 + permissions: + id-token: write # IMPORTANT: this permission is mandatory for trusted publishing + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + - name: Build + run: | + python -m pip install --upgrade pip + python -m pip install setuptools build + python -m build -s + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/HOWTOPUBLISH b/HOWTOPUBLISH index 29c4545c..bd78db26 100644 --- a/HOWTOPUBLISH +++ b/HOWTOPUBLISH @@ -1,4 +1,5 @@ # update contributors and CHANGELOG in README +python -m pip install pre-commit python -m pre_commit run -a # and then commit changes tox -e py39-extra,py310-extra,py311-extra,py312-extra,py313-extra # tag version release @@ -6,10 +7,9 @@ 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 +# move tag to the last commit: eg. +git tag v0.10.4 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 -twine upload --repository-url https://test.pypi.org/legacy/ dist/* -twine upload dist/* diff --git a/README.md b/README.md index 223a85a2..4d1f5a57 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,12 @@ python-tabulate =============== +> This is a temporary upgrade shim for https://github.com/astanin/python-tabulate + +> Install `tabulate` via `python -m pip install tabulate-slip39`, +> until the upstream https://pypi.org/project/tabulate is upgraded + + Pretty-print tabular data in Python, a library and a command-line utility. @@ -503,10 +509,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/pyproject.toml b/pyproject.toml index d13e92d4..3c7c0660 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools>=77.0.3", "setuptools_scm[toml]>=3.4.3"] build-backend = "setuptools.build_meta" [project] -name = "tabulate" +name = "tabulate-slip39" authors = [{name = "Sergey Astanin", email = "s.astanin@gmail.com"}] license = "MIT" license-files = ["LICENSE"] diff --git a/tox.ini b/tox.ini index 9605e79b..51f01006 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{39, 310, 311, 312, 313} isolated_build = True [gh] @@ -33,12 +33,6 @@ commands = python -m pre_commit run -a deps = pre-commit -[testenv:py38] -basepython = python3.8 -commands = pytest -v --doctest-modules --ignore benchmark {posargs} -deps = - pytest - [testenv:py38-extra] basepython = python3.8 commands = pytest -v --doctest-modules --ignore benchmark {posargs} From 3194c38de6bf6dc82a16953fe319fe4045d0bf9b Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Sun, 26 Oct 2025 21:12:24 +0400 Subject: [PATCH 2/9] Enable Nix-provision Python venv testing environment --- Makefile | 109 +++++++++++++++++++++++++++++++++++++++++++ flake.lock | 61 ++++++++++++++++++++++++ flake.nix | 63 +++++++++++++++++++++++++ nixpkgs.nix | 4 ++ requirements/dev.txt | 11 +++++ 5 files changed, 248 insertions(+) create mode 100644 Makefile create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 nixpkgs.nix create mode 100644 requirements/dev.txt diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..1f877076 --- /dev/null +++ b/Makefile @@ -0,0 +1,109 @@ +# Minimal Makefile for Nix and venv + +SHELL := /bin/bash + + +export PYTHON ?= $(shell python3 --version >/dev/null 2>&1 && echo python3 || echo python ) + +# Ensure $(PYTHON), $(VENV) are re-evaluated at time of expansion, when target $(PYTHON) are known to be available +PYTHON_V = $(shell $(PYTHON) -c "import sys; print('-'.join((('venv' if sys.prefix != sys.base_prefix else next(iter(filter(None,sys.base_prefix.split('/'))))),sys.platform,sys.implementation.cache_tag)))" 2>/dev/null ) + +export TOX ?= tox +export TOX_OPTS ?= -e py39-extra,py310-extra,py311-extra,py312-extra,py313-extra +#export TOX_OPTS ?= -e py311-extra,py312-extra,py313-extra +export PYTEST ?= $(PYTHON) -m pytest +export PYTEST_OPTS ?= # -vv --capture=no + +VERSION = $(shell $(PYTHON) -c "exec(open('tabulate/version.py').read()); print('.'.join(map(str, __version_tuple__[:-2])))" ) +VERSION_FULL = $(shell $(PYTHON) -c "exec(open('tabulate/version.py').read()); print(__version__)" ) +WHEEL = dist/tabulate_slip39-$(VERSION_FULL)-py3-none-any.whl +VENV = $(CURDIR)-$(VERSION)-$(PYTHON_V) + +# Force export of variables that might be set from command line +export VENV_OPTS ?= +export NIX_OPTS ?= + +# Put it first so that "make" without argument is like "make help". +help: + @echo "Build and test tabulate under Nix and Python venv" + @echo + @echo " nix-... Make a target in the Nix Flake develop environment" + @echo " venv Create and start a Python venv using the available Python interpreter" + @echo " venv-... Make a target using the venv environment" + @echo + @echo "For example, to run tox tests in a Nix-supplied Python venv:" + @echo + @echo " make nix-venv-test" + @echo + +.PHONY: help wheel install test bench analyze types venv Makefile FORCE + +wheel: $(WHEEL) + +$(WHEEL): FORCE + $(PYTHON) -m build + @ls -last dist + +# Install from wheel, including all optional extra dependencies (doesn't include dev) +install: $(WHEEL) FORCE + $(PYTHON) -m pip install --force-reinstall $< + +# Install from requirements/*; eg. install-dev, always getting the latest version +install-%: FORCE + $(PYTHON) -m pip install --upgrade -r requirements/$*.txt + + +unit-%: + $(PYTEST) $(PYTEST_OPTS) -k $* + +test: + $(TOX) $(TOX_OPTS) + +bench: + $(PYTHON) benchmark/benchmark.py + +analyze: + $(PYTHON) -m flake8 --color never -j 1 --max-line-length=250 \ + --ignore=W503,W504,E201,E202,E223,E226 \ + tabulate + +types: + mypy . + +# +# Nix and VirtualEnv build, install and activate +# +# Create, start and run commands in "interactive" shell with a python venv's activate init-file. +# Doesn't allow recursive creation of a venv with a venv-supplied python. Alters the bin/activate +# to include the user's .bashrc (eg. Git prompts, aliases, ...). Use to run Makefile targets in a +# proper context, for example to obtain a Nix environment containing the proper Python version, +# create a python venv with the current Python environment. +# +# make nix-venv-build +# +nix-%: + @if [ -r flake.nix ]; then \ + nix develop $(NIX_OPTS) --command make $*; \ + else \ + nix-shell $(NIX_OPTS) --run "make $*"; \ + fi + +venv-%: $(VENV) + @echo; echo "*** Running in $< VirtualEnv: make $*" + @bash --init-file $&1 || echo 'not available')" + #echo " python3.10: $(python3.10 --version 2>&1 || echo 'not available')" + echo " python3.11: $(python3.11 --version 2>&1 || echo 'not available')" + echo " python3.12: $(python3.12 --version 2>&1 || echo 'not available')" + echo " python3.13: $(python3.13 --version 2>&1 || echo 'not available')" + #echo " python3.14: $(python3.14 --version 2>&1 || echo 'not available')" + echo "" + echo "All versions have pytest and tox installed." + ''; + }; + }); +} diff --git a/nixpkgs.nix b/nixpkgs.nix new file mode 100644 index 00000000..526107e5 --- /dev/null +++ b/nixpkgs.nix @@ -0,0 +1,4 @@ +import (fetchTarball { + url = "https://github.com/NixOS/nixpkgs/archive/refs/tags/25.05.tar.gz"; + sha256 = "1915r28xc4znrh2vf4rrjnxldw2imysz819gzhk9qlrkqanmfsxd"; +}) diff --git a/requirements/dev.txt b/requirements/dev.txt new file mode 100644 index 00000000..70af0f3f --- /dev/null +++ b/requirements/dev.txt @@ -0,0 +1,11 @@ +build +pre-commit +setuptools >=77.0.3 +setuptools_scm[toml] >=3.4.3 +wheel +flake8 +pytest +tox +# for benchmark/benchmark.py +prettytable +texttable From a2cb29ebc12d555e925d7a266941d813cf7574ec Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Sun, 26 Oct 2025 21:14:07 +0400 Subject: [PATCH 3/9] Fix failing wcwidth "wide character" tests by reverting bad ANSI code --- tabulate/__init__.py | 52 +++++++++++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 18 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index e100c097..3e4da13c 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -2738,29 +2738,45 @@ 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: + + # Reverted the broken ANSI code handling stuff to fix wcwidth handling + # - Doesn't use self._lend, infinite loops + # - doesn't locate chunks correctly b/c could be split by ANSI codes + # + # 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] + # # Only count printable characters, so strip_ansi first, index later. + # for i in range( 1, space_left + 1 ): + # if self._len(_strip_ansi(chunk)[:i]) > space_left: + # break + # + # # Consider escape codes when breaking words up + # total_escape_len = 0 + # last_group = 0 + # 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] + # ): + # total_escape_len += escape_len + # found = _ansi_codes.search(chunk[last_group:]) + # last_group += found.end() + # cur_line.append(chunk[: i + total_escape_len - 1]) + # reversed_chunks[-1] = chunk[i + total_escape_len - 1 :] + + 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] i = 1 - # Only count printable characters, so strip_ansi first, index later. - while len(_strip_ansi(chunk)[:i]) <= space_left: + while self._len(chunk[:i]) <= space_left: i = i + 1 - # Consider escape codes when breaking words up - total_escape_len = 0 - last_group = 0 - 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] - ): - total_escape_len += escape_len - found = _ansi_codes.search(chunk[last_group:]) - last_group += found.end() - cur_line.append(chunk[: i + total_escape_len - 1]) - reversed_chunks[-1] = chunk[i + total_escape_len - 1 :] + cur_line.append(chunk[: i - 1]) + reversed_chunks[-1] = chunk[i - 1 :] # Otherwise, we have to preserve the long word intact. Only add # it to the current line if there's nothing already there -- From 4c3de5e16b17d23264378e0f4b4aa987bc5d7f2b Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Sun, 26 Oct 2025 21:28:40 +0400 Subject: [PATCH 4/9] Run release.yml on master and tags; only release on tags --- .github/workflows/release.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0187705e..d8a4cbf4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,12 +2,13 @@ name: Release on: push: + branches: + - master tags: - '*' jobs: pypi-publish: - if: startsWith(github.ref, 'refs/tags') name: Upload release to PyPI runs-on: ubuntu-latest environment: @@ -26,4 +27,5 @@ jobs: python -m pip install setuptools build python -m build -s - name: Publish package distributions to PyPI + if: startsWith(github.ref, 'refs/tags') uses: pypa/gh-action-pypi-publish@release/v1 From a03fd7523698d4b2a9999234d36fcb3c555a2f4d Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Mon, 27 Oct 2025 05:18:14 +0400 Subject: [PATCH 5/9] Clean up lint --- Makefile | 5 ++++- README.md | 4 ++-- flake.nix | 2 +- tabulate/__init__.py | 30 ++++++++++++++++++++++++------ test/test_output.py | 2 ++ 5 files changed, 33 insertions(+), 10 deletions(-) diff --git a/Makefile b/Makefile index 1f877076..37b47ab4 100644 --- a/Makefile +++ b/Makefile @@ -59,6 +59,9 @@ unit-%: test: $(TOX) $(TOX_OPTS) +lint: + python -m pre_commit run -a + bench: $(PYTHON) benchmark/benchmark.py @@ -70,7 +73,7 @@ analyze: types: mypy . -# +# # Nix and VirtualEnv build, install and activate # # Create, start and run commands in "interactive" shell with a python venv's activate init-file. diff --git a/README.md b/README.md index 4d1f5a57..9ada3650 100644 --- a/README.md +++ b/README.md @@ -1071,11 +1071,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/flake.nix b/flake.nix index 4a98800f..6b2e7068 100644 --- a/flake.nix +++ b/flake.nix @@ -10,7 +10,7 @@ flake-utils.lib.eachDefaultSystem (system: let pkgs = nixpkgs.legacyPackages.${system}; - + # Create Python environments with required packages mkPythonEnv = pythonPkg: pythonPkg.withPackages (ps: with ps; [ pytest diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 3e4da13c..9b53e0a4 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1638,7 +1638,13 @@ 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, break_long_words=_BREAK_LONG_WORDS, break_on_hyphens=_BREAK_ON_HYPHENS): +def _wrap_text_to_colwidths( + list_of_lists, + colwidths, + numparses=True, + 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,7 +1661,11 @@ def _wrap_text_to_colwidths(list_of_lists, colwidths, numparses=True, break_long 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, + ) casted_cell = str(cell) wrapped = [ "\n".join(wrapper.wrap(line)) @@ -2258,7 +2268,11 @@ def tabulate( numparses = _expand_numparse(disable_numparse, num_cols) list_of_lists = _wrap_text_to_colwidths( - list_of_lists, maxcolwidths, numparses=numparses, break_long_words=break_long_words, break_on_hyphens=break_on_hyphens + list_of_lists, + maxcolwidths, + numparses=numparses, + break_long_words=break_long_words, + break_on_hyphens=break_on_hyphens, ) if maxheadercolwidths is not None: @@ -2272,7 +2286,11 @@ def tabulate( numparses = _expand_numparse(disable_numparse, num_cols) headers = _wrap_text_to_colwidths( - [headers], maxheadercolwidths, numparses=numparses, break_long_words=break_long_words, break_on_hyphens=break_on_hyphens + [headers], + maxheadercolwidths, + numparses=numparses, + 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) @@ -2742,7 +2760,7 @@ def _handle_long_word(self, reversed_chunks, cur_line, cur_len, width): # Reverted the broken ANSI code handling stuff to fix wcwidth handling # - Doesn't use self._lend, infinite loops # - doesn't locate chunks correctly b/c could be split by ANSI codes - # + # # 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 @@ -2768,7 +2786,7 @@ def _handle_long_word(self, reversed_chunks, cur_line, cur_len, width): # cur_line.append(chunk[: i + total_escape_len - 1]) # reversed_chunks[-1] = chunk[i + total_escape_len - 1 :] - if self.break_long_words: # and space_left > 0: + 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_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"] From c1c2c796c64a738a74042fdf26f7d8eb374a175e Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Mon, 27 Oct 2025 11:21:09 +0400 Subject: [PATCH 6/9] Avoid including extended version number info; causes problems with venv --- Makefile | 2 +- pyproject.toml | 1 + tabulate/__init__.py | 78 +++++++++++++------------------------------- 3 files changed, 24 insertions(+), 57 deletions(-) diff --git a/Makefile b/Makefile index 37b47ab4..c4500a60 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ export TOX_OPTS ?= -e py39-extra,py310-extra,py311-extra,py312-extra,py313-extr export PYTEST ?= $(PYTHON) -m pytest export PYTEST_OPTS ?= # -vv --capture=no -VERSION = $(shell $(PYTHON) -c "exec(open('tabulate/version.py').read()); print('.'.join(map(str, __version_tuple__[:-2])))" ) +VERSION = $(shell $(PYTHON) -c "exec(open('tabulate/version.py').read()); print('.'.join(map(str, __version_tuple__[:3])))" ) VERSION_FULL = $(shell $(PYTHON) -c "exec(open('tabulate/version.py').read()); print(__version__)" ) WHEEL = dist/tabulate_slip39-$(VERSION_FULL)-py3-none-any.whl VENV = $(CURDIR)-$(VERSION)-$(PYTHON_V) diff --git a/pyproject.toml b/pyproject.toml index 3c7c0660..69642760 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,3 +37,4 @@ packages = ["tabulate"] [tool.setuptools_scm] write_to = "tabulate/version.py" +local_scheme = "no-local-version" diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 9b53e0a4..e100c097 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1638,13 +1638,7 @@ 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, - break_long_words=_BREAK_LONG_WORDS, - break_on_hyphens=_BREAK_ON_HYPHENS, -): +def _wrap_text_to_colwidths(list_of_lists, colwidths, numparses=True, 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: @@ -1661,11 +1655,7 @@ def _wrap_text_to_colwidths( 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) casted_cell = str(cell) wrapped = [ "\n".join(wrapper.wrap(line)) @@ -2268,11 +2258,7 @@ def tabulate( numparses = _expand_numparse(disable_numparse, num_cols) list_of_lists = _wrap_text_to_colwidths( - list_of_lists, - maxcolwidths, - numparses=numparses, - break_long_words=break_long_words, - break_on_hyphens=break_on_hyphens, + list_of_lists, maxcolwidths, numparses=numparses, break_long_words=break_long_words, break_on_hyphens=break_on_hyphens ) if maxheadercolwidths is not None: @@ -2286,11 +2272,7 @@ def tabulate( numparses = _expand_numparse(disable_numparse, num_cols) headers = _wrap_text_to_colwidths( - [headers], - maxheadercolwidths, - numparses=numparses, - break_long_words=break_long_words, - break_on_hyphens=break_on_hyphens, + [headers], maxheadercolwidths, numparses=numparses, 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) @@ -2756,45 +2738,29 @@ 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. - - # Reverted the broken ANSI code handling stuff to fix wcwidth handling - # - Doesn't use self._lend, infinite loops - # - doesn't locate chunks correctly b/c could be split by ANSI codes - # - # 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] - # # Only count printable characters, so strip_ansi first, index later. - # for i in range( 1, space_left + 1 ): - # if self._len(_strip_ansi(chunk)[:i]) > space_left: - # break - # - # # Consider escape codes when breaking words up - # total_escape_len = 0 - # last_group = 0 - # 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] - # ): - # total_escape_len += escape_len - # found = _ansi_codes.search(chunk[last_group:]) - # last_group += found.end() - # cur_line.append(chunk[: i + total_escape_len - 1]) - # reversed_chunks[-1] = chunk[i + total_escape_len - 1 :] - - if self.break_long_words: # and space_left > 0: + if self.break_long_words: # Tabulate Custom: Build the string up piece-by-piece in order to # take each charcter's width into account chunk = reversed_chunks[-1] i = 1 - while self._len(chunk[:i]) <= space_left: + # Only count printable characters, so strip_ansi first, index later. + while len(_strip_ansi(chunk)[:i]) <= space_left: i = i + 1 - cur_line.append(chunk[: i - 1]) - reversed_chunks[-1] = chunk[i - 1 :] + # Consider escape codes when breaking words up + total_escape_len = 0 + last_group = 0 + 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] + ): + total_escape_len += escape_len + found = _ansi_codes.search(chunk[last_group:]) + last_group += found.end() + cur_line.append(chunk[: i + total_escape_len - 1]) + reversed_chunks[-1] = chunk[i + total_escape_len - 1 :] # Otherwise, we have to preserve the long word intact. Only add # it to the current line if there's nothing already there -- From 2692e1afb54caba9b668cbf8bc5efe75b9422843 Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Mon, 27 Oct 2025 11:24:53 +0400 Subject: [PATCH 7/9] Fix and test long word wrapping o Tests pass with/without wcwidth module installed o Include some linting changes --- tabulate/__init__.py | 88 +++++++++++++++++++++------------------- test/test_textwrapper.py | 35 ++++++++++++++++ 2 files changed, 81 insertions(+), 42 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 3e4da13c..909293cf 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1638,7 +1638,13 @@ 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, break_long_words=_BREAK_LONG_WORDS, break_on_hyphens=_BREAK_ON_HYPHENS): +def _wrap_text_to_colwidths( + list_of_lists, + colwidths, + numparses=True, + 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,7 +1661,11 @@ def _wrap_text_to_colwidths(list_of_lists, colwidths, numparses=True, break_long 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, + ) casted_cell = str(cell) wrapped = [ "\n".join(wrapper.wrap(line)) @@ -2258,7 +2268,11 @@ def tabulate( numparses = _expand_numparse(disable_numparse, num_cols) list_of_lists = _wrap_text_to_colwidths( - list_of_lists, maxcolwidths, numparses=numparses, break_long_words=break_long_words, break_on_hyphens=break_on_hyphens + list_of_lists, + maxcolwidths, + numparses=numparses, + break_long_words=break_long_words, + break_on_hyphens=break_on_hyphens, ) if maxheadercolwidths is not None: @@ -2272,7 +2286,11 @@ def tabulate( numparses = _expand_numparse(disable_numparse, num_cols) headers = _wrap_text_to_colwidths( - [headers], maxheadercolwidths, numparses=numparses, break_long_words=break_long_words, break_on_hyphens=break_on_hyphens + [headers], + maxheadercolwidths, + numparses=numparses, + 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) @@ -2737,46 +2755,32 @@ def _handle_long_word(self, reversed_chunks, cur_line, cur_len, width): space_left = width - cur_len # 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. - - # Reverted the broken ANSI code handling stuff to fix wcwidth handling - # - Doesn't use self._lend, infinite loops - # - doesn't locate chunks correctly b/c could be split by ANSI codes - # - # 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] - # # Only count printable characters, so strip_ansi first, index later. - # for i in range( 1, space_left + 1 ): - # if self._len(_strip_ansi(chunk)[:i]) > space_left: - # break - # - # # Consider escape codes when breaking words up - # total_escape_len = 0 - # last_group = 0 - # 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] - # ): - # total_escape_len += escape_len - # found = _ansi_codes.search(chunk[last_group:]) - # last_group += found.end() - # cur_line.append(chunk[: i + total_escape_len - 1]) - # reversed_chunks[-1] = chunk[i + total_escape_len - 1 :] - - if self.break_long_words: # and space_left > 0: + # of the next chunk onto the current line as will fit. Be careful + # of empty chunks after ANSI codes removed. + chunk = reversed_chunks[-1] + chunk_noansi = _strip_ansi(chunk) + if self.break_long_words and chunk_noansi: # Tabulate Custom: Build the string up piece-by-piece in order to # take each charcter's width into account - chunk = reversed_chunks[-1] - i = 1 - while self._len(chunk[:i]) <= space_left: - i = i + 1 - cur_line.append(chunk[: i - 1]) - reversed_chunks[-1] = chunk[i - 1 :] + # Only count printable characters, so strip_ansi first, index later. + for i in range(1, len(chunk_noansi) + 1): + if self._len(chunk_noansi[:i]) > space_left: + break + # Consider escape codes when breaking words up + total_escape_len = 0 + last_group = 0 + 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] + ): + total_escape_len += escape_len + found = _ansi_codes.search(chunk[last_group:]) + last_group += found.end() + cur_line.append(chunk[: i + total_escape_len - 1]) + reversed_chunks[-1] = chunk[i + total_escape_len - 1 :] # Otherwise, we have to preserve the long word intact. Only add # it to the current line if there's nothing already there -- diff --git a/test/test_textwrapper.py b/test/test_textwrapper.py index 46dd818d..ce1b75c3 100644 --- a/test/test_textwrapper.py +++ b/test/test_textwrapper.py @@ -176,6 +176,41 @@ def test_wrap_color_line_longword(): assert_equal(expected, result) +def test_wrap_color_line_longword_zerowidth(): + """Lines with zero-width symbols (eg. 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_\u200bto_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 abe2989df1acfcae2ce1b3d0c92b05a17b6766eb Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Mon, 27 Oct 2025 11:36:19 +0400 Subject: [PATCH 8/9] Fix some line length issues --- test/test_textwrapper.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/test_textwrapper.py b/test/test_textwrapper.py index ce1b75c3..c0aa4c6e 100644 --- a/test/test_textwrapper.py +++ b/test/test_textwrapper.py @@ -177,7 +177,7 @@ def test_wrap_color_line_longword(): def test_wrap_color_line_longword_zerowidth(): - """Lines with zero-width symbols (eg. accents) must include those symbols with the prior symbol. + """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. @@ -199,7 +199,10 @@ def test_wrap_color_line_longword_zerowidth(): 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_\u200bto_te\u0301\u0308st_a\u0308ccent\033[32m\u200b\033[0m" + 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", From 53e57fc812d9004154054e72454e28b1df27bbc3 Mon Sep 17 00:00:00 2001 From: Perry Kundert Date: Mon, 27 Oct 2025 12:59:35 +0400 Subject: [PATCH 9/9] Enable manual nix-build to force regeneration of tabulate/version.py --- Makefile | 12 +++++++----- flake.nix | 2 ++ pyproject.toml | 1 - 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/Makefile b/Makefile index c4500a60..1dad350b 100644 --- a/Makefile +++ b/Makefile @@ -31,17 +31,19 @@ help: @echo " venv Create and start a Python venv using the available Python interpreter" @echo " venv-... Make a target using the venv environment" @echo - @echo "For example, to run tox tests in a Nix-supplied Python venv:" + @echo "For example, to build, create venv run tox tests in a Nix-supplied Python:" @echo - @echo " make nix-venv-test" + @echo " make nix-build nix-venv-test" @echo -.PHONY: help wheel install test bench analyze types venv Makefile FORCE +.PHONY: help wheel build install test bench analyze types venv Makefile FORCE wheel: $(WHEEL) -$(WHEEL): FORCE - $(PYTHON) -m build +$(WHEEL): build FORCE + +build: + $(PYTHON) -m build . @ls -last dist # Install from wheel, including all optional extra dependencies (doesn't include dev) diff --git a/flake.nix b/flake.nix index 6b2e7068..40b081f1 100644 --- a/flake.nix +++ b/flake.nix @@ -13,6 +13,8 @@ # Create Python environments with required packages mkPythonEnv = pythonPkg: pythonPkg.withPackages (ps: with ps; [ + pip + build pytest tox numpy diff --git a/pyproject.toml b/pyproject.toml index 69642760..3c7c0660 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,4 +37,3 @@ packages = ["tabulate"] [tool.setuptools_scm] write_to = "tabulate/version.py" -local_scheme = "no-local-version"