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:
parent
9e87f656fc
commit
9d296ca686
6 changed files with 468 additions and 43 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue