Coverage for src/lilbee/data/store/held_import.py: 100%
60 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
1"""The held-out records an older lilbee kept in JSON files at the data root."""
3from __future__ import annotations
5import contextlib
6import json
7import logging
8from pathlib import Path
10from .types import REMOVED_REASON, HeldOut, HeldRecord
12log = logging.getLogger(__name__)
14_MARKERS = "skipped_sources.json"
15_REASONS = "skip_reasons.json"
16_KINDS = "skip_kinds.json"
17_PENDING = "skip_records.pending.json"
18_RECORDS = (_MARKERS, _REASONS, _KINDS)
19# Every file the older format kept, the lock of its writers included.
20LEGACY_FILES = (*_RECORDS, _PENDING, f"{_MARKERS}.lock")
21# The reason an older lilbee wrote for a removal.
22_OLD_REMOVED_REASON = "removed via remove (re-add the source or run retry-skipped to restore)"
23_DEFAULT_REASON = "held out by an earlier sync"
26def legacy_files(data_root: Path) -> list[Path]:
27 """The files of the older format that are still at *data_root*."""
28 return [path for name in LEGACY_FILES if (path := data_root / name).exists()]
31def _text(path: Path) -> str | None:
32 """The text of *path*, or None when it is absent or cannot be read."""
33 try:
34 return path.read_text(encoding="utf-8")
35 except (OSError, UnicodeDecodeError) as exc:
36 log.debug("Cannot read %s: %s", path.name, exc)
37 return None
40def _object(text: str | None) -> dict[str, object]:
41 """The JSON object *text* holds, or an empty one."""
42 if text is None:
43 return {}
44 try:
45 raw = json.loads(text)
46 except json.JSONDecodeError:
47 return {}
48 # the files are untyped JSON
49 return {str(key): value for key, value in raw.items()} if isinstance(raw, dict) else {}
52def _texts(data_root: Path) -> dict[str, str | None]:
53 """The text of each record file, from a write that did not end when one is recorded."""
54 after = _object(_text(data_root / _PENDING)).get("after")
55 # the pending file is untyped JSON
56 if isinstance(after, dict) and all(isinstance(after.get(name), str) for name in _RECORDS):
57 return {name: after[name] for name in _RECORDS}
58 return {name: _text(data_root / name) for name in _RECORDS}
61def _kind(stored: object, marker: str, reason: str | None) -> HeldOut:
62 """The stored kind while it names this marker and reason; else the kind its reason gives."""
63 # the kinds file is untyped JSON
64 if isinstance(stored, dict) and (stored.get("hash"), stored.get("reason")) == (marker, reason):
65 with contextlib.suppress(ValueError):
66 return HeldOut(stored.get("kind", ""))
67 return HeldOut.REMOVED if reason == _OLD_REMOVED_REASON else HeldOut.FAILED
70def read_legacy_records(data_root: Path) -> dict[str, HeldRecord]:
71 """The record of each file the JSON files at *data_root* hold out, by path."""
72 texts = _texts(data_root)
73 markers, reasons, kinds = (_object(texts[name]) for name in _RECORDS)
74 records: dict[str, HeldRecord] = {}
75 for name, marker in markers.items():
76 if not isinstance(marker, str):
77 continue
78 reason = reasons.get(name)
79 text = reason if isinstance(reason, str) else None
80 kind = _kind(kinds.get(name), marker, text)
81 shown = REMOVED_REASON if kind is HeldOut.REMOVED else text or _DEFAULT_REASON
82 records[name] = HeldRecord(kind, marker, shown)
83 return records
86def remove_legacy_files(data_root: Path) -> None:
87 """Delete the files of the older format; one that cannot go is read again next open."""
88 for path in legacy_files(data_root):
89 try:
90 path.unlink(missing_ok=True)
91 except OSError as exc:
92 log.warning("Could not delete %s: %s", path, exc)