Source code for repomatic.tabular

# Copyright Kevin Deldycke <[email protected]> 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.

"""Render and persist the flat tables repomatic produces.

Two surfaces, one module, because both are the same shape of data: the CSV
files a repository commits (a scan verdict, a binary in a release, a metric
reading) and the Markdown tables its reports embed (a PR body's diff table, a
step summary's tally). {func}`render_markdown_table` is the one Markdown table
renderer, so every report agrees on the cell and separator spelling; the CSV
trio below decides how the committed datasets are stored.

```{note}
CSV over JSON for these, on four counts a flat table makes decisive:

- **Diff churn.** A record is one line, not seven. These files are sorted, so a
  scheduled append lands mid-file rather than at the end: ten new readings cost
  ten inserted lines instead of seventy.
- **Size.** Roughly half, and the gap widens as a history accrues.
- **Rendering.** MyST's `csv-table` directive reads one directly, and GitHub
  serves a committed CSV through its own searchable grid viewer where JSON is
  raw text.
- **No formatter contention.** Nothing in the autofix lane touches CSV, where
  a committed JSON file has to be serialized in Biome's exact style or
  `format-json` rewrites it right back.

JSON earns its place where a record nests. None of these do.
```
"""

from __future__ import annotations

import csv
import io
import logging

TYPE_CHECKING = False
if TYPE_CHECKING:
    from collections.abc import Iterable, Sequence
    from pathlib import Path


[docs] def render_csv(headers: Sequence[str], rows: Iterable[Sequence[object]]) -> str: """Render a header row and its data rows as CSV text. Newlines are `\\n` on every platform, since the output is committed and a platform-dependent line ending would make the file churn between a Windows and a Unix runner. :param headers: Column names, in order. :param rows: One sequence of cells per row, in the same order. :return: The complete CSV document, newline-terminated. """ buffer = io.StringIO() writer = csv.writer(buffer, lineterminator="\n") writer.writerow(headers) for row in rows: writer.writerow(row) return buffer.getvalue()
_ALIGN_MARKERS = {"": "---", "center": ":-:", "left": ":--", "right": "--:"} """GFM delimiter-row cell per alignment name, empty for the parser default."""
[docs] def render_markdown_table( headers: Sequence[object], rows: Iterable[Sequence[object]], align: Sequence[str] = (), ) -> str: """Render a GitHub-flavored Markdown table. Cells are used as given: a caller wanting a code span, a link or an emoji renders it into the cell first. Nothing is escaped, matching what every report renderer did by hand before this existed: none of them ever feeds a cell carrying a `|`. :param headers: Column titles, in order. :param rows: One sequence of cells per row, in the same order. :param align: Per-column alignment, `left`, `right` or `center`; an empty entry (or a list shorter than *headers*) leaves that column on the parser default. Alignment only changes how a *renderer* justifies the column, so it is worth declaring where it carries meaning, like a numeric column read against its neighbours. :return: The table's lines joined with newlines, no trailing newline. :raises KeyError: On an alignment name outside the vocabulary. """ markers = [ _ALIGN_MARKERS[align[index] if index < len(align) else ""] for index in range(len(headers)) ] lines = [ "| " + " | ".join(str(header) for header in headers) + " |", "| " + " | ".join(markers) + " |", ] lines.extend("| " + " | ".join(str(cell) for cell in row) + " |" for row in rows) return "\n".join(lines)
[docs] def read_csv(path: Path) -> list[dict[str, str]]: """Read a committed CSV into one mapping per row. Every cell comes back as a string: CSV carries no types, so a caller wanting a number coerces it. A missing file reads as no rows, which is what a first run sees. :param path: Path to the CSV file. :return: One mapping per data row, keyed by column name. :raises ValueError: When the file exists but carries no header row. Loud on purpose: a truncated or half-written file must never be silently treated as empty and clobbered by the next {func}`write_csv`. """ if not path.exists(): return [] with path.open(encoding="UTF-8", newline="") as handle: reader = csv.DictReader(handle) if reader.fieldnames is None: msg = f"Malformed CSV {path}: no header row." raise ValueError(msg) return [dict(row) for row in reader]
[docs] def write_if_changed(path: Path, content: str) -> bool: """Write *content* to *path*, leaving an already-matching file alone. Creates the parent directories when missing. Comparing before writing is what every generator in the package leans on: one that rewrote its output unconditionally would turn each scheduled run into a commit, and a sync job that opens a pull request would open one forever. Format-neutral despite sitting beside the CSV helpers, because what it encodes is the write rather than the bytes. The SVG charts in {mod}`repomatic.metric_chart` route through it too. :param path: File to write. :param content: The full text the file should hold. :return: `True` when the file was created or its content changed. """ if path.exists() and path.read_text(encoding="UTF-8") == content: logging.debug(f"{path} already up to date.") return False path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding="UTF-8") return True
[docs] def write_csv(path: Path, content: str) -> bool: """Write rendered CSV to *path*, leaving an unchanged file alone. :param path: Path to the CSV file. :param content: Rendered CSV, from {func}`render_csv`. :return: `True` when the file content changed. """ return write_if_changed(path, content)