llm-ingestion-okf/src/llm_ingestion_okf/errors.py
Kjell Tore Guttormsen 64e3187639 fix(errors): type the NUL-byte path failure as SourceError
safe_resolve let an untyped ValueError escape when a target carried an
embedded NUL: os.path.realpath rejects the string before the boundary
check runs. That broke SourceError's documented contract ("always typed,
never a leaked OSError") and the v0.3.0 promise of stable machine-readable
codes on every failure.

A target that cannot be resolved at all now fails closed under the
existing `path_escape` code, with its own message. No new code is added —
`path_escape` already means "the boundary refused this target"; its
docstring is widened to say so. Consumers asserting on the code are
unaffected.

Found by consumer review: portfolio-optimiser's navigate_bundle leaks the
same ValueError from the same class of input, which makes an entire bundle
unreadable instead of skipping one link. That defect is theirs to fix; this
commit closes the library-side half.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdVgowYC4LARgvNdNMiuvz
2026-07-20 10:30:42 +02:00

93 lines
3.7 KiB
Python

"""Typed error hierarchy rooted in IngestError.
STABILITY CONTRACT: `IngestError.code` is the machine-readable API for
distinguishing sub-causes — consumers assert on it, never on message
wording. Codes listed in the class docstrings below are stable across
releases; message text is NOT stable and may improve freely.
"""
from __future__ import annotations
class IngestError(Exception):
"""Base class for every error raised by this library.
Carries a stable, machine-readable `code` naming the sub-cause (see the
subclass docstrings for the registry). Errors constructed without an
explicit code carry `"unspecified"`.
"""
def __init__(self, message: str, *, code: str = "unspecified") -> None:
super().__init__(message)
self.code = code
class ManifestError(IngestError):
"""The manifest failed fail-fast schema validation (ingest-spec §4).
Codes:
- `manifest_unreadable` — the manifest file cannot be read
- `manifest_invalid_json` — the bytes are not valid UTF-8 JSON
- `manifest_version_unsupported` — manifest_version is not the integer 1
- `manifest_schema` — a generic shape violation (wrong type, missing or
unknown field, empty string, bad identifier, multi-line title,
non-positive max_rows, empty extractions list)
- `source_type_unknown` — source.type is not 'file', 'sql', or 'http'
- `credential_embedded` — source.base_url embeds userinfo credentials
- `extraction_id_duplicate` — two extractions share an id
- `okf_type_reserved` — an extraction claims the reserved 'verdict' layer
"""
class RenderError(IngestError):
"""A value cannot be rendered under the §5 body rules (never silent coercion).
Codes:
- `unsupported_cell_type` — a SQL cell is not integer/float/text/NULL
"""
class SourceError(IngestError):
"""An extraction failed against its source (ingest-spec §4, §8).
Covers fail-closed path-boundary violations, missing/malformed source
content, and max_rows cap violations — always typed, never a leaked
OSError and never silent truncation.
Codes:
- `path_escape` — a path resolves outside its root directory, or cannot
be resolved at all (e.g. an embedded NUL byte)
- `source_root_missing` — the file-source root is not a directory
- `source_file_missing` — the query does not resolve to a file
- `csv_no_header` — the CSV has no header row
- `csv_ragged_row` — a CSV row's cell count differs from the header's
- `max_rows_exceeded` — an extraction exceeds max_rows (any source type)
- `connection_ref_unset` — the sql connection_ref env var is not set
- `database_missing` — the sql connection_ref points at a missing file
- `sql_no_columns` — the sql statement produced no result columns
- `sql_failed` — the sql statement failed at the database
- `credential_ref_unset` — the http credential_ref env var is not set
- `http_transport` — the http GET failed at transport or decode
- `fence_marker_in_body` — an http body line is a code-fence marker
"""
class MaterializationError(IngestError):
"""Materialization refused or failed (ingest-spec §5).
Codes:
- `ingested_at_invalid` — ingested_at is not ISO-8601 UTC with a Z suffix
- `collision_unstamped` — the §3 collision gate: a generated name is
occupied by a file without the ingest stamp
"""
class NetworkGateError(IngestError):
"""A network source was used without the per-run opt-in flag (spec §8).
Local-only default, no silent egress: the flag is a run argument — the
manifest cannot grant itself network access.
Codes:
- `network_opt_in_missing` — an http source without allow_network=True
"""