Door A (manifest -> connector -> deterministic materialization -> index) is no longer implemented here. src/portfolio_optimiser/ingest.py becomes a thin consumer seam over the shared library, git-pinned to v0.3.1 on the same Forgejo channel portfolio-optimiser-claude uses. Net -626/+385; ingest.py 599 -> 145 lines. shared/ingest-spec.md remains the normative spec: the library implements it, it does not replace it. Spec changes continue to go via commons. Acceptance criterion met and proven: all three golden bundles (file/sql/http) are byte-exact before and after, including the idempotence re-run. examples/ and shared/ carry ZERO modifications -- the fasit was not adjusted to fit. The rejection set was verified equivalent, not assumed: all 22 malformations the repo's pydantic models refused are refused by the library, with typed codes (okf_type_reserved, credential_embedded, extraction_id_duplicate, ...). Test rebinding (invariants preserved, vehicle changed): the library has zero runtime dependencies by design, so pydantic is unavailable to it. ManifestV1.model_validate(dict) -> load_manifest_bytes(bytes); ValidationError -> ManifestError; model_fields -> dataclasses.fields; PathSecurityError -> SourceError(path_escape); ValueError -> MaterializationError(ingested_at_invalid). Tests now also pin the refusal `code`, the library's documented stability contract -- a sharper assertion than "some validation error was raised". Two accepted behavioural deltas, recorded rather than silently dropped: - Title whitespace is stored verbatim instead of collapsed at validation, so the frontmatter title and the index label are no longer guaranteed identical for irregular whitespace. Both behaviours are spec-conformant (the spec is SILENT; the old one was a repo-local pinned decision). Queued as a commons-amendment candidate so both stacks pin the same answer. Goldens unaffected. - The section 8 audit log moves to logger llm_ingestion_okf.materialize. Nothing in the repo consumed the old channel. Also: the `type` discriminator is no longer a dataclass field, so the spec cross-check asserts it explicitly -- without that line the swap would have silently narrowed the test. New tests/test_ingest_library_seam.py pins the seam itself: the restated section 5 stamp formula against the stamp the library actually writes (the one place the adapter does not purely delegate, since v0.3.1 exposes no stamp helper), the local-only allow_network default, the list[Path] unwrapping, and a guard that the adapter never regrows local Door A machinery. All four verified RED when detached, as were both golden regressions under a byte-level render mutation. Door A is UNGATED: it calls no guard before writing to disk. Gating untrusted content remains the caller's responsibility (guard wiring still planned). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B4jNN186eVqfe1x5DnTU6r
229 lines
9.9 KiB
Python
229 lines
9.9 KiB
Python
"""I2 Step 1 tests — fail-fast ingest-manifest contract (spec §4, verdict reservation §3).
|
|
|
|
Every malformed manifest raises at validation, BEFORE any source access (the startup-contract
|
|
discipline of method spec §10 / ingest spec §4 and §9's technical gate). The verdict-layer
|
|
reservation (okf_type != verdict, case-insensitive) is enforced here — at the contract, never
|
|
downstream — closing session-plan key assumption 2 (fail-fast manifest validation without
|
|
network).
|
|
|
|
Since the ``llm-ingestion-okf`` adoption (2026-07-20) the contract is enforced by the shared
|
|
library rather than by repo-local pydantic models. The INVARIANTS are unchanged — every
|
|
malformation rejected before was verified to still be rejected — but the assertion vehicle
|
|
moved: ``ManifestV1.model_validate(dict)`` → ``load_manifest_bytes(bytes)``, and
|
|
``pydantic.ValidationError`` → ``ManifestError``. The library has zero runtime dependencies
|
|
BY DESIGN, so pydantic is not available to it. These tests now also pin the refusal ``code``,
|
|
which is the library's documented stability contract (the message text explicitly is not) —
|
|
a strictly sharper assertion than "some validation error was raised".
|
|
"""
|
|
|
|
import copy
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from llm_ingestion_okf import FileSource, HttpSource, ManifestError, SqlSource
|
|
from llm_ingestion_okf.manifest import load_manifest_bytes
|
|
|
|
from portfolio_optimiser.ingest import ManifestV1, load_manifest
|
|
|
|
_MANIFEST: dict[str, Any] = {
|
|
"manifest_version": 1,
|
|
"source": {"type": "file", "id": "prosjekt-arkiv", "root": "fixture"},
|
|
"bundle_summary": "Cost extracts from the project archive.",
|
|
"extractions": [
|
|
{
|
|
"id": "costs",
|
|
"title": "Project costs",
|
|
"query": "costs.csv",
|
|
"okf_type": "dataset",
|
|
"max_rows": 100,
|
|
},
|
|
{
|
|
"id": "meta",
|
|
"title": "Catalogue metadata",
|
|
"query": "meta.csv",
|
|
"okf_type": "reference",
|
|
"max_rows": 10,
|
|
},
|
|
],
|
|
}
|
|
|
|
|
|
def _write(tmp_path: Path, data: dict[str, Any], name: str = "manifest.json") -> Path:
|
|
path = tmp_path / name
|
|
path.write_text(json.dumps(data), encoding="utf-8")
|
|
return path
|
|
|
|
|
|
def _variant(**overrides: Any) -> dict[str, Any]:
|
|
data = copy.deepcopy(_MANIFEST)
|
|
data.update(overrides)
|
|
return data
|
|
|
|
|
|
def _validate(data: dict[str, Any]) -> ManifestV1:
|
|
"""In-memory validation without touching disk — the library's equivalent of the pydantic
|
|
``model_validate`` these tests used before the adoption."""
|
|
return load_manifest_bytes(json.dumps(data).encode("utf-8"))
|
|
|
|
|
|
def test_valid_file_manifest_loads_with_stamp(tmp_path: Path) -> None:
|
|
path = _write(tmp_path, _MANIFEST)
|
|
manifest, stamp = load_manifest(path)
|
|
assert isinstance(manifest, ManifestV1)
|
|
# The library's source models drop the `type` discriminator as a FIELD (it is consumed by
|
|
# validation dispatch), so the variant is identified by class rather than by `.type`.
|
|
assert isinstance(manifest.source, FileSource)
|
|
assert [e.id for e in manifest.extractions] == ["costs", "meta"]
|
|
# §5: stamp = {stem}@{first 16 hex of SHA-256 over the manifest file's RAW bytes}.
|
|
expected = "manifest@" + hashlib.sha256(path.read_bytes()).hexdigest()[:16]
|
|
assert stamp == expected
|
|
|
|
|
|
def test_each_missing_top_level_field_raises(tmp_path: Path) -> None:
|
|
for field in ("manifest_version", "source", "bundle_summary", "extractions"):
|
|
data = copy.deepcopy(_MANIFEST)
|
|
del data[field]
|
|
with pytest.raises(ManifestError):
|
|
load_manifest(_write(tmp_path, data, name=f"missing-{field}.json"))
|
|
|
|
|
|
def test_manifest_version_other_than_1_rejected(tmp_path: Path) -> None:
|
|
with pytest.raises(ManifestError) as exc:
|
|
load_manifest(_write(tmp_path, _variant(manifest_version=2)))
|
|
assert exc.value.code == "manifest_version_unsupported"
|
|
|
|
|
|
def test_empty_extractions_rejected(tmp_path: Path) -> None:
|
|
with pytest.raises(ManifestError):
|
|
load_manifest(_write(tmp_path, _variant(extractions=[])))
|
|
|
|
|
|
def test_bad_id_grammar_rejected() -> None:
|
|
# §4 grammar for source.id and extraction.id: ^[a-z0-9][a-z0-9-]*$
|
|
for bad in ("Upper", "-leading", "", "space id", "æøå"):
|
|
data = copy.deepcopy(_MANIFEST)
|
|
data["source"]["id"] = bad
|
|
with pytest.raises(ManifestError):
|
|
_validate(data)
|
|
data = copy.deepcopy(_MANIFEST)
|
|
data["extractions"][0]["id"] = bad
|
|
with pytest.raises(ManifestError):
|
|
_validate(data)
|
|
|
|
|
|
def test_duplicate_extraction_ids_rejected(tmp_path: Path) -> None:
|
|
data = copy.deepcopy(_MANIFEST)
|
|
data["extractions"][1]["id"] = data["extractions"][0]["id"]
|
|
with pytest.raises(ManifestError) as exc:
|
|
load_manifest(_write(tmp_path, data))
|
|
assert exc.value.code == "extraction_id_duplicate"
|
|
|
|
|
|
def test_nonpositive_max_rows_rejected(tmp_path: Path) -> None:
|
|
for bad in (0, -1):
|
|
data = copy.deepcopy(_MANIFEST)
|
|
data["extractions"][0]["max_rows"] = bad
|
|
with pytest.raises(ManifestError):
|
|
load_manifest(_write(tmp_path, data, name=f"rows-{bad}.json"))
|
|
|
|
|
|
def test_unknown_source_type_rejected(tmp_path: Path) -> None:
|
|
data = copy.deepcopy(_MANIFEST)
|
|
data["source"] = {"type": "ftp", "id": "x", "root": "fixture"}
|
|
with pytest.raises(ManifestError) as exc:
|
|
load_manifest(_write(tmp_path, data))
|
|
assert exc.value.code == "source_type_unknown"
|
|
|
|
|
|
def test_file_source_missing_root_rejected(tmp_path: Path) -> None:
|
|
data = copy.deepcopy(_MANIFEST)
|
|
del data["source"]["root"]
|
|
with pytest.raises(ManifestError):
|
|
load_manifest(_write(tmp_path, data))
|
|
|
|
|
|
def test_verdict_okf_type_rejected_case_insensitively(tmp_path: Path) -> None:
|
|
# §3: the verdict layer is RESERVED — enforced at manifest validation, before any
|
|
# source call. The manifest is otherwise fully valid, so this validator is the ONLY
|
|
# thing standing between an ingest manifest and machine-generated "approved" verdicts.
|
|
for spelling in ("verdict", "Verdict", "VERDICT"):
|
|
data = copy.deepcopy(_MANIFEST)
|
|
data["extractions"][0]["okf_type"] = spelling
|
|
with pytest.raises(ManifestError) as exc:
|
|
load_manifest(_write(tmp_path, data, name=f"verdict-{spelling}.json"))
|
|
assert exc.value.code == "okf_type_reserved"
|
|
|
|
|
|
def test_multiline_title_rejected() -> None:
|
|
for bad in ("line1\nline2", "line1\rline2", ""):
|
|
data = copy.deepcopy(_MANIFEST)
|
|
data["extractions"][0]["title"] = bad
|
|
with pytest.raises(ManifestError):
|
|
_validate(data)
|
|
|
|
|
|
def test_title_whitespace_is_preserved_verbatim() -> None:
|
|
"""ACCEPTED DIVERGENCE (2026-07-20), recorded rather than silently dropped.
|
|
|
|
The repo previously COLLAPSED title whitespace runs at validation, so the frontmatter
|
|
title and the index label were guaranteed byte-identical. The library stores the title
|
|
verbatim instead: ``_render_frontmatter`` still collapses runs when writing frontmatter,
|
|
but the §6 index label is written raw — so for a title with irregular internal whitespace
|
|
the two now differ. Both behaviours are spec-conformant: ``shared/ingest-spec.md`` is
|
|
SILENT on normalization, and the old behaviour was a repo-local pinned decision, not a
|
|
spec requirement.
|
|
|
|
This test pins the CURRENT behaviour so the divergence cannot drift unnoticed. It goes RED
|
|
if the library starts normalizing — which is the desired end state, and is why the point is
|
|
queued as a commons-amendment candidate so both stacks pin the same answer in the spec.
|
|
The golden bundles are unaffected (their titles carry no irregular whitespace)."""
|
|
data = copy.deepcopy(_MANIFEST)
|
|
data["extractions"][0]["title"] = " Project costs "
|
|
manifest = _validate(data)
|
|
assert manifest.extractions[0].title == " Project costs "
|
|
|
|
|
|
def test_base_url_embedded_credentials_rejected() -> None:
|
|
# §4: base_url MUST NOT embed credentials (userinfo is the URL credential mechanism).
|
|
data = copy.deepcopy(_MANIFEST)
|
|
data["source"] = {"type": "http", "id": "api", "base_url": "https://user:pw@host/api"}
|
|
with pytest.raises(ManifestError) as exc:
|
|
_validate(data)
|
|
assert exc.value.code == "credential_embedded"
|
|
|
|
|
|
def test_sql_and_http_variants_validate() -> None:
|
|
# Schema breadth (brief assumption 1): the polymorphic §4 schema validates all three
|
|
# source variants; the `file` and `sql` connectors EXECUTE (I2, I4), http → I6.
|
|
sql = _validate(_variant(source={"type": "sql", "id": "db", "connection_ref": "PROJ_DB"}))
|
|
assert isinstance(sql.source, SqlSource)
|
|
assert sql.source.connection_ref == "PROJ_DB"
|
|
http = _validate(_variant(source={"type": "http", "id": "api", "base_url": "https://host/api"}))
|
|
assert isinstance(http.source, HttpSource)
|
|
assert http.source.credential_ref is None
|
|
|
|
|
|
def test_failfast_before_source_access(tmp_path: Path) -> None:
|
|
# Ordering proof (closes key assumption 2): the manifest is malformed on max_rows AND
|
|
# points at a root that does not exist — validation raises WITHOUT touching the root
|
|
# (load_manifest reads only the manifest file; the ingest analogue of
|
|
# test_no_chat_client_call_on_malformed_contract).
|
|
data = copy.deepcopy(_MANIFEST)
|
|
data["source"]["root"] = str(tmp_path / "does-not-exist")
|
|
data["extractions"][0]["max_rows"] = 0
|
|
with pytest.raises(ManifestError):
|
|
load_manifest(_write(tmp_path, data))
|
|
assert not (tmp_path / "does-not-exist").exists()
|
|
|
|
|
|
def test_malformed_json_raises(tmp_path: Path) -> None:
|
|
# The library wraps json decoding so EVERY manifest problem surfaces as one typed family
|
|
# (previously this leaked a raw json.JSONDecodeError to the caller).
|
|
path = tmp_path / "broken.json"
|
|
path.write_text("{not json", encoding="utf-8")
|
|
with pytest.raises(ManifestError) as exc:
|
|
load_manifest(path)
|
|
assert exc.value.code == "manifest_invalid_json"
|