Source code for kstlib.ui.tables

"""Config-driven helpers for rendering Rich tables."""

from __future__ import annotations

# pylint: disable=too-many-arguments
import asyncio
import copy
from collections.abc import Mapping, Sequence
from typing import Any

from box import Box
from rich import box as rich_box
from rich.console import Console
from rich.table import Table
from rich.text import Text

from kstlib.config import ConfigNotLoadedError, get_config
from kstlib.ui.exceptions import TableRenderingError
from kstlib.utils.dict import deep_merge

DEFAULT_TABLE_CONFIG: dict[str, Any] = {
    "defaults": {
        "table": {
            "title": None,
            "title_style": None,
            "caption": None,
            "caption_style": None,
            "box": "SIMPLE",
            "show_header": True,
            "header_style": "bold cyan",
            "show_lines": False,
            "row_styles": None,
            "expand": True,
            "pad_edge": False,
            "highlight": False,
        },
        "columns": [
            {
                "header": "Key",
                "key": "key",
                "justify": "left",
                "style": "bold white",
                "overflow": "fold",
                "no_wrap": False,
            },
            {
                "header": "Value",
                "key": "value",
                "justify": "left",
                "style": None,
                "overflow": "fold",
                "no_wrap": False,
            },
        ],
    },
    "presets": {
        "inventory": {
            "table": {
                "title": "Inventory",
                "box": "ROUNDED",
                "show_lines": True,
                "header_style": "bold yellow",
            },
        },
        "metrics": {
            "table": {
                "title": "Metrics",
                "box": "SIMPLE_HEAD",
                "header_style": "bold green",
            },
        },
    },
}


[docs] class TableBuilder: """Render Rich tables from configuration presets. Tables follow the same configuration cascade used across kstlib: ``kwargs > config preset > defaults``. Column definitions can be specified in defaults, presets, or passed at runtime. Data may be provided as a sequence of mappings or explicit row sequences. Example: Render a simple table with data dictionaries:: >>> from kstlib.ui.tables import TableBuilder >>> builder = TableBuilder() >>> data = [{"key": "Name", "value": "Alice"}, {"key": "Age", "value": "30"}] >>> table = builder.render_table(data=data) >>> table.row_count 2 Using a preset and custom columns:: >>> columns = [ ... {"header": "Metric", "key": "metric"}, ... {"header": "Value", "key": "val", "justify": "right"}, ... ] >>> data = [{"metric": "CPU", "val": "42%"}] >>> table = builder.render_table("metrics", data=data, columns=columns) """
[docs] def __init__(self, config: Mapping[str, Any] | Box | None = None, console: Console | None = None) -> None: """Store optional console and resolve configuration cascade.""" self.console = console self._config = self._prepare_config(config)
[docs] def render_table( self, kind: str | None = None, *, data: Sequence[Mapping[str, Any]] | None = None, rows: Sequence[Sequence[Any]] | None = None, columns: Sequence[Mapping[str, Any]] | None = None, **overrides: Any, ) -> Table: """Build a ``Table`` instance according to the configuration cascade. Args: kind: Name of the preset to apply. data: Sequence of mapping-like objects used to populate the table. rows: Explicit rows as iterables; bypasses automatic extraction. columns: Runtime column definitions. Replaces configured columns when provided. **overrides: Additional overrides applied on top of the resolved config. Returns: Configured Rich ``Table`` instance. Raises: TableRenderingError: If neither ``data`` nor ``rows`` can populate the table. """ resolved = self._resolve_table_config(kind, overrides, columns) table_config = resolved["table"] column_config = resolved.get("columns", []) table = self._create_table(table_config) self._add_columns(table, column_config) self._populate_rows(table, column_config, data=data, rows=rows) return table
[docs] def print_table( self, kind: str | None = None, *, data: Sequence[Mapping[str, Any]] | None = None, rows: Sequence[Sequence[Any]] | None = None, columns: Sequence[Mapping[str, Any]] | None = None, console: Console | None = None, **overrides: Any, ) -> Table: """Render and print a table synchronously.""" target_console = self._ensure_console(console) table = self.render_table( kind, data=data, rows=rows, columns=columns, **overrides, ) target_console.print(table) return table
[docs] async def print_table_async( self, kind: str | None = None, *, data: Sequence[Mapping[str, Any]] | None = None, rows: Sequence[Sequence[Any]] | None = None, columns: Sequence[Mapping[str, Any]] | None = None, console: Console | None = None, **overrides: Any, ) -> Table: """Render and print a table from an async context using a worker thread.""" target_console = self._ensure_console(console) return await asyncio.to_thread( self.print_table, kind, data=data, rows=rows, columns=columns, console=target_console, **overrides, )
# ------------------------------------------------------------------ # Configuration resolution # ------------------------------------------------------------------ def _prepare_config(self, config: Mapping[str, Any] | Box | None) -> dict[str, Any]: base_config = copy.deepcopy(DEFAULT_TABLE_CONFIG) user_config = self._load_runtime_config(config) if user_config: deep_merge(base_config, user_config) return base_config def _load_runtime_config(self, config: Mapping[str, Any] | Box | None) -> dict[str, Any]: if config is None: try: config = get_config() except ConfigNotLoadedError: return {} if isinstance(config, Box): config_mapping: Mapping[str, Any] = config.to_dict() else: config_mapping = dict(config) ui_config = config_mapping.get("ui", {}) if not isinstance(ui_config, Mapping): return {} tables_config = ui_config.get("tables", {}) if isinstance(tables_config, Box): return tables_config.to_dict() if isinstance(tables_config, Mapping): return dict(tables_config) return {} def _resolve_table_config( self, kind: str | None, overrides: Mapping[str, Any], runtime_columns: Sequence[Mapping[str, Any]] | None, ) -> dict[str, Any]: defaults = copy.deepcopy(self._config["defaults"]) if not isinstance(defaults, dict): raise TableRenderingError("Table defaults configuration must be a mapping") config: dict[str, Any] = defaults preset: Mapping[str, Any] = {} raw_presets = self._config.get("presets", {}) if isinstance(raw_presets, Mapping): candidate = raw_presets.get(kind or "", {}) if isinstance(candidate, Mapping): preset = candidate deep_merge(config, preset) if runtime_columns is not None: config["columns"] = [dict(column) for column in runtime_columns] if overrides: normalized = self._normalize_overrides(overrides) deep_merge(config, normalized) return config @staticmethod def _normalize_overrides(overrides: Mapping[str, Any]) -> dict[str, Any]: normalized: dict[str, Any] = {"table": {}, "columns": None} table_overrides = normalized["table"] for key, value in overrides.items(): if key == "table" and isinstance(value, Mapping): table_overrides.update(dict(value)) continue if key == "columns" and isinstance(value, Sequence): normalized["columns"] = [dict(column) for column in value] continue table_overrides[key] = value if normalized["columns"] is None: normalized.pop("columns", None) return normalized # ------------------------------------------------------------------ # Table construction # ------------------------------------------------------------------ def _create_table(self, table_config: Mapping[str, Any]) -> Table: box_name = table_config.get("box", "SIMPLE") box_obj = self._resolve_box(box_name) return Table( title=table_config.get("title"), caption=table_config.get("caption"), title_style=table_config.get("title_style"), caption_style=table_config.get("caption_style"), show_header=table_config.get("show_header", True), header_style=table_config.get("header_style"), show_lines=table_config.get("show_lines", False), row_styles=table_config.get("row_styles"), expand=table_config.get("expand", True), pad_edge=table_config.get("pad_edge", False), highlight=table_config.get("highlight", False), box=box_obj, ) def _add_columns(self, table: Table, columns: Sequence[Mapping[str, Any]]) -> None: if not columns: return for column in columns: header = str(column.get("header", "")) column_kwargs: dict[str, Any] = { "style": column.get("style"), "no_wrap": column.get("no_wrap", False), } justify = column.get("justify") if justify is not None: column_kwargs["justify"] = justify overflow = column.get("overflow") if overflow is not None: column_kwargs["overflow"] = overflow ratio = column.get("ratio") if ratio is not None: column_kwargs["ratio"] = ratio width = column.get("width") if width is not None: column_kwargs["width"] = width min_width = column.get("min_width") if min_width is not None: column_kwargs["min_width"] = min_width max_width = column.get("max_width") if max_width is not None: column_kwargs["max_width"] = max_width table.add_column(header, **column_kwargs) def _populate_rows( self, table: Table, columns: Sequence[Mapping[str, Any]], *, data: Sequence[Mapping[str, Any]] | None, rows: Sequence[Sequence[Any]] | None, ) -> None: if rows is not None: for row in rows: table.add_row(*[self._render_cell(value) for value in row]) return if data is None: raise TableRenderingError("Table requires either 'data' or 'rows'.") for item in data: rendered_row = [self._render_cell(self._extract_value(item, column)) for column in columns] table.add_row(*rendered_row) def _extract_value(self, item: Mapping[str, Any], column: Mapping[str, Any]) -> Any: key = column.get("key") if key is None: header = column.get("header") if header is None: return "" return item.get(str(header), "") if isinstance(key, str) and "." in key: return self._extract_dotted_key(item, key) return item.get(key, "") @staticmethod def _extract_dotted_key(item: Mapping[str, Any], key: str) -> Any: current: Any = item for part in key.split("."): if isinstance(current, Mapping): current = current.get(part) else: return "" return current @staticmethod def _render_cell(value: Any) -> Text: if isinstance(value, Text): return value return Text(str(value) if value is not None else "") @staticmethod def _resolve_box(box_name: str | None) -> Any: candidate = box_name or "SIMPLE" try: return getattr(rich_box, candidate) except AttributeError as exc: # pragma: no cover - defensive guard raise TableRenderingError(f"Unknown box style '{candidate}'") from exc def _ensure_console(self, console: Console | None) -> Console: if console is not None: return console if self.console is None: self.console = Console() if self.console is None: # pragma: no cover - defensive guard raise TableRenderingError("Console instance could not be created") return self.console