Coverage for .claude/hooks/moai/lib/config_cache.py: 0.00%

91 statements  

« prev     ^ index     » next       coverage.py v7.11.3, created at 2025-11-19 08:00 +0900

1#!/usr/bin/env python3 

2"""Singleton configuration cache for Alfred hooks 

3 

4Provides efficient caching of frequently accessed configuration data 

5with automatic invalidation based on file modification time. 

6 

7Features: 

8- Singleton pattern for global cache state 

9- File mtime-based cache invalidation 

10- Type-safe cache operations 

11- Graceful degradation on read errors 

12""" 

13 

14import json 

15from datetime import datetime, timedelta 

16from pathlib import Path 

17from typing import Any, Optional 

18 

19 

20class ConfigCache: 

21 """Singleton cache for configuration data. 

22 

23 Stores commonly accessed configuration to avoid repeated file I/O. 

24 Automatically invalidates cached data if source file is modified. 

25 

26 Usage: 

27 cache = ConfigCache() 

28 config = cache.get_config() 

29 spec_progress = cache.get_spec_progress() 

30 """ 

31 

32 _instance = None 

33 _cache = {} 

34 _mtimes = {} 

35 

36 def __new__(cls): 

37 if cls._instance is None: 

38 cls._instance = super().__new__(cls) 

39 return cls._instance 

40 

41 @staticmethod 

42 def get_config() -> Optional[dict[str, Any]]: 

43 """Get cached config, or load from file if not cached. 

44 

45 Returns: 

46 Configuration dict, or None if file doesn't exist 

47 """ 

48 config_path = Path.cwd() / ".moai" / "config" / "config.json" 

49 

50 # Check if cache is still valid 

51 if ConfigCache._is_cache_valid("config", config_path): 

52 return ConfigCache._cache.get("config") 

53 

54 # Load from file 

55 try: 

56 if not config_path.exists(): 

57 return None 

58 

59 config = json.loads(config_path.read_text()) 

60 ConfigCache._update_cache("config", config, config_path) 

61 return config 

62 

63 except Exception: 

64 return None 

65 

66 @staticmethod 

67 def get_spec_progress() -> dict[str, Any]: 

68 """Get cached SPEC progress, or compute if not cached. 

69 

70 Returns: 

71 Dict with keys: completed, total, percentage 

72 """ 

73 specs_dir = Path.cwd() / ".moai" / "specs" 

74 

75 # Check if cache is still valid (5 minute TTL) 

76 if ConfigCache._is_cache_valid("spec_progress", specs_dir, ttl_minutes=5): 

77 return ConfigCache._cache.get("spec_progress", {"completed": 0, "total": 0, "percentage": 0}) 

78 

79 # Compute from filesystem 

80 try: 

81 if not specs_dir.exists(): 

82 result = {"completed": 0, "total": 0, "percentage": 0} 

83 ConfigCache._update_cache("spec_progress", result, specs_dir) 

84 return result 

85 

86 spec_folders = [d for d in specs_dir.iterdir() if d.is_dir() and d.name.startswith("SPEC-")] 

87 total = len(spec_folders) 

88 

89 # Simple completion check - look for spec.md files 

90 completed = sum(1 for folder in spec_folders if (folder / "spec.md").exists()) 

91 

92 percentage = (completed / total * 100) if total > 0 else 0 

93 

94 result = { 

95 "completed": completed, 

96 "total": total, 

97 "percentage": round(percentage, 0) 

98 } 

99 

100 ConfigCache._update_cache("spec_progress", result, specs_dir) 

101 return result 

102 

103 except Exception: 

104 return {"completed": 0, "total": 0, "percentage": 0} 

105 

106 @staticmethod 

107 def _is_cache_valid(key: str, file_path: Path, ttl_minutes: int = 30) -> bool: 

108 """Check if cached data is still valid. 

109 

110 Args: 

111 key: Cache key 

112 file_path: Path to check for modifications 

113 ttl_minutes: Time-to-live in minutes 

114 

115 Returns: 

116 True if cache exists and is still valid 

117 """ 

118 if key not in ConfigCache._cache: 

119 return False 

120 

121 if not file_path.exists(): 

122 return False 

123 

124 # Check file modification time 

125 try: 

126 current_mtime = file_path.stat().st_mtime 

127 cached_mtime = ConfigCache._mtimes.get(key) 

128 

129 if cached_mtime is None: 

130 return False 

131 

132 # If file was modified, cache is invalid 

133 if current_mtime != cached_mtime: 

134 return False 

135 

136 # Check TTL 

137 cached_time = ConfigCache._cache.get(f"{key}_timestamp") 

138 if cached_time: 

139 elapsed = datetime.now() - cached_time 

140 if elapsed > timedelta(minutes=ttl_minutes): 

141 return False 

142 

143 return True 

144 

145 except Exception: 

146 return False 

147 

148 @staticmethod 

149 def _update_cache(key: str, data: Any, file_path: Path) -> None: 

150 """Update cache with new data. 

151 

152 Args: 

153 key: Cache key 

154 data: Data to cache 

155 file_path: Path to track for modifications 

156 """ 

157 try: 

158 ConfigCache._cache[key] = data 

159 ConfigCache._cache[f"{key}_timestamp"] = datetime.now() 

160 

161 if file_path.exists(): 

162 ConfigCache._mtimes[key] = file_path.stat().st_mtime 

163 

164 except Exception: 

165 pass # Silently fail on cache update 

166 

167 @staticmethod 

168 def clear() -> None: 

169 """Clear all cached data. 

170 

171 Useful for testing or forcing a refresh. 

172 """ 

173 ConfigCache._cache.clear() 

174 ConfigCache._mtimes.clear() 

175 

176 @staticmethod 

177 def get_cache_size() -> int: 

178 """Get current cache size in bytes. 

179 

180 Returns: 

181 Approximate size of cached data in bytes 

182 """ 

183 import sys 

184 size = sys.getsizeof(ConfigCache._cache) + sys.getsizeof(ConfigCache._mtimes) 

185 for key, value in ConfigCache._cache.items(): 

186 size += sys.getsizeof(value) 

187 return size 

188 

189 

190# Convenience functions for singleton access 

191def get_cached_config() -> Optional[dict[str, Any]]: 

192 """Get cached configuration.""" 

193 return ConfigCache.get_config() 

194 

195 

196def get_cached_spec_progress() -> dict[str, Any]: 

197 """Get cached SPEC progress.""" 

198 return ConfigCache.get_spec_progress() 

199 

200 

201def clear_config_cache() -> None: 

202 """Clear all cached data.""" 

203 ConfigCache.clear()