Coverage for src/secchi/history.py: 86%
103 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 23:28 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 23:28 +0000
1"""Local snapshot cache — produces real week-over-week deltas.
3GitHub stars and open-issue counts have no cheap point-in-time historical API,
4so we persist a small rolling cache of snapshots per package. Deltas degrade to
5None (rendered "—") until a baseline of the right age exists — never fabricated.
6"""
8from __future__ import annotations
10import contextlib
11import json
12import os
13import time
14from collections.abc import Callable, Iterator
15from datetime import UTC, datetime
16from pathlib import Path
18from secchi.models import HistorySnapshot
20_LOCK_STALE_SECONDS = 30
21_LOCK_TIMEOUT_SECONDS = 5
22_LOCK_POLL_SECONDS = 0.05
25def history_file_path(root: Path | None = None) -> Path:
26 """XDG_CACHE_HOME/secchi/history.json, else ~/.cache/secchi/history.json."""
27 if root is not None:
28 return root / "history.json"
29 if base := os.environ.get("XDG_CACHE_HOME", ""):
30 root = Path(base)
31 else:
32 root = Path.home() / ".cache"
33 return root / "secchi" / "history.json"
36@contextlib.contextmanager
37def _locked(path: Path) -> Iterator[None]:
38 """Best-effort advisory lock so two secchi processes don't lose each
39 other's snapshots in a read-modify-write race on history.json.
41 Uses atomic lock-file creation (``O_CREAT | O_EXCL``), which behaves
42 identically on POSIX and Windows — no ``fcntl``/``msvcrt`` split needed.
43 A lock older than ``_LOCK_STALE_SECONDS`` is assumed abandoned by a
44 crashed holder and reclaimed. If the lock can't be acquired within
45 ``_LOCK_TIMEOUT_SECONDS``, the operation proceeds unlocked rather than
46 hanging or failing — history is a best-effort local cache, never a
47 reason to block a run.
48 """
49 path.parent.mkdir(parents=True, exist_ok=True)
50 lock_path = path.with_name(f"{path.name}.lock")
51 deadline = time.monotonic() + _LOCK_TIMEOUT_SECONDS
52 fd: int | None = None
53 try:
54 while fd is None:
55 try:
56 fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
57 except OSError:
58 try:
59 age = time.time() - lock_path.stat().st_mtime
60 except OSError:
61 age = 0.0
62 if age > _LOCK_STALE_SECONDS:
63 with contextlib.suppress(OSError):
64 lock_path.unlink()
65 continue
66 if time.monotonic() >= deadline:
67 break
68 time.sleep(_LOCK_POLL_SECONDS)
69 yield
70 finally:
71 if fd is not None:
72 with contextlib.suppress(OSError):
73 os.close(fd)
74 with contextlib.suppress(OSError):
75 lock_path.unlink()
78def _load_all(path: Path | None = None) -> dict[str, list[dict]]:
79 path = path or history_file_path()
80 if not path.exists():
81 return {}
82 try:
83 return json.loads(path.read_text())
84 except (json.JSONDecodeError, OSError):
85 return {}
88def _save_all(data: dict[str, list[dict]], path: Path | None = None) -> None:
89 path = path or history_file_path()
90 tmp_path = path.with_name(f"{path.name}.{os.getpid()}.tmp")
91 try:
92 path.parent.mkdir(parents=True, exist_ok=True)
93 tmp_path.write_text(json.dumps(data, indent=2))
94 os.replace(tmp_path, path)
95 except OSError:
96 with contextlib.suppress(OSError):
97 tmp_path.unlink()
100def load_snapshots(key: str, *, path: Path | None = None) -> list[HistorySnapshot]:
101 snapshots: list[HistorySnapshot] = []
102 for raw in _load_all(path).get(key, []):
103 ts = raw.get("timestamp")
104 try:
105 timestamp = datetime.fromisoformat(ts) if ts else None
106 except (ValueError, TypeError):
107 timestamp = None
108 if timestamp is None:
109 continue
110 snapshots.append(
111 HistorySnapshot(
112 timestamp=timestamp,
113 stars=raw.get("stars", 0),
114 open_issues=raw.get("open_issues", 0),
115 health_score=raw.get("health_score"),
116 reverse_dependency_count=raw.get("reverse_dependency_count"),
117 )
118 )
119 return snapshots
122def append_snapshot(
123 key: str,
124 snapshot: HistorySnapshot,
125 max_keep: int = 420,
126 *,
127 path: Path | None = None,
128) -> None:
129 path = path or history_file_path()
130 with _locked(path):
131 data = _load_all(path)
132 entries = data.get(key, [])
133 entries.append(
134 {
135 "timestamp": snapshot.timestamp.isoformat(),
136 "stars": snapshot.stars,
137 "open_issues": snapshot.open_issues,
138 "health_score": snapshot.health_score,
139 "reverse_dependency_count": snapshot.reverse_dependency_count,
140 }
141 )
142 data[key] = entries[-max_keep:]
143 _save_all(data, path)
146def find_baseline(
147 snapshots: list[HistorySnapshot],
148 min_age_days: int = 6,
149 max_age_days: int = 10,
150 *,
151 now: Callable[[], datetime] | None = None,
152) -> HistorySnapshot | None:
153 """Closest snapshot whose age falls in [min_age_days, max_age_days]."""
154 current_time = (now or (lambda: datetime.now(UTC)))()
155 candidates: list[tuple[float, HistorySnapshot]] = []
156 for snap in snapshots:
157 ts = snap.timestamp
158 if ts.tzinfo is None:
159 ts = ts.replace(tzinfo=UTC)
160 age_days = (current_time - ts).total_seconds() / 86400
161 if min_age_days <= age_days <= max_age_days:
162 candidates.append((abs(age_days - 7), snap))
163 if not candidates:
164 return None
165 candidates.sort(key=lambda c: c[0])
166 return candidates[0][1]
169def compute_delta(current: int, baseline: int | None) -> int | None:
170 """current - baseline, or None if no baseline — never fabricate."""
171 if baseline is None:
172 return None
173 return current - baseline