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

@ -77,7 +77,9 @@ def load_manifest(path: Path) -> Manifest:
try:
raw = path.read_bytes()
except OSError as exc:
raise ManifestError(f"cannot read manifest {path}: {exc}") from exc
raise ManifestError(
f"cannot read manifest {path}: {exc}", code="manifest_unreadable"
) from exc
return load_manifest_bytes(raw)
@ -91,7 +93,9 @@ def load_manifest_bytes(raw: bytes) -> Manifest:
try:
data: object = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, ValueError) as exc:
raise ManifestError(f"manifest is not valid UTF-8 JSON: {exc}") from exc
raise ManifestError(
f"manifest is not valid UTF-8 JSON: {exc}", code="manifest_invalid_json"
) from exc
return _validate_manifest(data)
@ -101,7 +105,10 @@ def _validate_manifest(data: object) -> Manifest:
version = obj["manifest_version"]
if not _is_int(version) or version != 1:
raise ManifestError(f"manifest_version must be the integer 1, got {version!r}")
raise ManifestError(
f"manifest_version must be the integer 1, got {version!r}",
code="manifest_version_unsupported",
)
source = _validate_source(obj["source"])
bundle_summary = _require_str(obj["bundle_summary"], "bundle_summary", allow_empty=True)
@ -139,25 +146,33 @@ def _validate_source(data: object) -> Source:
base_url=_validate_base_url(obj["base_url"]),
credential_ref=credential_ref,
)
raise ManifestError(f"source.type must be one of 'file', 'sql', 'http', got {source_type!r}")
raise ManifestError(
f"source.type must be one of 'file', 'sql', 'http', got {source_type!r}",
code="source_type_unknown",
)
def _validate_base_url(value: object) -> str:
base_url = _require_str(value, "source.base_url")
# Credentials never live in the manifest (spec §4): no userinfo in the URL.
if urllib.parse.urlsplit(base_url).username is not None:
raise ManifestError("source.base_url must not embed credentials; use credential_ref")
raise ManifestError(
"source.base_url must not embed credentials; use credential_ref",
code="credential_embedded",
)
return base_url
def _validate_extractions(data: object) -> tuple[Extraction, ...]:
if not isinstance(data, list) or not data:
raise ManifestError("extractions must be a non-empty list")
raise ManifestError("extractions must be a non-empty list", code="manifest_schema")
extractions = tuple(_validate_extraction(entry, index) for index, entry in enumerate(data))
seen: set[str] = set()
for extraction in extractions:
if extraction.id in seen:
raise ManifestError(f"duplicate extraction id {extraction.id!r}")
raise ManifestError(
f"duplicate extraction id {extraction.id!r}", code="extraction_id_duplicate"
)
seen.add(extraction.id)
return extractions
@ -169,17 +184,22 @@ def _validate_extraction(data: object, index: int) -> Extraction:
title = _require_str(obj["title"], f"{label}.title")
if "\n" in title or "\r" in title:
raise ManifestError(f"{label}.title must be single-line")
raise ManifestError(f"{label}.title must be single-line", code="manifest_schema")
okf_type = _require_str(obj["okf_type"], f"{label}.okf_type")
# The verdict layer is RESERVED (spec §3): the promotion gate is the only
# path into it — enforced here, fail-fast, before any source call.
if okf_type.lower() == _RESERVED_OKF_TYPE:
raise ManifestError(f"{label}.okf_type must not be 'verdict' (reserved layer)")
raise ManifestError(
f"{label}.okf_type must not be 'verdict' (reserved layer)", code="okf_type_reserved"
)
max_rows = obj["max_rows"]
if not _is_int(max_rows) or max_rows < 1:
raise ManifestError(f"{label}.max_rows must be a positive integer, got {max_rows!r}")
raise ManifestError(
f"{label}.max_rows must be a positive integer, got {max_rows!r}",
code="manifest_schema",
)
return Extraction(
id=_validate_id(obj["id"], f"{label}.id"),
@ -193,13 +213,17 @@ def _validate_extraction(data: object, index: int) -> Extraction:
def _validate_id(value: object, label: str) -> str:
identifier = _require_str(value, label)
if not _ID_PATTERN.match(identifier):
raise ManifestError(f"{label} must match [a-z0-9][a-z0-9-]*, got {identifier!r}")
raise ManifestError(
f"{label} must match [a-z0-9][a-z0-9-]*, got {identifier!r}", code="manifest_schema"
)
return identifier
def _require_object(data: object, label: str) -> dict[str, Any]:
if not isinstance(data, dict):
raise ManifestError(f"{label} must be a JSON object, got {type(data).__name__}")
raise ManifestError(
f"{label} must be a JSON object, got {type(data).__name__}", code="manifest_schema"
)
return data
@ -208,17 +232,24 @@ def _require_keys(
) -> None:
missing = required - obj.keys()
if missing:
raise ManifestError(f"{label} is missing required field(s): {', '.join(sorted(missing))}")
raise ManifestError(
f"{label} is missing required field(s): {', '.join(sorted(missing))}",
code="manifest_schema",
)
unknown = obj.keys() - required - (optional or set())
if unknown:
raise ManifestError(f"{label} has unknown field(s): {', '.join(sorted(unknown))}")
raise ManifestError(
f"{label} has unknown field(s): {', '.join(sorted(unknown))}", code="manifest_schema"
)
def _require_str(value: object, label: str, *, allow_empty: bool = False) -> str:
if not isinstance(value, str):
raise ManifestError(f"{label} must be a string, got {type(value).__name__}")
raise ManifestError(
f"{label} must be a string, got {type(value).__name__}", code="manifest_schema"
)
if not value and not allow_empty:
raise ManifestError(f"{label} must be a non-empty string")
raise ManifestError(f"{label} must be a non-empty string", code="manifest_schema")
return value