Coverage for src/ecnl/application/service.py: 100%

69 statements  

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

1"""Application service — the core of the hexagonal architecture. 

2 

3Orchestrates work by delegating to outbound ports. It knows nothing about MCP, 

4HTTP, or JSON — those are adapter concerns. RPI math lives in the pure ``_rpi`` 

5module; this service only extracts results and hands them off. 

6""" 

7 

8import time 

9from collections import OrderedDict 

10from collections.abc import Callable 

11 

12from ..domain.exceptions import ECNLNotFoundError 

13from ..domain.models import ( 

14 Club, 

15 Event, 

16 EventOverview, 

17 Match, 

18 MatchResult, 

19 Standings, 

20 Team, 

21 TeamRPI, 

22) 

23from ..ports.outbound import DiscoveryPort, ECNLAPIPort 

24from ._rpi import compute_rpi 

25 

26_VALID_LEAGUES = ("ECNL", "ECRL") 

27_VALID_GENDERS = ("girls", "boys") 

28 

29 

30def _validate_enum(value: str | None, valid: tuple[str, ...], label: str) -> str | None: 

31 """Normalize and validate an optional enum-like argument. 

32 

33 Returns the normalized value, or None when ``value`` is None. 

34 

35 Raises: 

36 ValueError: If a non-None value is not in ``valid``. 

37 """ 

38 if value is None: 

39 return None 

40 normalized = value.strip().upper() if label == "league" else value.strip().lower() 

41 if normalized not in valid: 

42 raise ValueError(f"Invalid {label}={value!r}. Must be one of: {', '.join(valid)}") 

43 return normalized 

44 

45 

46class ECNLService: 

47 """Coordinates ECNL/ECRL data lookups through the outbound ports. 

48 

49 Two driven ports are injected: ``repo`` (the AthleteOne data feed) and 

50 ``discovery`` (resolves league/gender/season queries to events). 

51 """ 

52 

53 def __init__( 

54 self, 

55 repo: ECNLAPIPort, 

56 discovery: DiscoveryPort, 

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

58 rpi_ttl_seconds: float = 60.0, 

59 rpi_cache_size: int = 32, 

60 ) -> None: 

61 """Initialize the service. 

62 

63 Args: 

64 repo: The AthleteOne data port. 

65 discovery: The event-discovery port. 

66 now: Monotonic clock for the RPI table memo. Injectable for testing. 

67 rpi_ttl_seconds: TTL of a memoized RPI table — matches the schedule 

68 cache lifetime, since the table is a pure transform of the schedule. 

69 rpi_cache_size: Max distinct (event, flight, tie_weight) tables retained. 

70 """ 

71 self._repo = repo 

72 self._discovery = discovery 

73 self._now = now 

74 self._rpi_ttl = rpi_ttl_seconds 

75 self._rpi_cache_size = rpi_cache_size 

76 # LRU + TTL memo so get_rpi and get_team_rpi share one computation per 

77 # (event, flight, tie_weight) instead of rebuilding the table per call. 

78 self._rpi_cache: OrderedDict[tuple[int, int, float], tuple[float, list[TeamRPI]]] = OrderedDict() 

79 

80 # -- discovery & navigation -------------------------------------------- 

81 

82 async def find_events( 

83 self, 

84 league: str | None = None, 

85 gender: str | None = None, 

86 season: str | None = None, 

87 ) -> list[Event]: 

88 """Find ECNL/ECRL events by league, gender, and/or season.""" 

89 league_n = _validate_enum(league, _VALID_LEAGUES, "league") 

90 gender_n = _validate_enum(gender, _VALID_GENDERS, "gender") 

91 return await self._discovery.find_events(league_n, gender_n, season) 

92 

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

94 """Return the division/flight tree and metadata for an event.""" 

95 return await self._repo.get_event_overview(event_id) 

96 

97 # -- standings & schedule ---------------------------------------------- 

98 

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

100 """Return the standings table for a flight.""" 

101 return await self._repo.get_standings(event_id, division_id, flight_id) 

102 

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

104 """Return all matches for a flight.""" 

105 return await self._repo.get_schedule(event_id, flight_id) 

106 

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

108 """Return all matches for a single team within an event.""" 

109 return await self._repo.get_team_schedule(event_id, team_id) 

110 

111 # -- teams, clubs, matches, brackets ----------------------------------- 

112 

113 async def get_teams(self, flight_id: int) -> list[Team]: 

114 """Return the teams competing in a flight.""" 

115 return await self._repo.get_flight_teams(flight_id) 

116 

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

118 """Return the clubs participating in an event.""" 

119 return await self._repo.get_clubs(event_id) 

120 

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

122 """Return raw match-detail data for a match token.""" 

123 return await self._repo.get_match(match_token) 

124 

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

126 """Return raw playoff-bracket data for a flight.""" 

127 return await self._repo.get_brackets(event_id, flight_id) 

128 

129 # -- analytics: results & RPI ------------------------------------------ 

130 

131 async def get_results(self, event_id: int, flight_id: int) -> list[MatchResult]: 

132 """Return completed match results for a flight (played games only). 

133 

134 This is the raw feed RPI builds on, exposed on its own because "what are 

135 the final scores so far?" is a common question. 

136 """ 

137 schedule = await self._repo.get_schedule(event_id, flight_id) 

138 return [ 

139 MatchResult( 

140 home_team=m.home_team, 

141 away_team=m.away_team, 

142 home_score=m.home_score, # type: ignore[arg-type] 

143 away_score=m.away_score, # type: ignore[arg-type] 

144 ) 

145 for m in schedule 

146 if m.is_played 

147 ] 

148 

149 async def get_rpi(self, event_id: int, flight_id: int, tie_weight: float = 1 / 3) -> list[TeamRPI]: 

150 """Return the RPI ranking for every team in a flight. 

151 

152 Args: 

153 event_id: The event the flight belongs to. 

154 flight_id: The flight (conference tier) to rate. 

155 tie_weight: WP tie weight — 1/3 (2024 convention) or 1/2 (pre-2024). 

156 

157 The rating pool is the flight: opponents and their opponents are taken 

158 from the flight's own completed games. The computed table is memoized 

159 (see ``_rpi_table``), so repeat calls within the TTL are free. 

160 """ 

161 return await self._rpi_table(event_id, flight_id, tie_weight) 

162 

163 async def get_team_rpi(self, event_id: int, flight_id: int, team: str, tie_weight: float = 1 / 3) -> TeamRPI: 

164 """Return one team's RPI with its component breakdown. 

165 

166 Reuses the memoized flight table — OWP/OOWP need the whole graph, so the 

167 table is computed once and shared rather than rebuilt per team. 

168 

169 Args: 

170 team: Team name (case-insensitive substring match against the flight's teams). 

171 

172 Raises: 

173 ECNLNotFoundError: If no team in the flight matches ``team``. 

174 """ 

175 table = await self._rpi_table(event_id, flight_id, tie_weight) 

176 needle = team.strip().lower() 

177 for row in table: 

178 if needle in row.team.lower(): 

179 return row 

180 raise ECNLNotFoundError(f"No team matching {team!r} in flight {flight_id}") 

181 

182 async def _rpi_table(self, event_id: int, flight_id: int, tie_weight: float) -> list[TeamRPI]: 

183 """Return the (memoized) RPI table for a flight. 

184 

185 The table is a pure transform of the flight schedule, which the outbound 

186 cache already keeps for the same lifetime; memoizing the transform avoids 

187 recomputing it across get_rpi/get_team_rpi calls. Bounded by an LRU cap 

188 and a TTL, so the cache cannot grow without limit. 

189 """ 

190 key = (event_id, flight_id, tie_weight) 

191 cached = self._rpi_cache.get(key) 

192 if cached is not None and self._now() < cached[0]: 

193 self._rpi_cache.move_to_end(key) 

194 return cached[1] 

195 

196 results = await self.get_results(event_id, flight_id) 

197 table = compute_rpi(results, wp_tie_weight=tie_weight) 

198 self._rpi_cache[key] = (self._now() + self._rpi_ttl, table) 

199 self._rpi_cache.move_to_end(key) 

200 if len(self._rpi_cache) > self._rpi_cache_size: 

201 self._rpi_cache.popitem(last=False) 

202 return table