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
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-28 08:19 +0000
1"""CachingAdapter — transparent TTL-cache decorator for ECNLAPIPort.
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.
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"""
12import json
13import logging
14import time
15from collections.abc import Callable
16from typing import Any
18from ...domain.models import Club, Event, EventOverview, Match, Standings, Team
19from ...ports.outbound import ECNLAPIPort
21logger = logging.getLogger(__name__)
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)
29class CachingAdapter:
30 """Decorates an ECNLAPIPort with a TTL-based in-memory cache."""
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.
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]] = {}
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]
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
70 async def get_event(self, event_id: int) -> Event:
71 return await self._get_or_fetch("get_event", self._ttl, event_id)
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)
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)
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)
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)
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)
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)
91 async def get_clubs(self, event_id: int) -> list[Club]:
92 return await self._get_or_fetch("get_clubs", self._ttl, event_id)
94 async def get_match(self, match_token: str) -> dict:
95 return await self._get_or_fetch("get_match", self._live_ttl, match_token)
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)
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)