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.
- simplyplural/__init__.py +3 -0
- simplyplural/api_client.py +616 -0
- simplyplural/cache_manager.py +294 -0
- simplyplural/cli.py +1409 -0
- simplyplural/config_manager.py +687 -0
- simplyplural/daemon.py +1042 -0
- simplyplural/daemon_client.py +313 -0
- simplyplural/daemon_protocol.py +219 -0
- simplyplural/shell_integration.py +169 -0
- simplyplural_cli-0.1.0.dist-info/METADATA +599 -0
- simplyplural_cli-0.1.0.dist-info/RECORD +15 -0
- simplyplural_cli-0.1.0.dist-info/WHEEL +5 -0
- simplyplural_cli-0.1.0.dist-info/entry_points.txt +2 -0
- simplyplural_cli-0.1.0.dist-info/licenses/LICENSE +504 -0
- simplyplural_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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')
|