Coverage for src/secchi/history.py: 43%
65 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-04 22:15 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-04 22:15 +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 json
11import os
12from collections.abc import Callable
13from datetime import datetime, timezone
14from pathlib import Path
16from secchi.models import HistorySnapshot
19def history_file_path(root: Path | None = None) -> Path:
20 """XDG_CACHE_HOME/secchi/history.json, else ~/.cache/secchi/history.json."""
21 if root is not None:
22 return root / "history.json"
23 if base := os.environ.get("XDG_CACHE_HOME", ""):
24 root = Path(base)
25 else:
26 root = Path.home() / ".cache"
27 return root / "secchi" / "history.json"
30def _load_all(path: Path | None = None) -> dict[str, list[dict]]:
31 path = path or history_file_path()
32 if not path.exists():
33 return {}
34 try:
35 return json.loads(path.read_text())
36 except (json.JSONDecodeError, OSError):
37 return {}
40def _save_all(data: dict[str, list[dict]], path: Path | None = None) -> None:
41 path = path or history_file_path()
42 try:
43 path.parent.mkdir(parents=True, exist_ok=True)
44 path.write_text(json.dumps(data, indent=2))
45 except OSError:
46 pass
49def load_snapshots(key: str, *, path: Path | None = None) -> list[HistorySnapshot]:
50 snapshots: list[HistorySnapshot] = []
51 for raw in _load_all(path).get(key, []):
52 ts = raw.get("timestamp")
53 try:
54 timestamp = datetime.fromisoformat(ts) if ts else None
55 except (ValueError, TypeError):
56 timestamp = None
57 if timestamp is None:
58 continue
59 snapshots.append(
60 HistorySnapshot(
61 timestamp=timestamp,
62 stars=raw.get("stars", 0),
63 open_issues=raw.get("open_issues", 0),
64 health_score=raw.get("health_score"),
65 reverse_dependency_count=raw.get("reverse_dependency_count"),
66 )
67 )
68 return snapshots
71def append_snapshot(
72 key: str,
73 snapshot: HistorySnapshot,
74 max_keep: int = 420,
75 *,
76 path: Path | None = None,
77) -> None:
78 data = _load_all(path)
79 entries = data.get(key, [])
80 entries.append(
81 {
82 "timestamp": snapshot.timestamp.isoformat(),
83 "stars": snapshot.stars,
84 "open_issues": snapshot.open_issues,
85 "health_score": snapshot.health_score,
86 "reverse_dependency_count": snapshot.reverse_dependency_count,
87 }
88 )
89 data[key] = entries[-max_keep:]
90 _save_all(data, path)
93def find_baseline(
94 snapshots: list[HistorySnapshot],
95 min_age_days: int = 6,
96 max_age_days: int = 10,
97 *,
98 now: Callable[[], datetime] | None = None,
99) -> HistorySnapshot | None:
100 """Closest snapshot whose age falls in [min_age_days, max_age_days]."""
101 current_time = (now or (lambda: datetime.now(timezone.utc)))()
102 candidates: list[tuple[float, HistorySnapshot]] = []
103 for snap in snapshots:
104 ts = snap.timestamp
105 if ts.tzinfo is None:
106 ts = ts.replace(tzinfo=timezone.utc)
107 age_days = (current_time - ts).total_seconds() / 86400
108 if min_age_days <= age_days <= max_age_days:
109 candidates.append((abs(age_days - 7), snap))
110 if not candidates:
111 return None
112 candidates.sort(key=lambda c: c[0])
113 return candidates[0][1]
116def compute_delta(current: int, baseline: int | None) -> int | None:
117 """current - baseline, or None if no baseline — never fabricate."""
118 if baseline is None:
119 return None
120 return current - baseline