Implement caching system for API data with proper permission handling and atomic operations

This commit is contained in:
ChuckBuilds
2025-04-18 19:56:43 -05:00
parent 170db5809a
commit 090f89b781
4 changed files with 316 additions and 69 deletions

166
src/cache_manager.py Normal file
View File

@@ -0,0 +1,166 @@
import json
import os
import time
from datetime import datetime
import pytz
from typing import Any, Dict, Optional
import logging
import stat
class CacheManager:
def __init__(self, cache_dir: str = "cache"):
self.cache_dir = cache_dir
self._ensure_cache_dir()
self.logger = logging.getLogger(__name__)
def _ensure_cache_dir(self) -> None:
"""Ensure the cache directory exists with proper permissions."""
if not os.path.exists(self.cache_dir):
try:
# Create directory with 755 permissions (rwxr-xr-x)
os.makedirs(self.cache_dir, mode=0o755, exist_ok=True)
# If running as sudo, change ownership to the real user
if os.geteuid() == 0: # Check if running as root
import pwd
# Get the real user (not root)
real_user = os.environ.get('SUDO_USER')
if real_user:
uid = pwd.getpwnam(real_user).pw_uid
gid = pwd.getpwnam(real_user).pw_gid
os.chown(self.cache_dir, uid, gid)
self.logger.info(f"Changed cache directory ownership to {real_user}")
except Exception as e:
self.logger.error(f"Error setting up cache directory: {e}")
# Fall back to current directory if we can't create the cache directory
self.cache_dir = "."
def _get_cache_path(self, data_type: str) -> str:
"""Get the path for a specific cache file."""
return os.path.join(self.cache_dir, f"{data_type}_cache.json")
def load_cache(self, data_type: str) -> Optional[Dict[str, Any]]:
"""Load cached data for a specific type."""
cache_path = self._get_cache_path(data_type)
try:
if os.path.exists(cache_path):
with open(cache_path, 'r') as f:
return json.load(f)
except Exception as e:
self.logger.error(f"Error loading cache for {data_type}: {e}")
return None
def save_cache(self, data_type: str, data: Dict[str, Any]) -> bool:
"""Save data to cache with proper permissions."""
cache_path = self._get_cache_path(data_type)
try:
# Create a temporary file first
temp_path = cache_path + '.tmp'
with open(temp_path, 'w') as f:
json.dump(data, f)
# Set proper permissions (644 - rw-r--r--)
os.chmod(temp_path, 0o644)
# If running as sudo, change ownership to the real user
if os.geteuid() == 0: # Check if running as root
import pwd
real_user = os.environ.get('SUDO_USER')
if real_user:
uid = pwd.getpwnam(real_user).pw_uid
gid = pwd.getpwnam(real_user).pw_gid
os.chown(temp_path, uid, gid)
# Rename temp file to actual cache file (atomic operation)
os.replace(temp_path, cache_path)
return True
except Exception as e:
self.logger.error(f"Error saving cache for {data_type}: {e}")
# Clean up temp file if it exists
if os.path.exists(temp_path):
try:
os.remove(temp_path)
except:
pass
return False
def has_data_changed(self, data_type: str, new_data: Dict[str, Any]) -> bool:
"""Check if data has changed from cached version."""
cached_data = self.load_cache(data_type)
if not cached_data:
return True
if data_type == 'weather':
return self._has_weather_changed(cached_data, new_data)
elif data_type == 'stocks':
return self._has_stocks_changed(cached_data, new_data)
elif data_type == 'stock_news':
return self._has_news_changed(cached_data, new_data)
elif data_type == 'nhl':
return self._has_nhl_changed(cached_data, new_data)
return True
def _has_weather_changed(self, cached: Dict[str, Any], new: Dict[str, Any]) -> bool:
"""Check if weather data has changed."""
return (cached.get('temp') != new.get('temp') or
cached.get('condition') != new.get('condition'))
def _has_stocks_changed(self, cached: Dict[str, Any], new: Dict[str, Any]) -> bool:
"""Check if stock data has changed."""
if not self._is_market_open():
return False
return cached.get('price') != new.get('price')
def _has_news_changed(self, cached: Dict[str, Any], new: Dict[str, Any]) -> bool:
"""Check if news data has changed."""
cached_headlines = set(h.get('id') for h in cached.get('headlines', []))
new_headlines = set(h.get('id') for h in new.get('headlines', []))
return not cached_headlines.issuperset(new_headlines)
def _has_nhl_changed(self, cached: Dict[str, Any], new: Dict[str, Any]) -> bool:
"""Check if NHL data has changed."""
return (cached.get('game_status') != new.get('game_status') or
cached.get('score') != new.get('score'))
def _is_market_open(self) -> bool:
"""Check if the US stock market is currently open."""
et_tz = pytz.timezone('America/New_York')
now = datetime.now(et_tz)
# Check if it's a weekday
if now.weekday() >= 5: # 5 = Saturday, 6 = Sunday
return False
# Convert current time to ET
current_time = now.time()
market_open = datetime.strptime('09:30', '%H:%M').time()
market_close = datetime.strptime('16:00', '%H:%M').time()
return market_open <= current_time <= market_close
def update_cache(self, data_type: str, data: Dict[str, Any]) -> bool:
"""Update cache with new data."""
cache_data = {
'data': data,
'timestamp': time.time()
}
return self.save_cache(data_type, cache_data)
def get_cached_data(self, data_type: str) -> Optional[Dict[str, Any]]:
"""Get cached data if it exists and is still valid."""
cached = self.load_cache(data_type)
if not cached:
return None
# Check if cache is still valid based on data type
if data_type == 'weather' and time.time() - cached['timestamp'] > 600: # 10 minutes
return None
elif data_type == 'stocks' and time.time() - cached['timestamp'] > 300: # 5 minutes
return None
elif data_type == 'stock_news' and time.time() - cached['timestamp'] > 800: # ~13 minutes
return None
elif data_type == 'nhl' and time.time() - cached['timestamp'] > 60: # 1 minute
return None
return cached['data']

View File

@@ -11,6 +11,7 @@ import re
from PIL import Image, ImageDraw, ImageFont
import numpy as np
import hashlib
from .cache_manager import CacheManager
# Configure logging
logging.basicConfig(level=logging.INFO)
@@ -27,6 +28,7 @@ class StockManager:
self.scroll_position = 0
self.cached_text_image = None
self.cached_text = None
self.cache_manager = CacheManager()
# Get scroll settings from config with faster defaults
self.scroll_speed = self.stocks_config.get('scroll_speed', 1)
@@ -125,6 +127,12 @@ class StockManager:
def _fetch_stock_data(self, symbol: str) -> Dict[str, Any]:
"""Fetch stock data from Yahoo Finance public API."""
# Try to get cached data first
cached_data = self.cache_manager.get_cached_data('stocks')
if cached_data and symbol in cached_data:
logger.info(f"Using cached data for {symbol}")
return cached_data[symbol]
try:
# Use Yahoo Finance query1 API for chart data
encoded_symbol = urllib.parse.quote(symbol)
@@ -174,7 +182,7 @@ class StockManager:
logger.debug(f"Processed data for {symbol}: price={current_price}, change={change_pct}%")
return {
stock_data = {
"symbol": symbol,
"name": name,
"price": current_price,
@@ -183,8 +191,20 @@ class StockManager:
"price_history": price_history
}
# Cache the new data
if cached_data is None:
cached_data = {}
cached_data[symbol] = stock_data
self.cache_manager.update_cache('stocks', cached_data)
return stock_data
except requests.exceptions.RequestException as e:
logger.error(f"Network error fetching data for {symbol}: {e}")
# Try to use cached data as fallback
if cached_data and symbol in cached_data:
logger.info(f"Using cached data as fallback for {symbol}")
return cached_data[symbol]
return None
except (ValueError, IndexError, KeyError) as e:
logger.error(f"Error parsing data for {symbol}: {e}")
@@ -264,47 +284,42 @@ class StockManager:
logger.info(f"Stock symbols changed. New symbols: {new_symbols}")
def update_stock_data(self):
"""Update stock data if enough time has passed."""
"""Update stock data for all configured symbols."""
current_time = time.time()
update_interval = self.stocks_config.get('update_interval', 60)
update_interval = self.stocks_config.get('update_interval', 300)
# If not enough time has passed, keep using existing data
if current_time - self.last_update < update_interval + random.uniform(0, 2):
return
# Check if we need to update based on time
if current_time - self.last_update > update_interval:
symbols = self.stocks_config.get('symbols', [])
if not symbols:
logger.warning("No stock symbols configured")
return
# Reload config to check for symbol changes
self._reload_config()
# Get cached data
cached_data = self.cache_manager.get_cached_data('stocks')
# Get symbols from config
symbols = self.stocks_config.get('symbols', [])
if not symbols:
logger.warning("No stock symbols configured")
return
# If symbols is a list of strings, convert to list of dicts
if isinstance(symbols[0], str):
symbols = [{"symbol": symbol} for symbol in symbols]
# Create temporary storage for new data
new_data = {}
success = False
for stock in symbols:
symbol = stock['symbol']
# Add a small delay between requests to avoid rate limiting
time.sleep(random.uniform(0.1, 0.3)) # Reduced delay
data = self._fetch_stock_data(symbol)
if data:
new_data[symbol] = data
success = True
logger.info(f"Updated {symbol}: ${data['price']:.2f} ({data['change']:+.2f}%)")
if success:
# Only update the displayed data when we have new data
self.stock_data.update(new_data)
# Check if market is open
if cached_data and not self.cache_manager._is_market_open():
logger.info("Market is closed, using cached data")
self.stock_data = cached_data
self.last_update = current_time
return
# Update each symbol
for symbol in symbols:
# Check if data has changed before fetching
if cached_data and symbol in cached_data:
current_state = cached_data[symbol]
if not self.cache_manager.has_data_changed('stocks', current_state):
logger.info(f"Stock data hasn't changed for {symbol}, using existing data")
self.stock_data[symbol] = current_state
continue
data = self._fetch_stock_data(symbol)
if data:
self.stock_data[symbol] = data
self.last_update = current_time
else:
logger.error("Failed to fetch data for any configured stocks")
def _get_stock_logo(self, symbol: str) -> Image.Image:
"""Get stock logo image from local ticker icons directory.

View File

@@ -10,6 +10,7 @@ import urllib.parse
import re
from src.config_manager import ConfigManager
from PIL import Image, ImageDraw
from .cache_manager import CacheManager
# Configure logging
logging.basicConfig(level=logging.INFO)
@@ -28,7 +29,7 @@ class StockNewsManager:
self.scroll_position = 0
self.cached_text_image = None # Cache for the text image
self.cached_text = None # Cache for the text string
self.cache_manager = CacheManager()
# Get scroll settings from config with faster defaults
self.scroll_speed = self.stock_news_config.get('scroll_speed', 1)
@@ -51,6 +52,12 @@ class StockNewsManager:
def _fetch_news_for_symbol(self, symbol: str) -> List[Dict[str, Any]]:
"""Fetch news headlines for a stock symbol."""
# Try to get cached data first
cached_data = self.cache_manager.get_cached_data('stock_news')
if cached_data and symbol in cached_data:
logger.info(f"Using cached news data for {symbol}")
return cached_data[symbol]
try:
# Using Yahoo Finance API to get news
encoded_symbol = urllib.parse.quote(symbol)
@@ -75,10 +82,21 @@ class StockNewsManager:
})
logger.info(f"Fetched {len(formatted_news)} news items for {symbol}")
# Cache the new data
if cached_data is None:
cached_data = {}
cached_data[symbol] = formatted_news
self.cache_manager.update_cache('stock_news', cached_data)
return formatted_news
except requests.exceptions.RequestException as e:
logger.error(f"Network error fetching news for {symbol}: {e}")
# Try to use cached data as fallback
if cached_data and symbol in cached_data:
logger.info(f"Using cached news data as fallback for {symbol}")
return cached_data[symbol]
return []
except (ValueError, IndexError, KeyError) as e:
logger.error(f"Error parsing news data for {symbol}: {e}")
@@ -90,37 +108,46 @@ class StockNewsManager:
def update_news_data(self):
"""Update news data for all configured stock symbols."""
current_time = time.time()
update_interval = self.stock_news_config.get('update_interval', 300) # Default to 5 minutes
update_interval = self.stock_news_config.get('update_interval', 300)
# If not enough time has passed, keep using existing data
if current_time - self.last_update < update_interval:
return
# Check if we need to update based on time
if current_time - self.last_update > update_interval:
symbols = self.stocks_config.get('symbols', [])
if not symbols:
logger.warning("No stock symbols configured for news")
return
# Get cached data
cached_data = self.cache_manager.get_cached_data('stock_news')
# Get symbols from config
symbols = self.stocks_config.get('symbols', [])
if not symbols:
logger.warning("No stock symbols configured for news")
return
# Update each symbol
new_data = {}
success = False
# Create temporary storage for new data
new_data = {}
success = False
for symbol in symbols:
# Add a small delay between requests to avoid rate limiting
time.sleep(random.uniform(0.1, 0.3))
news_items = self._fetch_news_for_symbol(symbol)
if news_items:
new_data[symbol] = news_items
success = True
if success:
# Only update the displayed data when we have new data
self.news_data = new_data
self.last_update = current_time
logger.info(f"Updated news data for {len(new_data)} symbols")
else:
logger.error("Failed to fetch news for any configured stocks")
for symbol in symbols:
# Check if data has changed before fetching
if cached_data and symbol in cached_data:
current_state = cached_data[symbol]
if not self.cache_manager.has_data_changed('stock_news', current_state):
logger.info(f"News data hasn't changed for {symbol}, using existing data")
new_data[symbol] = current_state
success = True
continue
# Add a small delay between requests to avoid rate limiting
time.sleep(random.uniform(0.1, 0.3))
news_items = self._fetch_news_for_symbol(symbol)
if news_items:
new_data[symbol] = news_items
success = True
if success:
# Only update the displayed data when we have new data
self.news_data = new_data
self.last_update = current_time
logger.info(f"Updated news data for {len(new_data)} symbols")
else:
logger.error("Failed to fetch news for any configured stocks")
def _create_text_image(self, text: str, color: Tuple[int, int, int] = (255, 255, 255)) -> Image.Image:
"""Create an image containing the text for efficient scrolling."""

View File

@@ -4,6 +4,7 @@ from datetime import datetime
from typing import Dict, Any, List
from PIL import Image, ImageDraw
from .weather_icons import WeatherIcons
from .cache_manager import CacheManager
class WeatherManager:
# Weather condition to larger colored icons (we'll use these as placeholders until you provide custom ones)
@@ -36,6 +37,7 @@ class WeatherManager:
self.hourly_forecast = None
self.daily_forecast = None
self.last_draw_time = 0
self.cache_manager = CacheManager()
# Layout constants
self.PADDING = 1
self.ICON_SIZE = {
@@ -65,6 +67,17 @@ class WeatherManager:
print("No API key configured for weather")
return
# Try to get cached data first
cached_data = self.cache_manager.get_cached_data('weather')
if cached_data:
self.weather_data = cached_data.get('current')
self.forecast_data = cached_data.get('forecast')
if self.weather_data and self.forecast_data:
self._process_forecast_data(self.forecast_data)
self.last_update = time.time()
print("Using cached weather data")
return
city = self.location['city']
state = self.location['state']
country = self.location['country']
@@ -103,12 +116,27 @@ class WeatherManager:
# Process forecast data
self._process_forecast_data(self.forecast_data)
# Cache the new data
cache_data = {
'current': self.weather_data,
'forecast': self.forecast_data
}
self.cache_manager.update_cache('weather', cache_data)
self.last_update = time.time()
print("Weather data updated successfully")
except Exception as e:
print(f"Error fetching weather data: {e}")
self.weather_data = None
self.forecast_data = None
# If we have cached data, use it as fallback
if cached_data:
self.weather_data = cached_data.get('current')
self.forecast_data = cached_data.get('forecast')
if self.weather_data and self.forecast_data:
self._process_forecast_data(self.forecast_data)
print("Using cached weather data as fallback")
else:
self.weather_data = None
self.forecast_data = None
def _process_forecast_data(self, forecast_data: Dict[str, Any]) -> None:
"""Process forecast data into hourly and daily forecasts."""
@@ -170,9 +198,20 @@ class WeatherManager:
def get_weather(self) -> Dict[str, Any]:
"""Get current weather data, fetching new data if needed."""
current_time = time.time()
update_interval = self.weather_config.get('update_interval', 300)
# Check if we need to update based on time or if we have no data
if (not self.weather_data or
current_time - self.last_update > self.weather_config.get('update_interval', 300)):
current_time - self.last_update > update_interval):
# Check if data has changed before fetching
current_state = self._get_weather_state()
if current_state and not self.cache_manager.has_data_changed('weather', current_state):
print("Weather data hasn't changed, using existing data")
return self.weather_data
self._fetch_weather()
return self.weather_data
def _get_weather_state(self) -> Dict[str, Any]: