simplyplural-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,294 @@
1
+ """
2
+ Cache Manager for Simply Plural CLI
3
+
4
+ Handles local caching of API responses to improve performance and reduce API calls.
5
+ Uses both memory and file-based caching with configurable TTL.
6
+ """
7
+
8
+ import json
9
+ import time
10
+ import tempfile
11
+ import os
12
+ from pathlib import Path
13
+ from typing import Dict, Any, Optional, List
14
+ from dataclasses import dataclass
15
+
16
+
17
+ @dataclass
18
+ class CacheEntry:
19
+ """Represents a cached item with metadata"""
20
+ data: Any
21
+ timestamp: float
22
+ ttl: int # Time to live in seconds
23
+
24
+ @property
25
+ def is_expired(self) -> bool:
26
+ """Check if this cache entry has expired"""
27
+ return (time.time() - self.timestamp) > self.ttl
28
+
29
+ @property
30
+ def age(self) -> int:
31
+ """Get age of cache entry in seconds"""
32
+ return int(time.time() - self.timestamp)
33
+
34
+
35
+ class CacheManager:
36
+ """Manages local caching for API responses"""
37
+
38
+ def __init__(self, cache_dir: Path, config_manager=None):
39
+ self.cache_dir = Path(cache_dir)
40
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
41
+ self.config = config_manager
42
+
43
+ # In-memory cache for very recent data
44
+ self.memory_cache: Dict[str, CacheEntry] = {}
45
+
46
+ # Default TTL values (in seconds) - use config if available
47
+ if self.config:
48
+ self.default_ttl = {
49
+ 'fronters': self.config.cache_fronters_ttl,
50
+ 'members': self.config.cache_members_ttl,
51
+ 'switches': self.config.cache_switches_ttl,
52
+ 'custom_fronts': self.config.cache_custom_fronts_ttl,
53
+ }
54
+ else:
55
+ self.default_ttl = {
56
+ 'fronters': 300, # 5 minutes
57
+ 'members': 3600, # 1 hour
58
+ 'switches': 1800, # 30 minutes
59
+ 'custom_fronts': 3600, # 1 hour
60
+ }
61
+
62
+ # Memory cache TTL (shorter for responsiveness)
63
+ self.memory_ttl = {
64
+ 'fronters': 300, # 5 minutes - matches file cache for efficiency
65
+ 'members': 300, # 5 minutes
66
+ 'switches': 300, # 5 minutes
67
+ 'custom_fronts': 300, # 5 minutes
68
+ }
69
+
70
+ def _get_cache_file(self, key: str) -> Path:
71
+ """Get the cache file path for a given key"""
72
+ return self.cache_dir / f"{key}.json"
73
+
74
+ def _load_from_file(self, key: str) -> Optional[CacheEntry]:
75
+ """Load cache entry from file"""
76
+ cache_file = self._get_cache_file(key)
77
+
78
+ try:
79
+ if not cache_file.exists():
80
+ return None
81
+
82
+ with open(cache_file, 'r') as f:
83
+ cache_data = json.load(f)
84
+
85
+ return CacheEntry(
86
+ data=cache_data['data'],
87
+ timestamp=cache_data['timestamp'],
88
+ ttl=cache_data.get('ttl', self.default_ttl.get(key, 3600))
89
+ )
90
+
91
+ except (json.JSONDecodeError, KeyError, IOError):
92
+ # If cache file is corrupted, remove it
93
+ try:
94
+ cache_file.unlink()
95
+ except:
96
+ pass
97
+ return None
98
+
99
+ def _save_to_file(self, key: str, entry: CacheEntry):
100
+ """Save cache entry to file atomically"""
101
+ cache_file = self._get_cache_file(key)
102
+
103
+ cache_data = {
104
+ 'data': entry.data,
105
+ 'timestamp': entry.timestamp,
106
+ 'ttl': entry.ttl
107
+ }
108
+
109
+ try:
110
+ # Atomic write using temporary file
111
+ with tempfile.NamedTemporaryFile(
112
+ mode='w',
113
+ dir=self.cache_dir,
114
+ delete=False,
115
+ suffix='.tmp'
116
+ ) as tmp_file:
117
+ json.dump(cache_data, tmp_file, indent=2)
118
+ tmp_file.flush()
119
+ os.fsync(tmp_file.fileno())
120
+ temp_path = tmp_file.name
121
+
122
+ # Replace the original file
123
+ os.replace(temp_path, cache_file)
124
+
125
+ except (IOError, OSError):
126
+ # Clean up temporary file if it exists
127
+ try:
128
+ if 'temp_path' in locals():
129
+ os.unlink(temp_path)
130
+ except:
131
+ pass
132
+
133
+ def get(self, key: str) -> Optional[Any]:
134
+ """Get cached data for a key"""
135
+
136
+ # 1. Check memory cache first (fastest)
137
+ if key in self.memory_cache:
138
+ entry = self.memory_cache[key]
139
+ if not entry.is_expired:
140
+ return entry.data
141
+ else:
142
+ # Remove expired entry
143
+ del self.memory_cache[key]
144
+
145
+ # 2. Check file cache
146
+ entry = self._load_from_file(key)
147
+ if entry and not entry.is_expired:
148
+ # Promote to memory cache if still fresh enough
149
+ if entry.age <= self.memory_ttl.get(key, 300):
150
+ memory_entry = CacheEntry(
151
+ data=entry.data,
152
+ timestamp=time.time(),
153
+ ttl=self.memory_ttl.get(key, 300)
154
+ )
155
+ self.memory_cache[key] = memory_entry
156
+
157
+ return entry.data
158
+
159
+ return None
160
+
161
+ def set(self, key: str, data: Any, ttl: Optional[int] = None):
162
+ """Set cached data for a key"""
163
+ if ttl is None:
164
+ ttl = self.default_ttl.get(key, 3600)
165
+
166
+ timestamp = time.time()
167
+
168
+ # Save to file cache
169
+ file_entry = CacheEntry(data=data, timestamp=timestamp, ttl=ttl)
170
+ self._save_to_file(key, file_entry)
171
+
172
+ # Save to memory cache with shorter TTL
173
+ memory_ttl = min(ttl, self.memory_ttl.get(key, 300))
174
+ memory_entry = CacheEntry(data=data, timestamp=timestamp, ttl=memory_ttl)
175
+ self.memory_cache[key] = memory_entry
176
+
177
+ def invalidate(self, key: str):
178
+ """Invalidate cached data for a key"""
179
+ # Remove from memory cache
180
+ if key in self.memory_cache:
181
+ del self.memory_cache[key]
182
+
183
+ # Remove file cache
184
+ cache_file = self._get_cache_file(key)
185
+ try:
186
+ cache_file.unlink()
187
+ except FileNotFoundError:
188
+ pass
189
+
190
+ def clear_all(self):
191
+ """Clear all cached data"""
192
+ # Clear memory cache
193
+ self.memory_cache.clear()
194
+
195
+ # Clear file cache
196
+ for cache_file in self.cache_dir.glob("*.json"):
197
+ try:
198
+ cache_file.unlink()
199
+ except:
200
+ pass
201
+
202
+ def get_cache_info(self) -> Dict[str, Dict[str, Any]]:
203
+ """Get information about cached items"""
204
+ info = {}
205
+
206
+ # Check all possible cache files
207
+ for cache_file in self.cache_dir.glob("*.json"):
208
+ key = cache_file.stem
209
+ entry = self._load_from_file(key)
210
+
211
+ if entry:
212
+ info[key] = {
213
+ 'age_seconds': entry.age,
214
+ 'ttl_seconds': entry.ttl,
215
+ 'expired': entry.is_expired,
216
+ 'in_memory': key in self.memory_cache,
217
+ 'file_size': cache_file.stat().st_size
218
+ }
219
+
220
+ return info
221
+
222
+ # Convenience methods for specific data types
223
+
224
+ def get_fronters(self) -> Optional[Dict[str, Any]]:
225
+ """Get cached fronters data"""
226
+ return self.get('fronters')
227
+
228
+ def set_fronters(self, data: Dict[str, Any]):
229
+ """Cache fronters data"""
230
+ self.set('fronters', data)
231
+
232
+ def invalidate_fronters(self):
233
+ """Invalidate fronters cache (e.g., after a switch)"""
234
+ self.invalidate('fronters')
235
+
236
+ def get_fronters_timestamp(self) -> Optional[float]:
237
+ """Get timestamp of when fronters were last cached"""
238
+ if 'fronters' in self.memory_cache:
239
+ return self.memory_cache['fronters'].timestamp
240
+
241
+ entry = self._load_from_file('fronters')
242
+ return entry.timestamp if entry else None
243
+
244
+ def get_members(self) -> Optional[List[Dict[str, Any]]]:
245
+ """Get cached members data"""
246
+ return self.get('members')
247
+
248
+ def set_members(self, data: List[Dict[str, Any]]):
249
+ """Cache members data"""
250
+ self.set('members', data)
251
+
252
+ def get_member(self, member_id: str) -> Optional[Dict[str, Any]]:
253
+ """Get cached individual member data"""
254
+ return self.get(f'member_{member_id}')
255
+
256
+ def set_member(self, member_id: str, data: Dict[str, Any]):
257
+ """Cache individual member data"""
258
+ self.set(f'member_{member_id}', data)
259
+
260
+ def get_switches(self, period: str = "recent") -> Optional[List[Dict[str, Any]]]:
261
+ """Get cached switches data"""
262
+ return self.get(f'switches_{period}')
263
+
264
+ def set_switches(self, data: List[Dict[str, Any]], period: str = "recent"):
265
+ """Cache switches data"""
266
+ self.set(f'switches_{period}', data)
267
+
268
+ def invalidate_member(self, member_id: str):
269
+ """Invalidate cached member data"""
270
+ self.invalidate(f'member_{member_id}')
271
+
272
+ def get_custom_fronts(self) -> Optional[List[Dict[str, Any]]]:
273
+ """Get cached custom fronts data"""
274
+ return self.get('custom_fronts')
275
+
276
+ def set_custom_fronts(self, data: List[Dict[str, Any]]):
277
+ """Cache custom fronts data"""
278
+ self.set('custom_fronts', data)
279
+
280
+ def get_custom_front(self, custom_front_id: str) -> Optional[Dict[str, Any]]:
281
+ """Get cached individual custom front data"""
282
+ return self.get(f'custom_front_{custom_front_id}')
283
+
284
+ def set_custom_front(self, custom_front_id: str, data: Dict[str, Any]):
285
+ """Cache individual custom front data"""
286
+ self.set(f'custom_front_{custom_front_id}', data)
287
+
288
+ def invalidate_custom_front(self, custom_front_id: str):
289
+ """Invalidate cached custom front data"""
290
+ self.invalidate(f'custom_front_{custom_front_id}')
291
+
292
+ def invalidate_custom_fronts(self):
293
+ """Invalidate all custom fronts cache"""
294
+ self.invalidate('custom_fronts')