Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 32 additions & 9 deletions tabulate/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1147,7 +1147,7 @@ def _choose_width_fn(has_invisible, enable_widechars, is_multiline):
return width_fn


def _align_column_choose_padfn(strings, alignment, has_invisible, preserve_whitespace):
def _align_column_choose_padfn(strings, alignment, has_invisible, preserve_whitespace, pad_none):
if alignment == "right":
if not preserve_whitespace:
strings = [s.strip() for s in strings]
Expand All @@ -1165,7 +1165,7 @@ def _align_column_choose_padfn(strings, alignment, has_invisible, preserve_white
strings = [s + (maxdecimals - decs) * " " for s, decs in zip(strings, decimals)]
padfn = _padleft
elif not alignment:
padfn = _padnone
padfn = _padright if pad_none else _padnone
else:
if not preserve_whitespace:
strings = [s.strip() for s in strings]
Expand Down Expand Up @@ -1212,10 +1212,11 @@ def _align_column(
enable_widechars=False,
is_multiline=False,
preserve_whitespace=False,
pad_none=False,
):
"""[string] -> [padded_string]"""
strings, padfn = _align_column_choose_padfn(
strings, alignment, has_invisible, preserve_whitespace
strings, alignment, has_invisible, preserve_whitespace, pad_none
)
width_fn = _align_column_choose_width_fn(has_invisible, enable_widechars, is_multiline)

Expand Down Expand Up @@ -1358,11 +1359,16 @@ def _format(val, valtype, floatfmt, intfmt, missingval="", has_invisible=True):
return f"{val}"


def _align_header(header, alignment, width, visible_width, is_multiline=False, width_fn=None):
def _align_header(
header, alignment, width, visible_width, is_multiline=False, width_fn=None, pad_none=False
):
"Pad string header to width chars given known visible_width of the header."
if is_multiline:
header_lines = re.split(_multiline_codes, header)
padded_lines = [_align_header(h, alignment, width, width_fn(h)) for h in header_lines]
padded_lines = [
_align_header(h, alignment, width, width_fn(h), pad_none=pad_none)
for h in header_lines
]
return "\n".join(padded_lines)
# else: not multiline
ninvisible = len(header) - visible_width
Expand All @@ -1372,11 +1378,23 @@ def _align_header(header, alignment, width, visible_width, is_multiline=False, w
elif alignment == "center":
return _padboth(width, header)
elif not alignment:
return f"{header}"
return _padright(width, header) if pad_none else f"{header}"
else:
return _padleft(width, header)


def _is_fixed_width_table_format(fmt):
"""Whether a format's row and line metadata describe a fixed text box."""
return (
isinstance(fmt.datarow, DataRow)
and bool(fmt.datarow.begin and fmt.datarow.end)
and any(
isinstance(line, Line) and bool(line.begin and line.end)
for line in (fmt.lineabove, fmt.linebetweenrows, fmt.linebelow)
)
)


def _remove_separating_lines(rows):
if isinstance(rows, list):
separating_lines = []
Expand Down Expand Up @@ -2334,6 +2352,11 @@ def tabulate(
is_multiline = True
else:
is_multiline = False
if isinstance(tablefmt, TableFormat):
format_metadata = tablefmt
else:
format_metadata = _table_formats.get(tablefmt, _table_formats["simple"])
pad_none = _is_fixed_width_table_format(format_metadata)
width_fn = _choose_width_fn(has_invisible, enable_widechars, is_multiline)

# format rows and columns, convert numeric values to strings
Expand Down Expand Up @@ -2400,6 +2423,7 @@ def tabulate(
enable_widechars,
is_multiline,
preserve_whitespace,
pad_none,
)
for c, a, minw in zip(cols, aligns_copy, minwidths)
]
Expand Down Expand Up @@ -2435,16 +2459,15 @@ def tabulate(
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)
_align_header(h, a, minw, width_fn(h), is_multiline, width_fn, pad_none)
for h, a, minw in zip(headers, aligns_headers, minwidths)
]
rows = list(zip(*cols))
else:
minwidths = [max(width_fn(cl) for cl in c) for c in cols]
rows = list(zip(*cols))

if not isinstance(tablefmt, TableFormat):
tablefmt = _table_formats.get(tablefmt, _table_formats["simple"])
tablefmt = format_metadata

ra_default = rowalign if isinstance(rowalign, str) else None
rowaligns = _expand_iterable(rowalign, len(rows), ra_default)
Expand Down
110 changes: 109 additions & 1 deletion test/test_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,15 @@

from pytest import mark

from tabulate import SEPARATING_LINE, simple_separated_format, tabulate
from tabulate import (
SEPARATING_LINE,
DataRow,
Line,
TableFormat,
_visible_width,
simple_separated_format,
tabulate,
)

from common import assert_equal, check_warnings, raises, skip

Expand Down Expand Up @@ -2288,6 +2296,106 @@ def test_pretty_headerless():
assert_equal(expected, result)


def test_none_alignment_preserves_content_and_box_geometry():
"""None alignment keeps input whitespace while filling physical boxes."""
expected = "+---------+-------+\n| header | title |\n+---------+-------+\n| foo | title |\n| bar | value |\n+---------+-------+"
result = tabulate(
[[" foo", "title"], ["bar", "value"]],
stralign=None,
headers=["header", "title"],
headersalign=[None, None],
tablefmt="pretty",
)
assert_equal(expected, result)
assert all(len(line) == 19 for line in result.splitlines())


def test_none_alignment_uses_custom_box_metadata():
"""A custom fixed format gets the same padding without a format-name check."""
fmt = TableFormat(
lineabove=Line("<", "=", "+", ">"),
linebelowheader=Line("<", "=", "+", ">"),
linebetweenrows=None,
linebelow=Line("<", "=", "+", ">"),
headerrow=DataRow("[", "][", "]"),
datarow=DataRow("[", "][", "]"),
padding=1,
with_header_hide=None,
)
result = tabulate([[" foo"], ["bar"]], stralign=None, tablefmt=fmt)
assert_equal("<=========>\n[ foo ]\n[ bar ]\n<=========>", result)


def test_none_alignment_orgtbl_remains_unpadded():
"""A header separator alone does not turn a markup format into a box."""
assert_equal(
"| foo |\n| bar |",
tabulate([[" foo"], ["bar"]], stralign=None, tablefmt="orgtbl"),
)
assert_equal(
"| h |\n|---------|\n| foo |\n| bar |",
tabulate([[" foo"], ["bar"]], headers=["h"], stralign=None, tablefmt="orgtbl"),
)


def test_none_alignment_custom_header_separator_is_not_a_box():
fmt = TableFormat(
lineabove=None,
linebelowheader=Line("", "-", "+", ""),
linebetweenrows=None,
linebelow=None,
headerrow=DataRow("|", "|", "|"),
datarow=DataRow("|", "|", "|"),
padding=1,
with_header_hide=None,
)
assert_equal(
"| h |\n---------\n| foo |\n| bar |",
tabulate([[" foo"], ["bar"]], headers=["h"], stralign=None, tablefmt=fmt),
)


def test_none_alignment_open_or_callable_frame_is_not_a_box():
open_frame = TableFormat(
lineabove=Line("<", "=", "+", ""),
linebelowheader=None,
linebetweenrows=None,
linebelow=None,
headerrow=DataRow("|", "|", "|"),
datarow=DataRow("|", "|", "|"),
padding=1,
with_header_hide=None,
)
callable_frame = TableFormat(
lineabove=lambda widths, aligns: "".join("-" for _ in widths),
linebelowheader=None,
linebetweenrows=None,
linebelow=None,
headerrow=DataRow("|", "|", "|"),
datarow=DataRow("|", "|", "|"),
padding=1,
with_header_hide=None,
)
for fmt, expected in (
(open_frame, "<=========\n| foo |\n| bar |"),
(callable_frame, "-\n| foo |\n| bar |"),
):
assert_equal(
expected,
tabulate([[" foo"], ["bar"]], stralign=None, tablefmt=fmt),
)


def test_none_alignment_box_handles_multiline_separator_and_index():
table = [["a\nb", "中"], SEPARATING_LINE, ["long", "x"]]
result = tabulate(
table, stralign=None, numalign=None, showindex=["i", "", "j"], tablefmt="grid"
)
lines = result.splitlines()
assert all(_visible_width(line) == _visible_width(lines[0]) for line in lines)
assert "a" in result and "b" in result and "中" in result


def test_pretty_multiline_headerless():
"Output: pretty with multiline cells without headers"
table = [["foo bar\nbaz\nbau", "hello"], ["", "multiline\nworld"]]
Expand Down