recce-nightly 1.10.0.20250625__py3-none-any.whl → 1.30.0.20251221__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 (229) hide show
  1. recce/VERSION +1 -1
  2. recce/__init__.py +5 -0
  3. recce/adapter/dbt_adapter/__init__.py +343 -245
  4. recce/apis/check_api.py +20 -14
  5. recce/apis/check_events_api.py +353 -0
  6. recce/apis/check_func.py +5 -5
  7. recce/apis/run_func.py +32 -3
  8. recce/artifact.py +76 -3
  9. recce/cli.py +705 -82
  10. recce/config.py +2 -2
  11. recce/connect_to_cloud.py +1 -1
  12. recce/core.py +3 -3
  13. recce/data/404/index.html +2 -0
  14. recce/data/404.html +2 -22
  15. recce/data/__next.@lineage.!KHNsb3Qp.__PAGE__.txt +7 -0
  16. recce/data/__next.@lineage.!KHNsb3Qp.txt +4 -0
  17. recce/data/__next.__PAGE__.txt +6 -0
  18. recce/data/__next._full.txt +32 -0
  19. recce/data/__next._head.txt +8 -0
  20. recce/data/__next._index.txt +14 -0
  21. recce/data/__next._tree.txt +8 -0
  22. recce/data/_next/static/chunks/025a7e3e3f9f40ae.js +1 -0
  23. recce/data/_next/static/chunks/0ce56d67ef5779ca.js +4 -0
  24. recce/data/_next/static/chunks/1a6a78780155dac7.js +48 -0
  25. recce/data/_next/static/chunks/1de8485918b9182a.css +2 -0
  26. recce/data/_next/static/chunks/1e4b1b50d1e34993.js +1 -0
  27. recce/data/_next/static/chunks/206d5d181e4c738e.js +1 -0
  28. recce/data/_next/static/chunks/2c357efc34c5b859.js +25 -0
  29. recce/data/_next/static/chunks/2e9d95d2d48c479c.js +1 -0
  30. recce/data/_next/static/chunks/2f016dc4a3edad2e.js +2 -0
  31. recce/data/_next/static/chunks/313251962d698f7c.js +1 -0
  32. recce/data/_next/static/chunks/3a9f021f38eb5574.css +1 -0
  33. recce/data/_next/static/chunks/40079da8d2b8f651.js +1 -0
  34. recce/data/_next/static/chunks/4599182bffb64661.js +38 -0
  35. recce/data/_next/static/chunks/4e62f6e184173580.js +1 -0
  36. recce/data/_next/static/chunks/5c4dfb0d09eaa401.js +1 -0
  37. recce/data/_next/static/chunks/69e4f06ccfdfc3ac.js +1 -0
  38. recce/data/_next/static/chunks/6b206cb4707d6bee.js +1 -0
  39. recce/data/_next/static/chunks/6d8557f062aa4386.css +1 -0
  40. recce/data/_next/static/chunks/7fbe3650bd83b6b5.js +1 -0
  41. recce/data/_next/static/chunks/83fa823a825674f6.js +1 -0
  42. recce/data/_next/static/chunks/848a6c9b5f55f7ed.js +1 -0
  43. recce/data/_next/static/chunks/859462b0858aef88.css +2 -0
  44. recce/data/_next/static/chunks/923964f18c87d0f1.css +1 -0
  45. recce/data/_next/static/chunks/939390f911895d7c.js +48 -0
  46. recce/data/_next/static/chunks/99a9817237a07f43.js +1 -0
  47. recce/data/_next/static/chunks/9fed8b4b2b924054.js +5 -0
  48. recce/data/_next/static/chunks/b6949f6c5892110c.js +1 -0
  49. recce/data/_next/static/chunks/b851a1d3f8149828.js +1 -0
  50. recce/data/_next/static/chunks/c734f9ad957de0b4.js +1 -0
  51. recce/data/_next/static/chunks/cdde321b0ec75717.js +2 -0
  52. recce/data/_next/static/chunks/d0f91117d77ff844.css +1 -0
  53. recce/data/_next/static/chunks/d6c8667911c2500f.js +1 -0
  54. recce/data/_next/static/chunks/da8dab68c02752cf.js +74 -0
  55. recce/data/_next/static/chunks/dc074049c9d12d97.js +109 -0
  56. recce/data/_next/static/chunks/ee7f1a8227342421.js +1 -0
  57. recce/data/_next/static/chunks/fa2f4e56c2fccc73.js +1 -0
  58. recce/data/_next/static/chunks/turbopack-1fad664f62979b93.js +3 -0
  59. recce/data/_next/static/media/favicon.a8d38d84.ico +0 -0
  60. recce/data/_next/static/media/montserrat-cyrillic-800-normal.d80d830d.woff2 +0 -0
  61. recce/data/_next/static/media/{montserrat-cyrillic-800-normal.bd5c9f50.woff → montserrat-cyrillic-800-normal.f9d58125.woff} +0 -0
  62. recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.076c2a93.woff2 +0 -0
  63. recce/data/_next/static/media/montserrat-latin-800-normal.cde454cc.woff2 +0 -0
  64. recce/data/_next/static/media/{montserrat-latin-800-normal.fc315020.woff → montserrat-latin-800-normal.d5761935.woff} +0 -0
  65. recce/data/_next/static/media/montserrat-latin-ext-800-normal.40ec0659.woff2 +0 -0
  66. recce/data/_next/static/media/{montserrat-latin-ext-800-normal.2e5381b2.woff → montserrat-latin-ext-800-normal.b671449b.woff} +0 -0
  67. recce/data/_next/static/media/{montserrat-vietnamese-800-normal.20c545e6.woff → montserrat-vietnamese-800-normal.9f7b8541.woff} +0 -0
  68. recce/data/_next/static/media/montserrat-vietnamese-800-normal.f9eb854e.woff2 +0 -0
  69. recce/data/_next/static/nX-Uz0AH6Tc6hIQUFGqaB/_buildManifest.js +11 -0
  70. recce/data/_next/static/nX-Uz0AH6Tc6hIQUFGqaB/_clientMiddlewareManifest.json +1 -0
  71. recce/data/_not-found/__next._full.txt +24 -0
  72. recce/data/_not-found/__next._head.txt +8 -0
  73. recce/data/_not-found/__next._index.txt +13 -0
  74. recce/data/_not-found/__next._not-found.__PAGE__.txt +5 -0
  75. recce/data/_not-found/__next._not-found.txt +4 -0
  76. recce/data/_not-found/__next._tree.txt +6 -0
  77. recce/data/_not-found/index.html +2 -0
  78. recce/data/_not-found/index.txt +24 -0
  79. recce/data/auth_callback.html +1 -1
  80. recce/data/checks/__next.@lineage.__DEFAULT__.txt +7 -0
  81. recce/data/checks/__next._full.txt +39 -0
  82. recce/data/checks/__next._head.txt +8 -0
  83. recce/data/checks/__next._index.txt +14 -0
  84. recce/data/checks/__next._tree.txt +8 -0
  85. recce/data/checks/__next.checks.__PAGE__.txt +10 -0
  86. recce/data/checks/__next.checks.txt +4 -0
  87. recce/data/checks/index.html +2 -0
  88. recce/data/checks/index.txt +39 -0
  89. recce/data/index.html +2 -27
  90. recce/data/index.txt +32 -8
  91. recce/data/lineage/__next.@lineage.__DEFAULT__.txt +7 -0
  92. recce/data/lineage/__next._full.txt +39 -0
  93. recce/data/lineage/__next._head.txt +8 -0
  94. recce/data/lineage/__next._index.txt +14 -0
  95. recce/data/lineage/__next._tree.txt +8 -0
  96. recce/data/lineage/__next.lineage.__PAGE__.txt +10 -0
  97. recce/data/lineage/__next.lineage.txt +4 -0
  98. recce/data/lineage/index.html +2 -0
  99. recce/data/lineage/index.txt +39 -0
  100. recce/data/query/__next.@lineage.__DEFAULT__.txt +7 -0
  101. recce/data/query/__next._full.txt +37 -0
  102. recce/data/query/__next._head.txt +8 -0
  103. recce/data/query/__next._index.txt +14 -0
  104. recce/data/query/__next._tree.txt +8 -0
  105. recce/data/query/__next.query.__PAGE__.txt +9 -0
  106. recce/data/query/__next.query.txt +4 -0
  107. recce/data/query/index.html +2 -0
  108. recce/data/query/index.txt +37 -0
  109. recce/event/CONFIG.bak +1 -0
  110. recce/event/__init__.py +9 -8
  111. recce/event/collector.py +6 -2
  112. recce/event/track.py +10 -0
  113. recce/github.py +1 -1
  114. recce/mcp_server.py +725 -0
  115. recce/models/check.py +433 -15
  116. recce/models/types.py +61 -2
  117. recce/pull_request.py +1 -1
  118. recce/run.py +37 -17
  119. recce/server.py +216 -21
  120. recce/state/__init__.py +31 -0
  121. recce/state/cloud.py +644 -0
  122. recce/state/const.py +26 -0
  123. recce/state/local.py +56 -0
  124. recce/state/state.py +119 -0
  125. recce/state/state_loader.py +174 -0
  126. recce/summary.py +25 -3
  127. recce/tasks/dataframe.py +63 -1
  128. recce/tasks/query.py +40 -3
  129. recce/tasks/rowcount.py +4 -1
  130. recce/tasks/schema.py +4 -1
  131. recce/tasks/utils.py +147 -0
  132. recce/tasks/valuediff.py +85 -57
  133. recce/util/api_token.py +11 -2
  134. recce/util/breaking.py +10 -1
  135. recce/util/cll.py +1 -2
  136. recce/util/cloud/__init__.py +15 -0
  137. recce/util/cloud/base.py +115 -0
  138. recce/util/cloud/check_events.py +190 -0
  139. recce/util/cloud/checks.py +242 -0
  140. recce/util/io.py +2 -2
  141. recce/util/lineage.py +19 -18
  142. recce/util/perf_tracking.py +85 -0
  143. recce/util/recce_cloud.py +254 -5
  144. recce/util/startup_perf.py +121 -0
  145. recce/yaml/__init__.py +2 -2
  146. {recce_nightly-1.10.0.20250625.dist-info → recce_nightly-1.30.0.20251221.dist-info}/METADATA +91 -71
  147. recce_nightly-1.30.0.20251221.dist-info/RECORD +183 -0
  148. {recce_nightly-1.10.0.20250625.dist-info → recce_nightly-1.30.0.20251221.dist-info}/WHEEL +1 -2
  149. recce/data/_next/static/abCX3x3UoIdRLEDWxx4xd/_buildManifest.js +0 -1
  150. recce/data/_next/static/chunks/181-acc61ddada3bc0ca.js +0 -43
  151. recce/data/_next/static/chunks/1bff33f1-1ef85cf5e658a751.js +0 -1
  152. recce/data/_next/static/chunks/217-879a84d70f7a907c.js +0 -2
  153. recce/data/_next/static/chunks/29e3cc0d-60045b2e47aa3916.js +0 -1
  154. recce/data/_next/static/chunks/36e1c10d-8e7be4a6c1f6ab2d.js +0 -1
  155. recce/data/_next/static/chunks/3998a672-03adacad07b346ac.js +0 -1
  156. recce/data/_next/static/chunks/3a92ee20-1081c360214f9602.js +0 -1
  157. recce/data/_next/static/chunks/42-cd3c06533f5fd47c.js +0 -9
  158. recce/data/_next/static/chunks/450c323b-fd94e7ffaa4a5efa.js +0 -1
  159. recce/data/_next/static/chunks/47d8844f-929aed9b1c73a905.js +0 -1
  160. recce/data/_next/static/chunks/608-3b079b544e5d5f5e.js +0 -15
  161. recce/data/_next/static/chunks/6dc81886-adbfa45836061d79.js +0 -1
  162. recce/data/_next/static/chunks/7a8a3e83-edf6dc64b5d5f0a5.js +0 -1
  163. recce/data/_next/static/chunks/7f27ae6c-d5f0438edd5c2a5b.js +0 -1
  164. recce/data/_next/static/chunks/86730205-cfb14e3f051bab35.js +0 -1
  165. recce/data/_next/static/chunks/8d700b6a.8bb140898499c512.js +0 -1
  166. recce/data/_next/static/chunks/92-607cd1af83c41f43.js +0 -1
  167. recce/data/_next/static/chunks/9746af58-a42b7d169cacadf0.js +0 -1
  168. recce/data/_next/static/chunks/a30376cd-de84559016d7e133.js +0 -1
  169. recce/data/_next/static/chunks/app/_not-found/page-01ed58b7f971d311.js +0 -1
  170. recce/data/_next/static/chunks/app/layout-177a410a97e0d018.js +0 -1
  171. recce/data/_next/static/chunks/app/page-da6e046a8235dbfc.js +0 -1
  172. recce/data/_next/static/chunks/b63b1b3f-4282bdcf459e075c.js +0 -1
  173. recce/data/_next/static/chunks/bbda5537-9ec25eb1dd62348a.js +0 -1
  174. recce/data/_next/static/chunks/c132bf7d-08cb668a789d6afd.js +0 -1
  175. recce/data/_next/static/chunks/ce84277d-2e5d1d46910cf052.js +0 -1
  176. recce/data/_next/static/chunks/febdd86e-c6b525341634b860.js +0 -54
  177. recce/data/_next/static/chunks/fee69bc6-2dbccaf9b90474e6.js +0 -1
  178. recce/data/_next/static/chunks/framework-ded83d71b51ce901.js +0 -1
  179. recce/data/_next/static/chunks/main-app-39061b0166c47f55.js +0 -1
  180. recce/data/_next/static/chunks/main-b5b3ae20a1405261.js +0 -1
  181. recce/data/_next/static/chunks/pages/_app-437c455677d62394.js +0 -1
  182. recce/data/_next/static/chunks/pages/_error-e7650df18ca04bde.js +0 -1
  183. recce/data/_next/static/chunks/webpack-7b49d5ba7e3a434d.js +0 -1
  184. recce/data/_next/static/css/17a96168e3a9db13.css +0 -1
  185. recce/data/_next/static/css/1b121dc4d36aeb4d.css +0 -3
  186. recce/data/_next/static/css/35c6679a098e1e34.css +0 -1
  187. recce/data/_next/static/css/951e2e0eea2d4a5b.css +0 -14
  188. recce/data/_next/static/media/montserrat-cyrillic-800-normal.22628180.woff2 +0 -0
  189. recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.94a63aea.woff2 +0 -0
  190. recce/data/_next/static/media/montserrat-latin-800-normal.6f8fa298.woff2 +0 -0
  191. recce/data/_next/static/media/montserrat-latin-ext-800-normal.013b84f9.woff2 +0 -0
  192. recce/data/_next/static/media/montserrat-vietnamese-800-normal.c0035377.woff2 +0 -0
  193. recce/data/_next/static/media/reload-image.79aabb7d.svg +0 -4
  194. recce/state.py +0 -786
  195. recce_nightly-1.10.0.20250625.dist-info/RECORD +0 -154
  196. recce_nightly-1.10.0.20250625.dist-info/top_level.txt +0 -2
  197. tests/__init__.py +0 -0
  198. tests/adapter/__init__.py +0 -0
  199. tests/adapter/dbt_adapter/__init__.py +0 -0
  200. tests/adapter/dbt_adapter/conftest.py +0 -17
  201. tests/adapter/dbt_adapter/dbt_test_helper.py +0 -298
  202. tests/adapter/dbt_adapter/test_dbt_adapter.py +0 -25
  203. tests/adapter/dbt_adapter/test_dbt_cll.py +0 -384
  204. tests/adapter/dbt_adapter/test_selector.py +0 -202
  205. tests/tasks/__init__.py +0 -0
  206. tests/tasks/conftest.py +0 -4
  207. tests/tasks/test_histogram.py +0 -129
  208. tests/tasks/test_lineage.py +0 -55
  209. tests/tasks/test_preset_checks.py +0 -64
  210. tests/tasks/test_profile.py +0 -397
  211. tests/tasks/test_query.py +0 -151
  212. tests/tasks/test_row_count.py +0 -135
  213. tests/tasks/test_schema.py +0 -122
  214. tests/tasks/test_top_k.py +0 -77
  215. tests/tasks/test_valuediff.py +0 -85
  216. tests/test_cli.py +0 -133
  217. tests/test_config.py +0 -43
  218. tests/test_connect_to_cloud.py +0 -82
  219. tests/test_core.py +0 -29
  220. tests/test_dbt.py +0 -36
  221. tests/test_pull_request.py +0 -130
  222. tests/test_server.py +0 -104
  223. tests/test_state.py +0 -134
  224. tests/test_summary.py +0 -65
  225. /recce/data/_next/static/chunks/{polyfills-42372ed130431b0a.js → a6dad97d9634a72d.js} +0 -0
  226. /recce/data/_next/static/media/{montserrat-cyrillic-ext-800-normal.e6e0d8d0.woff → montserrat-cyrillic-ext-800-normal.a4fa76b5.woff} +0 -0
  227. /recce/data/_next/static/{abCX3x3UoIdRLEDWxx4xd → nX-Uz0AH6Tc6hIQUFGqaB}/_ssgManifest.js +0 -0
  228. {recce_nightly-1.10.0.20250625.dist-info → recce_nightly-1.30.0.20251221.dist-info}/entry_points.txt +0 -0
  229. {recce_nightly-1.10.0.20250625.dist-info → recce_nightly-1.30.0.20251221.dist-info}/licenses/LICENSE +0 -0
recce/state/cloud.py ADDED
@@ -0,0 +1,644 @@
1
+ import logging
2
+ import os
3
+ from base64 import b64encode
4
+ from hashlib import md5, sha256
5
+ from typing import Dict, Optional, Tuple, Union
6
+ from urllib.parse import urlencode
7
+
8
+ from recce.exceptions import RecceException
9
+ from recce.pull_request import PullRequestInfo, fetch_pr_metadata
10
+ from recce.util.io import SupportedFileTypes, file_io_factory
11
+ from recce.util.recce_cloud import PresignedUrlMethod, RecceCloud, RecceCloudException
12
+ from recce.util.startup_perf import track_timing
13
+
14
+ from ..event import get_recce_api_token
15
+ from ..models import CheckDAO
16
+ from .const import (
17
+ RECCE_API_TOKEN_MISSING,
18
+ RECCE_CLOUD_PASSWORD_MISSING,
19
+ RECCE_CLOUD_TOKEN_MISSING,
20
+ RECCE_STATE_COMPRESSED_FILE,
21
+ )
22
+ from .state import RecceState
23
+ from .state_loader import RecceStateLoader
24
+
25
+ logger = logging.getLogger("uvicorn")
26
+
27
+
28
+ def s3_sse_c_headers(password: str) -> Dict[str, str]:
29
+ hashed_password = sha256()
30
+ md5_hash = md5()
31
+ hashed_password.update(password.encode())
32
+ md5_hash.update(hashed_password.digest())
33
+ encoded_passwd = b64encode(hashed_password.digest()).decode("utf-8")
34
+ encoded_md5 = b64encode(md5_hash.digest()).decode("utf-8")
35
+ return {
36
+ "x-amz-server-side-encryption-customer-algorithm": "AES256",
37
+ "x-amz-server-side-encryption-customer-key": encoded_passwd,
38
+ "x-amz-server-side-encryption-customer-key-MD5": encoded_md5,
39
+ }
40
+
41
+
42
+ class CloudStateLoader(RecceStateLoader):
43
+ def __init__(
44
+ self,
45
+ review_mode: bool = False,
46
+ cloud_options: Optional[Dict[str, str]] = None,
47
+ initial_state: Optional[RecceState] = None,
48
+ ):
49
+ super().__init__(
50
+ cloud_mode=True,
51
+ review_mode=review_mode,
52
+ cloud_options=cloud_options,
53
+ initial_state=initial_state,
54
+ )
55
+ self.recce_cloud = RecceCloud(token=self.token)
56
+ # Initialize org_id and project_id attributes
57
+ # These will be set when loading from session
58
+ self.org_id = None
59
+ self.project_id = None
60
+
61
+ def verify(self) -> bool:
62
+ if self.catalog == "github":
63
+ if self.cloud_options.get("github_token") is None:
64
+ self.error_message = RECCE_CLOUD_TOKEN_MISSING.error_message
65
+ self.hint_message = RECCE_CLOUD_TOKEN_MISSING.hint_message
66
+ return False
67
+ if not self.cloud_options.get("host"):
68
+ if self.cloud_options.get("password") is None:
69
+ self.error_message = RECCE_CLOUD_PASSWORD_MISSING.error_message
70
+ self.hint_message = RECCE_CLOUD_PASSWORD_MISSING.hint_message
71
+ return False
72
+ elif self.catalog == "preview":
73
+ if self.cloud_options.get("api_token") is None:
74
+ self.error_message = RECCE_API_TOKEN_MISSING.error_message
75
+ self.hint_message = RECCE_API_TOKEN_MISSING.hint_message
76
+ return False
77
+ if self.cloud_options.get("share_id") is None:
78
+ self.error_message = "No share ID is provided for the preview catalog."
79
+ self.hint_message = (
80
+ 'Please provide a share URL in the command argument with option "--share-url <share-url>"'
81
+ )
82
+ return False
83
+ elif self.catalog == "session":
84
+ if self.cloud_options.get("api_token") is None:
85
+ self.error_message = RECCE_API_TOKEN_MISSING.error_message
86
+ self.hint_message = RECCE_API_TOKEN_MISSING.hint_message
87
+ return False
88
+ if self.cloud_options.get("session_id") is None:
89
+ self.error_message = "No session ID is provided for the session catalog."
90
+ self.hint_message = (
91
+ 'Please provide a session ID in the command argument with option "--session-id <session-id>"'
92
+ )
93
+ return False
94
+ return True
95
+
96
+ def purge(self) -> bool:
97
+ rc, err_msg = RecceCloudStateManager(self.cloud_options).purge_cloud_state()
98
+ if err_msg:
99
+ self.error_message = err_msg
100
+ return rc
101
+
102
+ def _load_state(self) -> Tuple[RecceState, str]:
103
+ """
104
+ Load the state from Recce Cloud based on catalog type.
105
+
106
+ Returns:
107
+ RecceState: The state object.
108
+ str: The etag of the state file (only used for GitHub).
109
+ """
110
+ if self.catalog == "github":
111
+ return self._load_state_from_github()
112
+ elif self.catalog == "preview":
113
+ return self._load_state_from_preview()
114
+ elif self.catalog == "session":
115
+ return self._load_state_from_session(), None
116
+ else:
117
+ raise RecceException(f"Unsupported catalog type: {self.catalog}")
118
+
119
+ def _load_state_from_github(self) -> Tuple[RecceState, str]:
120
+ """Load state from GitHub PR with etag checking."""
121
+ if (self.pr_info is None) or (self.pr_info.id is None) or (self.pr_info.repository is None):
122
+ raise RecceException("Cannot get the pull request information from GitHub.")
123
+
124
+ logger.debug("Fetching GitHub state from Recce Cloud...")
125
+
126
+ # Check metadata and etag for GitHub only
127
+ metadata = self._get_metadata_from_recce_cloud()
128
+ state_etag = metadata.get("etag") if metadata else None
129
+
130
+ # Return cached state if etag matches
131
+ if self.state_etag and state_etag == self.state_etag:
132
+ return self.state, self.state_etag
133
+
134
+ # Download state from GitHub
135
+ presigned_url = self.recce_cloud.get_presigned_url_by_github_repo(
136
+ method=PresignedUrlMethod.DOWNLOAD,
137
+ pr_id=self.pr_info.id,
138
+ repository=self.pr_info.repository,
139
+ artifact_name=RECCE_STATE_COMPRESSED_FILE,
140
+ )
141
+
142
+ password = self.cloud_options.get("password")
143
+ if password is None:
144
+ raise RecceException(RECCE_CLOUD_PASSWORD_MISSING.error_message)
145
+
146
+ headers = s3_sse_c_headers(password)
147
+ loaded_state = self._download_state_from_url(presigned_url, SupportedFileTypes.GZIP, headers)
148
+
149
+ # Handle the case where download returns None (404 error)
150
+ if loaded_state is None:
151
+ return None, state_etag
152
+
153
+ return loaded_state, state_etag
154
+
155
+ def _load_state_from_preview(self) -> Tuple[RecceState, None]:
156
+ """Load state from preview share (no etag checking needed)."""
157
+ if self.share_id is None:
158
+ raise RecceException("Cannot load the share state from Recce Cloud. No share ID is provided.")
159
+
160
+ logger.debug("Fetching preview state from Recce Cloud...")
161
+
162
+ # Download state from preview share
163
+ presigned_url = self.recce_cloud.get_presigned_url_by_share_id(
164
+ method=PresignedUrlMethod.DOWNLOAD, share_id=self.share_id
165
+ )
166
+
167
+ loaded_state = self._download_state_from_url(presigned_url, SupportedFileTypes.FILE)
168
+
169
+ # Handle the case where download returns None (404 error)
170
+ if loaded_state is None:
171
+ return None, None
172
+
173
+ return loaded_state, None
174
+
175
+ def _get_metadata_from_recce_cloud(self) -> Union[dict, None]:
176
+ return self.recce_cloud.get_artifact_metadata(pr_info=self.pr_info) if self.pr_info else None
177
+
178
+ @track_timing("state_download")
179
+ def _download_state_from_url(
180
+ self, presigned_url: str, file_type: SupportedFileTypes, headers: dict = None
181
+ ) -> RecceState:
182
+ """Download state file from presigned URL and convert to RecceState."""
183
+ import tempfile
184
+
185
+ import requests
186
+
187
+ with tempfile.NamedTemporaryFile() as tmp:
188
+ response = requests.get(presigned_url, headers=headers)
189
+
190
+ if response.status_code == 404:
191
+ self.error_message = "The state file is not found in Recce Cloud."
192
+ return None
193
+ elif response.status_code != 200:
194
+ self.error_message = response.text
195
+ error_msg = f"{response.status_code} Failed to download the state file from Recce Cloud."
196
+ if headers: # GitHub case with password
197
+ error_msg += " The password could be wrong."
198
+ raise RecceException(error_msg)
199
+
200
+ with open(tmp.name, "wb") as f:
201
+ f.write(response.content)
202
+
203
+ return RecceState.from_file(tmp.name, file_type=file_type)
204
+
205
+ def _load_state_from_session(self) -> RecceState:
206
+ """
207
+ Load state from session by:
208
+ 1. Get session info
209
+ 2. Download artifacts for both base and current sessions
210
+ 3. Download recce_state if available, otherwise create empty state with artifacts
211
+ """
212
+ if self.session_id is None:
213
+ raise RecceException("Cannot load the session state from Recce Cloud. No session ID is provided.")
214
+
215
+ # 1. Get session information
216
+ logger.debug(f"Getting session {self.session_id}")
217
+ session = self.recce_cloud.get_session(self.session_id)
218
+
219
+ pr_url = session.get("pr_link")
220
+ org_id = session.get("org_id")
221
+ project_id = session.get("project_id")
222
+
223
+ if not org_id or not project_id:
224
+ raise RecceException(f"Session {self.session_id} does not belong to a valid organization or project.")
225
+
226
+ # IMPORTANT: Store org_id and project_id as attributes for later use
227
+ # This allows CheckDAO and other components to access them without repeated API calls
228
+ self.org_id = org_id
229
+ self.project_id = project_id
230
+
231
+ # 2. Download manifests and catalogs for both session
232
+ logger.debug(f"Downloading current session artifacts for {self.session_id}")
233
+ current_artifacts = self._download_session_artifacts(self.recce_cloud, org_id, project_id, self.session_id)
234
+
235
+ logger.debug(f"Downloading base session artifacts for project {project_id}")
236
+ base_artifacts = self._download_base_session_artifacts(self.recce_cloud, org_id, project_id)
237
+
238
+ # 3. Try to download existing recce_state, otherwise create new state
239
+ try:
240
+ logger.debug(f"Downloading recce_state for session {self.session_id}")
241
+ state = self._download_session_recce_state(self.recce_cloud, org_id, project_id, self.session_id)
242
+ except Exception as e:
243
+ logger.debug(f"No existing recce_state found, creating new state: {e}")
244
+ state = RecceState()
245
+
246
+ if pr_url:
247
+ pr_id = pr_url.rstrip("/").split("/")[-1]
248
+ pull_request = PullRequestInfo(id=pr_id, url=pr_url)
249
+ self.pr_info = pull_request
250
+ if state.pull_request is None:
251
+ state.pull_request = pull_request
252
+
253
+ # Set artifacts regardless of whether we loaded existing state
254
+ state.artifacts.base = base_artifacts
255
+ state.artifacts.current = current_artifacts
256
+ state.checks = []
257
+
258
+ return state
259
+
260
+ def _download_session_artifacts(self, recce_cloud, org_id: str, project_id: str, session_id: str) -> dict:
261
+ """Download manifest and catalog for a session, return JSON data directly."""
262
+ import requests
263
+
264
+ # Get download URLs
265
+ presigned_urls = recce_cloud.get_download_urls_by_session_id(org_id, project_id, session_id)
266
+
267
+ artifacts = {}
268
+
269
+ # Download manifest
270
+ response = requests.get(presigned_urls["manifest_url"])
271
+ if response.status_code == 200:
272
+ artifacts["manifest"] = response.json()
273
+ else:
274
+ raise RecceException(f"Failed to download manifest for session {session_id}")
275
+
276
+ # Download catalog
277
+ response = requests.get(presigned_urls["catalog_url"])
278
+ if response.status_code == 200:
279
+ artifacts["catalog"] = response.json()
280
+ else:
281
+ raise RecceException(f"Failed to download catalog for session {session_id}")
282
+
283
+ return artifacts
284
+
285
+ def _download_session_recce_state(self, recce_cloud, org_id: str, project_id: str, session_id: str) -> RecceState:
286
+ """Download recce_state for a session."""
287
+ # Get download URLs (now includes recce_state_url)
288
+ presigned_urls = recce_cloud.get_download_urls_by_session_id(org_id, project_id, session_id)
289
+ recce_state_url = presigned_urls.get("recce_state_url")
290
+
291
+ if not recce_state_url:
292
+ raise RecceException(f"No recce_state_url found for session {session_id}")
293
+
294
+ # Reuse the existing download method
295
+ state = self._download_state_from_url(recce_state_url, SupportedFileTypes.FILE)
296
+
297
+ if state is None:
298
+ raise RecceException(f"Failed to download recce_state for session {session_id}")
299
+
300
+ return state
301
+
302
+ def _download_base_session_artifacts(self, recce_cloud, org_id: str, project_id: str) -> dict:
303
+ """Download manifest and catalog for the base session, return JSON data directly."""
304
+ import requests
305
+
306
+ # Get download URLs for base session
307
+ presigned_urls = recce_cloud.get_base_session_download_urls(org_id, project_id)
308
+
309
+ artifacts = {}
310
+
311
+ # Download manifest
312
+ response = requests.get(presigned_urls["manifest_url"])
313
+ if response.status_code == 200:
314
+ artifacts["manifest"] = response.json()
315
+ else:
316
+ raise RecceException(f"Failed to download base session manifest for project {project_id}")
317
+
318
+ # Download catalog
319
+ response = requests.get(presigned_urls["catalog_url"])
320
+ if response.status_code == 200:
321
+ artifacts["catalog"] = response.json()
322
+ else:
323
+ raise RecceException(f"Failed to download base session catalog for project {project_id}")
324
+
325
+ return artifacts
326
+
327
+ def _export_state(self) -> Tuple[Union[str, None], str]:
328
+ """
329
+ Export state to Recce Cloud based on catalog type.
330
+
331
+ Returns:
332
+ str: A message indicating the result of the export operation.
333
+ str: The etag of the exported state file (only used for GitHub).
334
+ """
335
+ logger.info("Store recce state to Recce Cloud")
336
+
337
+ if self.catalog == "github":
338
+ return self._export_state_to_github()
339
+ elif self.catalog == "preview":
340
+ return self._export_state_to_preview()
341
+ elif self.catalog == "session":
342
+ return self._export_state_to_session()
343
+ else:
344
+ raise RecceException(f"Unsupported catalog type: {self.catalog}")
345
+
346
+ def _export_state_to_github(self) -> Tuple[Union[str, None], str]:
347
+ """Export state to GitHub PR with metadata and etag."""
348
+ if (self.pr_info is None) or (self.pr_info.id is None) or (self.pr_info.repository is None):
349
+ raise RecceException("Cannot get the pull request information from GitHub.")
350
+
351
+ # Generate metadata for GitHub only
352
+ check_status = CheckDAO().status()
353
+ metadata = {
354
+ "total_checks": check_status.get("total", 0),
355
+ "approved_checks": check_status.get("approved", 0),
356
+ }
357
+
358
+ # Upload to Cloud
359
+ presigned_url = self.recce_cloud.get_presigned_url_by_github_repo(
360
+ method=PresignedUrlMethod.UPLOAD,
361
+ repository=self.pr_info.repository,
362
+ artifact_name=RECCE_STATE_COMPRESSED_FILE,
363
+ pr_id=self.pr_info.id,
364
+ metadata=metadata,
365
+ )
366
+ message = self._upload_state_to_url(
367
+ presigned_url=presigned_url,
368
+ file_type=SupportedFileTypes.GZIP,
369
+ password=self.cloud_options.get("password"),
370
+ metadata=metadata,
371
+ )
372
+
373
+ # Get updated etag after upload
374
+ metadata_response = self._get_metadata_from_recce_cloud()
375
+ state_etag = metadata_response.get("etag") if metadata_response else None
376
+
377
+ if message:
378
+ logger.warning(message)
379
+ return message, state_etag
380
+
381
+ def _export_state_to_preview(self) -> Tuple[Union[str, None], None]:
382
+ """Export state to preview share (no metadata or etag needed)."""
383
+ share_id = self.cloud_options.get("share_id")
384
+ presigned_url = self.recce_cloud.get_presigned_url_by_share_id(
385
+ method=PresignedUrlMethod.UPLOAD,
386
+ share_id=share_id,
387
+ metadata=None,
388
+ )
389
+ message = self._upload_state_to_url(
390
+ presigned_url=presigned_url, file_type=SupportedFileTypes.FILE, password=None, metadata=None
391
+ )
392
+
393
+ if message:
394
+ logger.warning(message)
395
+ return message, None
396
+
397
+ def _export_state_to_session(self) -> Tuple[Union[str, None], None]:
398
+ """Export state to session (upload recce_state with empty artifacts)."""
399
+ if self.session_id is None:
400
+ raise RecceException("Cannot export state to session. No session ID is provided.")
401
+
402
+ # Get session information
403
+ session = self.recce_cloud.get_session(self.session_id)
404
+ org_id = session.get("org_id")
405
+ project_id = session.get("project_id")
406
+
407
+ if not org_id or not project_id:
408
+ raise RecceException(f"Session {self.session_id} does not belong to a valid organization or project.")
409
+
410
+ # Get upload URLs (now includes recce_state_url)
411
+ presigned_urls = self.recce_cloud.get_upload_urls_by_session_id(org_id, project_id, self.session_id)
412
+ recce_state_url = presigned_urls.get("recce_state_url")
413
+
414
+ if not recce_state_url:
415
+ raise RecceException(f"No recce_state_url found for session {self.session_id}")
416
+
417
+ # Create a copy of the state with empty artifacts for upload
418
+ upload_state = RecceState()
419
+ upload_state.runs = self.state.runs.copy() if self.state.runs else []
420
+ upload_state.checks = []
421
+ # Keep artifacts empty (don't copy self.state.artifacts)
422
+
423
+ # Upload the state with empty artifacts
424
+ message = self._upload_state_to_url(
425
+ presigned_url=recce_state_url,
426
+ file_type=SupportedFileTypes.FILE,
427
+ password=None,
428
+ metadata=None,
429
+ state=upload_state,
430
+ )
431
+
432
+ if message:
433
+ logger.warning(message)
434
+ else:
435
+ # Notify Recce Cloud that the state has been uploaded if upload is successful
436
+ self.recce_cloud.post_recce_state_uploaded_by_session_id(org_id, project_id, self.session_id)
437
+ return message, None
438
+
439
+ def _upload_state_to_url(
440
+ self,
441
+ presigned_url: str,
442
+ file_type: SupportedFileTypes,
443
+ password: str = None,
444
+ metadata: dict = None,
445
+ state: RecceState = None,
446
+ ) -> Union[str, None]:
447
+ """Upload state file to presigned URL."""
448
+ import tempfile
449
+
450
+ import requests
451
+
452
+ # Use provided state or default to self.state
453
+ upload_state = state or self.state
454
+
455
+ # Prepare headers
456
+ headers = {}
457
+ if password:
458
+ headers.update(s3_sse_c_headers(password))
459
+ if metadata:
460
+ headers["x-amz-tagging"] = urlencode(metadata)
461
+
462
+ with tempfile.NamedTemporaryFile() as tmp:
463
+ # Use the specified state to export to file
464
+ json_data = upload_state.to_json()
465
+ io = file_io_factory(file_type)
466
+ io.write(tmp.name, json_data)
467
+
468
+ with open(tmp.name, "rb") as fd:
469
+ response = requests.put(presigned_url, data=fd.read(), headers=headers)
470
+
471
+ if response.status_code not in [200, 204]:
472
+ self.error_message = response.text
473
+ return "Failed to upload the state file to Recce Cloud. Reason: " + response.text
474
+
475
+ return None
476
+
477
+ def check_conflict(self) -> bool:
478
+ if self.catalog != "github":
479
+ return False
480
+
481
+ metadata = self._get_metadata_from_recce_cloud()
482
+ if not metadata:
483
+ return False
484
+
485
+ state_etag = metadata.get("etag")
486
+ return state_etag != self.state_etag
487
+
488
+
489
+ class RecceCloudStateManager:
490
+ error_message: str
491
+ hint_message: str
492
+
493
+ # It is a class to upload, download and purge the state file on Recce Cloud.
494
+
495
+ def __init__(self, cloud_options: Optional[Dict[str, str]] = None):
496
+ self.cloud_options = cloud_options or {}
497
+ self.pr_info = None
498
+ self.error_message = None
499
+ self.hint_message = None
500
+ self.github_token = self.cloud_options.get("github_token")
501
+
502
+ if not self.github_token:
503
+ raise RecceException(RECCE_CLOUD_TOKEN_MISSING.error_message)
504
+ self.pr_info = fetch_pr_metadata(cloud=True, github_token=self.github_token)
505
+ if self.pr_info.id is None:
506
+ raise RecceException("Cannot get the pull request information from GitHub.")
507
+
508
+ def verify(self) -> bool:
509
+ if self.github_token is None:
510
+ self.error_message = RECCE_CLOUD_TOKEN_MISSING.error_message
511
+ self.hint_message = RECCE_CLOUD_TOKEN_MISSING.hint_message
512
+ return False
513
+ if self.cloud_options.get("password") is None:
514
+ self.error_message = RECCE_CLOUD_PASSWORD_MISSING.error_message
515
+ self.hint_message = RECCE_CLOUD_PASSWORD_MISSING.hint_message
516
+ return False
517
+ return True
518
+
519
+ @property
520
+ def error_and_hint(self) -> (Union[str, None], Union[str, None]):
521
+ return self.error_message, self.hint_message
522
+
523
+ def _check_state_in_recce_cloud(self) -> bool:
524
+ return RecceCloud(token=self.github_token).check_artifacts_exists(self.pr_info)
525
+
526
+ def check_cloud_state_exists(self) -> bool:
527
+ return self._check_state_in_recce_cloud()
528
+
529
+ def _upload_state_to_recce_cloud(self, state: RecceState, metadata: dict = None) -> Union[str, None]:
530
+ import tempfile
531
+
532
+ import requests
533
+
534
+ presigned_url = RecceCloud(token=self.github_token).get_presigned_url_by_github_repo(
535
+ method=PresignedUrlMethod.UPLOAD,
536
+ repository=self.pr_info.repository,
537
+ artifact_name=RECCE_STATE_COMPRESSED_FILE,
538
+ pr_id=self.pr_info.id,
539
+ metadata=metadata,
540
+ )
541
+
542
+ compress_passwd = self.cloud_options.get("password")
543
+ headers = s3_sse_c_headers(compress_passwd)
544
+ with tempfile.NamedTemporaryFile() as tmp:
545
+ state.to_file(tmp.name, file_type=SupportedFileTypes.GZIP)
546
+ response = requests.put(presigned_url, data=open(tmp.name, "rb").read(), headers=headers)
547
+ if response.status_code != 200:
548
+ return f"Failed to upload the state file to Recce Cloud. Reason: {response.text}"
549
+ return "The state file is uploaded to Recce Cloud."
550
+
551
+ def upload_state_to_cloud(self, state: RecceState) -> Union[str, None]:
552
+ if (self.pr_info is None) or (self.pr_info.id is None) or (self.pr_info.repository is None):
553
+ raise RecceException("Cannot get the pull request information from GitHub.")
554
+
555
+ checks = state.checks
556
+
557
+ metadata = {
558
+ "total_checks": len(checks),
559
+ "approved_checks": len([c for c in checks if c.is_checked]),
560
+ }
561
+
562
+ return self._upload_state_to_recce_cloud(state, metadata)
563
+
564
+ def _download_state_from_recce_cloud(self, filepath):
565
+ import io
566
+
567
+ import requests
568
+
569
+ presigned_url = RecceCloud(token=self.github_token).get_presigned_url_by_github_repo(
570
+ method=PresignedUrlMethod.DOWNLOAD,
571
+ repository=self.pr_info.repository,
572
+ artifact_name=RECCE_STATE_COMPRESSED_FILE,
573
+ pr_id=self.pr_info.id,
574
+ )
575
+
576
+ password = self.cloud_options.get("password")
577
+ if password is None:
578
+ raise RecceException(RECCE_CLOUD_PASSWORD_MISSING.error_message)
579
+
580
+ headers = s3_sse_c_headers(password)
581
+ response = requests.get(presigned_url, headers=headers)
582
+
583
+ if response.status_code != 200:
584
+ raise RecceException(
585
+ f"{response.status_code} Failed to download the state file from Recce Cloud. The password could be wrong."
586
+ )
587
+
588
+ byte_stream = io.BytesIO(response.content)
589
+ gzip_io = file_io_factory(SupportedFileTypes.GZIP)
590
+ decompressed_content = gzip_io.read_fileobj(byte_stream)
591
+
592
+ dirs = os.path.dirname(filepath)
593
+ if dirs:
594
+ os.makedirs(dirs, exist_ok=True)
595
+ with open(filepath, "wb") as f:
596
+ f.write(decompressed_content)
597
+
598
+ def download_state_from_cloud(self, filepath: str) -> Union[str, None]:
599
+ if (self.pr_info is None) or (self.pr_info.id is None) or (self.pr_info.repository is None):
600
+ raise RecceException("Cannot get the pull request information from GitHub.")
601
+
602
+ logger.debug("Download state file from Recce Cloud...")
603
+ return self._download_state_from_recce_cloud(filepath)
604
+
605
+ def _purge_state_from_recce_cloud(self) -> (bool, str):
606
+ try:
607
+ RecceCloud(token=self.github_token).purge_artifacts(self.pr_info.repository, pr_id=self.pr_info.id)
608
+ except RecceCloudException as e:
609
+ return False, e.reason
610
+ return True, None
611
+
612
+ def purge_cloud_state(self) -> (bool, str):
613
+ return self._purge_state_from_recce_cloud()
614
+
615
+
616
+ class RecceShareStateManager:
617
+ error_message: str
618
+ hint_message: str
619
+
620
+ # It is a class to share state file on Recce Cloud.
621
+
622
+ def __init__(self, auth_options: Optional[Dict[str, str]] = None):
623
+ self.auth_options = auth_options or {}
624
+ self.error_message = None
625
+ self.hint_message = None
626
+
627
+ def verify(self) -> bool:
628
+ if get_recce_api_token() is None:
629
+ self.error_message = RECCE_API_TOKEN_MISSING.error_message
630
+ self.hint_message = RECCE_API_TOKEN_MISSING.hint_message
631
+ return False
632
+ return True
633
+
634
+ @property
635
+ def error_and_hint(self) -> (Union[str, None], Union[str, None]):
636
+ return self.error_message, self.hint_message
637
+
638
+ def share_state(self, file_name: str, state: RecceState) -> Dict:
639
+ import tempfile
640
+
641
+ with tempfile.NamedTemporaryFile() as tmp:
642
+ state.to_file(tmp.name, file_type=SupportedFileTypes.FILE)
643
+ response = RecceCloud(token=get_recce_api_token()).share_state(file_name, open(tmp.name, "rb"))
644
+ return response
recce/state/const.py ADDED
@@ -0,0 +1,26 @@
1
+ from dataclasses import dataclass
2
+
3
+ RECCE_STATE_FILE = "recce-state.json"
4
+ RECCE_STATE_COMPRESSED_FILE = f"{RECCE_STATE_FILE}.gz"
5
+
6
+
7
+ @dataclass
8
+ class ErrorMessage:
9
+ error_message: str
10
+ hint_message: str
11
+
12
+
13
+ RECCE_CLOUD_TOKEN_MISSING = ErrorMessage(
14
+ error_message="No GitHub token is provided to access the pull request information",
15
+ hint_message="Please provide a GitHub token in the command argument",
16
+ )
17
+
18
+ RECCE_CLOUD_PASSWORD_MISSING = ErrorMessage(
19
+ error_message="No password provided to access the state file in Recce Cloud",
20
+ hint_message='Please provide a password with the option "--password <compress-password>"',
21
+ )
22
+
23
+ RECCE_API_TOKEN_MISSING = ErrorMessage(
24
+ error_message="No Recce API token is provided",
25
+ hint_message="Please login to Recce Cloud and copy the API token from the settings page",
26
+ )