Coverage for src/ecnl/adapters/outbound/caching_adapter.py: 83%

53 statements  

« prev     ^ index     » next       coverage.py v7.14.2, created at 2026-06-28 08:19 +0000

1"""CachingAdapter — transparent TTL-cache decorator for ECNLAPIPort. 

2 

3Wraps any ECNLAPIPort implementation and caches successful results in a plain 

4dict keyed by method name plus arguments. The clock is injectable so tests can 

5control expiry without sleeping. 

6 

7Two TTLs: event structure (divisions, flights, teams, clubs, org events) changes 

8rarely and uses the long TTL; standings and schedules move during match days and 

9use a short TTL. 

10""" 

11 

12import json 

13import logging 

14import time 

15from collections.abc import Callable 

16from typing import Any 

17 

18from ...domain.models import Club, Event, EventOverview, Match, Standings, Team 

19from ...ports.outbound import ECNLAPIPort 

20 

21logger = logging.getLogger(__name__) 

22 

23 

24def _cache_key(method: str, args: tuple[Any, ...]) -> str: 

25 """Build a deterministic cache key from a method name and its arguments.""" 

26 return json.dumps({"method": method, "args": args}, sort_keys=True, default=str) 

27 

28 

29class CachingAdapter: 

30 """Decorates an ECNLAPIPort with a TTL-based in-memory cache.""" 

31 

32 def __init__( 

33 self, 

34 inner: ECNLAPIPort, 

35 ttl_seconds: float = 300.0, 

36 live_ttl_seconds: float = 60.0, 

37 now: Callable[[], float] = time.monotonic, 

38 ) -> None: 

39 """Initialize the caching adapter. 

40 

41 Args: 

42 inner: The ECNLAPIPort implementation to wrap. 

43 ttl_seconds: Default TTL for stable structural data. 

44 live_ttl_seconds: Shorter TTL for standings/schedules (live during matches). 

45 now: Callable returning the current monotonic time. Injectable for testing. 

46 """ 

47 self._inner = inner 

48 self._ttl = ttl_seconds 

49 self._live_ttl = live_ttl_seconds 

50 self._now = now 

51 self._cache: dict[str, tuple[float, Any]] = {} 

52 

53 async def _get_or_fetch(self, method_name: str, ttl: float, *args: Any) -> Any: 

54 """Return a cached result or fetch from the inner adapter and cache it.""" 

55 key = _cache_key(method_name, args) 

56 entry = self._cache.get(key) 

57 if entry is not None: 

58 expiry, result = entry 

59 if self._now() < expiry: 

60 logger.debug("Cache hit for %s", method_name) 

61 return result 

62 del self._cache[key] 

63 

64 logger.debug("Cache miss for %s, fetching from inner adapter", method_name) 

65 method = getattr(self._inner, method_name) 

66 result = await method(*args) 

67 self._cache[key] = (self._now() + ttl, result) 

68 return result 

69 

70 async def get_event(self, event_id: int) -> Event: 

71 return await self._get_or_fetch("get_event", self._ttl, event_id) 

72 

73 async def get_event_overview(self, event_id: int) -> EventOverview: 

74 return await self._get_or_fetch("get_event_overview", self._ttl, event_id) 

75 

76 async def get_standings(self, event_id: int, division_id: int, flight_id: int) -> Standings: 

77 return await self._get_or_fetch("get_standings", self._live_ttl, event_id, division_id, flight_id) 

78 

79 async def get_schedule(self, event_id: int, flight_id: int) -> list[Match]: 

80 return await self._get_or_fetch("get_schedule", self._live_ttl, event_id, flight_id) 

81 

82 async def get_team_schedule(self, event_id: int, team_id: int) -> list[Match]: 

83 return await self._get_or_fetch("get_team_schedule", self._live_ttl, event_id, team_id) 

84 

85 async def get_flight_teams(self, flight_id: int) -> list[Team]: 

86 return await self._get_or_fetch("get_flight_teams", self._ttl, flight_id) 

87 

88 async def get_event_teams(self, event_id: int) -> list[Team]: 

89 return await self._get_or_fetch("get_event_teams", self._ttl, event_id) 

90 

91 async def get_clubs(self, event_id: int) -> list[Club]: 

92 return await self._get_or_fetch("get_clubs", self._ttl, event_id) 

93 

94 async def get_match(self, match_token: str) -> dict: 

95 return await self._get_or_fetch("get_match", self._live_ttl, match_token) 

96 

97 async def get_brackets(self, event_id: int, flight_id: int) -> dict: 

98 return await self._get_or_fetch("get_brackets", self._live_ttl, event_id, flight_id) 

99 

100 async def get_org_club_events(self, org_id: int) -> list[int]: 

101 return await self._get_or_fetch("get_org_club_events", self._ttl, org_id)