recce-cloud 1.33.1__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.33.1
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
@@ -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:
@@ -488,7 +506,7 @@ class RecceCloudClient:
488
506
  )
489
507
  if response.status_code == 403:
490
508
  raise RecceCloudException(
491
- reason=response.json().get("detail", "Permission denied"),
509
+ reason=self._safe_get_error_detail(response, "Permission denied"),
492
510
  status_code=response.status_code,
493
511
  )
494
512
  if response.status_code not in [200, 201]:
@@ -503,6 +521,240 @@ class RecceCloudClient:
503
521
  return result["session"]
504
522
  return result
505
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
+
506
758
 
507
759
  class ReportClient:
508
760
  """Client for fetching reports from Recce Cloud API."""
recce_cloud/api/github.py CHANGED
@@ -26,7 +26,7 @@ class GitHubRecceCloudClient(BaseRecceCloudClient):
26
26
  self,
27
27
  branch: str,
28
28
  adapter_type: str,
29
- cr_number: Optional[int] = None,
29
+ pr_number: Optional[int] = None,
30
30
  commit_sha: Optional[str] = None,
31
31
  session_type: Optional[str] = None,
32
32
  ) -> Dict:
@@ -36,9 +36,9 @@ class GitHubRecceCloudClient(BaseRecceCloudClient):
36
36
  Args:
37
37
  branch: Branch name
38
38
  adapter_type: DBT adapter type
39
- cr_number: PR number for pull request sessions (None for prod sessions)
39
+ pr_number: PR number for pull request sessions (None for prod sessions)
40
40
  commit_sha: Not used for GitHub (optional for compatibility)
41
- session_type: Session type ("cr", "prod", "dev") - determines if pr_number is passed
41
+ session_type: Session type ("pr", "prod", "dev") - determines if pr_number is passed
42
42
 
43
43
  Returns:
44
44
  Dictionary containing session_id, manifest_upload_url, catalog_upload_url
@@ -50,10 +50,10 @@ class GitHubRecceCloudClient(BaseRecceCloudClient):
50
50
  "adapter_type": adapter_type,
51
51
  }
52
52
 
53
- # Only include pr_number for "cr" type sessions
54
- # For "prod" type, omit pr_number even if cr_number is detected
55
- if session_type == "cr" and cr_number is not None:
56
- payload["pr_number"] = cr_number
53
+ # Only include pr_number for "pr" type sessions
54
+ # For "prod" type, omit pr_number even if pr_number is detected
55
+ if session_type == "pr" and pr_number is not None:
56
+ payload["pr_number"] = pr_number
57
57
 
58
58
  return self._make_request("POST", url, json=payload)
59
59
 
@@ -78,15 +78,15 @@ class GitHubRecceCloudClient(BaseRecceCloudClient):
78
78
 
79
79
  def get_session_download_urls(
80
80
  self,
81
- cr_number: Optional[int] = None,
81
+ pr_number: Optional[int] = None,
82
82
  session_type: Optional[str] = None,
83
83
  ) -> Dict:
84
84
  """
85
85
  Get download URLs for artifacts from a GitHub session.
86
86
 
87
87
  Args:
88
- cr_number: PR number for pull request sessions
89
- session_type: Session type ("cr", "prod", "dev")
88
+ pr_number: PR number for pull request sessions
89
+ session_type: Session type ("pr", "prod", "dev")
90
90
 
91
91
  Returns:
92
92
  Dictionary containing session_id, manifest_url, catalog_url
@@ -99,23 +99,23 @@ class GitHubRecceCloudClient(BaseRecceCloudClient):
99
99
  # For prod session, set base=true
100
100
  if session_type == "prod":
101
101
  params["base"] = "true"
102
- # For CR session, include pr_number
103
- elif session_type == "cr" and cr_number is not None:
104
- params["pr_number"] = cr_number
102
+ # For PR session, include pr_number
103
+ elif session_type == "pr" and pr_number is not None:
104
+ params["pr_number"] = pr_number
105
105
 
106
106
  return self._make_request("GET", url, params=params)
107
107
 
108
108
  def delete_session(
109
109
  self,
110
- cr_number: Optional[int] = None,
110
+ pr_number: Optional[int] = None,
111
111
  session_type: Optional[str] = None,
112
112
  ) -> Dict:
113
113
  """
114
114
  Delete a GitHub PR/base session.
115
115
 
116
116
  Args:
117
- cr_number: PR number for pull request sessions
118
- session_type: Session type ("cr", "prod") - "prod" deletes base session
117
+ pr_number: PR number for pull request sessions
118
+ session_type: Session type ("pr", "prod") - "prod" deletes base session
119
119
 
120
120
  Returns:
121
121
  Dictionary containing session_id of deleted session
@@ -128,8 +128,8 @@ class GitHubRecceCloudClient(BaseRecceCloudClient):
128
128
  # For prod session, set base=true
129
129
  if session_type == "prod":
130
130
  params["base"] = "true"
131
- # For CR session, include pr_number
132
- elif session_type == "cr" and cr_number is not None:
133
- params["pr_number"] = cr_number
131
+ # For PR session, include pr_number
132
+ elif session_type == "pr" and pr_number is not None:
133
+ params["pr_number"] = pr_number
134
134
 
135
135
  return self._make_request("DELETE", url, params=params)
recce_cloud/api/gitlab.py CHANGED
@@ -28,7 +28,7 @@ class GitLabRecceCloudClient(BaseRecceCloudClient):
28
28
  self,
29
29
  branch: str,
30
30
  adapter_type: str,
31
- cr_number: Optional[int] = None,
31
+ pr_number: Optional[int] = None,
32
32
  commit_sha: Optional[str] = None,
33
33
  session_type: Optional[str] = None,
34
34
  ) -> Dict:
@@ -38,9 +38,9 @@ class GitLabRecceCloudClient(BaseRecceCloudClient):
38
38
  Args:
39
39
  branch: Branch name
40
40
  adapter_type: DBT adapter type
41
- cr_number: MR IID for merge request sessions (None for prod sessions)
41
+ pr_number: MR IID for merge request sessions (None for prod sessions)
42
42
  commit_sha: Commit SHA (required for GitLab)
43
- session_type: Session type ("cr", "prod", "dev") - determines if mr_iid is passed
43
+ session_type: Session type ("pr", "prod", "dev") - determines if mr_iid is passed
44
44
 
45
45
  Returns:
46
46
  Dictionary containing session_id, manifest_upload_url, catalog_upload_url
@@ -54,10 +54,10 @@ class GitLabRecceCloudClient(BaseRecceCloudClient):
54
54
  "repository_url": self.repository_url,
55
55
  }
56
56
 
57
- # Only include mr_iid for "cr" type sessions
58
- # For "prod" type, omit mr_iid even if cr_number is detected
59
- if session_type == "cr" and cr_number is not None:
60
- payload["mr_iid"] = cr_number
57
+ # Only include mr_iid for "pr" type sessions
58
+ # For "prod" type, omit mr_iid even if pr_number is detected
59
+ if session_type == "pr" and pr_number is not None:
60
+ payload["mr_iid"] = pr_number
61
61
 
62
62
  return self._make_request("POST", url, json=payload)
63
63
 
@@ -83,15 +83,15 @@ class GitLabRecceCloudClient(BaseRecceCloudClient):
83
83
 
84
84
  def get_session_download_urls(
85
85
  self,
86
- cr_number: Optional[int] = None,
86
+ pr_number: Optional[int] = None,
87
87
  session_type: Optional[str] = None,
88
88
  ) -> Dict:
89
89
  """
90
90
  Get download URLs for artifacts from a GitLab session.
91
91
 
92
92
  Args:
93
- cr_number: MR IID for merge request sessions
94
- session_type: Session type ("cr", "prod", "dev")
93
+ pr_number: MR IID for merge request sessions
94
+ session_type: Session type ("pr", "prod", "dev")
95
95
 
96
96
  Returns:
97
97
  Dictionary containing session_id, manifest_url, catalog_url
@@ -104,23 +104,23 @@ class GitLabRecceCloudClient(BaseRecceCloudClient):
104
104
  # For prod session, set base=true
105
105
  if session_type == "prod":
106
106
  params["base"] = "true"
107
- # For CR session, include mr_iid
108
- elif session_type == "cr" and cr_number is not None:
109
- params["mr_iid"] = cr_number
107
+ # For PR session, include mr_iid
108
+ elif session_type == "pr" and pr_number is not None:
109
+ params["mr_iid"] = pr_number
110
110
 
111
111
  return self._make_request("GET", url, params=params)
112
112
 
113
113
  def delete_session(
114
114
  self,
115
- cr_number: Optional[int] = None,
115
+ pr_number: Optional[int] = None,
116
116
  session_type: Optional[str] = None,
117
117
  ) -> Dict:
118
118
  """
119
119
  Delete a GitLab MR/base session.
120
120
 
121
121
  Args:
122
- cr_number: MR IID for merge request sessions
123
- session_type: Session type ("cr", "prod") - "prod" deletes base session
122
+ pr_number: MR IID for merge request sessions
123
+ session_type: Session type ("pr", "prod") - "prod" deletes base session
124
124
 
125
125
  Returns:
126
126
  Dictionary containing session_id of deleted session
@@ -133,8 +133,8 @@ class GitLabRecceCloudClient(BaseRecceCloudClient):
133
133
  # For prod session, set base=true
134
134
  if session_type == "prod":
135
135
  params["base"] = "true"
136
- # For CR session, include mr_iid
137
- elif session_type == "cr" and cr_number is not None:
138
- params["mr_iid"] = cr_number
136
+ # For PR session, include mr_iid
137
+ elif session_type == "pr" and pr_number is not None:
138
+ params["mr_iid"] = pr_number
139
139
 
140
140
  return self._make_request("DELETE", url, params=params)
@@ -13,9 +13,9 @@ class CIInfo:
13
13
  """Information extracted from CI environment."""
14
14
 
15
15
  platform: Optional[str] = None # "github-actions", "gitlab-ci", etc.
16
- cr_number: Optional[int] = None # Change request number (PR/MR)
17
- cr_url: Optional[str] = None # Change request URL (for session linking)
18
- session_type: Optional[str] = None # "cr", "prod", "dev"
16
+ pr_number: Optional[int] = None # Pull/Merge request number (PR/MR)
17
+ pr_url: Optional[str] = None # Pull/Merge request URL (for session linking)
18
+ session_type: Optional[str] = None # "pr", "prod", "dev"
19
19
  commit_sha: Optional[str] = None # Full commit SHA
20
20
  base_branch: Optional[str] = None # Target/base branch
21
21
  source_branch: Optional[str] = None # Source/head branch
@@ -64,19 +64,19 @@ class BaseCIProvider(ABC):
64
64
  return None
65
65
 
66
66
  @staticmethod
67
- def determine_session_type(cr_number: Optional[int], source_branch: Optional[str]) -> str:
67
+ def determine_session_type(pr_number: Optional[int], source_branch: Optional[str]) -> str:
68
68
  """
69
69
  Determine session type based on context.
70
70
 
71
71
  Args:
72
- cr_number: Change request number (PR/MR)
72
+ pr_number: Pull/Merge request number (PR/MR)
73
73
  source_branch: Source branch name
74
74
 
75
75
  Returns:
76
- Session type: "cr", "prod", or "dev"
76
+ Session type: "pr", "prod", or "dev"
77
77
  """
78
- if cr_number is not None:
79
- return "cr"
78
+ if pr_number is not None:
79
+ return "pr"
80
80
  if source_branch in ["main", "master"]:
81
81
  return "prod"
82
82
  return "dev"