# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
"""Collection of table rendering utilities."""
from __future__ import annotations
import csv
from enum import Enum
from functools import partial
from gettext import gettext as _
from io import StringIO
import tabulate
import wcwidth
from tabulate import DataRow, Line
from tabulate import TableFormat as TabulateTableFormat
from . import EnumChoice, echo, unstyle
from .parameters import ExtraOption
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Callable, Sequence
import click
tabulate.MIN_PADDING = 0
"""Neutralize spurious double-spacing in table rendering."""
tabulate._table_formats.update( # type: ignore[attr-defined]
{
"aligned": TabulateTableFormat(
lineabove=None,
linebelowheader=None,
linebetweenrows=None,
linebelow=None,
headerrow=DataRow("", " ", ""),
datarow=DataRow("", " ", ""),
padding=0,
with_header_hide=None,
),
"github": TabulateTableFormat(
lineabove=Line("| ", "-", " | ", " |"),
linebelowheader=Line("| ", "-", " | ", " |"),
linebetweenrows=None,
linebelow=None,
headerrow=DataRow("| ", " | ", " |"),
datarow=DataRow("| ", " | ", " |"),
padding=0,
with_header_hide=["lineabove"],
),
},
)
"""Custom table formats registered with tabulate.
``aligned``
A minimal format with single-space column separators and no borders or decorations.
Similar to ``plain`` but more compact (single space instead of double space between
columns). Useful for bar plugin output or other contexts requiring minimal formatting.
``github``
Tweaked table separators to match MyST and GFM syntax. Adds a space between the
column separator and the dashes filling a cell:
``|---|---|---|`` β ``| --- | --- | --- |``
That way we produce a table that doesn't need any supplement linting.
.. todo::
This has been merged upstream and can be removed once python-tabulate v0.9.1 is
released:
- https://github.com/astanin/python-tabulate/pull/261
- https://github.com/astanin/python-tabulate/pull/341
- https://github.com/astanin/python-tabulate/issues/364
- https://github.com/astanin/python-tabulate/issues/335
"""
MARKUP_FORMATS = {
TableFormat.ASCIIDOC,
TableFormat.CSV,
TableFormat.CSV_EXCEL,
TableFormat.CSV_EXCEL_TAB,
TableFormat.CSV_UNIX,
TableFormat.GITHUB,
TableFormat.HTML,
TableFormat.JIRA,
TableFormat.LATEX,
TableFormat.LATEX_BOOKTABS,
TableFormat.LATEX_LONGTABLE,
TableFormat.LATEX_RAW,
TableFormat.MEDIAWIKI,
TableFormat.MOINMOIN,
TableFormat.ORGTBL,
TableFormat.PIPE,
TableFormat.RST,
TableFormat.TEXTILE,
TableFormat.TSV,
TableFormat.UNSAFEHTML,
TableFormat.YOUTRACK,
}
"""Subset of table formats that are considered as markup rendering.
"""
DEFAULT_FORMAT = TableFormat.ROUNDED_OUTLINE
"""Default table format, if none is specified."""
def _get_csv_dialect(table_format: TableFormat | None = None) -> str:
"""Extract, validate and normalize CSV dialect ID from format.
Defaults to ``excel`` rendering, like in Python's csv module.
"""
dialect = "excel"
# Extract dialect ID from table format, if any.
if table_format:
format_id = table_format.value
assert format_id.startswith("csv")
parts = format_id.split("-", 1)
assert parts[0] == "csv"
if len(parts) > 1:
dialect = parts[1]
csv.get_dialect(dialect) # Validate dialect.
return dialect
def _render_csv(
table_data: Sequence[Sequence[str | None]],
headers: Sequence[str | None] | None = None,
table_format: TableFormat | None = None,
**kwargs,
) -> str:
"""Render a table in CSV format.
.. note::
StringIO is used to capture CSV output in memory. `Hard-coded to default to
UTF-8 <https://github.com/python/cpython/blob/9291095/Lib/_pyio.py#L2652>`_.
"""
defaults = {"dialect": _get_csv_dialect(table_format)}
defaults.update(kwargs)
with StringIO(newline="") as output:
writer = csv.writer(output, **defaults) # type: ignore[arg-type]
if headers:
writer.writerow(headers)
writer.writerows(table_data)
return output.getvalue()
def _render_vertical(
table_data: Sequence[Sequence[str | None]],
headers: Sequence[str | None] | None = None,
sep_character: str = "*",
sep_length: int = 27,
**kwargs,
) -> str:
"""Re-implements ``cli-helpers``'s vertical table layout.
.. note::
See `cli-helpers source code for reference
<https://github.com/dbcli/cli_helpers/blob/v2.7.0/cli_helpers/tabular_output/vertical_table_adapter.py>`_.
.. caution::
This layout is `hard-coded to 27 asterisks to separate rows
<https://github.com/dbcli/cli_helpers/blob/c34ae9f/cli_helpers/tabular_output/vertical_table_adapter.py#L34>`_,
as in the original implementation.
"""
if not headers:
headers = []
# Calculate header lengths and pad headers in one pass.
header_length = [0 if h is None else len(h) for h in headers]
max_length = max(header_length) if header_length else 0
padded_headers = ["" if h is None else h.ljust(max_length) for h in headers]
table_lines = []
sep = sep_character * sep_length
for index, row in enumerate(table_data):
table_lines.append(f"{sep}[ {index + 1}. row ]{sep}")
for cell_label, cell_value in zip(padded_headers, row):
# Like other formats, render None as an empty string.
cell_value = "" if cell_value is None else cell_value
table_lines.append(f"{cell_label} | {cell_value}")
return "\n".join(table_lines)
def _render_tabulate(
table_data: Sequence[Sequence[str | None]],
headers: Sequence[str | None] | None = None,
table_format: TableFormat | None = None,
**kwargs,
) -> str:
"""Render a table with ``tabulate``.
Default format is ``TableFormat.ROUNDED_OUTLINE``.
"""
if not headers:
headers = ()
if not table_format:
table_format = DEFAULT_FORMAT
defaults = {
"disable_numparse": True,
"numalign": None,
# tabulate()'s format ID uses underscores instead of dashes.
"tablefmt": table_format.value.replace("-", "_"),
}
defaults.update(kwargs)
return tabulate.tabulate(table_data, headers, **defaults) # type: ignore[arg-type]
def _render_github(
table_data: Sequence[Sequence[str | None]],
headers: Sequence[str | None] | None = None,
colalign: Sequence[str] | None = None,
**kwargs,
) -> str:
"""Render a GitHub-flavored Markdown table with alignment hints.
Produces a markdown table with alignment hints in the separator row:
- ``:---`` for left alignment
- ``:---:`` for center alignment
- ``---:`` for right alignment
Uses display width (not character count) for proper unicode/emoji padding,
matching the behavior of ``mdformat`` linter.
.. note::
This is a local implementation because tabulate does not support alignment
hints in the ``github`` format. The ``colalign`` parameter is ignored for
the separator row, which always renders as ``|--------|`` instead of
``:-------|`` or ``|------:`` for left/right alignment.
This was reported upstream but not fixed:
- https://github.com/astanin/python-tabulate/issues/53
- https://github.com/astanin/python-tabulate/pull/261#issuecomment-3833075937
Args:
table_data: 2D sequence of cell values.
headers: Column headers.
colalign: Column alignments as expected by tabulate (e.g., ``("left", "right")``).
**kwargs: Ignored (for compatibility with other render functions).
"""
# Convert headers, defaulting None values to empty strings.
header_list: list[str] = []
if headers:
header_list = ["" if h is None else str(h) for h in headers]
# Convert table data, defaulting None values to empty strings.
data_list: list[list[str]] = []
for row in table_data:
data_list.append(["" if cell is None else str(cell) for cell in row])
# Convert colalign to alignments list, defaulting to no alignment hint.
# Unknown alignment values are preserved and result in plain dashes (no hint).
alignments: list[str | None] = [None] * len(header_list)
if colalign:
alignments = list(colalign)
def visible_width(s: str) -> int:
"""Return the display width of a string, accounting for unicode characters.
Uses ``wcwidth`` to calculate the proper display width of unicode and emoji
characters. This matches the behavior of ``mdformat`` linter. ANSI escape
codes are stripped before measuring.
.. note::
This is a local patch while waiting for a new python-tabulate release. See:
- https://github.com/astanin/python-tabulate/pull/391
- https://github.com/astanin/python-tabulate/pull/387
- https://github.com/astanin/python-tabulate/issues/389
"""
# Strip ANSI escape codes before measuring width.
s = unstyle(s)
width = wcwidth.wcswidth(s)
# wcswidth returns -1 for control characters; fall back to len() in that case.
return len(s) if width < 0 else width
# Calculate column widths based on display width (for proper unicode/emoji
# handling). This matches the behavior of mdformat linter.
col_widths = []
for col_index, header in enumerate(header_list):
cells = [row[col_index] for row in data_list] + [header]
col_widths.append(max(visible_width(c) for c in cells))
# Build separator row with proper alignment hints.
separators = []
for col_index, width in enumerate(col_widths):
align = alignments[col_index]
if align == "left":
sep = f":{'-' * (width - 1)}"
elif align == "center":
sep = f":{'-' * (width - 2)}:"
elif align == "right":
sep = f"{'-' * (width - 1)}:"
else:
# No alignment hint for unknown or None alignment values.
sep = "-" * width
separators.append(sep)
def pad_cell(content: str, width: int, align: str | None) -> str:
"""Pad a cell to the target display width with proper alignment."""
content_width = visible_width(content)
padding_needed = width - content_width
if align == "center":
left_pad = padding_needed // 2
right_pad = padding_needed - left_pad
return " " * left_pad + content + " " * right_pad
elif align == "right":
return " " * padding_needed + content
else: # left, None, or unknown: left-pad by default
return content + " " * padding_needed
# Build header row.
header_cells = [
pad_cell(h, col_widths[i], alignments[i]) for i, h in enumerate(header_list)
]
# Build data rows.
data_rows = []
for row in data_list:
row_cells = [
pad_cell(cell, col_widths[i], alignments[i]) for i, cell in enumerate(row)
]
data_rows.append(row_cells)
# Assemble the table.
lines = []
lines.append("| " + " | ".join(header_cells) + " |")
lines.append("| " + " | ".join(separators) + " |")
for row_cells in data_rows:
lines.append("| " + " | ".join(row_cells) + " |")
return "\n".join(lines)
def _select_table_funcs(
table_format: TableFormat | None = None,
) -> tuple[Callable[..., str], Callable[[str], None]]:
"""Returns the rendering and print functions for the given ``table_format``.
For all formats other than CSV, we relying on Click's ``echo()`` as the print
function, to benefit from its sensitivity to global colorization settings. Thanks
to this the ``--color``/``--no-color`` option is automatically supported.
For CSV formats we returns the Python standard ``print()`` function, to preserve
line terminations, avoid extra line returns and keep ANSI coloring.
.. todo::
Consider to force stripping of ANSI coloring for CSV and other markup formats.
"""
print_func = echo
match table_format:
case (
TableFormat.CSV
| TableFormat.CSV_EXCEL
| TableFormat.CSV_EXCEL_TAB
| TableFormat.CSV_UNIX
):
print_func = partial(print, end="")
return partial(_render_csv, table_format=table_format), print_func
case TableFormat.VERTICAL:
return _render_vertical, print_func
# Bypass tabulate's own GitHub format, which doesn't support alignment
# hints for markdown tables.
case TableFormat.GITHUB:
return _render_github, print_func
case _:
return partial(_render_tabulate, table_format=table_format), print_func
[docs]
def render_table(
table_data: Sequence[Sequence[str | None]],
headers: Sequence[str | None] | None = None,
table_format: TableFormat | None = None,
**kwargs,
) -> str:
"""Render a table and return it as a string."""
render_func, _ = _select_table_funcs(table_format)
return render_func(table_data, headers, **kwargs)
[docs]
def print_table(
table_data: Sequence[Sequence[str | None]],
headers: Sequence[str | None] | None = None,
table_format: TableFormat | None = None,
**kwargs,
) -> None:
"""Render a table and print it to the console."""
render_func, print_func = _select_table_funcs(table_format)
print_func(render_func(table_data, headers, **kwargs))