Source code for genro_toolbox.ascii_table

"""ASCII and Markdown table rendering utilities."""

import re
import textwrap
from datetime import datetime

ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")

DEFAULT_MAX_WIDTH = 120
MIN_COLUMN_WIDTH = 6


def strip_ansi(s):
    return ANSI_RE.sub("", s)


def normalize_date_format(fmt: str) -> str:
    mapping = {
        "yyyy": "%Y",
        "yy": "%y",
        "mm": "%m",
        "dd": "%d",
        "HH": "%H",
        "MM": "%M",
        "SS": "%S",
    }
    result = fmt
    for k, v in mapping.items():
        result = result.replace(k, v)
    return result


def parse_bool(value):
    v = str(value).strip().lower()
    if v in ("true", "yes", "1"):
        return True
    if v in ("false", "no", "0"):
        return False
    return value


def _format_bool(value, _fmt):
    v = parse_bool(value)
    return "true" if v is True else "false" if v is False else str(value)


def _format_int(value, _fmt):
    try:
        return str(int(value))
    except (ValueError, TypeError):
        return str(value)


def _format_float(value, fmt):
    try:
        f = float(value)
        return format(f, fmt) if fmt else f"{f:g}"
    except (ValueError, TypeError):
        return str(value)


def _format_date(value, fmt):
    try:
        d = datetime.fromisoformat(str(value)).date()
    except (ValueError, TypeError):
        return str(value)
    return d.strftime(normalize_date_format(fmt)) if fmt else d.isoformat()


def _format_datetime(value, fmt):
    try:
        dt = datetime.fromisoformat(str(value))
    except (ValueError, TypeError):
        return str(value)
    return dt.strftime(normalize_date_format(fmt)) if fmt else dt.strftime("%Y-%m-%d %H:%M:%S")


_FORMATTERS = {
    "bool": _format_bool,
    "int": _format_int,
    "float": _format_float,
    "date": _format_date,
    "datetime": _format_datetime,
}


def format_cell(value, coldef):
    ctype = coldef.get("type", "str")
    fmt = coldef.get("format")
    formatter = _FORMATTERS.get(ctype)
    if formatter is not None:
        return formatter(value, fmt)
    return str(value)


def build_tree(paths, sep):
    tree = {}
    for full in paths:
        parts = str(full).split(sep)
        node = tree
        for part in parts:
            node = node.setdefault(part, {})
    return tree


def flatten_tree(tree, level=0, prefix=""):
    nodes = []
    for key in sorted(tree.keys()):
        full = prefix + key if prefix == "" else prefix + "/" + key
        children = tree[key]
        is_leaf = len(children) == 0
        nodes.append((full, key, level, is_leaf))
        nodes.extend(flatten_tree(children, level + 1, full))
    return nodes


def apply_hierarchy(headers, rows):
    for idx, h in enumerate(headers):
        if "hierarchy" not in h:
            continue
        sep = h["hierarchy"].get("sep", "/")
        original = [r[idx] for r in rows]
        values_by_path = {r[idx]: r[1:] for r in rows}
        tree = build_tree(original, sep)
        tree_items = flatten_tree(tree)
        other_col_count = len(rows[0]) - 1
        expanded_rows = []
        for full, label, level, is_leaf in tree_items:
            values = (
                values_by_path[full]
                if is_leaf and full in values_by_path
                else [""] * other_col_count
            )
            expanded_rows.append(["  " * level + label] + values)
        return expanded_rows
    return rows


def compute_col_widths(names, rows, max_width=DEFAULT_MAX_WIDTH, minw=MIN_COLUMN_WIDTH, pad=1):
    usable = max_width - (len(names) + 1)
    widths = []
    min_widths = []  # Minimum width based on longest word

    for i, n in enumerate(names):
        # Find longest word in this column
        longest_word = len(strip_ansi(n))
        for word in strip_ansi(n).split():
            longest_word = max(longest_word, len(word))
        for r in rows:
            cell_str = strip_ansi(str(r[i]))
            for word in cell_str.split():
                longest_word = max(longest_word, len(word))

        # Ideal width (full content) and minimum (longest word)
        ideal = len(strip_ansi(n))
        for r in rows:
            ideal = max(ideal, len(strip_ansi(str(r[i]))))

        widths.append(max(ideal + pad, minw))
        min_widths.append(max(longest_word + pad, minw))

    total = sum(widths)
    if total > usable:
        # First try: ensure no word is broken
        min_total = sum(min_widths)
        if min_total <= usable:
            # Distribute remaining space proportionally to each column's excess over min
            remaining = usable - min_total
            excess = total - min_total
            ratio = remaining / excess
            for i in range(len(widths)):
                extra = widths[i] - min_widths[i]
                widths[i] = min_widths[i] + int(extra * ratio)
        else:
            # Not enough space even for longest words - scale down proportionally
            scale = usable / sum(min_widths)
            widths = [max(minw, int(w * scale)) for w in min_widths]
    return widths


def wrap_row(row, widths):
    result = []
    for cell, width in zip(row, widths, strict=False):
        s = str(cell)
        has_long_word = any(len(w) > width for w in s.split())
        result.append(
            textwrap.wrap(s, width, break_long_words=has_long_word, break_on_hyphens=False) or [""]
        )
    return result


def merge_wrapped(wrapped):
    max_lines = max(len(col) for col in wrapped)
    return [[col[i] if i < len(col) else "" for col in wrapped] for i in range(max_lines)]


def apply_align(text, width, align):
    if align == "right":
        return text.rjust(width)
    if align == "center":
        return text.center(width)
    return text.ljust(width)


def draw_table(headers, rows, max_width=DEFAULT_MAX_WIDTH):
    names = [h["name"] for h in headers]
    widths = compute_col_widths(names, rows, max_width)

    def sep():
        return "+" + "+".join("-" * w for w in widths) + "+"

    def format_row(row_data):
        lines = []
        for line in merge_wrapped(wrap_row(row_data, widths)):
            lines.append(
                "|"
                + "|".join(
                    apply_align(txt, w, h.get("align", "left"))
                    for txt, w, h in zip(line, widths, headers, strict=False)
                )
                + "|"
            )
        return lines

    out = [sep()]
    out.extend(format_row(names))
    out.append(sep())
    for row in rows:
        out.extend(format_row(row))
        out.append(sep())
    return "\n".join(out)


[docs] def render_ascii_table(data, max_width=None): headers = data["headers"] rows = data["rows"] if max_width is None: max_width = data.get("max_width", DEFAULT_MAX_WIDTH) formatted = [[format_cell(c, h) for c, h in zip(r, headers, strict=False)] for r in rows] final = apply_hierarchy(headers, formatted) table = draw_table(headers, final, max_width=max_width) title = data.get("title") return title.center(max_width) + "\n" + table if title else table
[docs] def render_markdown_table(data): headers = data["headers"] rows = data["rows"] names = [h["name"] for h in headers] out = [] out.append("| " + " | ".join(names) + " |") out.append("| " + " | ".join("---" for _ in names) + " |") for r in rows: vals = [format_cell(c, h) for c, h in zip(r, headers, strict=False)] out.append("| " + " | ".join(vals) + " |") return "\n".join(out)