Coverage for src / moai_adk / core / performance / cache_system.py: 0.00%

148 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-11-20 20:52 +0900

1""" 

2Cache System 

3 

4Provides persistent caching capabilities with TTL support for improved performance. 

5""" 

6 

7import json 

8import os 

9import time 

10from typing import Any, Dict, List, Optional 

11 

12 

13class CacheSystem: 

14 """ 

15 A persistent cache system with TTL support. 

16 

17 This class provides file-based caching with support for time-to-live, 

18 multiple operations, persistence across instances, and thread safety. 

19 """ 

20 

21 def __init__(self, cache_dir: Optional[str] = None, auto_cleanup: bool = True): 

22 """ 

23 Initialize the cache system. 

24 

25 Args: 

26 cache_dir: Directory to store cache files. If None, uses default temp directory. 

27 auto_cleanup: Whether to automatically clean up expired files on operations 

28 """ 

29 self.auto_cleanup = auto_cleanup 

30 

31 if cache_dir is None: 

32 import tempfile 

33 

34 self.cache_dir = os.path.join(tempfile.gettempdir(), "moai_adk_cache") 

35 else: 

36 self.cache_dir = cache_dir 

37 

38 # Cache file extension 

39 self.file_extension = ".cache" 

40 

41 # Create cache directory if it doesn't exist 

42 self._ensure_cache_dir() 

43 

44 def _ensure_cache_dir(self) -> None: 

45 """Ensure cache directory exists.""" 

46 try: 

47 os.makedirs(self.cache_dir, exist_ok=True) 

48 except OSError as e: 

49 raise OSError(f"Failed to create cache directory {self.cache_dir}: {e}") 

50 

51 def _validate_key(self, key: str) -> str: 

52 """ 

53 Validate and sanitize cache key. 

54 

55 Args: 

56 key: Raw cache key 

57 

58 Returns: 

59 Sanitized key suitable for filename 

60 """ 

61 if not isinstance(key, str): 

62 raise TypeError("Cache key must be a string") 

63 

64 if not key or key.isspace(): 

65 raise ValueError("Cache key cannot be empty") 

66 

67 # Sanitize key for safe filename usage 

68 safe_key = key.replace("/", "_").replace("\\", "_") 

69 if safe_key != key: 

70 return safe_key 

71 

72 return key 

73 

74 def _get_file_path(self, key: str) -> str: 

75 """Get file path for a given cache key.""" 

76 safe_key = self._validate_key(key) 

77 return os.path.join(self.cache_dir, f"{safe_key}{self.file_extension}") 

78 

79 def _is_expired(self, data: Dict[str, Any]) -> bool: 

80 """Check if cache data is expired.""" 

81 if "expires_at" not in data: 

82 return False 

83 

84 return time.time() > data["expires_at"] 

85 

86 def _cleanup_expired_files(self) -> None: 

87 """Remove expired cache files.""" 

88 time.time() 

89 for file_name in os.listdir(self.cache_dir): 

90 if file_name.endswith(self.file_extension): 

91 file_path = os.path.join(self.cache_dir, file_name) 

92 try: 

93 with open(file_path, "r", encoding="utf-8") as f: 

94 data = json.load(f) 

95 

96 if self._is_expired(data): 

97 os.remove(file_path) 

98 except (json.JSONDecodeError, KeyError, OSError): 

99 # Remove corrupted files too 

100 try: 

101 os.remove(file_path) 

102 except OSError: 

103 pass 

104 

105 def _write_data(self, file_path: str, data: Dict[str, Any]) -> None: 

106 """Write data to file with error handling.""" 

107 try: 

108 with open(file_path, "w", encoding="utf-8") as f: 

109 json.dump(data, f, indent=2, ensure_ascii=False) 

110 except (OSError, TypeError) as e: 

111 raise OSError(f"Failed to write cache file {file_path}: {e}") 

112 

113 def _read_data(self, file_path: str) -> Optional[Dict[str, Any]]: 

114 """Read data from file with error handling.""" 

115 if not os.path.exists(file_path): 

116 return None 

117 

118 try: 

119 with open(file_path, "r", encoding="utf-8") as f: 

120 return json.load(f) 

121 except (json.JSONDecodeError, OSError): 

122 # File is corrupted, remove it 

123 try: 

124 os.remove(file_path) 

125 except OSError: 

126 pass 

127 return None 

128 

129 def set(self, key: str, value: Any, ttl: Optional[float] = None) -> None: 

130 """ 

131 Set a value in the cache. 

132 

133 Args: 

134 key: Cache key 

135 value: Value to cache (must be JSON serializable) 

136 ttl: Time to live in seconds (optional) 

137 

138 Raises: 

139 TypeError: If value is not JSON serializable 

140 OSError: If file operations fail 

141 """ 

142 # Validate JSON serializability 

143 try: 

144 json.dumps(value) 

145 except (TypeError, ValueError) as e: 

146 raise TypeError(f"Cache value must be JSON serializable: {e}") 

147 

148 data = {"value": value, "created_at": time.time()} 

149 

150 if ttl is not None: 

151 if not isinstance(ttl, (int, float)) or ttl < 0: 

152 raise ValueError("TTL must be a positive number") 

153 data["expires_at"] = data["created_at"] + ttl 

154 

155 file_path = self._get_file_path(key) 

156 self._write_data(file_path, data) 

157 

158 # Auto-cleanup if enabled 

159 if self.auto_cleanup: 

160 self._cleanup_expired_files() 

161 

162 def get(self, key: str) -> Optional[Any]: 

163 """ 

164 Get a value from the cache. 

165 

166 Args: 

167 key: Cache key 

168 

169 Returns: 

170 Cached value or None if not found or expired 

171 """ 

172 file_path = self._get_file_path(key) 

173 data = self._read_data(file_path) 

174 

175 if data is None: 

176 return None 

177 

178 # Check expiration 

179 if self._is_expired(data): 

180 try: 

181 os.remove(file_path) 

182 except OSError: 

183 pass 

184 return None 

185 

186 return data["value"] 

187 

188 def delete(self, key: str) -> bool: 

189 """ 

190 Delete a value from the cache. 

191 

192 Args: 

193 key: Cache key 

194 

195 Returns: 

196 True if file was deleted, False if it didn't exist 

197 """ 

198 file_path = self._get_file_path(key) 

199 try: 

200 os.remove(file_path) 

201 return True 

202 except OSError: 

203 return False 

204 

205 def clear(self) -> int: 

206 """ 

207 Clear all values from the cache. 

208 

209 Returns: 

210 Number of files removed 

211 """ 

212 count = 0 

213 for file_name in os.listdir(self.cache_dir): 

214 if file_name.endswith(self.file_extension): 

215 file_path = os.path.join(self.cache_dir, file_name) 

216 try: 

217 os.remove(file_path) 

218 count += 1 

219 except OSError: 

220 continue 

221 return count 

222 

223 def exists(self, key: str) -> bool: 

224 """ 

225 Check if a key exists in the cache. 

226 

227 Args: 

228 key: Cache key 

229 

230 Returns: 

231 True if key exists and is not expired, False otherwise 

232 """ 

233 return self.get(key) is not None 

234 

235 def size(self) -> int: 

236 """ 

237 Get the number of items in the cache. 

238 

239 Returns: 

240 Number of non-expired cache items 

241 """ 

242 count = 0 

243 for file_name in os.listdir(self.cache_dir): 

244 if file_name.endswith(self.file_extension): 

245 os.path.join(self.cache_dir, file_name) 

246 key = file_name[: -len(self.file_extension)] # Remove extension 

247 

248 if self.exists(key): 

249 count += 1 

250 return count 

251 

252 def set_if_not_exists( 

253 self, key: str, value: Any, ttl: Optional[float] = None 

254 ) -> bool: 

255 """ 

256 Set a value only if the key doesn't exist. 

257 

258 Args: 

259 key: Cache key 

260 value: Value to cache 

261 ttl: Time to live in seconds (optional) 

262 

263 Returns: 

264 True if value was set, False if key already existed 

265 """ 

266 if self.exists(key): 

267 return False 

268 

269 self.set(key, value, ttl) 

270 return True 

271 

272 def get_multiple(self, keys: List[str]) -> Dict[str, Optional[Any]]: 

273 """ 

274 Get multiple values from the cache. 

275 

276 Args: 

277 keys: List of cache keys 

278 

279 Returns: 

280 Dictionary mapping keys to values (or None) 

281 """ 

282 if not isinstance(keys, list): 

283 raise TypeError("keys must be a list") 

284 

285 result = {} 

286 for key in keys: 

287 if not isinstance(key, str): 

288 raise TypeError("All keys must be strings") 

289 result[key] = self.get(key) 

290 return result 

291 

292 def get_stats(self) -> Dict[str, Any]: 

293 """ 

294 Get cache statistics. 

295 

296 Returns: 

297 Dictionary with cache statistics 

298 """ 

299 total_files = 0 

300 expired_files = 0 

301 time.time() 

302 

303 for file_name in os.listdir(self.cache_dir): 

304 if file_name.endswith(self.file_extension): 

305 total_files += 1 

306 file_path = os.path.join(self.cache_dir, file_name) 

307 data = self._read_data(file_path) 

308 

309 if data and self._is_expired(data): 

310 expired_files += 1 

311 

312 return { 

313 "total_files": total_files, 

314 "expired_files": expired_files, 

315 "valid_files": total_files - expired_files, 

316 "cache_directory": self.cache_dir, 

317 "auto_cleanup_enabled": self.auto_cleanup, 

318 }