Coverage for src/usls/adapters/outbound/parsers.py: 91%

69 statements  

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

1"""Pure functions that map ESPN JSON wire format into domain models. 

2 

3Extracted from espn_adapter.py so the adapter focuses on HTTP/transport concerns 

4while parsing stays a side-effect-free, easily testable concern. 

5""" 

6 

7from typing import Any 

8 

9from ...domain.exceptions import USLSNotFoundError 

10from ...domain.models import Match, MatchCompetitor, MatchDetails, MatchEvent, NewsArticle, Player, Standing, Team 

11 

12 

13def _parse_team(raw: dict[str, Any]) -> Team: 

14 """Map a raw ESPN team object to a domain Team. 

15 

16 Args: 

17 raw: The team dict from the ESPN API (may be nested under "team" key). 

18 """ 

19 team = raw.get("team", raw) 

20 logos = team.get("logos", []) 

21 logo_url = logos[0].get("href") if logos else None 

22 return Team( 

23 id=str(team.get("id", "")), 

24 name=team.get("name", ""), 

25 abbreviation=team.get("abbreviation", ""), 

26 location=team.get("location", ""), 

27 display_name=team.get("displayName", ""), 

28 logo_url=logo_url, 

29 ) 

30 

31 

32def _extract_score(raw_score: object) -> str | None: 

33 """Pull a clean score string out of either ESPN serialization shape. 

34 

35 The scoreboard endpoint returns ``score`` as a primitive (e.g. ``2``), but the 

36 team-schedule endpoint returns it as a $ref dict like 

37 ``{'$ref': '...', 'value': 2.0, 'displayValue': '2', 'winner': False, ...}``. 

38 We prefer ``displayValue``, fall back to ``value``, and stringify primitives. 

39 """ 

40 if raw_score is None: 

41 return None 

42 if isinstance(raw_score, dict): 

43 display = raw_score.get("displayValue") 

44 if display is not None: 

45 return str(display) 

46 value = raw_score.get("value") 

47 return str(int(value)) if isinstance(value, int | float) else None 

48 return str(raw_score) 

49 

50 

51def _extract_winner(raw_score: object, raw_winner: object) -> bool | None: 

52 """Pull ``winner`` from the competitor, falling back to the embedded score dict.""" 

53 if raw_winner is not None: 

54 return bool(raw_winner) 

55 if isinstance(raw_score, dict) and raw_score.get("winner") is not None: 

56 return bool(raw_score.get("winner")) 

57 return None 

58 

59 

60def _parse_competitor(raw: dict[str, Any]) -> MatchCompetitor: 

61 """Map a raw ESPN competitor object to a domain MatchCompetitor.""" 

62 score = raw.get("score") 

63 return MatchCompetitor( 

64 team=_parse_team(raw), 

65 home_away=raw.get("homeAway", ""), 

66 score=_extract_score(score), 

67 winner=_extract_winner(score, raw.get("winner")), 

68 ) 

69 

70 

71def _parse_match(event: dict[str, Any]) -> Match: 

72 """Map a raw ESPN scoreboard event to a domain Match.""" 

73 competition = event.get("competitions", [{}])[0] 

74 status = competition.get("status", {}) 

75 status_type = status.get("type", {}) 

76 competitors = [_parse_competitor(c) for c in competition.get("competitors", [])] 

77 return Match( 

78 id=str(event.get("id", "")), 

79 date=event.get("date", ""), 

80 name=event.get("name", ""), 

81 short_name=event.get("shortName", ""), 

82 status_type=status_type.get("state", ""), 

83 status_detail=status.get("displayClock", status_type.get("description", "")), 

84 competitors=competitors, 

85 ) 

86 

87 

88def _parse_key_event(raw: dict[str, Any]) -> MatchEvent: 

89 """Map a raw ESPN keyEvents entry to a domain MatchEvent.""" 

90 team = raw.get("team") or {} 

91 return MatchEvent( 

92 clock=raw.get("clock", {}).get("displayValue", ""), 

93 period=int(raw.get("period", {}).get("number", 0)), 

94 type=raw.get("type", {}).get("type", ""), 

95 scoring=bool(raw.get("scoringPlay", False)), 

96 text=raw.get("text"), 

97 team_name=team.get("displayName"), 

98 ) 

99 

100 

101def _competitor_by_side(competitors: list[dict[str, Any]], side: str) -> dict[str, Any]: 

102 """Find the home or away competitor in a competitors list.""" 

103 return next((c for c in competitors if c.get("homeAway") == side), {}) 

104 

105 

106def _competitor_team_name(competitor: dict[str, Any]) -> str: 

107 """Pull the displayName off a competitor's nested team dict.""" 

108 return (competitor.get("team") or {}).get("displayName", "") 

109 

110 

111def _extract_status_detail(status: dict[str, Any]) -> str: 

112 """Pick a human-readable status string, preferring description over clock.""" 

113 return status.get("type", {}).get("description", status.get("displayClock", "")) 

114 

115 

116def _extract_venue(game_info: dict[str, Any]) -> tuple[str | None, str | None]: 

117 """Return ``(venue_full_name, venue_city)`` from a gameInfo block.""" 

118 venue = game_info.get("venue") or {} 

119 return venue.get("fullName"), (venue.get("address") or {}).get("city") 

120 

121 

122def _parse_match_details(data: dict[str, Any]) -> MatchDetails: 

123 """Map a raw ESPN summary response to a domain MatchDetails. 

124 

125 Raises: 

126 USLSNotFoundError: If the response lacks a ``header.competitions[0]`` block. 

127 """ 

128 header = data.get("header") or {} 

129 competitions = header.get("competitions") or [] 

130 if not competitions: 

131 raise USLSNotFoundError(f"Match not found: {header.get('id', '?')}") 

132 

133 competition = competitions[0] 

134 competitors = competition.get("competitors", []) 

135 home = _competitor_by_side(competitors, "home") 

136 away = _competitor_by_side(competitors, "away") 

137 game_info = data.get("gameInfo") or {} 

138 venue_name, venue_city = _extract_venue(game_info) 

139 

140 return MatchDetails( 

141 id=str(header.get("id", "")), 

142 date=competition.get("date", ""), 

143 status_detail=_extract_status_detail(competition.get("status", {})), 

144 home_team=_competitor_team_name(home), 

145 away_team=_competitor_team_name(away), 

146 home_score=home.get("score"), 

147 away_score=away.get("score"), 

148 venue=venue_name, 

149 venue_city=venue_city, 

150 attendance=game_info.get("attendance"), 

151 key_events=[_parse_key_event(e) for e in data.get("keyEvents", [])], 

152 ) 

153 

154 

155def _parse_player(raw: dict[str, Any]) -> Player: 

156 """Map a raw ESPN roster athlete entry to a domain Player.""" 

157 position = raw.get("position") or {} 

158 return Player( 

159 id=str(raw.get("id", "")), 

160 full_name=raw.get("fullName", ""), 

161 jersey=raw.get("jersey"), 

162 position=position.get("displayName"), 

163 position_abbr=position.get("abbreviation"), 

164 citizenship=raw.get("citizenship"), 

165 age=raw.get("age"), 

166 ) 

167 

168 

169def _parse_article(raw: dict[str, Any]) -> NewsArticle: 

170 """Map a raw ESPN news article entry to a domain NewsArticle.""" 

171 web_link = (raw.get("links") or {}).get("web") or {} 

172 return NewsArticle( 

173 id=str(raw.get("id", "")), 

174 headline=raw.get("headline", ""), 

175 description=raw.get("description", ""), 

176 published=raw.get("published", ""), 

177 link=web_link.get("href"), 

178 ) 

179 

180 

181def _parse_standing(entry: dict[str, Any]) -> Standing | None: 

182 """Map a raw ESPN standings entry to a domain Standing. 

183 

184 Returns None for entries that lack the required team data. 

185 """ 

186 team_raw = entry.get("team") 

187 if not team_raw: 

188 return None 

189 

190 stats: dict[str, Any] = {s["name"]: s.get("value", 0) for s in entry.get("stats", [])} 

191 return Standing( 

192 team=_parse_team(team_raw), 

193 wins=int(stats.get("wins", 0)), 

194 losses=int(stats.get("losses", 0)), 

195 ties=int(stats.get("ties", 0)), 

196 points=int(stats.get("points", 0)), 

197 goals_for=int(stats.get("pointsFor", 0)), 

198 goals_against=int(stats.get("pointsAgainst", 0)), 

199 goal_difference=int(stats.get("pointDifferential", 0)), 

200 )