"""Exception chaining: every fail-fast wrap preserves its `__cause__`. The library turns leaked stdlib failures (OSError, ValueError, sqlite3.Error, URLError) into typed IngestErrors at the boundary — but always with `raise TypedError(...) from exc`, never a bare `raise`. That `from exc` keeps the triggering exception under `__cause__`, so an operator reading a traceback still sees the underlying cause (which file, which OS error, which SQL fault) beneath the typed message. This file IS the conformance suite for that invariant: one test per `raise ... from exc` site, asserting the chained type. Companion to test_error_codes.py, which pins the `.code` on the same sites. """ from __future__ import annotations 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_sql, safe_resolve from llm_ingestion_okf.errors import ManifestError, SourceError from llm_ingestion_okf.manifest import load_manifest, load_manifest_bytes from llm_ingestion_okf.materialize import materialize_bundle INGESTED_AT = "2026-07-17T12:00:00Z" # --- ManifestError sites (manifest.py, materialize.py) --- def test_load_manifest_unreadable_chains_oserror(tmp_path: Path) -> None: # manifest.py: OSError on read_bytes -> ManifestError(manifest_unreadable). with pytest.raises(ManifestError) as excinfo: load_manifest(tmp_path / "nope.json") assert isinstance(excinfo.value.__cause__, OSError) def test_materialize_unreadable_manifest_chains_oserror(tmp_path: Path) -> None: # materialize.py: the separate read inside materialize_bundle wraps the # same OSError once ingested_at has validated. with pytest.raises(ManifestError) as excinfo: materialize_bundle(tmp_path / "nope.json", tmp_path / "bundle", INGESTED_AT) assert isinstance(excinfo.value.__cause__, OSError) def test_load_manifest_bytes_bad_utf8_chains_unicode_error() -> None: # manifest.py: bytes that are not UTF-8 -> ManifestError(manifest_invalid_json). with pytest.raises(ManifestError) as excinfo: load_manifest_bytes(b"\xff\xfe") assert isinstance(excinfo.value.__cause__, UnicodeDecodeError) def test_load_manifest_bytes_bad_json_chains_value_error() -> None: # manifest.py: valid UTF-8 that is not JSON -> the same code, a ValueError # cause (json.JSONDecodeError is a ValueError subclass). with pytest.raises(ManifestError) as excinfo: load_manifest_bytes(b"not json") assert isinstance(excinfo.value.__cause__, ValueError) # --- SourceError sites (connectors.py) --- def test_safe_resolve_embedded_null_chains_value_error(tmp_path: Path) -> None: # connectors.py: a NUL byte makes the path syscalls raise ValueError before # any boundary check -> SourceError(path_escape). with pytest.raises(SourceError) as excinfo: safe_resolve(tmp_path, "bad\x00.md") assert isinstance(excinfo.value.__cause__, ValueError) def test_read_sql_failure_chains_sqlite_error( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: # connectors.py: a failing query -> SourceError(sql_failed) over sqlite3.Error. 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 isinstance(excinfo.value.__cause__, sqlite3.Error) def test_urllib_get_transport_failure_chains_urlerror(monkeypatch: pytest.MonkeyPatch) -> None: # connectors.py: a transport failure -> SourceError(http_transport) over URLError. 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 isinstance(excinfo.value.__cause__, urllib.error.URLError)