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 +11 -0
- f1_dash/cache_manager.py +403 -0
- f1_dash/database_manager.py +397 -0
- f1_dash/main.py +1088 -0
- f1_dash-0.2.0.dist-info/METADATA +143 -0
- f1_dash-0.2.0.dist-info/RECORD +9 -0
- f1_dash-0.2.0.dist-info/WHEEL +5 -0
- f1_dash-0.2.0.dist-info/entry_points.txt +2 -0
- f1_dash-0.2.0.dist-info/top_level.txt +1 -0
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"]
|
f1_dash/cache_manager.py
ADDED
|
@@ -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
|