Source code for click_extra.table

# 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
"""


[docs] class TableFormat(Enum): """Enumeration of supported table formats. Hard-coded to be in alphabetical order. Content of this enum is checked in unit tests. .. warning:: The ``youtrack`` format is missing in action from any official JetBrains documentation. So maybe it has been silently deprecated? Hence my `proposal to remove it in python-tabulate#375 <https://github.com/astanin/python-tabulate/issues/375>`_. """ ALIGNED = "aligned" ASCIIDOC = "asciidoc" CSV = "csv" CSV_EXCEL = "csv-excel" CSV_EXCEL_TAB = "csv-excel-tab" CSV_UNIX = "csv-unix" DOUBLE_GRID = "double-grid" DOUBLE_OUTLINE = "double-outline" FANCY_GRID = "fancy-grid" FANCY_OUTLINE = "fancy-outline" GITHUB = "github" GRID = "grid" HEAVY_GRID = "heavy-grid" HEAVY_OUTLINE = "heavy-outline" HTML = "html" JIRA = "jira" LATEX = "latex" LATEX_BOOKTABS = "latex-booktabs" LATEX_LONGTABLE = "latex-longtable" LATEX_RAW = "latex-raw" MEDIAWIKI = "mediawiki" MIXED_GRID = "mixed-grid" MIXED_OUTLINE = "mixed-outline" MOINMOIN = "moinmoin" ORGTBL = "orgtbl" OUTLINE = "outline" PIPE = "pipe" PLAIN = "plain" PRESTO = "presto" PRETTY = "pretty" PSQL = "psql" ROUNDED_GRID = "rounded-grid" ROUNDED_OUTLINE = "rounded-outline" RST = "rst" SIMPLE = "simple" SIMPLE_GRID = "simple-grid" SIMPLE_OUTLINE = "simple-outline" TEXTILE = "textile" TSV = "tsv" UNSAFEHTML = "unsafehtml" VERTICAL = "vertical" YOUTRACK = "youtrack" def __str__(self): return self.name.lower().replace("_", "-")
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] class TableFormatOption(ExtraOption): """A pre-configured option that is adding a ``--table-format`` flag to select the rendering style of a table. The selected table format ID is made available in the context in ``ctx.meta["click_extra.table_format"]``, and two helper methods are added to the context: - ``ctx.render_table(table_data, headers, **kwargs)``: renders and returns the table as a string, - ``ctx.print_table(table_data, headers, **kwargs)``: renders and prints the table to the console. Where: - ``table_data`` is a 2-dimensional iterable of iterables for rows and cells values, - ``headers`` is a list of string to be used as column headers, - ``**kwargs`` are any extra keyword arguments supported by the underlying table formatting function. """ def __init__( self, param_decls: Sequence[str] | None = None, type=EnumChoice(TableFormat), default=DEFAULT_FORMAT, expose_value=False, is_eager=True, help=_("Rendering style of tables."), **kwargs, ) -> None: if not param_decls: param_decls = ("--table-format",) kwargs.setdefault("callback", self.init_formatter) super().__init__( param_decls=param_decls, type=type, default=default, expose_value=expose_value, help=help, is_eager=is_eager, **kwargs, )
[docs] def init_formatter( self, ctx: click.Context, param: click.Parameter, table_format: TableFormat | None, ) -> None: """Save in the context: ``table_format``, ``render_table`` & ``print_table``.""" ctx.meta["click_extra.table_format"] = table_format ctx.render_table = partial( # type: ignore[attr-defined] render_table, table_format=table_format, ) ctx.print_table = partial( # type: ignore[attr-defined] print_table, table_format=table_format, )