recce-cloud 1.32.0__py3-none-any.whl → 1.34.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.
recce_cloud/VERSION CHANGED
@@ -1 +1 @@
1
- 1.32.0
1
+ 1.34.0
recce_cloud/api/base.py CHANGED
@@ -74,7 +74,7 @@ class BaseRecceCloudClient(ABC):
74
74
  self,
75
75
  branch: str,
76
76
  adapter_type: str,
77
- cr_number: Optional[int] = None,
77
+ pr_number: Optional[int] = None,
78
78
  commit_sha: Optional[str] = None,
79
79
  session_type: Optional[str] = None,
80
80
  ) -> Dict:
@@ -84,9 +84,9 @@ class BaseRecceCloudClient(ABC):
84
84
  Args:
85
85
  branch: Branch name
86
86
  adapter_type: DBT adapter type (e.g., 'postgres', 'snowflake', 'bigquery')
87
- cr_number: Change request number (PR/MR number) for CR sessions
87
+ pr_number: Pull/Merge request number (PR/MR number) for PR sessions
88
88
  commit_sha: Commit SHA (GitLab requires this)
89
- session_type: Session type ("cr", "prod", "dev") - determines if cr_number is used
89
+ session_type: Session type ("pr", "prod", "dev") - determines if pr_number is used
90
90
 
91
91
  Returns:
92
92
  Dictionary containing:
@@ -113,15 +113,15 @@ class BaseRecceCloudClient(ABC):
113
113
  @abstractmethod
114
114
  def get_session_download_urls(
115
115
  self,
116
- cr_number: Optional[int] = None,
116
+ pr_number: Optional[int] = None,
117
117
  session_type: Optional[str] = None,
118
118
  ) -> Dict:
119
119
  """
120
120
  Get download URLs for artifacts from a session.
121
121
 
122
122
  Args:
123
- cr_number: Change request number (PR/MR number) for CR sessions
124
- session_type: Session type ("cr", "prod", "dev")
123
+ pr_number: Pull/Merge request number (PR/MR number) for PR sessions
124
+ session_type: Session type ("pr", "prod", "dev")
125
125
 
126
126
  Returns:
127
127
  Dictionary containing:
@@ -134,15 +134,15 @@ class BaseRecceCloudClient(ABC):
134
134
  @abstractmethod
135
135
  def delete_session(
136
136
  self,
137
- cr_number: Optional[int] = None,
137
+ pr_number: Optional[int] = None,
138
138
  session_type: Optional[str] = None,
139
139
  ) -> Dict:
140
140
  """
141
141
  Delete a session.
142
142
 
143
143
  Args:
144
- cr_number: Change request number (PR/MR number) for CR sessions
145
- session_type: Session type ("cr", "prod") - "prod" deletes base session
144
+ pr_number: Pull/Merge request number (PR/MR number) for PR sessions
145
+ session_type: Session type ("pr", "prod") - "prod" deletes base session
146
146
 
147
147
  Returns:
148
148
  Dictionary containing:
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
 
@@ -42,6 +42,24 @@ class RecceCloudClient:
42
42
  }
43
43
  return requests.request(method, url, headers=headers, **kwargs)
44
44
 
45
+ def _safe_get_error_detail(self, response, default: str) -> str:
46
+ """Safely extract error detail from response JSON.
47
+
48
+ Some error responses may not have a valid JSON body (e.g., HTML error pages
49
+ from proxies), so we need to handle JSONDecodeError gracefully.
50
+
51
+ Args:
52
+ response: The HTTP response object.
53
+ default: Default message to return if JSON parsing fails.
54
+
55
+ Returns:
56
+ The error detail string from the response, or the default message.
57
+ """
58
+ try:
59
+ return response.json().get("detail", default)
60
+ except (json.JSONDecodeError, ValueError):
61
+ return default
62
+
45
63
  def _replace_localhost_with_docker_internal(self, url: str) -> str:
46
64
  """Convert localhost URLs to docker internal URLs if running in Docker."""
47
65
  if url is None:
@@ -75,7 +93,7 @@ class RecceCloudClient:
75
93
  api_url = f"{self.base_url_v2}/sessions/{session_id}"
76
94
  response = self._request("GET", api_url)
77
95
  if response.status_code == 403:
78
- return {"status": "error", "message": response.json().get("detail")}
96
+ return {"status": "error", "message": self._safe_get_error_detail(response, "Permission denied")}
79
97
  if response.status_code != 200:
80
98
  raise RecceCloudException(
81
99
  reason=response.text,
@@ -181,7 +199,7 @@ class RecceCloudClient:
181
199
  data = {"adapter_type": adapter_type}
182
200
  response = self._request("PATCH", api_url, json=data)
183
201
  if response.status_code == 403:
184
- return {"status": "error", "message": response.json().get("detail")}
202
+ return {"status": "error", "message": self._safe_get_error_detail(response, "Permission denied")}
185
203
  if response.status_code != 200:
186
204
  raise RecceCloudException(
187
205
  reason=response.text,
@@ -211,7 +229,7 @@ class RecceCloudClient:
211
229
  return True
212
230
  if response.status_code == 403:
213
231
  raise RecceCloudException(
214
- reason=response.json().get("detail", "Permission denied"),
232
+ reason=self._safe_get_error_detail(response, "Permission denied"),
215
233
  status_code=response.status_code,
216
234
  )
217
235
  if response.status_code == 404:
@@ -247,7 +265,7 @@ class RecceCloudClient:
247
265
  return response.json()
248
266
  if response.status_code == 403:
249
267
  raise RecceCloudException(
250
- reason=response.json().get("detail", "Permission denied"),
268
+ reason=self._safe_get_error_detail(response, "Permission denied"),
251
269
  status_code=response.status_code,
252
270
  )
253
271
  if response.status_code == 404:
@@ -260,6 +278,483 @@ class RecceCloudClient:
260
278
  status_code=response.status_code,
261
279
  )
262
280
 
281
+ def list_organizations(self) -> List[Dict[str, Any]]:
282
+ """
283
+ List all organizations the user has access to.
284
+
285
+ Returns:
286
+ List of organization dictionaries with id, name, slug fields.
287
+
288
+ Raises:
289
+ RecceCloudException: If the API call fails.
290
+ """
291
+ api_url = f"{self.base_url_v2}/organizations"
292
+ response = self._request("GET", api_url)
293
+
294
+ if response.status_code != 200:
295
+ raise RecceCloudException(
296
+ reason=response.text,
297
+ status_code=response.status_code,
298
+ )
299
+
300
+ data = response.json()
301
+ return data.get("organizations", [])
302
+
303
+ def list_projects(self, org_id: str) -> List[Dict[str, Any]]:
304
+ """
305
+ List all projects in an organization.
306
+
307
+ Args:
308
+ org_id: Organization ID or slug.
309
+
310
+ Returns:
311
+ List of project dictionaries with id, name, slug fields.
312
+
313
+ Raises:
314
+ RecceCloudException: If the API call fails.
315
+ """
316
+ api_url = f"{self.base_url_v2}/organizations/{org_id}/projects"
317
+ response = self._request("GET", api_url)
318
+
319
+ if response.status_code != 200:
320
+ raise RecceCloudException(
321
+ reason=response.text,
322
+ status_code=response.status_code,
323
+ )
324
+
325
+ data = response.json()
326
+ return data.get("projects", [])
327
+
328
+ def get_organization(self, org_id: str) -> Optional[Dict[str, Any]]:
329
+ """
330
+ Get a specific organization by ID or slug.
331
+
332
+ Args:
333
+ org_id: Organization ID or slug.
334
+
335
+ Returns:
336
+ Organization dictionary, or None if not found.
337
+
338
+ Raises:
339
+ RecceCloudException: If the API call fails.
340
+ """
341
+ orgs = self.list_organizations()
342
+ for org in orgs:
343
+ # Compare as strings to handle both int and str IDs
344
+ if str(org.get("id")) == str(org_id) or org.get("slug") == org_id or org.get("name") == org_id:
345
+ return org
346
+ return None
347
+
348
+ def get_project(self, org_id: str, project_id: str) -> Optional[Dict[str, Any]]:
349
+ """
350
+ Get a specific project by ID or slug.
351
+
352
+ Args:
353
+ org_id: Organization ID or slug.
354
+ project_id: Project ID or slug.
355
+
356
+ Returns:
357
+ Project dictionary, or None if not found.
358
+
359
+ Raises:
360
+ RecceCloudException: If the API call fails.
361
+ """
362
+ projects = self.list_projects(org_id)
363
+ for project in projects:
364
+ # Compare as strings to handle both int and str IDs
365
+ if (
366
+ str(project.get("id")) == str(project_id)
367
+ or project.get("slug") == project_id
368
+ or project.get("name") == project_id
369
+ ):
370
+ return project
371
+ return None
372
+
373
+ def list_sessions(
374
+ self,
375
+ org_id: str,
376
+ project_id: str,
377
+ session_name: Optional[str] = None,
378
+ session_type: Optional[str] = None,
379
+ branch: Optional[str] = None,
380
+ limit: Optional[int] = None,
381
+ offset: Optional[int] = None,
382
+ ) -> List[Dict[str, Any]]:
383
+ """
384
+ List sessions in a project with optional filtering.
385
+
386
+ Args:
387
+ org_id: Organization ID or slug.
388
+ project_id: Project ID or slug.
389
+ session_name: Filter by session name (exact match).
390
+ session_type: Filter by session type (e.g., "pr", "prod", "manual").
391
+ branch: Filter by branch name (exact match).
392
+ limit: Maximum number of results to return (1-1000).
393
+ offset: Number of results to skip for pagination.
394
+
395
+ Returns:
396
+ List of session dictionaries.
397
+
398
+ Raises:
399
+ RecceCloudException: If the API call fails.
400
+ """
401
+ api_url = f"{self.base_url_v2}/organizations/{org_id}/projects/{project_id}/sessions"
402
+ params = {}
403
+ if session_name:
404
+ params["name"] = session_name
405
+ if session_type:
406
+ params["type"] = session_type
407
+ if branch:
408
+ params["branch"] = branch
409
+ if limit is not None:
410
+ params["limit"] = limit
411
+ if offset is not None:
412
+ params["offset"] = offset
413
+
414
+ response = self._request("GET", api_url, params=params if params else None)
415
+
416
+ if response.status_code == 404:
417
+ raise RecceCloudException(
418
+ reason="Organization or project not found",
419
+ status_code=response.status_code,
420
+ )
421
+ if response.status_code != 200:
422
+ raise RecceCloudException(
423
+ reason=response.text,
424
+ status_code=response.status_code,
425
+ )
426
+
427
+ data = response.json()
428
+ return data.get("sessions", [])
429
+
430
+ def get_session_by_name(
431
+ self,
432
+ org_id: str,
433
+ project_id: str,
434
+ session_name: str,
435
+ ) -> Optional[Dict[str, Any]]:
436
+ """
437
+ Get a session by its name.
438
+
439
+ Uses the list sessions endpoint with filtering to find a session
440
+ by name, which is unique within a project.
441
+
442
+ Args:
443
+ org_id: Organization ID or slug.
444
+ project_id: Project ID or slug.
445
+ session_name: The session name to look up.
446
+
447
+ Returns:
448
+ Session dictionary if found, None otherwise.
449
+
450
+ Raises:
451
+ RecceCloudException: If the API call fails.
452
+ """
453
+ sessions = self.list_sessions(
454
+ org_id=org_id,
455
+ project_id=project_id,
456
+ session_name=session_name,
457
+ limit=1,
458
+ )
459
+ if sessions:
460
+ return sessions[0]
461
+ return None
462
+
463
+ def create_session(
464
+ self,
465
+ org_id: str,
466
+ project_id: str,
467
+ session_name: str,
468
+ adapter_type: Optional[str] = None,
469
+ session_type: str = "manual",
470
+ ) -> Dict[str, Any]:
471
+ """
472
+ Create a new session with the given name.
473
+
474
+ Args:
475
+ org_id: Organization ID or slug.
476
+ project_id: Project ID or slug.
477
+ session_name: The name for the new session.
478
+ adapter_type: dbt adapter type (e.g., "postgres", "snowflake").
479
+ session_type: Session type (default: "manual").
480
+
481
+ Returns:
482
+ Created session dictionary with id, name, and other fields.
483
+
484
+ Raises:
485
+ RecceCloudException: If the API call fails or session creation fails.
486
+ """
487
+ api_url = f"{self.base_url_v2}/organizations/{org_id}/projects/{project_id}/sessions"
488
+ data = {
489
+ "name": session_name,
490
+ "type": session_type,
491
+ }
492
+ if adapter_type:
493
+ data["adapter_type"] = adapter_type
494
+
495
+ response = self._request("POST", api_url, json=data)
496
+
497
+ if response.status_code == 404:
498
+ raise RecceCloudException(
499
+ reason="Organization or project not found",
500
+ status_code=response.status_code,
501
+ )
502
+ if response.status_code == 409:
503
+ raise RecceCloudException(
504
+ reason=f"Session with name '{session_name}' already exists",
505
+ status_code=response.status_code,
506
+ )
507
+ if response.status_code == 403:
508
+ raise RecceCloudException(
509
+ reason=self._safe_get_error_detail(response, "Permission denied"),
510
+ status_code=response.status_code,
511
+ )
512
+ if response.status_code not in [200, 201]:
513
+ raise RecceCloudException(
514
+ reason=response.text,
515
+ status_code=response.status_code,
516
+ )
517
+
518
+ result = response.json()
519
+ # Handle both direct session response and wrapped response
520
+ if "session" in result:
521
+ return result["session"]
522
+ return result
523
+
524
+ def check_prerequisites(
525
+ self,
526
+ org_id: str,
527
+ project_id: str,
528
+ session_id: str,
529
+ ) -> Dict[str, Any]:
530
+ """
531
+ Check prerequisites for data review generation.
532
+
533
+ This calls the backend API to verify:
534
+ 1. Session exists and belongs to the project
535
+ 2. Session has artifacts uploaded (adapter_type is set)
536
+ 3. Base session exists for the project
537
+ 4. Base session has artifacts uploaded
538
+
539
+ Args:
540
+ org_id: Organization ID or slug.
541
+ project_id: Project ID or slug.
542
+ session_id: Session ID to check.
543
+
544
+ Returns:
545
+ dict with:
546
+ - success: bool
547
+ - session_id: str
548
+ - session_name: str
549
+ - adapter_type: str or None
550
+ - has_base_session: bool
551
+ - base_session_has_artifacts: bool
552
+ - is_ready: bool
553
+ - reason: str or None (explains why not ready)
554
+
555
+ Raises:
556
+ RecceCloudException: If the API call fails.
557
+ """
558
+ api_url = (
559
+ f"{self.base_url_v2}/organizations/{org_id}/projects/{project_id}/sessions/{session_id}/check-prerequisites"
560
+ )
561
+ response = self._request("GET", api_url)
562
+
563
+ if response.status_code == 404:
564
+ error_detail = "Session or project not found"
565
+ try:
566
+ error_detail = response.json().get("detail", error_detail)
567
+ except Exception:
568
+ pass
569
+ raise RecceCloudException(
570
+ reason=error_detail,
571
+ status_code=response.status_code,
572
+ )
573
+ if response.status_code == 400:
574
+ raise RecceCloudException(
575
+ reason=self._safe_get_error_detail(response, "Bad request"),
576
+ status_code=response.status_code,
577
+ )
578
+ if response.status_code == 403:
579
+ raise RecceCloudException(
580
+ reason=self._safe_get_error_detail(response, "Permission denied"),
581
+ status_code=response.status_code,
582
+ )
583
+ if response.status_code != 200:
584
+ raise RecceCloudException(
585
+ reason=response.text,
586
+ status_code=response.status_code,
587
+ )
588
+
589
+ return response.json()
590
+
591
+ def generate_data_review(
592
+ self,
593
+ org_id: str,
594
+ project_id: str,
595
+ session_id: str,
596
+ regenerate: bool = False,
597
+ ) -> Dict[str, Any]:
598
+ """
599
+ Trigger data review generation for a session.
600
+
601
+ Args:
602
+ org_id: Organization ID or slug.
603
+ project_id: Project ID or slug.
604
+ session_id: Session ID to generate review for.
605
+ regenerate: If True, regenerate even if a review already exists.
606
+
607
+ Returns:
608
+ dict with task_id if a new task was created, or empty if review already exists.
609
+
610
+ Raises:
611
+ RecceCloudException: If the API call fails.
612
+ """
613
+ api_url = f"{self.base_url_v2}/organizations/{org_id}/projects/{project_id}/sessions/{session_id}/recce_summary"
614
+ data = {"regenerate": regenerate}
615
+
616
+ response = self._request("POST", api_url, json=data)
617
+
618
+ if response.status_code == 404:
619
+ raise RecceCloudException(
620
+ reason="Session not found",
621
+ status_code=response.status_code,
622
+ )
623
+ if response.status_code == 403:
624
+ raise RecceCloudException(
625
+ reason=self._safe_get_error_detail(response, "Permission denied"),
626
+ status_code=response.status_code,
627
+ )
628
+ if response.status_code == 400:
629
+ raise RecceCloudException(
630
+ reason=self._safe_get_error_detail(response, "Bad request"),
631
+ status_code=response.status_code,
632
+ )
633
+ if response.status_code not in [200, 201, 202]:
634
+ raise RecceCloudException(
635
+ reason=response.text,
636
+ status_code=response.status_code,
637
+ )
638
+
639
+ return response.json()
640
+
641
+ def get_data_review(
642
+ self,
643
+ org_id: str,
644
+ project_id: str,
645
+ session_id: str,
646
+ ) -> Optional[Dict[str, Any]]:
647
+ """
648
+ Get the existing data review for a session.
649
+
650
+ Args:
651
+ org_id: Organization ID or slug.
652
+ project_id: Project ID or slug.
653
+ session_id: Session ID to get review for.
654
+
655
+ Returns:
656
+ dict with session_id, session_name, summary (content), trace_id if found.
657
+ None if no review exists.
658
+
659
+ Raises:
660
+ RecceCloudException: If the API call fails.
661
+ """
662
+ api_url = f"{self.base_url_v2}/organizations/{org_id}/projects/{project_id}/sessions/{session_id}/recce_summary"
663
+ response = self._request("GET", api_url)
664
+
665
+ if response.status_code == 404:
666
+ # No review exists for this session
667
+ return None
668
+ if response.status_code == 403:
669
+ raise RecceCloudException(
670
+ reason=self._safe_get_error_detail(response, "Permission denied"),
671
+ status_code=response.status_code,
672
+ )
673
+ if response.status_code != 200:
674
+ raise RecceCloudException(
675
+ reason=response.text,
676
+ status_code=response.status_code,
677
+ )
678
+
679
+ return response.json()
680
+
681
+ def get_running_task(
682
+ self,
683
+ org_id: str,
684
+ project_id: str,
685
+ session_id: str,
686
+ ) -> Optional[Dict[str, Any]]:
687
+ """
688
+ Get the currently running task for a session.
689
+
690
+ Args:
691
+ org_id: Organization ID or slug.
692
+ project_id: Project ID or slug.
693
+ session_id: Session ID to check.
694
+
695
+ Returns:
696
+ dict with task_id and status if a task is running, None otherwise.
697
+
698
+ Raises:
699
+ RecceCloudException: If the API call fails.
700
+ """
701
+ api_url = f"{self.base_url_v2}/organizations/{org_id}/projects/{project_id}/sessions/{session_id}/running_task"
702
+ response = self._request("GET", api_url)
703
+
704
+ if response.status_code == 404:
705
+ return None
706
+ if response.status_code == 403:
707
+ raise RecceCloudException(
708
+ reason=self._safe_get_error_detail(response, "Permission denied"),
709
+ status_code=response.status_code,
710
+ )
711
+ if response.status_code != 200:
712
+ raise RecceCloudException(
713
+ reason=response.text,
714
+ status_code=response.status_code,
715
+ )
716
+
717
+ data = response.json()
718
+ # Return None if no task is running
719
+ if data.get("task_id") is None:
720
+ return None
721
+ return data
722
+
723
+ def get_task_status(self, org_id: str, task_id: str) -> Dict[str, Any]:
724
+ """
725
+ Get the status of a task by ID.
726
+
727
+ Args:
728
+ org_id: Organization ID or slug.
729
+ task_id: Task ID to check.
730
+
731
+ Returns:
732
+ dict with id, command, status, created_at, started_at, finished_at, metadata.
733
+
734
+ Raises:
735
+ RecceCloudException: If the API call fails or task not found.
736
+ """
737
+ api_url = f"{self.base_url_v2}/organizations/{org_id}/tasks/{task_id}/status"
738
+ response = self._request("GET", api_url)
739
+
740
+ if response.status_code == 404:
741
+ raise RecceCloudException(
742
+ reason="Task not found",
743
+ status_code=response.status_code,
744
+ )
745
+ if response.status_code == 403:
746
+ raise RecceCloudException(
747
+ reason=self._safe_get_error_detail(response, "Permission denied"),
748
+ status_code=response.status_code,
749
+ )
750
+ if response.status_code != 200:
751
+ raise RecceCloudException(
752
+ reason=response.text,
753
+ status_code=response.status_code,
754
+ )
755
+
756
+ return response.json()
757
+
263
758
 
264
759
  class ReportClient:
265
760
  """Client for fetching reports from Recce Cloud API."""