First, guard-independent step of Phase 2: a stdlib-only registry mapping a dropped file's extension to its text extractor, with the fail-fast gates that keep binary parsing out of core. `extract_text(filename, data)` dispatches (case-insensitively) to: - `md`/`txt` — utf-8-sig passthrough (BOM never leaks, baseline parity with Door A's read_csv); - `csv` — the Phase 1 `render_table` (renderer reused, not duplicated); - `json` — verbatim inside `render_fenced_block`; - `html`/`htm` — text via `html.parser`, `script`/`style` stripped, tags as word boundaries (spec B3: adequate for v1, richer is out of scope). `pdf`/`docx`/`xlsx` are `[extract]`-gated; until that extra ships a parser they fail fast with a typed error naming the extra — never a silent skip, never a bundled parser in core. New `ExtractionError(IngestError)` carries four stable codes (`extractor_unknown`, `extractor_extra_missing`, `extractor_decode_error`, `extractor_empty_csv`); a non-UTF-8 file is a typed corrupt-input failure, never a leaked UnicodeDecodeError. `extract_text` returns text content only — LF framing and concept frontmatter are the materializer's job (step 2). No runtime dependency and no guard call yet (the guard pin and 0.4.0 land with the persist gate in steps 4–5). TDD: test_extract.py + the four codes in the test_error_codes.py registry precede the implementation; mypy --strict, ruff, and the `sanitize|quarantine|lexicon` boundary grep-gate all clean; the Phase 1 golden suite still passes byte-for-byte. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBbjgS5A55RVavoyjJC4FX
357 lines
12 KiB
Python
357 lines
12 KiB
Python
"""Stable error codes: `IngestError.code` is the machine-readable API.
|
|
|
|
Every raise site carries a documented, stable code so consumers can assert
|
|
on sub-causes without binding message wording (messages may improve freely;
|
|
codes may not change). One test per code — this file IS the registry's
|
|
conformance suite.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sqlite3
|
|
import urllib.error
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
import llm_ingestion_okf.connectors as connectors
|
|
from llm_ingestion_okf.connectors import read_csv, read_http, read_sql, safe_resolve
|
|
from llm_ingestion_okf.errors import (
|
|
ExtractionError,
|
|
IngestError,
|
|
ManifestError,
|
|
MaterializationError,
|
|
NetworkGateError,
|
|
RenderError,
|
|
SourceError,
|
|
)
|
|
from llm_ingestion_okf.extract import extract_text
|
|
from llm_ingestion_okf.manifest import load_manifest, load_manifest_bytes
|
|
from llm_ingestion_okf.materialize import materialize_bundle
|
|
from llm_ingestion_okf.render import sql_value_to_text
|
|
|
|
INGESTED_AT = "2026-07-17T12:00:00Z"
|
|
|
|
|
|
def manifest_data(**overrides: Any) -> dict[str, Any]:
|
|
data: dict[str, Any] = {
|
|
"manifest_version": 1,
|
|
"source": {"type": "file", "id": "src-1", "root": "data"},
|
|
"bundle_summary": "A bundle.",
|
|
"extractions": [
|
|
{
|
|
"id": "orders",
|
|
"title": "Orders",
|
|
"query": "orders.csv",
|
|
"okf_type": "dataset",
|
|
"max_rows": 100,
|
|
}
|
|
],
|
|
}
|
|
data.update(overrides)
|
|
return data
|
|
|
|
|
|
def load(data: dict[str, Any]) -> None:
|
|
load_manifest_bytes(json.dumps(data).encode("utf-8"))
|
|
|
|
|
|
def code_of(excinfo: pytest.ExceptionInfo[IngestError]) -> str:
|
|
return excinfo.value.code
|
|
|
|
|
|
# --- the attribute itself ---
|
|
|
|
|
|
def test_bare_ingest_error_defaults_to_unspecified() -> None:
|
|
assert IngestError("something").code == "unspecified"
|
|
|
|
|
|
def test_code_does_not_change_the_message() -> None:
|
|
exc = SourceError("the message", code="max_rows_exceeded")
|
|
assert str(exc) == "the message"
|
|
assert exc.code == "max_rows_exceeded"
|
|
|
|
|
|
# --- ManifestError codes ---
|
|
|
|
|
|
def test_manifest_unreadable(tmp_path: Path) -> None:
|
|
with pytest.raises(ManifestError) as excinfo:
|
|
load_manifest(tmp_path / "nope.json")
|
|
assert code_of(excinfo) == "manifest_unreadable"
|
|
|
|
|
|
def test_manifest_unreadable_via_materialize(tmp_path: Path) -> None:
|
|
with pytest.raises(ManifestError) as excinfo:
|
|
materialize_bundle(tmp_path / "nope.json", tmp_path / "bundle", INGESTED_AT)
|
|
assert code_of(excinfo) == "manifest_unreadable"
|
|
|
|
|
|
@pytest.mark.parametrize("raw", [b"\xff\xfe", b"not json"])
|
|
def test_manifest_invalid_json(raw: bytes) -> None:
|
|
with pytest.raises(ManifestError) as excinfo:
|
|
load_manifest_bytes(raw)
|
|
assert code_of(excinfo) == "manifest_invalid_json"
|
|
|
|
|
|
def test_manifest_version_unsupported() -> None:
|
|
with pytest.raises(ManifestError) as excinfo:
|
|
load(manifest_data(manifest_version=2))
|
|
assert code_of(excinfo) == "manifest_version_unsupported"
|
|
|
|
|
|
def test_source_type_unknown() -> None:
|
|
with pytest.raises(ManifestError) as excinfo:
|
|
load(manifest_data(source={"type": "ftp", "id": "x"}))
|
|
assert code_of(excinfo) == "source_type_unknown"
|
|
|
|
|
|
def test_credential_embedded_in_base_url() -> None:
|
|
source = {"type": "http", "id": "api-1", "base_url": "https://user:pw@example.test"}
|
|
with pytest.raises(ManifestError) as excinfo:
|
|
load(manifest_data(source=source))
|
|
assert code_of(excinfo) == "credential_embedded"
|
|
|
|
|
|
def test_extraction_id_duplicate() -> None:
|
|
extraction = manifest_data()["extractions"][0]
|
|
with pytest.raises(ManifestError) as excinfo:
|
|
load(manifest_data(extractions=[extraction, dict(extraction)]))
|
|
assert code_of(excinfo) == "extraction_id_duplicate"
|
|
|
|
|
|
def test_okf_type_reserved() -> None:
|
|
extraction = dict(manifest_data()["extractions"][0], okf_type="verdict")
|
|
with pytest.raises(ManifestError) as excinfo:
|
|
load(manifest_data(extractions=[extraction]))
|
|
assert code_of(excinfo) == "okf_type_reserved"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"data",
|
|
[
|
|
"not an object",
|
|
manifest_data(bundle_summary=None),
|
|
{k: v for k, v in manifest_data().items() if k != "source"},
|
|
manifest_data(surprise="unknown field"),
|
|
manifest_data(extractions=[]),
|
|
manifest_data(source={"type": "file", "id": "UPPER", "root": "data"}),
|
|
manifest_data(extractions=[dict(manifest_data()["extractions"][0], title="two\nlines")]),
|
|
manifest_data(extractions=[dict(manifest_data()["extractions"][0], title="link [x]")]),
|
|
manifest_data(extractions=[dict(manifest_data()["extractions"][0], max_rows=0)]),
|
|
manifest_data(extractions=[dict(manifest_data()["extractions"][0], query="")]),
|
|
],
|
|
)
|
|
def test_manifest_schema_violations_share_one_code(data: Any) -> None:
|
|
with pytest.raises(ManifestError) as excinfo:
|
|
load_manifest_bytes(json.dumps(data).encode("utf-8"))
|
|
assert code_of(excinfo) == "manifest_schema"
|
|
|
|
|
|
# --- SourceError codes ---
|
|
|
|
|
|
def test_path_escape(tmp_path: Path) -> None:
|
|
with pytest.raises(SourceError) as excinfo:
|
|
safe_resolve(tmp_path, "../outside")
|
|
assert code_of(excinfo) == "path_escape"
|
|
|
|
|
|
def test_path_escape_covers_embedded_null(tmp_path: Path) -> None:
|
|
# A NUL byte makes the OS path syscalls raise ValueError before any
|
|
# boundary check runs. SourceError promises "always typed, never a
|
|
# leaked OSError" — an untyped ValueError breaks that contract, so a
|
|
# malformed target must fail closed under the same code as any other
|
|
# target that cannot resolve inside the root.
|
|
with pytest.raises(SourceError) as excinfo:
|
|
safe_resolve(tmp_path, "bad\x00.md")
|
|
assert code_of(excinfo) == "path_escape"
|
|
|
|
|
|
def test_source_root_missing(tmp_path: Path) -> None:
|
|
with pytest.raises(SourceError) as excinfo:
|
|
read_csv(tmp_path / "nope", "a.csv", max_rows=1)
|
|
assert code_of(excinfo) == "source_root_missing"
|
|
|
|
|
|
def test_source_file_missing(tmp_path: Path) -> None:
|
|
with pytest.raises(SourceError) as excinfo:
|
|
read_csv(tmp_path, "nope.csv", max_rows=1)
|
|
assert code_of(excinfo) == "source_file_missing"
|
|
|
|
|
|
def test_csv_no_header(tmp_path: Path) -> None:
|
|
(tmp_path / "empty.csv").write_text("", encoding="utf-8")
|
|
with pytest.raises(SourceError) as excinfo:
|
|
read_csv(tmp_path, "empty.csv", max_rows=1)
|
|
assert code_of(excinfo) == "csv_no_header"
|
|
|
|
|
|
def test_csv_ragged_row(tmp_path: Path) -> None:
|
|
(tmp_path / "ragged.csv").write_text("a,b\n1\n", encoding="utf-8", newline="")
|
|
with pytest.raises(SourceError) as excinfo:
|
|
read_csv(tmp_path, "ragged.csv", max_rows=10)
|
|
assert code_of(excinfo) == "csv_ragged_row"
|
|
|
|
|
|
def test_max_rows_exceeded_csv(tmp_path: Path) -> None:
|
|
(tmp_path / "big.csv").write_text("a\n1\n2\n", encoding="utf-8", newline="")
|
|
with pytest.raises(SourceError) as excinfo:
|
|
read_csv(tmp_path, "big.csv", max_rows=1)
|
|
assert code_of(excinfo) == "max_rows_exceeded"
|
|
|
|
|
|
def test_max_rows_exceeded_sql(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
db = tmp_path / "fixture.db"
|
|
with sqlite3.connect(db) as conn:
|
|
conn.execute("CREATE TABLE t (id INTEGER)")
|
|
conn.executemany("INSERT INTO t VALUES (?)", [(1,), (2,)])
|
|
monkeypatch.setenv("OKF_TEST_DB", str(db))
|
|
with pytest.raises(SourceError) as excinfo:
|
|
read_sql("OKF_TEST_DB", "SELECT id FROM t", max_rows=1)
|
|
assert code_of(excinfo) == "max_rows_exceeded"
|
|
|
|
|
|
def test_max_rows_exceeded_http() -> None:
|
|
with pytest.raises(SourceError) as excinfo:
|
|
read_http("https://example.test", "/x", max_rows=1, get=lambda url, cred: "a\nb\n")
|
|
assert code_of(excinfo) == "max_rows_exceeded"
|
|
|
|
|
|
def test_connection_ref_unset(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.delenv("OKF_TEST_DB", raising=False)
|
|
with pytest.raises(SourceError) as excinfo:
|
|
read_sql("OKF_TEST_DB", "SELECT 1", max_rows=1)
|
|
assert code_of(excinfo) == "connection_ref_unset"
|
|
|
|
|
|
def test_database_missing(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("OKF_TEST_DB", str(tmp_path / "nope.db"))
|
|
with pytest.raises(SourceError) as excinfo:
|
|
read_sql("OKF_TEST_DB", "SELECT 1", max_rows=1)
|
|
assert code_of(excinfo) == "database_missing"
|
|
|
|
|
|
def test_sql_no_columns(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
db = tmp_path / "fixture.db"
|
|
sqlite3.connect(db).close()
|
|
monkeypatch.setenv("OKF_TEST_DB", str(db))
|
|
# BEGIN succeeds read-only and yields no result columns.
|
|
with pytest.raises(SourceError) as excinfo:
|
|
read_sql("OKF_TEST_DB", "BEGIN", max_rows=1)
|
|
assert code_of(excinfo) == "sql_no_columns"
|
|
|
|
|
|
def test_sql_failed(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
db = tmp_path / "fixture.db"
|
|
sqlite3.connect(db).close()
|
|
monkeypatch.setenv("OKF_TEST_DB", str(db))
|
|
with pytest.raises(SourceError) as excinfo:
|
|
read_sql("OKF_TEST_DB", "SELECT nope FROM missing", max_rows=1)
|
|
assert code_of(excinfo) == "sql_failed"
|
|
|
|
|
|
def test_credential_ref_unset(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.delenv("OKF_TEST_TOKEN", raising=False)
|
|
with pytest.raises(SourceError) as excinfo:
|
|
read_http(
|
|
"https://example.test",
|
|
"/x",
|
|
max_rows=1,
|
|
credential_ref="OKF_TEST_TOKEN",
|
|
get=lambda url, cred: "",
|
|
)
|
|
assert code_of(excinfo) == "credential_ref_unset"
|
|
|
|
|
|
def test_http_transport(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
def refuse(request: Any) -> Any:
|
|
raise urllib.error.URLError("refused")
|
|
|
|
monkeypatch.setattr(connectors, "urlopen", refuse)
|
|
with pytest.raises(SourceError) as excinfo:
|
|
connectors.urllib_get("https://example.test/x", None)
|
|
assert code_of(excinfo) == "http_transport"
|
|
|
|
|
|
def test_fence_marker_in_body() -> None:
|
|
with pytest.raises(SourceError) as excinfo:
|
|
read_http("https://example.test", "/x", max_rows=10, get=lambda url, cred: "```\n")
|
|
assert code_of(excinfo) == "fence_marker_in_body"
|
|
|
|
|
|
# --- RenderError codes ---
|
|
|
|
|
|
@pytest.mark.parametrize("value", [True, b"bytes"])
|
|
def test_unsupported_cell_type(value: object) -> None:
|
|
with pytest.raises(RenderError) as excinfo:
|
|
sql_value_to_text(value)
|
|
assert code_of(excinfo) == "unsupported_cell_type"
|
|
|
|
|
|
# --- ExtractionError codes ---
|
|
|
|
|
|
def test_extractor_unknown() -> None:
|
|
with pytest.raises(ExtractionError) as excinfo:
|
|
extract_text("archive.zip", b"")
|
|
assert code_of(excinfo) == "extractor_unknown"
|
|
|
|
|
|
def test_extractor_extra_missing() -> None:
|
|
with pytest.raises(ExtractionError) as excinfo:
|
|
extract_text("doc.pdf", b"binary")
|
|
assert code_of(excinfo) == "extractor_extra_missing"
|
|
|
|
|
|
def test_extractor_decode_error() -> None:
|
|
with pytest.raises(ExtractionError) as excinfo:
|
|
extract_text("note.txt", b"\xffbad")
|
|
assert code_of(excinfo) == "extractor_decode_error"
|
|
|
|
|
|
def test_extractor_empty_csv() -> None:
|
|
with pytest.raises(ExtractionError) as excinfo:
|
|
extract_text("empty.csv", b"")
|
|
assert code_of(excinfo) == "extractor_empty_csv"
|
|
|
|
|
|
# --- MaterializationError codes ---
|
|
|
|
|
|
def test_ingested_at_invalid(tmp_path: Path) -> None:
|
|
with pytest.raises(MaterializationError) as excinfo:
|
|
materialize_bundle(tmp_path / "m.json", tmp_path / "bundle", "2026-07-17")
|
|
assert code_of(excinfo) == "ingested_at_invalid"
|
|
|
|
|
|
def test_collision_unstamped(tmp_path: Path) -> None:
|
|
src = tmp_path / "src"
|
|
src.mkdir()
|
|
(src / "manifest.json").write_text(json.dumps(manifest_data()), encoding="utf-8")
|
|
(src / "data").mkdir()
|
|
(src / "data" / "orders.csv").write_text("a\n1\n", encoding="utf-8", newline="")
|
|
bundle = tmp_path / "bundle"
|
|
bundle.mkdir()
|
|
(bundle / "ingest-orders.md").write_text("curated, no stamp\n", encoding="utf-8")
|
|
with pytest.raises(MaterializationError) as excinfo:
|
|
materialize_bundle(src / "manifest.json", bundle, INGESTED_AT)
|
|
assert code_of(excinfo) == "collision_unstamped"
|
|
|
|
|
|
# --- NetworkGateError codes ---
|
|
|
|
|
|
def test_network_opt_in_missing(tmp_path: Path) -> None:
|
|
src = tmp_path / "src"
|
|
src.mkdir()
|
|
data = manifest_data(source={"type": "http", "id": "api-1", "base_url": "https://x.test"})
|
|
data["extractions"][0]["query"] = "/orders"
|
|
(src / "manifest.json").write_text(json.dumps(data), encoding="utf-8")
|
|
with pytest.raises(NetworkGateError) as excinfo:
|
|
materialize_bundle(src / "manifest.json", tmp_path / "bundle", INGESTED_AT)
|
|
assert code_of(excinfo) == "network_opt_in_missing"
|