recce-nightly 1.2.0.20250506__py3-none-any.whl → 1.26.0.20251124__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.

Potentially problematic release.


This version of recce-nightly might be problematic. Click here for more details.

Files changed (213) hide show
  1. recce/VERSION +1 -1
  2. recce/__init__.py +27 -22
  3. recce/adapter/base.py +11 -14
  4. recce/adapter/dbt_adapter/__init__.py +810 -480
  5. recce/adapter/dbt_adapter/dbt_version.py +3 -0
  6. recce/adapter/sqlmesh_adapter.py +24 -35
  7. recce/apis/check_api.py +39 -28
  8. recce/apis/check_func.py +33 -27
  9. recce/apis/run_api.py +25 -19
  10. recce/apis/run_func.py +29 -23
  11. recce/artifact.py +119 -51
  12. recce/cli.py +1299 -323
  13. recce/config.py +42 -33
  14. recce/connect_to_cloud.py +138 -0
  15. recce/core.py +55 -47
  16. recce/data/404.html +1 -1
  17. recce/data/__next.__PAGE__.txt +10 -0
  18. recce/data/__next._full.txt +23 -0
  19. recce/data/__next._head.txt +8 -0
  20. recce/data/__next._index.txt +8 -0
  21. recce/data/__next._tree.txt +5 -0
  22. recce/data/_next/static/52aV_JrNUZU6dMFgvTQEO/_buildManifest.js +11 -0
  23. recce/data/_next/static/52aV_JrNUZU6dMFgvTQEO/_clientMiddlewareManifest.json +1 -0
  24. recce/data/_next/static/chunks/02b996c7f6a29a06.js +4 -0
  25. recce/data/_next/static/chunks/19c10d219a6a21ff.js +1 -0
  26. recce/data/_next/static/chunks/2df9ec28a061971d.js +11 -0
  27. recce/data/_next/static/chunks/3098c987393bda15.js +1 -0
  28. recce/data/_next/static/chunks/393dc43e483f717a.css +2 -0
  29. recce/data/_next/static/chunks/399e8d91a7e45073.js +2 -0
  30. recce/data/_next/static/chunks/4d0186f631230245.js +1 -0
  31. recce/data/_next/static/chunks/5794ba9e10a9c060.js +11 -0
  32. recce/data/_next/static/chunks/715761c929a3f28b.js +110 -0
  33. recce/data/_next/static/chunks/71f88fcc615bf282.js +1 -0
  34. recce/data/_next/static/chunks/80d2a95eaf1201ea.js +1 -0
  35. recce/data/_next/static/chunks/9979c6109bbbee35.js +1 -0
  36. recce/data/_next/static/chunks/99d638224186c118.js +1 -0
  37. recce/data/_next/static/chunks/d003eb36240e92f3.js +1 -0
  38. recce/data/_next/static/chunks/d3167cdfec4fc351.js +1 -0
  39. recce/data/_next/static/chunks/e124bccf574a3361.css +1 -0
  40. recce/data/_next/static/chunks/f40141db1bdb46f0.css +6 -0
  41. recce/data/_next/static/chunks/fcc53a88741a52f9.js +1 -0
  42. recce/data/_next/static/chunks/turbopack-b1920d28cfb1f28d.js +3 -0
  43. recce/data/_next/static/media/favicon.a8d38d84.ico +0 -0
  44. recce/data/_next/static/media/montserrat-cyrillic-800-normal.d80d830d.woff2 +0 -0
  45. recce/data/_next/static/media/montserrat-cyrillic-800-normal.f9d58125.woff +0 -0
  46. recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.076c2a93.woff2 +0 -0
  47. recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.a4fa76b5.woff +0 -0
  48. recce/data/_next/static/media/montserrat-latin-800-normal.cde454cc.woff2 +0 -0
  49. recce/data/_next/static/media/montserrat-latin-800-normal.d5761935.woff +0 -0
  50. recce/data/_next/static/media/montserrat-latin-ext-800-normal.40ec0659.woff2 +0 -0
  51. recce/data/_next/static/media/montserrat-latin-ext-800-normal.b671449b.woff +0 -0
  52. recce/data/_next/static/media/montserrat-vietnamese-800-normal.9f7b8541.woff +0 -0
  53. recce/data/_next/static/media/montserrat-vietnamese-800-normal.f9eb854e.woff2 +0 -0
  54. recce/data/_next/static/media/reload-image.7aa931c7.svg +4 -0
  55. recce/data/_not-found/__next._full.txt +17 -0
  56. recce/data/_not-found/__next._head.txt +8 -0
  57. recce/data/_not-found/__next._index.txt +8 -0
  58. recce/data/_not-found/__next._not-found.__PAGE__.txt +5 -0
  59. recce/data/_not-found/__next._not-found.txt +4 -0
  60. recce/data/_not-found/__next._tree.txt +3 -0
  61. recce/data/_not-found.html +1 -0
  62. recce/data/_not-found.txt +17 -0
  63. recce/data/auth_callback.html +68 -0
  64. recce/data/imgs/reload-image.svg +4 -0
  65. recce/data/index.html +1 -27
  66. recce/data/index.txt +23 -7
  67. recce/diff.py +6 -12
  68. recce/event/__init__.py +86 -74
  69. recce/event/collector.py +33 -22
  70. recce/event/track.py +49 -27
  71. recce/exceptions.py +1 -1
  72. recce/git.py +7 -7
  73. recce/github.py +57 -53
  74. recce/mcp_server.py +716 -0
  75. recce/models/__init__.py +4 -1
  76. recce/models/check.py +6 -7
  77. recce/models/run.py +1 -0
  78. recce/models/types.py +131 -28
  79. recce/pull_request.py +27 -25
  80. recce/run.py +165 -121
  81. recce/server.py +303 -111
  82. recce/state/__init__.py +31 -0
  83. recce/state/cloud.py +632 -0
  84. recce/state/const.py +26 -0
  85. recce/state/local.py +56 -0
  86. recce/state/state.py +119 -0
  87. recce/state/state_loader.py +174 -0
  88. recce/summary.py +188 -143
  89. recce/tasks/__init__.py +19 -3
  90. recce/tasks/core.py +11 -13
  91. recce/tasks/dataframe.py +82 -18
  92. recce/tasks/histogram.py +69 -34
  93. recce/tasks/lineage.py +2 -2
  94. recce/tasks/profile.py +152 -86
  95. recce/tasks/query.py +139 -87
  96. recce/tasks/rowcount.py +37 -31
  97. recce/tasks/schema.py +18 -15
  98. recce/tasks/top_k.py +35 -35
  99. recce/tasks/valuediff.py +216 -152
  100. recce/util/__init__.py +3 -0
  101. recce/util/api_token.py +80 -0
  102. recce/util/breaking.py +87 -85
  103. recce/util/cll.py +274 -219
  104. recce/util/io.py +22 -17
  105. recce/util/lineage.py +65 -16
  106. recce/util/logger.py +1 -1
  107. recce/util/onboarding_state.py +45 -0
  108. recce/util/perf_tracking.py +85 -0
  109. recce/util/recce_cloud.py +322 -72
  110. recce/util/singleton.py +4 -4
  111. recce/yaml/__init__.py +7 -10
  112. recce_cloud/__init__.py +24 -0
  113. recce_cloud/api/__init__.py +17 -0
  114. recce_cloud/api/base.py +111 -0
  115. recce_cloud/api/client.py +150 -0
  116. recce_cloud/api/exceptions.py +26 -0
  117. recce_cloud/api/factory.py +63 -0
  118. recce_cloud/api/github.py +76 -0
  119. recce_cloud/api/gitlab.py +82 -0
  120. recce_cloud/artifact.py +57 -0
  121. recce_cloud/ci_providers/__init__.py +9 -0
  122. recce_cloud/ci_providers/base.py +82 -0
  123. recce_cloud/ci_providers/detector.py +147 -0
  124. recce_cloud/ci_providers/github_actions.py +136 -0
  125. recce_cloud/ci_providers/gitlab_ci.py +130 -0
  126. recce_cloud/cli.py +245 -0
  127. recce_cloud/upload.py +214 -0
  128. {recce_nightly-1.2.0.20250506.dist-info → recce_nightly-1.26.0.20251124.dist-info}/METADATA +68 -37
  129. recce_nightly-1.26.0.20251124.dist-info/RECORD +180 -0
  130. {recce_nightly-1.2.0.20250506.dist-info → recce_nightly-1.26.0.20251124.dist-info}/WHEEL +1 -1
  131. {recce_nightly-1.2.0.20250506.dist-info → recce_nightly-1.26.0.20251124.dist-info}/top_level.txt +1 -0
  132. tests/adapter/dbt_adapter/conftest.py +9 -5
  133. tests/adapter/dbt_adapter/dbt_test_helper.py +37 -22
  134. tests/adapter/dbt_adapter/test_dbt_adapter.py +0 -15
  135. tests/adapter/dbt_adapter/test_dbt_cll.py +656 -41
  136. tests/adapter/dbt_adapter/test_selector.py +22 -21
  137. tests/recce_cloud/__init__.py +0 -0
  138. tests/recce_cloud/test_ci_providers.py +351 -0
  139. tests/recce_cloud/test_cli.py +372 -0
  140. tests/recce_cloud/test_client.py +273 -0
  141. tests/recce_cloud/test_platform_clients.py +333 -0
  142. tests/tasks/conftest.py +1 -1
  143. tests/tasks/test_histogram.py +58 -66
  144. tests/tasks/test_lineage.py +36 -23
  145. tests/tasks/test_preset_checks.py +45 -31
  146. tests/tasks/test_profile.py +339 -15
  147. tests/tasks/test_query.py +46 -46
  148. tests/tasks/test_row_count.py +65 -46
  149. tests/tasks/test_schema.py +65 -42
  150. tests/tasks/test_top_k.py +22 -18
  151. tests/tasks/test_valuediff.py +43 -32
  152. tests/test_cli.py +174 -60
  153. tests/test_cli_mcp_optional.py +45 -0
  154. tests/test_cloud_listing_cli.py +324 -0
  155. tests/test_config.py +7 -9
  156. tests/test_connect_to_cloud.py +82 -0
  157. tests/test_core.py +151 -4
  158. tests/test_dbt.py +7 -7
  159. tests/test_mcp_server.py +332 -0
  160. tests/test_pull_request.py +1 -1
  161. tests/test_server.py +25 -19
  162. tests/test_summary.py +29 -17
  163. recce/data/_next/static/Kcbs3GEIyH2LxgLYat0es/_buildManifest.js +0 -1
  164. recce/data/_next/static/chunks/1f229bf6-d9fe92e56db8d93b.js +0 -1
  165. recce/data/_next/static/chunks/29e3cc0d-8c150e37dff9631b.js +0 -1
  166. recce/data/_next/static/chunks/368-7587b306577df275.js +0 -65
  167. recce/data/_next/static/chunks/36e1c10d-bb0210cbd6573a8d.js +0 -1
  168. recce/data/_next/static/chunks/3998a672-eaad84bdd88cc73e.js +0 -1
  169. recce/data/_next/static/chunks/3a92ee20-3b5d922d4157af5e.js +0 -1
  170. recce/data/_next/static/chunks/450c323b-1bb5db526e54435a.js +0 -1
  171. recce/data/_next/static/chunks/47d8844f-79a1b53c66a7d7ec.js +0 -1
  172. recce/data/_next/static/chunks/6dc81886-c94b9b91bc2c3caf.js +0 -1
  173. recce/data/_next/static/chunks/6ef81909-694dc38134099299.js +0 -1
  174. recce/data/_next/static/chunks/700-3b65fc3666820d00.js +0 -2
  175. recce/data/_next/static/chunks/7a8a3e83-d7fa409d97b38b2b.js +0 -1
  176. recce/data/_next/static/chunks/7f27ae6c-413f6b869a04183a.js +0 -1
  177. recce/data/_next/static/chunks/8d700b6a-f0b1f6b9e0d97ce2.js +0 -1
  178. recce/data/_next/static/chunks/9746af58-d74bef4d03eea6ab.js +0 -1
  179. recce/data/_next/static/chunks/a30376cd-7d806e1602f2dc3a.js +0 -1
  180. recce/data/_next/static/chunks/app/_not-found/page-8a886fa0855c3105.js +0 -1
  181. recce/data/_next/static/chunks/app/layout-9102e22cb73f74d6.js +0 -1
  182. recce/data/_next/static/chunks/app/page-cee661090afbd6aa.js +0 -1
  183. recce/data/_next/static/chunks/b63b1b3f-7395c74e11a14e95.js +0 -1
  184. recce/data/_next/static/chunks/c132bf7d-8102037f9ccf372a.js +0 -1
  185. recce/data/_next/static/chunks/c1ceaa8b-a1e442154d23515e.js +0 -1
  186. recce/data/_next/static/chunks/cd9f8d63-cf0d5a7b0f7a92e8.js +0 -54
  187. recce/data/_next/static/chunks/ce84277d-f42c2c58049cea2d.js +0 -1
  188. recce/data/_next/static/chunks/e24bf851-0f8cbc99656833e7.js +0 -1
  189. recce/data/_next/static/chunks/fee69bc6-f17d36c080742e74.js +0 -1
  190. recce/data/_next/static/chunks/framework-ded83d71b51ce901.js +0 -1
  191. recce/data/_next/static/chunks/main-a0859f1f36d0aa6c.js +0 -1
  192. recce/data/_next/static/chunks/main-app-0225a2255968e566.js +0 -1
  193. recce/data/_next/static/chunks/pages/_app-d5672bf3d8b6371b.js +0 -1
  194. recce/data/_next/static/chunks/pages/_error-ed75be3f25588548.js +0 -1
  195. recce/data/_next/static/chunks/webpack-567d72f0bc0820d5.js +0 -1
  196. recce/data/_next/static/css/c9ecb46a4b21c126.css +0 -14
  197. recce/data/_next/static/media/montserrat-cyrillic-800-normal.22628180.woff2 +0 -0
  198. recce/data/_next/static/media/montserrat-cyrillic-800-normal.31d693bb.woff +0 -0
  199. recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.7e2c1e62.woff +0 -0
  200. recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.94a63aea.woff2 +0 -0
  201. recce/data/_next/static/media/montserrat-latin-800-normal.6f8fa298.woff2 +0 -0
  202. recce/data/_next/static/media/montserrat-latin-800-normal.97e20d5e.woff +0 -0
  203. recce/data/_next/static/media/montserrat-latin-ext-800-normal.013b84f9.woff2 +0 -0
  204. recce/data/_next/static/media/montserrat-latin-ext-800-normal.aff52ab0.woff +0 -0
  205. recce/data/_next/static/media/montserrat-vietnamese-800-normal.5f21869b.woff +0 -0
  206. recce/data/_next/static/media/montserrat-vietnamese-800-normal.c0035377.woff2 +0 -0
  207. recce/state.py +0 -753
  208. recce_nightly-1.2.0.20250506.dist-info/RECORD +0 -142
  209. tests/test_state.py +0 -123
  210. /recce/data/_next/static/{Kcbs3GEIyH2LxgLYat0es → 52aV_JrNUZU6dMFgvTQEO}/_ssgManifest.js +0 -0
  211. /recce/data/_next/static/chunks/{polyfills-42372ed130431b0a.js → a6dad97d9634a72d.js} +0 -0
  212. {recce_nightly-1.2.0.20250506.dist-info → recce_nightly-1.26.0.20251124.dist-info}/entry_points.txt +0 -0
  213. {recce_nightly-1.2.0.20250506.dist-info → recce_nightly-1.26.0.20251124.dist-info}/licenses/LICENSE +0 -0
recce/util/recce_cloud.py CHANGED
@@ -1,20 +1,26 @@
1
1
  import json
2
2
  import logging
3
3
  import os
4
- from typing import Dict, IO
4
+ from typing import IO, Dict
5
5
 
6
6
  import requests
7
7
 
8
+ from recce import get_version
9
+ from recce.event import get_user_id, is_anonymous_tracking
8
10
  from recce.pull_request import PullRequestInfo
9
11
 
10
- RECCE_CLOUD_API_HOST = os.environ.get('RECCE_CLOUD_API_HOST', 'https://cloud.datarecce.io')
12
+ RECCE_CLOUD_API_HOST = os.environ.get("RECCE_CLOUD_API_HOST", "https://cloud.datarecce.io")
13
+ RECCE_CLOUD_BASE_URL = os.environ.get("RECCE_CLOUD_BASE_URL", RECCE_CLOUD_API_HOST)
11
14
 
12
- logger = logging.getLogger('uvicorn')
15
+ DOCKER_INTERNAL_URL_PREFIX = "http://host.docker.internal"
16
+ LOCALHOST_URL_PREFIX = "http://localhost"
17
+
18
+ logger = logging.getLogger("uvicorn")
13
19
 
14
20
 
15
21
  class PresignedUrlMethod:
16
- UPLOAD = 'upload'
17
- DOWNLOAD = 'download'
22
+ UPLOAD = "upload"
23
+ DOWNLOAD = "download"
18
24
 
19
25
 
20
26
  class RecceCloudException(Exception):
@@ -23,7 +29,7 @@ class RecceCloudException(Exception):
23
29
  self.status_code = status_code
24
30
 
25
31
  try:
26
- reason = json.loads(reason).get('detail', '')
32
+ reason = json.loads(reason).get("detail", "")
27
33
  except json.JSONDecodeError:
28
34
  pass
29
35
  self.reason = reason
@@ -31,147 +37,391 @@ class RecceCloudException(Exception):
31
37
 
32
38
  class RecceCloud:
33
39
  def __init__(self, token: str):
40
+ if token is None:
41
+ raise ValueError("Token cannot be None.")
34
42
  self.token = token
35
- self.base_url = f'{RECCE_CLOUD_API_HOST}/api/v1'
43
+ self.token_type = "github_token" if token.startswith(("ghp_", "gho_", "ghu_", "ghs_", "ghr_")) else "api_token"
44
+ self.base_url = f"{RECCE_CLOUD_API_HOST}/api/v1"
45
+ self.base_url_v2 = f"{RECCE_CLOUD_API_HOST}/api/v2"
36
46
 
37
- def _request(self, method, url, **kwargs):
47
+ def _request(self, method, url, headers: Dict = None, **kwargs):
38
48
  headers = {
39
- 'Authorization': f'Bearer {self.token}'
49
+ **(headers or {}),
50
+ "Authorization": f"Bearer {self.token}",
40
51
  }
41
52
  return requests.request(method, url, headers=headers, **kwargs)
42
53
 
43
- def get_presigned_url(self,
44
- method: PresignedUrlMethod,
45
- repository: str,
46
- artifact_name: str,
47
- metadata: dict = None,
48
- pr_id: int = None,
49
- branch: str = None) -> str:
54
+ def verify_token(self) -> bool:
55
+ if self.token_type == "github_token":
56
+ return True
57
+ # Verify the Recce Cloud API token
58
+ api_url = f"{self.base_url}/verify-token"
59
+ try:
60
+ headers: Dict = None
61
+ if is_anonymous_tracking():
62
+ headers = {
63
+ "X-Recce-Oss-User-Id": get_user_id(),
64
+ "X-Recce-Oss-Version": get_version(),
65
+ }
66
+ response = self._request("GET", api_url, headers=headers)
67
+ if response.status_code == 200:
68
+ return True
69
+ except Exception:
70
+ pass
71
+ return False
72
+
73
+ def get_presigned_url_by_github_repo(
74
+ self,
75
+ method: PresignedUrlMethod,
76
+ repository: str,
77
+ artifact_name: str,
78
+ metadata: dict = None,
79
+ pr_id: int = None,
80
+ branch: str = None,
81
+ ) -> str:
50
82
  response = self._fetch_presigned_url(method, repository, artifact_name, metadata, pr_id, branch)
51
- return response.get('presigned_url')
83
+ return response.get("presigned_url")
84
+
85
+ def _replace_localhost_with_docker_internal(self, url: str) -> str:
86
+ if url is None:
87
+ return None
88
+ if (
89
+ os.environ.get("RECCE_SHARE_INSTANCE_ENV") == "docker"
90
+ or os.environ.get("RECCE_TASK_INSTANCE_ENV") == "docker"
91
+ or os.environ.get("RECCE_INSTANCE_ENV") == "docker"
92
+ ):
93
+ # For local development, convert the presigned URL from localhost to host.docker.internal
94
+ if url.startswith(LOCALHOST_URL_PREFIX):
95
+ return url.replace(LOCALHOST_URL_PREFIX, DOCKER_INTERNAL_URL_PREFIX)
96
+ return url
97
+
98
+ def get_presigned_url_by_share_id(
99
+ self,
100
+ method: PresignedUrlMethod,
101
+ share_id: str,
102
+ metadata: dict = None,
103
+ ) -> str:
104
+ response = self._fetch_presigned_url_by_share_id(method, share_id, metadata=metadata)
105
+ presigned_url = response.get("presigned_url")
106
+ if not presigned_url:
107
+ raise RecceCloudException(
108
+ message="Failed to get presigned URL from Recce Cloud.",
109
+ reason="No presigned URL returned from the server.",
110
+ status_code=404,
111
+ )
112
+ presigned_url = self._replace_localhost_with_docker_internal(presigned_url)
113
+ return presigned_url
52
114
 
53
- def get_download_presigned_url_with_tags(self,
54
- repository: str,
55
- artifact_name: str,
56
- branch: str = None) -> (str, dict):
115
+ def get_download_presigned_url_by_github_repo_with_tags(
116
+ self, repository: str, artifact_name: str, branch: str = None
117
+ ) -> (str, dict):
57
118
  response = self._fetch_presigned_url(PresignedUrlMethod.DOWNLOAD, repository, artifact_name, branch=branch)
58
- return response.get('presigned_url'), response.get('tags', {})
59
-
60
- def _fetch_presigned_url(self,
61
- method: PresignedUrlMethod,
62
- repository: str,
63
- artifact_name: str,
64
- metadata: dict = None,
65
- pr_id: int = None,
66
- branch: str = None) -> str:
119
+ return response.get("presigned_url"), response.get("tags", {})
120
+
121
+ def _fetch_presigned_url(
122
+ self,
123
+ method: PresignedUrlMethod,
124
+ repository: str,
125
+ artifact_name: str,
126
+ metadata: dict = None,
127
+ pr_id: int = None,
128
+ branch: str = None,
129
+ ) -> str:
67
130
  if pr_id is not None:
68
- api_url = f'{self.base_url}/{repository}/pulls/{pr_id}/artifacts/{method}?artifact_name={artifact_name}&enable_ssec=true'
131
+ api_url = f"{self.base_url}/{repository}/pulls/{pr_id}/artifacts/{method}?artifact_name={artifact_name}&enable_ssec=true"
69
132
  elif branch is not None:
70
- api_url = f'{self.base_url}/{repository}/commits/{branch}/artifacts/{method}?artifact_name={artifact_name}&enable_ssec=true'
133
+ api_url = f"{self.base_url}/{repository}/commits/{branch}/artifacts/{method}?artifact_name={artifact_name}&enable_ssec=true"
71
134
  else:
72
- raise ValueError('Either pr_id or sha must be provided.')
73
- response = self._request('POST', api_url, json=metadata)
135
+ raise ValueError("Either pr_id or sha must be provided.")
136
+ response = self._request("POST", api_url, json=metadata)
137
+ if response.status_code != 200:
138
+ raise RecceCloudException(
139
+ message="Failed to {method} artifact {preposition} Recce Cloud.".format(
140
+ method=method, preposition="from" if method == PresignedUrlMethod.DOWNLOAD else "to"
141
+ ),
142
+ reason=response.text,
143
+ status_code=response.status_code,
144
+ )
145
+ return response.json()
146
+
147
+ def _fetch_presigned_url_by_share_id(
148
+ self,
149
+ method: PresignedUrlMethod,
150
+ share_id: str,
151
+ metadata: dict = None,
152
+ ):
153
+ api_url = f"{self.base_url}/shares/{share_id}/presigned/{method}"
154
+ data = None
155
+ # Only provide metadata for upload requests
156
+ if method == PresignedUrlMethod.UPLOAD:
157
+ # Covert metadata values to strings to ensure JSON serializability
158
+ data = {"metadata": {key: str(value) for key, value in metadata.items()}} if metadata else None
159
+ response = self._request(
160
+ "POST",
161
+ api_url,
162
+ json=data,
163
+ )
74
164
  if response.status_code != 200:
75
165
  raise RecceCloudException(
76
- message='Failed to {method} artifact {preposition} Recce Cloud.'.format(
77
- method=method,
78
- preposition='from' if method == PresignedUrlMethod.DOWNLOAD else 'to'
166
+ message="Failed to {method} artifact {preposition} Recce Cloud.".format(
167
+ method=method, preposition="from" if method == PresignedUrlMethod.DOWNLOAD else "to"
79
168
  ),
80
169
  reason=response.text,
81
- status_code=response.status_code
170
+ status_code=response.status_code,
82
171
  )
83
172
  return response.json()
84
173
 
85
174
  def get_artifact_metadata(self, pr_info: PullRequestInfo) -> dict:
86
- api_url = f'{self.base_url}/{pr_info.repository}/pulls/{pr_info.id}/metadata'
87
- response = self._request('GET', api_url)
175
+ api_url = f"{self.base_url}/{pr_info.repository}/pulls/{pr_info.id}/metadata"
176
+ response = self._request("GET", api_url)
88
177
  if response.status_code == 204:
89
178
  return None
90
179
  if response.status_code != 200:
91
180
  raise RecceCloudException(
92
- message='Failed to get artifact metadata from Recce Cloud.',
181
+ message="Failed to get artifact metadata from Recce Cloud.",
93
182
  reason=response.text,
94
- status_code=response.status_code
183
+ status_code=response.status_code,
95
184
  )
96
185
  return response.json()
97
186
 
98
- def purge_artifacts(self, pr_info: PullRequestInfo):
99
- api_url = f'{self.base_url}/{pr_info.repository}/pulls/{pr_info.id}/artifacts'
100
- response = self._request('DELETE', api_url)
187
+ def purge_artifacts(self, repository: str, pr_id: int = None, branch: str = None):
188
+ if pr_id is not None:
189
+ api_url = f"{self.base_url}/{repository}/pulls/{pr_id}/artifacts"
190
+ error_message = "Failed to purge artifacts from Recce Cloud."
191
+ elif branch is not None:
192
+ api_url = f"{self.base_url}/{repository}/commits/{branch}/artifacts"
193
+ error_message = "Failed to delete artifacts from Recce Cloud."
194
+ else:
195
+ raise ValueError(
196
+ "Please either run this command from within a pull request context "
197
+ "or specify a branch using the --branch option."
198
+ )
199
+ response = self._request("DELETE", api_url)
101
200
  if response.status_code != 204:
102
201
  raise RecceCloudException(
103
- message='Failed to purge artifacts from Recce Cloud.',
202
+ message=error_message,
104
203
  reason=response.text,
105
- status_code=response.status_code
204
+ status_code=response.status_code,
106
205
  )
107
206
 
108
207
  def check_artifacts_exists(self, pr_info: PullRequestInfo) -> bool:
109
- api_url = f'{self.base_url}/{pr_info.repository}/pulls/{pr_info.id}/metadata'
110
- response = self._request('GET', api_url)
208
+ api_url = f"{self.base_url}/{pr_info.repository}/pulls/{pr_info.id}/metadata"
209
+ response = self._request("GET", api_url)
111
210
  if response.status_code == 200:
112
211
  return True
113
212
  elif response.status_code == 204:
114
213
  return False
115
214
  else:
116
215
  raise RecceCloudException(
117
- message='Failed to check if artifacts exist in Recce Cloud.',
216
+ message="Failed to check if artifacts exist in Recce Cloud.",
118
217
  reason=response.text,
119
- status_code=response.status_code
218
+ status_code=response.status_code,
120
219
  )
121
220
 
122
221
  def share_state(self, file_name: str, file_io: IO):
123
- api_url = f'{self.base_url}/recce-state/upload'
124
- files = {'file': (file_name, file_io, 'application/json')}
125
- response = self._request('POST', api_url, files=files)
222
+ api_url = f"{self.base_url}/recce-state/upload"
223
+ files = {"file": (file_name, file_io, "application/json")}
224
+ response = self._request("POST", api_url, files=files)
126
225
  if response.status_code == 403:
127
- return {'status': 'error', 'message': response.json().get('detail')}
226
+ return {"status": "error", "message": response.json().get("detail")}
128
227
  if response.status_code != 200:
129
228
  raise RecceCloudException(
130
- message='Failed to share Recce state.',
131
- reason=response.text,
132
- status_code=response.status_code
229
+ message="Failed to share Recce state.", reason=response.text, status_code=response.status_code
133
230
  )
134
231
  return response.json()
135
232
 
136
233
  def update_github_pull_request_check(self, pr_info: PullRequestInfo, metadata: dict = None):
137
- api_url = f'{self.base_url}/{pr_info.repository}/pulls/{pr_info.id}/github/checks'
234
+ api_url = f"{self.base_url}/{pr_info.repository}/pulls/{pr_info.id}/github/checks"
138
235
  try:
139
- self._request('POST', api_url, json=metadata)
236
+ self._request("POST", api_url, json=metadata)
140
237
  except Exception as e:
141
238
  # We don't care the response of this request, so we don't need to raise any exception.
142
- logger.debug(f'Failed to update the GitHub PR check. Reason: {str(e)}')
239
+ logger.debug(f"Failed to update the GitHub PR check. Reason: {str(e)}")
143
240
 
144
241
  def get_user_info(self) -> Dict:
145
- api_url = f'{self.base_url}/users'
146
- response = self._request('GET', api_url)
242
+ api_url = f"{self.base_url}/users"
243
+ response = self._request("GET", api_url)
147
244
  if response.status_code != 200:
148
245
  raise RecceCloudException(
149
- message='Failed to get user info from Recce Cloud.',
246
+ message="Failed to get user info from Recce Cloud.",
150
247
  reason=response.text,
151
- status_code=response.status_code
248
+ status_code=response.status_code,
152
249
  )
153
- return response.json().get('user')
250
+ return response.json().get("user")
154
251
 
155
252
  def set_onboarding_state(self, state: str):
156
- api_url = f'{self.base_url}/users/onboarding-state'
157
- response = self._request('PUT', api_url, json={'state': state})
253
+ api_url = f"{self.base_url}/users/onboarding-state"
254
+ try:
255
+ response = self._request("PUT", api_url, json={"state": state})
256
+ response.raise_for_status()
257
+ except requests.exceptions.HTTPError as e:
258
+ # Don't Raise an exception if setting onboarding_state fails
259
+ logger.warning(f"Failed to set Onboarding State in Recce Cloud. Reason: {str(e)}")
260
+ return
261
+
262
+ def get_session(self, session_id: str):
263
+ api_url = f"{self.base_url_v2}/sessions/{session_id}"
264
+ response = self._request("GET", api_url)
265
+ if response.status_code == 403:
266
+ return {"status": "error", "message": response.json().get("detail")}
267
+ if response.status_code != 200:
268
+ raise RecceCloudException(
269
+ message="Failed to get session from Recce Cloud.",
270
+ reason=response.text,
271
+ status_code=response.status_code,
272
+ )
273
+ data = response.json()
274
+ if data["success"] is not True:
275
+ raise RecceCloudException(
276
+ message="Failed to get session from Recce Cloud.",
277
+ reason=data.get("message", "Unknown error"),
278
+ status_code=response.status_code,
279
+ )
280
+ return data["session"]
281
+
282
+ def update_session(self, org_id: str, project_id: str, session_id: str, adapter_type: str):
283
+ api_url = f"{self.base_url_v2}/organizations/{org_id}/projects/{project_id}/sessions/{session_id}"
284
+ data = {"adapter_type": adapter_type}
285
+ response = self._request("PATCH", api_url, json=data)
286
+ if response.status_code == 403:
287
+ return {"status": "error", "message": response.json().get("detail")}
288
+ if response.status_code != 200:
289
+ raise RecceCloudException(
290
+ message="Failed to update session in Recce Cloud.",
291
+ reason=response.text,
292
+ status_code=response.status_code,
293
+ )
294
+ return response.json()
295
+
296
+ def get_download_urls_by_session_id(self, org_id: str, project_id: str, session_id: str) -> dict[str, str]:
297
+ api_url = f"{self.base_url_v2}/organizations/{org_id}/projects/{project_id}/sessions/{session_id}/download-url"
298
+ response = self._request("GET", api_url)
158
299
  if response.status_code != 200:
159
300
  raise RecceCloudException(
160
- message='Failed to update onboarding state in Recce Cloud.',
301
+ message="Failed to download session from Recce Cloud.",
161
302
  reason=response.text,
162
- status_code=response.status_code
303
+ status_code=response.status_code,
163
304
  )
305
+ data = response.json()
306
+ if data["presigned_urls"] is None:
307
+ raise RecceCloudException(
308
+ message="No presigned URLs returned from the server.",
309
+ reason="",
310
+ status_code=404,
311
+ )
312
+
313
+ presigned_urls = data["presigned_urls"]
314
+ for key, url in presigned_urls.items():
315
+ presigned_urls[key] = self._replace_localhost_with_docker_internal(url)
316
+ return presigned_urls
317
+
318
+ def get_base_session_download_urls(self, org_id: str, project_id: str) -> dict[str, str]:
319
+ """Get download URLs for the base session of a project."""
320
+ api_url = f"{self.base_url_v2}/organizations/{org_id}/projects/{project_id}/base-session/download-url"
321
+ response = self._request("GET", api_url)
322
+ if response.status_code != 200:
323
+ raise RecceCloudException(
324
+ message="Failed to download base session from Recce Cloud.",
325
+ reason=response.text,
326
+ status_code=response.status_code,
327
+ )
328
+ data = response.json()
329
+ if data["presigned_urls"] is None:
330
+ raise RecceCloudException(
331
+ message="No presigned URLs returned from the server.",
332
+ reason="",
333
+ status_code=404,
334
+ )
335
+
336
+ presigned_urls = data["presigned_urls"]
337
+ for key, url in presigned_urls.items():
338
+ presigned_urls[key] = self._replace_localhost_with_docker_internal(url)
339
+ return presigned_urls
340
+
341
+ def get_upload_urls_by_session_id(self, org_id: str, project_id: str, session_id: str) -> dict[str, str]:
342
+ api_url = f"{self.base_url_v2}/organizations/{org_id}/projects/{project_id}/sessions/{session_id}/upload-url"
343
+ response = self._request("GET", api_url)
344
+ if response.status_code != 200:
345
+ raise RecceCloudException(
346
+ message="Failed to get upload URLs for session from Recce Cloud.",
347
+ reason=response.text,
348
+ status_code=response.status_code,
349
+ )
350
+ data = response.json()
351
+ if data["presigned_urls"] is None:
352
+ raise RecceCloudException(
353
+ message="No presigned URLs returned from the server.",
354
+ reason="",
355
+ status_code=404,
356
+ )
357
+
358
+ presigned_urls = data["presigned_urls"]
359
+ for key, url in presigned_urls.items():
360
+ presigned_urls[key] = self._replace_localhost_with_docker_internal(url)
361
+ return presigned_urls
362
+
363
+ def post_recce_state_uploaded_by_session_id(self, org_id: str, project_id: str, session_id: str):
364
+ api_url = f"{self.base_url_v2}/organizations/{org_id}/projects/{project_id}/sessions/{session_id}/recce-state-uploaded"
365
+ response = self._request("POST", api_url)
366
+ if response.status_code != 204:
367
+ raise RecceCloudException(
368
+ message="Failed to notify state uploaded for session in Recce Cloud.",
369
+ reason=response.text,
370
+ status_code=response.status_code,
371
+ )
372
+
373
+ def list_organizations(self) -> list:
374
+ """List all organizations the user has access to."""
375
+ api_url = f"{self.base_url_v2}/organizations"
376
+ response = self._request("GET", api_url)
377
+ if response.status_code != 200:
378
+ raise RecceCloudException(
379
+ message="Failed to list organizations from Recce Cloud.",
380
+ reason=response.text,
381
+ status_code=response.status_code,
382
+ )
383
+ data = response.json()
384
+ return data.get("organizations", [])
385
+
386
+ def list_projects(self, org_id: str) -> list:
387
+ """List all projects in an organization."""
388
+ api_url = f"{self.base_url_v2}/organizations/{org_id}/projects"
389
+ response = self._request("GET", api_url)
390
+ if response.status_code != 200:
391
+ raise RecceCloudException(
392
+ message="Failed to list projects from Recce Cloud.",
393
+ reason=response.text,
394
+ status_code=response.status_code,
395
+ )
396
+ data = response.json()
397
+ return data.get("projects", [])
398
+
399
+ def list_sessions(self, org_id: str, project_id: str) -> list:
400
+ """List all sessions in a project."""
401
+ api_url = f"{self.base_url_v2}/organizations/{org_id}/projects/{project_id}/sessions"
402
+ response = self._request("GET", api_url)
403
+ if response.status_code != 200:
404
+ raise RecceCloudException(
405
+ message="Failed to list sessions from Recce Cloud.",
406
+ reason=response.text,
407
+ status_code=response.status_code,
408
+ )
409
+ data = response.json()
410
+ return data.get("sessions", [])
164
411
 
165
412
 
166
413
  def get_recce_cloud_onboarding_state(token: str) -> str:
414
+ if token and token.startswith("rct-"):
415
+ return "undefined"
416
+
167
417
  try:
168
418
  recce_cloud = RecceCloud(token)
169
419
  user_info = recce_cloud.get_user_info()
170
420
  if user_info:
171
- return user_info.get('onboarding_state')
421
+ return user_info.get("onboarding_state")
172
422
  except Exception as e:
173
423
  logger.debug(str(e))
174
- return 'undefined'
424
+ return "undefined"
175
425
 
176
426
 
177
427
  def set_recce_cloud_onboarding_state(token: str, new_state: str):
recce/util/singleton.py CHANGED
@@ -1,9 +1,9 @@
1
1
  class SingletonMeta(type):
2
2
  """
3
- The Singleton class can be implemented in different ways in Python. Some
4
- possible methods include: base class, decorator, metaclass. We will use the
5
- metaclass because it is best suited for this purpose.
6
- """
3
+ The Singleton class can be implemented in different ways in Python. Some
4
+ possible methods include: base class, decorator, metaclass. We will use the
5
+ metaclass because it is best suited for this purpose.
6
+ """
7
7
 
8
8
  _instances = {}
9
9
 
recce/yaml/__init__.py CHANGED
@@ -1,10 +1,11 @@
1
1
  from typing import Any, Callable
2
2
 
3
3
  from ruamel import yaml
4
- from ruamel.yaml import CommentedMap as _cm, CommentedSeq as _cs
4
+ from ruamel.yaml import CommentedMap as _cm
5
+ from ruamel.yaml import CommentedSeq as _cs
5
6
 
6
7
  _yaml = yaml.YAML()
7
- _safe_yaml = yaml.YAML(typ='safe')
8
+ _safe_yaml = yaml.YAML(typ="safe")
8
9
 
9
10
  CommentedMap = _cm
10
11
  CommentedSeq = _cs
@@ -27,15 +28,13 @@ def safe_load(stream, version=None) -> Any:
27
28
  return _safe_yaml.load(stream)
28
29
 
29
30
 
30
- def dump(
31
- data, stream: Any = None, *, transform: Any = None
32
- ) -> Any:
31
+ def dump(data, stream: Any = None, *, transform: Any = None) -> Any:
33
32
  return _yaml.dump(data, stream, transform=transform)
34
33
 
35
34
 
36
35
  def safe_load_yaml(file_path):
37
36
  try:
38
- with open(file_path, 'r') as f:
37
+ with open(file_path, "r", encoding="utf-8") as f:
39
38
  payload = safe_load(f)
40
39
  except yaml.YAMLError as e:
41
40
  print(e)
@@ -46,7 +45,7 @@ def safe_load_yaml(file_path):
46
45
 
47
46
 
48
47
  def round_trip_load_yaml(file_path):
49
- with open(file_path, 'r') as f:
48
+ with open(file_path, "r", encoding="utf-8") as f:
50
49
  try:
51
50
  payload = load(f)
52
51
  except yaml.YAMLError as e:
@@ -55,7 +54,5 @@ def round_trip_load_yaml(file_path):
55
54
  return payload
56
55
 
57
56
 
58
- def round_trip_dump(
59
- data: Any,
60
- stream=None):
57
+ def round_trip_dump(data: Any, stream=None):
61
58
  return yaml.round_trip_dump(data, stream)
@@ -0,0 +1,24 @@
1
+ """Recce Cloud - Lightweight CLI for Recce Cloud operations."""
2
+
3
+ import os
4
+
5
+
6
+ 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()
13
+
14
+ # Fallback to ../recce/VERSION (for development)
15
+ version_file = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "recce", "VERSION"))
16
+ if os.path.exists(version_file):
17
+ with open(version_file) as fh:
18
+ return fh.read().strip()
19
+
20
+ # Last resort
21
+ return "unknown"
22
+
23
+
24
+ __version__ = get_version()
@@ -0,0 +1,17 @@
1
+ """Recce Cloud API client module."""
2
+
3
+ from recce_cloud.api.base import BaseRecceCloudClient
4
+ from recce_cloud.api.client import RecceCloudClient
5
+ from recce_cloud.api.exceptions import RecceCloudException
6
+ from recce_cloud.api.factory import create_platform_client
7
+ from recce_cloud.api.github import GitHubRecceCloudClient
8
+ from recce_cloud.api.gitlab import GitLabRecceCloudClient
9
+
10
+ __all__ = [
11
+ "BaseRecceCloudClient",
12
+ "RecceCloudClient",
13
+ "RecceCloudException",
14
+ "create_platform_client",
15
+ "GitHubRecceCloudClient",
16
+ "GitLabRecceCloudClient",
17
+ ]