llm-ingestion-okf/tests/test_error_codes.py
Kjell Tore Guttormsen db93de4aef feat(extract): Door B extraction registry (Phase 2 step 1)
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
2026-07-24 20:18:23 +02:00

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"