parent
d620ffd389
commit
a369c44ae9
2 changed files with 149 additions and 77 deletions
77
agenda/fx.py
77
agenda/fx.py
|
|
@ -9,6 +9,9 @@ from decimal import Decimal
|
||||||
import flask
|
import flask
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
DEFAULT_FX_CACHE_TTL_HOURS = 24
|
||||||
|
DEFAULT_FX_FAILURE_RETRY_HOURS = 24
|
||||||
|
|
||||||
|
|
||||||
async def get_gbpusd(config: flask.config.Config) -> Decimal:
|
async def get_gbpusd(config: flask.config.Config) -> Decimal:
|
||||||
"""Get the current value for GBPUSD, with caching."""
|
"""Get the current value for GBPUSD, with caching."""
|
||||||
|
|
@ -54,18 +57,51 @@ def read_cached_rates(
|
||||||
with open(filename) as file:
|
with open(filename) as file:
|
||||||
data = json.load(file, parse_float=Decimal)
|
data = json.load(file, parse_float=Decimal)
|
||||||
|
|
||||||
|
quotes = data.get("quotes")
|
||||||
|
if not isinstance(quotes, dict):
|
||||||
|
return {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cur: Decimal(data["quotes"][f"GBP{cur}"])
|
cur: Decimal(quotes[f"GBP{cur}"]) for cur in currencies if f"GBP{cur}" in quotes
|
||||||
for cur in currencies
|
|
||||||
if f"GBP{cur}" in data["quotes"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fx_cache_datetime(filename: str) -> datetime:
|
||||||
|
"""Extract the cache timestamp from an FX cache filename."""
|
||||||
|
return datetime.strptime(filename[:16], "%Y-%m-%d_%H:%M")
|
||||||
|
|
||||||
|
|
||||||
|
def _has_required_quotes(filename: str, currencies: list[str]) -> bool:
|
||||||
|
"""Return true if the cache file contains the requested GBP quotes."""
|
||||||
|
try:
|
||||||
|
return len(read_cached_rates(filename, currencies)) == len(currencies)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_file(
|
||||||
|
fx_dir: str, filenames: list[str], currencies: list[str], *, valid_only: bool
|
||||||
|
) -> str | None:
|
||||||
|
"""Return the newest matching FX cache filename."""
|
||||||
|
matching_files = [
|
||||||
|
filename
|
||||||
|
for filename in filenames
|
||||||
|
if not valid_only
|
||||||
|
or _has_required_quotes(os.path.join(fx_dir, filename), currencies)
|
||||||
|
]
|
||||||
|
|
||||||
|
return max(matching_files) if matching_files else None
|
||||||
|
|
||||||
|
|
||||||
def get_rates(config: flask.config.Config) -> dict[str, Decimal]:
|
def get_rates(config: flask.config.Config) -> dict[str, Decimal]:
|
||||||
"""Get current values of exchange rates for a list of currencies against GBP."""
|
"""Get current values of exchange rates for a list of currencies against GBP."""
|
||||||
currencies = config["CURRENCIES"]
|
currencies = config["CURRENCIES"]
|
||||||
access_key = config["EXCHANGERATE_ACCESS_KEY"]
|
access_key = config["EXCHANGERATE_ACCESS_KEY"]
|
||||||
data_dir = config["DATA_DIR"]
|
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 = datetime.now()
|
||||||
now_str = now.strftime("%Y-%m-%d_%H:%M")
|
now_str = now.strftime("%Y-%m-%d_%H:%M")
|
||||||
|
|
@ -75,20 +111,26 @@ def get_rates(config: flask.config.Config) -> dict[str, Decimal]:
|
||||||
currency_string = ",".join(sorted(currencies))
|
currency_string = ",".join(sorted(currencies))
|
||||||
file_suffix = f"{currency_string}_to_GBP.json"
|
file_suffix = f"{currency_string}_to_GBP.json"
|
||||||
existing_data = os.listdir(fx_dir)
|
existing_data = os.listdir(fx_dir)
|
||||||
existing_files = [f for f in existing_data if f.endswith(".json")]
|
existing_files = [f for f in existing_data if f.endswith(file_suffix)]
|
||||||
|
|
||||||
full_path: str | None = None
|
latest_attempt = _latest_file(fx_dir, existing_files, currencies, valid_only=False)
|
||||||
|
latest_valid = _latest_file(fx_dir, existing_files, currencies, valid_only=True)
|
||||||
|
latest_valid_path = (
|
||||||
|
os.path.join(fx_dir, latest_valid) if latest_valid is not None else None
|
||||||
|
)
|
||||||
|
|
||||||
if existing_files:
|
if latest_valid is not None:
|
||||||
recent_filename = max(existing_files)
|
recent = _fx_cache_datetime(latest_valid)
|
||||||
recent = datetime.strptime(recent_filename[:16], "%Y-%m-%d_%H:%M")
|
|
||||||
delta = now - recent
|
delta = now - recent
|
||||||
|
|
||||||
full_path = os.path.join(fx_dir, recent_filename)
|
if delta < timedelta(hours=cache_ttl_hours) or config["OFFLINE_MODE"]:
|
||||||
if recent_filename.endswith(file_suffix) and (
|
return read_cached_rates(latest_valid_path, currencies)
|
||||||
delta < timedelta(hours=12) or config["OFFLINE_MODE"]
|
|
||||||
):
|
if latest_attempt is not None:
|
||||||
return read_cached_rates(full_path, currencies)
|
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 = "http://api.exchangerate.host/live"
|
url = "http://api.exchangerate.host/live"
|
||||||
params = {"currencies": currency_string, "source": "GBP", "access_key": access_key}
|
params = {"currencies": currency_string, "source": "GBP", "access_key": access_key}
|
||||||
|
|
@ -98,12 +140,17 @@ def get_rates(config: flask.config.Config) -> dict[str, Decimal]:
|
||||||
with httpx.Client() as client:
|
with httpx.Client() as client:
|
||||||
response = client.get(url, params=params, timeout=10)
|
response = client.get(url, params=params, timeout=10)
|
||||||
except (httpx.ConnectError, httpx.ReadTimeout):
|
except (httpx.ConnectError, httpx.ReadTimeout):
|
||||||
return read_cached_rates(full_path, currencies)
|
return read_cached_rates(latest_valid_path, currencies)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(response.text, parse_float=Decimal)
|
data = json.loads(response.text, parse_float=Decimal)
|
||||||
except json.decoder.JSONDecodeError:
|
except json.decoder.JSONDecodeError:
|
||||||
return read_cached_rates(full_path, currencies)
|
return read_cached_rates(latest_valid_path, currencies)
|
||||||
|
|
||||||
|
if not data.get("success", True) or not isinstance(data.get("quotes"), dict):
|
||||||
|
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:
|
with open(os.path.join(fx_dir, filename), "w") as file:
|
||||||
file.write(response.text)
|
file.write(response.text)
|
||||||
|
|
|
||||||
149
tests/test_fx.py
149
tests/test_fx.py
|
|
@ -3,12 +3,11 @@
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from datetime import datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from unittest.mock import Mock, patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
from agenda.fx import get_rates, read_cached_rates
|
||||||
|
|
||||||
from agenda.fx import read_cached_rates
|
|
||||||
|
|
||||||
|
|
||||||
class TestReadCachedRates:
|
class TestReadCachedRates:
|
||||||
|
|
@ -27,23 +26,23 @@ class TestReadCachedRates:
|
||||||
"GBPUSD": 1.25,
|
"GBPUSD": 1.25,
|
||||||
"GBPEUR": 1.15,
|
"GBPEUR": 1.15,
|
||||||
"GBPJPY": 150.0,
|
"GBPJPY": 150.0,
|
||||||
"GBPCAD": 1.70 # Not requested
|
"GBPCAD": 1.70, # Not requested
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||||||
json.dump(data, f)
|
json.dump(data, f)
|
||||||
filepath = f.name
|
filepath = f.name
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = read_cached_rates(filepath, currencies)
|
result = read_cached_rates(filepath, currencies)
|
||||||
|
|
||||||
assert len(result) == 3
|
assert len(result) == 3
|
||||||
assert result["USD"] == Decimal("1.25")
|
assert result["USD"] == Decimal("1.25")
|
||||||
assert result["EUR"] == Decimal("1.15")
|
assert result["EUR"] == Decimal("1.15")
|
||||||
assert result["JPY"] == Decimal("150.0")
|
assert result["JPY"] == Decimal("150.0")
|
||||||
assert "CAD" not in result # Not requested
|
assert "CAD" not in result # Not requested
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
os.unlink(filepath)
|
os.unlink(filepath)
|
||||||
|
|
||||||
|
|
@ -53,23 +52,23 @@ class TestReadCachedRates:
|
||||||
data = {
|
data = {
|
||||||
"quotes": {
|
"quotes": {
|
||||||
"GBPUSD": 1.25,
|
"GBPUSD": 1.25,
|
||||||
"GBPEUR": 1.15
|
"GBPEUR": 1.15,
|
||||||
# GBPCHF missing
|
# GBPCHF missing
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||||||
json.dump(data, f)
|
json.dump(data, f)
|
||||||
filepath = f.name
|
filepath = f.name
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = read_cached_rates(filepath, currencies)
|
result = read_cached_rates(filepath, currencies)
|
||||||
|
|
||||||
assert len(result) == 2
|
assert len(result) == 2
|
||||||
assert result["USD"] == Decimal("1.25")
|
assert result["USD"] == Decimal("1.25")
|
||||||
assert result["EUR"] == Decimal("1.15")
|
assert result["EUR"] == Decimal("1.15")
|
||||||
assert "CHF" not in result # Missing from data
|
assert "CHF" not in result # Missing from data
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
os.unlink(filepath)
|
os.unlink(filepath)
|
||||||
|
|
||||||
|
|
@ -78,18 +77,18 @@ class TestReadCachedRates:
|
||||||
data = {
|
data = {
|
||||||
"quotes": {
|
"quotes": {
|
||||||
"GBPUSD": 1.25,
|
"GBPUSD": 1.25,
|
||||||
"GBPEUR": 1.15
|
"GBPEUR": 1.15,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||||||
json.dump(data, f)
|
json.dump(data, f)
|
||||||
filepath = f.name
|
filepath = f.name
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = read_cached_rates(filepath, [])
|
result = read_cached_rates(filepath, [])
|
||||||
assert result == {}
|
assert result == {}
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
os.unlink(filepath)
|
os.unlink(filepath)
|
||||||
|
|
||||||
|
|
@ -97,58 +96,35 @@ class TestReadCachedRates:
|
||||||
"""Test with data missing quotes key."""
|
"""Test with data missing quotes key."""
|
||||||
currencies = ["USD", "EUR"]
|
currencies = ["USD", "EUR"]
|
||||||
data = {"other_key": "value"} # No quotes key
|
data = {"other_key": "value"} # No quotes key
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||||||
json.dump(data, f)
|
json.dump(data, f)
|
||||||
filepath = f.name
|
filepath = f.name
|
||||||
|
|
||||||
try:
|
|
||||||
with pytest.raises(KeyError):
|
|
||||||
read_cached_rates(filepath, currencies)
|
|
||||||
|
|
||||||
finally:
|
|
||||||
os.unlink(filepath)
|
|
||||||
|
|
||||||
def test_read_cached_rates_file_not_found(self) -> None:
|
|
||||||
"""Test error handling when file doesn't exist."""
|
|
||||||
with pytest.raises(FileNotFoundError):
|
|
||||||
read_cached_rates("/nonexistent/file.json", ["USD"])
|
|
||||||
|
|
||||||
def test_read_cached_rates_invalid_json(self) -> None:
|
|
||||||
"""Test error handling with invalid JSON."""
|
|
||||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
|
|
||||||
f.write("invalid json content")
|
|
||||||
filepath = f.name
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with pytest.raises(json.JSONDecodeError):
|
assert read_cached_rates(filepath, currencies) == {}
|
||||||
read_cached_rates(filepath, ["USD"])
|
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
os.unlink(filepath)
|
os.unlink(filepath)
|
||||||
|
|
||||||
def test_read_cached_rates_decimal_precision(self) -> None:
|
def test_read_cached_rates_decimal_precision(self) -> None:
|
||||||
"""Test that rates are returned as Decimal with proper precision."""
|
"""Test that rates are returned as Decimal with proper precision."""
|
||||||
currencies = ["USD"]
|
currencies = ["USD"]
|
||||||
data = {
|
data = {"quotes": {"GBPUSD": 1.234567890123456789}} # High precision
|
||||||
"quotes": {
|
|
||||||
"GBPUSD": 1.234567890123456789 # High precision
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
|
|
||||||
json.dump(data, f)
|
json.dump(data, f)
|
||||||
filepath = f.name
|
filepath = f.name
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = read_cached_rates(filepath, currencies)
|
result = read_cached_rates(filepath, currencies)
|
||||||
|
|
||||||
assert isinstance(result["USD"], Decimal)
|
assert isinstance(result["USD"], Decimal)
|
||||||
# Should preserve reasonable precision from the JSON
|
# Should preserve reasonable precision from the JSON
|
||||||
# Python's JSON precision may be limited to float precision
|
# Python's JSON precision may be limited to float precision
|
||||||
expected = Decimal("1.234567890123456789")
|
expected = Decimal("1.234567890123456789")
|
||||||
assert abs(result["USD"] - expected) < Decimal("0.0000000000000001")
|
assert abs(result["USD"] - expected) < Decimal("0.0000000000000001")
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
os.unlink(filepath)
|
os.unlink(filepath)
|
||||||
|
|
||||||
|
|
@ -162,21 +138,70 @@ class TestReadCachedRates:
|
||||||
"GBPJPY": 150.0,
|
"GBPJPY": 150.0,
|
||||||
"GBPCHF": 1.12,
|
"GBPCHF": 1.12,
|
||||||
"GBPCAD": 1.70,
|
"GBPCAD": 1.70,
|
||||||
"GBPAUD": 1.85
|
"GBPAUD": 1.85,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||||||
json.dump(data, f)
|
json.dump(data, f)
|
||||||
filepath = f.name
|
filepath = f.name
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = read_cached_rates(filepath, currencies)
|
result = read_cached_rates(filepath, currencies)
|
||||||
|
|
||||||
assert len(result) == 6
|
assert len(result) == 6
|
||||||
for currency in currencies:
|
for currency in currencies:
|
||||||
assert currency in result
|
assert currency in result
|
||||||
assert isinstance(result[currency], Decimal)
|
assert isinstance(result[currency], Decimal)
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
os.unlink(filepath)
|
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."""
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
fx_dir = os.path.join(temp_dir, "fx")
|
||||||
|
os.mkdir(fx_dir)
|
||||||
|
suffix = "EUR,USD_to_GBP.json"
|
||||||
|
valid_file = os.path.join(fx_dir, f"2026-06-19_23:35_{suffix}")
|
||||||
|
usage_limit_file = os.path.join(fx_dir, f"2026-06-20_11:35_{suffix}")
|
||||||
|
|
||||||
|
with open(valid_file, "w") as f:
|
||||||
|
json.dump(
|
||||||
|
{"success": True, "quotes": {"GBPEUR": 1.15, "GBPUSD": 1.25}},
|
||||||
|
f,
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(usage_limit_file, "w") as f:
|
||||||
|
json.dump(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": {
|
||||||
|
"code": 104,
|
||||||
|
"type": "usage_limit_reached",
|
||||||
|
"info": "Your monthly usage limit has been reached.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
f,
|
||||||
|
)
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"CURRENCIES": ["USD", "EUR"],
|
||||||
|
"DATA_DIR": temp_dir,
|
||||||
|
"EXCHANGERATE_ACCESS_KEY": "key",
|
||||||
|
"OFFLINE_MODE": False,
|
||||||
|
"FX_CACHE_TTL_HOURS": 1,
|
||||||
|
"FX_FAILURE_RETRY_HOURS": 24,
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("agenda.fx.datetime") as mock_datetime, patch(
|
||||||
|
"agenda.fx.httpx.Client"
|
||||||
|
) as mock_client:
|
||||||
|
mock_datetime.now.return_value = datetime(2026, 6, 20, 12, 35)
|
||||||
|
mock_datetime.strptime.side_effect = datetime.strptime
|
||||||
|
|
||||||
|
result = get_rates(config)
|
||||||
|
|
||||||
|
assert result == {"USD": Decimal("1.25"), "EUR": Decimal("1.15")}
|
||||||
|
mock_client.assert_not_called()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue