Skip to content

Commit 95ae5eb

Browse files
authored
Merge pull request #221 from eliegoudout/master
Headers align + enhanced column align.
2 parents 83fd4fb + c1f91dd commit 95ae5eb

File tree

5 files changed

+162
-29
lines changed

5 files changed

+162
-29
lines changed

README.md

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -666,18 +666,31 @@ Ver2 19.2
666666

667667
### Custom column alignment
668668

669-
`tabulate` allows a custom column alignment to override the above. The
670-
`colalign` argument can be a list or a tuple of `stralign` named
671-
arguments. Possible column alignments are: `right`, `center`, `left`,
672-
`decimal` (only for numbers), and `None` (to disable alignment).
673-
Omitting an alignment uses the default. For example:
669+
`tabulate` allows a custom column alignment to override the smart alignment described above.
670+
Use `colglobalalign` to define a global setting. Possible alignments are: `right`, `center`, `left`, `decimal` (only for numbers).
671+
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`.
674672

675673
```pycon
676-
>>> print(tabulate([["one", "two"], ["three", "four"]], colalign=("right",))
677-
----- ----
678-
one two
679-
three four
680-
----- ----
674+
>>> print(tabulate([[1,2,3,4],[111,222,333,444]], colglobalalign='center', colalign = ('global','left','right')))
675+
--- --- --- ---
676+
1 2 3 4
677+
111 222 333 444
678+
--- --- --- ---
679+
```
680+
681+
### Custom header alignment
682+
683+
Headers' alignment can be defined separately from columns'. Like for columns, you can use:
684+
- `headersglobalalign` to define a header-specific global alignment setting. Possible values are `right`, `center`, `left`, `None` (to follow column alignment),
685+
- `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`.
686+
687+
```pycon
688+
>>> 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')))
689+
690+
h e a d e r
691+
--- --- --- --- --- ---
692+
1 2 3 4 5 6
693+
111 222 333 444 555 666
681694
```
682695

683696
### Number formatting
@@ -1123,5 +1136,5 @@ Bart Broere, Vilhelm Prytz, Alexander Gažo, Hugo van Kemenade,
11231136
jamescooke, Matt Warner, Jérôme Provensal, Kevin Deldycke,
11241137
Kian-Meng Ang, Kevin Patterson, Shodhan Save, cleoold, KOLANICH,
11251138
Vijaya Krishna Kasula, Furcy Pin, Christian Fibich, Shaun Duncan,
1126-
Dimitri Papadopoulos.
1139+
Dimitri Papadopoulos, Élie Goudout.
11271140

tabulate/__init__.py

Lines changed: 64 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Pretty-print tabular data."""
22

3+
import warnings
34
from collections import namedtuple
45
from collections.abc import Iterable, Sized
56
from html import escape as htmlescape
@@ -1318,7 +1319,7 @@ def _bool(val):
13181319

13191320

13201321
def _normalize_tabular_data(tabular_data, headers, showindex="default"):
1321-
"""Transform a supported data type to a list of lists, and a list of headers.
1322+
"""Transform a supported data type to a list of lists, and a list of headers, with headers padding.
13221323
13231324
Supported tabular data types:
13241325
@@ -1498,13 +1499,12 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"):
14981499
pass
14991500

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

1507-
return rows, headers
1507+
return rows, headers, headers_pad
15081508

15091509

15101510
def _wrap_text_to_colwidths(list_of_lists, colwidths, numparses=True):
@@ -1580,8 +1580,11 @@ def tabulate(
15801580
missingval=_DEFAULT_MISSINGVAL,
15811581
showindex="default",
15821582
disable_numparse=False,
1583+
colglobalalign=None,
15831584
colalign=None,
15841585
maxcolwidths=None,
1586+
headersglobalalign=None,
1587+
headersalign=None,
15851588
rowalign=None,
15861589
maxheadercolwidths=None,
15871590
):
@@ -1636,8 +1639,8 @@ def tabulate(
16361639
- - --
16371640
16381641
1639-
Column alignment
1640-
----------------
1642+
Column and Headers alignment
1643+
----------------------------
16411644
16421645
`tabulate` tries to detect column types automatically, and aligns
16431646
the values properly. By default it aligns decimal points of the
@@ -1646,6 +1649,23 @@ def tabulate(
16461649
(`numalign`, `stralign`) are: "right", "center", "left", "decimal"
16471650
(only for `numalign`), and None (to disable alignment).
16481651
1652+
`colglobalalign` allows for global alignment of columns, before any
1653+
specific override from `colalign`. Possible values are: None
1654+
(defaults according to coltype), "right", "center", "decimal",
1655+
"left".
1656+
`colalign` allows for column-wise override starting from left-most
1657+
column. Possible values are: "global" (no override), "right",
1658+
"center", "decimal", "left".
1659+
`headersglobalalign` allows for global headers alignment, before any
1660+
specific override from `headersalign`. Possible values are: None
1661+
(follow columns alignment), "right", "center", "left".
1662+
`headersalign` allows for header-wise override starting from left-most
1663+
given header. Possible values are: "global" (no override), "same"
1664+
(follow column alignment), "right", "center", "left".
1665+
1666+
Note on intended behaviour: If there is no `tabular_data`, any column
1667+
alignment argument is ignored. Hence, in this case, header
1668+
alignment cannot be inferred from column alignment.
16491669
16501670
Table formats
16511671
-------------
@@ -2065,7 +2085,7 @@ def tabulate(
20652085
if tabular_data is None:
20662086
tabular_data = []
20672087

2068-
list_of_lists, headers = _normalize_tabular_data(
2088+
list_of_lists, headers, headers_pad = _normalize_tabular_data(
20692089
tabular_data, headers, showindex=showindex
20702090
)
20712091
list_of_lists, separating_lines = _remove_separating_lines(list_of_lists)
@@ -2181,11 +2201,21 @@ def tabulate(
21812201
]
21822202

21832203
# align columns
2184-
aligns = [numalign if ct in [int, float] else stralign for ct in coltypes]
2204+
# first set global alignment
2205+
if colglobalalign is not None: # if global alignment provided
2206+
aligns = [colglobalalign] * len(cols)
2207+
else: # default
2208+
aligns = [numalign if ct in [int, float] else stralign for ct in coltypes]
2209+
# then specific alignements
21852210
if colalign is not None:
21862211
assert isinstance(colalign, Iterable)
2212+
if isinstance(colalign, str):
2213+
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)
21872214
for idx, align in enumerate(colalign):
2188-
aligns[idx] = align
2215+
if not idx < len(aligns):
2216+
break
2217+
elif align != "global":
2218+
aligns[idx] = align
21892219
minwidths = (
21902220
[width_fn(h) + min_padding for h in headers] if headers else [0] * len(cols)
21912221
)
@@ -2194,17 +2224,35 @@ def tabulate(
21942224
for c, a, minw in zip(cols, aligns, minwidths)
21952225
]
21962226

2227+
aligns_headers = None
21972228
if headers:
21982229
# align headers and add headers
21992230
t_cols = cols or [[""]] * len(headers)
2200-
t_aligns = aligns or [stralign] * len(headers)
2231+
# first set global alignment
2232+
if headersglobalalign is not None: # if global alignment provided
2233+
aligns_headers = [headersglobalalign] * len(t_cols)
2234+
else: # default
2235+
aligns_headers = aligns or [stralign] * len(headers)
2236+
# then specific header alignements
2237+
if headersalign is not None:
2238+
assert isinstance(headersalign, Iterable)
2239+
if isinstance(headersalign, str):
2240+
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)
2241+
for idx, align in enumerate(headersalign):
2242+
hidx = headers_pad + idx
2243+
if not hidx < len(aligns_headers):
2244+
break
2245+
elif align == "same" and hidx < len(aligns): # same as column align
2246+
aligns_headers[hidx] = aligns[hidx]
2247+
elif align != "global":
2248+
aligns_headers[hidx] = align
22012249
minwidths = [
22022250
max(minw, max(width_fn(cl) for cl in c))
22032251
for minw, c in zip(minwidths, t_cols)
22042252
]
22052253
headers = [
22062254
_align_header(h, a, minw, width_fn(h), is_multiline, width_fn)
2207-
for h, a, minw in zip(headers, t_aligns, minwidths)
2255+
for h, a, minw in zip(headers, aligns_headers, minwidths)
22082256
]
22092257
rows = list(zip(*cols))
22102258
else:
@@ -2219,7 +2267,7 @@ def tabulate(
22192267
_reinsert_separating_lines(rows, separating_lines)
22202268

22212269
return _format_table(
2222-
tablefmt, headers, rows, minwidths, aligns, is_multiline, rowaligns=rowaligns
2270+
tablefmt, headers, aligns_headers, rows, minwidths, aligns, is_multiline, rowaligns=rowaligns
22232271
)
22242272

22252273

@@ -2350,7 +2398,7 @@ def str(self):
23502398
return self
23512399

23522400

2353-
def _format_table(fmt, headers, rows, colwidths, colaligns, is_multiline, rowaligns):
2401+
def _format_table(fmt, headers, headersaligns, rows, colwidths, colaligns, is_multiline, rowaligns):
23542402
"""Produce a plain-text representation of the table."""
23552403
lines = []
23562404
hidden = fmt.with_header_hide if (headers and fmt.with_header_hide) else []
@@ -2372,7 +2420,7 @@ def _format_table(fmt, headers, rows, colwidths, colaligns, is_multiline, rowali
23722420
_append_line(lines, padded_widths, colaligns, fmt.lineabove)
23732421

23742422
if padded_headers:
2375-
append_row(lines, padded_headers, padded_widths, colaligns, headerrow)
2423+
append_row(lines, padded_headers, padded_widths, headersaligns, headerrow)
23762424
if fmt.linebelowheader and "linebelowheader" not in hidden:
23772425
_append_line(lines, padded_widths, colaligns, fmt.linebelowheader)
23782426

test/common.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import pytest # noqa
22
from pytest import skip, raises # noqa
3-
3+
import warnings
44

55
def assert_equal(expected, result):
66
print("Expected:\n%s\n" % expected)
@@ -27,3 +27,18 @@ def rows_to_pipe_table_str(rows):
2727
lines.append(line)
2828

2929
return "\n".join(lines)
30+
31+
def check_warnings(func_args_kwargs, *, num=None, category=None, contain=None):
32+
func, args, kwargs = func_args_kwargs
33+
with warnings.catch_warnings(record=True) as W:
34+
# Causes all warnings to always be triggered inside here.
35+
warnings.simplefilter("always")
36+
func(*args, **kwargs)
37+
# Checks
38+
if num is not None:
39+
assert len(W) == num
40+
if category is not None:
41+
assert all([issubclass(w.category, category) for w in W])
42+
if contain is not None:
43+
assert all([contain in str(w.message) for w in W])
44+

test/test_api.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,11 @@ def test_tabulate_signature():
4848
("missingval", ""),
4949
("showindex", "default"),
5050
("disable_numparse", False),
51+
("colglobalalign", None),
5152
("colalign", None),
5253
("maxcolwidths", None),
54+
("headersglobalalign", None),
55+
("headersalign", None),
5356
("rowalign", None),
5457
("maxheadercolwidths", None),
5558
]

test/test_output.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Test output of the various forms of tabular data."""
22

33
import tabulate as tabulate_module
4-
from common import assert_equal, raises, skip
4+
from common import assert_equal, raises, skip, check_warnings
55
from tabulate import tabulate, simple_separated_format, SEPARATING_LINE
66

77
# _test_table shows
@@ -2680,6 +2680,60 @@ def test_colalign_multi_with_sep_line():
26802680
expected = " one two\n\nthree four"
26812681
assert_equal(expected, result)
26822682

2683+
def test_column_global_and_specific_alignment():
2684+
""" Test `colglobalalign` and `"global"` parameter for `colalign`. """
2685+
table = [[1,2,3,4],[111,222,333,444]]
2686+
colglobalalign = 'center'
2687+
colalign = ('global','left', 'right')
2688+
result = tabulate(table, colglobalalign=colglobalalign, colalign=colalign)
2689+
expected = '\n'.join([
2690+
"--- --- --- ---",
2691+
" 1 2 3 4",
2692+
"111 222 333 444",
2693+
"--- --- --- ---"])
2694+
assert_equal(expected, result)
2695+
2696+
def test_headers_global_and_specific_alignment():
2697+
""" Test `headersglobalalign` and `headersalign`. """
2698+
table = [[1,2,3,4,5,6],[111,222,333,444,555,666]]
2699+
colglobalalign = 'center'
2700+
colalign = ('left',)
2701+
headers = ['h', 'e', 'a', 'd', 'e', 'r']
2702+
headersglobalalign = 'right'
2703+
headersalign = ('same', 'same', 'left', 'global', 'center')
2704+
result = tabulate(table, headers=headers, colglobalalign=colglobalalign, colalign=colalign, headersglobalalign=headersglobalalign, headersalign=headersalign)
2705+
expected = '\n'.join([
2706+
"h e a d e r",
2707+
"--- --- --- --- --- ---",
2708+
"1 2 3 4 5 6",
2709+
"111 222 333 444 555 666"])
2710+
assert_equal(expected, result)
2711+
2712+
def test_colalign_or_headersalign_too_long():
2713+
""" Test `colalign` and `headersalign` too long. """
2714+
table = [[1,2],[111,222]]
2715+
colalign = ('global', 'left', 'center')
2716+
headers = ['h']
2717+
headersalign = ('center', 'right', 'same')
2718+
result = tabulate(table, headers=headers, colalign=colalign, headersalign=headersalign)
2719+
expected = '\n'.join([
2720+
" h",
2721+
"--- ---",
2722+
" 1 2",
2723+
"111 222"])
2724+
assert_equal(expected, result)
2725+
2726+
def test_warning_when_colalign_or_headersalign_is_string():
2727+
""" Test user warnings when `colalign` or `headersalign` is a string. """
2728+
table = [[1,"bar"]]
2729+
opt = {
2730+
'colalign': "center",
2731+
'headers': ['foo', '2'],
2732+
'headersalign': "center"}
2733+
check_warnings((tabulate, [table], opt),
2734+
num = 2,
2735+
category = UserWarning,
2736+
contain = "As a string")
26832737

26842738
def test_float_conversions():
26852739
"Output: float format parsed"

0 commit comments

Comments
 (0)