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
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue