feat(errors): stable machine-readable codes on IngestError

Consumer tests were binding message wording (pytest.raises(match=...)) to
distinguish sub-causes within one error type. Every raise site now carries
a documented, stable code attribute; the registry lives in the errors.py
class docstrings. Codes are stable API; message text is explicitly not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-07-17 04:01:04 +02:00
commit 9d296ca686
6 changed files with 468 additions and 43 deletions

View file

@ -42,7 +42,7 @@ def safe_resolve(root: Path, relative: str) -> Path:
# Different drives / mixed absolute-relative -> not within.
within = False
if not within:
raise SourceError(f"path escapes its root directory: {relative!r}")
raise SourceError(f"path escapes its root directory: {relative!r}", code="path_escape")
return Path(candidate)
@ -56,25 +56,32 @@ def read_csv(root: str | Path, query: str, *, max_rows: int) -> tuple[list[str],
"""
root_path = Path(root)
if not root_path.is_dir():
raise SourceError(f"file-source root is not a directory: {root_path}")
raise SourceError(
f"file-source root is not a directory: {root_path}", code="source_root_missing"
)
resolved = safe_resolve(root_path, query)
if not resolved.is_file():
raise SourceError(f"extraction query does not resolve to a file: {query!r}")
raise SourceError(
f"extraction query does not resolve to a file: {query!r}",
code="source_file_missing",
)
with resolved.open(encoding="utf-8-sig", newline="") as handle:
reader = csv.reader(handle)
header = next(reader, None)
if header is None:
raise SourceError(f"CSV has no header row: {query!r}")
raise SourceError(f"CSV has no header row: {query!r}", code="csv_no_header")
rows: list[list[str]] = []
for row in reader:
if len(rows) >= max_rows:
raise SourceError(
f"extraction {query!r} exceeds max_rows={max_rows} "
"(error, never silent truncation — spec §8)"
"(error, never silent truncation — spec §8)",
code="max_rows_exceeded",
)
if len(row) != len(header):
raise SourceError(
f"ragged CSV row in {query!r}: expected {len(header)} cells, got {len(row)}"
f"ragged CSV row in {query!r}: expected {len(header)} cells, got {len(row)}",
code="csv_ragged_row",
)
rows.append(row)
return header, rows
@ -98,12 +105,14 @@ def read_sql(
if not dsn:
raise SourceError(
f"sql source connection_ref {connection_ref!r} is not set in the environment "
"(paths/credentials resolve at run time, never from the manifest — §4)"
"(paths/credentials resolve at run time, never from the manifest — §4)",
code="connection_ref_unset",
)
db_file = Path(dsn)
if not db_file.is_file():
raise SourceError(
f"sql connection_ref {connection_ref!r} points at a missing database file: {db_file}"
f"sql connection_ref {connection_ref!r} points at a missing database file: {db_file}",
code="database_missing",
)
# quote keeps '/' but encodes spaces/'?'/'#' so an odd path can't corrupt
# the URI; file:{abs path}?mode=ro is sqlite's documented read-only open.
@ -112,18 +121,23 @@ def read_sql(
with closing(sqlite3.connect(uri, uri=True)) as conn:
cursor = conn.execute(query)
if cursor.description is None: # a SELECT always has columns; defensive
raise SourceError(f"sql extraction returned no columns: {query!r}")
raise SourceError(
f"sql extraction returned no columns: {query!r}", code="sql_no_columns"
)
header = [column[0] for column in cursor.description]
rows: list[list[str]] = []
for row in cursor:
if len(rows) >= max_rows:
raise SourceError(
f"extraction {query!r} exceeds max_rows={max_rows} "
"(error, never silent truncation — spec §8)"
"(error, never silent truncation — spec §8)",
code="max_rows_exceeded",
)
rows.append([sql_value_to_text(value) for value in row])
except (sqlite3.Error, sqlite3.Warning) as exc:
raise SourceError(f"sql extraction failed for query {query!r}: {exc}") from exc
raise SourceError(
f"sql extraction failed for query {query!r}: {exc}", code="sql_failed"
) from exc
return header, rows
@ -142,7 +156,7 @@ def urllib_get(url: str, credential: str | None) -> str:
raw: bytes = response.read()
return raw.decode("utf-8")
except (URLError, OSError, UnicodeDecodeError) as exc:
raise SourceError(f"http GET failed for {url!r}: {exc}") from exc
raise SourceError(f"http GET failed for {url!r}: {exc}", code="http_transport") from exc
def read_http(
@ -174,7 +188,8 @@ def read_http(
if not credential:
raise SourceError(
f"http source credential_ref {credential_ref!r} is not set in the environment "
"(credentials resolve at run time, never from the manifest — §4)"
"(credentials resolve at run time, never from the manifest — §4)",
code="credential_ref_unset",
)
url = base_url.rstrip("/") + "/" + query.lstrip("/")
normalized = get(url, credential).replace("\r\n", "\n").replace("\r", "\n")
@ -183,7 +198,8 @@ def read_http(
raise SourceError(
f"http extraction {query!r} contains a code-fence marker line (```) — "
"cannot be safely embedded in a fenced code block; fail-fast, never "
"silent corruption"
"silent corruption",
code="fence_marker_in_body",
)
newline_count = normalized.count("\n")
line_count = (
@ -192,6 +208,7 @@ def read_http(
if line_count > max_rows:
raise SourceError(
f"http extraction {query!r} exceeds max_rows={max_rows} "
"(error, never silent truncation — spec §8)"
"(error, never silent truncation — spec §8)",
code="max_rows_exceeded",
)
return normalized