feat(ingest): CSV connector with boundary check, row cap and escaped table body (I2)
This commit is contained in:
parent
e4ee8bd52e
commit
f331cb4763
2 changed files with 185 additions and 0 deletions
|
|
@ -29,6 +29,7 @@ cells; empty/ragged CSV and missing ``root``/``query`` fail fast as ``IngestErro
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
|
@ -37,6 +38,8 @@ from urllib.parse import urlsplit
|
|||
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
from portfolio_optimiser.retrieval import safe_resolve
|
||||
|
||||
_ID_PATTERN = r"^[a-z0-9][a-z0-9-]*$"
|
||||
|
||||
|
||||
|
|
@ -145,3 +148,61 @@ def load_manifest(path: str | Path) -> tuple[ManifestV1, str]:
|
|||
stamp = f"{manifest_path.stem}@{hashlib.sha256(raw).hexdigest()[:16]}"
|
||||
manifest = ManifestV1.model_validate(json.loads(raw.decode("utf-8")))
|
||||
return manifest, stamp
|
||||
|
||||
|
||||
def read_csv(root: str | Path, query: str, *, max_rows: int) -> tuple[list[str], list[list[str]]]:
|
||||
"""Execute a ``file``-source extraction: read the CSV at ``query`` inside ``root`` (§4).
|
||||
|
||||
Fail-closed boundary check via ``safe_resolve`` (a query can never read outside the
|
||||
catalogue); ``utf-8-sig`` so a BOM never leaks into the first header cell (pinned
|
||||
decision); streaming ``max_rows`` cap — exceeding it is an ERROR the moment it happens,
|
||||
never a silent truncation (§8). Input-shape failures (missing root/file, empty CSV,
|
||||
ragged row) raise ``IngestError`` fail-fast — never a leaked ``FileNotFoundError`` and
|
||||
never silent coercion. Pure: no logging, no writes (materialization owns the §8 log)."""
|
||||
root_path = Path(root)
|
||||
if not root_path.is_dir():
|
||||
raise IngestError(f"file-source root is not a directory: {root_path}")
|
||||
resolved = Path(safe_resolve(str(root_path), query))
|
||||
if not resolved.is_file():
|
||||
raise IngestError(f"extraction query does not resolve to a file: {query!r}")
|
||||
with resolved.open(encoding="utf-8-sig", newline="") as handle:
|
||||
reader = csv.reader(handle)
|
||||
header = next(reader, None)
|
||||
if header is None:
|
||||
raise IngestError(f"CSV has no header row: {query!r}")
|
||||
rows: list[list[str]] = []
|
||||
for row in reader:
|
||||
if len(rows) >= max_rows:
|
||||
raise IngestError(
|
||||
f"extraction {query!r} exceeds max_rows={max_rows} (error, never "
|
||||
"silent truncation — ingest spec §8)"
|
||||
)
|
||||
if len(row) != len(header):
|
||||
raise IngestError(
|
||||
f"ragged CSV row in {query!r}: expected {len(header)} cells, "
|
||||
f"got {len(row)}"
|
||||
)
|
||||
rows.append(row)
|
||||
return header, rows
|
||||
|
||||
|
||||
def _escape_cell(value: str) -> str:
|
||||
# §5 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: list[str], rows: list[list[str]]) -> str:
|
||||
"""Render extraction rows as the §5 markdown-table body (LF-only, one trailing newline).
|
||||
|
||||
Cells are text-verbatim after escaping (pinned decision: on the ``file``/CSV path every
|
||||
cell is a string; §5's integer/float/NULL clauses bite typed ``sql`` values in I4).
|
||||
Header cells are escaped identically to data cells (pinned decision)."""
|
||||
|
||||
def line(cells: list[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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue