repr-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
repr/api.py ADDED
@@ -0,0 +1,263 @@
1
+ """
2
+ REST API client for repr.dev endpoints.
3
+ """
4
+
5
+ import hashlib
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from .auth import require_auth, AuthError
11
+ from .config import get_api_base
12
+
13
+
14
+ def _get_profile_url() -> str:
15
+ return f"{get_api_base()}/profile"
16
+
17
+
18
+ def _get_repo_profile_url() -> str:
19
+ return f"{get_api_base()}/repo-profile"
20
+
21
+
22
+ def _get_user_url() -> str:
23
+ return f"{get_api_base()}/user"
24
+
25
+
26
+ class APIError(Exception):
27
+ """API request error."""
28
+ pass
29
+
30
+
31
+ def _get_headers() -> dict[str, str]:
32
+ """Get headers with authentication."""
33
+ token = require_auth()
34
+ return {
35
+ "Authorization": f"Bearer {token}",
36
+ "Content-Type": "application/json",
37
+ "User-Agent": "repr-cli/0.1.0",
38
+ }
39
+
40
+
41
+ def compute_content_hash(content: str) -> str:
42
+ """Compute SHA256 hash of content."""
43
+ return hashlib.sha256(content.encode('utf-8')).hexdigest()
44
+
45
+
46
+ async def push_profile(content: str, profile_name: str, analyzed_repos: list[dict[str, Any] | str] | None = None) -> dict[str, Any]:
47
+ """
48
+ Push a profile to repr.dev.
49
+
50
+ Args:
51
+ content: Markdown content of the profile
52
+ profile_name: Name/identifier of the profile
53
+ analyzed_repos: Optional list of repository metadata (dicts) or names (strings for backward compat)
54
+
55
+ Returns:
56
+ Response data with profile URL
57
+
58
+ Raises:
59
+ APIError: If upload fails
60
+ AuthError: If not authenticated
61
+ """
62
+ async with httpx.AsyncClient() as client:
63
+ try:
64
+ # Compute content hash
65
+ content_hash = compute_content_hash(content)
66
+
67
+ payload = {
68
+ "content": content,
69
+ "name": profile_name,
70
+ "content_hash": content_hash,
71
+ }
72
+ if analyzed_repos is not None:
73
+ payload["analyzed_repos"] = analyzed_repos
74
+
75
+ response = await client.post(
76
+ _get_profile_url(),
77
+ headers=_get_headers(),
78
+ json=payload,
79
+ timeout=60,
80
+ )
81
+ response.raise_for_status()
82
+ return response.json()
83
+
84
+ except httpx.HTTPStatusError as e:
85
+ if e.response.status_code == 401:
86
+ raise AuthError("Session expired. Please run 'repr login' again.")
87
+ elif e.response.status_code == 413:
88
+ raise APIError("Profile too large to upload.")
89
+ else:
90
+ raise APIError(f"Upload failed: {e.response.status_code}")
91
+ except httpx.RequestError as e:
92
+ raise APIError(f"Network error: {str(e)}")
93
+
94
+
95
+ async def get_user_profile() -> dict[str, Any] | None:
96
+ """
97
+ Get the user's current profile from the server.
98
+
99
+ Returns:
100
+ Profile data or None if not found
101
+
102
+ Raises:
103
+ APIError: If request fails
104
+ AuthError: If not authenticated
105
+ """
106
+ async with httpx.AsyncClient() as client:
107
+ try:
108
+ response = await client.get(
109
+ _get_profile_url(),
110
+ headers=_get_headers(),
111
+ timeout=30,
112
+ )
113
+
114
+ if response.status_code == 404:
115
+ return None
116
+
117
+ response.raise_for_status()
118
+ return response.json()
119
+
120
+ except httpx.HTTPStatusError as e:
121
+ if e.response.status_code == 401:
122
+ raise AuthError("Session expired. Please run 'repr login' again.")
123
+ raise APIError(f"Failed to get profile: {e.response.status_code}")
124
+ except httpx.RequestError as e:
125
+ raise APIError(f"Network error: {str(e)}")
126
+
127
+
128
+ async def get_user_info() -> dict[str, Any]:
129
+ """
130
+ Get current user information.
131
+
132
+ Returns:
133
+ User info dict
134
+
135
+ Raises:
136
+ APIError: If request fails
137
+ AuthError: If not authenticated
138
+ """
139
+ async with httpx.AsyncClient() as client:
140
+ try:
141
+ response = await client.get(
142
+ _get_user_url(),
143
+ headers=_get_headers(),
144
+ timeout=30,
145
+ )
146
+ response.raise_for_status()
147
+ return response.json()
148
+
149
+ except httpx.HTTPStatusError as e:
150
+ if e.response.status_code == 401:
151
+ raise AuthError("Session expired. Please run 'repr login' again.")
152
+ raise APIError(f"Failed to get user info: {e.response.status_code}")
153
+ except httpx.RequestError as e:
154
+ raise APIError(f"Network error: {str(e)}")
155
+
156
+
157
+ async def delete_profile() -> bool:
158
+ """
159
+ Delete the user's profile from the server.
160
+
161
+ Returns:
162
+ True if deleted successfully
163
+
164
+ Raises:
165
+ APIError: If request fails
166
+ AuthError: If not authenticated
167
+ """
168
+ async with httpx.AsyncClient() as client:
169
+ try:
170
+ response = await client.delete(
171
+ _get_profile_url(),
172
+ headers=_get_headers(),
173
+ timeout=30,
174
+ )
175
+ response.raise_for_status()
176
+ return True
177
+
178
+ except httpx.HTTPStatusError as e:
179
+ if e.response.status_code == 401:
180
+ raise AuthError("Session expired. Please run 'repr login' again.")
181
+ elif e.response.status_code == 404:
182
+ return True # Already deleted
183
+ raise APIError(f"Failed to delete profile: {e.response.status_code}")
184
+ except httpx.RequestError as e:
185
+ raise APIError(f"Network error: {str(e)}")
186
+
187
+
188
+ async def push_repo_profile(
189
+ content: str,
190
+ repo_name: str,
191
+ repo_metadata: dict[str, Any],
192
+ ) -> dict[str, Any]:
193
+ """
194
+ Push a single repository profile to repr.dev.
195
+
196
+ Args:
197
+ content: Markdown content of the profile
198
+ repo_name: Name of the repository
199
+ repo_metadata: Repository metadata (commit_count, languages, etc.)
200
+
201
+ Returns:
202
+ Response data with profile URL
203
+
204
+ Raises:
205
+ APIError: If upload fails
206
+ AuthError: If not authenticated
207
+ """
208
+ async with httpx.AsyncClient() as client:
209
+ try:
210
+ content_hash = compute_content_hash(content)
211
+
212
+ payload = {
213
+ "repo_name": repo_name,
214
+ "content": content,
215
+ "content_hash": content_hash,
216
+ **repo_metadata,
217
+ }
218
+
219
+ response = await client.post(
220
+ _get_repo_profile_url(),
221
+ headers=_get_headers(),
222
+ json=payload,
223
+ timeout=60,
224
+ )
225
+ response.raise_for_status()
226
+ return response.json()
227
+
228
+ except httpx.HTTPStatusError as e:
229
+ if e.response.status_code == 401:
230
+ raise AuthError("Session expired. Please run 'repr login' again.")
231
+ elif e.response.status_code == 413:
232
+ raise APIError("Profile too large to upload.")
233
+ else:
234
+ raise APIError(f"Upload failed: {e.response.status_code}")
235
+ except httpx.RequestError as e:
236
+ raise APIError(f"Network error: {str(e)}")
237
+
238
+
239
+ def sync_push_profile(content: str, profile_name: str, analyzed_repos: list[str] | None = None) -> dict[str, Any]:
240
+ """
241
+ Synchronous wrapper for push_profile.
242
+
243
+ Args:
244
+ content: Markdown content of the profile
245
+ profile_name: Name/identifier of the profile
246
+ analyzed_repos: Optional list of repository names analyzed
247
+
248
+ Returns:
249
+ Response data with profile URL
250
+ """
251
+ import asyncio
252
+ return asyncio.run(push_profile(content, profile_name, analyzed_repos))
253
+
254
+
255
+ def sync_get_user_info() -> dict[str, Any]:
256
+ """
257
+ Synchronous wrapper for get_user_info.
258
+
259
+ Returns:
260
+ User info dict
261
+ """
262
+ import asyncio
263
+ return asyncio.run(get_user_info())
repr/auth.py ADDED
@@ -0,0 +1,300 @@
1
+ """
2
+ Authentication via device code flow.
3
+ """
4
+
5
+ import asyncio
6
+ import time
7
+ from dataclasses import dataclass
8
+
9
+ import httpx
10
+
11
+ from .config import set_auth, clear_auth, get_auth, is_authenticated, get_api_base
12
+
13
+
14
+ def _get_device_code_url() -> str:
15
+ return f"{get_api_base()}/device-code"
16
+
17
+
18
+ def _get_token_url() -> str:
19
+ return f"{get_api_base()}/token"
20
+
21
+ # Polling configuration
22
+ POLL_INTERVAL = 5 # seconds
23
+ MAX_POLL_TIME = 600 # 10 minutes
24
+
25
+
26
+ @dataclass
27
+ class DeviceCodeResponse:
28
+ """Response from device code request."""
29
+ device_code: str
30
+ user_code: str
31
+ verification_url: str
32
+ expires_in: int
33
+ interval: int
34
+
35
+
36
+ @dataclass
37
+ class TokenResponse:
38
+ """Response from token request."""
39
+ access_token: str
40
+ user_id: str
41
+ email: str
42
+ litellm_api_key: str | None = None # rf_* token for LLM proxy authentication
43
+
44
+
45
+ class AuthError(Exception):
46
+ """Authentication error."""
47
+ pass
48
+
49
+
50
+ async def request_device_code() -> DeviceCodeResponse:
51
+ """
52
+ Request a new device code for authentication.
53
+
54
+ Returns:
55
+ DeviceCodeResponse with user code to display
56
+
57
+ Raises:
58
+ AuthError: If request fails
59
+ """
60
+ async with httpx.AsyncClient() as client:
61
+ try:
62
+ response = await client.post(
63
+ _get_device_code_url(),
64
+ json={"client_id": "repr-cli"},
65
+ timeout=30,
66
+ )
67
+ response.raise_for_status()
68
+ data = response.json()
69
+
70
+ return DeviceCodeResponse(
71
+ device_code=data["device_code"],
72
+ user_code=data["user_code"],
73
+ verification_url=data.get("verification_url", "https://repr.dev/device"),
74
+ expires_in=data.get("expires_in", 600),
75
+ interval=data.get("interval", 5),
76
+ )
77
+ except httpx.HTTPStatusError as e:
78
+ raise AuthError(f"Failed to get device code: {e.response.status_code}")
79
+ except httpx.RequestError as e:
80
+ raise AuthError(f"Network error: {str(e)}")
81
+
82
+
83
+ async def poll_for_token(device_code: str, interval: int = POLL_INTERVAL) -> TokenResponse:
84
+ """
85
+ Poll for access token after user authorizes.
86
+
87
+ Args:
88
+ device_code: The device code from initial request
89
+ interval: Polling interval in seconds
90
+
91
+ Returns:
92
+ TokenResponse with access token
93
+
94
+ Raises:
95
+ AuthError: If polling fails or times out
96
+ """
97
+ start_time = time.time()
98
+
99
+ async with httpx.AsyncClient() as client:
100
+ while time.time() - start_time < MAX_POLL_TIME:
101
+ try:
102
+ response = await client.post(
103
+ _get_token_url(),
104
+ json={
105
+ "device_code": device_code,
106
+ "client_id": "repr-cli",
107
+ },
108
+ timeout=30,
109
+ )
110
+
111
+ if response.status_code == 200:
112
+ data = response.json()
113
+ return TokenResponse(
114
+ access_token=data["access_token"],
115
+ user_id=data["user_id"],
116
+ email=data["email"],
117
+ litellm_api_key=data.get("litellm_api_key"),
118
+ )
119
+
120
+ if response.status_code == 400:
121
+ data = response.json()
122
+ error = data.get("error", "unknown")
123
+
124
+ if error == "authorization_pending":
125
+ # User hasn't authorized yet, continue polling
126
+ await asyncio.sleep(interval)
127
+ continue
128
+ elif error == "slow_down":
129
+ # Increase interval
130
+ interval = min(interval + 5, 30)
131
+ await asyncio.sleep(interval)
132
+ continue
133
+ elif error == "expired_token":
134
+ raise AuthError("Device code expired. Please try again.")
135
+ elif error == "access_denied":
136
+ raise AuthError("Authorization denied by user.")
137
+ else:
138
+ raise AuthError(f"Authorization failed: {error}")
139
+
140
+ response.raise_for_status()
141
+
142
+ except httpx.RequestError as e:
143
+ # Network error, retry
144
+ await asyncio.sleep(interval)
145
+ continue
146
+
147
+ raise AuthError("Authorization timed out. Please try again.")
148
+
149
+
150
+ def save_token(token_response: TokenResponse) -> None:
151
+ """
152
+ Save authentication token to config.
153
+
154
+ Args:
155
+ token_response: Token response from successful auth
156
+ """
157
+ set_auth(
158
+ access_token=token_response.access_token,
159
+ user_id=token_response.user_id,
160
+ email=token_response.email,
161
+ litellm_api_key=token_response.litellm_api_key,
162
+ )
163
+
164
+
165
+ def logout() -> None:
166
+ """Clear authentication and logout."""
167
+ clear_auth()
168
+
169
+
170
+ def get_current_user() -> dict | None:
171
+ """
172
+ Get current authenticated user info.
173
+
174
+ Returns:
175
+ User info dict or None if not authenticated
176
+ """
177
+ return get_auth()
178
+
179
+
180
+ def require_auth() -> str:
181
+ """
182
+ Get access token, raising error if not authenticated.
183
+
184
+ Returns:
185
+ Access token
186
+
187
+ Raises:
188
+ AuthError: If not authenticated
189
+ """
190
+ auth = get_auth()
191
+ if not auth or not auth.get("access_token"):
192
+ raise AuthError("Not authenticated. Run 'repr login' first.")
193
+ return auth["access_token"]
194
+
195
+
196
+ class AuthFlow:
197
+ """
198
+ Manages the device code authentication flow with progress callbacks.
199
+ """
200
+
201
+ def __init__(
202
+ self,
203
+ on_code_received: callable = None,
204
+ on_progress: callable = None,
205
+ on_success: callable = None,
206
+ on_error: callable = None,
207
+ ):
208
+ self.on_code_received = on_code_received
209
+ self.on_progress = on_progress
210
+ self.on_success = on_success
211
+ self.on_error = on_error
212
+ self._cancelled = False
213
+
214
+ def cancel(self) -> None:
215
+ """Cancel the authentication flow."""
216
+ self._cancelled = True
217
+
218
+ async def run(self) -> TokenResponse | None:
219
+ """
220
+ Run the full authentication flow.
221
+
222
+ Returns:
223
+ TokenResponse if successful, None if cancelled
224
+ """
225
+ try:
226
+ # Request device code
227
+ device_code_response = await request_device_code()
228
+
229
+ if self.on_code_received:
230
+ self.on_code_received(device_code_response)
231
+
232
+ # Poll for token
233
+ start_time = time.time()
234
+ interval = device_code_response.interval
235
+
236
+ async with httpx.AsyncClient() as client:
237
+ while not self._cancelled:
238
+ elapsed = time.time() - start_time
239
+ remaining = device_code_response.expires_in - elapsed
240
+
241
+ if remaining <= 0:
242
+ raise AuthError("Device code expired. Please try again.")
243
+
244
+ if self.on_progress:
245
+ self.on_progress(remaining)
246
+
247
+ try:
248
+ response = await client.post(
249
+ _get_token_url(),
250
+ json={
251
+ "device_code": device_code_response.device_code,
252
+ "client_id": "repr-cli",
253
+ },
254
+ timeout=30,
255
+ )
256
+
257
+ if response.status_code == 200:
258
+ data = response.json()
259
+ user_data = data.get("user", {})
260
+ token = TokenResponse(
261
+ access_token=data["access_token"],
262
+ user_id=user_data.get("id", ""),
263
+ email=user_data.get("email", ""),
264
+ litellm_api_key=data.get("litellm_api_key"),
265
+ )
266
+ save_token(token)
267
+
268
+ if self.on_success:
269
+ self.on_success(token)
270
+
271
+ return token
272
+
273
+ if response.status_code == 400:
274
+ data = response.json()
275
+ error = data.get("error", "unknown")
276
+
277
+ if error == "authorization_pending":
278
+ await asyncio.sleep(interval)
279
+ continue
280
+ elif error == "slow_down":
281
+ interval = min(interval + 5, 30)
282
+ await asyncio.sleep(interval)
283
+ continue
284
+ elif error == "expired_token":
285
+ raise AuthError("Device code expired. Please try again.")
286
+ elif error == "access_denied":
287
+ raise AuthError("Authorization denied by user.")
288
+
289
+ except httpx.RequestError:
290
+ await asyncio.sleep(interval)
291
+ continue
292
+
293
+ await asyncio.sleep(interval)
294
+
295
+ return None # Cancelled
296
+
297
+ except AuthError as e:
298
+ if self.on_error:
299
+ self.on_error(e)
300
+ raise