Files
2025-11-14 23:16:51 +00:00

111 lines
3.8 KiB
Python

#!/usr/bin/env python3
"""
CI helper that ensures watched datasets keep their expected signatures and pass validation.
Fail conditions:
1. Dataset listed in baseline JSON is missing.
2. Manifest entry hash differs from the baseline (meaning data changed without approval).
3. Validation severity == "error".
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from loguru import logger
ROOT = Path(__file__).resolve().parents[1]
sys.path.append(str(ROOT))
from scripts.validate_dataset import compute_report, DEFAULT_MANIFEST # noqa
def load_manifest(manifest_path: Path) -> dict:
if not manifest_path.exists():
raise FileNotFoundError(f"Manifest not found: {manifest_path}")
with manifest_path.open("r", encoding="utf-8") as fh:
return json.load(fh)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Check dataset hashes & validation severity.")
parser.add_argument("--baseline", default="data/signature_baseline.json", help="Baseline hash file.")
parser.add_argument("--manifest", default=DEFAULT_MANIFEST, help="Manifest JSON to compare against.")
parser.add_argument("--max-outlier-z", type=float, default=5.0, help="Z-score threshold used for validation.")
return parser.parse_args()
def load_baseline_entries(path: Path) -> list[dict]:
if not path.exists():
raise FileNotFoundError(f"Baseline file missing: {path}")
with path.open("r", encoding="utf-8") as fh:
data = json.load(fh)
if isinstance(data, dict):
return [{"path": k, "sha256": v, "allow_drift": False} for k, v in data.items()]
if isinstance(data, list):
return data
raise ValueError("Baseline must be a dict or list of entries.")
def main() -> None:
args = parse_args()
baseline_path = (ROOT / args.baseline).resolve()
manifest_path = (ROOT / args.manifest).resolve()
baseline_entries = load_baseline_entries(baseline_path)
manifest = load_manifest(manifest_path)
manifest_index = {entry["path"]: entry for entry in manifest.get("files", [])}
errors = []
warnings = []
for item in baseline_entries:
rel_path = item["path"]
expected_hash = item.get("sha256")
allow_drift = bool(item.get("allow_drift", False))
dataset_path = (ROOT / rel_path).resolve()
manifest_entry = manifest_index.get(rel_path)
label = rel_path
if manifest_entry is None:
errors.append(f"{label}: missing from manifest {manifest_path}")
continue
actual_hash = manifest_entry.get("sha256")
if actual_hash != expected_hash:
message = (
f"{label}: hash drift detected (expected {expected_hash}, actual {actual_hash}). "
"Update the baseline with justification if intentional."
)
if allow_drift:
warnings.append(message)
else:
errors.append(message)
if not dataset_path.exists():
errors.append(f"{label}: dataset file not found on disk")
continue
report = compute_report(dataset_path, manifest_entry, args.max_outlier_z)
severity = report.get("severity")
msg = f"{label}: validation severity={severity} ({'; '.join(report.get('messages', []))})"
if severity == "error":
errors.append(msg)
elif severity == "warn":
warnings.append(msg)
for warn in warnings:
logger.warning(warn)
if errors:
logger.error("Data integrity check failed:")
for err in errors:
logger.error(" - {}", err)
sys.exit(1)
logger.info(f"Data integrity checks passed for {len(baseline_entries)} dataset(s).")
if __name__ == "__main__":
main()