Coverage for src/lpip/cache.py: 85%
101 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-17 20:20 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-17 20:20 +0000
1"""Persistent Git catalog cache and auto-refresh TTL (ADR-0003, ADR-0012)."""
3from __future__ import annotations
5import hashlib
6import json
7import os
8import shutil
9import stat
10from collections.abc import Mapping
11from dataclasses import dataclass
12from datetime import UTC, datetime, timedelta
13from pathlib import Path
15from lpip.config import default_user_cache_dir
16from lpip.gitutils import git_checkout, git_clone, git_fetch
17from lpip.models import RefreshMode
19AUTO_REFRESH_TTL = timedelta(hours=24)
20_METADATA_NAME = "metadata.json"
21_LAST_SUCCESS_KEY = "last_success"
24@dataclass(frozen=True)
25class CatalogCachePaths:
26 root: Path
27 repo: Path
28 metadata: Path
31def catalog_identity_hash(url: str, revision: str | None) -> str:
32 payload = f"{url}\n{revision or ''}".encode()
33 return hashlib.sha256(payload).hexdigest()
36def catalog_cache_paths(
37 url: str,
38 revision: str | None,
39 *,
40 cache_home: Path | None = None,
41) -> CatalogCachePaths:
42 home = cache_home if cache_home is not None else default_user_cache_dir()
43 root = home / "catalogs" / catalog_identity_hash(url, revision)
44 return CatalogCachePaths(root=root, repo=root / "repo", metadata=root / _METADATA_NAME)
47def read_last_success(paths: CatalogCachePaths) -> datetime | None:
48 if not paths.metadata.is_file():
49 return None
50 try:
51 data = json.loads(paths.metadata.read_text(encoding="utf-8"))
52 except (OSError, json.JSONDecodeError):
53 return None
54 raw = data.get(_LAST_SUCCESS_KEY)
55 if not isinstance(raw, str) or not raw: 55 ↛ 56line 55 didn't jump to line 56 because the condition on line 55 was never true
56 return None
57 try:
58 parsed = datetime.fromisoformat(raw)
59 except ValueError:
60 return None
61 if parsed.tzinfo is None: 61 ↛ 62line 61 didn't jump to line 62 because the condition on line 61 was never true
62 parsed = parsed.replace(tzinfo=UTC)
63 return parsed
66def write_last_success(paths: CatalogCachePaths, when: datetime | None = None) -> None:
67 """Record last successful clone/fetch. Call only after Git succeeds."""
68 stamp = when or datetime.now(UTC)
69 if stamp.tzinfo is None: 69 ↛ 70line 69 didn't jump to line 70 because the condition on line 69 was never true
70 stamp = stamp.replace(tzinfo=UTC)
71 payload: dict[str, object] = {}
72 if paths.metadata.is_file():
73 try:
74 loaded = json.loads(paths.metadata.read_text(encoding="utf-8"))
75 if isinstance(loaded, dict): 75 ↛ 79line 75 didn't jump to line 79 because the condition on line 75 was always true
76 payload = loaded
77 except (OSError, json.JSONDecodeError):
78 payload = {}
79 payload[_LAST_SUCCESS_KEY] = stamp.isoformat()
80 paths.root.mkdir(parents=True, exist_ok=True)
81 paths.metadata.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
84def needs_auto_refresh(
85 paths: CatalogCachePaths,
86 *,
87 now: datetime | None = None,
88) -> bool:
89 """True if timestamp is missing or older than 24 hours (ADR-0012)."""
90 last = read_last_success(paths)
91 if last is None:
92 return True
93 clock = now or datetime.now(UTC)
94 if clock.tzinfo is None: 94 ↛ 95line 94 didn't jump to line 95 because the condition on line 94 was never true
95 clock = clock.replace(tzinfo=UTC)
96 return clock - last >= AUTO_REFRESH_TTL
99def should_fetch(
100 paths: CatalogCachePaths,
101 refresh: RefreshMode,
102 *,
103 force: bool = False,
104 now: datetime | None = None,
105) -> bool:
106 """Whether to fetch an existing checkout. Absent cache is always cloned."""
107 if force or refresh is RefreshMode.ALWAYS:
108 return True
109 if refresh is RefreshMode.NEVER:
110 return False
111 return needs_auto_refresh(paths, now=now)
114def _repo_ready(repo: Path) -> bool:
115 git_dir = repo / ".git"
116 return git_dir.is_dir() or git_dir.is_file()
119def ensure_catalog_checkout(
120 url: str,
121 revision: str | None,
122 *,
123 cache_home: Path | None = None,
124 refresh: RefreshMode = RefreshMode.AUTO,
125 force_refresh: bool = False,
126 now: datetime | None = None,
127 environ: Mapping[str, str] | None = None,
128) -> CatalogCachePaths:
129 """Clone if missing; fetch per refresh policy; stamp only on Git success."""
130 paths = catalog_cache_paths(url, revision, cache_home=cache_home)
131 if not _repo_ready(paths.repo):
132 git_clone(url, paths.repo, environ=environ)
133 write_last_success(paths, now)
134 if revision: 134 ↛ 136line 134 didn't jump to line 136 because the condition on line 134 was always true
135 git_checkout(paths.repo, revision, environ=environ)
136 return paths
138 if should_fetch(paths, refresh, force=force_refresh, now=now):
139 git_fetch(paths.repo, environ=environ)
140 write_last_success(paths, now)
141 if revision: 141 ↛ 143line 141 didn't jump to line 143 because the condition on line 141 was always true
142 git_checkout(paths.repo, revision, environ=environ)
143 return paths
146def _rmtree(path: Path) -> None:
147 def _onerror(func: object, name: str, _exc: object) -> None:
148 os.chmod(name, stat.S_IWRITE)
149 func(name) # type: ignore[operator]
151 shutil.rmtree(path, onerror=_onerror)
154def purge_catalog_cache(
155 url: str,
156 revision: str | None,
157 *,
158 cache_home: Path | None = None,
159) -> CatalogCachePaths:
160 """Remove the cache directory for this catalog identity."""
161 paths = catalog_cache_paths(url, revision, cache_home=cache_home)
162 if paths.root.exists(): 162 ↛ 164line 162 didn't jump to line 164 because the condition on line 162 was always true
163 _rmtree(paths.root)
164 return paths