diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index ac9c525..0000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,60 +0,0 @@ -# Python CircleCI 2.0 configuration file -# -# Check https://circleci.com/docs/2.0/language-python/ for more details -# -version: 2 -jobs: - build: - docker: - # specify the version you desire here - # use `-browsers` prefix for selenium tests, e.g. `3.6.1-browsers` - - image: circleci/python:3.8 - - # Specify service dependencies here if necessary - # CircleCI maintains a library of pre-built images - # documented at https://circleci.com/docs/2.0/circleci-images/ - # - image: circleci/postgres:9.4 - - working_directory: ~/repo - - steps: - - checkout - - # Download and cache dependencies - - restore_cache: - keys: - - v1-dependencies-{{ checksum ".circleci/requirements.txt" }} - # fallback to using the latest cache if no exact match is found - - v1-dependencies- - - - run: - name: install dependencies - command: | - python3 -m venv venv - . venv/bin/activate - pip install -r .circleci/requirements.txt - - - save_cache: - paths: - - ./venv - key: v1-dependencies-{{ checksum ".circleci/requirements.txt" }} - - - run: - name: build wheel - command: | - . venv/bin/activate - python -m build -nwx . - - - store_artifacts: - path: dist - destination: dist - - - run: - name: run tests - command: | - . venv/bin/activate - tox -e py38-extra - - - store_artifacts: - path: test-reports - destination: test-reports diff --git a/.circleci/requirements.txt b/.circleci/requirements.txt deleted file mode 100644 index ea5e80c..0000000 --- a/.circleci/requirements.txt +++ /dev/null @@ -1,10 +0,0 @@ -pytest -tox -numpy -pandas -wcwidth -setuptools -pip -build -wheel -setuptools_scm diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..0093303 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,26 @@ +name: lint + +on: + - push + - pull_request + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.12'] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install tox tox-gh + - name: Run linters + run: | + tox -e lint diff --git a/.github/workflows/tabulate.yml b/.github/workflows/tabulate.yml new file mode 100644 index 0000000..c459484 --- /dev/null +++ b/.github/workflows/tabulate.yml @@ -0,0 +1,28 @@ +name: pytest + +on: + - push + - pull_request + +jobs: + build: + strategy: + matrix: + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] + os: ["ubuntu-latest", "windows-latest", "macos-latest"] + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install pytest numpy pandas + - name: Run tests + run: | + pytest -v --doctest-modules --ignore benchmark/benchmark.py diff --git a/CHANGELOG b/CHANGELOG index a18a5a1..2737441 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,4 +1,10 @@ -- 0.9.1: Future version. +- 0.10.0: Add support for Python 3.11, 3.12, 3.13. + Drop support for Python 3.7, 3.8. + 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) + Various bug fixes. + Improved error messages. - 0.9.0: Drop support for Python 2.7, 3.5, 3.6. Migrate to pyproject.toml project layout (PEP 621). New output formats: `asciidoc`, various `*grid` and `*outline` formats. diff --git a/HOWTOPUBLISH b/HOWTOPUBLISH index 5be16ed..29c4545 100644 --- a/HOWTOPUBLISH +++ b/HOWTOPUBLISH @@ -1,7 +1,15 @@ # 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 # tag version release -python3 benchmark.py # then update README -tox -e py37-extra,py38-extra,py39-extra,py310-extra -python3 -m build -nswx . +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 -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 d64b99a..f3d0fa9 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ pip install tabulate Build status ------------ -[![Build status](https://circleci.com/gh/astanin/python-tabulate.svg?style=svg)](https://circleci.com/gh/astanin/python-tabulate/tree/master) [![Build status](https://ci.appveyor.com/api/projects/status/8745yksvvol7h3d7/branch/master?svg=true)](https://ci.appveyor.com/project/astanin/python-tabulate/branch/master) +[![python-tabulate](https://github.com/astanin/python-tabulate/actions/workflows/tabulate.yml/badge.svg)](https://github.com/astanin/python-tabulate/actions/workflows/tabulate.yml) Library usage ------------- @@ -74,6 +74,7 @@ Earth 6371 5973.6 Moon 1737 73.5 Mars 3390 641.85 ----- ------ ------------- + ``` The following tabular data types are supported: @@ -81,7 +82,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 @@ -101,6 +102,7 @@ Sun 696000 1.9891e+09 Earth 6371 5973.6 Moon 1737 73.5 Mars 3390 641.85 + ``` If `headers="firstrow"`, then the first row of data is used: @@ -112,6 +114,7 @@ Name Age ------ ----- Alice 24 Bob 19 + ``` If `headers="keys"`, then the keys of a dictionary/dataframe, or column @@ -121,10 +124,24 @@ dictionaries or named tuples: ```pycon >>> print(tabulate({"Name": ["Alice", "Bob"], ... "Age": [24, 19]}, headers="keys")) - Age Name ------ ------ - 24 Alice - 19 Bob +Name Age +------ ----- +Alice 24 +Bob 19 + +``` + +When data is a list of dictionaries, a dictionary can be passed as `headers` +to replace the keys with other column labels: + +```pycon +>>> print(tabulate([{1: "Alice", 2: 24}, {1: "Bob", 2: 19}], +... headers={1: "Name", 2: "Age"})) +Name Age +------ ----- +Alice 24 +Bob 19 + ``` ### Row Indices @@ -142,6 +159,7 @@ or `showindex=False`. To add a custom row index column, pass 0 F 24 1 M 19 - - -- + ``` ### Table format @@ -198,6 +216,7 @@ item qty spam 42 eggs 451 bacon 0 + ``` `simple` is the default format (the default may change in future @@ -211,6 +230,7 @@ item qty spam 42 eggs 451 bacon 0 + ``` `github` follows the conventions of GitHub flavored Markdown. It @@ -218,11 +238,12 @@ corresponds to the `pipe` format without alignment colons: ```pycon >>> print(tabulate(table, headers, tablefmt="github")) -| item | qty | +| item | qty | |--------|-------| -| spam | 42 | -| eggs | 451 | -| bacon | 0 | +| spam | 42 | +| eggs | 451 | +| bacon | 0 | + ``` `grid` is like tables formatted by Emacs' @@ -240,6 +261,7 @@ corresponds to the `pipe` format without alignment colons: +--------+-------+ | bacon | 0 | +--------+-------+ + ``` `simple_grid` draws a grid using single-line box-drawing characters: @@ -321,8 +343,25 @@ corresponds to the `pipe` format without alignment colons: ├────────┼───────┤ │ bacon │ 0 │ ╘════════╧═══════╛ + ``` +`colon_grid` is similar to `grid` but uses colons only to define +columnwise content alignment , without whitespace padding, +similar the alignment specification of Pandoc `grid_tables`: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "colon_grid", + ... colalign=["right", "left"])) + +-----------+-----------+ + | strings | numbers | + +==========:+:==========+ + | spam | 41.9999 | + +-----------+-----------+ + | eggs | 451 | + +-----------+-----------+ + + `outline` is the same as the `grid` format but doesn't draw lines between rows: >>> print(tabulate(table, headers, tablefmt="outline")) @@ -409,6 +448,7 @@ corresponds to the `pipe` format without alignment colons: spam | 42 eggs | 451 bacon | 0 + ``` `pretty` attempts to be close to the format emitted by the PrettyTables @@ -423,6 +463,7 @@ library: | eggs | 451 | | bacon | 0 | +-------+-----+ + ``` `psql` is like tables formatted by Postgres' psql cli: @@ -436,6 +477,7 @@ library: | eggs | 451 | | bacon | 0 | +--------+-------+ + ``` `pipe` follows the conventions of [PHP Markdown @@ -450,6 +492,7 @@ indicate column alignment: | spam | 42 | | eggs | 451 | | bacon | 0 | + ``` `asciidoc` formats data like a simple table of the @@ -460,11 +503,12 @@ 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 |==== + ``` `orgtbl` follows the conventions of Emacs @@ -478,6 +522,7 @@ in the minor orgtbl-mode. Hence its name: | spam | 42 | | eggs | 451 | | bacon | 0 | + ``` `jira` follows the conventions of Atlassian Jira markup language: @@ -488,6 +533,7 @@ in the minor orgtbl-mode. Hence its name: | spam | 42 | | eggs | 451 | | bacon | 0 | + ``` `rst` formats data like a simple table of the @@ -503,6 +549,7 @@ spam 42 eggs 451 bacon 0 ====== ===== + ``` `mediawiki` format produces a table markup used in @@ -522,6 +569,7 @@ MediaWiki-based sites: |- | bacon || style="text-align: right;"| 0 |} + ``` `moinmoin` format produces a table markup used in @@ -529,20 +577,22 @@ MediaWiki-based sites: ```pycon >>> print(tabulate(table, headers, tablefmt="moinmoin")) -|| ''' item ''' || ''' quantity ''' || -|| spam || 41.999 || -|| eggs || 451 || -|| bacon || || +|| ''' item ''' || ''' qty ''' || +|| spam || 42 || +|| eggs || 451 || +|| bacon || 0 || + ``` `youtrack` format produces a table markup used in Youtrack tickets: ```pycon >>> print(tabulate(table, headers, tablefmt="youtrack")) -|| item || quantity || -| spam | 41.999 | -| eggs | 451 | -| bacon | | +|| item || qty || +| spam | 42 | +| eggs | 451 | +| bacon | 0 | + ``` `textile` format produces a table markup used in @@ -554,6 +604,7 @@ MediaWiki-based sites: |<. spam |>. 42 | |<. eggs |>. 451 | |<. bacon |>. 0 | + ``` `html` produces standard HTML markup as an html.escape'd str @@ -564,13 +615,16 @@ and a .str property so that the raw HTML remains accessible. ```pycon >>> print(tabulate(table, headers, tablefmt="html")) - + + +
item qty
spam 42
eggs 451
bacon 0
+ ``` `latex` format creates a `tabular` environment for LaTeX markup, @@ -588,6 +642,7 @@ correspondents: bacon & 0 \\ \hline \end{tabular} + ``` `latex_raw` behaves like `latex` but does not escape LaTeX commands and @@ -622,6 +677,7 @@ at a glance: 12345 1234.5 ---------- + ``` Compare this with a more common right alignment: @@ -635,6 +691,7 @@ Compare this with a more common right alignment: 12345 1234.5 ------ + ``` For `tabulate`, anything which can be parsed as a number is a number. @@ -643,7 +700,7 @@ comes in handy when reading a mixed table of text and numbers from a file: ```pycon ->>> import csv ; from StringIO import StringIO +>>> import csv; from io import StringIO >>> table = list(csv.reader(StringIO("spam, 42\neggs, 451\n"))) >>> table [['spam', ' 42'], ['eggs', ' 451']] @@ -652,32 +709,48 @@ file: spam 42 eggs 451 ---- ---- + ``` To disable this feature use `disable_numparse=True`. ```pycon ->>> print(tabulate.tabulate([["Ver1", "18.0"], ["Ver2","19.2"]], tablefmt="simple", disable_numparse=True)) +>>> print(tabulate([["Ver1", "18.0"], ["Ver2","19.2"]], tablefmt="simple", disable_numparse=True)) ---- ---- Ver1 18.0 Ver2 19.2 ---- ---- + ``` ### Custom column alignment -`tabulate` allows a custom column alignment to override the above. The -`colalign` argument can be a list or a tuple of `stralign` named -arguments. Possible column alignments are: `right`, `center`, `left`, -`decimal` (only for numbers), and `None` (to disable alignment). -Omitting an alignment uses the default. For example: +`tabulate` allows a custom column alignment to override the smart alignment described above. +Use `colglobalalign` to define a global setting. Possible alignments are: `right`, `center`, `left`, `decimal` (only for numbers). +Furthermore, you can define `colalign` for column-specific alignment as a list or a tuple. Possible values are `global` (keeps global setting), `right`, `center`, `left`, `decimal` (only for numbers), `None` (to disable alignment). Missing alignments are treated as `global`. + +```pycon +>>> print(tabulate([[1,2,3,4],[111,222,333,444]], colglobalalign='center', colalign = ('global','left','right'))) +--- --- --- --- + 1 2 3 4 +111 222 333 444 +--- --- --- --- + +``` + +### Custom header alignment + +Headers' alignment can be defined separately from columns'. Like for columns, you can use: +- `headersglobalalign` to define a header-specific global alignment setting. Possible values are `right`, `center`, `left`, `None` (to follow column alignment), +- `headersalign` list or tuple to further specify header-wise alignment. Possible values are `global` (keeps global setting), `same` (follow column alignment), `right`, `center`, `left`, `None` (to disable alignment). Missing alignments are treated as `global`. ```pycon ->>> print(tabulate([["one", "two"], ["three", "four"]], colalign=("right",)) ------ ---- - one two -three four ------ ---- +>>> print(tabulate([[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'))) +h e a d e r +--- --- --- --- --- --- +1 2 3 4 5 6 +111 222 333 444 555 666 + ``` ### Number formatting @@ -691,6 +764,7 @@ columns of decimal numbers. Use `floatfmt` named argument: pi 3.1416 e 2.7183 -- ------ + ``` `floatfmt` argument can be a list or a tuple of format strings, one per @@ -701,6 +775,7 @@ column, in which case every column may have different number formatting: --- ----- ------- 0.1 0.123 0.12345 --- ----- ------- + ``` `intfmt` works similarly for integers @@ -711,17 +786,53 @@ column, in which case every column may have different number formatting: b 90,000 - ------ -### Text formatting -By default, `tabulate` removes leading and trailing whitespace from text -columns. To disable whitespace removal, set the global module-level flag -`PRESERVE_WHITESPACE`: +### Type Deduction and Missing Values + +When `tabulate` sees numerical data (with our without comma separators), it +attempts to align the column on the decimal point. However, if it observes +non-numerical data in the column, it aligns it to the left by default. If +data is missing in a column (either None or empty values), the remaining +data in the column is used to infer the type: + +```pycon +>>> from fractions import Fraction +>>> test_table = [ +... [None, "1.23423515351", Fraction(1, 3)], +... [Fraction(56789, 1000000), 12345.1, b"abc"], +... ["", b"", None], +... [Fraction(10000, 3), None, ""], +... ] +>>> print(tabulate(test_table, floatfmt=",.5g", missingval="?")) +------------ ----------- --- + ? 1.2342 1/3 + 0.056789 12,345 abc + ? +3,333.3 ? +------------ ----------- --- -```python -import tabulate -tabulate.PRESERVE_WHITESPACE = True ``` +The deduced type (eg. str, float) influences the rendering of any types +that have alternative representations. For example, since `Fraction` has +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`. + +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 +like numbers such as "1e23": either convert them to the desired +representation before you `tabulate`, or ensure that the column always +contains at least one other `str`. + +### Text formatting + +By default, `tabulate` removes leading and trailing whitespace from text +columns. To disable whitespace removal, pass `preserve_whitespace=True`. +Older versions of the library used a global module-level flag PRESERVE_WHITESPACE. + ### Wide (fullwidth CJK) symbols To properly align tables which contain wide characters (typically @@ -766,6 +877,7 @@ a multiline cell, and headers with a multiline cell: ```pycon >>> table = [["eggs",451],["more\nspam",42]] >>> headers = ["item\nname", "qty"] + ``` `plain` tables: @@ -777,6 +889,7 @@ name eggs 451 more 42 spam + ``` `simple` tables: @@ -789,6 +902,7 @@ name eggs 451 more 42 spam + ``` `grid` tables: @@ -804,6 +918,7 @@ spam | more | 42 | | spam | | +--------+-------+ + ``` `fancy_grid` tables: @@ -819,6 +934,7 @@ spam │ more │ 42 │ │ spam │ │ ╘════════╧═══════╛ + ``` `pipe` tables: @@ -831,6 +947,7 @@ spam | eggs | 451 | | more | 42 | | spam | | + ``` `orgtbl` tables: @@ -843,18 +960,19 @@ spam | eggs | 451 | | more | 42 | | spam | | + ``` `jira` tables: ```pycon >>> print(tabulate(table, headers, tablefmt="jira")) -| item | qty | -| name | | -|:-------|------:| +|| item || qty || +|| name || || | eggs | 451 | | more | 42 | | spam | | + ``` `presto` tables: @@ -867,6 +985,7 @@ spam eggs | 451 more | 42 spam | + ``` `pretty` tables: @@ -881,6 +1000,7 @@ spam | more | 42 | | spam | | +------+-----+ + ``` `psql` tables: @@ -895,6 +1015,7 @@ spam | more | 42 | | spam | | +--------+-------+ + ``` `rst` tables: @@ -909,6 +1030,7 @@ eggs 451 more 42 spam ====== ===== + ``` Multiline cells are not well-supported for the other table formats. @@ -938,6 +1060,7 @@ the lines being wrapped would probably be significantly longer than this. | John Smith | Middle | | | Manager | +------------+---------+ + ``` ### Adding Separating lines @@ -980,7 +1103,7 @@ hyperlinks is that column width will be based on the length of the URL _text_ ra itself (terminals would show this text). For example: >>> len('\x1b]8;;https://example.com\x1b\\example\x1b]8;;\x1b\\') # display length is 7, showing 'example' - 45 + 40 Usage of the command line utility @@ -1023,21 +1146,19 @@ 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 slower than `asciitable`, and -faster than `PrettyTable` and `texttable` The following mini-benchmark -was run in Python 3.9.13 on Windows 10: - - ================================= ========== =========== - Table formatter time, μs rel. time - ================================= ========== =========== - csv to StringIO 12.5 1.0 - join with tabs and newlines 14.6 1.2 - asciitable (0.8.0) 192.0 15.4 - tabulate (0.9.0) 483.5 38.7 - tabulate (0.9.0, WIDE_CHARS_MODE) 637.6 51.1 - PrettyTable (3.4.1) 1080.6 86.6 - texttable (1.6.4) 1390.3 111.4 - ================================= ========== =========== +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): + + ================================== ========== =========== + 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 + ================================== ========== =========== Version history @@ -1061,14 +1182,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 @@ -1076,10 +1197,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. @@ -1087,7 +1208,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 @@ -1102,6 +1223,13 @@ tox -e lint See `tox.ini` file to learn how to use to test individual Python versions. +To test the "doctest" examples and their outputs in `README.md`: + +```shell +python3 -m pip install pytest-doctestplus[md] +python3 -m doctest README.md +``` + Contributors ------------ @@ -1120,8 +1248,10 @@ endolith, Dominic Davis-Foster, pavlocat, Daniel Aslau, paulc, Felix Yan, Shane Loretz, Frank Busse, Harsh Singh, Derek Weitzel, Vladimir Vrzić, 서승우 (chrd5273), Georgy Frolov, Christian Cwienk, Bart Broere, Vilhelm Prytz, Alexander Gažo, Hugo van Kemenade, -jamescooke, Matt Warner, Jérôme Provensal, Kevin Deldycke, +jamescooke, Matt Warner, Jérôme Provensal, Michał Górny, Kevin Deldycke, Kian-Meng Ang, Kevin Patterson, Shodhan Save, cleoold, KOLANICH, Vijaya Krishna Kasula, Furcy Pin, Christian Fibich, Shaun Duncan, -Dimitri Papadopoulos. - +Dimitri Papadopoulos, Élie Goudout, Racerroar888, Phill Zarfos, +Keyacom, Andrew Coffey, Arpit Jain, Israel Roldan, ilya112358, +Dan Nicholson, Frederik Scheerer, cdar07 (cdar), Racerroar888, +Perry Kundert. diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index 4eb2dd8..0000000 --- a/appveyor.yml +++ /dev/null @@ -1,56 +0,0 @@ -image: Visual Studio 2022 -environment: - - matrix: - - # For Python versions available on Appveyor, see - # https://www.appveyor.com/docs/windows-images-software/#python - # 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" - - PYTHON: "C:\\Python311-x64" - -install: - # Newer setuptools is needed for proper support of pyproject.toml - - "%PYTHON%\\python.exe -m pip install setuptools --upgrade" - # We need wheel installed to build wheels - - "%PYTHON%\\python.exe -m pip install wheel --upgrade" - - "%PYTHON%\\python.exe -m pip install build setuptools_scm" - - "%PYTHON%\\python.exe -m pip install pytest numpy pandas" - -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. - #- "build.cmd %PYTHON%\\python.exe setup.py test" - - "%PYTHON%\\python.exe -m pytest -v --doctest-modules --ignore benchmark.py" - -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 - #- "build.cmd %PYTHON%\\python.exe setup.py bdist_wheel" - - "%PYTHON%\\python.exe -m build -nswx ." - -artifacts: - # bdist_wheel puts your built wheel in the dist directory - - path: dist\* - -#on_success: -# You can use this step to upload your artifacts to a public website. -# See Appveyor's documentation for more details. Or you can simply -# access your wheels from the Appveyor "artifacts" tab for your build. diff --git a/benchmark.py b/benchmark/benchmark.py similarity index 89% rename from benchmark.py rename to benchmark/benchmark.py index 8422f5c..a89b709 100644 --- a/benchmark.py +++ b/benchmark/benchmark.py @@ -1,6 +1,5 @@ from timeit import timeit import tabulate -import asciitable import prettytable import texttable import sys @@ -9,7 +8,6 @@ from csv import writer from io import StringIO import tabulate -import asciitable import prettytable import texttable @@ -34,12 +32,6 @@ def run_prettytable(table): return str(pp) -def run_asciitable(table): - buf = StringIO() - asciitable.write(table, output=buf, Writer=asciitable.FixedWidth) - return buf.getvalue() - - def run_texttable(table): pp = texttable.Texttable() pp.set_cols_align(["l"] + ["r"]*9) @@ -61,7 +53,6 @@ def run_tabulate(table, widechars=False): methods = [ ("join with tabs and newlines", "join_table(table)"), ("csv to StringIO", "csv_table(table)"), - ("asciitable (%s)" % asciitable.__version__, "run_asciitable(table)"), ("tabulate (%s)" % tabulate.__version__, "run_tabulate(table)"), ( "tabulate (%s, WIDE_CHARS_MODE)" % tabulate.__version__, diff --git a/benchmark/requirements.txt b/benchmark/requirements.txt new file mode 100644 index 0000000..81086ef --- /dev/null +++ b/benchmark/requirements.txt @@ -0,0 +1,2 @@ +prettytable +texttable \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 5a8c1fd..4144f9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,14 +13,14 @@ 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", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Software Development :: Libraries", ] -requires-python = ">=3.7" +requires-python = ">=3.9" dynamic = ["version"] [project.urls] diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 3b1a1e1..b15d7f0 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1,5 +1,6 @@ """Pretty-print tabular data.""" +import warnings from collections import namedtuple from collections.abc import Iterable, Sized from html import escape as htmlescape @@ -10,6 +11,7 @@ import math import textwrap import dataclasses +import sys try: import wcwidth # optional wide-character (CJK) support @@ -31,9 +33,6 @@ def _is_file(f): # minimum extra space in headers MIN_PADDING = 2 -# Whether or not to preserve leading/trailing whitespace in data. -PRESERVE_WHITESPACE = False - _DEFAULT_FLOATFMT = "g" _DEFAULT_INTFMT = "" _DEFAULT_MISSINGVAL = "" @@ -101,12 +100,17 @@ def _is_file(f): ) +def _is_separating_line_value(value): + return type(value) is str and value.strip() == SEPARATING_LINE + + 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 _is_separating_line_value(row[0])) + or (len(row) >= 2 and _is_separating_line_value(row[1])) ) + return is_sl @@ -133,6 +137,29 @@ def _pipe_line_with_colons(colwidths, colaligns): return "|" + "|".join(segments) + "|" +def _grid_segment_with_colons(colwidth, align): + """Return a segment of a horizontal line with optional colons which indicate + column's alignment in a grid table.""" + width = colwidth + if align == "right": + return ("=" * (width - 1)) + ":" + elif align == "center": + return ":" + ("=" * (width - 2)) + ":" + elif align == "left": + return ":" + ("=" * (width - 1)) + else: + return "=" * width + + +def _grid_line_with_colons(colwidths, colaligns): + """Return a horizontal line with optional colons to indicate column's alignment + 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) + "+" + + def _mediawiki_row_with_attrs(separator, cell_values, colwidths, colaligns): alignment = { "left": "", @@ -223,7 +250,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)) + '"'] @@ -399,6 +426,16 @@ def escape_empty(val): padding=1, with_header_hide=None, ), + "colon_grid": TableFormat( + lineabove=Line("+", "-", "+", "+"), + linebelowheader=_grid_line_with_colons, + linebetweenrows=Line("+", "-", "+", "+"), + linebelow=Line("+", "-", "+", "+"), + headerrow=DataRow("|", "|", "|"), + datarow=DataRow("|", "|", "|"), + padding=1, + with_header_hide=None, + ), "outline": TableFormat( lineabove=Line("+", "-", "+", "+"), linebelowheader=Line("+", "=", "+", "+"), @@ -692,6 +729,7 @@ def escape_empty(val): "mixed_grid": "mixed_grid", "double_grid": "double_grid", "fancy_grid": "fancy_grid", + "colon_grid": "colon_grid", "pipe": "pipe", "orgtbl": "orgtbl", "jira": "jira", @@ -838,25 +876,55 @@ def _isconvertible(conv, string): def _isnumber(string): - """ + """Detects if something *could* be considered a numeric value, vs. just a string. + + This promotes types convertible to both int and float to be considered + a float. Note that, iff *all* values appear to be some form of numeric + value such as eg. "1e2", they would be considered numbers! + + The exception is things that appear to be numbers but overflow to + +/-inf, eg. "1e23456"; we'll have to exclude them explicitly. + + >>> _isnumber(123) + True + >>> _isnumber(123.45) + True >>> _isnumber("123.45") True >>> _isnumber("123") True >>> _isnumber("spam") False - >>> _isnumber("123e45678") + >>> _isnumber("123e45") + True + >>> _isnumber("123e45678") # evaluates equal to 'inf', but ... isn't False >>> _isnumber("inf") True + >>> from fractions import Fraction + >>> _isnumber(Fraction(1,3)) + True + """ - if not _isconvertible(float, string): - return False - elif isinstance(string, (str, bytes)) and ( - math.isinf(float(string)) or math.isnan(float(string)) - ): - return string.lower() in ["inf", "-inf", "nan"] - return True + return ( + # fast path + type(string) in (float, int) + # covers 'NaN', +/- 'inf', and eg. '1e2', as well as any type + # convertible to int/float. + or ( + _isconvertible(float, string) + and ( + # some other type convertible to float + not isinstance(string, (str, bytes)) + # or, a numeric string eg. "1e1...", "NaN", ..., but isn't + # just an over/underflow + or ( + not (math.isinf(float(string)) or math.isnan(float(string))) + or string.lower() in ["inf", "-inf", "nan"] + ) + ) + ) + ) def _isint(string, inttype=int): @@ -895,8 +963,13 @@ def _isbool(string): def _type(string, has_invisible=True, numparse=True): """The least generic type (type(None), int, float, str, unicode). + Treats empty string as missing for the purposes of type deduction, so as to not influence + the type of an otherwise complete column; does *not* result in missingval replacement! + >>> _type(None) is type(None) True + >>> _type("") is type(None) + True >>> _type("foo") is type("") True >>> _type("1") is type(1) @@ -911,15 +984,25 @@ def _type(string, has_invisible=True, numparse=True): if has_invisible and isinstance(string, (str, bytes)): string = _strip_ansi(string) - if string is None: + if string is None or (isinstance(string, (bytes, str)) and not string): return type(None) elif hasattr(string, "isoformat"): # datetime.datetime, date, and time return str elif _isbool(string): return bool - elif _isint(string) and numparse: + elif numparse and ( + _isint(string) + or ( + isinstance(string, str) + and _isnumber_with_thousands_separator(string) + and "." not in string + ) + ): return int - elif _isnumber(string) and numparse: + elif numparse and ( + _isnumber(string) + or (isinstance(string, str) and _isnumber_with_thousands_separator(string)) + ): return float elif isinstance(string, bytes): return bytes @@ -1058,13 +1141,13 @@ def _choose_width_fn(has_invisible, enable_widechars, is_multiline): return width_fn -def _align_column_choose_padfn(strings, alignment, has_invisible): +def _align_column_choose_padfn(strings, alignment, has_invisible, preserve_whitespace): if alignment == "right": - if not PRESERVE_WHITESPACE: + if not preserve_whitespace: strings = [s.strip() for s in strings] padfn = _padleft elif alignment == "center": - if not PRESERVE_WHITESPACE: + if not preserve_whitespace: strings = [s.strip() for s in strings] padfn = _padboth elif alignment == "decimal": @@ -1078,7 +1161,7 @@ def _align_column_choose_padfn(strings, alignment, has_invisible): elif not alignment: padfn = _padnone else: - if not PRESERVE_WHITESPACE: + if not preserve_whitespace: strings = [s.strip() for s in strings] padfn = _padright return strings, padfn @@ -1107,8 +1190,7 @@ def _flat_list(nested_list): ret = [] for item in nested_list: if isinstance(item, list): - for subitem in item: - ret.append(subitem) + ret.extend(item) else: ret.append(item) return ret @@ -1121,9 +1203,12 @@ def _align_column( has_invisible=True, enable_widechars=False, is_multiline=False, + preserve_whitespace=False, ): """[string] -> [padded_string]""" - strings, padfn = _align_column_choose_padfn(strings, alignment, has_invisible) + 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 ) @@ -1211,7 +1296,7 @@ def _column_type(strings, has_invisible=True, numparse=True): def _format(val, valtype, floatfmt, intfmt, missingval="", has_invisible=True): - """Format a value according to its type. + """Format a value according to its deduced type. Empty values are deemed valid for any type. Unicode is supported: @@ -1224,10 +1309,29 @@ def _format(val, valtype, floatfmt, intfmt, missingval="", has_invisible=True): """ # noqa if val is None: return missingval + if isinstance(val, (bytes, str)) and not val: + return "" if valtype is str: return f"{val}" 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 + ) + 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 = val_new.encode("utf-8").decode("unicode_escape") + intfmt = "" return format(val, intfmt) elif valtype is bytes: try: @@ -1241,6 +1345,8 @@ def _format(val, valtype, floatfmt, intfmt, missingval="", has_invisible=True): formatted_val = format(float(raw_val), floatfmt) 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) else: return f"{val}" @@ -1270,7 +1376,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): @@ -1296,7 +1402,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 = [] @@ -1318,7 +1424,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. + """Transform a supported data type to a list of lists, and a list of headers, + with headers padding. Supported tabular data types: @@ -1330,7 +1437,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 @@ -1352,20 +1459,27 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): try: bool(headers) - is_headers2bool_broken = False # noqa except ValueError: # numpy.ndarray, pandas.core.index.Index, ... - is_headers2bool_broken = True # noqa headers = list(headers) + err_msg = ( + "\n\nTo build a table python-tabulate requires two-dimensional data " + "like a list of lists or similar." + "\nDid you forget a pair of extra [] or ',' in ()?" + ) index = None if hasattr(tabular_data, "keys") and hasattr(tabular_data, "values"): # dict-like and pandas.DataFrame? if hasattr(tabular_data.values, "__call__"): # likely a conventional dict keys = tabular_data.keys() - rows = list( - izip_longest(*tabular_data.values()) - ) # columns have to be transposed + try: + 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) @@ -1388,7 +1502,10 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): headers = list(map(str, keys)) # headers should be strings else: # it's a usual iterable of iterables, or a NumPy array, or an iterable of dataclasses - rows = list(tabular_data) + try: + rows = list(tabular_data) + except TypeError: # not iterable + raise TypeError(err_msg) if headers == "keys" and not rows: # an empty table (issue #81) @@ -1456,7 +1573,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 @@ -1498,13 +1615,12 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): pass # pad with empty headers for initial columns if necessary + headers_pad = 0 if headers and len(rows) > 0: - nhs = len(headers) - ncols = len(rows[0]) - if nhs < ncols: - headers = [""] * (ncols - nhs) + headers + headers_pad = max(0, len(rows[0]) - len(headers)) + headers = [""] * headers_pad + headers - return rows, headers + return rows, headers, headers_pad def _wrap_text_to_colwidths(list_of_lists, colwidths, numparses=True): @@ -1525,9 +1641,10 @@ 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 + # 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) ) @@ -1580,8 +1697,12 @@ def tabulate( missingval=_DEFAULT_MISSINGVAL, showindex="default", disable_numparse=False, + colglobalalign=None, colalign=None, + preserve_whitespace=False, maxcolwidths=None, + headersglobalalign=None, + headersalign=None, rowalign=None, maxheadercolwidths=None, ): @@ -1597,7 +1718,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. @@ -1636,8 +1757,8 @@ def tabulate( - - -- - Column alignment - ---------------- + Column and Headers alignment + ---------------------------- `tabulate` tries to detect column types automatically, and aligns the values properly. By default it aligns decimal points of the @@ -1646,6 +1767,23 @@ def tabulate( (`numalign`, `stralign`) are: "right", "center", "left", "decimal" (only for `numalign`), and None (to disable alignment). + `colglobalalign` allows for global alignment of columns, before any + specific override from `colalign`. Possible values are: None + (defaults according to coltype), "right", "center", "decimal", + "left". + `colalign` allows for column-wise override starting from left-most + column. Possible values are: "global" (no override), "right", + "center", "decimal", "left". + `headersglobalalign` allows for global headers alignment, before any + specific override from `headersalign`. Possible values are: None + (follow columns alignment), "right", "center", "left". + `headersalign` allows for header-wise override starting from left-most + given header. Possible values are: "global" (no override), "same" + (follow column alignment), "right", "center", "left". + + Note on intended behaviour: If there is no `tabular_data`, any column + alignment argument is ignored. Hence, in this case, header + alignment cannot be inferred from column alignment. Table formats ------------- @@ -1802,6 +1940,31 @@ def tabulate( │ eggs │ 451 │ ╘═══════════╧═══════════╛ + "colon_grid" is similar to "grid" but uses colons only to define + columnwise content alignment, without whitespace padding, + similar to the alignment specification of Pandoc `grid_tables`: + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "colon_grid")) + +-----------+-----------+ + | strings | numbers | + +:==========+:==========+ + | spam | 41.9999 | + +-----------+-----------+ + | eggs | 451 | + +-----------+-----------+ + + >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], + ... ["strings", "numbers"], "colon_grid", + ... colalign=["right", "left"])) + +-----------+-----------+ + | strings | numbers | + +==========:+:==========+ + | spam | 41.9999 | + +-----------+-----------+ + | eggs | 451 | + +-----------+-----------+ + "outline" is the same as the "grid" format but doesn't draw lines between rows: >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], @@ -2065,12 +2228,14 @@ def tabulate( if tabular_data is None: tabular_data = [] - list_of_lists, headers = _normalize_tabular_data( + list_of_lists, headers, headers_pad = _normalize_tabular_data( tabular_data, headers, showindex=showindex ) list_of_lists, separating_lines = _remove_separating_lines(list_of_lists) if maxcolwidths is not None: + if type(maxcolwidths) is tuple: # Check if tuple, convert to list if so + maxcolwidths = list(maxcolwidths) if len(list_of_lists): num_cols = len(list_of_lists[0]) else: @@ -2118,6 +2283,13 @@ def tabulate( numalign = "decimal" if numalign == _DEFAULT_ALIGN else numalign stralign = "left" if stralign == _DEFAULT_ALIGN else stralign + # 'colon_grid' uses colons in the line beneath the header to represent a column's + # alignment instead of literally aligning the text differently. Hence, + # left alignment of the data in the text output is enforced. + if tablefmt == "colon_grid": + colglobalalign = "left" + headersglobalalign = "left" + # optimization: look for ANSI control codes once, # enable smart width functions only if a control code is found # @@ -2181,30 +2353,80 @@ def tabulate( ] # align columns - aligns = [numalign if ct in [int, float] else stralign for ct in coltypes] + # first set global alignment + if colglobalalign is not None: # if global alignment provided + aligns = [colglobalalign] * len(cols) + else: # default + aligns = [numalign if ct in [int, float] else stralign for ct in coltypes] + # then specific alignments 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]}. " + f'Did you mean `colglobalalign = "{colalign}"` or `colalign = ("{colalign}",)`?', + stacklevel=2, + ) for idx, align in enumerate(colalign): - aligns[idx] = align + if not idx < len(aligns): + break + elif align != "global": + aligns[idx] = align 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. + if tablefmt == "colon_grid": + aligns_copy = ["left"] * len(cols) cols = [ - _align_column(c, a, minw, has_invisible, enable_widechars, is_multiline) - for c, a, minw in zip(cols, aligns, minwidths) + _align_column( + c, + a, + minw, + has_invisible, + enable_widechars, + is_multiline, + preserve_whitespace, + ) + for c, a, minw in zip(cols, aligns_copy, minwidths) ] + aligns_headers = None if headers: # align headers and add headers t_cols = cols or [[""]] * len(headers) - t_aligns = aligns or [stralign] * len(headers) + # first set global alignment + if headersglobalalign is not None: # if global alignment provided + aligns_headers = [headersglobalalign] * len(t_cols) + else: # default + aligns_headers = aligns or [stralign] * len(headers) + # then specific header alignments + 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]}. " + 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 + aligns_headers[hidx] = aligns[hidx] + 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) ] headers = [ _align_header(h, a, minw, width_fn(h), is_multiline, width_fn) - for h, a, minw in zip(headers, t_aligns, minwidths) + for h, a, minw in zip(headers, aligns_headers, minwidths) ] rows = list(zip(*cols)) else: @@ -2219,7 +2441,14 @@ def tabulate( _reinsert_separating_lines(rows, separating_lines) return _format_table( - tablefmt, headers, rows, minwidths, aligns, is_multiline, rowaligns=rowaligns + tablefmt, + headers, + aligns_headers, + rows, + minwidths, + aligns, + is_multiline, + rowaligns=rowaligns, ) @@ -2256,6 +2485,8 @@ def _expand_iterable(original, num_desired, default): def _pad_row(cells, padding): if cells: + if cells == SEPARATING_LINE: + return SEPARATING_LINE pad = " " * padding padded_cells = [pad + cell + pad for cell in cells] return padded_cells @@ -2350,7 +2581,9 @@ def str(self): return self -def _format_table(fmt, headers, 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 [] @@ -2366,27 +2599,32 @@ def _format_table(fmt, headers, rows, colwidths, colaligns, is_multiline, rowali append_row = _append_basic_row padded_headers = pad_row(headers, pad) - padded_rows = [pad_row(row, pad) for row in rows] if fmt.lineabove and "lineabove" not in hidden: _append_line(lines, padded_widths, colaligns, fmt.lineabove) if padded_headers: - append_row(lines, padded_headers, padded_widths, colaligns, headerrow) + append_row(lines, padded_headers, padded_widths, headersaligns, headerrow) if fmt.linebelowheader and "linebelowheader" not in hidden: _append_line(lines, padded_widths, colaligns, fmt.linebelowheader) - if padded_rows and fmt.linebetweenrows and "linebetweenrows" not in hidden: + if rows and fmt.linebetweenrows and "linebetweenrows" not in hidden: # initial rows with a line below - for row, ralign in zip(padded_rows[:-1], rowaligns): - append_row( - lines, row, padded_widths, colaligns, fmt.datarow, rowalign=ralign - ) + for row, ralign in zip(rows[:-1], rowaligns): + if row != SEPARATING_LINE: + append_row( + lines, + pad_row(row, pad), + padded_widths, + colaligns, + fmt.datarow, + rowalign=ralign, + ) _append_line(lines, padded_widths, colaligns, fmt.linebetweenrows) # the last row without a line below append_row( lines, - padded_rows[-1], + pad_row(rows[-1], pad), padded_widths, colaligns, fmt.datarow, @@ -2400,13 +2638,15 @@ def _format_table(fmt, headers, rows, colwidths, colaligns, is_multiline, rowali or fmt.lineabove or Line("", "", "", "") ) - for row in padded_rows: + for row in rows: # test to see if either the 1st column or the 2nd column (account for showindex) has # the SEPARATING_LINE flag if _is_separating_line(row): _append_line(lines, padded_widths, colaligns, separating_line) else: - append_row(lines, row, 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) @@ -2464,7 +2704,7 @@ def _update_lines(self, lines, new_line): else: # A single reset code resets everything self._active_codes = [] - # Always ensure each line is color terminted if any colors are + # Always ensure each line is color terminated if any colors are # still active, otherwise colors will bleed into other cells on the console if len(self._active_codes) > 0: new_line = new_line + _ansi_color_reset_code @@ -2492,10 +2732,24 @@ def _handle_long_word(self, reversed_chunks, cur_line, cur_len, width): # 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 -- @@ -2646,15 +2900,22 @@ def _main(): (default: simple) """ import getopt - import sys - import textwrap usage = textwrap.dedent(_main.__doc__) try: opts, args = getopt.getopt( sys.argv[1:], - "h1o:s:F:A:f:", - ["help", "header", "output", "sep=", "float=", "int=", "align=", "format="], + "h1o:s:F:I:f:", + [ + "help", + "header", + "output=", + "sep=", + "float=", + "int=", + "colalign=", + "format=", + ], ) except getopt.GetoptError as e: print(e) @@ -2690,7 +2951,7 @@ def _main(): print(usage) sys.exit(0) files = [sys.stdin] if not args else args - with (sys.stdout if outfile == "-" else open(outfile, "w")) as out: + with sys.stdout if outfile == "-" else open(outfile, "w") as out: for f in files: if f == "-": f = sys.stdin diff --git a/test/common.py b/test/common.py index d95e84f..ec2fb35 100644 --- a/test/common.py +++ b/test/common.py @@ -1,10 +1,11 @@ import pytest # noqa 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) + print("Expected:\n%r\n" % expected) + print("Got:\n%r\n" % result) assert expected == result @@ -27,3 +28,18 @@ def rows_to_pipe_table_str(rows): lines.append(line) 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: + # Causes all warnings to always be triggered inside here. + warnings.simplefilter("always") + func(*args, **kwargs) + # Checks + if num is not None: + assert len(W) == num + if category is not 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_api.py b/test/test_api.py index 046d752..062573c 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -48,8 +48,12 @@ def test_tabulate_signature(): ("missingval", ""), ("showindex", "default"), ("disable_numparse", False), + ("colglobalalign", None), ("colalign", None), + ("preserve_whitespace", False), ("maxcolwidths", None), + ("headersglobalalign", None), + ("headersalign", None), ("rowalign", None), ("maxheadercolwidths", None), ] diff --git a/test/test_cli.py b/test/test_cli.py index ce85f19..e71572d 100644 --- a/test/test_cli.py +++ b/test/test_cli.py @@ -2,7 +2,6 @@ """ - import os import sys diff --git a/test/test_input.py b/test/test_input.py index a178bd9..b910a34 100644 --- a/test/test_input.py +++ b/test/test_input.py @@ -11,7 +11,7 @@ def test_iterable_of_iterables(): - "Input: an interable 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", "- - - - -"] @@ -21,7 +21,7 @@ def test_iterable_of_iterables(): def test_iterable_of_iterables_headers(): - "Input: an interable of iterables with headers." + "Input: an iterable of iterables with headers." ii = iter(map(lambda x: iter(x), [range(5), range(5, 0, -1)])) expected = "\n".join( [ @@ -36,7 +36,7 @@ def test_iterable_of_iterables_headers(): def test_iterable_of_iterables_firstrow(): - "Input: an interable of iterables with the first row as headers" + "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)])) expected = "\n".join( [ @@ -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_internal.py b/test/test_internal.py index 64e1d12..e7564d3 100644 --- a/test/test_internal.py +++ b/test/test_internal.py @@ -182,7 +182,12 @@ def test_wrap_text_wide_chars(): rows = [["청자청자청자청자청자", "약간 감싸면 더 잘 보일 수있는 다소 긴 설명입니다"]] widths = [5, 20] - expected = [["청자\n청자\n청자\n청자\n청자", "약간 감싸면 더 잘\n보일 수있는 다소 긴\n설명입니다"]] + expected = [ + [ + "청자\n청자\n청자\n청자\n청자", + "약간 감싸면 더 잘\n보일 수있는 다소 긴\n설명입니다", + ] + ] result = T._wrap_text_to_colwidths(rows, widths) assert_equal(expected, result) diff --git a/test/test_output.py b/test/test_output.py index 9043aed..e3d369a 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -1,7 +1,8 @@ """Test output of the various forms of tabular data.""" -import tabulate as tabulate_module -from common import assert_equal, raises, skip +from pytest import mark + +from common import assert_equal, raises, skip, check_warnings from tabulate import tabulate, simple_separated_format, SEPARATING_LINE # _test_table shows @@ -136,7 +137,10 @@ def test_plain_maxcolwidth_autowraps_wide_chars(): table = [ ["hdr", "fold"], - ["1", "약간 감싸면 더 잘 보일 수있는 다소 긴 설명입니다 설명입니다 설명입니다 설명입니다 설명"], + [ + "1", + "약간 감싸면 더 잘 보일 수있는 다소 긴 설명입니다 설명입니다 설명입니다 설명입니다 설명", + ], ] expected = "\n".join( [ @@ -257,6 +261,21 @@ def test_simple_with_sep_line(): assert_equal(expected, result) +def test_orgtbl_with_sep_line(): + "Output: orgtbl with headers and separating line" + expected = "\n".join( + [ + "| strings | numbers |", + "|-----------+-----------|", + "| spam | 41.9999 |", + "|-----------+-----------|", + "| eggs | 451 |", + ] + ) + result = tabulate(_test_table_with_sep_line, _test_table_headers, tablefmt="orgtbl") + assert_equal(expected, result) + + def test_readme_example_with_sep(): table = [["Earth", 6371], ["Mars", 3390], SEPARATING_LINE, ["Moon", 1737]] expected = "\n".join( @@ -311,6 +330,28 @@ def test_simple_multiline_2_with_sep_line(): assert_equal(expected, result) +def test_orgtbl_multiline_2_with_sep_line(): + "Output: simple with multiline cells" + expected = "\n".join( + [ + "| key | value |", + "|-------+-----------|", + "| foo | bar |", + "|-------+-----------|", + "| spam | multiline |", + "| | world |", + ] + ) + table = [ + ["key", "value"], + ["foo", "bar"], + SEPARATING_LINE, + ["spam", "multiline\nworld"], + ] + result = tabulate(table, headers="firstrow", stralign="center", tablefmt="orgtbl") + assert_equal(expected, result) + + def test_simple_headerless(): "Output: simple without headers" expected = "\n".join( @@ -335,6 +376,36 @@ def test_simple_headerless_with_sep_line(): assert_equal(expected, result) +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 |", + ] + ) + result = tabulate(_test_table_with_sep_line, tablefmt="github") + assert_equal(expected, result) + + +def test_simple_headerless_with_sep_line_with_linebetweenrows_in_tablefmt(): + "Output: simple without headers with sep line with linebetweenrows in tablefmt" + expected = "\n".join( + [ + "+------+----------+", + "| spam | 41.9999 |", + "+------+----------+", + "+------+----------+", + "| eggs | 451 |", + "+------+----------+", + ] + ) + result = tabulate(_test_table_with_sep_line, tablefmt="grid") + assert_equal(expected, result) + + def test_simple_multiline_headerless(): "Output: simple with multiline cells without headers" table = [["foo bar\nbaz\nbau", "hello"], ["", "multiline\nworld"]] @@ -1414,6 +1485,104 @@ def test_fancy_grid_multiline_row_align(): assert_equal(expected, result) +def test_colon_grid(): + "Output: colon_grid with two columns aligned left and center" + expected = "\n".join( + [ + "+------+------+", + "| H1 | H2 |", + "+=====:+:====:+", + "| 3 | 4 |", + "+------+------+", + ] + ) + result = tabulate( + [[3, 4]], + headers=("H1", "H2"), + tablefmt="colon_grid", + colalign=["right", "center"], + ) + assert_equal(expected, result) + + +def test_colon_grid_wide_characters(): + "Output: colon_grid with wide chars in header" + try: + import wcwidth # noqa + except ImportError: + skip("test_colon_grid_wide_characters is skipped") + headers = list(_test_table_headers) + headers[1] = "配列" + expected = "\n".join( + [ + "+-----------+---------+", + "| strings | 配列 |", + "+:==========+========:+", + "| spam | 41.9999 |", + "+-----------+---------+", + "| eggs | 451 |", + "+-----------+---------+", + ] + ) + result = tabulate( + _test_table, headers, tablefmt="colon_grid", colalign=["left", "right"] + ) + assert_equal(expected, result) + + +def test_colon_grid_headerless(): + "Output: colon_grid without headers" + expected = "\n".join( + [ + "+------+---------+", + "| spam | 41.9999 |", + "+------+---------+", + "| eggs | 451 |", + "+------+---------+", + ] + ) + result = tabulate(_test_table, tablefmt="colon_grid") + assert_equal(expected, result) + + +def test_colon_grid_multiline(): + "Output: colon_grid with multiline cells" + table = [["Data\n5", "33\n3"]] + headers = ["H1\n1", "H2\n2"] + expected = "\n".join( + [ + "+------+------+", + "| H1 | H2 |", + "| 1 | 2 |", + "+:=====+:=====+", + "| Data | 33 |", + "| 5 | 3 |", + "+------+------+", + ] + ) + result = tabulate(table, headers, tablefmt="colon_grid") + assert_equal(expected, result) + + +def test_colon_grid_with_empty_cells(): + table = [["A", ""], ["", "B"]] + headers = ["H1", "H2"] + alignments = ["center", "right"] + expected = "\n".join( + [ + "+------+------+", + "| H1 | H2 |", + "+:====:+=====:+", + "| A | |", + "+------+------+", + "| | B |", + "+------+------+", + ] + ) + result = tabulate(table, headers, tablefmt="colon_grid", colalign=alignments) + assert_equal(expected, result) + + def test_outline(): "Output: outline with headers" expected = "\n".join( @@ -2638,6 +2807,46 @@ def test_intfmt(): assert_equal(expected, result) +def test_intfmt_with_string_as_integer(): + "Output: integer format" + result = tabulate([[82642], ["1500"], [2463]], intfmt=",", tablefmt="plain") + expected = "82,642\n 1500\n 2,463" + assert_equal(expected, result) + + +@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" + ) + expected = "82000.4\n 1500.47\n 2463\n92,165" + assert_equal(expected, result) + + +def test_intfmt_with_colors(): + "Regression: Align ANSI-colored values as if they were colorless." + colortable = [ + ("\x1b[33mabc\x1b[0m", 42, "\x1b[31m42\x1b[0m"), + ("\x1b[35mdef\x1b[0m", 987654321, "\x1b[32m987654321\x1b[0m"), + ] + colorheaders = ("test", "\x1b[34mtest\x1b[0m", "test") + formatted = tabulate(colortable, colorheaders, "grid", intfmt=",") + expected = "\n".join( + [ + "+--------+-------------+-------------+", + "| test | \x1b[34mtest\x1b[0m | test |", + "+========+=============+=============+", + "| \x1b[33mabc\x1b[0m | 42 | \x1b[31m42\x1b[0m |", + "+--------+-------------+-------------+", + "| \x1b[35mdef\x1b[0m | 987,654,321 | \x1b[32m987,654,321\x1b[0m |", + "+--------+-------------+-------------+", + ] + ) + print(f"expected: {expected!r}\n\ngot: {formatted!r}\n") + assert_equal(expected, formatted) + + def test_empty_data_with_headers(): "Output: table with empty data and headers as firstrow" expected = "" @@ -2652,6 +2861,15 @@ def test_floatfmt(): assert_equal(expected, result) +def test_floatfmt_thousands(): + "Output: floating point format" + 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( @@ -2681,6 +2899,72 @@ def test_colalign_multi_with_sep_line(): 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") + result = tabulate(table, colglobalalign=colglobalalign, colalign=colalign) + 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", + ] + ) + 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"]) + 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" + ) + + def test_float_conversions(): "Output: float format parsed" test_headers = ["str", "bad_float", "just_float", "with_inf", "with_nan", "neg_inf"] @@ -2726,6 +3010,32 @@ def test_missingval_multi(): assert_equal(expected, result) +def test_column_emptymissing_deduction(): + "Missing or empty/blank values shouldn't change type deduction of rest of column" + from fractions import Fraction + + test_table = [ + [None, "1.23423515351", Fraction(1, 3)], + [Fraction(56789, 1000000), 12345.1, b"abc"], + ["", b"", None], + [Fraction(10000, 3), None, ""], + ] + result = tabulate( + test_table, + floatfmt=",.5g", + missingval="?", + ) + print(f"\n{result}") + expected = """\ +------------ ----------- --- + ? 1.2342 1/3 + 0.056789 12,345 abc + ? +3,333.3 ? +------------ ----------- ---""" + assert_equal(expected, result) + + def test_column_alignment(): "Output: custom alignment for text and numbers" expected = "\n".join(["----- ---", "Alice 1", " Bob 333", "----- ---"]) @@ -2881,6 +3191,27 @@ def test_list_of_lists_with_index_with_sep_line(): assert_equal(expected, result) +def test_with_padded_columns_with_sep_line(): + table = [ + ["1", "one"], # "1" as a str on purpose + [1_000, "one K"], + SEPARATING_LINE, + [1_000_000, "one M"], + ] + expected = "\n".join( + [ + "+---------+-------+", + "| 1 | one |", + "| 1000 | one K |", + "|---------+-------|", + "| 1000000 | one M |", + "+---------+-------+", + ] + ) + result = tabulate(table, tablefmt="psql") + assert_equal(expected, result) + + def test_list_of_lists_with_supplied_index(): "Output: a table with a supplied index" dd = zip(*[list(range(3)), list(range(101, 104))]) @@ -2958,18 +3289,16 @@ def test_disable_numparse_list(): def test_preserve_whitespace(): "Output: Default table output, but with preserved leading whitespace." - tabulate_module.PRESERVE_WHITESPACE = True table_headers = ["h1", "h2", "h3"] test_table = [[" foo", " bar ", "foo"]] expected = "\n".join( ["h1 h2 h3", "----- ------- ----", " foo bar foo"] ) - result = tabulate(test_table, table_headers) + result = tabulate(test_table, table_headers, preserve_whitespace=True) assert_equal(expected, result) - tabulate_module.PRESERVE_WHITESPACE = False table_headers = ["h1", "h2", "h3"] test_table = [[" foo", " bar ", "foo"]] expected = "\n".join(["h1 h2 h3", "---- ---- ----", "foo bar foo"]) - result = tabulate(test_table, table_headers) + result = tabulate(test_table, table_headers, preserve_whitespace=False) assert_equal(expected, result) diff --git a/test/test_regression.py b/test/test_regression.py index 8f60ce7..bf26247 100644 --- a/test/test_regression.py +++ b/test/test_regression.py @@ -488,6 +488,27 @@ def test_preserve_line_breaks_with_maxcolwidths(): assert_equal(expected, result) +def test_maxcolwidths_accepts_list_or_tuple(): + "Regression: maxcolwidths can accept a list or a tuple (github issue #214)" + table = [["lorem ipsum dolor sit amet"] * 3] + expected = "\n".join( + [ + "+-------------+----------+----------------------------+", + "| lorem ipsum | lorem | lorem ipsum dolor sit amet |", + "| dolor sit | ipsum | |", + "| amet | dolor | |", + "| | sit amet | |", + "+-------------+----------+----------------------------+", + ] + ) + # test with maxcolwidths as a list + result = tabulate(table, tablefmt="grid", maxcolwidths=[12, 8]) + assert_equal(expected, result) + # test with maxcolwidths as a tuple + result = tabulate(table, tablefmt="grid", maxcolwidths=(12, 8)) + assert_equal(expected, result) + + def test_exception_on_empty_data_with_maxcolwidths(): "Regression: exception on empty data when using maxcolwidths (github issue #180)" result = tabulate([], maxcolwidths=5) @@ -512,3 +533,15 @@ def test_numpy_int64_as_integer(): assert_equal(expected, result) except ImportError: raise skip("") + + +def test_empty_table_with_colalign(): + "Regression: empty table with colalign kwarg" + table = tabulate([], ["a", "b", "c"], colalign=("center", "left", "left", "center")) + expected = "\n".join( + [ + "a b c", + "--- --- ---", + ] + ) + assert_equal(expected, table) diff --git a/test/test_textwrapper.py b/test/test_textwrapper.py index f3070b1..8c0a6cc 100644 --- a/test/test_textwrapper.py +++ b/test/test_textwrapper.py @@ -1,9 +1,8 @@ """Discretely test functionality of our custom TextWrapper""" -from __future__ import unicode_literals import datetime -from tabulate import _CustomTextWrap as CTW, tabulate +from tabulate import _CustomTextWrap as CTW, tabulate, _strip_ansi from textwrap import TextWrapper as OTW from common import skip, assert_equal @@ -144,7 +143,8 @@ def test_wrap_color_in_single_line(): def test_wrap_color_line_splillover(): """TextWrapper: Wrap a line - preserve internal color tags and wrap them to - other lines when required, requires adding the colors tags to other lines as appropriate""" + other lines when required, requires adding the colors tags to other lines as appropriate + """ # This has both a text color and a background color data = "This is a \033[31mtest string for testing TextWrap\033[0m with colors" @@ -158,6 +158,42 @@ def test_wrap_color_line_splillover(): assert_equal(expected, result) +def test_wrap_color_line_longword(): + """TextWrapper: Wrap a line - preserve internal color tags and wrap them to + other lines when required, requires adding the colors tags to other lines as appropriate + and avoiding splitting escape codes.""" + data = "This_is_a_\033[31mtest_string_for_testing_TextWrap\033[0m_with_colors" + + expected = [ + "This_is_a_\033[31mte\033[0m", + "\033[31mst_string_fo\033[0m", + "\033[31mr_testing_Te\033[0m", + "\033[31mxtWrap\033[0m_with_", + "colors", + ] + 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 = [ + "012345(\x1b[32ma\x1b[0mbc\x1b[32m\x1b[0m", + "\x1b[32mdefghij\x1b[0m)", + ] + wrapper = CTW(width=10) + result = wrapper.wrap(data) + assert_equal(expected, result) + + clean_data = _strip_ansi(data) + for width in range(2, len(clean_data)): + wrapper = CTW(width=width) + result = wrapper.wrap(data) + # Comparing after stripping ANSI should be enough to catch broken escape codes + assert_equal(clean_data, _strip_ansi("".join(result))) + + def test_wrap_datetime(): """TextWrapper: Show that datetimes can be wrapped without crashing""" data = [ diff --git a/tox.ini b/tox.ini index c6260d2..9605e79 100644 --- a/tox.ini +++ b/tox.ini @@ -8,11 +8,19 @@ # for testing and it is disabled by default. [tox] -envlist = lint, py{37, 38, 39, 310, 311} +envlist = lint, py{38, 39, 310, 311, 312, 313} isolated_build = True +[gh] +python = + 3.9: py39-extra + 3.10: py310-extra + 3.11: py311-extra + 3.12: py312-extra + 3.13: py313-extra + [testenv] -commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} +commands = pytest -v --doctest-modules --ignore benchmark {posargs} deps = pytest passenv = @@ -25,30 +33,15 @@ 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} +commands = pytest -v --doctest-modules --ignore benchmark {posargs} deps = pytest [testenv:py38-extra] basepython = python3.8 -commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} +commands = pytest -v --doctest-modules --ignore benchmark {posargs} deps = pytest numpy @@ -58,13 +51,13 @@ deps = [testenv:py39] basepython = python3.9 -commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} +commands = pytest -v --doctest-modules --ignore benchmark {posargs} deps = pytest [testenv:py39-extra] basepython = python3.9 -commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} +commands = pytest -v --doctest-modules --ignore benchmark {posargs} deps = pytest numpy @@ -74,14 +67,14 @@ deps = [testenv:py310] basepython = python3.10 -commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} +commands = pytest -v --doctest-modules --ignore benchmark {posargs} deps = pytest [testenv:py310-extra] basepython = python3.10 setenv = PYTHONDEVMODE = 1 -commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} +commands = pytest -v --doctest-modules --ignore benchmark {posargs} deps = pytest numpy @@ -91,20 +84,51 @@ deps = [testenv:py311] basepython = python3.11 -commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} +commands = pytest -v --doctest-modules --ignore benchmark {posargs} deps = pytest [testenv:py311-extra] basepython = python3.11 setenv = PYTHONDEVMODE = 1 -commands = pytest -v --doctest-modules --ignore benchmark.py {posargs} +commands = pytest -v --doctest-modules --ignore benchmark {posargs} deps = pytest numpy pandas wcwidth +[testenv:py312] +basepython = python3.12 +commands = pytest -v --doctest-modules --ignore benchmark {posargs} +deps = + pytest + +[testenv:py312-extra] +basepython = python3.12 +setenv = PYTHONDEVMODE = 1 +commands = pytest -v --doctest-modules --ignore benchmark {posargs} +deps = + pytest + numpy + pandas + wcwidth + +[testenv:py313] +basepython = python3.13 +commands = pytest -v --doctest-modules --ignore benchmark {posargs} +deps = + pytest + +[testenv:py313-extra] +basepython = python3.13 +setenv = PYTHONDEVMODE = 1 +commands = pytest -v --doctest-modules --ignore benchmark {posargs} +deps = + pytest + numpy + pandas + wcwidth [flake8] max-complexity = 22