Coverage for packages / ingest_client / client.py: 29%

52 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-08 08:14 +1200

1"""Client for tab-api-ingest service. 

2 

3Provides async HTTP client for consuming racing data from the tab-api-ingest 

4REST API instead of calling the TAB Affiliates API directly. 

5""" 

6 

7from __future__ import annotations 

8 

9from typing import Any 

10 

11import httpx 

12 

13from packages.core.common.logging import get_logger 

14from packages.core.common.settings import get_settings 

15 

16logger = get_logger(__name__) 

17 

18 

19class IngestServiceClient: 

20 """Async HTTP client for tab-api-ingest REST API. 

21 

22 Fetches meetings, races, and runner data from the tab-api-ingest service 

23 which sources data from the TAB/HRNZ APIs and stores it in its own DB. 

24 

25 Example: 

26 >>> async with IngestServiceClient() as client: 

27 >>> meetings = await client.get_meetings(date="2024-01-01") 

28 >>> for meeting in meetings: 

29 >>> races = await client.get_races(meeting_id=meeting["meeting"]) 

30 """ 

31 

32 def __init__(self, base_url: str | None = None): 

33 """Initialize the ingest service client. 

34 

35 Args: 

36 base_url: tab-api-ingest service base URL. Defaults to the 

37 INGEST_SERVICE_URL setting or http://localhost:9090. 

38 """ 

39 settings = get_settings() 

40 self.base_url = ( 

41 base_url 

42 or getattr(settings, "ingest_service", None) 

43 and settings.ingest_service.url 

44 or "http://localhost:9090" 

45 ) 

46 self._client: httpx.AsyncClient | None = None 

47 

48 async def __aenter__(self): 

49 """Async context manager entry — creates the HTTP client.""" 

50 self._client = httpx.AsyncClient(timeout=30.0) 

51 return self 

52 

53 async def __aexit__(self, exc_type, exc_val, exc_tb): 

54 """Async context manager exit — closes the HTTP client.""" 

55 if self._client is not None: 

56 await self._client.aclose() 

57 self._client = None 

58 

59 async def _request( 

60 self, 

61 method: str, 

62 url: str, 

63 params: dict[str, Any] | None = None, 

64 ) -> Any: 

65 """Make an HTTP request with the internal client. 

66 

67 Args: 

68 method: HTTP method 

69 url: Full URL 

70 params: Query parameters 

71 

72 Returns: 

73 Response JSON data 

74 

75 Raises: 

76 httpx.HTTPError: On request failure 

77 """ 

78 if self._client is None: 

79 self._client = httpx.AsyncClient(timeout=30.0) 

80 response = await self._client.request(method, url, params=params) 

81 response.raise_for_status() 

82 return response.json() 

83 

84 async def get_meetings( 

85 self, 

86 date: str | None = None, 

87 country: str | None = None, 

88 category: str | None = None, 

89 ) -> list[dict[str, Any]]: 

90 """Fetch meetings from the ingest service. 

91 

92 Args: 

93 date: Optional ISO date filter (YYYY-MM-DD) 

94 country: Optional country code filter (e.g. NZ, AUS) 

95 category: Optional racing category (T, H, G) 

96 

97 Returns: 

98 List of meeting data dictionaries 

99 """ 

100 params: dict[str, Any] = {} 

101 if date: 

102 params["date"] = date 

103 if country: 

104 params["country"] = country 

105 if category: 

106 params["category"] = category 

107 

108 logger.info( 

109 "Fetching meetings from ingest service", 

110 extra={"params": params}, 

111 ) 

112 

113 data = await self._request("GET", f"{self.base_url}/api/meetings", params) 

114 logger.info(f"Found {len(data)} meetings from ingest service") 

115 return data 

116 

117 async def get_race(self, race_id: str) -> dict[str, Any]: 

118 """Fetch a single race from the ingest service. 

119 

120 Args: 

121 race_id: Race/event ID (string) 

122 

123 Returns: 

124 Race data dictionary 

125 """ 

126 logger.info(f"Fetching race {race_id} from ingest service") 

127 return await self._request("GET", f"{self.base_url}/api/races/{race_id}") 

128 

129 async def get_runners(self, race_id: str) -> list[dict[str, Any]]: 

130 """Fetch runners for a race from the ingest service. 

131 

132 Args: 

133 race_id: Race/event ID (string) 

134 

135 Returns: 

136 List of runner data dictionaries 

137 """ 

138 logger.info(f"Fetching runners for race {race_id} from ingest service") 

139 return await self._request( 

140 "GET", 

141 f"{self.base_url}/api/races/{race_id}/runners", 

142 ) 

143 

144 async def get_races( 

145 self, 

146 date: str | None = None, 

147 meeting_id: str | None = None, 

148 status: str | None = None, 

149 limit: int = 100, 

150 offset: int = 0, 

151 ) -> list[dict[str, Any]]: 

152 """Fetch races from the ingest service. 

153 

154 Args: 

155 date: Optional ISO date filter (YYYY-MM-DD) 

156 meeting_id: Optional meeting ID filter 

157 status: Optional status filter 

158 limit: Maximum results (default 100) 

159 offset: Pagination offset (default 0) 

160 

161 Returns: 

162 List of race data dictionaries 

163 """ 

164 params: dict[str, Any] = {"limit": limit, "offset": offset} 

165 if date: 

166 params["date"] = date 

167 if meeting_id: 

168 params["meetingId"] = meeting_id 

169 if status: 

170 params["status"] = status 

171 

172 logger.info( 

173 "Fetching races from ingest service", 

174 extra={"params": params}, 

175 ) 

176 

177 return await self._request("GET", f"{self.base_url}/api/races", params)