Coverage for src/lilbee/data/ingest/skip_marker.py: 100%
52 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-08 09:20 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-08 09:20 +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 collections.abc import Iterable
24from pathlib import Path
26from lilbee.data.types import SkippedSource
28log = logging.getLogger(__name__)
30SKIP_MARKER_FILENAME = "skipped_sources.json"
31SKIP_REASON_FILENAME = "skip_reasons.json"
32DEFAULT_SKIP_REASON = "held out by an earlier sync"
35def _load_str_map(path: Path) -> dict[str, str]:
36 """Load a ``{str: str}`` JSON file, or empty dict on any read/parse error."""
37 if not path.exists():
38 return {}
39 try:
40 raw = json.loads(path.read_text(encoding="utf-8"))
41 except (OSError, json.JSONDecodeError, UnicodeDecodeError) as exc:
42 log.debug("Sidecar %s unreadable, treating as empty: %s", path.name, exc)
43 return {}
44 if not isinstance(raw, dict):
45 return {}
46 return {str(k): str(v) for k, v in raw.items() if isinstance(v, str)}
49def _write_str_map(path: Path, data: dict[str, str]) -> None:
50 """Replace *path* atomically with a ``{str: str}`` JSON map. Best-effort."""
51 tmp = path.with_suffix(path.suffix + ".tmp")
52 try:
53 path.parent.mkdir(parents=True, exist_ok=True)
54 tmp.write_text(json.dumps(data, sort_keys=True), encoding="utf-8")
55 os.replace(tmp, path)
56 except OSError as exc:
57 log.warning("Failed to persist %s: %s", path, exc)
58 with contextlib.suppress(OSError):
59 tmp.unlink()
62def _unlink(path: Path) -> None:
63 try:
64 path.unlink(missing_ok=True)
65 except OSError as exc:
66 log.debug("Could not remove %s: %s", path, exc)
69def load_skip_markers(data_root: Path) -> dict[str, str]:
70 """Load the filename → failed-hash map, or empty dict on any read error."""
71 return _load_str_map(data_root / SKIP_MARKER_FILENAME)
74def write_skip_markers(data_root: Path, markers: dict[str, str]) -> None:
75 """Replace the marker file atomically. Best-effort: errors are logged, not raised."""
76 _write_str_map(data_root / SKIP_MARKER_FILENAME, markers)
79def load_skip_reasons(data_root: Path) -> dict[str, str]:
80 """Load the filename → skip-reason map (informational), empty on any read error."""
81 return _load_str_map(data_root / SKIP_REASON_FILENAME)
84def write_skip_reasons(data_root: Path, reasons: dict[str, str]) -> None:
85 """Replace the reasons sidecar atomically. Best-effort: errors are logged, not raised."""
86 _write_str_map(data_root / SKIP_REASON_FILENAME, reasons)
89def describe_skips(data_root: Path, names: Iterable[str]) -> list[SkippedSource]:
90 """Pair each name with its recorded reason, in order; ``DEFAULT_SKIP_REASON`` when none."""
91 reasons = load_skip_reasons(data_root)
92 return [
93 SkippedSource(filename=name, reason=reasons.get(name, DEFAULT_SKIP_REASON))
94 for name in names
95 ]
98def clear_skip_markers(data_root: Path) -> None:
99 """Delete both the marker file and the reasons sidecar. No-op if absent."""
100 _unlink(data_root / SKIP_MARKER_FILENAME)
101 _unlink(data_root / SKIP_REASON_FILENAME)