llm-ingestion-okf/src/llm_ingestion_okf/render.py
Kjell Tore Guttormsen 9d296ca686 feat(errors): stable machine-readable codes on IngestError
Consumer tests were binding message wording (pytest.raises(match=...)) to
distinguish sub-causes within one error type. Every raise site now carries
a documented, stable code attribute; the registry lives in the errors.py
class docstrings. Codes are stable API; message text is explicitly not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 04:01:04 +02:00

72 lines
2.7 KiB
Python

"""Body rendering as pure functions (ingest-spec §5).
Byte-compatible with the reference implementation: golden conformance (§11)
freezes the markdown-table shape, the escape order, and the fenced-block form.
"""
from __future__ import annotations
from collections.abc import Sequence
from .errors import RenderError
def sql_value_to_text(value: object) -> str:
"""Convert one SQL cell value to its §5 text form (BEFORE table escaping).
SQL NULL → empty string; integers plain decimal; non-integral numbers in
shortest round-trip decimal form (repr); text verbatim; any other type
MUST fail — never silent coercion. bool is an int subclass sqlite never
emits, guarded explicitly so a stray one can never render as str(True).
"""
if value is None:
return ""
if isinstance(value, bool):
raise RenderError(
f"unsupported SQL cell type bool ({value!r}) — never silent coercion",
code="unsupported_cell_type",
)
if isinstance(value, int):
return str(value)
if isinstance(value, float):
return repr(value)
if isinstance(value, str):
return value
raise RenderError(
f"unsupported SQL cell type {type(value).__name__} — spec §5 allows "
"integer/float/text/NULL only; never silent coercion",
code="unsupported_cell_type",
)
def _escape_cell(value: str) -> str:
# Escape order is load-bearing: `\` FIRST (else the backslash introduced
# by pipe-escaping gets double-escaped), then `|`, then newlines → single
# space with CRLF treated as ONE unit.
value = value.replace("\\", "\\\\").replace("|", "\\|")
return value.replace("\r\n", " ").replace("\r", " ").replace("\n", " ")
def render_table(header: Sequence[str], rows: Sequence[Sequence[str]]) -> str:
"""Render extraction rows as the §5 markdown-table body.
Cells are strings by the time they reach here (sql values via
sql_value_to_text). Header cells are escaped identically to data cells.
LF-only, exactly one trailing newline.
"""
def line(cells: Sequence[str]) -> str:
return "| " + " | ".join(_escape_cell(cell) for cell in cells) + " |"
separator = "| " + " | ".join("---" for _ in header) + " |"
return "\n".join([line(header), separator, *(line(row) for row in rows)]) + "\n"
def render_fenced_block(body: str) -> str:
"""Render an http response body verbatim inside a fenced code block (§5).
NOT table-escaped — pipe/backslash survive verbatim. The body's own
trailing newlines are stripped so the closing fence sits flush; LF-only,
exactly one trailing newline.
"""
return f"```\n{body.rstrip(chr(10))}\n```\n"