Source code for gamesheet_sdk.common.output

# Copyright (c) 2026 bdperkin
# SPDX-License-Identifier: MIT

"""Multi-format rendering of tabular data for CLI workflows.

Every SDK workflow that produces a list of rows (associations, leagues, teams, ...) flows through
:func:`render` to get a string. Supported formats fall into two groups. **Data formats** (machine-friendly,
not via tabulate): ``json``, ``yaml``, ``csv``, ``tsv``. **Tabulate formats** (human-readable or markup-
embeddable; every ``tablefmt`` value tabulate accepts): ``plain``, ``simple``, ``grid``, ``fancy_grid``,
``pipe``, ``orgtbl``, ``rst``, ``mediawiki``, ``html``, ``latex``, ``latex_raw``, ``latex_booktabs``,
``latex_longtable``. The default format is ``simple``. :func:`write_output` complements :func:`render` by
writing the rendered text to a file path (when one is given) or to stdout (with optional ``rich``-driven
syntax highlighting when stdout is a TTY and the format is ``json`` or ``yaml``).
"""

from __future__ import annotations

import csv
import io
import json
from pathlib import Path
import sys
from typing import TYPE_CHECKING, Any

from rich.console import Console
from rich.syntax import Syntax
import tabulate as _tabulate
import yaml

from gamesheet_sdk.common.constants import (
    DEFAULT_OUTPUT_FORMAT,
    ENCODING_UTF8,
    JSON_INDENT_SPACES,
    SYNTAX_BG_COLOR,
    SYNTAX_THEME,
)

if TYPE_CHECKING:
    from collections.abc import Callable
# Every ``tablefmt`` value :func:`render` accepts from tabulate.
TABULATE_FORMATS: tuple[str, ...] = (
    "plain",
    "simple",
    "grid",
    "fancy_grid",
    "pipe",
    "orgtbl",
    "rst",
    "mediawiki",
    "html",
    "latex",
    "latex_raw",
    "latex_booktabs",
    "latex_longtable",
)
# Machine-friendly formats :func:`render` renders without tabulate.
DATA_FORMATS: tuple[str, ...] = ("json", "yaml", "csv", "tsv")
# Union of every format :func:`render` understands.
ALL_FORMATS: tuple[str, ...] = DATA_FORMATS + TABULATE_FORMATS
# Format used when the caller does not specify one.
DEFAULT_FORMAT = DEFAULT_OUTPUT_FORMAT


def _render_json(
    rows: list[dict[str, Any]],
    _columns: list[str],
) -> str:
    """Render rows as indented, sorted JSON.

    :param rows: List of row dictionaries to serialize.
    :type rows: list[dict[str, Any]]
    :param _columns: Column names list (unused for JSON format).
    :type _columns: list[str]
    :returns: JSON string with 2-space indentation and sorted keys.
    :rtype: str

    Example output::

        [
            {
                "id": 123,
                "name": "Example"
            }
        ]
    """
    return json.dumps(rows, indent=JSON_INDENT_SPACES, sort_keys=True, default=str)


def _render_yaml(
    rows: list[dict[str, Any]],
    _columns: list[str],
) -> str:
    """Render rows as block-style YAML.

    :param rows: List of row dictionaries to serialize.
    :type rows: list[dict[str, Any]]
    :param _columns: Column names list (unused for YAML format).
    :type _columns: list[str]
    :returns: YAML string in block style with sorted keys, trailing whitespace stripped.
    :rtype: str

    Example output::

        - id: 123
            name: Example
        - id: 456
            name: Another
    """
    return yaml.safe_dump(rows, sort_keys=True, default_flow_style=False).rstrip()


def _render_dsv(
    rows: list[dict[str, Any]],
    columns: list[str],
    *,
    delimiter: str,
) -> str:
    r"""Render rows as delimiter-separated values (CSV, TSV, etc.).

    :param rows: List of row dictionaries to serialize.
    :type rows: list[dict[str, Any]]
    :param columns: Column names in display order; keys not in this list are ignored.
    :type columns: list[str]
    :param delimiter: Single-character separator (e.g. ``,`` for CSV, ``\t`` for TSV).
    :type delimiter: str
    :returns: DSV string with header row, LF line endings, trailing newline stripped.
    None values are converted to empty strings. Extra keys in each row beyond
    ``columns`` are silently ignored (``extrasaction="ignore"``).
    Example (delimiter=``,``)::
        id,name
        123,Example
        456,Another
    """
    buf = io.StringIO()
    writer = csv.DictWriter(
        buf,
        fieldnames=columns,
        delimiter=delimiter,
        extrasaction="ignore",
        lineterminator="\n",
    )
    writer.writeheader()
    for row in rows:
        writer.writerow(
            {key: ("" if value is None else value) for key, value in row.items()},
        )
    return buf.getvalue().rstrip("\n")


def _render_csv(rows: list[dict[str, Any]], columns: list[str]) -> str:
    """Render rows as comma-separated values.

    Thin wrapper around :func:`_render_dsv` with ``delimiter=","``.

    :param rows: List of row dictionaries to serialize.
    :type rows: list[dict[str, Any]]
    :param columns: Column names in display order.
    :type columns: list[str]
    :returns: CSV string with header row and comma delimiters.
    :rtype: str
    """
    return _render_dsv(rows, columns, delimiter=",")


def _render_tsv(rows: list[dict[str, Any]], columns: list[str]) -> str:
    r"""Render rows as tab-separated values.

    Thin wrapper around :func:`_render_dsv` with ``delimiter="\t"``.

    :param rows: List of row dictionaries to serialize.
    :type rows: list[dict[str, Any]]
    :param columns: Column names in display order.
    :type columns: list[str]
    :returns: TSV string with header row and tab delimiters.
    :rtype: str
    """
    return _render_dsv(rows, columns, delimiter="\t")


def _render_tabulate(rows: list[dict[str, Any]], columns: list[str], fmt: str) -> str:
    """Render rows using the tabulate library.

    Missing keys in a row default to an empty string. The ``fmt`` parameter controls alignment, borders, and
    markup dialect.

    :param rows: List of row dictionaries to render.
    :type rows: list[dict[str, Any]]
    :param columns: Column names in display order; used as table headers.
    :type columns: list[str]
    :param fmt: A ``tablefmt`` value tabulate accepts (e.g. ``"simple"``, ``"grid"``, ``"rst"``). See
        :data:`TABULATE_FORMATS` for the full list of accepted formats.
    :type fmt: str
    :returns: Formatted table string.
    :rtype: str
    """
    table_rows = [[row.get(col, "") for col in columns] for row in rows]
    return _tabulate.tabulate(table_rows, headers=columns, tablefmt=fmt)


# Dispatch table for the non-tabulate ("data") formats. Keeps :func:`render`
# branchless across the format axis; tabulate formats fall through to
# :func:`_render_tabulate`.
_DATA_RENDERERS: dict[str, Callable[[list[dict[str, Any]], list[str]], str]] = {
    "json": _render_json,
    "yaml": _render_yaml,
    "csv": _render_csv,
    "tsv": _render_tsv,
}


def _derive_columns(rows: list[dict[str, Any]]) -> list[str]:
    """Derive column names from the first row's keys.

    Used by :func:`render` when the caller does not provide an explicit column list. Column order is the
    natural iteration order of the first row's keys (insertion order in Python 3.7+).

    :param rows: List of row dictionaries.
    :type rows: list[dict[str, Any]]
    :returns: Keys from the first row, or an empty list if ``rows`` is empty.
    :rtype: list[str]
    """
    if not rows:
        return []
    return list(rows[0].keys())


[docs] def render( rows: list[dict[str, Any]], fmt: str = DEFAULT_FORMAT, *, columns: list[str] | None = None, ) -> str: """Render ``rows`` as a string in the requested format. :param rows: A list of mappings -- each becomes one row. :type rows: list[dict[str, Any]] :param fmt: One of :data:`ALL_FORMATS`. Defaults to :data:`DEFAULT_FORMAT` (``"simple"``). :type fmt: str :param columns: Restrict and order the column set. If ``None`` and ``rows`` is non-empty, the first row's keys are used in their natural order. :type columns: list[str] | None :returns: Rendered string in the requested format. :rtype: str :raises ValueError: If ``fmt`` is not in :data:`ALL_FORMATS`. **Example usage:** .. code-block:: python from gamesheet_sdk.common.output import render rows = [{"id": 123, "name": "Example"}, {"id": 456, "name": "Another"}] # Simple table format print(render(rows, fmt="simple")) # Output: # id name # --- ------- # 123 Example # 456 Another # JSON format print(render(rows, fmt="json")) # Output: # [ # { # "id": 123, # "name": "Example" # }, # { # "id": 456, # "name": "Another" # } # ] # CSV format print(render(rows, fmt="csv")) # Output: # id,name # 123,Example # 456,Another """ if fmt not in ALL_FORMATS: _fmt_list = ", ".join(ALL_FORMATS) _err_msg = f"Unknown format: {fmt!r}. Expected one of {_fmt_list}." raise ValueError(_err_msg) effective_columns = columns if columns is not None else _derive_columns(rows) renderer = _DATA_RENDERERS.get(fmt) if renderer is not None: return renderer(rows, effective_columns) return _render_tabulate(rows, effective_columns, fmt)
def _ensure_trailing_newline(text: str) -> str: """Append a newline to ``text`` if it does not already end with one. Used by :func:`write_output` to ensure file writes and stdout prints end with a newline, which most terminals expect. :param text: Input string. :type text: str :returns: Text string with a guaranteed trailing newline. :rtype: str """ return text if text.endswith("\n") else f"{text}\n"
[docs] def write_output( text: str, path: str | Path | None, *, fmt: str, ) -> None: """Write ``text`` to ``path`` or to stdout. When ``path`` is ``None`` and stdout is a TTY, JSON and YAML output is syntax-highlighted via :class:`rich.syntax.Syntax`. Other formats and any non-TTY destination receive ``text`` verbatim with a trailing newline if it does not already have one. **File output example**:: >>> from gamesheet_sdk.common.output import render, write_output >>> rows = [{"id": 123, "name": "Example"}] >>> text = render(rows, fmt="json") >>> write_output(text, "output.json", fmt="json") # Writes to output.json with trailing newline **TTY stdout example** (JSON/YAML only):: >>> from gamesheet_sdk.common.output import write_output >>> write_output(text, None, fmt="json") # Syntax-highlighted output to terminal if stdout is a TTY **Non-TTY / other formats**:: >>> from gamesheet_sdk.common.output import write_output >>> write_output(text, None, fmt="csv") # Plain text to stdout with trailing newline :param text: Rendered output string to write. :type text: str :param path: Destination file path (str or :class:`pathlib.Path`), or ``None`` for stdout. :type path: str | Path | None :param fmt: Format name (e.g. ``"json"``, ``"yaml"``, ``"csv"``); controls syntax highlighting on TTY stdout. :type fmt: str """ if path is not None: Path(path).write_text(_ensure_trailing_newline(text), encoding=ENCODING_UTF8) return if sys.stdout.isatty() and fmt in ("json", "yaml"): Console().print( Syntax( text, fmt, theme=SYNTAX_THEME, background_color=SYNTAX_BG_COLOR, word_wrap=True, ), ) return sys.stdout.write(_ensure_trailing_newline(text))