diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d8a4cbf --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,31 @@ +name: Release + +on: + push: + branches: + - master + tags: + - '*' + +jobs: + pypi-publish: + 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 + if: startsWith(github.ref, 'refs/tags') + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/HOWTOPUBLISH b/HOWTOPUBLISH index 29c4545..bd78db2 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/Makefile b/Makefile new file mode 100644 index 0000000..1dad350 --- /dev/null +++ b/Makefile @@ -0,0 +1,114 @@ +# 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__[: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) + +# 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 build, create venv run tox tests in a Nix-supplied Python:" + @echo + @echo " make nix-build nix-venv-test" + @echo + +.PHONY: help wheel build install test bench analyze types venv Makefile FORCE + +wheel: $(WHEEL) + +$(WHEEL): build FORCE + +build: + $(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) + +lint: + python -m pre_commit run -a + +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 $ 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 |==== ``` @@ -1065,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.lock b/flake.lock new file mode 100644 index 0000000..bcd8285 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1748026580, + "narHash": "sha256-rWtXrcIzU5wm/C8F9LWvUfBGu5U5E7cFzPYT1pHIJaQ=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "11cb3517b3af6af300dd6c055aeda73c9bf52c48", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "25.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..40b081f --- /dev/null +++ b/flake.nix @@ -0,0 +1,65 @@ +{ + description = "Python HD Wallet development environment with multiple Python versions"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/25.05"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + + # Create Python environments with required packages + mkPythonEnv = pythonPkg: pythonPkg.withPackages (ps: with ps; [ + pip + build + pytest + tox + numpy + pandas + wcwidth + ]); + + python310Env = mkPythonEnv pkgs.python310; + python311Env = mkPythonEnv pkgs.python311; + python312Env = mkPythonEnv pkgs.python312; + python313Env = mkPythonEnv pkgs.python313; + python314Env = mkPythonEnv pkgs.python314; + + in { + # Single development shell with all Python versions + devShells.default = pkgs.mkShell { + buildInputs = with pkgs; [ + # Common tools + cacert + git + gnumake + openssh + bash + bash-completion + + # All Python versions with packages + #python310Env + python311Env + python312Env + python313Env + #python314Env + ]; + + shellHook = '' + echo "Welcome to the multi-Python development environment!" + echo "Available Python interpreters:" + echo " python (default): $(python --version 2>&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 0000000..526107e --- /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/pyproject.toml b/pyproject.toml index d13e92d..3c7c066 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/requirements/dev.txt b/requirements/dev.txt new file mode 100644 index 0000000..70af0f3 --- /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 diff --git a/tabulate/__init__.py b/tabulate/__init__.py index e100c09..909293c 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,15 +2755,17 @@ 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. - if self.break_long_words: + # 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 # Only count printable characters, so strip_ansi first, index later. - while len(_strip_ansi(chunk)[:i]) <= space_left: - i = i + 1 + 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 diff --git a/test/test_output.py b/test/test_output.py index 12dfc3a..d7c225b 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 46dd818..c0aa4c6 100644 --- a/test/test_textwrapper.py +++ b/test/test_textwrapper.py @@ -176,6 +176,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 = [ diff --git a/tox.ini b/tox.ini index 9605e79..51f0100 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}