Coverage for src/clients/amperity.py: 39%
117 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-06-05 22:56 -0700
« prev ^ index » next coverage.py v7.8.0, created at 2025-06-05 22:56 -0700
1"""Amperity API client for authentication."""
3import logging
4import os
5import requests
6import threading
7import time
8import webbrowser
9import readchar
10import json
11from rich.console import Console
13from src.config import set_amperity_token
14from src.ui.theme import (
15 SUCCESS_STYLE,
16 INFO_STYLE,
17)
19# Default Amperity base domain used when environment variable is not provided
20DEFAULT_AMPERITY_URL = "chuck.amperity.com"
23def get_amperity_url() -> str:
24 """Return the Amperity base URL, using env var override if set.
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
37class AmperityAPIClient:
38 """Client for handling Amperity authentication flow."""
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
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"
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}"
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}"
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)
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)}
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]")
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']}"
138 import sys
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()
149 time.sleep(poll_interval)
150 elapsed += poll_interval
152 except KeyboardInterrupt:
153 print("\n")
154 return False, "Authentication cancelled by user"
156 def submit_metrics(self, payload: dict, token: str) -> bool:
157 """Send usage metrics to the Amperity API.
159 Args:
160 payload: The data payload to send
161 token: The authentication token
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 }
173 response = requests.post(
174 url,
175 headers=headers,
176 data=json.dumps(payload),
177 timeout=10, # 10 seconds timeout
178 )
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
189 except Exception as e:
190 logging.error(f"Error sending metrics: {e}", exc_info=True)
191 return False
193 def submit_bug_report(self, payload: dict, token: str) -> tuple[bool, str]:
194 """Send a bug report to the Amperity API.
196 Args:
197 payload: The bug report payload to send.
198 token: The authentication token.
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 }
210 response = requests.post(
211 url,
212 headers=headers,
213 data=json.dumps(payload),
214 timeout=10,
215 )
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"
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}"
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)