feat(render): add §5 body renderers as pure functions
TDD step 2: markdown-table rendering with the load-bearing escape order (backslash, then pipe, then newlines to spaces with CRLF as one unit), SQL value-to-text conversion (NULL empty, plain-decimal integers, shortest round-trip floats, explicit bool/BLOB rejection — never silent coercion), and the verbatim fenced block for http bodies. Table shape and fenced-block form match the reference implementation byte for byte ahead of the shared §11 golden fixtures. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QeqhJpYQyghASjiJo5EhGg
This commit is contained in:
parent
54ac494830
commit
cda4a6a497
3 changed files with 187 additions and 0 deletions
|
|
@ -9,3 +9,7 @@ class IngestError(Exception):
|
|||
|
||||
class ManifestError(IngestError):
|
||||
"""The manifest failed fail-fast schema validation (ingest-spec §4)."""
|
||||
|
||||
|
||||
class RenderError(IngestError):
|
||||
"""A value cannot be rendered under the §5 body rules (never silent coercion)."""
|
||||
|
|
|
|||
68
src/llm_ingestion_okf/render.py
Normal file
68
src/llm_ingestion_okf/render.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
"""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")
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
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"
|
||||
115
tests/test_render.py
Normal file
115
tests/test_render.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"""Body rendering as pure functions (ingest-spec §5).
|
||||
|
||||
Byte-compatible with the reference implementation (portfolio-optimiser):
|
||||
markdown table with `| --- |` separator, escape order `\\` then `|` then
|
||||
newlines→space, SQL value conversion with fail-on-unknown-type, and the
|
||||
verbatim fenced block for http bodies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from llm_ingestion_okf.errors import IngestError, RenderError
|
||||
from llm_ingestion_okf.render import render_fenced_block, render_table, sql_value_to_text
|
||||
|
||||
# --- error hierarchy ---
|
||||
|
||||
|
||||
def test_render_error_is_ingest_error() -> None:
|
||||
assert issubclass(RenderError, IngestError)
|
||||
|
||||
|
||||
# --- render_table ---
|
||||
|
||||
|
||||
def test_basic_table_bytes() -> None:
|
||||
assert (
|
||||
render_table(["a", "b"], [["1", "x"], ["2", "y"]])
|
||||
== "| a | b |\n| --- | --- |\n| 1 | x |\n| 2 | y |\n"
|
||||
)
|
||||
|
||||
|
||||
def test_header_only_table() -> None:
|
||||
assert render_table(["a"], []) == "| a |\n| --- |\n"
|
||||
|
||||
|
||||
def test_table_is_lf_only_with_one_trailing_newline() -> None:
|
||||
rendered = render_table(["a"], [["x"]])
|
||||
assert "\r" not in rendered
|
||||
assert rendered.endswith("|\n")
|
||||
assert not rendered.endswith("\n\n")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "escaped"),
|
||||
[
|
||||
("a|b", "a\\|b"), # pipe → \|
|
||||
("a\\b", "a\\\\b"), # backslash → \\
|
||||
("|", "\\|"), # escape ORDER: backslash first, else this becomes \\|
|
||||
("\\|", "\\\\\\|"), # combined: raw \| → \\ + \|
|
||||
("a\nb", "a b"), # LF → single space
|
||||
("a\rb", "a b"), # CR → single space
|
||||
("a\r\nb", "a b"), # CRLF is ONE unit → one space
|
||||
],
|
||||
)
|
||||
def test_cell_escaping(raw: str, escaped: str) -> None:
|
||||
assert render_table(["h"], [[raw]]) == f"| h |\n| --- |\n| {escaped} |\n"
|
||||
|
||||
|
||||
def test_header_cells_escaped_identically() -> None:
|
||||
assert render_table(["a|b"], []) == "| a\\|b |\n| --- |\n"
|
||||
|
||||
|
||||
# --- sql_value_to_text ---
|
||||
|
||||
|
||||
def test_sql_null_is_empty_string() -> None:
|
||||
assert sql_value_to_text(None) == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "text"), [(0, "0"), (42, "42"), (-7, "-7"), (10**20, "100000000000000000000")]
|
||||
)
|
||||
def test_sql_integers_plain_decimal(value: int, text: str) -> None:
|
||||
assert sql_value_to_text(value) == text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "text"),
|
||||
[(1.5, "1.5"), (0.1, "0.1"), (-2.25, "-2.25"), (1e300, "1e+300")],
|
||||
)
|
||||
def test_sql_floats_shortest_round_trip(value: float, text: str) -> None:
|
||||
assert sql_value_to_text(value) == text
|
||||
|
||||
|
||||
def test_sql_text_verbatim_unescaped() -> None:
|
||||
# Table escaping is render_table's job, not the value conversion's.
|
||||
assert sql_value_to_text("a|b\\c") == "a|b\\c"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [True, False, b"blob", [1], {"a": 1}, object()])
|
||||
def test_sql_unsupported_types_fail(value: object) -> None:
|
||||
"""Never silent coercion (spec §5) — bool guarded explicitly (int subclass)."""
|
||||
with pytest.raises(RenderError):
|
||||
sql_value_to_text(value)
|
||||
|
||||
|
||||
# --- render_fenced_block (http bodies) ---
|
||||
|
||||
|
||||
def test_fenced_block_verbatim() -> None:
|
||||
assert render_fenced_block("hello") == "```\nhello\n```\n"
|
||||
|
||||
|
||||
def test_fenced_block_strips_trailing_newlines_only() -> None:
|
||||
assert render_fenced_block("hello\n\n") == "```\nhello\n```\n"
|
||||
assert render_fenced_block("a\n\nb\n") == "```\na\n\nb\n```\n"
|
||||
|
||||
|
||||
def test_fenced_block_not_table_escaped() -> None:
|
||||
assert render_fenced_block("a|b\\c") == "```\na|b\\c\n```\n"
|
||||
|
||||
|
||||
def test_fenced_block_empty_body() -> None:
|
||||
assert render_fenced_block("") == "```\n\n```\n"
|
||||
Loading…
Add table
Add a link
Reference in a new issue