Ingest-spec §4 (ratified D1, commons bfa5a9b) requires a title to reject `[` and `]` fail-fast at manifest load. The title is rendered verbatim into the index link label and frontmatter (§5/§6), where a bracket would break index-link and navigation parsing (method-spec §3 Step 1). The rule was specced but unenforced: validation only checked single-line. Satisfies the commons "Title link-safety" load-bearing §11 seam. Same `manifest_schema` error code as the sibling single-line/max_rows rules; parametrized cases added to test_manifest and test_error_codes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016oMpAhQcJZVGBW182stSPn
349 lines
11 KiB
Python
349 lines
11 KiB
Python
"""Manifest parsing and fail-fast validation (ingest-spec §3, §4).
|
|
|
|
The manifest MUST be schema-validated fail-fast BEFORE any source call.
|
|
The verdict layer is reserved: no `okf_type: verdict` (case-insensitive),
|
|
and generated filenames stay disjoint from `index.md` / `promoted-verdict-*`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from llm_ingestion_okf.errors import IngestError, ManifestError
|
|
from llm_ingestion_okf.manifest import (
|
|
Extraction,
|
|
FileSource,
|
|
HttpSource,
|
|
Manifest,
|
|
SqlSource,
|
|
generated_filename,
|
|
load_manifest,
|
|
)
|
|
|
|
|
|
def valid_file_manifest() -> dict[str, Any]:
|
|
return {
|
|
"manifest_version": 1,
|
|
"source": {"type": "file", "id": "catalogue-1", "root": "data"},
|
|
"bundle_summary": "A test bundle.",
|
|
"extractions": [
|
|
{
|
|
"id": "orders",
|
|
"title": "Orders",
|
|
"query": "orders.csv",
|
|
"okf_type": "dataset",
|
|
"max_rows": 100,
|
|
}
|
|
],
|
|
}
|
|
|
|
|
|
def valid_sql_manifest() -> dict[str, Any]:
|
|
manifest = valid_file_manifest()
|
|
manifest["source"] = {"type": "sql", "id": "db-1", "connection_ref": "TEST_DB"}
|
|
manifest["extractions"][0]["query"] = "SELECT a, b FROM t ORDER BY a"
|
|
return manifest
|
|
|
|
|
|
def valid_http_manifest() -> dict[str, Any]:
|
|
manifest = valid_file_manifest()
|
|
manifest["source"] = {"type": "http", "id": "api-1", "base_url": "https://example.test"}
|
|
manifest["extractions"][0]["query"] = "/orders"
|
|
return manifest
|
|
|
|
|
|
def write_manifest(tmp_path: Path, data: dict[str, Any]) -> Path:
|
|
path = tmp_path / "manifest.json"
|
|
path.write_text(json.dumps(data), encoding="utf-8")
|
|
return path
|
|
|
|
|
|
def load(tmp_path: Path, data: dict[str, Any]) -> Manifest:
|
|
return load_manifest(write_manifest(tmp_path, data))
|
|
|
|
|
|
# --- error hierarchy ---
|
|
|
|
|
|
def test_manifest_error_is_ingest_error() -> None:
|
|
assert issubclass(ManifestError, IngestError)
|
|
assert issubclass(IngestError, Exception)
|
|
|
|
|
|
# --- valid manifests parse into typed objects ---
|
|
|
|
|
|
def test_valid_file_manifest_parses(tmp_path: Path) -> None:
|
|
manifest = load(tmp_path, valid_file_manifest())
|
|
assert manifest.manifest_version == 1
|
|
assert isinstance(manifest.source, FileSource)
|
|
assert manifest.source.id == "catalogue-1"
|
|
assert manifest.source.root == "data"
|
|
assert manifest.bundle_summary == "A test bundle."
|
|
assert manifest.extractions == (
|
|
Extraction(
|
|
id="orders", title="Orders", query="orders.csv", okf_type="dataset", max_rows=100
|
|
),
|
|
)
|
|
|
|
|
|
def test_valid_sql_manifest_parses(tmp_path: Path) -> None:
|
|
manifest = load(tmp_path, valid_sql_manifest())
|
|
assert isinstance(manifest.source, SqlSource)
|
|
assert manifest.source.connection_ref == "TEST_DB"
|
|
|
|
|
|
def test_valid_http_manifest_parses(tmp_path: Path) -> None:
|
|
manifest = load(tmp_path, valid_http_manifest())
|
|
assert isinstance(manifest.source, HttpSource)
|
|
assert manifest.source.base_url == "https://example.test"
|
|
assert manifest.source.credential_ref is None
|
|
|
|
|
|
def test_http_manifest_with_credential_ref(tmp_path: Path) -> None:
|
|
data = valid_http_manifest()
|
|
data["source"]["credential_ref"] = "API_TOKEN"
|
|
manifest = load(tmp_path, data)
|
|
assert isinstance(manifest.source, HttpSource)
|
|
assert manifest.source.credential_ref == "API_TOKEN"
|
|
|
|
|
|
# --- top-level schema ---
|
|
|
|
|
|
@pytest.mark.parametrize("missing", ["manifest_version", "source", "bundle_summary", "extractions"])
|
|
def test_missing_top_level_field_rejected(tmp_path: Path, missing: str) -> None:
|
|
data = valid_file_manifest()
|
|
del data[missing]
|
|
with pytest.raises(ManifestError):
|
|
load(tmp_path, data)
|
|
|
|
|
|
@pytest.mark.parametrize("version", [0, 2, "1", 1.0, True, None])
|
|
def test_unsupported_manifest_version_rejected(tmp_path: Path, version: Any) -> None:
|
|
data = valid_file_manifest()
|
|
data["manifest_version"] = version
|
|
with pytest.raises(ManifestError):
|
|
load(tmp_path, data)
|
|
|
|
|
|
def test_unknown_top_level_field_rejected(tmp_path: Path) -> None:
|
|
data = valid_file_manifest()
|
|
data["extra"] = "x"
|
|
with pytest.raises(ManifestError):
|
|
load(tmp_path, data)
|
|
|
|
|
|
def test_non_object_manifest_rejected(tmp_path: Path) -> None:
|
|
path = tmp_path / "manifest.json"
|
|
path.write_text(json.dumps([1, 2]), encoding="utf-8")
|
|
with pytest.raises(ManifestError):
|
|
load_manifest(path)
|
|
|
|
|
|
def test_invalid_json_rejected(tmp_path: Path) -> None:
|
|
path = tmp_path / "manifest.json"
|
|
path.write_text("{not json", encoding="utf-8")
|
|
with pytest.raises(ManifestError):
|
|
load_manifest(path)
|
|
|
|
|
|
@pytest.mark.parametrize("summary", [None, 7, ["x"]])
|
|
def test_non_string_bundle_summary_rejected(tmp_path: Path, summary: Any) -> None:
|
|
data = valid_file_manifest()
|
|
data["bundle_summary"] = summary
|
|
with pytest.raises(ManifestError):
|
|
load(tmp_path, data)
|
|
|
|
|
|
# --- source: polymorphic on type ---
|
|
|
|
|
|
@pytest.mark.parametrize("bad_type", ["ftp", "", None, 3])
|
|
def test_unknown_source_type_rejected(tmp_path: Path, bad_type: Any) -> None:
|
|
data = valid_file_manifest()
|
|
data["source"]["type"] = bad_type
|
|
with pytest.raises(ManifestError):
|
|
load(tmp_path, data)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("factory", "required"),
|
|
[
|
|
(valid_file_manifest, "root"),
|
|
(valid_sql_manifest, "connection_ref"),
|
|
(valid_http_manifest, "base_url"),
|
|
],
|
|
)
|
|
def test_missing_source_field_rejected(tmp_path: Path, factory: Any, required: str) -> None:
|
|
data = factory()
|
|
del data["source"][required]
|
|
with pytest.raises(ManifestError):
|
|
load(tmp_path, data)
|
|
|
|
|
|
def test_unknown_source_field_rejected(tmp_path: Path) -> None:
|
|
data = valid_file_manifest()
|
|
data["source"]["connection_ref"] = "X" # sql field on a file source
|
|
with pytest.raises(ManifestError):
|
|
load(tmp_path, data)
|
|
|
|
|
|
@pytest.mark.parametrize("bad_id", ["", "-x", "Xy", "a_b", "a b", "æ", 7, None])
|
|
def test_bad_source_id_rejected(tmp_path: Path, bad_id: Any) -> None:
|
|
data = valid_file_manifest()
|
|
data["source"]["id"] = bad_id
|
|
with pytest.raises(ManifestError):
|
|
load(tmp_path, data)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"url",
|
|
[
|
|
"https://user:pass@example.test", # embedded credentials (spec §4: MUST NOT)
|
|
"https://token@example.test",
|
|
],
|
|
)
|
|
def test_base_url_with_embedded_credentials_rejected(tmp_path: Path, url: str) -> None:
|
|
data = valid_http_manifest()
|
|
data["source"]["base_url"] = url
|
|
with pytest.raises(ManifestError):
|
|
load(tmp_path, data)
|
|
|
|
|
|
# --- extractions ---
|
|
|
|
|
|
def test_empty_extractions_rejected(tmp_path: Path) -> None:
|
|
data = valid_file_manifest()
|
|
data["extractions"] = []
|
|
with pytest.raises(ManifestError):
|
|
load(tmp_path, data)
|
|
|
|
|
|
@pytest.mark.parametrize("missing", ["id", "title", "query", "okf_type", "max_rows"])
|
|
def test_missing_extraction_field_rejected(tmp_path: Path, missing: str) -> None:
|
|
data = valid_file_manifest()
|
|
del data["extractions"][0][missing]
|
|
with pytest.raises(ManifestError):
|
|
load(tmp_path, data)
|
|
|
|
|
|
def test_unknown_extraction_field_rejected(tmp_path: Path) -> None:
|
|
data = valid_file_manifest()
|
|
data["extractions"][0]["max_row"] = 5 # typo for max_rows
|
|
with pytest.raises(ManifestError):
|
|
load(tmp_path, data)
|
|
|
|
|
|
@pytest.mark.parametrize("bad_id", ["", "-x", "Orders", "a_b", "a/b", "a.b", 7, None])
|
|
def test_bad_extraction_id_rejected(tmp_path: Path, bad_id: Any) -> None:
|
|
data = valid_file_manifest()
|
|
data["extractions"][0]["id"] = bad_id
|
|
with pytest.raises(ManifestError):
|
|
load(tmp_path, data)
|
|
|
|
|
|
def test_duplicate_extraction_ids_rejected(tmp_path: Path) -> None:
|
|
data = valid_file_manifest()
|
|
data["extractions"].append(dict(data["extractions"][0]))
|
|
with pytest.raises(ManifestError):
|
|
load(tmp_path, data)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"bad_title",
|
|
[
|
|
"two\nlines",
|
|
"carriage\rreturn",
|
|
"",
|
|
7,
|
|
None,
|
|
# `[`/`]` break index-link and navigation parsing (ingest spec §4/§6):
|
|
# rejected fail-fast at manifest load so downstream verbatim rendering is safe.
|
|
"opening [bracket",
|
|
"closing ]bracket",
|
|
"[wrapped]",
|
|
],
|
|
)
|
|
def test_bad_title_rejected(tmp_path: Path, bad_title: Any) -> None:
|
|
data = valid_file_manifest()
|
|
data["extractions"][0]["title"] = bad_title
|
|
with pytest.raises(ManifestError):
|
|
load(tmp_path, data)
|
|
|
|
|
|
@pytest.mark.parametrize("bad_rows", [0, -1, "100", 1.5, True, False, None])
|
|
def test_bad_max_rows_rejected(tmp_path: Path, bad_rows: Any) -> None:
|
|
data = valid_file_manifest()
|
|
data["extractions"][0]["max_rows"] = bad_rows
|
|
with pytest.raises(ManifestError):
|
|
load(tmp_path, data)
|
|
|
|
|
|
@pytest.mark.parametrize("bad_query", ["", 7, None])
|
|
def test_bad_query_rejected(tmp_path: Path, bad_query: Any) -> None:
|
|
data = valid_file_manifest()
|
|
data["extractions"][0]["query"] = bad_query
|
|
with pytest.raises(ManifestError):
|
|
load(tmp_path, data)
|
|
|
|
|
|
# --- verdict reservation (spec §3) — load-bearing seam ---
|
|
|
|
|
|
@pytest.mark.parametrize("verdict", ["verdict", "Verdict", "VERDICT", "vErDiCt"])
|
|
def test_verdict_okf_type_rejected(tmp_path: Path, verdict: str) -> None:
|
|
"""Load-bearing (spec §11): MUST fail if a verdict-typed mapping stops being rejected."""
|
|
data = valid_file_manifest()
|
|
data["extractions"][0]["okf_type"] = verdict
|
|
with pytest.raises(ManifestError):
|
|
load(tmp_path, data)
|
|
|
|
|
|
@pytest.mark.parametrize("bad_type", ["", 7, None])
|
|
def test_bad_okf_type_rejected(tmp_path: Path, bad_type: Any) -> None:
|
|
data = valid_file_manifest()
|
|
data["extractions"][0]["okf_type"] = bad_type
|
|
with pytest.raises(ManifestError):
|
|
load(tmp_path, data)
|
|
|
|
|
|
@pytest.mark.parametrize("extraction_id", ["index", "promoted-verdict-1", "orders"])
|
|
def test_generated_filenames_stay_outside_reserved_namespaces(extraction_id: str) -> None:
|
|
"""The ingest- prefix keeps generated names disjoint from index.md and
|
|
promoted-verdict-* (spec §5) — even for adversarial extraction ids."""
|
|
name = generated_filename(extraction_id)
|
|
assert name == f"ingest-{extraction_id}.md"
|
|
assert name != "index.md"
|
|
assert not name.startswith("promoted-verdict-")
|
|
|
|
|
|
# --- fail-fast: validation error BEFORE any source call (spec §4) ---
|
|
|
|
|
|
def test_validation_fails_before_any_source_access(tmp_path: Path) -> None:
|
|
"""Load-bearing (spec §11): a malformed manifest never starts a run.
|
|
|
|
The manifest points at a nonexistent source root AND carries a reserved
|
|
okf_type. Validation must raise ManifestError — not any I/O error — and
|
|
must not resolve or touch the source at all.
|
|
"""
|
|
data = valid_file_manifest()
|
|
data["source"]["root"] = str(tmp_path / "does-not-exist")
|
|
data["extractions"][0]["okf_type"] = "verdict"
|
|
with pytest.raises(ManifestError):
|
|
load(tmp_path, data)
|
|
|
|
|
|
def test_validation_does_not_require_source_to_exist(tmp_path: Path) -> None:
|
|
"""Manifest validation is schema-only: source existence is a connector
|
|
concern, checked at extraction time — never at validation time."""
|
|
data = valid_file_manifest()
|
|
data["source"]["root"] = str(tmp_path / "does-not-exist")
|
|
manifest = load(tmp_path, data)
|
|
assert isinstance(manifest.source, FileSource)
|