feat(manifest): add fail-fast manifest validation (spec §3–§4)
TDD step 1 of the phase 1 plan: typed error hierarchy rooted in IngestError, manifest parsing with schema validation before any source call — id grammar, polymorphic source types, extraction rules, verdict reservation (okf_type and filename namespace), and the no-embedded- credentials rule for base_url. Dev tooling (pytest/mypy/ruff) added as a PEP 735 dependency group; runtime dependencies stay empty. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QeqhJpYQyghASjiJo5EhGg
This commit is contained in:
parent
ec2d632b4f
commit
54ac494830
5 changed files with 959 additions and 0 deletions
11
src/llm_ingestion_okf/errors.py
Normal file
11
src/llm_ingestion_okf/errors.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
"""Typed error hierarchy rooted in IngestError."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class IngestError(Exception):
|
||||
"""Base class for every error raised by this library."""
|
||||
|
||||
|
||||
class ManifestError(IngestError):
|
||||
"""The manifest failed fail-fast schema validation (ingest-spec §4)."""
|
||||
212
src/llm_ingestion_okf/manifest.py
Normal file
212
src/llm_ingestion_okf/manifest.py
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
"""Manifest parsing and fail-fast validation (ingest-spec §3, §4).
|
||||
|
||||
Validation is schema-only and runs BEFORE any source call: source existence,
|
||||
credentials, and network access are connector concerns. Queries are
|
||||
configuration, not code — nothing here evaluates manifest content.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Union
|
||||
|
||||
from .errors import ManifestError
|
||||
|
||||
_ID_PATTERN = re.compile(r"[a-z0-9][a-z0-9-]*\Z")
|
||||
|
||||
_RESERVED_OKF_TYPE = "verdict"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FileSource:
|
||||
id: str
|
||||
root: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SqlSource:
|
||||
id: str
|
||||
connection_ref: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HttpSource:
|
||||
id: str
|
||||
base_url: str
|
||||
credential_ref: Union[str, None] = None
|
||||
|
||||
|
||||
Source = Union[FileSource, SqlSource, HttpSource]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Extraction:
|
||||
id: str
|
||||
title: str
|
||||
query: str
|
||||
okf_type: str
|
||||
max_rows: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Manifest:
|
||||
manifest_version: int
|
||||
source: Source
|
||||
bundle_summary: str
|
||||
extractions: tuple[Extraction, ...]
|
||||
|
||||
|
||||
def generated_filename(extraction_id: str) -> str:
|
||||
"""The concept filename for an extraction (spec §5).
|
||||
|
||||
The `ingest-` prefix keeps the namespace disjoint from `index.md` and
|
||||
`promoted-verdict-*` (spec §3) for every id the §4 grammar admits.
|
||||
"""
|
||||
return f"ingest-{extraction_id}.md"
|
||||
|
||||
|
||||
def load_manifest(path: Path) -> Manifest:
|
||||
"""Read and fail-fast validate a manifest file (spec §4).
|
||||
|
||||
Raises ManifestError on any schema violation, before any source call.
|
||||
"""
|
||||
try:
|
||||
data: object = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
raise ManifestError(f"cannot read manifest {path}: {exc}") from exc
|
||||
return _validate_manifest(data)
|
||||
|
||||
|
||||
def _validate_manifest(data: object) -> Manifest:
|
||||
obj = _require_object(data, "manifest")
|
||||
_require_keys(obj, "manifest", {"manifest_version", "source", "bundle_summary", "extractions"})
|
||||
|
||||
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}")
|
||||
|
||||
source = _validate_source(obj["source"])
|
||||
bundle_summary = _require_str(obj["bundle_summary"], "bundle_summary", allow_empty=True)
|
||||
extractions = _validate_extractions(obj["extractions"])
|
||||
|
||||
return Manifest(
|
||||
manifest_version=1,
|
||||
source=source,
|
||||
bundle_summary=bundle_summary,
|
||||
extractions=extractions,
|
||||
)
|
||||
|
||||
|
||||
def _validate_source(data: object) -> Source:
|
||||
obj = _require_object(data, "source")
|
||||
source_type = obj.get("type")
|
||||
if source_type == "file":
|
||||
_require_keys(obj, "source", {"type", "id", "root"})
|
||||
return FileSource(
|
||||
id=_validate_id(obj["id"], "source.id"), root=_require_str(obj["root"], "source.root")
|
||||
)
|
||||
if source_type == "sql":
|
||||
_require_keys(obj, "source", {"type", "id", "connection_ref"})
|
||||
return SqlSource(
|
||||
id=_validate_id(obj["id"], "source.id"),
|
||||
connection_ref=_require_str(obj["connection_ref"], "source.connection_ref"),
|
||||
)
|
||||
if source_type == "http":
|
||||
_require_keys(obj, "source", {"type", "id", "base_url"}, optional={"credential_ref"})
|
||||
credential_ref = obj.get("credential_ref")
|
||||
if credential_ref is not None:
|
||||
credential_ref = _require_str(credential_ref, "source.credential_ref")
|
||||
return HttpSource(
|
||||
id=_validate_id(obj["id"], "source.id"),
|
||||
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}")
|
||||
|
||||
|
||||
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")
|
||||
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")
|
||||
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}")
|
||||
seen.add(extraction.id)
|
||||
return extractions
|
||||
|
||||
|
||||
def _validate_extraction(data: object, index: int) -> Extraction:
|
||||
label = f"extractions[{index}]"
|
||||
obj = _require_object(data, label)
|
||||
_require_keys(obj, label, {"id", "title", "query", "okf_type", "max_rows"})
|
||||
|
||||
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")
|
||||
|
||||
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)")
|
||||
|
||||
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}")
|
||||
|
||||
return Extraction(
|
||||
id=_validate_id(obj["id"], f"{label}.id"),
|
||||
title=title,
|
||||
query=_require_str(obj["query"], f"{label}.query"),
|
||||
okf_type=okf_type,
|
||||
max_rows=max_rows,
|
||||
)
|
||||
|
||||
|
||||
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}")
|
||||
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__}")
|
||||
return data
|
||||
|
||||
|
||||
def _require_keys(
|
||||
obj: dict[str, Any], label: str, required: set[str], optional: set[str] | None = None
|
||||
) -> None:
|
||||
missing = required - obj.keys()
|
||||
if missing:
|
||||
raise ManifestError(f"{label} is missing required field(s): {', '.join(sorted(missing))}")
|
||||
unknown = obj.keys() - required - (optional or set())
|
||||
if unknown:
|
||||
raise ManifestError(f"{label} has unknown field(s): {', '.join(sorted(unknown))}")
|
||||
|
||||
|
||||
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__}")
|
||||
if not value and not allow_empty:
|
||||
raise ManifestError(f"{label} must be a non-empty string")
|
||||
return value
|
||||
|
||||
|
||||
def _is_int(value: object) -> bool:
|
||||
return isinstance(value, int) and not isinstance(value, bool)
|
||||
Loading…
Add table
Add a link
Reference in a new issue