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

60 statements  

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

1#!/usr/bin/env python3 

2"""Version information cache with TTL support 

3 

4TTL-based caching system for version check results to minimize network calls 

5during SessionStart hook execution. 

6 

7SPEC: SPEC-UPDATE-ENHANCE-001 - SessionStart version check system enhancement 

8Phase 1: Cache System Implementation 

9""" 

10 

11import json 

12from datetime import datetime, timezone 

13from pathlib import Path 

14from typing import Any 

15 

16 

17class VersionCache: 

18 """TTL-based version information cache 

19 

20 Caches version check results with configurable Time-To-Live (TTL) 

21 to avoid excessive network calls to PyPI during SessionStart events. 

22 

23 Attributes: 

24 cache_dir: Directory to store cache file 

25 ttl_hours: Time-to-live in hours (default 24) 

26 cache_file: Path to the cache JSON file 

27 

28 Examples: 

29 >>> cache = VersionCache(Path(".moai/cache"), ttl_hours=24) 

30 >>> cache.save({"current_version": "0.8.1", "latest_version": "0.9.0"}) 

31 True 

32 >>> cache.is_valid() 

33 True 

34 >>> data = cache.load() 

35 >>> data["current_version"] 

36 '0.8.1' 

37 """ 

38 

39 def __init__(self, cache_dir: Path, ttl_hours: int = 4): 

40 """Initialize cache with TTL in hours 

41 

42 Args: 

43 cache_dir: Directory where cache file will be stored 

44 ttl_hours: Time-to-live in hours (default 4) 

45 """ 

46 self.cache_dir = Path(cache_dir) 

47 self.ttl_hours = ttl_hours 

48 self.cache_file = self.cache_dir / "version-check.json" 

49 

50 def _calculate_age_hours(self, last_check_iso: str) -> float: 

51 """Calculate age in hours from ISO timestamp (internal helper) 

52 

53 Normalizes timezone-aware and naive datetimes for consistent comparison. 

54 

55 Args: 

56 last_check_iso: ISO format timestamp string 

57 

58 Returns: 

59 Age in hours 

60 

61 Raises: 

62 ValueError: If timestamp parsing fails 

63 """ 

64 last_check = datetime.fromisoformat(last_check_iso) 

65 

66 # Normalize to naive datetime (remove timezone for comparison) 

67 if last_check.tzinfo is not None: 

68 last_check = last_check.replace(tzinfo=None) 

69 

70 now = datetime.now() 

71 return (now - last_check).total_seconds() / 3600 

72 

73 def is_valid(self) -> bool: 

74 """Check if cache exists and is not expired 

75 

76 Returns: 

77 True if cache file exists and is within TTL, False otherwise 

78 

79 Examples: 

80 >>> cache = VersionCache(Path(".moai/cache")) 

81 >>> cache.is_valid() 

82 False # No cache file exists yet 

83 """ 

84 if not self.cache_file.exists(): 

85 return False 

86 

87 try: 

88 with open(self.cache_file, "r") as f: 

89 data = json.load(f) 

90 

91 age_hours = self._calculate_age_hours(data["last_check"]) 

92 return age_hours < self.ttl_hours 

93 

94 except (json.JSONDecodeError, KeyError, ValueError, OSError): 

95 # Corrupted or invalid cache file 

96 return False 

97 

98 def load(self) -> dict[str, Any] | None: 

99 """Load cached version info if valid 

100 

101 Returns: 

102 Cached version info dictionary if valid, None otherwise 

103 

104 Examples: 

105 >>> cache = VersionCache(Path(".moai/cache")) 

106 >>> data = cache.load() 

107 >>> data is None 

108 True # No valid cache exists 

109 """ 

110 if not self.is_valid(): 

111 return None 

112 

113 try: 

114 with open(self.cache_file, "r") as f: 

115 return json.load(f) 

116 except (json.JSONDecodeError, OSError): 

117 # Graceful degradation on read errors 

118 return None 

119 

120 def save(self, version_info: dict[str, Any]) -> bool: 

121 """Save version info to cache file 

122 

123 Creates cache directory if it doesn't exist. 

124 Updates last_check timestamp to current time if not provided. 

125 

126 Args: 

127 version_info: Version information dictionary to cache 

128 

129 Returns: 

130 True on successful save, False on error 

131 

132 Examples: 

133 >>> cache = VersionCache(Path(".moai/cache")) 

134 >>> cache.save({"current_version": "0.8.1"}) 

135 True 

136 """ 

137 try: 

138 # Create cache directory if it doesn't exist 

139 self.cache_dir.mkdir(parents=True, exist_ok=True) 

140 

141 # Update last_check timestamp only if not provided (for testing) 

142 if "last_check" not in version_info: 

143 version_info["last_check"] = datetime.now(timezone.utc).isoformat() 

144 

145 # Write to cache file 

146 with open(self.cache_file, "w") as f: 

147 json.dump(version_info, f, indent=2) 

148 

149 return True 

150 

151 except (OSError, TypeError): 

152 # Graceful degradation on write errors 

153 return False 

154 

155 def clear(self) -> bool: 

156 """Clear/remove cache file 

157 

158 Returns: 

159 True if cache file was removed or didn't exist, False on error 

160 

161 Examples: 

162 >>> cache = VersionCache(Path(".moai/cache")) 

163 >>> cache.clear() 

164 True 

165 """ 

166 try: 

167 if self.cache_file.exists(): 

168 self.cache_file.unlink() 

169 return True 

170 except OSError: 

171 return False 

172 

173 def get_age_hours(self) -> float: 

174 """Get age of cache in hours 

175 

176 Returns: 

177 Age in hours, or 0.0 if cache doesn't exist or is invalid 

178 

179 Examples: 

180 >>> cache = VersionCache(Path(".moai/cache")) 

181 >>> cache.get_age_hours() 

182 0.0 # No cache exists 

183 """ 

184 if not self.cache_file.exists(): 

185 return 0.0 

186 

187 try: 

188 with open(self.cache_file, "r") as f: 

189 data = json.load(f) 

190 

191 return self._calculate_age_hours(data["last_check"]) 

192 

193 except (json.JSONDecodeError, KeyError, ValueError, OSError): 

194 return 0.0 

195 

196 

197__all__ = ["VersionCache"]