Coverage for src/chuck_data/clients/amperity.py: 0%

117 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-06-05 22:56 -0700

1"""Amperity API client for authentication.""" 

2 

3import logging 

4import os 

5import requests 

6import threading 

7import time 

8import webbrowser 

9import readchar 

10import json 

11from rich.console import Console 

12 

13from ..config import set_amperity_token 

14from ..ui.theme import ( 

15 SUCCESS_STYLE, 

16 INFO_STYLE, 

17) 

18 

19# Default Amperity base domain used when environment variable is not provided 

20DEFAULT_AMPERITY_URL = "chuck.amperity.com" 

21 

22 

23def get_amperity_url() -> str: 

24 """Return the Amperity base URL, using env var override if set. 

25 

26 Strips any protocol prefix to ensure clean domain-only return. 

27 """ 

28 url = os.getenv("CHUCK_AMPERITY_URL", DEFAULT_AMPERITY_URL) 

29 # Remove protocol if present 

30 if url.startswith("https://"): 

31 url = url[8:] 

32 elif url.startswith("http://"): 

33 url = url[7:] 

34 return url 

35 

36 

37class AmperityAPIClient: 

38 """Client for handling Amperity authentication flow.""" 

39 

40 def __init__(self) -> None: 

41 self.base_url = get_amperity_url() 

42 self.nonce: str | None = None 

43 self.token: str | None = None 

44 self.state = "pending" 

45 self.auth_thread: threading.Thread | None = None 

46 

47 def start_auth(self) -> tuple[bool, str]: 

48 """Start the authentication process.""" 

49 try: 

50 resp = requests.post(f"https://{self.base_url}/api/auth/start", timeout=30) 

51 if resp.status_code != 200: 

52 return False, f"Failed to start auth: {resp.status_code} - {resp.text}" 

53 auth_data = resp.json() 

54 self.nonce = auth_data.get("nonce") 

55 if not self.nonce: 

56 return False, "Failed to get nonce from Amperity auth server" 

57 

58 console = Console() 

59 console.print( 

60 f"Amperity Nonce: [{SUCCESS_STYLE}]{self.nonce}[/{SUCCESS_STYLE}]" 

61 ) 

62 console.print( 

63 f"[{INFO_STYLE}]Press any key to open the login page in your browser...[/{INFO_STYLE}]" 

64 ) 

65 readchar.readchar() 

66 login_url = f"https://{self.base_url}/login?nonce={self.nonce}" 

67 try: 

68 webbrowser.open(login_url) 

69 except Exception as e: # pragma: no cover - cannot trigger in tests 

70 logging.error("Failed to open browser: %s", e) 

71 return False, f"Failed to open browser: {e}" 

72 

73 self.auth_thread = threading.Thread( 

74 target=self._poll_auth_state, daemon=True 

75 ) 

76 self.auth_thread.start() 

77 return True, "Authentication started. Please log in via the browser." 

78 except Exception as e: # pragma: no cover - network issues 

79 logging.error("Auth start error: %s", e) 

80 return False, f"Authentication error: {e}" 

81 

82 def _poll_auth_state(self) -> None: 

83 """Poll the auth state endpoint until authentication is complete.""" 

84 while self.state not in {"success", "error"}: 

85 try: 

86 state_url = f"https://{self.base_url}/api/auth/state/{self.nonce}" 

87 resp = requests.get(state_url) 

88 if resp.status_code == 200: 

89 state_data = resp.json() 

90 self.state = state_data.get("state", "unknown") 

91 if self.state == "success": 

92 self.token = state_data.get("token") 

93 if self.token: 

94 set_amperity_token(self.token) 

95 break 

96 if self.state == "error": 

97 logging.error("Authentication failed") 

98 break 

99 elif 400 <= resp.status_code < 500: 

100 logging.error( 

101 "Authentication state polling received %s", resp.status_code 

102 ) 

103 self.state = "error" 

104 break 

105 except Exception as e: # pragma: no cover - network issues 

106 logging.error("Error polling auth state: %s", e) 

107 self.state = "error" 

108 break 

109 time.sleep(2) 

110 

111 def get_auth_status(self) -> dict: 

112 """Return the current authentication status.""" 

113 return {"state": self.state, "nonce": self.nonce, "has_token": bool(self.token)} 

114 

115 def wait_for_auth_completion( 

116 self, poll_interval: int = 1, timeout: int = None 

117 ) -> tuple[bool, str]: 

118 """Wait for authentication to complete in a blocking manner.""" 

119 if not self.nonce: 

120 return False, "Authentication not started" 

121 console = Console() 

122 console.print( 

123 f"[{INFO_STYLE}]Waiting for authentication to complete...[/{INFO_STYLE}]" 

124 ) 

125 console.print("[dim]Press Ctrl+C to cancel[/dim]") 

126 

127 elapsed = 0 

128 try: 

129 while True: 

130 status = self.get_auth_status() 

131 if status["state"] == "success": 

132 print("\n") 

133 return True, "Authentication completed successfully." 

134 if status["state"] in {"error", "timeout"}: 

135 print("\n") 

136 return False, f"Authentication failed: {status['state']}" 

137 

138 import sys 

139 

140 # Show elapsed time and helpful message after 30 seconds 

141 if elapsed > 30: 

142 sys.stdout.write( 

143 f"\r[{elapsed}s] Still waiting... Please complete authentication in your browser" 

144 ) 

145 else: 

146 sys.stdout.write(f"\r[{elapsed}s] Waiting for authentication...") 

147 sys.stdout.flush() 

148 

149 time.sleep(poll_interval) 

150 elapsed += poll_interval 

151 

152 except KeyboardInterrupt: 

153 print("\n") 

154 return False, "Authentication cancelled by user" 

155 

156 def submit_metrics(self, payload: dict, token: str) -> bool: 

157 """Send usage metrics to the Amperity API. 

158 

159 Args: 

160 payload: The data payload to send 

161 token: The authentication token 

162 

163 Returns: 

164 bool: True if metrics were sent successfully, False otherwise. 

165 """ 

166 try: 

167 url = f"https://{self.base_url}/api/usage" 

168 headers = { 

169 "Content-Type": "application/json", 

170 "Authorization": f"Bearer {token}", 

171 } 

172 

173 response = requests.post( 

174 url, 

175 headers=headers, 

176 data=json.dumps(payload), 

177 timeout=10, # 10 seconds timeout 

178 ) 

179 

180 if response.status_code == 200 or response.status_code == 201: 

181 logging.debug(f"Metrics sent successfully: {response.status_code}") 

182 return True 

183 else: 

184 logging.debug( 

185 f"Failed to send metrics: {response.status_code} - {response.text}" 

186 ) 

187 return False 

188 

189 except Exception as e: 

190 logging.error(f"Error sending metrics: {e}", exc_info=True) 

191 return False 

192 

193 def submit_bug_report(self, payload: dict, token: str) -> tuple[bool, str]: 

194 """Send a bug report to the Amperity API. 

195 

196 Args: 

197 payload: The bug report payload to send. 

198 token: The authentication token. 

199 

200 Returns: 

201 tuple[bool, str]: Success flag and response message. 

202 """ 

203 try: 

204 url = f"https://{self.base_url}/api/usage" 

205 headers = { 

206 "Content-Type": "application/json", 

207 "Authorization": f"Bearer {token}", 

208 } 

209 

210 response = requests.post( 

211 url, 

212 headers=headers, 

213 data=json.dumps(payload), 

214 timeout=10, 

215 ) 

216 

217 if response.status_code in (200, 201, 202, 204): 

218 logging.debug( 

219 f"Bug report submitted successfully: {response.status_code}" 

220 ) 

221 return True, "Bug report submitted successfully" 

222 

223 logging.debug( 

224 f"Failed to submit bug report: {response.status_code} - {response.text}" 

225 ) 

226 return False, f"Failed to submit bug report: {response.status_code}" 

227 

228 except Exception as e: # pragma: no cover - network issues 

229 logging.error(f"Error submitting bug report: {e}", exc_info=True) 

230 return False, str(e)