♻️ refactor(utils/conversion): introduce CurrencyCache for thread-safe background currency updates and caching

This commit is contained in:
sankalp committed 2025-06-24 03:47:15 +05:45
1 parent d50ba1cee3
commit 574f8ed034
4 files changed
+154 -1

No files matched your search

+4
View File
@@ -83,6 +83,10 @@
"ps": {
"icon": "process",
"description": "Process Manager - View and manage running processes"
},
"weather": {
"icon": "cloud",
"description": "Weather - Get current weather information"
}
},
"settings": {
+15 -1
View File
@@ -87,16 +87,20 @@ class CalculatorPlugin(PluginBase):
cache_key = f"{value}_{from_unit}_{to_unit}"
if cache_key in self._conversion_cache:
result = self._conversion_cache[cache_key]
subtitle = f"{value} {from_unit} = {result:.6g} {to_unit}"
else:
# Use the conversion utility
result = self.converter.convert(value, from_unit, to_unit)
# Cache the result
self._conversion_cache[cache_key] = result
subtitle = f"{value} {from_unit} = {result:.6g} {to_unit}"
return [
Result(
title=f"{result:.6g} {to_unit}",
subtitle=f"{value} {from_unit} = {result:.6g} {to_unit}",
subtitle=subtitle,
icon_markup=icons.calculator,
action=lambda r=f"{result:.6g}": self._copy_to_clipboard(r),
relevance=1.0,
@@ -136,6 +140,15 @@ class CalculatorPlugin(PluginBase):
return []
def _format_cache_age(self, age_seconds: float) -> str:
"""Format cache age for display."""
if age_seconds < 60:
return f"{int(age_seconds)}s ago"
elif age_seconds < 3600:
return f"{int(age_seconds // 60)}m ago"
else:
return f"{int(age_seconds // 3600)}h ago"
def _copy_to_clipboard(self, text: str):
"""Copy text to clipboard using cliphist."""
try:
@@ -150,3 +163,4 @@ class CalculatorPlugin(PluginBase):
def cleanup(self):
"""Clean up resources."""
self._conversion_cache.clear()
self.converter.cleanup()
+2
View File
@@ -4,5 +4,7 @@ Contains background services and utilities for the shell.
"""
from .notification import CustomNotifications
from .weather import WeatherService
notification_service = CustomNotifications()
weather_service = WeatherService()
+133
View File
@@ -1,4 +1,105 @@
import requests
import time
import threading
from concurrent.futures import ThreadPoolExecutor
from typing import Dict, Optional, Tuple
class CurrencyCache:
"""Thread-safe currency exchange rate cache with background updates."""
def __init__(self):
self._cache: Dict[str, Dict] = {} # {base_currency: {rates_data, timestamp}}
self._cache_lock = threading.Lock()
self._cache_ttl = 3600 # 1 hour in seconds
self._request_timeout = 2 # 2 seconds for faster response
self._executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="currency")
self._pending_requests: Dict[str, threading.Event] = {}
def get_rate(self, from_code: str, to_code: str) -> Optional[Tuple[float, float]]:
"""
Get exchange rate from cache or fetch if needed.
Returns (rate, cache_age_seconds) or None if unavailable.
"""
from_lower = from_code.lower()
to_lower = to_code.lower()
if from_lower == to_lower:
return 1.0, 0.0
current_time = time.time()
with self._cache_lock:
# Check if we have fresh cached data
if from_lower in self._cache:
cache_entry = self._cache[from_lower]
cache_age = current_time - cache_entry['timestamp']
if cache_age < self._cache_ttl and to_lower in cache_entry['rates']:
rate = cache_entry['rates'][to_lower]['rate']
return rate, cache_age
# Check if we're already fetching this currency
if from_lower in self._pending_requests:
# Don't wait, return cached data if available (even if stale)
if from_lower in self._cache and to_lower in self._cache[from_lower]['rates']:
cache_entry = self._cache[from_lower]
cache_age = current_time - cache_entry['timestamp']
rate = cache_entry['rates'][to_lower]['rate']
return rate, cache_age
return None
# Start background fetch
self._pending_requests[from_lower] = threading.Event()
# Submit background task to fetch rates
self._executor.submit(self._fetch_rates_background, from_lower)
# Return stale cached data if available
with self._cache_lock:
if from_lower in self._cache and to_lower in self._cache[from_lower]['rates']:
cache_entry = self._cache[from_lower]
cache_age = current_time - cache_entry['timestamp']
rate = cache_entry['rates'][to_lower]['rate']
return rate, cache_age
return None
def _fetch_rates_background(self, from_code: str):
"""Fetch exchange rates in background thread."""
try:
url = f"https://www.floatrates.com/daily/{from_code}.json"
response = requests.get(url, timeout=self._request_timeout)
if response.status_code == 200:
rates_data = response.json()
current_time = time.time()
with self._cache_lock:
self._cache[from_code] = {
'rates': rates_data,
'timestamp': current_time
}
except Exception as e:
print(f"Background currency fetch failed for {from_code}: {e}")
finally:
# Mark request as complete
with self._cache_lock:
if from_code in self._pending_requests:
self._pending_requests[from_code].set()
del self._pending_requests[from_code]
def cleanup(self):
"""Cleanup resources."""
self._executor.shutdown(wait=False)
with self._cache_lock:
self._cache.clear()
self._pending_requests.clear()
# Global currency cache instance
_currency_cache = CurrencyCache()
class Units():
@@ -306,6 +407,7 @@ class Units():
class Conversion():
def __init__(self):
self.units = Units()
self.currency_cache = _currency_cache
def convert(self, value: float, from_type: str, to_type: str):
"""
@@ -363,11 +465,26 @@ class Conversion():
# 2) If both are currency codes (e.g. “USD”, “ARS”)
# we assume they are uppercase and have 3 letters.
if len(from_type) == 3 and len(to_type) == 3 and from_type.isalpha() and to_type.isalpha():
result = self._convert_currency_fast(value, from_type, to_type)
if result is not None:
return result
# Fallback to slow method if fast method fails
return self._convert_currency_via_floatrates(value, from_type, to_type)
# 3) If it doesn't fall into any case, error.
raise ValueError(f"Unsupported conversion: {from_type} to {to_type}")
def _convert_currency_fast(self, value: float, from_code: str, to_code: str) -> Optional[float]:
"""
Fast currency conversion using cached exchange rates.
Returns None if rate is not available in cache.
"""
rate_info = self.currency_cache.get_rate(from_code, to_code)
if rate_info is not None:
rate, _ = rate_info # cache_age not needed here
return value * rate
return None
def _convert_currency_via_floatrates(self, value: float, from_code: str, to_code: str) -> float:
"""
Converts using the JSON from floatrates.com:
@@ -440,6 +557,22 @@ class Conversion():
return singular.lower()
return type
def cleanup(self):
"""Cleanup resources."""
self.currency_cache.cleanup()
def get_currency_cache_info(self, from_code: str, to_code: str) -> Optional[Tuple[bool, float]]:
"""
Get currency cache information for UI display.
Returns (is_fresh, cache_age_seconds) or None if not cached.
"""
rate_info = self.currency_cache.get_rate(from_code, to_code)
if rate_info is not None:
_, cache_age = rate_info # rate not needed here
is_fresh = cache_age < 300 # Consider fresh if less than 5 minutes old
return is_fresh, cache_age
return None
# Quick usage example:
if __name__ == "__main__":