diff --git a/tabulate/__init__.py b/tabulate/__init__.py index 12a2950..45973e8 100644 --- a/tabulate/__init__.py +++ b/tabulate/__init__.py @@ -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] @@ -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] @@ -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) @@ -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 @@ -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 = [] @@ -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 @@ -2400,6 +2423,7 @@ def tabulate( enable_widechars, is_multiline, preserve_whitespace, + pad_none, ) for c, a, minw in zip(cols, aligns_copy, minwidths) ] @@ -2435,7 +2459,7 @@ 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)) @@ -2443,8 +2467,7 @@ def tabulate( 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) diff --git a/test/test_output.py b/test/test_output.py index ea3da87..553057c 100644 --- a/test/test_output.py +++ b/test/test_output.py @@ -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 @@ -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"]]