recce-cloud 1.32.0__py3-none-any.whl → 1.33.1__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.
recce_cloud/VERSION CHANGED
@@ -1 +1 @@
1
- 1.32.0
1
+ 1.33.1
recce_cloud/api/client.py CHANGED
@@ -1,13 +1,13 @@
1
1
  """
2
2
  Recce Cloud API clients for lightweight operations.
3
3
 
4
- Provides clients for session management and report generation.
4
+ Provides clients for session management, organization/project listing, and report generation.
5
5
  """
6
6
 
7
7
  import json
8
8
  import logging
9
9
  import os
10
- from typing import Optional
10
+ from typing import Any, Dict, List, Optional
11
11
 
12
12
  import requests
13
13
 
@@ -260,6 +260,249 @@ class RecceCloudClient:
260
260
  status_code=response.status_code,
261
261
  )
262
262
 
263
+ def list_organizations(self) -> List[Dict[str, Any]]:
264
+ """
265
+ List all organizations the user has access to.
266
+
267
+ Returns:
268
+ List of organization dictionaries with id, name, slug fields.
269
+
270
+ Raises:
271
+ RecceCloudException: If the API call fails.
272
+ """
273
+ api_url = f"{self.base_url_v2}/organizations"
274
+ response = self._request("GET", api_url)
275
+
276
+ if response.status_code != 200:
277
+ raise RecceCloudException(
278
+ reason=response.text,
279
+ status_code=response.status_code,
280
+ )
281
+
282
+ data = response.json()
283
+ return data.get("organizations", [])
284
+
285
+ def list_projects(self, org_id: str) -> List[Dict[str, Any]]:
286
+ """
287
+ List all projects in an organization.
288
+
289
+ Args:
290
+ org_id: Organization ID or slug.
291
+
292
+ Returns:
293
+ List of project dictionaries with id, name, slug fields.
294
+
295
+ Raises:
296
+ RecceCloudException: If the API call fails.
297
+ """
298
+ api_url = f"{self.base_url_v2}/organizations/{org_id}/projects"
299
+ response = self._request("GET", api_url)
300
+
301
+ if response.status_code != 200:
302
+ raise RecceCloudException(
303
+ reason=response.text,
304
+ status_code=response.status_code,
305
+ )
306
+
307
+ data = response.json()
308
+ return data.get("projects", [])
309
+
310
+ def get_organization(self, org_id: str) -> Optional[Dict[str, Any]]:
311
+ """
312
+ Get a specific organization by ID or slug.
313
+
314
+ Args:
315
+ org_id: Organization ID or slug.
316
+
317
+ Returns:
318
+ Organization dictionary, or None if not found.
319
+
320
+ Raises:
321
+ RecceCloudException: If the API call fails.
322
+ """
323
+ orgs = self.list_organizations()
324
+ for org in orgs:
325
+ # Compare as strings to handle both int and str IDs
326
+ if str(org.get("id")) == str(org_id) or org.get("slug") == org_id or org.get("name") == org_id:
327
+ return org
328
+ return None
329
+
330
+ def get_project(self, org_id: str, project_id: str) -> Optional[Dict[str, Any]]:
331
+ """
332
+ Get a specific project by ID or slug.
333
+
334
+ Args:
335
+ org_id: Organization ID or slug.
336
+ project_id: Project ID or slug.
337
+
338
+ Returns:
339
+ Project dictionary, or None if not found.
340
+
341
+ Raises:
342
+ RecceCloudException: If the API call fails.
343
+ """
344
+ projects = self.list_projects(org_id)
345
+ for project in projects:
346
+ # Compare as strings to handle both int and str IDs
347
+ if (
348
+ str(project.get("id")) == str(project_id)
349
+ or project.get("slug") == project_id
350
+ or project.get("name") == project_id
351
+ ):
352
+ return project
353
+ return None
354
+
355
+ def list_sessions(
356
+ self,
357
+ org_id: str,
358
+ project_id: str,
359
+ session_name: Optional[str] = None,
360
+ session_type: Optional[str] = None,
361
+ branch: Optional[str] = None,
362
+ limit: Optional[int] = None,
363
+ offset: Optional[int] = None,
364
+ ) -> List[Dict[str, Any]]:
365
+ """
366
+ List sessions in a project with optional filtering.
367
+
368
+ Args:
369
+ org_id: Organization ID or slug.
370
+ project_id: Project ID or slug.
371
+ session_name: Filter by session name (exact match).
372
+ session_type: Filter by session type (e.g., "pr", "prod", "manual").
373
+ branch: Filter by branch name (exact match).
374
+ limit: Maximum number of results to return (1-1000).
375
+ offset: Number of results to skip for pagination.
376
+
377
+ Returns:
378
+ List of session dictionaries.
379
+
380
+ Raises:
381
+ RecceCloudException: If the API call fails.
382
+ """
383
+ api_url = f"{self.base_url_v2}/organizations/{org_id}/projects/{project_id}/sessions"
384
+ params = {}
385
+ if session_name:
386
+ params["name"] = session_name
387
+ if session_type:
388
+ params["type"] = session_type
389
+ if branch:
390
+ params["branch"] = branch
391
+ if limit is not None:
392
+ params["limit"] = limit
393
+ if offset is not None:
394
+ params["offset"] = offset
395
+
396
+ response = self._request("GET", api_url, params=params if params else None)
397
+
398
+ if response.status_code == 404:
399
+ raise RecceCloudException(
400
+ reason="Organization or project not found",
401
+ status_code=response.status_code,
402
+ )
403
+ if response.status_code != 200:
404
+ raise RecceCloudException(
405
+ reason=response.text,
406
+ status_code=response.status_code,
407
+ )
408
+
409
+ data = response.json()
410
+ return data.get("sessions", [])
411
+
412
+ def get_session_by_name(
413
+ self,
414
+ org_id: str,
415
+ project_id: str,
416
+ session_name: str,
417
+ ) -> Optional[Dict[str, Any]]:
418
+ """
419
+ Get a session by its name.
420
+
421
+ Uses the list sessions endpoint with filtering to find a session
422
+ by name, which is unique within a project.
423
+
424
+ Args:
425
+ org_id: Organization ID or slug.
426
+ project_id: Project ID or slug.
427
+ session_name: The session name to look up.
428
+
429
+ Returns:
430
+ Session dictionary if found, None otherwise.
431
+
432
+ Raises:
433
+ RecceCloudException: If the API call fails.
434
+ """
435
+ sessions = self.list_sessions(
436
+ org_id=org_id,
437
+ project_id=project_id,
438
+ session_name=session_name,
439
+ limit=1,
440
+ )
441
+ if sessions:
442
+ return sessions[0]
443
+ return None
444
+
445
+ def create_session(
446
+ self,
447
+ org_id: str,
448
+ project_id: str,
449
+ session_name: str,
450
+ adapter_type: Optional[str] = None,
451
+ session_type: str = "manual",
452
+ ) -> Dict[str, Any]:
453
+ """
454
+ Create a new session with the given name.
455
+
456
+ Args:
457
+ org_id: Organization ID or slug.
458
+ project_id: Project ID or slug.
459
+ session_name: The name for the new session.
460
+ adapter_type: dbt adapter type (e.g., "postgres", "snowflake").
461
+ session_type: Session type (default: "manual").
462
+
463
+ Returns:
464
+ Created session dictionary with id, name, and other fields.
465
+
466
+ Raises:
467
+ RecceCloudException: If the API call fails or session creation fails.
468
+ """
469
+ api_url = f"{self.base_url_v2}/organizations/{org_id}/projects/{project_id}/sessions"
470
+ data = {
471
+ "name": session_name,
472
+ "type": session_type,
473
+ }
474
+ if adapter_type:
475
+ data["adapter_type"] = adapter_type
476
+
477
+ response = self._request("POST", api_url, json=data)
478
+
479
+ if response.status_code == 404:
480
+ raise RecceCloudException(
481
+ reason="Organization or project not found",
482
+ status_code=response.status_code,
483
+ )
484
+ if response.status_code == 409:
485
+ raise RecceCloudException(
486
+ reason=f"Session with name '{session_name}' already exists",
487
+ status_code=response.status_code,
488
+ )
489
+ if response.status_code == 403:
490
+ raise RecceCloudException(
491
+ reason=response.json().get("detail", "Permission denied"),
492
+ status_code=response.status_code,
493
+ )
494
+ if response.status_code not in [200, 201]:
495
+ raise RecceCloudException(
496
+ reason=response.text,
497
+ status_code=response.status_code,
498
+ )
499
+
500
+ result = response.json()
501
+ # Handle both direct session response and wrapped response
502
+ if "session" in result:
503
+ return result["session"]
504
+ return result
505
+
263
506
 
264
507
  class ReportClient:
265
508
  """Client for fetching reports from Recce Cloud API."""
@@ -0,0 +1,21 @@
1
+ """
2
+ Authentication module for Recce Cloud CLI.
3
+
4
+ This module provides browser-based OAuth authentication and credential management.
5
+ It is designed to be standalone (duplicated from Recce OSS) to support future
6
+ repository separation.
7
+ """
8
+
9
+ from recce_cloud.auth.profile import (
10
+ get_api_token,
11
+ get_user_id,
12
+ load_profile,
13
+ update_api_token,
14
+ )
15
+
16
+ __all__ = [
17
+ "get_api_token",
18
+ "get_user_id",
19
+ "load_profile",
20
+ "update_api_token",
21
+ ]
@@ -0,0 +1,128 @@
1
+ """
2
+ One-time HTTP callback server for OAuth authentication.
3
+
4
+ This server receives the encrypted token from Recce Cloud after
5
+ browser-based authentication.
6
+ """
7
+
8
+ import threading
9
+ from http.server import BaseHTTPRequestHandler, HTTPServer
10
+ from pathlib import Path
11
+ from typing import Optional
12
+ from urllib.parse import parse_qs, urlparse
13
+
14
+
15
+ class CallbackResult:
16
+ """Container for callback result."""
17
+
18
+ def __init__(self):
19
+ self.code: Optional[str] = None
20
+ self.error: Optional[str] = None
21
+
22
+
23
+ def make_callback_handler(result: CallbackResult, on_success_html: str, on_error_html: str):
24
+ """
25
+ Create a one-time HTTP request handler.
26
+
27
+ Args:
28
+ result: CallbackResult object to store the received code.
29
+ on_success_html: HTML content to return on success.
30
+ on_error_html: HTML content to return on error.
31
+
32
+ Returns:
33
+ HTTP request handler class.
34
+ """
35
+
36
+ class OneTimeHTTPRequestHandler(BaseHTTPRequestHandler):
37
+ def do_GET(self):
38
+ try:
39
+ parsed_url = urlparse(self.path)
40
+ query_params = parse_qs(parsed_url.query)
41
+
42
+ code = query_params.get("code", [None])[0]
43
+ if not code:
44
+ result.error = "Missing 'code' parameter in callback"
45
+ self._send_response(400, on_error_html)
46
+ return
47
+
48
+ result.code = code
49
+ self._send_response(200, on_success_html)
50
+
51
+ except Exception as e:
52
+ result.error = str(e)
53
+ self._send_response(500, on_error_html)
54
+ finally:
55
+ # Shutdown server after handling the request
56
+ self.server.server_close()
57
+ threading.Thread(target=self.server.shutdown, daemon=True).start()
58
+
59
+ def _send_response(self, status_code: int, html_content: str):
60
+ self.send_response(status_code)
61
+ self.send_header("Content-Type", "text/html")
62
+ self.send_header("Content-Length", str(len(html_content.encode())))
63
+ self.end_headers()
64
+ self.wfile.write(html_content.encode())
65
+
66
+ def log_message(self, format, *args):
67
+ # Suppress default logging
68
+ pass
69
+
70
+ return OneTimeHTTPRequestHandler
71
+
72
+
73
+ def run_callback_server(
74
+ port: int,
75
+ result: CallbackResult,
76
+ on_success_html: str,
77
+ on_error_html: str,
78
+ timeout: int = 300,
79
+ ) -> bool:
80
+ """
81
+ Run a one-time HTTP server to receive the OAuth callback.
82
+
83
+ Args:
84
+ port: Port to listen on.
85
+ result: CallbackResult object to store the received code.
86
+ on_success_html: HTML content to return on success.
87
+ on_error_html: HTML content to return on error.
88
+ timeout: Server timeout in seconds (default 5 minutes).
89
+
90
+ Returns:
91
+ True if callback was received, False if timeout or error.
92
+ """
93
+ handler = make_callback_handler(result, on_success_html, on_error_html)
94
+ server = HTTPServer(("localhost", port), handler)
95
+ server.timeout = timeout
96
+
97
+ try:
98
+ # Handle a single request
99
+ server.handle_request()
100
+ return result.code is not None
101
+ except Exception:
102
+ return False
103
+ finally:
104
+ server.server_close()
105
+
106
+
107
+ def _load_template(filename: str) -> str:
108
+ """
109
+ Load HTML template from the templates directory.
110
+
111
+ Args:
112
+ filename: Name of the template file (e.g., 'success.html').
113
+
114
+ Returns:
115
+ HTML content as string.
116
+
117
+ Raises:
118
+ FileNotFoundError: If template file is not found.
119
+ """
120
+ templates_dir = Path(__file__).parent / "templates"
121
+ template_path = templates_dir / filename
122
+ with open(template_path, "r", encoding="utf-8") as f:
123
+ return f.read()
124
+
125
+
126
+ # Load HTML templates from separate files
127
+ SUCCESS_HTML = _load_template("success.html")
128
+ ERROR_HTML = _load_template("error.html")
@@ -0,0 +1,281 @@
1
+ """
2
+ Browser-based OAuth login flow for Recce Cloud.
3
+
4
+ This module implements the RSA-encrypted OAuth flow:
5
+ 1. Generate RSA-2048 key pair
6
+ 2. Open browser to Recce Cloud with public key
7
+ 3. Start local callback server
8
+ 4. Receive encrypted token via callback
9
+ 5. Decrypt and verify token
10
+ 6. Save to profile
11
+ """
12
+
13
+ import base64
14
+ import os
15
+ import random
16
+ import webbrowser
17
+ from typing import Optional, Tuple
18
+
19
+ import requests
20
+ from cryptography.hazmat.backends import default_backend
21
+ from cryptography.hazmat.primitives import hashes, serialization
22
+ from cryptography.hazmat.primitives.asymmetric import padding, rsa
23
+ from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey
24
+ from rich.console import Console
25
+
26
+ from recce_cloud.auth.callback_server import (
27
+ ERROR_HTML,
28
+ SUCCESS_HTML,
29
+ CallbackResult,
30
+ run_callback_server,
31
+ )
32
+ from recce_cloud.auth.profile import (
33
+ clear_api_token,
34
+ get_api_token,
35
+ get_profile_path,
36
+ update_api_token,
37
+ )
38
+
39
+ # Cloud API configuration
40
+ RECCE_CLOUD_API_HOST = os.environ.get("RECCE_CLOUD_API_HOST", "https://cloud.datarecce.io")
41
+ RECCE_CLOUD_BASE_URL = os.environ.get("RECCE_CLOUD_BASE_URL", RECCE_CLOUD_API_HOST)
42
+
43
+ console = Console()
44
+
45
+
46
+ def generate_key_pair() -> Tuple[RSAPrivateKey, RSAPublicKey]:
47
+ """
48
+ Generate RSA-2048 key pair for secure token exchange.
49
+
50
+ Returns:
51
+ Tuple of (private_key, public_key).
52
+ """
53
+ private_key = rsa.generate_private_key(
54
+ public_exponent=65537,
55
+ key_size=2048,
56
+ backend=default_backend(),
57
+ )
58
+ public_key = private_key.public_key()
59
+ return private_key, public_key
60
+
61
+
62
+ def decrypt_code(private_key: RSAPrivateKey, encrypted_code: str) -> str:
63
+ """
64
+ Decrypt the RSA-encrypted token from Recce Cloud.
65
+
66
+ Uses RSA-OAEP-SHA1 padding to match Node.js defaults.
67
+
68
+ Args:
69
+ private_key: RSA private key.
70
+ encrypted_code: Base64-encoded encrypted token.
71
+
72
+ Returns:
73
+ Decrypted API token string.
74
+ """
75
+ ciphertext = base64.b64decode(encrypted_code)
76
+ plaintext = private_key.decrypt(
77
+ ciphertext,
78
+ padding.OAEP(
79
+ mgf=padding.MGF1(algorithm=hashes.SHA1()), # Node.js uses SHA1 by default
80
+ algorithm=hashes.SHA1(),
81
+ label=None,
82
+ ),
83
+ )
84
+ return plaintext.decode("utf-8")
85
+
86
+
87
+ def prepare_connection_url(public_key: RSAPublicKey) -> Tuple[str, int]:
88
+ """
89
+ Prepare the OAuth connection URL with public key.
90
+
91
+ Args:
92
+ public_key: RSA public key to include in URL.
93
+
94
+ Returns:
95
+ Tuple of (connection_url, callback_port).
96
+ """
97
+ public_key_pem_bytes = public_key.public_bytes(
98
+ encoding=serialization.Encoding.PEM,
99
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
100
+ )
101
+ public_key_pem_str = base64.b64encode(public_key_pem_bytes).decode("utf-8")
102
+ callback_port = random.randint(10000, 15000)
103
+ connect_url = f"{RECCE_CLOUD_BASE_URL}/connect?_key={public_key_pem_str}&_port={callback_port}"
104
+ return connect_url, callback_port
105
+
106
+
107
+ def verify_token(token: str) -> bool:
108
+ """
109
+ Verify the API token with Recce Cloud.
110
+
111
+ Args:
112
+ token: API token to verify.
113
+
114
+ Returns:
115
+ True if token is valid, False otherwise.
116
+ """
117
+ api_url = f"{RECCE_CLOUD_API_HOST}/api/v1/verify-token"
118
+ try:
119
+ response = requests.get(
120
+ api_url,
121
+ headers={"Authorization": f"Bearer {token}"},
122
+ timeout=30,
123
+ )
124
+ return response.status_code == 200
125
+ except Exception:
126
+ return False
127
+
128
+
129
+ def get_user_info(token: str) -> Optional[dict]:
130
+ """
131
+ Get user information from Recce Cloud.
132
+
133
+ Args:
134
+ token: API token for authentication.
135
+
136
+ Returns:
137
+ User info dict with email, name, etc., or None if failed.
138
+ """
139
+ api_url = f"{RECCE_CLOUD_API_HOST}/api/v1/users"
140
+ try:
141
+ response = requests.get(
142
+ api_url,
143
+ headers={"Authorization": f"Bearer {token}"},
144
+ timeout=30,
145
+ )
146
+ if response.status_code == 200:
147
+ return response.json().get("user")
148
+ except Exception:
149
+ pass
150
+ return None
151
+
152
+
153
+ def login_with_browser() -> bool:
154
+ """
155
+ Perform browser-based OAuth login.
156
+
157
+ Opens browser to Recce Cloud, waits for callback with encrypted token,
158
+ decrypts and verifies token, then saves to profile.
159
+
160
+ Returns:
161
+ True if login successful, False otherwise.
162
+ """
163
+ console.print("Opening browser to authenticate...")
164
+ console.print()
165
+
166
+ # Generate key pair
167
+ private_key, public_key = generate_key_pair()
168
+ connect_url, callback_port = prepare_connection_url(public_key)
169
+
170
+ # Open browser
171
+ webbrowser.open(connect_url)
172
+
173
+ # Always show the URL for manual access
174
+ console.print("If the browser does not open, please visit:")
175
+ console.print(f" [cyan]{connect_url}[/cyan]")
176
+ console.print()
177
+
178
+ # Wait for callback
179
+ console.print("Waiting for authentication...")
180
+ result = CallbackResult()
181
+
182
+ if not run_callback_server(callback_port, result, SUCCESS_HTML, ERROR_HTML):
183
+ if result.error:
184
+ console.print(f"[red]Error:[/red] {result.error}")
185
+ else:
186
+ console.print("[red]Error:[/red] Authentication timed out")
187
+ return False
188
+
189
+ if not result.code:
190
+ console.print("[red]Error:[/red] No authentication code received")
191
+ return False
192
+
193
+ # Decrypt token
194
+ try:
195
+ api_token = decrypt_code(private_key, result.code)
196
+ except Exception as e:
197
+ console.print(f"[red]Error:[/red] Failed to decrypt authentication code: {e}")
198
+ return False
199
+
200
+ # Verify token
201
+ if not verify_token(api_token):
202
+ console.print("[red]Error:[/red] Invalid token received from Recce Cloud")
203
+ return False
204
+
205
+ # Save token
206
+ update_api_token(api_token)
207
+
208
+ # Get user info for display
209
+ user_info = get_user_info(api_token)
210
+ if user_info:
211
+ email = user_info.get("email", "Unknown")
212
+ console.print(f"[green]✓[/green] Logged in as [cyan]{email}[/cyan]")
213
+ else:
214
+ console.print("[green]✓[/green] Logged in successfully")
215
+
216
+ console.print(f" Credentials saved to {get_profile_path()}")
217
+ return True
218
+
219
+
220
+ def login_with_token(token: str) -> bool:
221
+ """
222
+ Login with a manually provided token.
223
+
224
+ Args:
225
+ token: API token to use.
226
+
227
+ Returns:
228
+ True if login successful, False otherwise.
229
+ """
230
+ # Verify token
231
+ if not verify_token(token):
232
+ console.print("[red]Error:[/red] Invalid API token")
233
+ return False
234
+
235
+ # Save token
236
+ update_api_token(token)
237
+
238
+ # Get user info for display
239
+ user_info = get_user_info(token)
240
+ if user_info:
241
+ email = user_info.get("email", "Unknown")
242
+ console.print(f"[green]✓[/green] Logged in as [cyan]{email}[/cyan]")
243
+ else:
244
+ console.print("[green]✓[/green] Logged in successfully")
245
+
246
+ console.print(f" Credentials saved to {get_profile_path()}")
247
+ return True
248
+
249
+
250
+ def check_login_status() -> Tuple[bool, Optional[str]]:
251
+ """
252
+ Check current login status.
253
+
254
+ Checks for authentication token in this order:
255
+ 1. RECCE_API_TOKEN environment variable
256
+ 2. Stored token from profile (via get_api_token)
257
+
258
+ Returns:
259
+ Tuple of (is_logged_in, user_email_or_none).
260
+ """
261
+ token = os.getenv("RECCE_API_TOKEN") or get_api_token()
262
+ if not token:
263
+ return False, None
264
+
265
+ if not verify_token(token):
266
+ return False, None
267
+
268
+ user_info = get_user_info(token)
269
+ email = user_info.get("email") if user_info else None
270
+ return True, email
271
+
272
+
273
+ def logout() -> bool:
274
+ """
275
+ Clear stored credentials.
276
+
277
+ Returns:
278
+ True if logout successful.
279
+ """
280
+ clear_api_token()
281
+ return True