Skip to content

Commit e9ce726

Browse files
authored
feat(cache): implement caching mechanism for repository metadata and content (#68)
feat(cache): implement caching mechanism for repository metadata and content
2 parents d85e821 + e28709a commit e9ce726

3 files changed

Lines changed: 235 additions & 1 deletion

File tree

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
"""
2+
Cache manager for storing and retrieving cached data.
3+
"""
4+
5+
import hashlib
6+
import json
7+
import os
8+
from pathlib import Path
9+
from typing import Optional, Any
10+
from datetime import datetime, timedelta
11+
12+
from ..models import CacheEntry, RepositoryInfo, GitReference
13+
from forklet.infrastructure.logger import logger
14+
15+
16+
class CacheManager:
17+
"""
18+
Manages caching of data to disk with expiration and access tracking.
19+
"""
20+
21+
def __init__(
22+
self, cache_dir: Optional[Path] = None, default_expire_hours: int = 24
23+
):
24+
"""
25+
Initialize the cache manager.
26+
27+
Args:
28+
cache_dir: Directory to store cache files. If None, uses a default directory.
29+
default_expire_hours: Default expiration time in hours for cache entries.
30+
"""
31+
if cache_dir is None:
32+
# Use platform-appropriate cache directory
33+
cache_dir = Path.home() / ".cache" / "forklet"
34+
35+
self.cache_dir = cache_dir
36+
self.cache_dir.mkdir(parents=True, exist_ok=True)
37+
self.default_expire_hours = default_expire_hours
38+
logger.debug(f"Cache manager initialized with directory: {self.cache_dir}")
39+
40+
def _get_cache_path(self, key: str) -> Path:
41+
"""
42+
Get the file path for a cache key.
43+
44+
Args:
45+
key: Cache key
46+
47+
Returns:
48+
Path to the cache file
49+
"""
50+
# Use a hash of the key to avoid filesystem issues with special characters
51+
key_hash = hashlib.sha256(key.encode("utf-8")).hexdigest()
52+
return self.cache_dir / f"{key_hash}.json"
53+
54+
def get(self, key: str) -> Optional[CacheEntry]:
55+
"""
56+
Retrieve a cache entry by key.
57+
58+
Args:
59+
key: Cache key
60+
61+
Returns:
62+
CacheEntry if found and not expired, None otherwise
63+
"""
64+
cache_path = self._get_cache_path(key)
65+
66+
if not cache_path.exists():
67+
return None
68+
69+
try:
70+
with open(cache_path, "r") as f:
71+
data = json.load(f)
72+
73+
# Reconstruct CacheEntry object
74+
entry = CacheEntry(
75+
key=data["key"],
76+
repository=RepositoryInfo(**data["repository"]),
77+
git_ref=GitReference(**data["git_ref"]),
78+
content_hash=data["content_hash"],
79+
created_at=datetime.fromisoformat(data["created_at"]),
80+
expires_at=datetime.fromisoformat(data["expires_at"])
81+
if data["expires_at"]
82+
else None,
83+
access_count=data["access_count"],
84+
last_accessed=datetime.fromisoformat(data["last_accessed"]),
85+
)
86+
87+
# Check if expired
88+
if entry.is_expired:
89+
logger.debug(f"Cache entry expired for key: {key}")
90+
self.delete(key)
91+
return None
92+
93+
# Update access info
94+
entry.touch()
95+
self._update_cache_file(entry)
96+
97+
logger.debug(f"Cache hit for key: {key}")
98+
return entry
99+
100+
except Exception as e:
101+
logger.warning(f"Failed to read cache entry for key {key}: {e}")
102+
# Delete corrupted cache file
103+
self.delete(key)
104+
return None
105+
106+
def set(self, entry: CacheEntry) -> None:
107+
"""
108+
Store a cache entry.
109+
110+
Args:
111+
entry: CacheEntry to store
112+
"""
113+
# Set expiration if not already set
114+
if entry.expires_at is None:
115+
entry.expires_at = datetime.now() + timedelta(
116+
hours=self.default_expire_hours
117+
)
118+
119+
try:
120+
self._update_cache_file(entry)
121+
logger.debug(f"Cached entry for key: {entry.key}")
122+
except Exception as e:
123+
logger.error(f"Failed to write cache entry for key {entry.key}: {e}")
124+
125+
def _update_cache_file(self, entry: CacheEntry) -> None:
126+
"""
127+
Write a cache entry to disk.
128+
129+
Args:
130+
entry: CacheEntry to write
131+
"""
132+
cache_path = self._get_cache_path(entry.key)
133+
134+
# Convert entry to dictionary for JSON serialization
135+
data = {
136+
"key": entry.key,
137+
"repository": {
138+
"owner": entry.repository.owner,
139+
"name": entry.repository.name,
140+
"full_name": entry.repository.full_name,
141+
"url": entry.repository.url,
142+
"default_branch": entry.repository.default_branch,
143+
"repo_type": entry.repository.repo_type.value,
144+
"size": entry.repository.size,
145+
"is_private": entry.repository.is_private,
146+
"is_fork": entry.repository.is_fork,
147+
"created_at": entry.repository.created_at.isoformat(),
148+
"updated_at": entry.repository.updated_at.isoformat(),
149+
"language": entry.repository.language,
150+
"description": entry.repository.description,
151+
"topics": entry.repository.topics,
152+
},
153+
"git_ref": {
154+
"name": entry.git_ref.name,
155+
"ref_type": entry.git_ref.ref_type,
156+
"sha": entry.git_ref.sha,
157+
},
158+
"content_hash": entry.content_hash,
159+
"created_at": entry.created_at.isoformat(),
160+
"expires_at": entry.expires_at.isoformat() if entry.expires_at else None,
161+
"access_count": entry.access_count,
162+
"last_accessed": entry.last_accessed.isoformat(),
163+
}
164+
165+
# Write to a temporary file first, then rename for atomicity
166+
temp_path = cache_path.with_suffix(".json.tmp")
167+
with open(temp_path, "w") as f:
168+
json.dump(data, f, indent=2)
169+
temp_path.replace(cache_path)
170+
171+
def delete(self, key: str) -> bool:
172+
"""
173+
Delete a cache entry.
174+
175+
Args:
176+
key: Cache key
177+
178+
Returns:
179+
True if deleted, False if not found
180+
"""
181+
cache_path = self._get_cache_path(key)
182+
if cache_path.exists():
183+
cache_path.unlink()
184+
logger.debug(f"Deleted cache entry for key: {key}")
185+
return True
186+
return False
187+
188+
def clear(self) -> int:
189+
"""
190+
Clear all cache entries.
191+
192+
Returns:
193+
Number of entries deleted
194+
"""
195+
count = 0
196+
for cache_file in self.cache_dir.glob("*.json"):
197+
try:
198+
cache_file.unlink()
199+
count += 1
200+
except Exception as e:
201+
logger.warning(f"Failed to delete cache file {cache_file}: {e}")
202+
203+
logger.info(f"Cleared {count} cache entries")
204+
return count
205+
206+
def cleanup_expired(self) -> int:
207+
"""
208+
Remove expired cache entries.
209+
210+
Returns:
211+
Number of entries removed
212+
"""
213+
count = 0
214+
for cache_file in self.cache_dir.glob("*.json"):
215+
try:
216+
with open(cache_file, "r") as f:
217+
data = json.load(f)
218+
219+
expires_at = data.get("expires_at")
220+
if expires_at:
221+
expires_dt = datetime.fromisoformat(expires_at)
222+
if datetime.now() > expires_dt:
223+
cache_file.unlink()
224+
count += 1
225+
except Exception as e:
226+
# If we can't read the file, delete it to be safe
227+
logger.warning(f"Failed to read cache file {cache_file}, deleting: {e}")
228+
cache_file.unlink()
229+
count += 1
230+
231+
if count > 0:
232+
logger.info(f"Cleaned up {count} expired cache entries")
233+
return count

forklet/models/download.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ class CacheEntry:
268268
key: str
269269
repository: RepositoryInfo
270270
git_ref: GitReference
271-
content_hash: str
271+
data: Any
272272
created_at: datetime
273273
expires_at: Optional[datetime] = None
274274
access_count: int = 0

forklet/services/github_api.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
RepositoryNotFoundError,
1818
DownloadError,
1919
)
20+
from ..infrastructure.cache_manager import CacheManager
2021
from ..models import RepositoryInfo, GitReference, RepositoryType, GitHubFile
2122
from ..models.constants import USER_AGENT
2223

0 commit comments

Comments
 (0)