♻️ refactor(utils/conversion): introduce CurrencyCache for thread-safe background currency updates and caching
This commit is contained in:
4 files changed
+154
-1
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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__":
|
||||
|
||||
Reference in new issue
Block a user