Coverage for src/lilbee/data/ingest/skip_marker.py: 100%
46 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
1"""Sidecar records of files that produced no chunks, so a sync can skip them.
3A file that yields zero chunks (Tesseract timeout, decode failure, no usable
4text) gets a marker here keyed by the file hash that failed.
5``_plan_file_changes`` treats a file whose current hash matches its marker as
6unchanged, so the per-file extract cost (30-60s for a stubborn scanned PDF) is
7paid once, not on every sync. The marker is a small JSON file in
8``cfg.data_root``; editing the file changes its hash and re-arms it, and
9``retry_skipped`` / ``force_rebuild`` drop the file from the marker set.
11A second sidecar (``skip_reasons.json``) records filename → human-readable
12reason, so a report can say WHY a file was skipped (the exception message, or
13"no text extracted"), not just that it was. It is informational only -- the
14hash-keyed markers above drive the resume logic -- and is cleared alongside them.
15"""
17from __future__ import annotations
19import contextlib
20import json
21import logging
22import os
23from pathlib import Path
25log = logging.getLogger(__name__)
27SKIP_MARKER_FILENAME = "skipped_sources.json"
28SKIP_REASON_FILENAME = "skip_reasons.json"
31def _load_str_map(path: Path) -> dict[str, str]:
32 """Load a ``{str: str}`` JSON file, or empty dict on any read/parse error."""
33 if not path.exists():
34 return {}
35 try:
36 raw = json.loads(path.read_text(encoding="utf-8"))
37 except (OSError, json.JSONDecodeError, UnicodeDecodeError) as exc:
38 log.debug("Sidecar %s unreadable, treating as empty: %s", path.name, exc)
39 return {}
40 if not isinstance(raw, dict):
41 return {}
42 return {str(k): str(v) for k, v in raw.items() if isinstance(v, str)}
45def _write_str_map(path: Path, data: dict[str, str]) -> None:
46 """Replace *path* atomically with a ``{str: str}`` JSON map. Best-effort."""
47 tmp = path.with_suffix(path.suffix + ".tmp")
48 try:
49 path.parent.mkdir(parents=True, exist_ok=True)
50 tmp.write_text(json.dumps(data, sort_keys=True), encoding="utf-8")
51 os.replace(tmp, path)
52 except OSError as exc:
53 log.warning("Failed to persist %s: %s", path, exc)
54 with contextlib.suppress(OSError):
55 tmp.unlink()
58def _unlink(path: Path) -> None:
59 try:
60 path.unlink(missing_ok=True)
61 except OSError as exc:
62 log.debug("Could not remove %s: %s", path, exc)
65def load_skip_markers(data_root: Path) -> dict[str, str]:
66 """Load the filename → failed-hash map, or empty dict on any read error."""
67 return _load_str_map(data_root / SKIP_MARKER_FILENAME)
70def write_skip_markers(data_root: Path, markers: dict[str, str]) -> None:
71 """Replace the marker file atomically. Best-effort: errors are logged, not raised."""
72 _write_str_map(data_root / SKIP_MARKER_FILENAME, markers)
75def load_skip_reasons(data_root: Path) -> dict[str, str]:
76 """Load the filename → skip-reason map (informational), empty on any read error."""
77 return _load_str_map(data_root / SKIP_REASON_FILENAME)
80def write_skip_reasons(data_root: Path, reasons: dict[str, str]) -> None:
81 """Replace the reasons sidecar atomically. Best-effort: errors are logged, not raised."""
82 _write_str_map(data_root / SKIP_REASON_FILENAME, reasons)
85def clear_skip_markers(data_root: Path) -> None:
86 """Delete both the marker file and the reasons sidecar. No-op if absent."""
87 _unlink(data_root / SKIP_MARKER_FILENAME)
88 _unlink(data_root / SKIP_REASON_FILENAME)