Skip to content

Commit cd1a5e5

Browse files
committed
Merge branch 'feature/multiline-rows'
2 parents a352ad5 + ff9b009 commit cd1a5e5

File tree

4 files changed

+226
-65
lines changed

4 files changed

+226
-65
lines changed

README.rst

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -532,30 +532,30 @@ Version history
532532
---------------
533533

534534
- 0.8.1: FUTURE RELEASE
535-
- 0.8.0: ``latex_raw`` format, column-specific floating point formatting.
536-
Python 3.5 & 3.6 support. Drop support for Python 2.6, 3.2, 3.3.
535+
- 0.8.0: Multiline cells. ``latex_raw`` format. Column-specific floating point formatting.
536+
Python 3.5 & 3.6 support. Drop support for Python 2.6, 3.2, 3.3 (should still work).
537537
- 0.7.7: Identical to 0.7.6, resolving some PyPI issues.
538538
- 0.7.6: Bug fixes. New table formats (``psql``, ``jira``, ``moinmoin``, ``textile``).
539-
Wide character support. Printing from database cursors.
540-
Option to print row indices. Boolean columns. Ragged rows.
541-
Option to disable number parsing.
539+
Wide character support. Printing from database cursors.
540+
Option to print row indices. Boolean columns. Ragged rows.
541+
Option to disable number parsing.
542542
- 0.7.5: Bug fixes. ``--float`` format option for the command line utility.
543543
- 0.7.4: Bug fixes. ``fancy_grid`` and ``html`` formats. Command line utility.
544544
- 0.7.3: Bug fixes. Python 3.4 support. Iterables of dicts. ``latex_booktabs`` format.
545545
- 0.7.2: Python 3.2 support.
546546
- 0.7.1: Bug fixes. ``tsv`` format. Column alignment can be disabled.
547-
- 0.7: ``latex`` tables. Printing lists of named tuples and NumPy
548-
record arrays. Fix printing date and time values. Python <= 2.6.4 is supported.
549-
- 0.6: ``mediawiki`` tables, bug fixes.
547+
- 0.7: ``latex`` tables. Printing lists of named tuples and NumPy
548+
record arrays. Fix printing date and time values. Python <= 2.6.4 is supported.
549+
- 0.6: ``mediawiki`` tables, bug fixes.
550550
- 0.5.1: Fix README.rst formatting. Optimize (performance similar to 0.4.4).
551-
- 0.5: ANSI color sequences. Printing dicts of iterables and Pandas' dataframes.
551+
- 0.5: ANSI color sequences. Printing dicts of iterables and Pandas' dataframes.
552552
- 0.4.4: Python 2.6 support.
553553
- 0.4.3: Bug fix, None as a missing value.
554554
- 0.4.2: Fix manifest file.
555555
- 0.4.1: Update license and documentation.
556-
- 0.4: Unicode support, Python3 support, ``rst`` tables.
557-
- 0.3: Initial PyPI release. Table formats: ``simple``, ``plain``,
558-
``grid``, ``pipe``, and ``orgtbl``.
556+
- 0.4: Unicode support, Python3 support, ``rst`` tables.
557+
- 0.3: Initial PyPI release. Table formats: ``simple``, ``plain``,
558+
``grid``, ``pipe``, and ``orgtbl``.
559559

560560

561561
How to contribute

tabulate.py

Lines changed: 132 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -367,8 +367,14 @@ def escape_empty(val):
367367

368368

369369
tabulate_formats = list(sorted(_table_formats.keys()))
370+
multiline_formats = {
371+
"fancy_grid": "fancy_grid",
372+
"grid": "grid",
373+
"simple": "simple_multiline"}
370374

371375

376+
_multiline_codes = re.compile(r"\r|\n|\r\n")
377+
_multiline_codes_bytes = re.compile(b"\r|\n|\r\n")
372378
_invisible_codes = re.compile(r"\x1b\[\d+[;\d]*m|\x1b\[\d*\;\d*\;\d*m") # ANSI color codes
373379
_invisible_codes_bytes = re.compile(b"\x1b\[\d+[;\d]*m|\x1b\[\d*\;\d*\;\d*m") # ANSI color codes
374380

@@ -533,6 +539,10 @@ def _padboth(width, s):
533539
return fmt.format(s)
534540

535541

542+
def _padnone(ignore_width, s):
543+
return s
544+
545+
536546
def _strip_invisible(s):
537547
"Remove invisible ANSI color codes."
538548
if isinstance(s, _text_type):
@@ -559,16 +569,34 @@ def _visible_width(s):
559569
return len_fn(_text_type(s))
560570

561571

562-
def _align_column(strings, alignment, minwidth=0, has_invisible=True):
563-
"""[string] -> [padded_string]
572+
def _is_multiline(s):
573+
if isinstance(s, _text_type):
574+
return bool(re.search(_multiline_codes, s))
575+
else: # a bytestring
576+
return bool(re.search(_multiline_codes_bytes, s))
577+
564578

565-
>>> list(map(str,_align_column(["12.345", "-1234.5", "1.23", "1234.5", "1e+234", "1.0e234"], "decimal")))
566-
[' 12.345 ', '-1234.5 ', ' 1.23 ', ' 1234.5 ', ' 1e+234 ', ' 1.0e234']
579+
def _multiline_width(multiline_s, line_width_fn=len):
580+
"""Visible width of a potentially multiline content."""
581+
return max(map(line_width_fn, re.split("[\r\n]", multiline_s)))
567582

568-
>>> list(map(str,_align_column(['123.4', '56.7890'], None)))
569-
['123.4', '56.7890']
570583

571-
"""
584+
def _choose_width_fn(has_invisible, enable_widechars, is_multiline):
585+
"""Return a function to calculate visible cell width."""
586+
if has_invisible:
587+
line_width_fn = _visible_width
588+
elif enable_widechars: # optional wide-character support if available
589+
line_width_fn = wcwidth.wcswidth
590+
else:
591+
line_width_fn = len
592+
if is_multiline:
593+
width_fn = lambda s: _multiline_width(s, line_width_fn)
594+
else:
595+
width_fn = line_width_fn
596+
return width_fn
597+
598+
599+
def _align_column_choose_padfn(strings, alignment, has_invisible):
572600
if alignment == "right":
573601
if not PRESERVE_WHITESPACE:
574602
strings = [s.strip() for s in strings]
@@ -587,31 +615,46 @@ def _align_column(strings, alignment, minwidth=0, has_invisible=True):
587615
for s, decs in zip(strings, decimals)]
588616
padfn = _padleft
589617
elif not alignment:
590-
return strings
618+
padfn = _padnone
591619
else:
592620
if not PRESERVE_WHITESPACE:
593621
strings = [s.strip() for s in strings]
594622
padfn = _padright
623+
return strings, padfn
595624

596-
enable_widechars = wcwidth is not None and WIDE_CHARS_MODE
597-
if has_invisible:
598-
width_fn = _visible_width
599-
elif enable_widechars: # optional wide-character support if available
600-
width_fn = wcwidth.wcswidth
601-
else:
602-
width_fn = len
603625

604-
s_lens = list(map(len, strings))
626+
def _align_column(strings, alignment, minwidth=0,
627+
has_invisible=True, enable_widechars=False, is_multiline=False):
628+
"""[string] -> [padded_string]"""
629+
strings, padfn = _align_column_choose_padfn(strings, alignment, has_invisible)
630+
width_fn = _choose_width_fn(has_invisible, enable_widechars, is_multiline)
631+
605632
s_widths = list(map(width_fn, strings))
606633
maxwidth = max(max(s_widths), minwidth)
607-
if not enable_widechars and not has_invisible:
608-
padded_strings = [padfn(maxwidth, s) for s in strings]
609-
else:
610-
# enable wide-character width corrections
611-
visible_widths = [maxwidth - (w - l) for w, l in zip(s_widths, s_lens)]
612-
# wcswidth and _visible_width don't count invisible characters;
613-
# padfn doesn't need to apply another correction
614-
padded_strings = [padfn(w, s) for s, w in zip(strings, visible_widths)]
634+
# TODO: refactor column alignment in single-line and multiline modes
635+
if is_multiline:
636+
if not enable_widechars and not has_invisible:
637+
padded_strings = [
638+
"\n".join([padfn(maxwidth, s) for s in ms.splitlines()])
639+
for ms in strings]
640+
else:
641+
# enable wide-character width corrections
642+
s_lens = [max((len(s) for s in re.split("[\r\n]", ms))) for ms in strings]
643+
visible_widths = [maxwidth - (w - l) for w, l in zip(s_widths, s_lens)]
644+
# wcswidth and _visible_width don't count invisible characters;
645+
# padfn doesn't need to apply another correction
646+
padded_strings = ["\n".join([padfn(w, s) for s in (ms.splitlines() or ms)])
647+
for ms, w in zip(strings, visible_widths)]
648+
else: # single-line cell values
649+
if not enable_widechars and not has_invisible:
650+
padded_strings = [padfn(maxwidth, s) for s in strings]
651+
else:
652+
# enable wide-character width corrections
653+
s_lens = list(map(len, strings))
654+
visible_widths = [maxwidth - (w - l) for w, l in zip(s_widths, s_lens)]
655+
# wcswidth and _visible_width don't count invisible characters;
656+
# padfn doesn't need to apply another correction
657+
padded_strings = [padfn(w, s) for s, w in zip(strings, visible_widths)]
615658
return padded_strings
616659

617660

@@ -682,9 +725,15 @@ def _format(val, valtype, floatfmt, missingval="", has_invisible=True):
682725
return "{0}".format(val)
683726

684727

685-
def _align_header(header, alignment, width, visible_width):
728+
def _align_header(header, alignment, width, visible_width, is_multiline=False):
686729
"Pad string header to width chars given known visible_width of the header."
687-
width += len(header) - visible_width
730+
if is_multiline:
731+
header_lines = re.split(_multiline_codes, header)
732+
padded_lines = [_align_header(h, alignment, width, visible_width) for h in header_lines]
733+
return "\n".join(padded_lines)
734+
# else: not multiline
735+
ninvisible = max(0, len(header) - visible_width)
736+
width += ninvisible
688737
if alignment == "left":
689738
return _padright(width, header)
690739
elif alignment == "center":
@@ -1173,17 +1222,17 @@ def tabulate(tabular_data, headers=(), tablefmt="simple",
11731222

11741223
# optimization: look for ANSI control codes once,
11751224
# enable smart width functions only if a control code is found
1176-
plain_text = '\n'.join(['\t'.join(map(_text_type, headers))] + \
1225+
plain_text = '\t'.join(['\t'.join(map(_text_type, headers))] + \
11771226
['\t'.join(map(_text_type, row)) for row in list_of_lists])
11781227

11791228
has_invisible = re.search(_invisible_codes, plain_text)
11801229
enable_widechars = wcwidth is not None and WIDE_CHARS_MODE
1181-
if has_invisible:
1182-
width_fn = _visible_width
1183-
elif enable_widechars: # optional wide-character support if available
1184-
width_fn = wcwidth.wcswidth
1230+
if tablefmt in multiline_formats and _is_multiline(plain_text):
1231+
tablefmt = multiline_formats.get(tablefmt, tablefmt)
1232+
is_multiline = True
11851233
else:
1186-
width_fn = len
1234+
is_multiline = False
1235+
width_fn = _choose_width_fn(has_invisible, enable_widechars, is_multiline)
11871236

11881237
# format rows and columns, convert numeric values to strings
11891238
cols = list(izip_longest(*list_of_lists))
@@ -1208,15 +1257,15 @@ def tabulate(tabular_data, headers=(), tablefmt="simple",
12081257
# align columns
12091258
aligns = [numalign if ct in [int,float] else stralign for ct in coltypes]
12101259
minwidths = [width_fn(h) + MIN_PADDING for h in headers] if headers else [0]*len(cols)
1211-
cols = [_align_column(c, a, minw, has_invisible)
1260+
cols = [_align_column(c, a, minw, has_invisible, enable_widechars, is_multiline)
12121261
for c, a, minw in zip(cols, aligns, minwidths)]
12131262

12141263
if headers:
12151264
# align headers and add headers
12161265
t_cols = cols or [['']] * len(headers)
12171266
t_aligns = aligns or [stralign] * len(headers)
12181267
minwidths = [max(minw, width_fn(c[0])) for minw, c in zip(minwidths, t_cols)]
1219-
headers = [_align_header(h, a, minw, width_fn(h))
1268+
headers = [_align_header(h, a, minw, width_fn(h), is_multiline)
12201269
for h, a, minw in zip(headers, t_aligns, minwidths)]
12211270
rows = list(zip(*cols))
12221271
else:
@@ -1226,7 +1275,7 @@ def tabulate(tabular_data, headers=(), tablefmt="simple",
12261275
if not isinstance(tablefmt, TableFormat):
12271276
tablefmt = _table_formats.get(tablefmt, _table_formats["simple"])
12281277

1229-
return _format_table(tablefmt, headers, rows, minwidths, aligns)
1278+
return _format_table(tablefmt, headers, rows, minwidths, aligns, is_multiline)
12301279

12311280

12321281
def _expand_numparse(disable_numparse, column_count):
@@ -1246,6 +1295,15 @@ def _expand_numparse(disable_numparse, column_count):
12461295
return [not disable_numparse] * column_count
12471296

12481297

1298+
def _pad_row(cells, padding):
1299+
if cells:
1300+
pad = " "*padding
1301+
padded_cells = [pad + cell + pad for cell in cells]
1302+
return padded_cells
1303+
else:
1304+
return cells
1305+
1306+
12491307
def _build_simple_row(padded_cells, rowfmt):
12501308
"Format row according to DataRow format without padding."
12511309
begin, sep, end = rowfmt
@@ -1262,6 +1320,24 @@ def _build_row(padded_cells, colwidths, colaligns, rowfmt):
12621320
return _build_simple_row(padded_cells, rowfmt)
12631321

12641322

1323+
def _append_basic_row(lines, padded_cells, colwidths, colaligns, rowfmt):
1324+
lines.append(_build_row(padded_cells, colwidths, colaligns, rowfmt))
1325+
return lines
1326+
1327+
1328+
def _append_multiline_row(lines, padded_multiline_cells, padded_widths, colaligns, rowfmt, pad):
1329+
colwidths = [w - 2*pad for w in padded_widths]
1330+
cells_lines = [c.splitlines() for c in padded_multiline_cells]
1331+
nlines = max(map(len, cells_lines)) # number of lines in the row
1332+
# vertically pad cells where some lines are missing
1333+
cells_lines = [(cl + [' '*w]*(nlines - len(cl))) for cl, w in zip(cells_lines, colwidths)]
1334+
lines_cells = [[cl[i] for cl in cells_lines] for i in range(nlines)]
1335+
for ln in lines_cells:
1336+
padded_ln = _pad_row(ln, 1)
1337+
_append_basic_row(lines, padded_ln, colwidths, colaligns, rowfmt)
1338+
return lines
1339+
1340+
12651341
def _build_line(colwidths, colaligns, linefmt):
12661342
"Return a string which represents a horizontal line."
12671343
if not linefmt:
@@ -1274,47 +1350,50 @@ def _build_line(colwidths, colaligns, linefmt):
12741350
return _build_simple_row(cells, (begin, sep, end))
12751351

12761352

1277-
def _pad_row(cells, padding):
1278-
if cells:
1279-
pad = " "*padding
1280-
padded_cells = [pad + cell + pad for cell in cells]
1281-
return padded_cells
1282-
else:
1283-
return cells
1353+
def _append_line(lines, colwidths, colaligns, linefmt):
1354+
lines.append(_build_line(colwidths, colaligns, linefmt))
1355+
return lines
12841356

12851357

1286-
def _format_table(fmt, headers, rows, colwidths, colaligns):
1358+
def _format_table(fmt, headers, rows, colwidths, colaligns, is_multiline):
12871359
"""Produce a plain-text representation of the table."""
12881360
lines = []
12891361
hidden = fmt.with_header_hide if (headers and fmt.with_header_hide) else []
12901362
pad = fmt.padding
12911363
headerrow = fmt.headerrow
12921364

12931365
padded_widths = [(w + 2*pad) for w in colwidths]
1294-
padded_headers = _pad_row(headers, pad)
1295-
padded_rows = [_pad_row(row, pad) for row in rows]
1366+
if is_multiline:
1367+
pad_row = lambda row, _: row # do it later, in _append_multiline_row
1368+
append_row = partial(_append_multiline_row, pad=pad)
1369+
else:
1370+
pad_row = _pad_row
1371+
append_row = _append_basic_row
1372+
1373+
padded_headers = pad_row(headers, pad)
1374+
padded_rows = [pad_row(row, pad) for row in rows]
12961375

12971376
if fmt.lineabove and "lineabove" not in hidden:
1298-
lines.append(_build_line(padded_widths, colaligns, fmt.lineabove))
1377+
_append_line(lines, padded_widths, colaligns, fmt.lineabove)
12991378

13001379
if padded_headers:
1301-
lines.append(_build_row(padded_headers, padded_widths, colaligns, headerrow))
1380+
append_row(lines, padded_headers, padded_widths, colaligns, headerrow)
13021381
if fmt.linebelowheader and "linebelowheader" not in hidden:
1303-
lines.append(_build_line(padded_widths, colaligns, fmt.linebelowheader))
1382+
_append_line(lines, padded_widths, colaligns, fmt.linebelowheader)
13041383

13051384
if padded_rows and fmt.linebetweenrows and "linebetweenrows" not in hidden:
13061385
# initial rows with a line below
13071386
for row in padded_rows[:-1]:
1308-
lines.append(_build_row(row, padded_widths, colaligns, fmt.datarow))
1309-
lines.append(_build_line(padded_widths, colaligns, fmt.linebetweenrows))
1387+
append_row(lines, row, padded_widths, colaligns, fmt.datarow)
1388+
_append_line(lines, padded_widths, colaligns, fmt.linebetweenrows)
13101389
# the last row without a line below
1311-
lines.append(_build_row(padded_rows[-1], padded_widths, colaligns, fmt.datarow))
1390+
append_row(lines, padded_rows[-1], padded_widths, colaligns, fmt.datarow)
13121391
else:
13131392
for row in padded_rows:
1314-
lines.append(_build_row(row, padded_widths, colaligns, fmt.datarow))
1393+
append_row(lines, row, padded_widths, colaligns, fmt.datarow)
13151394

13161395
if fmt.linebelow and "linebelow" not in hidden:
1317-
lines.append(_build_line(padded_widths, colaligns, fmt.linebelow))
1396+
_append_line(lines, padded_widths, colaligns, fmt.linebelow)
13181397

13191398
if headers or rows:
13201399
return "\n".join(lines)

0 commit comments

Comments
 (0)