f1-dash 0.2.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.
f1_dash/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """
2
+ F1 Dashboard - An enhanced F1 Live Position Dashboard with telemetry data.
3
+ """
4
+
5
+ __version__ = "0.2.1"
6
+
7
+ from .main import main, F1Dashboard, AVAILABLE_SEASONS
8
+ from .database_manager import DatabaseManager, get_db_manager
9
+ from .cache_manager import CacheManager, get_cache_manager
10
+
11
+ __all__ = ["main", "F1Dashboard", "AVAILABLE_SEASONS", "DatabaseManager", "get_db_manager", "CacheManager", "get_cache_manager"]
@@ -0,0 +1,403 @@
1
+ """
2
+ Cache Manager Module
3
+
4
+ Implements Redis caching with Cache-Aside pattern for F1 Dashboard.
5
+ Uses aioredis for async compatibility with Textual's async nature.
6
+ """
7
+
8
+ import json
9
+ import logging
10
+ import os
11
+ from typing import Optional, Dict, Any, List, Tuple
12
+ from datetime import datetime
13
+ import asyncio
14
+
15
+ # Use aioredis for async Redis operations
16
+ try:
17
+ import aioredis
18
+ from aioredis import Redis as AsyncRedis
19
+ REDIS_AVAILABLE = True
20
+ except ImportError:
21
+ REDIS_AVAILABLE = False
22
+ AsyncRedis = None
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ # Configuration
27
+ DEFAULT_REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379')
28
+ DEFAULT_TTL = int(os.environ.get('REDIS_TTL', 3600)) # 1 hour default
29
+
30
+
31
+ class CacheManager:
32
+ """
33
+ Manages Redis caching with Cache-Aside pattern for F1 data.
34
+
35
+ Pattern:
36
+ 1. Check Redis for cached data
37
+ 2. If missing, check SQLite database
38
+ 3. If missing, fetch from FastF1 API
39
+ 4. Store fetched data in both SQLite and Redis for future requests
40
+ """
41
+
42
+ def __init__(self, redis_url: Optional[str] = None, ttl: int = DEFAULT_TTL):
43
+ self.redis_url = redis_url or DEFAULT_REDIS_URL
44
+ self.ttl = ttl
45
+ self._redis: Optional[AsyncRedis] = None
46
+ self._connected = False
47
+ self._fallback_to_memory = True
48
+ self._memory_cache: Dict[str, Dict[str, Any]] = {}
49
+
50
+ async def connect(self) -> bool:
51
+ """Initialize Redis connection."""
52
+ if not REDIS_AVAILABLE:
53
+ logger.warning("aioredis not available, using memory cache fallback")
54
+ return False
55
+
56
+ try:
57
+ self._redis = await aioredis.from_url(
58
+ self.redis_url,
59
+ encoding='utf-8',
60
+ decode_responses=True
61
+ )
62
+ # Test connection
63
+ await self._redis.ping()
64
+ self._connected = True
65
+ logger.info(f"Connected to Redis at {self.redis_url}")
66
+ return True
67
+ except Exception as e:
68
+ logger.warning(f"Failed to connect to Redis: {e}. Using memory cache fallback.")
69
+ self._connected = False
70
+ return False
71
+
72
+ async def disconnect(self):
73
+ """Close Redis connection."""
74
+ if self._redis and self._connected:
75
+ await self._redis.close()
76
+ self._connected = False
77
+
78
+ def _generate_session_key(self, year: int, round_number: int, session_type: str) -> str:
79
+ """Generate a unique cache key for a session."""
80
+ return f"f1dash:session:{year}:{round_number}:{session_type}"
81
+
82
+ def _generate_event_key(self, year: int, round_number: int) -> str:
83
+ """Generate a unique cache key for an event's sessions list."""
84
+ return f"f1dash:event:{year}:{round_number}"
85
+
86
+ def _generate_schedule_key(self, year: int) -> str:
87
+ """Generate a cache key for a season's schedule."""
88
+ return f"f1dash:schedule:{year}"
89
+
90
+ async def get_session_data(
91
+ self,
92
+ year: int,
93
+ round_number: int,
94
+ session_type: str,
95
+ db_manager: Optional[Any] = None
96
+ ) -> Optional[Dict[str, Any]]:
97
+ """
98
+ Get session data using Cache-Aside pattern.
99
+
100
+ Order: Redis -> SQLite -> None
101
+ If not in cache, check database. Database misses return None.
102
+
103
+ Args:
104
+ year: Season year
105
+ round_number: Event round number
106
+ session_type: Session key (FP1, FP2, Q, R, etc.)
107
+ db_manager: Optional DatabaseManager instance for SQLite lookup
108
+
109
+ Returns:
110
+ Session data dict with 'data', 'drivers', 'columns' keys, or None
111
+ """
112
+ cache_key = self._generate_session_key(year, round_number, session_type)
113
+ session_id = f"{year}_{round_number}_{session_type}"
114
+
115
+ # 1. Check Redis cache first
116
+ try:
117
+ if self._connected and self._redis:
118
+ cached_data = await self._redis.get(cache_key)
119
+ if cached_data:
120
+ logger.debug(f"Redis cache hit for {cache_key}")
121
+ return json.loads(cached_data)
122
+ except Exception as e:
123
+ logger.warning(f"Redis get error: {e}")
124
+
125
+ # Check memory fallback
126
+ if not self._connected and self._fallback_to_memory:
127
+ if cache_key in self._memory_cache:
128
+ logger.debug(f"Memory cache hit for {cache_key}")
129
+ return self._memory_cache[cache_key]['data']
130
+
131
+ # 2. Check SQLite database if db_manager provided
132
+ if db_manager:
133
+ try:
134
+ db_data = db_manager.get_session_results(session_id)
135
+ if db_data:
136
+ logger.debug(f"Database hit for {session_id}")
137
+ # Cache in Redis for future requests
138
+ await self.set_session_data(year, round_number, session_type, db_data)
139
+ return db_data
140
+ except Exception as e:
141
+ logger.warning(f"Database lookup error: {e}")
142
+
143
+ logger.debug(f"Cache miss for {cache_key}")
144
+ return None
145
+
146
+ async def set_session_data(
147
+ self,
148
+ year: int,
149
+ round_number: int,
150
+ session_type: str,
151
+ data: Dict[str, Any],
152
+ ttl: Optional[int] = None
153
+ ) -> bool:
154
+ """
155
+ Store session data in Redis cache.
156
+
157
+ Args:
158
+ year: Season year
159
+ round_number: Event round number
160
+ session_type: Session key
161
+ data: Session data to cache
162
+ ttl: Optional custom TTL in seconds
163
+
164
+ Returns:
165
+ True if stored successfully, False otherwise
166
+ """
167
+ cache_key = self._generate_session_key(year, round_number, session_type)
168
+ effective_ttl = ttl or self.ttl
169
+
170
+ # Add cache metadata
171
+ data_with_meta = {
172
+ **data,
173
+ '_cached_at': datetime.now().isoformat(),
174
+ '_cache_ttl': effective_ttl
175
+ }
176
+
177
+ try:
178
+ if self._connected and self._redis:
179
+ json_data = json.dumps(data_with_meta)
180
+ await self._redis.setex(cache_key, effective_ttl, json_data)
181
+ logger.debug(f"Cached session data in Redis: {cache_key}")
182
+ return True
183
+ except Exception as e:
184
+ logger.warning(f"Redis set error: {e}")
185
+
186
+ # Fallback to memory cache
187
+ if self._fallback_to_memory:
188
+ self._memory_cache[cache_key] = {
189
+ 'data': data_with_meta,
190
+ 'timestamp': datetime.now().timestamp()
191
+ }
192
+ return True
193
+
194
+ return False
195
+
196
+ async def get_schedule(self, year: int, db_manager: Optional[Any] = None) -> Optional[List[Dict[str, Any]]]:
197
+ """
198
+ Get season schedule from cache.
199
+
200
+ Args:
201
+ year: Season year
202
+ db_manager: Optional DatabaseManager for SQLite fallback
203
+
204
+ Returns:
205
+ List of event dictionaries or None
206
+ """
207
+ cache_key = self._generate_schedule_key(year)
208
+
209
+ # Check Redis
210
+ try:
211
+ if self._connected and self._redis:
212
+ cached_data = await self._redis.get(cache_key)
213
+ if cached_data:
214
+ return json.loads(cached_data)
215
+ except Exception as e:
216
+ logger.warning(f"Redis get error: {e}")
217
+
218
+ # Check memory fallback
219
+ if not self._connected and self._fallback_to_memory:
220
+ if cache_key in self._memory_cache:
221
+ return self._memory_cache[cache_key]['data']
222
+
223
+ # Check database
224
+ if db_manager:
225
+ try:
226
+ db_events = db_manager.get_events_by_year(year)
227
+ if db_events:
228
+ await self.set_schedule(year, db_events)
229
+ return db_events
230
+ except Exception as e:
231
+ logger.warning(f"Database lookup error: {e}")
232
+
233
+ return None
234
+
235
+ async def set_schedule(
236
+ self,
237
+ year: int,
238
+ events: List[Dict[str, Any]],
239
+ ttl: Optional[int] = None
240
+ ) -> bool:
241
+ """Store season schedule in cache."""
242
+ cache_key = self._generate_schedule_key(year)
243
+ effective_ttl = ttl or (self.ttl * 24) # Cache schedules longer (24 hours)
244
+
245
+ try:
246
+ if self._connected and self._redis:
247
+ json_data = json.dumps(events)
248
+ await self._redis.setex(cache_key, effective_ttl, json_data)
249
+ return True
250
+ except Exception as e:
251
+ logger.warning(f"Redis set error: {e}")
252
+
253
+ # Fallback to memory cache
254
+ if self._fallback_to_memory:
255
+ self._memory_cache[cache_key] = {
256
+ 'data': events,
257
+ 'timestamp': datetime.now().timestamp()
258
+ }
259
+ return True
260
+
261
+ return False
262
+
263
+ async def invalidate_session(self, year: int, round_number: int, session_type: str) -> bool:
264
+ """Remove a specific session from cache."""
265
+ cache_key = self._generate_session_key(year, round_number, session_type)
266
+
267
+ try:
268
+ if self._connected and self._redis:
269
+ await self._redis.delete(cache_key)
270
+ return True
271
+ except Exception as e:
272
+ logger.warning(f"Redis delete error: {e}")
273
+
274
+ # Remove from memory cache
275
+ if cache_key in self._memory_cache:
276
+ del self._memory_cache[cache_key]
277
+
278
+ return True
279
+
280
+ async def invalidate_event(self, year: int, round_number: int) -> bool:
281
+ """Remove all sessions for an event from cache."""
282
+ pattern = f"f1dash:session:{year}:{round_number}:*"
283
+
284
+ try:
285
+ if self._connected and self._redis:
286
+ # Find and delete matching keys
287
+ keys = await self._redis.keys(pattern)
288
+ if keys:
289
+ await self._redis.delete(*keys)
290
+ # Also delete the event sessions list
291
+ event_key = self._generate_event_key(year, round_number)
292
+ await self._redis.delete(event_key)
293
+ return True
294
+ except Exception as e:
295
+ logger.warning(f"Redis pattern delete error: {e}")
296
+
297
+ # Clear from memory cache
298
+ keys_to_remove = [k for k in self._memory_cache.keys() if k.startswith(f"f1dash:session:{year}:{round_number}:")]
299
+ for key in keys_to_remove:
300
+ del self._memory_cache[key]
301
+
302
+ event_key = self._generate_event_key(year, round_number)
303
+ if event_key in self._memory_cache:
304
+ del self._memory_cache[event_key]
305
+
306
+ return True
307
+
308
+ async def clear_all_cache(self) -> bool:
309
+ """Clear all F1 dashboard related cache entries."""
310
+ pattern = "f1dash:*"
311
+
312
+ try:
313
+ if self._connected and self._redis:
314
+ keys = await self._redis.keys(pattern)
315
+ if keys:
316
+ await self._redis.delete(*keys)
317
+ logger.info("Cleared all F1 dashboard cache entries")
318
+ return True
319
+ except Exception as e:
320
+ logger.warning(f"Redis clear error: {e}")
321
+
322
+ # Clear memory cache
323
+ self._memory_cache.clear()
324
+ return True
325
+
326
+ async def get_cache_stats(self) -> Dict[str, Any]:
327
+ """Get cache statistics."""
328
+ stats = {
329
+ 'connected': self._connected,
330
+ 'redis_url': self.redis_url if not self._connected else '***',
331
+ 'default_ttl': self.ttl,
332
+ 'memory_cache_entries': len(self._memory_cache)
333
+ }
334
+
335
+ try:
336
+ if self._connected and self._redis:
337
+ # Count F1 dashboard keys
338
+ keys = await self._redis.keys("f1dash:*")
339
+ stats['redis_keys_count'] = len(keys)
340
+
341
+ # Get Redis info
342
+ info = await self._redis.info()
343
+ stats['redis_version'] = info.get('redis_version', 'unknown')
344
+ stats['used_memory_human'] = info.get('used_memory_human', 'unknown')
345
+ except Exception as e:
346
+ stats['error'] = str(e)
347
+
348
+ return stats
349
+
350
+ async def health_check(self) -> Dict[str, Any]:
351
+ """Check cache health and connectivity."""
352
+ result = {
353
+ 'status': 'unknown',
354
+ 'redis_connected': False,
355
+ 'fallback_active': False
356
+ }
357
+
358
+ try:
359
+ if REDIS_AVAILABLE:
360
+ if not self._connected:
361
+ await self.connect()
362
+
363
+ if self._connected and self._redis:
364
+ await self._redis.ping()
365
+ result['status'] = 'healthy'
366
+ result['redis_connected'] = True
367
+ else:
368
+ result['status'] = 'fallback'
369
+ result['fallback_active'] = True
370
+ else:
371
+ result['status'] = 'fallback'
372
+ result['fallback_active'] = True
373
+ result['reason'] = 'aioredis not installed'
374
+ except Exception as e:
375
+ result['status'] = 'error'
376
+ result['error'] = str(e)
377
+ result['fallback_active'] = True
378
+
379
+ return result
380
+
381
+
382
+ # Global instance for singleton access
383
+ cache_manager = CacheManager()
384
+
385
+
386
+ async def get_cache_manager(redis_url: Optional[str] = None, ttl: int = DEFAULT_TTL) -> CacheManager:
387
+ """Get or create a cache manager instance."""
388
+ if redis_url or ttl != DEFAULT_TTL:
389
+ cm = CacheManager(redis_url, ttl)
390
+ await cm.connect()
391
+ return cm
392
+
393
+ if not cache_manager._connected:
394
+ await cache_manager.connect()
395
+ return cache_manager
396
+
397
+
398
+ # Synchronous wrapper for non-async contexts
399
+ def get_cache_manager_sync(redis_url: Optional[str] = None, ttl: int = DEFAULT_TTL) -> CacheManager:
400
+ """Synchronous wrapper to get cache manager."""
401
+ cm = CacheManager(redis_url, ttl)
402
+ # Don't connect here - let the async lifecycle handle it
403
+ return cm