Chosen: a stdlib BMP reader, because `read_image` is on the CORE path and an asset's name is its content digest. Measured first, as the order requires: Pillow 12.3.0 IS in this tree (transitively under `pdfplumber`) and it DOES decode RLE8 correctly -- a hand-written stdlib decoder and Pillow agree on 19 of 19 of R761's real files, RGB per pixel. So the choice does not rest on capability. It rests on two properties of this package: `.html` and `.xml` carry images with no `[extract]` extra installed, so a Pillow converter either makes a core path depend on an optional binary wheel or buys the second runtime dependency; and encoding through an installed library would make a bundle's identity move with that library's version, which is the property 0.10.0 felled page rasterisation over and `encode_png`'s docstring already defends. Pillow keeps the job it is good for: the INDEPENDENT decoder in the tests, on neither side of the conversion. The defect, measured over the frozen R761 delivery's `assets/`, denominator 50: 29 JPEG, 2 PNG and 19 RLE8 BMP. The 19 are byte-correct files nothing reads, so 19 figures were present and invisible while `images: N` reported that they had arrived. - `VIEWABLE_MEDIA_TYPES` is tested against every asset's SNIFFED type, so it is a property and not a list of formats we met. WebP is on it and `sniff` does not recognise one; the limit is stated, not implied. - `bmp_to_png`: 8-bit uncompressed, 8-bit RLE8, 24-bit uncompressed. All five RLE8 opcodes. 19 of 19 real files convert with RGB identical to Pillow's decoding of the source, 2 366 365 pixels compared. - `asset_not_viewable` and `asset_bmp_unsupported`, both published, both leaving the concept's "not carried" line. - Traceability on the pointer's second line, where the rest of the asset metadata already lives: original media type, original sha256 in full, new sha256 in full. A converted asset is ONE asset. - The ceiling is paid on the DECLARATION before a row is allocated, and an RLE run is one clipped slice -- painting pixel by pixel leaves the memory bounded and the CPU unbounded. Two repairs the change forced, each measured rather than assumed: - `tests/test_assets.py`'s "dimensions absent is absent" used a TIFF, which is now refused before `read_image` returns. The property still has a reachable case -- a JPEG whose frame header never arrives -- and uses it. - `asset_holds` in the accounting gate proved a carry by hashing the SOURCE file, which a converted image's bundle cannot satisfy. It now also reads the two digests the bundle states and HASHES THE ASSET ITSELF, so a bundle claiming a conversion it did not perform still fails. `tools/okf_asset_census.py` is the committed instrument for the known-positive: one row per image, from two pinned trees. It was caught by the rule it serves -- its first version handed `_pdf_images` the wrong page object and reported 0 images over 67 PDFs with exit 0. The attribute is asserted now and a known-positive runs before the sweep. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
293 lines
16 KiB
Python
293 lines
16 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 ExtractionError(IngestError):
|
|
"""A dropped file could not be converted to text (Door B, Phase 2).
|
|
|
|
File-type -> text extraction is text-only plumbing; a corrupt file, an
|
|
unknown type, or a binary type without its optional parser is a typed
|
|
per-file failure — never a silent skip, never a leaked stdlib error, and
|
|
never a bundled parser in core.
|
|
|
|
Codes:
|
|
- `extractor_unknown` — no extractor is registered for the file extension
|
|
- `extractor_extra_missing` — a `[extract]`-gated binary type (pdf/docx/
|
|
xlsx/pptx/odt/rtf) was given but the optional extra is not installed
|
|
- `extractor_decode_error` — a text-type file's bytes are not valid UTF-8
|
|
- `extractor_empty_csv` — a CSV has no header row
|
|
- `extractor_empty_pdf` — a PDF yielded no text on any page (a scanned or
|
|
image-only document); refused rather than persisted as an empty concept,
|
|
which would be the silent skip this registry exists to prevent
|
|
- `extractor_pdf_error` — the PDF parser failed on the file's bytes; the
|
|
third-party exception is wrapped, never leaked
|
|
- `extractor_binary_missing` — the converter binary is absent; distinct
|
|
from the extra not being installed, because the wheel can be present
|
|
while the binary it should carry is not
|
|
- `extractor_binary_version` — the converter binary is present but is not
|
|
the pinned version; refused rather than used, because extraction is
|
|
deterministic only within one converter version and a byte-pinned
|
|
fixture cannot tell "different version" from "defect"
|
|
- `extractor_convert_error` — the converter failed on this file's bytes;
|
|
the third-party failure is wrapped, never leaked
|
|
- `extractor_empty_conversion` — the converter returned no text; refused
|
|
rather than persisted as an empty concept, for the same reason as
|
|
`extractor_empty_pdf`
|
|
- `extractor_ocr_group_missing` — a PDF page was to be read with OCR but
|
|
the optional `ocr` group is not installed. DISTINCT from
|
|
`extractor_extra_missing`: the `[extract]` extra can be fully installed
|
|
and the document parsed, with only the OCR engine absent, and one code
|
|
for both would send an operator to reinstall what they already have
|
|
|
|
ASSET codes (0.10.0). None of these fails a document: an image a reader
|
|
could not carry is a ROW in the run log and a line in the concept saying
|
|
what was there, because one unreadable picture must not cost the three
|
|
thousand concepts of text around it.
|
|
|
|
- `asset_type_unknown` — the bytes behind a pointer are not an image format
|
|
this package recognises. Sniffed from the bytes, never from the claimed
|
|
extension: a `.jpg` that is really a PNG would otherwise be written under
|
|
a name whose extension lies
|
|
- `asset_unresolved` — the file a document points at was not found beside
|
|
it, or an inline data URI could not be decoded. Containment is the
|
|
document's own directory, so a reference above it lands here rather than
|
|
being followed
|
|
- `asset_remote` — the source is off this machine. Extraction opens no
|
|
socket: network access is an explicit per-run opt-in and extraction is not
|
|
on that path, so a remote image is carried as a pointer and never as bytes
|
|
- `asset_pdf_unsupported` — a PDF image whose samples this encoder does not
|
|
express: a stencil mask, a `Decode` array, a colour space with no exact
|
|
PNG form, a soft mask that cannot be carried, or anything but 8-bit
|
|
samples. Refused rather than approximated, because a picture that is
|
|
plausibly the wrong colour is wrong in a way no consumer can detect
|
|
- `asset_samples_invalid` — the sample buffer does not fit the dimensions
|
|
the image dictionary declares. Refused rather than padded: a short buffer
|
|
means the dictionary was read wrong
|
|
- `asset_too_large` — the picture is over this package's bound: because it
|
|
DECLARES a size beyond `MAX_IMAGE_PIXELS`, because the file itself is
|
|
that large, because the stream behind it DECOMPRESSES to more than
|
|
`MAX_IMAGE_BYTES`, or because one link of its filter chain would COST
|
|
more than `MAX_FILTER_DECODE_BYTES` to decode. The four are one code
|
|
because they are one decision — this run will not hold that picture —
|
|
and because a consumer counting refusals wants the picture, not the
|
|
mechanism. Each bound is read off the corpora and sits an order of
|
|
magnitude above anything measured
|
|
- `asset_size_invalid` — the container declares a size that is not a size:
|
|
a zero or negative `/Width` or `/Height`. DISTINCT from
|
|
`asset_too_large`, because the two say different things about the
|
|
document — one is a legitimate publisher shipping a picture bigger than
|
|
this package carries, the other is a dictionary written wrong or written
|
|
to be read wrong — and counting them together would make a corpus
|
|
statistic about the first untrue. Refused before the stream is read: a
|
|
negative dimension multiplies to a negative pixel count, under which
|
|
every bound reads as satisfied
|
|
- `asset_not_viewable` — the bytes are a real image in a format no model
|
|
can be SHOWN (TIFF, JPEG 2000), and this package has no lossless
|
|
conversion for it. DISTINCT from `asset_type_unknown`, which says the
|
|
bytes are not an image at all: this one says they are, and carrying them
|
|
would put a file in the bundle that the `images: N` count reports as
|
|
arrived and nothing downstream can read. Measured 2026-09-19 on the
|
|
frozen R761 delivery: 19 of its 50 assets were carried in exactly that
|
|
condition, as RLE8 BMP
|
|
- `asset_bmp_unsupported` — a BMP variant this reader does not express
|
|
(RLE4, BITFIELDS, 16- or 32-bit samples, a 12-byte BITMAPCOREHEADER, a
|
|
palette over 256 entries). DISTINCT from `asset_not_viewable`, which
|
|
says there is no conversion route for the format at all: this one says
|
|
there is one and this file is outside it, which is a different fact
|
|
about the document and a different thing to go and fix
|
|
- `asset_pdf_unbounded` — the image is reached through a PDF stream filter
|
|
this package has no measured cost ratio for (`LZWDecode`,
|
|
`RunLengthDecode`, `CCITTFaxDecode`, `/Crypt`, anything unknown), or
|
|
through an encrypted stream it cannot decipher. DISTINCT from
|
|
`asset_too_large`, which says a measurement was taken or predicted and
|
|
came out over the bound: this one says neither was possible, so the
|
|
picture is refused UNREAD rather than decoded to find out what it costs.
|
|
Measured 2026-09-18: bounding only the first link of a filter chain let
|
|
1 636 bytes of PDF cost 886 554 624 bytes of peak RSS, and bounding
|
|
every link's OUTPUT still let 33 475 bytes cost 3 261 599 744 through a
|
|
filter whose decoder holds a hundred bytes per byte of input
|
|
"""
|
|
|
|
|
|
class ExtractionWarning(UserWarning):
|
|
"""Extraction succeeded, but the output is lossy in a way worth stating.
|
|
|
|
Text extraction recovers text. Anything a PDF *draws* — figures, diagrams,
|
|
images — has no text to recover, so a bundle built from drawn documents is
|
|
incomplete by construction. That is categorically true rather than
|
|
document-specific, so it is warned about rather than detected: deciding
|
|
"is there a figure on this page" is a layout heuristic this library does
|
|
not own. A named class so a consumer can filter it deliberately.
|
|
"""
|
|
|
|
|
|
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
|
|
- `asset_collision` — two different images reduce to one asset name in one
|
|
run, or a name in `assets/` is occupied by different bytes (Doors B and
|
|
C, 0.10.0). An asset name carries the digest of its own contents, so this
|
|
is a `sha256[:12]` collision; refused rather than resolved, because
|
|
resolving it silently means one of two pictures is lost and every pointer
|
|
to it shows the other
|
|
- `source_reference_unquotable` — a manifest source's id or locator
|
|
contains a character that would restructure the `sources` flow mapping
|
|
(Door A, v0.2 profiles); refused rather than emitted, because the
|
|
resulting document parses cleanly into a record no one wrote
|
|
- `sources_empty` — a `sources` list with no entries; `sources: []` reads
|
|
as a measured absence when it is the absence of a measurement
|
|
- `inbox_slug_empty` — a dropped file's name reduces to an empty slug
|
|
under the id grammar (Door B; never an invented fallback name)
|
|
- `inbox_slug_too_long` — the generated inbox filename would exceed the
|
|
255-byte filesystem limit (Door B; never a truncated name, which would
|
|
be lossy and could collide with another long name sharing its prefix)
|
|
- `inbox_slug_collision` — two files dropped in the same run reduce to one
|
|
generated filename (Door B); both are refused rather than letting
|
|
iteration order decide which one survives
|
|
- `inbox_title_invalid` — an inbox title is multi-line or contains `[`/`]`,
|
|
either of which would break frontmatter or an index link
|
|
- `inbox_source_file_invalid` — an inbox `source_file` is multi-line and
|
|
would inject frontmatter lines
|
|
- `unknown_renderer` — a profile names a per-suffix renderer that is not
|
|
registered; refused rather than falling back to identity, which would
|
|
produce a bundle that looks rendered and is not
|
|
- `okf_type_reserved` — an inbox concept claims the reserved 'verdict'
|
|
layer (the same reservation ManifestError enforces at Door A)
|
|
- `import_path_empty` — an external concept path reduces to an empty slug
|
|
under the id grammar (Door C; never an invented fallback name)
|
|
- `import_path_too_long` — the generated import filename would exceed the
|
|
255-byte filesystem limit (Door C; never a truncated name)
|
|
- `import_slug_collision` — two concepts in one external bundle reduce to
|
|
one generated filename (Door C); both are refused rather than letting
|
|
iteration order decide which one survives
|
|
- `import_label_invalid` — an external concept path contains `[`/`]`, which
|
|
would break its index link (the guard's path gate permits them)
|
|
- `import_provenance_invalid` — an `origin`/`channel` outside the guard's
|
|
pinned vocabulary (Door C); refused rather than carried, because the
|
|
guard derives trust from `origin` by enum identity and an unrecognised
|
|
value would be silently downgraded
|
|
"""
|
|
|
|
|
|
class SegmentationError(IngestError):
|
|
"""A segmentation plan is unusable, or does not apply (Door B, 1-to-N).
|
|
|
|
A concept is "a single unit of knowledge within a bundle" (OKF v0.2 §2),
|
|
not a file someone dropped, so splitting one document into several is a
|
|
judgement. A judgement cannot be made on a run path that promises zero
|
|
model calls, so it is made once, written down as data, adjudicated by a
|
|
human, and replayed deterministically. Every failure here is that replay
|
|
refusing to guess: a plan that no longer matches its extraction is never
|
|
silently re-derived, because the offsets it carries would then point into
|
|
text no one adjudicated.
|
|
|
|
Codes:
|
|
- `segmentation_plan_invalid` — the plan's shape is wrong: a missing or
|
|
wrongly-typed field, an empty entry list, or a `parent_id` naming no
|
|
entry in the same plan
|
|
- `segmentation_path_invalid` — an entry's path is not a bundle-relative
|
|
`/`-separated path (absolute, empty, or containing `.`/`..`), a
|
|
component reduces to nothing under the id grammar, or two entries claim
|
|
one path after normalisation
|
|
- `segmentation_span_invalid` — a span is not a half-open pair of
|
|
non-negative offsets with `start < end`, or it reaches past the end of
|
|
the canonical extracted text
|
|
- `segmentation_duplicate_id` — two entries share a `segment_id`
|
|
- `segmentation_extractor_mismatch` — the plan was adjudicated against a
|
|
different extraction. Source bytes cannot see an extractor swap or a
|
|
version bump, so the hash alone would still match while every stored
|
|
offset had silently moved
|
|
- `segmentation_unsupported_profile` — a plan was passed to a profile that
|
|
does not declare the segmentation capability
|
|
- `segmentation_plan_unmatched` — the plan is well-formed but its
|
|
`source_sha256` matches no dropped file, so nothing would be segmented
|
|
and the run would report an ordinary success over a flat bundle. A
|
|
mistyped hash is the likely cause and it is unreadable from the result;
|
|
refusing is the only way the operator learns that the judgement they
|
|
adjudicated was never replayed
|
|
"""
|
|
|
|
|
|
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
|
|
"""
|