diff options
| -rw-r--r-- | .circleci/config.yml | 60 | ||||
| -rw-r--r-- | .circleci/requirements.txt | 10 | ||||
| -rw-r--r-- | .github/workflows/lint.yml | 26 | ||||
| -rw-r--r-- | .github/workflows/tabulate.yml | 28 | ||||
| -rw-r--r-- | .pre-commit-config.yaml | 10 | ||||
| -rw-r--r-- | CHANGELOG | 8 | ||||
| -rw-r--r-- | HOWTOPUBLISH | 18 | ||||
| -rw-r--r-- | PKG-INFO | 321 | ||||
| -rw-r--r-- | README.md | 301 | ||||
| -rw-r--r-- | appveyor.yml | 55 | ||||
| -rw-r--r-- | benchmark/benchmark.py (renamed from benchmark.py) | 9 | ||||
| -rw-r--r-- | benchmark/requirements.txt | 2 | ||||
| -rw-r--r-- | debian/changelog | 12 | ||||
| -rw-r--r-- | debian/clean | 1 | ||||
| -rw-r--r-- | debian/control | 10 | ||||
| -rwxr-xr-x | debian/rules | 3 | ||||
| -rw-r--r-- | pyproject.toml | 20 | ||||
| -rw-r--r-- | tabulate.egg-info/PKG-INFO | 321 | ||||
| -rw-r--r-- | tabulate.egg-info/SOURCES.txt | 9 | ||||
| -rw-r--r-- | tabulate/__init__.py | 582 | ||||
| -rw-r--r-- | tabulate/version.py | 5 | ||||
| -rw-r--r-- | test/common.py | 24 | ||||
| -rw-r--r-- | test/test_api.py | 12 | ||||
| -rw-r--r-- | test/test_cli.py | 5 | ||||
| -rw-r--r-- | test/test_input.py | 40 | ||||
| -rw-r--r-- | test/test_internal.py | 42 | ||||
| -rw-r--r-- | test/test_output.py | 445 | ||||
| -rw-r--r-- | test/test_regression.py | 165 | ||||
| -rw-r--r-- | test/test_textwrapper.py | 126 | ||||
| -rw-r--r-- | tox.ini | 103 |
30 files changed, 2121 insertions, 652 deletions
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..3bf20ee --- /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.13'] + + 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..141a686 --- /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', '3.14'] + 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/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7349858..28c2f8c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,15 +1,19 @@ repos: - repo: https://github.com/python/black - rev: 22.3.0 + rev: 25.1.0 hooks: - id: black args: [--safe] language_version: python3 - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.2.3 + rev: v6.0.0 hooks: - id: trailing-whitespace - id: check-yaml - id: debug-statements - - id: flake8 language_version: python3 +- repo: https://github.com/pycqa/flake8 + rev: 7.3.0 + hooks: + - id: flake8 + language_version: python3
\ No newline at end of file @@ -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..880ab19 100644 --- a/HOWTOPUBLISH +++ b/HOWTOPUBLISH @@ -1,7 +1,17 @@ # 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 -python3 -m build -nswx . +python -m pre_commit run -a # and then commit changes +tox -e py310-extra,py311-extra,py312-extra,py313-extra,py314-extra +# tag version release (vX.Y.Z) +python -m pip install build twine +python -m build -s # this will update tabulate/version.py +python -m 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/* +# use __token__ as username andPyPI API token as password (generate at pypi.org → Account settings → API tokens)
\ No newline at end of file @@ -1,23 +1,25 @@ -Metadata-Version: 2.1
+Metadata-Version: 2.4
Name: tabulate
-Version: 0.9.0
+Version: 0.10.0
Summary: Pretty-print tabular data
Author-email: Sergey Astanin <s.astanin@gmail.com>
-License: MIT
+License-Expression: MIT
Project-URL: Homepage, https://github.com/astanin/python-tabulate
Classifier: Development Status :: 4 - Beta
-Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
-Classifier: Programming Language :: Python :: 3.7
-Classifier: Programming Language :: Python :: 3.8
-Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
+Classifier: Programming Language :: Python :: 3.11
+Classifier: Programming Language :: Python :: 3.12
+Classifier: Programming Language :: Python :: 3.13
+Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries
-Requires-Python: >=3.7
+Requires-Python: >=3.10
Description-Content-Type: text/markdown
-Provides-Extra: widechars
License-File: LICENSE
+Provides-Extra: widechars
+Requires-Dist: wcwidth; extra == "widechars"
+Dynamic: license-file
python-tabulate
===============
@@ -74,7 +76,7 @@ pip install tabulate Build status
------------
-[](https://circleci.com/gh/astanin/python-tabulate/tree/master) [](https://ci.appveyor.com/project/astanin/python-tabulate/branch/master)
+[](https://github.com/astanin/python-tabulate/actions/workflows/tabulate.yml)
Library usage
-------------
@@ -95,6 +97,7 @@ Earth 6371 5973.6 Moon 1737 73.5
Mars 3390 641.85
----- ------ -------------
+
```
The following tabular data types are supported:
@@ -102,7 +105,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
@@ -122,6 +125,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:
@@ -133,6 +137,7 @@ Name Age ------ -----
Alice 24
Bob 19
+
```
If `headers="keys"`, then the keys of a dictionary/dataframe, or column
@@ -142,10 +147,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
@@ -163,6 +182,7 @@ or `showindex=False`. To add a custom row index column, pass 0 F 24
1 M 19
- - --
+
```
### Table format
@@ -219,6 +239,7 @@ item qty spam 42
eggs 451
bacon 0
+
```
`simple` is the default format (the default may change in future
@@ -232,6 +253,7 @@ item qty spam 42
eggs 451
bacon 0
+
```
`github` follows the conventions of GitHub flavored Markdown. It
@@ -239,11 +261,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'
@@ -261,6 +284,7 @@ corresponds to the `pipe` format without alignment colons: +--------+-------+
| bacon | 0 |
+--------+-------+
+
```
`simple_grid` draws a grid using single-line box-drawing characters:
@@ -342,8 +366,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"))
@@ -430,6 +471,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
@@ -444,6 +486,7 @@ library: | eggs | 451 |
| bacon | 0 |
+-------+-----+
+
```
`psql` is like tables formatted by Postgres' psql cli:
@@ -457,6 +500,7 @@ library: | eggs | 451 |
| bacon | 0 |
+--------+-------+
+
```
`pipe` follows the conventions of [PHP Markdown
@@ -471,6 +515,7 @@ indicate column alignment: | spam | 42 |
| eggs | 451 |
| bacon | 0 |
+
```
`asciidoc` formats data like a simple table of the
@@ -479,13 +524,14 @@ format: ```pycon
>>> print(tabulate(table, headers, tablefmt="asciidoc"))
-[cols="8<,7>",options="header"]
+[cols="<8,>7",options="header"]
|====
| item | qty
| spam | 42
| eggs | 451
| bacon | 0
|====
+
```
`orgtbl` follows the conventions of Emacs
@@ -499,6 +545,7 @@ in the minor orgtbl-mode. Hence its name: | spam | 42 |
| eggs | 451 |
| bacon | 0 |
+
```
`jira` follows the conventions of Atlassian Jira markup language:
@@ -509,6 +556,7 @@ in the minor orgtbl-mode. Hence its name: | spam | 42 |
| eggs | 451 |
| bacon | 0 |
+
```
`rst` formats data like a simple table of the
@@ -524,6 +572,7 @@ spam 42 eggs 451
bacon 0
====== =====
+
```
`mediawiki` format produces a table markup used in
@@ -535,14 +584,15 @@ MediaWiki-based sites: {| class="wikitable" style="text-align: left;"
|+ <!-- caption -->
|-
-! item !! align="right"| qty
+! item !! style="text-align: right;"| qty
|-
-| spam || align="right"| 42
+| spam || style="text-align: right;"| 42
|-
-| eggs || align="right"| 451
+| eggs || style="text-align: right;"| 451
|-
-| bacon || align="right"| 0
+| bacon || style="text-align: right;"| 0
|}
+
```
`moinmoin` format produces a table markup used in
@@ -550,20 +600,22 @@ MediaWiki-based sites: ```pycon
>>> print(tabulate(table, headers, tablefmt="moinmoin"))
-|| ''' item ''' || ''' quantity ''' ||
-|| spam || 41.999 ||
-|| eggs || 451 ||
-|| bacon || ||
+|| ''' item ''' ||<style="text-align: right;"> ''' qty ''' ||
+|| spam ||<style="text-align: right;"> 42 ||
+|| eggs ||<style="text-align: right;"> 451 ||
+|| bacon ||<style="text-align: right;"> 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
@@ -575,6 +627,7 @@ MediaWiki-based sites: |<. spam |>. 42 |
|<. eggs |>. 451 |
|<. bacon |>. 0 |
+
```
`html` produces standard HTML markup as an html.escape'd str
@@ -585,13 +638,16 @@ and a .str property so that the raw HTML remains accessible. ```pycon
>>> print(tabulate(table, headers, tablefmt="html"))
<table>
-<tbody>
+<thead>
<tr><th>item </th><th style="text-align: right;"> qty</th></tr>
+</thead>
+<tbody>
<tr><td>spam </td><td style="text-align: right;"> 42</td></tr>
<tr><td>eggs </td><td style="text-align: right;"> 451</td></tr>
<tr><td>bacon </td><td style="text-align: right;"> 0</td></tr>
</tbody>
</table>
+
```
`latex` format creates a `tabular` environment for LaTeX markup,
@@ -609,6 +665,7 @@ correspondents: bacon & 0 \\
\hline
\end{tabular}
+
```
`latex_raw` behaves like `latex` but does not escape LaTeX commands and
@@ -643,6 +700,7 @@ at a glance: 12345
1234.5
----------
+
```
Compare this with a more common right alignment:
@@ -656,6 +714,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.
@@ -664,7 +723,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']]
@@ -673,32 +732,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
@@ -712,6 +787,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
@@ -722,6 +798,7 @@ column, in which case every column may have different number formatting: --- ----- -------
0.1 0.123 0.12345
--- ----- -------
+
```
`intfmt` works similarly for integers
@@ -732,17 +809,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__` representation will be considered a `float`.
+
+Therefore, if your table contains types convertible to int/float but you'd
+*prefer* they be represented as strings, or your strings *might* all look
+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
@@ -787,6 +900,7 @@ a multiline cell, and headers with a multiline cell: ```pycon
>>> table = [["eggs",451],["more\nspam",42]]
>>> headers = ["item\nname", "qty"]
+
```
`plain` tables:
@@ -798,6 +912,7 @@ name eggs 451
more 42
spam
+
```
`simple` tables:
@@ -810,6 +925,7 @@ name eggs 451
more 42
spam
+
```
`grid` tables:
@@ -825,6 +941,7 @@ spam | more | 42 |
| spam | |
+--------+-------+
+
```
`fancy_grid` tables:
@@ -840,6 +957,7 @@ spam │ more │ 42 │
│ spam │ │
╘════════╧═══════╛
+
```
`pipe` tables:
@@ -852,6 +970,7 @@ spam | eggs | 451 |
| more | 42 |
| spam | |
+
```
`orgtbl` tables:
@@ -864,18 +983,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:
@@ -888,6 +1008,7 @@ spam eggs | 451
more | 42
spam |
+
```
`pretty` tables:
@@ -902,6 +1023,7 @@ spam | more | 42 |
| spam | |
+------+-----+
+
```
`psql` tables:
@@ -916,6 +1038,7 @@ spam | more | 42 |
| spam | |
+--------+-------+
+
```
`rst` tables:
@@ -930,6 +1053,7 @@ eggs 451 more 42
spam
====== =====
+
```
Multiline cells are not well-supported for the other table formats.
@@ -959,6 +1083,32 @@ the lines being wrapped would probably be significantly longer than this. | John Smith | Middle |
| | Manager |
+------------+---------+
+
+```
+
+Text is preferably wrapped on whitespaces and right after the hyphens in hyphenated words.
+
+break_long_words (default: True) If true, then words longer than width will be broken in order to ensure that no lines are longer than width.
+If it is false, long words will not be broken, and some lines may be longer than width.
+(Long words will be put on a line by themselves, in order to minimize the amount by which width is exceeded.)
+
+break_on_hyphens (default: True) If true, wrapping will occur preferably on whitespaces and right after hyphens in compound words, as it is customary in English.
+If false, only whitespaces will be considered as potentially good places for line breaks.
+
+```pycon
+>>> print(tabulate([["John Smith", "Middle-Manager"]], headers=["Name", "Title"], tablefmt="grid", maxcolwidths=[None, 5], break_long_words=False))
++------------+---------+
+| Name | Title |
++============+=========+
+| John Smith | Middle- |
+| | Manager |
++------------+---------+
+>>> print(tabulate([["John Smith", "Middle-Manager"]], headers=["Name", "Title"], tablefmt="grid", maxcolwidths=[None, 5], break_long_words=False, break_on_hyphens=False))
++------------+----------------+
+| Name | Title |
++============+================+
+| John Smith | Middle-Manager |
++------------+----------------+
```
### Adding Separating lines
@@ -996,12 +1146,12 @@ table, however, ANSI escape sequences are not removed so the original styling is Some terminals support a special grouping of ANSI escape sequences that are intended to display hyperlinks
much in the same way they are shown in browsers. These are handled just as mentioned before: non-printable
-ANSI escape sequences are removed prior to string length calculation. The only diifference with escaped
+ANSI escape sequences are removed prior to string length calculation. The only difference with escaped
hyperlinks is that column width will be based on the length of the URL _text_ rather than the URL
itself (terminals would show this text). For example:
>>> 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
@@ -1044,21 +1194,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.13.7 on Windows 11 (x64):
+
+ ================================== ========== ===========
+ Table formatter time, μs rel. time
+ ================================== ========== ===========
+ csv to StringIO 11.9 1.0
+ join with tabs and newlines 12.1 1.0
+ PrettyTable (3.17.0) 468.0 39.3
+ tabulate (0.10.0) 553.4 46.5
+ tabulate (0.10.0, WIDE_CHARS_MODE) 612.2 51.4
+ texttable (1.7.0) 1071.4 90.0
+ ================================== ========== ===========
Version history
@@ -1082,14 +1230,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
@@ -1097,10 +1245,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.
@@ -1108,7 +1256,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
@@ -1123,6 +1271,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
------------
@@ -1141,8 +1296,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.
@@ -53,7 +53,7 @@ pip install tabulate Build status ------------ -[](https://circleci.com/gh/astanin/python-tabulate/tree/master) [](https://ci.appveyor.com/project/astanin/python-tabulate/branch/master) +[](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 @@ -458,13 +501,14 @@ format: ```pycon >>> print(tabulate(table, headers, tablefmt="asciidoc")) -[cols="8<,7>",options="header"] +[cols="<8,>7",options="header"] |==== | 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 @@ -514,14 +561,15 @@ MediaWiki-based sites: {| class="wikitable" style="text-align: left;" |+ <!-- caption --> |- -! item !! align="right"| qty +! item !! style="text-align: right;"| qty |- -| spam || align="right"| 42 +| spam || style="text-align: right;"| 42 |- -| eggs || align="right"| 451 +| eggs || style="text-align: right;"| 451 |- -| bacon || align="right"| 0 +| 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 ''' ||<style="text-align: right;"> ''' qty ''' || +|| spam ||<style="text-align: right;"> 42 || +|| eggs ||<style="text-align: right;"> 451 || +|| bacon ||<style="text-align: right;"> 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")) <table> -<tbody> +<thead> <tr><th>item </th><th style="text-align: right;"> qty</th></tr> +</thead> +<tbody> <tr><td>spam </td><td style="text-align: right;"> 42</td></tr> <tr><td>eggs </td><td style="text-align: right;"> 451</td></tr> <tr><td>bacon </td><td style="text-align: right;"> 0</td></tr> </tbody> </table> + ``` `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__` representation will be considered a `float`. + +Therefore, if your table contains types convertible to int/float but you'd +*prefer* they be represented as strings, or your strings *might* all look +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,32 @@ the lines being wrapped would probably be significantly longer than this. | John Smith | Middle | | | Manager | +------------+---------+ + +``` + +Text is preferably wrapped on whitespaces and right after the hyphens in hyphenated words. + +break_long_words (default: True) If true, then words longer than width will be broken in order to ensure that no lines are longer than width. +If it is false, long words will not be broken, and some lines may be longer than width. +(Long words will be put on a line by themselves, in order to minimize the amount by which width is exceeded.) + +break_on_hyphens (default: True) If true, wrapping will occur preferably on whitespaces and right after hyphens in compound words, as it is customary in English. +If false, only whitespaces will be considered as potentially good places for line breaks. + +```pycon +>>> print(tabulate([["John Smith", "Middle-Manager"]], headers=["Name", "Title"], tablefmt="grid", maxcolwidths=[None, 5], break_long_words=False)) ++------------+---------+ +| Name | Title | ++============+=========+ +| John Smith | Middle- | +| | Manager | ++------------+---------+ +>>> print(tabulate([["John Smith", "Middle-Manager"]], headers=["Name", "Title"], tablefmt="grid", maxcolwidths=[None, 5], break_long_words=False, break_on_hyphens=False)) ++------------+----------------+ +| Name | Title | ++============+================+ +| John Smith | Middle-Manager | ++------------+----------------+ ``` ### Adding Separating lines @@ -975,12 +1123,12 @@ table, however, ANSI escape sequences are not removed so the original styling is Some terminals support a special grouping of ANSI escape sequences that are intended to display hyperlinks much in the same way they are shown in browsers. These are handled just as mentioned before: non-printable -ANSI escape sequences are removed prior to string length calculation. The only diifference with escaped +ANSI escape sequences are removed prior to string length calculation. The only difference with escaped hyperlinks is that column width will be based on the length of the URL _text_ rather than the URL itself (terminals would show this text). For example: >>> 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 +1171,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.13.7 on Windows 11 (x64): + + ================================== ========== =========== + Table formatter time, μs rel. time + ================================== ========== =========== + csv to StringIO 11.9 1.0 + join with tabs and newlines 12.1 1.0 + PrettyTable (3.17.0) 468.0 39.3 + tabulate (0.10.0) 553.4 46.5 + tabulate (0.10.0, WIDE_CHARS_MODE) 612.2 51.4 + texttable (1.7.0) 1071.4 90.0 + ================================== ========== =========== Version history @@ -1061,14 +1207,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 +1222,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 +1233,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 +1248,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 +1273,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 e7aae8e..0000000 --- a/appveyor.yml +++ /dev/null @@ -1,55 +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" - -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 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 @@ setup_code = r""" 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/debian/changelog b/debian/changelog index 6a0287b..c2c85e1 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,15 @@ +python-tabulate (0.10.0-1) unstable; urgency=medium + + * Team upload. + * New upstream release. + * Use dh-sequence-python3. + * Run tests at build time. + * Drop "Priority: optional", default as of dpkg-dev 1.22.13. + * Standards-Version: 4.7.4. + * Clean *.egg-info/ (closes: #1046910). + + -- Colin Watson <cjwatson@debian.org> Wed, 03 Jun 2026 10:55:04 +0100 + python-tabulate (0.9.0-1) unstable; urgency=medium * New upstream version 0.9.0 (Closes: #1070359) diff --git a/debian/clean b/debian/clean new file mode 100644 index 0000000..3feb78a --- /dev/null +++ b/debian/clean @@ -0,0 +1 @@ +*.egg-info/ diff --git a/debian/control b/debian/control index 39e4aea..d92397d 100644 --- a/debian/control +++ b/debian/control @@ -1,16 +1,18 @@ Source: python-tabulate -Priority: optional Maintainer: Debian Python Team <team+python@tracker.debian.org> Uploaders: Yago González <yagogonzalezg@gmail.com>, ChangZhuo Chen (陳昌倬) <czchen@debian.org> Build-Depends: debhelper-compat (= 13), - dh-python, + dh-sequence-python3, pybuild-plugin-pyproject, python3-all, + python3-numpy <!nocheck>, + python3-pandas <!nocheck>, + python3-pytest <!nocheck>, python3-setuptools, python3-setuptools-scm, - python3-wcwidth, -Standards-Version: 4.7.0 + python3-wcwidth <!nocheck>, +Standards-Version: 4.7.4 Section: python Homepage: https://github.com/astanin/python-tabulate/ Vcs-Git: https://salsa.debian.org/python-team/packages/python-tabulate.git diff --git a/debian/rules b/debian/rules index 287c00b..f0464be 100755 --- a/debian/rules +++ b/debian/rules @@ -1,6 +1,7 @@ #!/usr/bin/make -f #DH_VERBOSE = 1 export PYBUILD_NAME = tabulate +export PYBUILD_TEST_ARGS := --doctest-modules %: - dh $@ --with python3 --buildsystem=pybuild + dh $@ --buildsystem=pybuild diff --git a/pyproject.toml b/pyproject.toml index 85ede02..b602919 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,25 +1,26 @@ [build-system] -requires = ["setuptools>=61.2.0", "wheel", "setuptools_scm[toml]>=3.4.3"] +requires = ["setuptools>=77.0.3", "setuptools_scm[toml]>=3.4.3"] build-backend = "setuptools.build_meta" [project] name = "tabulate" authors = [{name = "Sergey Astanin", email = "s.astanin@gmail.com"}] -license = {text = "MIT"} +license = "MIT" +license-files = ["LICENSE"] description = "Pretty-print tabular data" readme = "README.md" classifiers = [ "Development Status :: 4 - Beta", - "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.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", + "Programming Language :: Python :: 3.14", "Topic :: Software Development :: Libraries", ] -requires-python = ">=3.7" +requires-python = ">=3.10" dynamic = ["version"] [project.urls] @@ -31,8 +32,7 @@ widechars = ["wcwidth"] [project.scripts] tabulate = "tabulate:_main" -[tool.setuptools] -packages = ["tabulate"] +# [tool.setuptools] +# packages = ["tabulate"] [tool.setuptools_scm] -write_to = "tabulate/version.py" diff --git a/tabulate.egg-info/PKG-INFO b/tabulate.egg-info/PKG-INFO index 3884e13..2cf4c72 100644 --- a/tabulate.egg-info/PKG-INFO +++ b/tabulate.egg-info/PKG-INFO @@ -1,23 +1,25 @@ -Metadata-Version: 2.1
+Metadata-Version: 2.4
Name: tabulate
-Version: 0.9.0
+Version: 0.10.0
Summary: Pretty-print tabular data
Author-email: Sergey Astanin <s.astanin@gmail.com>
-License: MIT
+License-Expression: MIT
Project-URL: Homepage, https://github.com/astanin/python-tabulate
Classifier: Development Status :: 4 - Beta
-Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
-Classifier: Programming Language :: Python :: 3.7
-Classifier: Programming Language :: Python :: 3.8
-Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
+Classifier: Programming Language :: Python :: 3.11
+Classifier: Programming Language :: Python :: 3.12
+Classifier: Programming Language :: Python :: 3.13
+Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries
-Requires-Python: >=3.7
+Requires-Python: >=3.10
Description-Content-Type: text/markdown
-Provides-Extra: widechars
License-File: LICENSE
+Provides-Extra: widechars
+Requires-Dist: wcwidth; extra == "widechars"
+Dynamic: license-file
python-tabulate
===============
@@ -74,7 +76,7 @@ pip install tabulate Build status
------------
-[](https://circleci.com/gh/astanin/python-tabulate/tree/master) [](https://ci.appveyor.com/project/astanin/python-tabulate/branch/master)
+[](https://github.com/astanin/python-tabulate/actions/workflows/tabulate.yml)
Library usage
-------------
@@ -95,6 +97,7 @@ Earth 6371 5973.6 Moon 1737 73.5
Mars 3390 641.85
----- ------ -------------
+
```
The following tabular data types are supported:
@@ -102,7 +105,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
@@ -122,6 +125,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:
@@ -133,6 +137,7 @@ Name Age ------ -----
Alice 24
Bob 19
+
```
If `headers="keys"`, then the keys of a dictionary/dataframe, or column
@@ -142,10 +147,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
@@ -163,6 +182,7 @@ or `showindex=False`. To add a custom row index column, pass 0 F 24
1 M 19
- - --
+
```
### Table format
@@ -219,6 +239,7 @@ item qty spam 42
eggs 451
bacon 0
+
```
`simple` is the default format (the default may change in future
@@ -232,6 +253,7 @@ item qty spam 42
eggs 451
bacon 0
+
```
`github` follows the conventions of GitHub flavored Markdown. It
@@ -239,11 +261,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'
@@ -261,6 +284,7 @@ corresponds to the `pipe` format without alignment colons: +--------+-------+
| bacon | 0 |
+--------+-------+
+
```
`simple_grid` draws a grid using single-line box-drawing characters:
@@ -342,8 +366,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"))
@@ -430,6 +471,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
@@ -444,6 +486,7 @@ library: | eggs | 451 |
| bacon | 0 |
+-------+-----+
+
```
`psql` is like tables formatted by Postgres' psql cli:
@@ -457,6 +500,7 @@ library: | eggs | 451 |
| bacon | 0 |
+--------+-------+
+
```
`pipe` follows the conventions of [PHP Markdown
@@ -471,6 +515,7 @@ indicate column alignment: | spam | 42 |
| eggs | 451 |
| bacon | 0 |
+
```
`asciidoc` formats data like a simple table of the
@@ -479,13 +524,14 @@ format: ```pycon
>>> print(tabulate(table, headers, tablefmt="asciidoc"))
-[cols="8<,7>",options="header"]
+[cols="<8,>7",options="header"]
|====
| item | qty
| spam | 42
| eggs | 451
| bacon | 0
|====
+
```
`orgtbl` follows the conventions of Emacs
@@ -499,6 +545,7 @@ in the minor orgtbl-mode. Hence its name: | spam | 42 |
| eggs | 451 |
| bacon | 0 |
+
```
`jira` follows the conventions of Atlassian Jira markup language:
@@ -509,6 +556,7 @@ in the minor orgtbl-mode. Hence its name: | spam | 42 |
| eggs | 451 |
| bacon | 0 |
+
```
`rst` formats data like a simple table of the
@@ -524,6 +572,7 @@ spam 42 eggs 451
bacon 0
====== =====
+
```
`mediawiki` format produces a table markup used in
@@ -535,14 +584,15 @@ MediaWiki-based sites: {| class="wikitable" style="text-align: left;"
|+ <!-- caption -->
|-
-! item !! align="right"| qty
+! item !! style="text-align: right;"| qty
|-
-| spam || align="right"| 42
+| spam || style="text-align: right;"| 42
|-
-| eggs || align="right"| 451
+| eggs || style="text-align: right;"| 451
|-
-| bacon || align="right"| 0
+| bacon || style="text-align: right;"| 0
|}
+
```
`moinmoin` format produces a table markup used in
@@ -550,20 +600,22 @@ MediaWiki-based sites: ```pycon
>>> print(tabulate(table, headers, tablefmt="moinmoin"))
-|| ''' item ''' || ''' quantity ''' ||
-|| spam || 41.999 ||
-|| eggs || 451 ||
-|| bacon || ||
+|| ''' item ''' ||<style="text-align: right;"> ''' qty ''' ||
+|| spam ||<style="text-align: right;"> 42 ||
+|| eggs ||<style="text-align: right;"> 451 ||
+|| bacon ||<style="text-align: right;"> 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
@@ -575,6 +627,7 @@ MediaWiki-based sites: |<. spam |>. 42 |
|<. eggs |>. 451 |
|<. bacon |>. 0 |
+
```
`html` produces standard HTML markup as an html.escape'd str
@@ -585,13 +638,16 @@ and a .str property so that the raw HTML remains accessible. ```pycon
>>> print(tabulate(table, headers, tablefmt="html"))
<table>
-<tbody>
+<thead>
<tr><th>item </th><th style="text-align: right;"> qty</th></tr>
+</thead>
+<tbody>
<tr><td>spam </td><td style="text-align: right;"> 42</td></tr>
<tr><td>eggs </td><td style="text-align: right;"> 451</td></tr>
<tr><td>bacon </td><td style="text-align: right;"> 0</td></tr>
</tbody>
</table>
+
```
`latex` format creates a `tabular` environment for LaTeX markup,
@@ -609,6 +665,7 @@ correspondents: bacon & 0 \\
\hline
\end{tabular}
+
```
`latex_raw` behaves like `latex` but does not escape LaTeX commands and
@@ -643,6 +700,7 @@ at a glance: 12345
1234.5
----------
+
```
Compare this with a more common right alignment:
@@ -656,6 +714,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.
@@ -664,7 +723,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']]
@@ -673,32 +732,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
@@ -712,6 +787,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
@@ -722,6 +798,7 @@ column, in which case every column may have different number formatting: --- ----- -------
0.1 0.123 0.12345
--- ----- -------
+
```
`intfmt` works similarly for integers
@@ -732,17 +809,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__` representation will be considered a `float`.
+
+Therefore, if your table contains types convertible to int/float but you'd
+*prefer* they be represented as strings, or your strings *might* all look
+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
@@ -787,6 +900,7 @@ a multiline cell, and headers with a multiline cell: ```pycon
>>> table = [["eggs",451],["more\nspam",42]]
>>> headers = ["item\nname", "qty"]
+
```
`plain` tables:
@@ -798,6 +912,7 @@ name eggs 451
more 42
spam
+
```
`simple` tables:
@@ -810,6 +925,7 @@ name eggs 451
more 42
spam
+
```
`grid` tables:
@@ -825,6 +941,7 @@ spam | more | 42 |
| spam | |
+--------+-------+
+
```
`fancy_grid` tables:
@@ -840,6 +957,7 @@ spam │ more │ 42 │
│ spam │ │
╘════════╧═══════╛
+
```
`pipe` tables:
@@ -852,6 +970,7 @@ spam | eggs | 451 |
| more | 42 |
| spam | |
+
```
`orgtbl` tables:
@@ -864,18 +983,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:
@@ -888,6 +1008,7 @@ spam eggs | 451
more | 42
spam |
+
```
`pretty` tables:
@@ -902,6 +1023,7 @@ spam | more | 42 |
| spam | |
+------+-----+
+
```
`psql` tables:
@@ -916,6 +1038,7 @@ spam | more | 42 |
| spam | |
+--------+-------+
+
```
`rst` tables:
@@ -930,6 +1053,7 @@ eggs 451 more 42
spam
====== =====
+
```
Multiline cells are not well-supported for the other table formats.
@@ -959,6 +1083,32 @@ the lines being wrapped would probably be significantly longer than this. | John Smith | Middle |
| | Manager |
+------------+---------+
+
+```
+
+Text is preferably wrapped on whitespaces and right after the hyphens in hyphenated words.
+
+break_long_words (default: True) If true, then words longer than width will be broken in order to ensure that no lines are longer than width.
+If it is false, long words will not be broken, and some lines may be longer than width.
+(Long words will be put on a line by themselves, in order to minimize the amount by which width is exceeded.)
+
+break_on_hyphens (default: True) If true, wrapping will occur preferably on whitespaces and right after hyphens in compound words, as it is customary in English.
+If false, only whitespaces will be considered as potentially good places for line breaks.
+
+```pycon
+>>> print(tabulate([["John Smith", "Middle-Manager"]], headers=["Name", "Title"], tablefmt="grid", maxcolwidths=[None, 5], break_long_words=False))
++------------+---------+
+| Name | Title |
++============+=========+
+| John Smith | Middle- |
+| | Manager |
++------------+---------+
+>>> print(tabulate([["John Smith", "Middle-Manager"]], headers=["Name", "Title"], tablefmt="grid", maxcolwidths=[None, 5], break_long_words=False, break_on_hyphens=False))
++------------+----------------+
+| Name | Title |
++============+================+
+| John Smith | Middle-Manager |
++------------+----------------+
```
### Adding Separating lines
@@ -996,12 +1146,12 @@ table, however, ANSI escape sequences are not removed so the original styling is Some terminals support a special grouping of ANSI escape sequences that are intended to display hyperlinks
much in the same way they are shown in browsers. These are handled just as mentioned before: non-printable
-ANSI escape sequences are removed prior to string length calculation. The only diifference with escaped
+ANSI escape sequences are removed prior to string length calculation. The only difference with escaped
hyperlinks is that column width will be based on the length of the URL _text_ rather than the URL
itself (terminals would show this text). For example:
>>> 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
@@ -1044,21 +1194,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.13.7 on Windows 11 (x64):
+
+ ================================== ========== ===========
+ Table formatter time, μs rel. time
+ ================================== ========== ===========
+ csv to StringIO 11.9 1.0
+ join with tabs and newlines 12.1 1.0
+ PrettyTable (3.17.0) 468.0 39.3
+ tabulate (0.10.0) 553.4 46.5
+ tabulate (0.10.0, WIDE_CHARS_MODE) 612.2 51.4
+ texttable (1.7.0) 1071.4 90.0
+ ================================== ========== ===========
Version history
@@ -1082,14 +1230,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
@@ -1097,10 +1245,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.
@@ -1108,7 +1256,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
@@ -1123,6 +1271,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
------------
@@ -1141,8 +1296,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/tabulate.egg-info/SOURCES.txt b/tabulate.egg-info/SOURCES.txt index 2e49f97..e3d60e4 100644 --- a/tabulate.egg-info/SOURCES.txt +++ b/tabulate.egg-info/SOURCES.txt @@ -6,14 +6,13 @@ LICENSE MANIFEST.in README README.md -appveyor.yml -benchmark.py pyproject.toml tox.ini -.circleci/config.yml -.circleci/requirements.txt +.github/workflows/lint.yml +.github/workflows/tabulate.yml +benchmark/benchmark.py +benchmark/requirements.txt tabulate/__init__.py -tabulate/version.py tabulate.egg-info/PKG-INFO tabulate.egg-info/SOURCES.txt tabulate.egg-info/dependency_links.txt diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 503df34..fb157f4 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -1,5 +1,16 @@ """Pretty-print tabular data.""" +from importlib.metadata import ( + version as _version, + PackageNotFoundError as _PackageNotFoundError, +) + +try: + __version__ = _version("tabulate") +except _PackageNotFoundError: + __version__ = "unknown" + +import warnings from collections import namedtuple from collections.abc import Iterable, Sized from html import escape as htmlescape @@ -10,6 +21,7 @@ import re import math import textwrap import dataclasses +import sys try: import wcwidth # optional wide-character (CJK) support @@ -34,6 +46,12 @@ MIN_PADDING = 2 # Whether or not to preserve leading/trailing whitespace in data. PRESERVE_WHITESPACE = False +# TextWrapper breaks words longer than 'width'. +_BREAK_LONG_WORDS = True +# TextWrapper is breaking hyphenated words. +_BREAK_ON_HYPHENS = True + + _DEFAULT_FLOATFMT = "g" _DEFAULT_INTFMT = "" _DEFAULT_MISSINGVAL = "" @@ -101,12 +119,17 @@ TableFormat = namedtuple( ) +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 @@ -129,16 +152,43 @@ def _pipe_line_with_colons(colwidths, colaligns): alignment (as in `pipe` output format).""" if not colaligns: # e.g. printing an empty data frame (github issue #15) colaligns = [""] * len(colwidths) - segments = [_pipe_segment_with_colons(a, w) for a, w in zip(colaligns, colwidths)] - return "|" + "|".join(segments) + "|" + segments = "|".join( + _pipe_segment_with_colons(a, w) for a, w in zip(colaligns, colwidths) + ) + return f"|{segments}|" + + +def _grid_segment_with_colons(colwidth, align): + """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 = "+".join( + _grid_segment_with_colons(w, a) for a, w in zip(colaligns, colwidths) + ) + return f"+{segments}+" def _mediawiki_row_with_attrs(separator, cell_values, colwidths, colaligns): alignment = { "left": "", - "right": 'align="right"| ', - "center": 'align="center"| ', - "decimal": 'align="right"| ', + "right": 'style="text-align: right;"| ', + "center": 'style="text-align: center;"| ', + "decimal": 'style="text-align: right;"| ', } # hard-coded padding _around_ align attribute and value together # rather than padding parameter which affects only the value @@ -152,8 +202,8 @@ def _mediawiki_row_with_attrs(separator, cell_values, colwidths, colaligns): def _textile_row_with_attrs(cell_values, colwidths, colaligns): cell_values[0] += " " alignment = {"left": "<.", "right": ">.", "center": "=.", "decimal": ">."} - values = (alignment.get(a, "") + v for a, v in zip(colaligns, cell_values)) - return "|" + "|".join(values) + "|" + values = "|".join(alignment.get(a, "") + v for a, v in zip(colaligns, cell_values)) + return f"|{values}|" def _html_begin_table_without_header(colwidths_ignore, colaligns_ignore): @@ -223,7 +273,7 @@ def _asciidoc_row(is_header, *args): colwidths, [alignment[colalign] for colalign in colaligns] ) asciidoc_column_specifiers = [ - "{:d}{}".format(width, align) for width, align in asciidoc_alignments + f"{align}{width:d}" for width, align in asciidoc_alignments ] header_list = ['cols="' + (",".join(asciidoc_column_specifiers)) + '"'] @@ -234,7 +284,8 @@ def _asciidoc_row(is_header, *args): options_list.append("header") if options_list: - header_list += ['options="' + ",".join(options_list) + '"'] + options_list = ",".join(options_list) + header_list.append(f'options="{options_list}"') # generate the list of entries in the table header field @@ -259,7 +310,7 @@ def _asciidoc_row(is_header, *args): else: raise ValueError( - " _asciidoc_row() requires two (colwidths, colaligns) " + "_asciidoc_row() requires two (colwidths, colaligns) " + "or three (cell_values, colwidths, colaligns) arguments) " ) @@ -399,6 +450,16 @@ _table_formats = { 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("+", "=", "+", "+"), @@ -677,7 +738,7 @@ _table_formats = { } -tabulate_formats = list(sorted(_table_formats.keys())) +tabulate_formats = sorted(_table_formats.keys()) # The table formats for which multiline cells will be folded into subsequent # table rows. The key is the original format specified at the API. The value is @@ -692,6 +753,7 @@ multiline_formats = { "mixed_grid": "mixed_grid", "double_grid": "double_grid", "fancy_grid": "fancy_grid", + "colon_grid": "colon_grid", "pipe": "pipe", "orgtbl": "orgtbl", "jira": "jira", @@ -699,6 +761,14 @@ multiline_formats = { "pretty": "pretty", "psql": "psql", "rst": "rst", + "github": "github", + "outline": "outline", + "simple_outline": "simple_outline", + "rounded_outline": "rounded_outline", + "heavy_outline": "heavy_outline", + "mixed_outline": "mixed_outline", + "double_outline": "double_outline", + "fancy_outline": "fancy_outline", } # TODO: Add multiline support for the remaining table formats: @@ -831,25 +901,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): @@ -861,8 +961,13 @@ def _isint(string, inttype=int): """ return ( type(string) is inttype - or isinstance(string, (bytes, str)) - and _isconvertible(inttype, string) + or ( + (hasattr(string, "is_integer") or hasattr(string, "__array__")) + and str(type(string)).startswith("<class 'numpy.int") + ) # numpy.int64 and similar + or ( + isinstance(string, (bytes, str)) and _isconvertible(inttype, string) + ) # integer as string ) @@ -883,8 +988,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) @@ -899,15 +1009,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 @@ -1040,19 +1160,19 @@ def _choose_width_fn(has_invisible, enable_widechars, is_multiline): else: line_width_fn = len if is_multiline: - width_fn = lambda s: _multiline_width(s, line_width_fn) # noqa + width_fn = lambda s: _multiline_width(s, line_width_fn) # noqa: E731 else: width_fn = line_width_fn return width_fn -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": @@ -1066,7 +1186,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 @@ -1080,7 +1200,9 @@ def _align_column_choose_width_fn(has_invisible, enable_widechars, is_multiline) else: line_width_fn = len if is_multiline: - width_fn = lambda s: _align_column_multiline_width(s, line_width_fn) # noqa + width_fn = lambda s: _align_column_multiline_width( # noqa: E731 + s, line_width_fn + ) else: width_fn = line_width_fn return width_fn @@ -1095,8 +1217,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 @@ -1109,9 +1230,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 ) @@ -1199,7 +1323,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: @@ -1209,13 +1333,32 @@ def _format(val, valtype, floatfmt, intfmt, missingval="", has_invisible=True): tabulate(tbl, headers=hrow) == good_result True - """ # noqa + """ # noqa: E501 if val is None: return missingval + if isinstance(val, (bytes, str)) and not val: + 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: @@ -1226,10 +1369,18 @@ def _format(val, valtype, floatfmt, intfmt, missingval="", has_invisible=True): is_a_colored_number = has_invisible and isinstance(val, (str, bytes)) if is_a_colored_number: raw_val = _strip_ansi(val) - formatted_val = format(float(raw_val), floatfmt) + try: + formatted_val = format(float(raw_val), floatfmt) + except (ValueError, TypeError): + return f"{val}" return val.replace(raw_val, formatted_val) else: - return format(float(val), floatfmt) + if isinstance(val, str) and "," in val: + val = val.replace(",", "") # handle thousands-separators + try: + return format(float(val), floatfmt) + except (ValueError, TypeError): + return f"{val}" else: return f"{val}" @@ -1258,7 +1409,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): @@ -1284,7 +1435,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 = [] @@ -1306,7 +1457,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: @@ -1318,7 +1470,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 @@ -1340,20 +1492,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__"): + if callable(tabular_data.values): # 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) @@ -1376,7 +1535,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) @@ -1384,7 +1546,7 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"): elif ( headers == "keys" and hasattr(tabular_data, "dtype") - and getattr(tabular_data.dtype, "names") + and tabular_data.dtype.names ): # numpy record array headers = tabular_data.dtype.names @@ -1444,11 +1606,18 @@ 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 - rows = [[getattr(row, f) for f in field_names] for row in rows] + rows = [ + ( + [getattr(row, f) for f in field_names] + if not _is_separating_line(row) + else row + ) + for row in rows + ] elif headers == "keys" and len(rows) > 0: # keys are column indices @@ -1486,17 +1655,27 @@ 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): - numparses = _expand_iterable(numparses, len(list_of_lists[0]), True) +def _wrap_text_to_colwidths( + list_of_lists, + colwidths, + numparses=True, + missingval=_DEFAULT_MISSINGVAL, + break_long_words=_BREAK_LONG_WORDS, + break_on_hyphens=_BREAK_ON_HYPHENS, +): + if len(list_of_lists): + num_cols = len(list_of_lists[0]) + else: + num_cols = 0 + numparses = _expand_iterable(numparses, num_cols, True) result = [] @@ -1508,14 +1687,29 @@ def _wrap_text_to_colwidths(list_of_lists, colwidths, numparses=True): continue 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 + wrapper = _CustomTextWrap( + width=width, + break_long_words=break_long_words, + break_on_hyphens=break_on_hyphens, + ) + # Cast based on our internal type handling. Any future custom + # formatting of types (such as datetimes) may need to be more + # explicit than just `str` of the object. Also doesn't work for + # custom floatfmt/intfmt, nor with any missing/blank cells. casted_cell = ( - str(cell) if _isnumber(cell) else _type(cell, numparse)(cell) + missingval + if cell is None + else ( + str(cell) + if cell == "" or _isnumber(cell) + else str(_type(cell, numparse)(cell)) + ) ) - wrapped = wrapper.wrap(casted_cell) + wrapped = [ + "\n".join(wrapper.wrap(line)) + for line in casted_cell.splitlines() + if line.strip() != "" + ] new_row.append("\n".join(wrapped)) else: new_row.append(cell) @@ -1560,10 +1754,16 @@ 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, + break_long_words=_BREAK_LONG_WORDS, + break_on_hyphens=_BREAK_ON_HYPHENS, ): """Format a fixed width table for pretty printing. @@ -1577,7 +1777,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. @@ -1616,8 +1816,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 @@ -1626,6 +1826,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 ------------- @@ -1782,6 +1999,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"]], @@ -1932,11 +2174,11 @@ def tabulate( {| class="wikitable" style="text-align: left;" |+ <!-- caption --> |- - ! strings !! align="right"| numbers + ! strings !! style="text-align: right;"| numbers |- - | spam || align="right"| 41.9999 + | spam || style="text-align: right;"| 41.9999 |- - | eggs || align="right"| 451 + | eggs || style="text-align: right;"| 451 |} "html" produces HTML markup as an html.escape'd str @@ -2020,8 +2262,8 @@ def tabulate( Tabulate will, by default, set the width of each column to the length of the longest element in that column. However, in situations where fields are expected to reasonably be too long to look good as a single line, tabulate can help automate - word wrapping long fields for you. Use the parameter `maxcolwidth` to provide a - list of maximal column widths + word wrapping long fields for you. Use the parameter `maxcolwidths` to provide a + list of maximal column widths: >>> print(tabulate( \ [('1', 'John Smith', \ @@ -2038,20 +2280,25 @@ def tabulate( | | | better if it is wrapped a bit | +------------+------------+-------------------------------+ - Header column width can be specified in a similar way using `maxheadercolwidth` + Header column width can be specified in a similar way using `maxheadercolwidths`. """ 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: - num_cols = len(list_of_lists[0]) + 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: + num_cols = 0 if isinstance(maxcolwidths, int): # Expand scalar for all columns maxcolwidths = _expand_iterable(maxcolwidths, num_cols, maxcolwidths) else: # Ignore col width for any 'trailing' columns @@ -2059,11 +2306,16 @@ def tabulate( numparses = _expand_numparse(disable_numparse, num_cols) list_of_lists = _wrap_text_to_colwidths( - list_of_lists, maxcolwidths, numparses=numparses + list_of_lists, + maxcolwidths, + numparses=numparses, + missingval=missingval, + break_long_words=break_long_words, + break_on_hyphens=break_on_hyphens, ) if maxheadercolwidths is not None: - num_cols = len(list_of_lists[0]) + num_cols = len(list_of_lists[0]) if list_of_lists else len(headers) if isinstance(maxheadercolwidths, int): # Expand scalar for all columns maxheadercolwidths = _expand_iterable( maxheadercolwidths, num_cols, maxheadercolwidths @@ -2073,7 +2325,12 @@ def tabulate( numparses = _expand_numparse(disable_numparse, num_cols) headers = _wrap_text_to_colwidths( - [headers], maxheadercolwidths, numparses=numparses + [headers], + maxheadercolwidths, + numparses=numparses, + missingval=missingval, + break_long_words=break_long_words, + break_on_hyphens=break_on_hyphens, )[0] # empty values in the first column of RST tables should be escaped (issue #82) @@ -2095,6 +2352,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 # @@ -2158,30 +2422,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: @@ -2196,7 +2510,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, ) @@ -2233,6 +2554,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 @@ -2250,7 +2573,7 @@ def _build_row(padded_cells, colwidths, colaligns, rowfmt): "Return a string which represents a row of data cells." if not rowfmt: return None - if hasattr(rowfmt, "__call__"): + if callable(rowfmt): return rowfmt(padded_cells, colwidths, colaligns) else: return _build_simple_row(padded_cells, rowfmt) @@ -2301,7 +2624,7 @@ def _build_line(colwidths, colaligns, linefmt): "Return a string which represents a horizontal line." if not linefmt: return None - if hasattr(linefmt, "__call__"): + if callable(linefmt): return linefmt(colwidths, colaligns) else: begin, fill, sep, end = linefmt @@ -2327,7 +2650,9 @@ class JupyterHTMLStr(str): 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 [] @@ -2336,34 +2661,41 @@ def _format_table(fmt, headers, rows, colwidths, colaligns, is_multiline, rowali padded_widths = [(w + 2 * pad) for w in colwidths] if is_multiline: - pad_row = lambda row, _: row # noqa do it later, in _append_multiline_row + pad_row = ( + lambda row, _: row + ) # noqa: E731 # do it later, in _append_multiline_row append_row = partial(_append_multiline_row, pad=pad) else: pad_row = _pad_row 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, @@ -2377,13 +2709,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) @@ -2441,7 +2775,7 @@ class _CustomTextWrap(textwrap.TextWrapper): 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 @@ -2464,15 +2798,32 @@ class _CustomTextWrap(textwrap.TextWrapper): # If we're allowed to break long words, then do so: put as much # of the next chunk onto the current line as will fit. - if self.break_long_words: + if self.break_long_words and space_left > 0: # Tabulate Custom: Build the string up piece-by-piece in order to # take each charcter's width into account chunk = reversed_chunks[-1] i = 1 - while self._len(chunk[:i]) <= space_left: + # Only count printable characters, so strip_ansi first, index later. + stripped_chunk = _strip_ansi(chunk) + while ( + i <= len(stripped_chunk) and self._len(stripped_chunk[:i]) <= space_left + ): 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 -- @@ -2623,15 +2974,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) @@ -2667,7 +3025,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/tabulate/version.py b/tabulate/version.py deleted file mode 100644 index 1dd234c..0000000 --- a/tabulate/version.py +++ /dev/null @@ -1,5 +0,0 @@ -# coding: utf-8
-# file generated by setuptools_scm
-# don't change, don't track in version control
-__version__ = version = '0.9.0'
-__version_tuple__ = version_tuple = (0, 9, 0)
diff --git a/test/common.py b/test/common.py index d95e84f..31d6b82 100644 --- a/test/common.py +++ b/test/common.py @@ -1,10 +1,11 @@ -import pytest # noqa -from pytest import skip, raises # noqa +import pytest # noqa: F401 +from pytest import skip, raises # noqa: F401 +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..e186442 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -1,6 +1,4 @@ -"""API properties. - -""" +"""API properties.""" from tabulate import tabulate, tabulate_formats, simple_separated_format from common import skip @@ -26,7 +24,7 @@ def _check_signature(function, expected_sig): if not signature: skip("") actual_sig = signature(function) - print(f"expected: {expected_sig}\nactual: {str(actual_sig)}\n") + print(f"expected: {expected_sig}\nactual: {actual_sig}\n") assert len(actual_sig.parameters) == len(expected_sig) @@ -48,10 +46,16 @@ 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), + ("break_long_words", True), + ("break_on_hyphens", True), ] _check_signature(tabulate, expected_sig) diff --git a/test/test_cli.py b/test/test_cli.py index ce85f19..573c99e 100644 --- a/test/test_cli.py +++ b/test/test_cli.py @@ -1,7 +1,4 @@ -"""Command-line interface. - -""" - +"""Command-line interface.""" import os import sys diff --git a/test/test_input.py b/test/test_input.py index a178bd9..073dead 100644 --- a/test/test_input.py +++ b/test/test_input.py @@ -1,6 +1,6 @@ """Test support of the various forms of tabular data.""" -from tabulate import tabulate +from tabulate import tabulate, SEPARATING_LINE from common import assert_equal, assert_in, raises, skip try: @@ -11,7 +11,7 @@ except ImportError: 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( [ @@ -173,7 +173,7 @@ def test_numpy_record_array(): [("Alice", 23, 169.5), ("Bob", 27, 175.0)], dtype={ "names": ["name", "age", "height"], - "formats": ["a32", "uint8", "float32"], + "formats": ["S32", "uint8", "float32"], }, ) expected = "\n".join( @@ -199,7 +199,7 @@ def test_numpy_record_array_keys(): [("Alice", 23, 169.5), ("Bob", 27, 175.0)], dtype={ "names": ["name", "age", "height"], - "formats": ["a32", "uint8", "float32"], + "formats": ["S32", "uint8", "float32"], }, ) expected = "\n".join( @@ -225,7 +225,7 @@ def test_numpy_record_array_headers(): [("Alice", 23, 169.5), ("Bob", 27, 175.0)], dtype={ "names": ["name", "age", "height"], - "formats": ["a32", "uint8", "float32"], + "formats": ["S32", "uint8", "float32"], }, ) expected = "\n".join( @@ -313,6 +313,7 @@ def test_sqlite3(): cursor.execute("INSERT INTO people VALUES (?, ?, ?)", values) cursor.execute("SELECT name, age, height FROM people ORDER BY name") result = tabulate(cursor, headers=["whom", "how old", "how tall"]) + conn.close() expected = """\ whom how old how tall ------ --------- ---------- @@ -337,6 +338,7 @@ def test_sqlite3_keys(): 'SELECT name "whom", age "how old", height "how tall" FROM people ORDER BY name' ) result = tabulate(cursor, headers="keys") + conn.close() expected = """\ whom how old how tall ------ --------- ---------- @@ -520,9 +522,31 @@ def test_py37orlater_list_of_dataclasses_headers(): skip("test_py37orlater_list_of_dataclasses_headers is skipped") +def test_py37orlater_list_of_dataclasses_with_separating_line(): + "Input: a list of dataclasses with a separating line" + try: + from dataclasses import make_dataclass + + Person = make_dataclass("Person", ["name", "age", "height"]) + ld = [Person("Alice", 23, 169.5), SEPARATING_LINE, Person("Bob", 27, 175.0)] + result = tabulate(ld, headers="keys") + expected = "\n".join( + [ + "name age height", + "------ ----- --------", + "Alice 23 169.5", + "------ ----- --------", + "Bob 27 175", + ] + ) + assert_equal(expected, result) + except ImportError: + skip("test_py37orlater_list_of_dataclasses_keys is skipped") + + def test_list_bytes(): "Input: a list of bytes. (issue #192)" - lb = [["你好".encode("utf-8")], ["你好"]] + 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 00208c7..fb2cec8 100644 --- a/test/test_internal.py +++ b/test/test_internal.py @@ -16,7 +16,7 @@ def test_multiline_width(): def test_align_column_decimal(): "Internal: _align_column(..., 'decimal')" column = ["12.345", "-1234.5", "1.23", "1234.5", "1e+234", "1.0e234"] - output = T._align_column(column, "decimal") + result = T._align_column(column, "decimal") expected = [ " 12.345 ", "-1234.5 ", @@ -25,7 +25,7 @@ def test_align_column_decimal(): " 1e+234 ", " 1.0e234", ] - assert_equal(output, expected) + assert_equal(expected, result) def test_align_column_decimal_with_thousand_separators(): @@ -40,7 +40,7 @@ def test_align_column_decimal_with_thousand_separators(): " 1e+234 ", " 1.0e234", ] - assert_equal(output, expected) + assert_equal(expected, output) def test_align_column_decimal_with_incorrect_thousand_separators(): @@ -55,7 +55,7 @@ def test_align_column_decimal_with_incorrect_thousand_separators(): " 1e+234 ", " 1.0e234", ] - assert_equal(output, expected) + assert_equal(expected, output) def test_align_column_none(): @@ -63,7 +63,7 @@ def test_align_column_none(): column = ["123.4", "56.7890"] output = T._align_column(column, None) expected = ["123.4", "56.7890"] - assert_equal(output, expected) + assert_equal(expected, output) def test_align_column_multiline(): @@ -71,7 +71,7 @@ def test_align_column_multiline(): column = ["1", "123", "12345\n6"] output = T._align_column(column, "center", is_multiline=True) expected = [" 1 ", " 123 ", "12345" + "\n" + " 6 "] - assert_equal(output, expected) + assert_equal(expected, output) def test_align_cell_veritically_one_line_only(): @@ -170,22 +170,29 @@ def test_wrap_text_to_colwidths(): ] result = T._wrap_text_to_colwidths(rows, widths) - assert_equal(result, expected) + assert_equal(expected, result) def test_wrap_text_wide_chars(): "Internal: Wrap wide characters based on column width" try: - import wcwidth # noqa + import wcwidth # noqa: F401 except ImportError: skip("test_wrap_text_wide_chars is skipped") - rows = [["청자청자청자청자청자", "약간 감싸면 더 잘 보일 수있는 다소 긴 설명입니다"]] + 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(result, expected) + assert_equal(expected, result) def test_wrap_text_to_numbers(): @@ -202,7 +209,7 @@ def test_wrap_text_to_numbers(): ] result = T._wrap_text_to_colwidths(rows, widths, numparses=[True, True, False]) - assert_equal(result, expected) + assert_equal(expected, result) def test_wrap_text_to_colwidths_single_ansi_colors_full_cell(): @@ -235,11 +242,18 @@ def test_wrap_text_to_colwidths_single_ansi_colors_full_cell(): def test_wrap_text_to_colwidths_colors_wide_char(): """Internal: autowrapped text can retain a ANSI colors with wide chars""" try: - import wcwidth # noqa + import wcwidth # noqa: F401 except ImportError: skip("test_wrap_text_to_colwidths_colors_wide_char is skipped") - data = [[("\033[31m약간 감싸면 더 잘 보일 수있는 다소 긴" " 설명입니다 설명입니다 설명입니다 설명입니다 설명\033[0m")]] + data = [ + [ + ( + "\033[31m약간 감싸면 더 잘 보일 수있는 다소 긴" + " 설명입니다 설명입니다 설명입니다 설명입니다 설명\033[0m" + ) + ] + ] result = T._wrap_text_to_colwidths(data, [30]) expected = [ diff --git a/test/test_output.py b/test/test_output.py index 55ff7e3..8871f22 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 @@ -130,13 +131,16 @@ def test_plain_maxcolwidth_autowraps_with_sep(): def test_plain_maxcolwidth_autowraps_wide_chars(): "Output: maxcolwidth and autowrapping functions with wide characters" try: - import wcwidth # noqa + import wcwidth # noqa: F401 except ImportError: skip("test_wrap_text_wide_chars is skipped") 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"]] @@ -438,6 +509,23 @@ def test_github(): assert_equal(expected, result) +def test_github_multiline(): + "Output: github with multiline cells with headers" + table = [[2, "foo\nbar"]] + headers = ("more\nspam eggs", "more spam\n& eggs") + expected = "\n".join( + [ + "| more | more spam |", + "| spam eggs | & eggs |", + "|-------------|-------------|", + "| 2 | foo |", + "| | bar |", + ] + ) + result = tabulate(table, headers, tablefmt="github") + assert_equal(expected, result) + + def test_grid(): "Output: grid with headers" expected = "\n".join( @@ -458,7 +546,7 @@ def test_grid(): def test_grid_wide_characters(): "Output: grid with wide characters in headers" try: - import wcwidth # noqa + import wcwidth # noqa: F401 except ImportError: skip("test_grid_wide_characters is skipped") headers = list(_test_table_headers) @@ -593,7 +681,7 @@ def test_simple_grid(): def test_simple_grid_wide_characters(): "Output: simple_grid with wide characters in headers" try: - import wcwidth # noqa + import wcwidth # noqa: F401 except ImportError: skip("test_simple_grid_wide_characters is skipped") headers = list(_test_table_headers) @@ -728,7 +816,7 @@ def test_rounded_grid(): def test_rounded_grid_wide_characters(): "Output: rounded_grid with wide characters in headers" try: - import wcwidth # noqa + import wcwidth # noqa: F401 except ImportError: skip("test_rounded_grid_wide_characters is skipped") headers = list(_test_table_headers) @@ -863,7 +951,7 @@ def test_heavy_grid(): def test_heavy_grid_wide_characters(): "Output: heavy_grid with wide characters in headers" try: - import wcwidth # noqa + import wcwidth # noqa: F401 except ImportError: skip("test_heavy_grid_wide_characters is skipped") headers = list(_test_table_headers) @@ -998,7 +1086,7 @@ def test_mixed_grid(): def test_mixed_grid_wide_characters(): "Output: mixed_grid with wide characters in headers" try: - import wcwidth # noqa + import wcwidth # noqa: F401 except ImportError: skip("test_mixed_grid_wide_characters is skipped") headers = list(_test_table_headers) @@ -1133,7 +1221,7 @@ def test_double_grid(): def test_double_grid_wide_characters(): "Output: double_grid with wide characters in headers" try: - import wcwidth # noqa + import wcwidth # noqa: F401 except ImportError: skip("test_double_grid_wide_characters is skipped") headers = list(_test_table_headers) @@ -1268,7 +1356,7 @@ def test_fancy_grid(): def test_fancy_grid_wide_characters(): "Output: fancy_grid with wide characters in headers" try: - import wcwidth # noqa + import wcwidth # noqa: F401 except ImportError: skip("test_fancy_grid_wide_characters is skipped") headers = list(_test_table_headers) @@ -1414,6 +1502,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: F401 + 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( @@ -1433,7 +1619,7 @@ def test_outline(): def test_outline_wide_characters(): "Output: outline with wide characters in headers" try: - import wcwidth # noqa + import wcwidth # noqa: F401 except ImportError: skip("test_outline_wide_characters is skipped") headers = list(_test_table_headers) @@ -1485,7 +1671,7 @@ def test_simple_outline(): def test_simple_outline_wide_characters(): "Output: simple_outline with wide characters in headers" try: - import wcwidth # noqa + import wcwidth # noqa: F401 except ImportError: skip("test_simple_outline_wide_characters is skipped") headers = list(_test_table_headers) @@ -1537,7 +1723,7 @@ def test_rounded_outline(): def test_rounded_outline_wide_characters(): "Output: rounded_outline with wide characters in headers" try: - import wcwidth # noqa + import wcwidth # noqa: F401 except ImportError: skip("test_rounded_outline_wide_characters is skipped") headers = list(_test_table_headers) @@ -1589,7 +1775,7 @@ def test_heavy_outline(): def test_heavy_outline_wide_characters(): "Output: heavy_outline with wide characters in headers" try: - import wcwidth # noqa + import wcwidth # noqa: F401 except ImportError: skip("test_heavy_outline_wide_characters is skipped") headers = list(_test_table_headers) @@ -1641,7 +1827,7 @@ def test_mixed_outline(): def test_mixed_outline_wide_characters(): "Output: mixed_outline with wide characters in headers" try: - import wcwidth # noqa + import wcwidth # noqa: F401 except ImportError: skip("test_mixed_outline_wide_characters is skipped") headers = list(_test_table_headers) @@ -1693,7 +1879,7 @@ def test_double_outline(): def test_double_outline_wide_characters(): "Output: double_outline with wide characters in headers" try: - import wcwidth # noqa + import wcwidth # noqa: F401 except ImportError: skip("test_double_outline_wide_characters is skipped") headers = list(_test_table_headers) @@ -1745,7 +1931,7 @@ def test_fancy_outline(): def test_fancy_outline_wide_characters(): "Output: fancy_outline with wide characters in headers" try: - import wcwidth # noqa + import wcwidth # noqa: F401 except ImportError: skip("test_fancy_outline_wide_characters is skipped") headers = list(_test_table_headers) @@ -1915,7 +2101,7 @@ def test_asciidoc(): "Output: asciidoc with headers" expected = "\n".join( [ - '[cols="11<,11>",options="header"]', + '[cols="<11,>11",options="header"]', "|====", "| strings | numbers ", "| spam | 41.9999 ", @@ -1931,7 +2117,7 @@ def test_asciidoc_headerless(): "Output: asciidoc without headers" expected = "\n".join( [ - '[cols="6<,10>"]', + '[cols="<6,>10"]', "|====", "| spam | 41.9999 ", "| eggs | 451 ", @@ -2322,11 +2508,11 @@ def test_mediawiki(): '{| class="wikitable" style="text-align: left;"', "|+ <!-- caption -->", "|-", - '! strings !! align="right"| numbers', + '! strings !! style="text-align: right;"| numbers', "|-", - '| spam || align="right"| 41.9999', + '| spam || style="text-align: right;"| 41.9999', "|-", - '| eggs || align="right"| 451', + '| eggs || style="text-align: right;"| 451', "|}", ] ) @@ -2341,9 +2527,9 @@ def test_mediawiki_headerless(): '{| class="wikitable" style="text-align: left;"', "|+ <!-- caption -->", "|-", - '| spam || align="right"| 41.9999', + '| spam || style="text-align: right;"| 41.9999', "|-", - '| eggs || align="right"| 451', + '| eggs || style="text-align: right;"| 451', "|}", ] ) @@ -2638,6 +2824,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 +2878,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 +2916,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 +3027,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", "----- ---"]) @@ -2847,7 +3174,7 @@ def test_dict_like_with_index(): dd = {"b": range(101, 104)} expected = "\n".join([" b", "-- ---", " 0 101", " 1 102", " 2 103"]) result = tabulate(dd, "keys", showindex=True) - assert_equal(result, expected) + assert_equal(expected, result) def test_list_of_lists_with_index(): @@ -2859,7 +3186,7 @@ def test_list_of_lists_with_index(): [" a b", "-- --- ---", " 0 0 101", " 1 1 102", " 2 2 103"] ) result = tabulate(dd, headers=["a", "b"], showindex=True) - assert_equal(result, expected) + assert_equal(expected, result) def test_list_of_lists_with_index_with_sep_line(): @@ -2878,7 +3205,28 @@ def test_list_of_lists_with_index_with_sep_line(): ] ) result = tabulate(dd, headers=["a", "b"], showindex=True) - assert_equal(result, expected) + 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(): @@ -2888,7 +3236,7 @@ def test_list_of_lists_with_supplied_index(): [" a b", "-- --- ---", " 1 0 101", " 2 1 102", " 3 2 103"] ) result = tabulate(dd, headers=["a", "b"], showindex=[1, 2, 3]) - assert_equal(result, expected) + assert_equal(expected, result) # TODO: make it a separate test case # the index must be as long as the number of rows with raises(ValueError): @@ -2902,7 +3250,7 @@ def test_list_of_lists_with_index_firstrow(): [" a b", "-- --- ---", " 0 0 101", " 1 1 102", " 2 2 103"] ) result = tabulate(dd, headers="firstrow", showindex=True) - assert_equal(result, expected) + assert_equal(expected, result) # TODO: make it a separate test case # the index must be as long as the number of rows with raises(ValueError): @@ -2958,18 +3306,47 @@ 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) + + +def test_break_long_words(): + "Output: Default table output, with breakwords true." + table_headers = ["h1", "h2", "h3"] + test_table = [[" foo1", " bar2 ", "foo3"]] + + # Table is not wrapped on 3 letters due to long word + expected = "h1 h2 h3\n---- ---- ----\nfoo1 bar2 foo3" + result = tabulate(test_table, table_headers, maxcolwidths=3, break_long_words=False) + assert_equal(expected, result) + + # Table max width is 3 letters + expected = "h1 h2 h3\n---- ---- ----\nf ba foo\noo1 r2 3" + result = tabulate(test_table, table_headers, maxcolwidths=3, break_long_words=True) + assert_equal(expected, result) + + +def test_break_on_hyphens(): + "Output: Default table output, with break on hyphens true." + table_headers = ["h1", "h2", "h3"] + test_table = [[" foo-bar", " bar-bar ", "foo-foo"]] + # Table max width is 5, long lines breaks on hyphens + expected = "h1 h2 h3\n---- ---- -----\nfoo bar- foo-f\n-bar bar oo" + result = tabulate(test_table, table_headers, maxcolwidths=5, break_on_hyphens=False) + assert_equal(expected, result) + + # Table data is no longer breaks on hyphens + expected = "h1 h2 h3\n---- ---- ----\nfoo- bar- foo-\nbar bar foo" + result = tabulate(test_table, table_headers, maxcolwidths=5, break_on_hyphens=True) assert_equal(expected, result) diff --git a/test/test_regression.py b/test/test_regression.py index 22e544a..35fa42a 100644 --- a/test/test_regression.py +++ b/test/test_regression.py @@ -163,14 +163,14 @@ def test_column_type_of_bytestring_columns(): result = _column_type([b"foo", b"bar"]) expected = bytes - assert_equal(result, expected) + assert_equal(expected, result) def test_numeric_column_headers(): "Regression: numbers as column headers (issue #22)" result = tabulate([[1], [2]], [42]) expected = " 42\n----\n 1\n 2" - assert_equal(result, expected) + assert_equal(expected, result) lod = [{p: i for p in range(5)} for i in range(5)] result = tabulate(lod, "keys") @@ -185,7 +185,7 @@ def test_numeric_column_headers(): " 4 4 4 4 4", ] ) - assert_equal(result, expected) + assert_equal(expected, result) def test_88_256_ANSI_color_codes(): @@ -226,14 +226,14 @@ def test_latex_escape_special_chars(): ] ) result = tabulate([["&%^_$#{}<>~"]], ["foo^bar"], tablefmt="latex") - assert_equal(result, expected) + assert_equal(expected, result) def test_isconvertible_on_set_values(): "Regression: don't fail with TypeError on set values (issue #35)" expected = "\n".join(["a b", "--- -----", "Foo set()"]) result = tabulate([["Foo", set()]], headers=["a", "b"]) - assert_equal(result, expected) + assert_equal(expected, result) def test_ansi_color_for_decimal_numbers(): @@ -243,7 +243,7 @@ def test_ansi_color_for_decimal_numbers(): ["------- ---", "Magenta \x1b[95m1.1\x1b[0m", "------- ---"] ) result = tabulate(table) - assert_equal(result, expected) + assert_equal(expected, result) def test_alignment_of_decimal_numbers_with_ansi_color(): @@ -253,20 +253,23 @@ def test_alignment_of_decimal_numbers_with_ansi_color(): table = [[v1], [v2]] expected = "\n".join(["\x1b[95m12.34\x1b[0m", " \x1b[95m1.23456\x1b[0m"]) result = tabulate(table, tablefmt="plain") - assert_equal(result, expected) + assert_equal(expected, result) def test_alignment_of_decimal_numbers_with_commas(): "Regression: alignment for decimal numbers with comma separators" - skip("test is temporarily disable until the feature is reimplemented") - # table = [["c1r1", "14502.05"], ["c1r2", 105]] - # result = tabulate(table, tablefmt="grid", floatfmt=',.2f') - # expected = "\n".join( - # ['+------+-----------+', '| c1r1 | 14,502.05 |', - # '+------+-----------+', '| c1r2 | 105.00 |', - # '+------+-----------+'] - # ) - # assert_equal(result, expected) + table = [["c1r1", "14502.05"], ["c1r2", 105]] + result = tabulate(table, tablefmt="grid", floatfmt=",.2f") + expected = "\n".join( + [ + "+------+-----------+", + "| c1r1 | 14,502.05 |", + "+------+-----------+", + "| c1r2 | 105.00 |", + "+------+-----------+", + ] + ) + assert_equal(expected, result) def test_long_integers(): @@ -274,7 +277,7 @@ def test_long_integers(): table = [[18446744073709551614]] result = tabulate(table, tablefmt="plain") expected = "18446744073709551614" - assert_equal(result, expected) + assert_equal(expected, result) def test_colorclass_colors(): @@ -285,7 +288,7 @@ def test_colorclass_colors(): s = colorclass.Color("{magenta}3.14{/magenta}") result = tabulate([[s]], tablefmt="plain") expected = "\x1b[35m3.14\x1b[39m" - assert_equal(result, expected) + assert_equal(expected, result) except ImportError: class textclass(str): @@ -294,13 +297,13 @@ def test_colorclass_colors(): s = textclass("\x1b[35m3.14\x1b[39m") result = tabulate([[s]], tablefmt="plain") expected = "\x1b[35m3.14\x1b[39m" - assert_equal(result, expected) + assert_equal(expected, result) def test_mix_normal_and_wide_characters(): "Regression: wide characters in a grid format (issue #51)" try: - import wcwidth # noqa + import wcwidth # noqa: F401 ru_text = "\u043f\u0440\u0438\u0432\u0435\u0442" cn_text = "\u4f60\u597d" @@ -314,7 +317,7 @@ def test_mix_normal_and_wide_characters(): "+--------+", ] ) - assert_equal(result, expected) + assert_equal(expected, result) except ImportError: skip("test_mix_normal_and_wide_characters is skipped (requires wcwidth lib)") @@ -322,7 +325,7 @@ def test_mix_normal_and_wide_characters(): def test_multiline_with_wide_characters(): "Regression: multiline tables with varying number of wide characters (github issue #28)" try: - import wcwidth # noqa + import wcwidth # noqa: F401 table = [["가나\n가ab", "가나", "가나"]] result = tabulate(table, tablefmt="fancy_grid") @@ -334,7 +337,7 @@ def test_multiline_with_wide_characters(): "╘══════╧══════╧══════╛", ] ) - assert_equal(result, expected) + assert_equal(expected, result) except ImportError: skip("test_multiline_with_wide_characters is skipped (requires wcwidth lib)") @@ -344,7 +347,7 @@ def test_align_long_integers(): table = [[int(1)], [int(234)]] result = tabulate(table, tablefmt="plain") expected = "\n".join([" 1", "234"]) - assert_equal(result, expected) + assert_equal(expected, result) def test_numpy_array_as_headers(): @@ -355,7 +358,7 @@ def test_numpy_array_as_headers(): headers = np.array(["foo", "bar"]) result = tabulate([], headers, tablefmt="plain") expected = "foo bar" - assert_equal(result, expected) + assert_equal(expected, result) except ImportError: raise skip("") @@ -365,7 +368,7 @@ def test_boolean_columns(): xortable = [[False, True], [True, False]] expected = "\n".join(["False True", "True False"]) result = tabulate(xortable, tablefmt="plain") - assert_equal(result, expected) + assert_equal(expected, result) def test_ansi_color_bold_and_fgcolor(): @@ -383,14 +386,14 @@ def test_ansi_color_bold_and_fgcolor(): "+---+---+---+", ] ) - assert_equal(result, expected) + assert_equal(expected, result) def test_empty_table_with_keys_as_header(): "Regression: headers='keys' on an empty table (issue #81)" result = tabulate([], headers="keys") expected = "" - assert_equal(result, expected) + assert_equal(expected, result) def test_escape_empty_cell_in_first_column_in_rst(): @@ -409,7 +412,7 @@ def test_escape_empty_cell_in_first_column_in_rst(): ] ) result = tabulate(table, headers, tablefmt="rst") - assert_equal(result, expected) + assert_equal(expected, result) def test_ragged_rows(): @@ -417,7 +420,7 @@ def test_ragged_rows(): table = [[1, 2, 3], [1, 2], [1, 2, 3, 4]] expected = "\n".join(["- - - -", "1 2 3", "1 2", "1 2 3 4", "- - - -"]) result = tabulate(table) - assert_equal(result, expected) + assert_equal(expected, result) def test_empty_pipe_table_with_columns(): @@ -426,7 +429,7 @@ def test_empty_pipe_table_with_columns(): headers = ["Col1", "Col2"] expected = "\n".join(["| Col1 | Col2 |", "|--------|--------|"]) result = tabulate(table, headers, tablefmt="pipe") - assert_equal(result, expected) + assert_equal(expected, result) def test_custom_tablefmt(): @@ -444,7 +447,7 @@ def test_custom_tablefmt(): rows = [["foo", "bar"], ["baz", "qux"]] expected = "\n".join(["A B", "--- ---", "foo bar", "baz qux"]) result = tabulate(rows, headers=["A", "B"], tablefmt=tablefmt) - assert_equal(result, expected) + assert_equal(expected, result) def test_string_with_comma_between_digits_without_floatfmt_grouping_option(): @@ -452,7 +455,7 @@ def test_string_with_comma_between_digits_without_floatfmt_grouping_option(): table = [["126,000"]] expected = "126,000" result = tabulate(table, tablefmt="plain") - assert_equal(result, expected) # no exception + assert_equal(expected, result) # no exception def test_iterable_row_index(): @@ -469,4 +472,98 @@ def test_iterable_row_index(): expected = "1 a\n2 b\n3 c" result = tabulate(table, showindex=count(1), tablefmt="plain") - assert_equal(result, expected) + assert_equal(expected, result) + + +def test_preserve_line_breaks_with_maxcolwidths(): + "Regression: preserve line breaks when using maxcolwidths (github issue #190)" + table = [["123456789 bbb\nccc"]] + expected = "\n".join( + [ + "+-----------+", + "| 123456789 |", + "| bbb |", + "| ccc |", + "+-----------+", + ] + ) + result = tabulate(table, tablefmt="grid", maxcolwidths=10) + 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) + assert_equal(result, "") + + +def test_numpy_int64_as_integer(): + "Regression: format numpy.int64 as integer (github issue #18)" + try: + import numpy as np + + headers = ["int", "float"] + table = [[np.int64(1), 3.14159]] + result = tabulate(table, headers, tablefmt="pipe", floatfmt=".2f") + expected = "\n".join( + [ + "| int | float |", + "|------:|--------:|", + "| 1 | 3.14 |", + ] + ) + 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) + + +def test_empty_table_with_maxheadercolwidths(): + "Regression: empty table with maxheadercolwidths kwarg (issue #365)" + result = tabulate([], headers=["one", "two", "three"], maxheadercolwidths=5) + expected = "\n".join( + [ + "one two three", + "----- ----- -------", + ] + ) + assert_equal(expected, result) + + +def test_mixed_bool_strings_and_numeric_strings(): + "Regression: column with bool-like strings and numeric strings should not crash (issue #209)" + result = tabulate([["False"], ["1."]]) + expected = "\n".join(["-----", "False", " 1", "-----"]) + assert_equal(expected, result) diff --git a/test/test_textwrapper.py b/test/test_textwrapper.py index f3070b1..5e86b5c 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 @@ -16,9 +15,9 @@ def test_wrap_multiword_non_wide(): orig = OTW(width=width) cust = CTW(width=width) - assert orig.wrap(data) == cust.wrap( - data - ), "Failure on non-wide char multiword regression check for width " + str(width) + assert [line.rstrip() for line in orig.wrap(data)] == [ + line.rstrip() for line in cust.wrap(data) + ], "Failure on non-wide char multiword regression check for width " + str(width) def test_wrap_multiword_non_wide_with_hypens(): @@ -28,9 +27,9 @@ def test_wrap_multiword_non_wide_with_hypens(): orig = OTW(width=width) cust = CTW(width=width) - assert orig.wrap(data) == cust.wrap( - data - ), "Failure on non-wide char hyphen regression check for width " + str(width) + assert [line.rstrip() for line in orig.wrap(data)] == [ + line.rstrip() for line in cust.wrap(data) + ], "Failure on non-wide char hyphen regression check for width " + str(width) def test_wrap_longword_non_wide(): @@ -48,7 +47,7 @@ def test_wrap_longword_non_wide(): def test_wrap_wide_char_multiword(): """TextWrapper: wrapping support for wide characters with multiple words""" try: - import wcwidth # noqa + import wcwidth # noqa: F401 except ImportError: skip("test_wrap_wide_char is skipped") @@ -64,7 +63,7 @@ def test_wrap_wide_char_multiword(): def test_wrap_wide_char_longword(): """TextWrapper: wrapping wide char word that needs to be broken up""" try: - import wcwidth # noqa + import wcwidth # noqa: F401 except ImportError: skip("test_wrap_wide_char_longword is skipped") @@ -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 = [ @@ -184,3 +220,71 @@ def test_wrap_datetime(): ] expected = "\n".join(expected) assert_equal(expected, result) + + +def test_wrap_none_value(): + """TextWrapper: Show that None can be wrapped without crashing""" + data = [["First Entry", None], ["Second Entry", None]] + headers = ["Title", "Value"] + result = tabulate(data, headers=headers, tablefmt="grid", maxcolwidths=[7, 5]) + + expected = [ + "+---------+---------+", + "| Title | Value |", + "+=========+=========+", + "| First | |", + "| Entry | |", + "+---------+---------+", + "| Second | |", + "| Entry | |", + "+---------+---------+", + ] + expected = "\n".join(expected) + assert_equal(expected, result) + + +def test_wrap_none_value_with_missingval(): + """TextWrapper: Show that None can be wrapped without crashing and with a missing value""" + data = [["First Entry", None], ["Second Entry", None]] + headers = ["Title", "Value"] + result = tabulate( + data, headers=headers, tablefmt="grid", maxcolwidths=[7, 5], missingval="???" + ) + + expected = [ + "+---------+---------+", + "| Title | Value |", + "+=========+=========+", + "| First | ??? |", + "| Entry | |", + "+---------+---------+", + "| Second | ??? |", + "| Entry | |", + "+---------+---------+", + ] + expected = "\n".join(expected) + assert_equal(expected, result) + + +def test_wrap_optional_bool_strs(): + """TextWrapper: Show that str bools and None can be wrapped without crashing""" + data = [ + ["First Entry", "True"], + ["Second Entry", None], + ] + headers = ["Title", "When"] + result = tabulate(data, headers=headers, tablefmt="grid", maxcolwidths=[7, 5]) + + expected = [ + "+---------+--------+", + "| Title | When |", + "+=========+========+", + "| First | True |", + "| Entry | |", + "+---------+--------+", + "| Second | |", + "| Entry | |", + "+---------+--------+", + ] + expected = "\n".join(expected) + assert_equal(expected, result) @@ -8,11 +8,19 @@ # for testing and it is disabled by default. [tox] -envlist = lint, py{37, 38, 39, 310} +envlist = lint, py{38, 39, 310, 311, 312, 313, 314} isolated_build = True +[gh] +python = + 3.10: py310-extra + 3.11: py311-extra + 3.12: py312-extra + 3.13: py313-extra + 3.14: py314-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,20 +67,84 @@ 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 + pandas + wcwidth + + +[testenv:py311] +basepython = python3.11 +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 {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 + +[testenv:py314] +basepython = python3.14 +commands = pytest -v --doctest-modules --ignore benchmark {posargs} +deps = + pytest + +[testenv:py314-extra] +basepython = python3.14 +setenv = PYTHONDEVMODE = 1 +commands = pytest -v --doctest-modules --ignore benchmark {posargs} +deps = + pytest + numpy + pandas + wcwidth [flake8] max-complexity = 22 |
