From 4a8778008b984b93762f2b6f4cefb9443905a798 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Thu, 21 Dec 2023 12:12:43 +0200 Subject: [PATCH 1/4] Add support for Python 3.12 --- appveyor.yml | 1 + tox.ini | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/appveyor.yml b/appveyor.yml index 4eb2dd8..318a1d6 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -16,6 +16,7 @@ environment: - PYTHON: "C:\\Python39-x64" - PYTHON: "C:\\Python310-x64" - PYTHON: "C:\\Python311-x64" + - PYTHON: "C:\\Python312-x64" install: # Newer setuptools is needed for proper support of pyproject.toml diff --git a/tox.ini b/tox.ini index c6260d2..4c4fe06 100644 --- a/tox.ini +++ b/tox.ini @@ -8,7 +8,7 @@ # for testing and it is disabled by default. [tox] -envlist = lint, py{37, 38, 39, 310, 311} +envlist = lint, py{37, 38, 39, 310, 311, 312} isolated_build = True [testenv] @@ -105,6 +105,21 @@ deps = pandas wcwidth +[testenv:py312] +basepython = python3.12 +commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} +deps = + pytest + +[testenv:py312-extra] +basepython = python3.12 +setenv = PYTHONDEVMODE = 1 +commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} +deps = + pytest + numpy + pandas + wcwidth [flake8] max-complexity = 22 From 733be92058815b4295d8ed5858fa14d7b0aad890 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Thu, 21 Dec 2023 12:18:54 +0200 Subject: [PATCH 2/4] Drop support for EOL Python 3.7 --- HOWTOPUBLISH | 2 +- README.md | 16 ++++++++-------- appveyor.yml | 9 +-------- pyproject.toml | 3 +-- tabulate/__init__.py | 6 +++--- tox.ini | 17 +---------------- 6 files changed, 15 insertions(+), 38 deletions(-) diff --git a/HOWTOPUBLISH b/HOWTOPUBLISH index 5be16ed..795fc73 100644 --- a/HOWTOPUBLISH +++ b/HOWTOPUBLISH @@ -1,7 +1,7 @@ # update contributors and CHANGELOG in README # tag version release python3 benchmark.py # then update README -tox -e py37-extra,py38-extra,py39-extra,py310-extra +tox -e py38-extra,py39-extra,py310-extra,py311-extra,py312-extra python3 -m build -nswx . twine upload --repository-url https://test.pypi.org/legacy/ dist/* twine upload dist/* diff --git a/README.md b/README.md index 07ab28c..944a260 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ The following tabular data types are supported: - list of lists or another iterable of iterables - list or another iterable of dicts (keys as columns) - dict of iterables (keys as columns) -- list of dataclasses (Python 3.7+ only, field names as columns) +- list of dataclasses (field names as columns) - two-dimensional NumPy array - NumPy record arrays (names as columns) - pandas.DataFrame @@ -1074,14 +1074,14 @@ To run tests on all supported Python versions, make sure all Python interpreters, `pytest` and `tox` are installed, then run `tox` in the root of the project source tree. -On Linux `tox` expects to find executables like `python3.7`, `python3.8` etc. -On Windows it looks for `C:\Python37\python.exe`, `C:\Python38\python.exe` etc. respectively. +On Linux `tox` expects to find executables like `python3.11`, `python3.12` etc. +On Windows it looks for `C:\Python311\python.exe`, `C:\Python312\python.exe` etc. respectively. One way to install all the required versions of the Python interpreter is to use [pyenv](https://github.com/pyenv/pyenv). All versions can then be easily installed with something like: - pyenv install 3.7.12 - pyenv install 3.8.12 + pyenv install 3.11.7 + pyenv install 3.12.1 ... Don't forget to change your `PATH` so that `tox` knows how to find all the installed versions. Something like @@ -1089,10 +1089,10 @@ Don't forget to change your `PATH` so that `tox` knows how to find all the insta export PATH="${PATH}:${HOME}/.pyenv/shims" To test only some Python environments, use `-e` option. For example, to -test only against Python 3.7 and Python 3.10, run: +test only against Python 3.11 and Python 3.12, run: ```shell -tox -e py37,py310 +tox -e py311,py312 ``` in the root of the project source tree. @@ -1100,7 +1100,7 @@ in the root of the project source tree. To enable NumPy and Pandas tests, run: ```shell -tox -e py37-extra,py310-extra +tox -e py311-extra,py312-extra ``` (this may take a long time the first time, because NumPy and Pandas will diff --git a/appveyor.yml b/appveyor.yml index 318a1d6..d36b8c9 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -8,10 +8,8 @@ environment: # The list here is complete (excluding Python 2.6, which # isn't covered by this document) at the time of writing. - - PYTHON: "C:\\Python37" - PYTHON: "C:\\Python38" - PYTHON: "C:\\Python39" - - PYTHON: "C:\\Python37-x64" - PYTHON: "C:\\Python38-x64" - PYTHON: "C:\\Python39-x64" - PYTHON: "C:\\Python310-x64" @@ -30,9 +28,6 @@ build: off test_script: # Put your test command here. - # If you don't need to build C extensions on 64-bit Python 3.3 or 3.4, - # you can remove "build.cmd" from the front of the command, as it's - # only needed to support those cases. # Note that you must use the environment variable %PYTHON% to refer to # the interpreter you're using - Appveyor does not do anything special # to put the Python version you want to use on PATH. @@ -41,9 +36,7 @@ test_script: after_test: # This step builds your wheels. - # Again, you only need build.cmd if you're building C extensions for - # 64-bit Python 3.3/3.4. And you need to use %PYTHON% to get the correct - # interpreter + # Again, you need to use %PYTHON% to get the correct interpreter #- "build.cmd %PYTHON%\\python.exe setup.py bdist_wheel" - "%PYTHON%\\python.exe -m build -nswx ." diff --git a/pyproject.toml b/pyproject.toml index 5a8c1fd..837747d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,14 +13,13 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Topic :: Software Development :: Libraries", ] -requires-python = ">=3.7" +requires-python = ">=3.8" dynamic = ["version"] [project.urls] diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 11bb865..dbbe45d 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1331,7 +1331,7 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): * list of OrderedDicts (usually used with headers="keys") - * list of dataclasses (Python 3.7+ only, usually used with headers="keys") + * list of dataclasses (usually used with headers="keys") * 2D NumPy arrays @@ -1457,7 +1457,7 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): and len(rows) > 0 and dataclasses.is_dataclass(rows[0]) ): - # Python 3.7+'s dataclass + # Python's dataclass field_names = [field.name for field in dataclasses.fields(rows[0])] if headers == "keys": headers = field_names @@ -1600,7 +1600,7 @@ def tabulate( The first required argument (`tabular_data`) can be a list-of-lists (or another iterable of iterables), a list of named tuples, a dictionary of iterables, an iterable of dictionaries, - an iterable of dataclasses (Python 3.7+), a two-dimensional NumPy array, + an iterable of dataclasses, a two-dimensional NumPy array, NumPy record array, or a Pandas' dataframe. diff --git a/tox.ini b/tox.ini index 4c4fe06..7fd65a7 100644 --- a/tox.ini +++ b/tox.ini @@ -8,7 +8,7 @@ # for testing and it is disabled by default. [tox] -envlist = lint, py{37, 38, 39, 310, 311, 312} +envlist = lint, py{38, 39, 310, 311, 312} isolated_build = True [testenv] @@ -25,21 +25,6 @@ commands = python -m pre_commit run -a deps = pre-commit -[testenv:py37] -basepython = python3.7 -commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} -deps = - pytest - -[testenv:py37-extra] -basepython = python3.7 -commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} -deps = - pytest - numpy - pandas - wcwidth - [testenv:py38] basepython = python3.8 commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} From 36fb3088a85c0855a0985a2f2206ed872e0c71e1 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Thu, 21 Dec 2023 12:21:58 +0200 Subject: [PATCH 3/4] Fix Black and Flake8 --- tabulate/__init__.py | 41 +++++++++++++----- test/common.py | 3 +- test/test_output.py | 98 +++++++++++++++++++++++++------------------- 3 files changed, 87 insertions(+), 55 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index dbbe45d..4b4cf2c 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1271,7 +1271,7 @@ def _align_header( def _remove_separating_lines(rows): - if type(rows) == list: + if isinstance(rows, list): separating_lines = [] sans_rows = [] for index, row in enumerate(rows): @@ -1319,7 +1319,8 @@ def _bool(val): def _normalize_tabular_data(tabular_data, headers, showindex="default"): - """Transform a supported data type to a list of lists, and a list of headers, with headers padding. + """Transform a supported data type to a list of lists, and a list of headers, + with headers padding. Supported tabular data types: @@ -2202,15 +2203,19 @@ def tabulate( # align columns # first set global alignment - if colglobalalign is not None: # if global alignment provided + if colglobalalign is not None: # if global alignment provided aligns = [colglobalalign] * len(cols) - else: # default + else: # default aligns = [numalign if ct in [int, float] else stralign for ct in coltypes] # then specific alignements if colalign is not None: assert isinstance(colalign, Iterable) if isinstance(colalign, str): - warnings.warn(f"As a string, `colalign` is interpreted as {[c for c in colalign]}. Did you mean `colglobalalign = \"{colalign}\"` or `colalign = (\"{colalign}\",)`?", stacklevel=2) + warnings.warn( + f"As a string, `colalign` is interpreted as {[c for c in colalign]}. " + f'Did you mean `colglobalalign = "{colalign}"` or `colalign = ("{colalign}",)`?', + stacklevel=2, + ) for idx, align in enumerate(colalign): if not idx < len(aligns): break @@ -2229,20 +2234,25 @@ def tabulate( # align headers and add headers t_cols = cols or [[""]] * len(headers) # first set global alignment - if headersglobalalign is not None: # if global alignment provided + if headersglobalalign is not None: # if global alignment provided aligns_headers = [headersglobalalign] * len(t_cols) - else: # default + else: # default aligns_headers = aligns or [stralign] * len(headers) # then specific header alignements if headersalign is not None: assert isinstance(headersalign, Iterable) if isinstance(headersalign, str): - warnings.warn(f"As a string, `headersalign` is interpreted as {[c for c in headersalign]}. Did you mean `headersglobalalign = \"{headersalign}\"` or `headersalign = (\"{headersalign}\",)`?", stacklevel=2) + warnings.warn( + f"As a string, `headersalign` is interpreted as {[c for c in headersalign]}. " + f'Did you mean `headersglobalalign = "{headersalign}"` ' + f'or `headersalign = ("{headersalign}",)`?', + stacklevel=2, + ) for idx, align in enumerate(headersalign): hidx = headers_pad + idx if not hidx < len(aligns_headers): break - elif align == "same" and hidx < len(aligns): # same as column align + elif align == "same" and hidx < len(aligns): # same as column align aligns_headers[hidx] = aligns[hidx] elif align != "global": aligns_headers[hidx] = align @@ -2267,7 +2277,14 @@ def tabulate( _reinsert_separating_lines(rows, separating_lines) return _format_table( - tablefmt, headers, aligns_headers, rows, minwidths, aligns, is_multiline, rowaligns=rowaligns + tablefmt, + headers, + aligns_headers, + rows, + minwidths, + aligns, + is_multiline, + rowaligns=rowaligns, ) @@ -2398,7 +2415,9 @@ def str(self): return self -def _format_table(fmt, headers, headersaligns, rows, colwidths, colaligns, is_multiline, rowaligns): +def _format_table( + fmt, headers, headersaligns, rows, colwidths, colaligns, is_multiline, rowaligns +): """Produce a plain-text representation of the table.""" lines = [] hidden = fmt.with_header_hide if (headers and fmt.with_header_hide) else [] diff --git a/test/common.py b/test/common.py index 4cd3709..fe33259 100644 --- a/test/common.py +++ b/test/common.py @@ -2,6 +2,7 @@ from pytest import skip, raises # noqa import warnings + def assert_equal(expected, result): print("Expected:\n%s\n" % expected) print("Got:\n%s\n" % result) @@ -28,6 +29,7 @@ def rows_to_pipe_table_str(rows): return "\n".join(lines) + def check_warnings(func_args_kwargs, *, num=None, category=None, contain=None): func, args, kwargs = func_args_kwargs with warnings.catch_warnings(record=True) as W: @@ -41,4 +43,3 @@ def check_warnings(func_args_kwargs, *, num=None, category=None, contain=None): 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]) - diff --git a/test/test_output.py b/test/test_output.py index d572498..8a60809 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -2680,60 +2680,72 @@ def test_colalign_multi_with_sep_line(): expected = " one two\n\nthree four" assert_equal(expected, result) + def test_column_global_and_specific_alignment(): - """ Test `colglobalalign` and `"global"` parameter for `colalign`. """ - table = [[1,2,3,4],[111,222,333,444]] - colglobalalign = 'center' - colalign = ('global','left', 'right') + """Test `colglobalalign` and `"global"` parameter for `colalign`.""" + table = [[1, 2, 3, 4], [111, 222, 333, 444]] + colglobalalign = "center" + colalign = ("global", "left", "right") result = tabulate(table, colglobalalign=colglobalalign, colalign=colalign) - expected = '\n'.join([ - "--- --- --- ---", - " 1 2 3 4", - "111 222 333 444", - "--- --- --- ---"]) + expected = "\n".join( + [ + "--- --- --- ---", + " 1 2 3 4", + "111 222 333 444", + "--- --- --- ---", + ] + ) assert_equal(expected, result) + def test_headers_global_and_specific_alignment(): - """ Test `headersglobalalign` and `headersalign`. """ - table = [[1,2,3,4,5,6],[111,222,333,444,555,666]] - colglobalalign = 'center' - colalign = ('left',) - headers = ['h', 'e', 'a', 'd', 'e', 'r'] - headersglobalalign = 'right' - headersalign = ('same', 'same', 'left', 'global', 'center') - result = tabulate(table, headers=headers, colglobalalign=colglobalalign, colalign=colalign, headersglobalalign=headersglobalalign, headersalign=headersalign) - expected = '\n'.join([ - "h e a d e r", - "--- --- --- --- --- ---", - "1 2 3 4 5 6", - "111 222 333 444 555 666"]) + """Test `headersglobalalign` and `headersalign`.""" + table = [[1, 2, 3, 4, 5, 6], [111, 222, 333, 444, 555, 666]] + colglobalalign = "center" + colalign = ("left",) + headers = ["h", "e", "a", "d", "e", "r"] + headersglobalalign = "right" + headersalign = ("same", "same", "left", "global", "center") + result = tabulate( + table, + headers=headers, + colglobalalign=colglobalalign, + colalign=colalign, + headersglobalalign=headersglobalalign, + headersalign=headersalign, + ) + expected = "\n".join( + [ + "h e a d e r", + "--- --- --- --- --- ---", + "1 2 3 4 5 6", + "111 222 333 444 555 666", + ] + ) assert_equal(expected, result) + def test_colalign_or_headersalign_too_long(): - """ Test `colalign` and `headersalign` too long. """ - table = [[1,2],[111,222]] - colalign = ('global', 'left', 'center') - headers = ['h'] - headersalign = ('center', 'right', 'same') - result = tabulate(table, headers=headers, colalign=colalign, headersalign=headersalign) - expected = '\n'.join([ - " h", - "--- ---", - " 1 2", - "111 222"]) + """Test `colalign` and `headersalign` too long.""" + table = [[1, 2], [111, 222]] + colalign = ("global", "left", "center") + headers = ["h"] + headersalign = ("center", "right", "same") + result = tabulate( + table, headers=headers, colalign=colalign, headersalign=headersalign + ) + expected = "\n".join([" h", "--- ---", " 1 2", "111 222"]) assert_equal(expected, result) + 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") + """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" + ) + def test_float_conversions(): "Output: float format parsed" From a37d34af8ea72a6b3a5afb18862fe2dedb391690 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade Date: Thu, 21 Dec 2023 12:22:26 +0200 Subject: [PATCH 4/4] Upgrade Python syntax with pyupgrade --py38-plus --- tabulate/__init__.py | 4 ++-- test/test_input.py | 2 +- test/test_textwrapper.py | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 4b4cf2c..6adacde 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -224,7 +224,7 @@ def make_header_line(is_header, colwidths, colaligns): colwidths, [alignment[colalign] for colalign in colaligns] ) asciidoc_column_specifiers = [ - "{:d}{}".format(width, align) for width, align in asciidoc_alignments + f"{width:d}{align}" for width, align in asciidoc_alignments ] header_list = ['cols="' + (",".join(asciidoc_column_specifiers)) + '"'] @@ -1297,7 +1297,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: " - + "len(index)={} len(rows)={}".format(len(index), len(rows)) + + f"len(index)={len(index)} len(rows)={len(rows)}" ) sans_rows, separating_lines = _remove_separating_lines(rows) new_rows = [] diff --git a/test/test_input.py b/test/test_input.py index a178bd9..721d03a 100644 --- a/test/test_input.py +++ b/test/test_input.py @@ -522,7 +522,7 @@ def test_py37orlater_list_of_dataclasses_headers(): def test_list_bytes(): "Input: a list of bytes. (issue #192)" - lb = [["你好".encode("utf-8")], ["你好"]] + lb = [["你好".encode()], ["你好"]] expected = "\n".join( ["bytes", "---------------------------", r"b'\xe4\xbd\xa0\xe5\xa5\xbd'", "你好"] ) diff --git a/test/test_textwrapper.py b/test/test_textwrapper.py index f3070b1..c8feded 100644 --- a/test/test_textwrapper.py +++ b/test/test_textwrapper.py @@ -1,5 +1,4 @@ """Discretely test functionality of our custom TextWrapper""" -from __future__ import unicode_literals import datetime