recce-cloud 1.31.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.31.0
1
+ 1.33.1
recce_cloud/__init__.py CHANGED
@@ -1,18 +1,22 @@
1
1
  """Recce Cloud - Lightweight CLI for Recce Cloud operations."""
2
2
 
3
3
  import os
4
+ from importlib.metadata import PackageNotFoundError, version
4
5
 
5
6
 
6
7
  def get_version():
7
- """Get version from VERSION file."""
8
- # Try recce_cloud/VERSION first (for standalone package)
9
- version_file = os.path.join(os.path.dirname(__file__), "VERSION")
10
- if os.path.exists(version_file):
11
- with open(version_file) as fh:
12
- return fh.read().strip()
8
+ """Get version from package metadata or VERSION file."""
9
+ # Try importlib.metadata first (works for installed packages)
10
+ # Try both package names (nightly and official)
11
+ for pkg_name in ["recce-cloud-nightly", "recce-cloud"]:
12
+ try:
13
+ return version(pkg_name)
14
+ except PackageNotFoundError:
15
+ pass
13
16
 
14
- # Fallback to ../recce/VERSION (for development)
15
- version_file = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "recce", "VERSION"))
17
+ # Fallback to VERSION file (for development with editable install)
18
+ # VERSION is now at recce_cloud/recce_cloud/VERSION (same dir as __init__.py)
19
+ version_file = os.path.join(os.path.dirname(__file__), "VERSION")
16
20
  if os.path.exists(version_file):
17
21
  with open(version_file) as fh:
18
22
  return fh.read().strip()
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
 
@@ -224,6 +224,285 @@ class RecceCloudClient:
224
224
  status_code=response.status_code,
225
225
  )
226
226
 
227
+ def upload_completed(self, session_id: str) -> dict:
228
+ """
229
+ Notify Recce Cloud that upload is complete for a session.
230
+
231
+ This triggers post-upload processing such as AI summary generation.
232
+
233
+ Args:
234
+ session_id: Session ID to notify completion for
235
+
236
+ Returns:
237
+ dict containing acknowledgement or empty dict
238
+
239
+ Raises:
240
+ RecceCloudException: If the request fails
241
+ """
242
+ api_url = f"{self.base_url_v2}/sessions/{session_id}/upload-completed"
243
+ response = self._request("POST", api_url)
244
+ if response.status_code in [200, 204]:
245
+ if response.status_code == 204 or not response.content:
246
+ return {}
247
+ return response.json()
248
+ if response.status_code == 403:
249
+ raise RecceCloudException(
250
+ reason=response.json().get("detail", "Permission denied"),
251
+ status_code=response.status_code,
252
+ )
253
+ if response.status_code == 404:
254
+ raise RecceCloudException(
255
+ reason="Session not found",
256
+ status_code=response.status_code,
257
+ )
258
+ raise RecceCloudException(
259
+ reason=response.text,
260
+ status_code=response.status_code,
261
+ )
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
+
227
506
 
228
507
  class ReportClient:
229
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")