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

69 statements  

« prev     ^ index     » next       coverage.py v7.15.3, created at 2026-08-05 10:11 +0000

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

2 

3This layer orchestrates work by delegating to outbound ports. It knows nothing 

4about MCP, HTTP, or JSON — those are adapter concerns. 

5""" 

6 

7from ..domain.models import ( 

8 AdjustedPointsPerGame, 

9 Match, 

10 MatchDetails, 

11 NewsArticle, 

12 OpponentPPG, 

13 Player, 

14 ResultsByOpponentTier, 

15 Standing, 

16 StrengthOfSchedule, 

17 Team, 

18) 

19from ..ports.outbound import MLSAPIPort 

20from ._analytics_helpers import ( 

21 _build_ppg_index, 

22 _build_tier_record, 

23 _build_tier_specs, 

24 _league_average_ppg, 

25 _mean, 

26 _opponent_ppgs, 

27 _played_opponents, 

28 _resolve_team, 

29 _safe_ratio, 

30 _self_record, 

31 _tally_tier_results, 

32 _validate_team_id, 

33 _validate_tier_size, 

34) 

35from ._helpers import _validate_yyyymmdd 

36 

37 

38class MLSService: 

39 """Coordinates MLS data lookups through the outbound ports. 

40 

41 One driven port is injected: the ESPN-backed ``repo`` (read-only league 

42 feeds). Additional data sources (SDP/Opta, CMS) can be added as separate 

43 ports as they come online. 

44 """ 

45 

46 def __init__(self, repo: MLSAPIPort) -> None: 

47 self._repo = repo 

48 

49 async def get_teams(self) -> list[Team]: 

50 """Return all active MLS teams.""" 

51 return await self._repo.get_teams() 

52 

53 async def get_team(self, team_id: str) -> Team: 

54 """Return a single team by its ESPN team ID. 

55 

56 Args: 

57 team_id: ESPN numeric team ID or team abbreviation. 

58 

59 Raises: 

60 ValueError: If team_id is empty. 

61 MLSNotFoundError: If no team with that ID exists. 

62 """ 

63 if not team_id or not team_id.strip(): 

64 raise ValueError("team_id must not be empty") 

65 return await self._repo.get_team(team_id.strip()) 

66 

67 async def get_scoreboard(self, date: str | None = None, end_date: str | None = None) -> list[Match]: 

68 """Return matches for a date, a date range, or today if date is None. 

69 

70 Args: 

71 date: Optional date string in YYYYMMDD format (e.g. "20260601"). 

72 end_date: Optional end of a range in YYYYMMDD format. Requires ``date`` 

73 to be provided as the start of the range. 

74 

75 Raises: 

76 ValueError: If either argument is malformed, or if end_date is given 

77 without a starting date. 

78 """ 

79 if end_date is not None and date is None: 

80 raise ValueError("end_date requires a starting date") 

81 if date is not None: 

82 date = _validate_yyyymmdd(date.strip(), "date") 

83 if end_date is not None: 

84 end_date = _validate_yyyymmdd(end_date.strip(), "end_date") 

85 return await self._repo.get_scoreboard(date, end_date) 

86 

87 async def get_news(self, limit: int = 10) -> list[NewsArticle]: 

88 """Return up to ``limit`` recent MLS news articles. 

89 

90 Args: 

91 limit: Maximum number of articles to return (must be positive). 

92 

93 Raises: 

94 ValueError: If limit is not a positive integer. 

95 """ 

96 if limit <= 0: 

97 raise ValueError("limit must be positive") 

98 return await self._repo.get_news(limit) 

99 

100 async def get_roster(self, team_id: str) -> list[Player]: 

101 """Return the active roster for a team. 

102 

103 Args: 

104 team_id: ESPN numeric team ID. 

105 

106 Raises: 

107 ValueError: If team_id is empty. 

108 MLSNotFoundError: If no team with that ID exists. 

109 """ 

110 if not team_id or not team_id.strip(): 

111 raise ValueError("team_id must not be empty") 

112 return await self._repo.get_roster(team_id.strip()) 

113 

114 async def get_match_details(self, match_id: str) -> MatchDetails: 

115 """Return detailed information for a single match. 

116 

117 Args: 

118 match_id: ESPN numeric event ID. 

119 

120 Raises: 

121 ValueError: If match_id is empty. 

122 MLSNotFoundError: If no match with that ID exists. 

123 """ 

124 if not match_id or not match_id.strip(): 

125 raise ValueError("match_id must not be empty") 

126 return await self._repo.get_match_details(match_id.strip()) 

127 

128 async def get_team_schedule(self, team_id: str) -> list[Match]: 

129 """Return all scheduled and completed matches for a team in the current season. 

130 

131 Args: 

132 team_id: ESPN numeric team ID. 

133 

134 Raises: 

135 ValueError: If team_id is empty. 

136 MLSNotFoundError: If no team with that ID exists. 

137 """ 

138 if not team_id or not team_id.strip(): 

139 raise ValueError("team_id must not be empty") 

140 return await self._repo.get_team_schedule(team_id.strip()) 

141 

142 async def get_standings(self) -> list[Standing]: 

143 """Return the current MLS standings ordered by points descending. 

144 

145 Each row carries a ``conference`` label so callers can group Eastern vs 

146 Western. The list itself is flat across both conferences, sorted by 

147 overall points — Supporters' Shield ordering. 

148 """ 

149 return await self._repo.get_standings() 

150 

151 async def get_strength_of_schedule(self, team_id: str) -> StrengthOfSchedule: 

152 """Return the average current PPG of opponents this team has faced. 

153 

154 Walks the team's completed matches and aggregates each opponent's 

155 league-table points-per-game (no self-exclusion). Useful for "who has 

156 played the tougher schedule so far?" questions. 

157 

158 Args: 

159 team_id: ESPN numeric team ID. 

160 

161 Raises: 

162 ValueError: If team_id is empty. 

163 MLSNotFoundError: If no team with that ID exists. 

164 """ 

165 team_id = _validate_team_id(team_id) 

166 standings = await self._repo.get_standings() 

167 schedule = await self._repo.get_team_schedule(team_id) 

168 ppg_index = _build_ppg_index(standings) 

169 team = _resolve_team(standings, schedule, team_id) 

170 opponents = [ 

171 OpponentPPG( 

172 team=opp, 

173 matches_played=ppg_index[opp.id].matches_played, 

174 points=ppg_index[opp.id].points, 

175 points_per_game=ppg_index[opp.id].ppg, 

176 ) 

177 for opp in _played_opponents(schedule, team_id) 

178 if opp.id in ppg_index 

179 ] 

180 return StrengthOfSchedule( 

181 team=team, 

182 matches_played=len(opponents), 

183 opponents=opponents, 

184 average_opponent_ppg=_mean([o.points_per_game for o in opponents]), 

185 ) 

186 

187 async def get_results_by_opponent_tier(self, team_id: str, tier_size: int = 5) -> ResultsByOpponentTier: 

188 """Return W-L-T splits against current top-tier, middle, and bottom-tier teams. 

189 

190 Tiers are derived from the current league standings: the top ``tier_size``, 

191 the bottom ``tier_size``, and everyone in between. Draws (no declared winner) 

192 count as ties; matches against teams not in the current standings are 

193 skipped. 

194 

195 Args: 

196 team_id: ESPN numeric team ID. 

197 tier_size: Number of teams in each of the top and bottom tiers. 

198 Defaults to 5. Must be at least 1, and 2*tier_size must not 

199 exceed the league size. 

200 

201 Raises: 

202 ValueError: If team_id is empty or tier_size is invalid. 

203 MLSNotFoundError: If no team with that ID exists. 

204 """ 

205 team_id = _validate_team_id(team_id) 

206 standings = await self._repo.get_standings() 

207 _validate_tier_size(tier_size, len(standings)) 

208 schedule = await self._repo.get_team_schedule(team_id) 

209 rank_by_id = {s.team.id: i + 1 for i, s in enumerate(standings)} 

210 team = _resolve_team(standings, schedule, team_id) 

211 tier_specs = _build_tier_specs(tier_size, len(standings)) 

212 tally = _tally_tier_results(schedule, team_id, rank_by_id, tier_specs) 

213 tiers = [_build_tier_record(name, low, high, tally) for name, low, high in tier_specs if high >= low] 

214 return ResultsByOpponentTier(team=team, tier_size=tier_size, tiers=tiers) 

215 

216 async def get_adjusted_points_per_game(self, team_id: str) -> AdjustedPointsPerGame: 

217 """Return raw PPG plus a schedule-strength-adjusted PPG. 

218 

219 Adjusted PPG = raw_ppg * (avg_opponent_ppg / league_average_ppg). Values 

220 above raw_ppg mean the team has earned points against a tougher 

221 schedule than league average. 

222 

223 Args: 

224 team_id: ESPN numeric team ID. 

225 

226 Raises: 

227 ValueError: If team_id is empty. 

228 MLSNotFoundError: If no team with that ID exists. 

229 """ 

230 team_id = _validate_team_id(team_id) 

231 standings = await self._repo.get_standings() 

232 schedule = await self._repo.get_team_schedule(team_id) 

233 ppg_index = _build_ppg_index(standings) 

234 team = _resolve_team(standings, schedule, team_id) 

235 matches_played, points, raw_ppg = _self_record(ppg_index, team_id) 

236 opp_entries = _opponent_ppgs(schedule, team_id, ppg_index) 

237 avg_opp_ppg = _mean([e.ppg for e in opp_entries]) 

238 league_avg = _league_average_ppg(standings) 

239 return AdjustedPointsPerGame( 

240 team=team, 

241 matches_played=matches_played, 

242 points=points, 

243 raw_ppg=raw_ppg, 

244 average_opponent_ppg=avg_opp_ppg, 

245 league_average_ppg=league_avg, 

246 adjusted_ppg=raw_ppg * _safe_ratio(avg_opp_ppg, league_avg), 

247 )