Skip to content

Commit

Permalink
Merge pull request #221 from eliegoudout/master
Browse files Browse the repository at this point in the history
Headers align + enhanced column align.
  • Loading branch information
astanin authored Apr 30, 2023
2 parents 83fd4fb + c1f91dd commit 95ae5eb
Show file tree
Hide file tree
Showing 5 changed files with 162 additions and 29 deletions.
35 changes: 24 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -666,18 +666,31 @@ 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([["one", "two"], ["three", "four"]], colalign=("right",))
----- ----
one two
three four
----- ----
>>> 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([[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
Expand Down Expand Up @@ -1123,5 +1136,5 @@ Bart Broere, Vilhelm Prytz, Alexander Gažo, Hugo van Kemenade,
jamescooke, Matt Warner, Jérôme Provensal, 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.

80 changes: 64 additions & 16 deletions tabulate/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Pretty-print tabular data."""

import warnings
from collections import namedtuple
from collections.abc import Iterable, Sized
from html import escape as htmlescape
Expand Down Expand Up @@ -1318,7 +1319,7 @@ 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:
Expand Down Expand Up @@ -1498,13 +1499,12 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"):
pass

# pad with empty headers for initial columns if necessary
headers_pad = 0
if headers and len(rows) > 0:
nhs = len(headers)
ncols = len(rows[0])
if nhs < ncols:
headers = [""] * (ncols - nhs) + headers
headers_pad = max(0, len(rows[0]) - len(headers))
headers = [""] * headers_pad + headers

return rows, headers
return rows, headers, headers_pad


def _wrap_text_to_colwidths(list_of_lists, colwidths, numparses=True):
Expand Down Expand Up @@ -1580,8 +1580,11 @@ def tabulate(
missingval=_DEFAULT_MISSINGVAL,
showindex="default",
disable_numparse=False,
colglobalalign=None,
colalign=None,
maxcolwidths=None,
headersglobalalign=None,
headersalign=None,
rowalign=None,
maxheadercolwidths=None,
):
Expand Down Expand Up @@ -1636,8 +1639,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
Expand All @@ -1646,6 +1649,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
-------------
Expand Down Expand Up @@ -2065,7 +2085,7 @@ def tabulate(
if tabular_data is None:
tabular_data = []

list_of_lists, headers = _normalize_tabular_data(
list_of_lists, headers, headers_pad = _normalize_tabular_data(
tabular_data, headers, showindex=showindex
)
list_of_lists, separating_lines = _remove_separating_lines(list_of_lists)
Expand Down Expand Up @@ -2181,11 +2201,21 @@ 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 alignements
if colalign is not None:
assert isinstance(colalign, Iterable)
if isinstance(colalign, str):
warnings.warn(f"As a string, `colalign` is interpreted as {[c for c in colalign]}. Did you mean `colglobalalign = \"{colalign}\"` or `colalign = (\"{colalign}\",)`?", stacklevel=2)
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)
)
Expand All @@ -2194,17 +2224,35 @@ def tabulate(
for c, a, minw in zip(cols, aligns, 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 alignements
if headersalign is not None:
assert isinstance(headersalign, Iterable)
if isinstance(headersalign, str):
warnings.warn(f"As a string, `headersalign` is interpreted as {[c for c in headersalign]}. Did you mean `headersglobalalign = \"{headersalign}\"` or `headersalign = (\"{headersalign}\",)`?", stacklevel=2)
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:
Expand All @@ -2219,7 +2267,7 @@ 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
)


Expand Down Expand Up @@ -2350,7 +2398,7 @@ def str(self):
return self


def _format_table(fmt, headers, rows, colwidths, colaligns, is_multiline, rowaligns):
def _format_table(fmt, headers, headersaligns, rows, colwidths, colaligns, is_multiline, rowaligns):
"""Produce a plain-text representation of the table."""
lines = []
hidden = fmt.with_header_hide if (headers and fmt.with_header_hide) else []
Expand All @@ -2372,7 +2420,7 @@ def _format_table(fmt, headers, rows, colwidths, colaligns, is_multiline, rowali
_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)

Expand Down
17 changes: 16 additions & 1 deletion test/common.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import pytest # noqa
from pytest import skip, raises # noqa

import warnings

def assert_equal(expected, result):
print("Expected:\n%s\n" % expected)
Expand All @@ -27,3 +27,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])

3 changes: 3 additions & 0 deletions test/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,11 @@ def test_tabulate_signature():
("missingval", ""),
("showindex", "default"),
("disable_numparse", False),
("colglobalalign", None),
("colalign", None),
("maxcolwidths", None),
("headersglobalalign", None),
("headersalign", None),
("rowalign", None),
("maxheadercolwidths", None),
]
Expand Down
56 changes: 55 additions & 1 deletion test/test_output.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Test output of the various forms of tabular data."""

import tabulate as tabulate_module
from common import assert_equal, raises, skip
from common import assert_equal, raises, skip, check_warnings
from tabulate import tabulate, simple_separated_format, SEPARATING_LINE

# _test_table shows
Expand Down Expand Up @@ -2680,6 +2680,60 @@ def test_colalign_multi_with_sep_line():
expected = " one two\n\nthree four"
assert_equal(expected, result)

def test_column_global_and_specific_alignment():
""" Test `colglobalalign` and `"global"` parameter for `colalign`. """
table = [[1,2,3,4],[111,222,333,444]]
colglobalalign = 'center'
colalign = ('global','left', 'right')
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"
Expand Down

0 comments on commit 95ae5eb

Please sign in to comment.