diff --git a/agenda/fx.py b/agenda/fx.py index a19425d..9a65338 100644 --- a/agenda/fx.py +++ b/agenda/fx.py @@ -11,6 +11,7 @@ import httpx DEFAULT_FX_CACHE_TTL_HOURS = 24 DEFAULT_FX_FAILURE_RETRY_HOURS = 24 +FRANKFURTER_CACHE_PREFIX = "frankfurter" async def get_gbpusd(config: flask.config.Config) -> Decimal: @@ -57,13 +58,47 @@ def read_cached_rates( with open(filename) as file: data = json.load(file, parse_float=Decimal) - quotes = data.get("quotes") - if not isinstance(quotes, dict): + frankfurter_rates = _frankfurter_rates(data, currencies) + if frankfurter_rates: + return frankfurter_rates + + if isinstance(data, list): return {} - return { - cur: Decimal(quotes[f"GBP{cur}"]) for cur in currencies if f"GBP{cur}" in quotes - } + if not isinstance(data, dict): + return {} + + rates = data.get("rates") + if isinstance(rates, dict): + return {cur: Decimal(rates[cur]) for cur in currencies if cur in rates} + + quotes = data.get("quotes") + if isinstance(quotes, dict): + return { + cur: Decimal(quotes[f"GBP{cur}"]) + for cur in currencies + if f"GBP{cur}" in quotes + } + + return {} + + +def _frankfurter_rates(data: typing.Any, currencies: list[str]) -> dict[str, Decimal]: + """Extract rates from Frankfurter's response format.""" + if not isinstance(data, list): + return {} + + rates: dict[str, Decimal] = {} + for item in data: + if not isinstance(item, dict): + continue + + quote = item.get("quote") + rate = item.get("rate") + if isinstance(quote, str) and quote in currencies and rate is not None: + rates[quote] = Decimal(rate) + + return rates def _fx_cache_datetime(filename: str) -> datetime: @@ -93,8 +128,12 @@ def _latest_file( return max(matching_files) if matching_files else None -def get_rates(config: flask.config.Config) -> dict[str, Decimal]: - """Get current values of exchange rates for a list of currencies against GBP.""" +def get_rates_exchangerate_host(config: flask.config.Config) -> dict[str, Decimal]: + """Get exchange rates from exchangerate.host. + + Kept as a fallback implementation in case we decide to switch back from + Frankfurter. + """ currencies = config["CURRENCIES"] access_key = config["EXCHANGERATE_ACCESS_KEY"] data_dir = config["DATA_DIR"] @@ -160,3 +199,82 @@ def get_rates(config: flask.config.Config) -> dict[str, Decimal]: for cur in currencies if f"GBP{cur}" in data["quotes"] } + + +def get_rates(config: flask.config.Config) -> dict[str, Decimal]: + """Get current values of exchange rates for a list of currencies against GBP.""" + currencies = config["CURRENCIES"] + data_dir = config["DATA_DIR"] + cache_ttl_hours = int(config.get("FX_CACHE_TTL_HOURS", DEFAULT_FX_CACHE_TTL_HOURS)) + failure_retry_hours = int( + config.get("FX_FAILURE_RETRY_HOURS", DEFAULT_FX_FAILURE_RETRY_HOURS) + ) + + now = datetime.now() + now_str = now.strftime("%Y-%m-%d_%H:%M") + fx_dir = os.path.join(data_dir, "fx") + os.makedirs(fx_dir, exist_ok=True) # Ensure the directory exists + + currency_string = ",".join(sorted(currencies)) + legacy_file_suffix = f"{currency_string}_to_GBP.json" + frankfurter_file_suffix = f"{FRANKFURTER_CACHE_PREFIX}_{legacy_file_suffix}" + existing_data = os.listdir(fx_dir) + valid_cache_files = [ + f + for f in existing_data + if f.endswith(legacy_file_suffix) or f.endswith(frankfurter_file_suffix) + ] + attempt_files = [f for f in existing_data if f.endswith(frankfurter_file_suffix)] + + latest_attempt = _latest_file(fx_dir, attempt_files, currencies, valid_only=False) + latest_source_valid = _latest_file( + fx_dir, attempt_files, currencies, valid_only=True + ) + latest_valid = _latest_file(fx_dir, valid_cache_files, currencies, valid_only=True) + latest_valid_path = ( + os.path.join(fx_dir, latest_valid) if latest_valid is not None else None + ) + + if config["OFFLINE_MODE"]: + return read_cached_rates(latest_valid_path, currencies) + + if latest_source_valid is not None: + recent = _fx_cache_datetime(latest_source_valid) + delta = now - recent + + if delta < timedelta(hours=cache_ttl_hours): + return read_cached_rates( + os.path.join(fx_dir, latest_source_valid), currencies + ) + + if latest_attempt is not None: + recent_attempt = _fx_cache_datetime(latest_attempt) + attempt_delta = now - recent_attempt + if attempt_delta < timedelta(hours=failure_retry_hours): + return read_cached_rates(latest_valid_path, currencies) + + url = "https://api.frankfurter.dev/v2/rates" + params = {"base": "GBP", "quotes": currency_string} + + filename = f"{now_str}_{frankfurter_file_suffix}" + try: + with httpx.Client() as client: + response = client.get(url, params=params, timeout=10) + except (httpx.ConnectError, httpx.ReadTimeout): + return read_cached_rates(latest_valid_path, currencies) + + try: + data = json.loads(response.text, parse_float=Decimal) + except json.decoder.JSONDecodeError: + return read_cached_rates(latest_valid_path, currencies) + + frankfurter_rates = _frankfurter_rates(data, currencies) + if not frankfurter_rates: + with open(os.path.join(fx_dir, filename), "w") as file: + file.write(response.text) + return read_cached_rates(latest_valid_path, currencies) + + with open(os.path.join(fx_dir, filename), "w") as file: + file.write(response.text) + + return frankfurter_rates diff --git a/tests/test_fx.py b/tests/test_fx.py index 83b5dfa..8446761 100644 --- a/tests/test_fx.py +++ b/tests/test_fx.py @@ -19,7 +19,7 @@ class TestReadCachedRates: assert result == {} def test_read_cached_rates_valid_file(self) -> None: - """Test reading valid cached rates file.""" + """Test reading valid exchangerate.host cached rates file.""" currencies = ["USD", "EUR", "JPY"] data = { "quotes": { @@ -46,6 +46,37 @@ class TestReadCachedRates: finally: os.unlink(filepath) + def test_read_cached_rates_frankfurter_file(self) -> None: + """Test reading valid Frankfurter cached rates file.""" + currencies = ["USD", "EUR", "JPY"] + data = [ + {"date": "2026-06-20", "base": "GBP", "quote": "USD", "rate": 1.25}, + {"date": "2026-06-20", "base": "GBP", "quote": "EUR", "rate": 1.15}, + {"date": "2026-06-20", "base": "GBP", "quote": "JPY", "rate": 150.0}, + { + "date": "2026-06-20", + "base": "GBP", + "quote": "CAD", + "rate": 1.70, + }, # Not requested + ] + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(data, f) + filepath = f.name + + try: + result = read_cached_rates(filepath, currencies) + + assert len(result) == 3 + assert result["USD"] == Decimal("1.25") + assert result["EUR"] == Decimal("1.15") + assert result["JPY"] == Decimal("150.0") + assert "CAD" not in result # Not requested + + finally: + os.unlink(filepath) + def test_read_cached_rates_missing_currencies(self) -> None: """Test with some currencies missing from data.""" currencies = ["USD", "EUR", "CHF"] # CHF not in data @@ -158,8 +189,8 @@ class TestReadCachedRates: os.unlink(filepath) -def test_get_rates_uses_older_valid_cache_when_latest_attempt_is_usage_limit() -> None: - """A usage-limit API response should not replace usable cached rates.""" +def test_get_rates_ignores_old_usage_limit_attempt_when_using_frankfurter() -> None: + """An old provider usage-limit response should not block Frankfurter refreshes.""" with tempfile.TemporaryDirectory() as temp_dir: fx_dir = os.path.join(temp_dir, "fx") os.mkdir(fx_dir) @@ -200,8 +231,31 @@ def test_get_rates_uses_older_valid_cache_when_latest_attempt_is_usage_limit() - ) as mock_client: mock_datetime.now.return_value = datetime(2026, 6, 20, 12, 35) mock_datetime.strptime.side_effect = datetime.strptime + mock_response = ( + mock_client.return_value.__enter__.return_value.get.return_value + ) + mock_response.text = json.dumps( + [ + { + "date": "2026-06-20", + "base": "GBP", + "quote": "EUR", + "rate": 1.16, + }, + { + "date": "2026-06-20", + "base": "GBP", + "quote": "USD", + "rate": 1.26, + }, + ] + ) result = get_rates(config) - assert result == {"USD": Decimal("1.25"), "EUR": Decimal("1.15")} - mock_client.assert_not_called() + assert result == {"USD": Decimal("1.26"), "EUR": Decimal("1.16")} + mock_client.return_value.__enter__.return_value.get.assert_called_once_with( + "https://api.frankfurter.dev/v2/rates", + params={"base": "GBP", "quotes": "EUR,USD"}, + timeout=10, + )