recce-nightly 1.16.0.20250813__py3-none-any.whl → 1.16.0.20250817__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.
- recce/VERSION +1 -1
- recce/cli.py +13 -4
- recce/data/404.html +1 -1
- recce/data/_next/static/{RX_nnCGyE7ZOeoTStwYCZ → -zBI1h60_HeMe-9ZrCw7s}/_buildManifest.js +1 -1
- recce/data/_next/static/chunks/068b80ea-7fab85837b21fed5.js +1 -0
- recce/data/_next/static/chunks/{0ddaf06c-c7961285f66460f6.js → 0ddaf06c-05b8d2a8a3e5c6f3.js} +1 -1
- recce/data/_next/static/chunks/{12f8fac4-16838e42d28d45c3.js → 12f8fac4-170216f89d5576c6.js} +1 -1
- recce/data/_next/static/chunks/{235b8375-8c84c51d7bd4f6aa.js → 235b8375-c2d62e0b44e3ae74.js} +1 -1
- recce/data/_next/static/chunks/{2541941f-2cd3a7c2d629bd33.js → 2541941f-d65696bd5d37dfa2.js} +1 -1
- recce/data/_next/static/chunks/25c097a4-51d7c9060de49a6c.js +1 -0
- recce/data/_next/static/chunks/27e92eb0-4d8ec3c6cdf0d5f1.js +1 -0
- recce/data/_next/static/chunks/{2fc37c1e-910deebeb3d77c90.js → 2fc37c1e-fd3c94ef85344810.js} +1 -1
- recce/data/_next/static/chunks/370-bd3e0bf7237858bc.js +1 -0
- recce/data/_next/static/chunks/{3a92ee20-0400ffe460c7c803.js → 3a92ee20-a33bb5964d0b2f37.js} +1 -1
- recce/data/_next/static/chunks/490-ce854af0bc670d3e.js +1 -0
- recce/data/_next/static/chunks/575-2443813ff441d02c.js +1 -0
- recce/data/_next/static/chunks/{62446465-423c03bb8c1f59b6.js → 62446465-5277e7ad93431c07.js} +1 -1
- recce/data/_next/static/chunks/{6af7f9e9-60aa8706f49dae45.js → 6af7f9e9-1b5f068628caacd4.js} +1 -1
- recce/data/_next/static/chunks/{6cf54382-49d52ae6e564e2ac.js → 6cf54382-9bc9b4c7b7e4b8d8.js} +1 -1
- recce/data/_next/static/chunks/6dc81886-2b329a8d619994c9.js +1 -0
- recce/data/_next/static/chunks/{715e4acc-9e2e6df4eb3809d1.js → 715e4acc-be8c974e746c0e77.js} +1 -1
- recce/data/_next/static/chunks/76-54eeed3fa75d6146.js +10 -0
- recce/data/_next/static/chunks/{8d700b6a.7fe2c8c3f4e333a6.js → 8d700b6a.6c883c62894c0d11.js} +1 -1
- recce/data/_next/static/chunks/975-341ea105d3d5e6ca.js +30 -0
- recce/data/_next/static/chunks/{ae307f12-01100009689ace61.js → ae307f12-0f5382a8bd52593f.js} +1 -1
- recce/data/_next/static/chunks/app/_not-found/page-f6afb3793e4b6ce1.js +1 -0
- recce/data/_next/static/chunks/app/layout-6b127f6a9d30f81d.js +1 -0
- recce/data/_next/static/chunks/app/page-6e7f45c0d0eb69b3.js +1 -0
- recce/data/_next/static/chunks/c0015c5c-6adfd10682179dba.js +1 -0
- recce/data/_next/static/chunks/{1268aea1-6dc1251c01bd724b.js → c41d1589-27a36c0adae3f11b.js} +4 -4
- recce/data/_next/static/chunks/{d90cfbaa-e7d779b3912afeec.js → d90cfbaa-10a3b2cefa2eb9ba.js} +1 -1
- recce/data/_next/static/chunks/{e07c302e-cd170429646873e1.js → e07c302e-21163cafc81583fb.js} +1 -1
- recce/data/_next/static/chunks/{fa5fb511-15fb438349ad5b97.js → fa5fb511-722a622636dc535b.js} +1 -1
- recce/data/_next/static/chunks/{framework-7950757d31580329.js → framework-d3cdc9ad10f589c3.js} +1 -1
- recce/data/_next/static/chunks/main-0aa311ca5eab4c3d.js +1 -0
- recce/data/_next/static/chunks/main-app-a6bd599e25466331.js +1 -0
- recce/data/_next/static/chunks/pages/_app-f73a7d040f1ea45c.js +1 -0
- recce/data/_next/static/chunks/pages/_error-c550ce619d2ef6f8.js +1 -0
- recce/data/_next/static/chunks/{webpack-84df6dd5ae3cf908.js → webpack-e707d93a7b1691e8.js} +1 -1
- recce/data/_next/static/css/9ba7df14573e8fe7.css +1 -0
- recce/data/index.html +1 -1
- recce/data/index.txt +14 -14
- recce/state/__init__.py +31 -0
- recce/state/cloud.py +384 -0
- recce/state/const.py +26 -0
- recce/state/local.py +56 -0
- recce/state/state.py +118 -0
- recce/state/state_loader.py +179 -0
- {recce_nightly-1.16.0.20250813.dist-info → recce_nightly-1.16.0.20250817.dist-info}/METADATA +1 -1
- {recce_nightly-1.16.0.20250813.dist-info → recce_nightly-1.16.0.20250817.dist-info}/RECORD +58 -54
- tests/test_cli.py +3 -3
- tests/test_core.py +147 -0
- tests/test_server.py +6 -6
- recce/data/_next/static/chunks/0376eeba-3db2196398d62270.js +0 -1
- recce/data/_next/static/chunks/068b80ea-833a129468ee1622.js +0 -1
- recce/data/_next/static/chunks/273-f3fa401bd2b6fc91.js +0 -10
- recce/data/_next/static/chunks/338-2e7eed5135c64550.js +0 -30
- recce/data/_next/static/chunks/367-ae35f5a152ee1ce5.js +0 -1
- recce/data/_next/static/chunks/6dc81886-78e2efe4538794ae.js +0 -1
- recce/data/_next/static/chunks/72-181b430654230f0e.js +0 -1
- recce/data/_next/static/chunks/786-774e3e3ed70a41b3.js +0 -1
- recce/data/_next/static/chunks/a69d64b4-d6890125a87b0aba.js +0 -1
- recce/data/_next/static/chunks/app/_not-found/page-c7ef8ed6dc07aaeb.js +0 -1
- recce/data/_next/static/chunks/app/layout-744f0a78e9e50e60.js +0 -1
- recce/data/_next/static/chunks/app/page-e8f798c2ae3f59c2.js +0 -1
- recce/data/_next/static/chunks/c0015c5c-82c219792582c104.js +0 -1
- recce/data/_next/static/chunks/main-app-4df79eb11c34d43c.js +0 -1
- recce/data/_next/static/chunks/main-cd6c104af638214a.js +0 -1
- recce/data/_next/static/chunks/pages/_app-73008661edbd5e05.js +0 -1
- recce/data/_next/static/chunks/pages/_error-cf8bbdc3cf76c83f.js +0 -1
- recce/data/_next/static/css/188a3a1687e2a064.css +0 -1
- recce/state.py +0 -865
- tests/test_state.py +0 -134
- /recce/data/_next/static/{RX_nnCGyE7ZOeoTStwYCZ → -zBI1h60_HeMe-9ZrCw7s}/_ssgManifest.js +0 -0
- {recce_nightly-1.16.0.20250813.dist-info → recce_nightly-1.16.0.20250817.dist-info}/WHEEL +0 -0
- {recce_nightly-1.16.0.20250813.dist-info → recce_nightly-1.16.0.20250817.dist-info}/entry_points.txt +0 -0
- {recce_nightly-1.16.0.20250813.dist-info → recce_nightly-1.16.0.20250817.dist-info}/licenses/LICENSE +0 -0
- {recce_nightly-1.16.0.20250813.dist-info → recce_nightly-1.16.0.20250817.dist-info}/top_level.txt +0 -0
recce/state/cloud.py
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
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 fetch_pr_metadata
|
|
10
|
+
from recce.util.io import SupportedFileTypes, file_io_factory
|
|
11
|
+
from recce.util.recce_cloud import PresignedUrlMethod, RecceCloud, RecceCloudException
|
|
12
|
+
|
|
13
|
+
from ..event import get_recce_api_token
|
|
14
|
+
from ..models import CheckDAO
|
|
15
|
+
from .const import (
|
|
16
|
+
RECCE_API_TOKEN_MISSING,
|
|
17
|
+
RECCE_CLOUD_PASSWORD_MISSING,
|
|
18
|
+
RECCE_CLOUD_TOKEN_MISSING,
|
|
19
|
+
RECCE_STATE_COMPRESSED_FILE,
|
|
20
|
+
)
|
|
21
|
+
from .state import RecceState
|
|
22
|
+
from .state_loader import RecceStateLoader
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("uvicorn")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def s3_sse_c_headers(password: str) -> Dict[str, str]:
|
|
28
|
+
hashed_password = sha256()
|
|
29
|
+
md5_hash = md5()
|
|
30
|
+
hashed_password.update(password.encode())
|
|
31
|
+
md5_hash.update(hashed_password.digest())
|
|
32
|
+
encoded_passwd = b64encode(hashed_password.digest()).decode("utf-8")
|
|
33
|
+
encoded_md5 = b64encode(md5_hash.digest()).decode("utf-8")
|
|
34
|
+
return {
|
|
35
|
+
"x-amz-server-side-encryption-customer-algorithm": "AES256",
|
|
36
|
+
"x-amz-server-side-encryption-customer-key": encoded_passwd,
|
|
37
|
+
"x-amz-server-side-encryption-customer-key-MD5": encoded_md5,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class CloudStateLoader(RecceStateLoader):
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
review_mode: bool = False,
|
|
45
|
+
cloud_options: Optional[Dict[str, str]] = None,
|
|
46
|
+
initial_state: Optional[RecceState] = None,
|
|
47
|
+
):
|
|
48
|
+
super().__init__(
|
|
49
|
+
cloud_mode=True,
|
|
50
|
+
review_mode=review_mode,
|
|
51
|
+
cloud_options=cloud_options,
|
|
52
|
+
initial_state=initial_state,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def verify(self) -> bool:
|
|
56
|
+
if self.catalog == "github":
|
|
57
|
+
if self.cloud_options.get("github_token") is None:
|
|
58
|
+
self.error_message = RECCE_CLOUD_TOKEN_MISSING.error_message
|
|
59
|
+
self.hint_message = RECCE_CLOUD_TOKEN_MISSING.hint_message
|
|
60
|
+
return False
|
|
61
|
+
if not self.cloud_options.get("host"):
|
|
62
|
+
if self.cloud_options.get("password") is None:
|
|
63
|
+
self.error_message = RECCE_CLOUD_PASSWORD_MISSING.error_message
|
|
64
|
+
self.hint_message = RECCE_CLOUD_PASSWORD_MISSING.hint_message
|
|
65
|
+
return False
|
|
66
|
+
elif self.catalog == "preview":
|
|
67
|
+
if self.cloud_options.get("api_token") is None:
|
|
68
|
+
self.error_message = RECCE_API_TOKEN_MISSING.error_message
|
|
69
|
+
self.hint_message = RECCE_API_TOKEN_MISSING.hint_message
|
|
70
|
+
return False
|
|
71
|
+
if self.cloud_options.get("share_id") is None:
|
|
72
|
+
self.error_message = "No share ID is provided for the preview catalog."
|
|
73
|
+
self.hint_message = (
|
|
74
|
+
'Please provide a share URL in the command argument with option "--share-url <share-url>"'
|
|
75
|
+
)
|
|
76
|
+
return False
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
def _load_state(self) -> Tuple[RecceState, str]:
|
|
80
|
+
return self._load_state_from_cloud()
|
|
81
|
+
|
|
82
|
+
def _export_state(self, state: RecceState = None) -> Union[str, None]:
|
|
83
|
+
return self._export_state_to_cloud()
|
|
84
|
+
|
|
85
|
+
def purge(self) -> bool:
|
|
86
|
+
rc, err_msg = RecceCloudStateManager(self.cloud_options).purge_cloud_state()
|
|
87
|
+
if err_msg:
|
|
88
|
+
self.error_message = err_msg
|
|
89
|
+
return rc
|
|
90
|
+
|
|
91
|
+
def _load_state_from_cloud(self) -> Tuple[RecceState, str]:
|
|
92
|
+
"""
|
|
93
|
+
Load the state from Recce Cloud.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
RecceState: The state object.
|
|
97
|
+
str: The etag of the state file.
|
|
98
|
+
"""
|
|
99
|
+
if self.catalog == "github":
|
|
100
|
+
if (self.pr_info is None) or (self.pr_info.id is None) or (self.pr_info.repository is None):
|
|
101
|
+
raise RecceException("Cannot get the pull request information from GitHub.")
|
|
102
|
+
elif self.catalog == "preview":
|
|
103
|
+
if self.share_id is None:
|
|
104
|
+
raise RecceException("Cannot load the share state from Recce Cloud. No share ID is provided.")
|
|
105
|
+
|
|
106
|
+
logger.debug("Fetching state from Recce Cloud...")
|
|
107
|
+
metadata = self._get_metadata_from_recce_cloud()
|
|
108
|
+
if metadata:
|
|
109
|
+
state_etag = metadata.get("etag")
|
|
110
|
+
else:
|
|
111
|
+
state_etag = None
|
|
112
|
+
if self.state_etag and state_etag == self.state_etag:
|
|
113
|
+
return self.state, self.state_etag
|
|
114
|
+
|
|
115
|
+
return self._load_state_from_recce_cloud(), state_etag
|
|
116
|
+
|
|
117
|
+
def _get_metadata_from_recce_cloud(self) -> Union[dict, None]:
|
|
118
|
+
recce_cloud = RecceCloud(token=self.token)
|
|
119
|
+
return recce_cloud.get_artifact_metadata(pr_info=self.pr_info) if self.pr_info else None
|
|
120
|
+
|
|
121
|
+
def _load_state_from_recce_cloud(self) -> Union[RecceState, None]:
|
|
122
|
+
import tempfile
|
|
123
|
+
|
|
124
|
+
import requests
|
|
125
|
+
|
|
126
|
+
recce_cloud = RecceCloud(token=self.token)
|
|
127
|
+
password = None
|
|
128
|
+
|
|
129
|
+
if self.catalog == "github":
|
|
130
|
+
presigned_url = recce_cloud.get_presigned_url_by_github_repo(
|
|
131
|
+
method=PresignedUrlMethod.DOWNLOAD,
|
|
132
|
+
pr_id=self.pr_info.id,
|
|
133
|
+
repository=self.pr_info.repository,
|
|
134
|
+
artifact_name=RECCE_STATE_COMPRESSED_FILE,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
password = self.cloud_options.get("password")
|
|
138
|
+
if password is None:
|
|
139
|
+
raise RecceException(RECCE_CLOUD_PASSWORD_MISSING.error_message)
|
|
140
|
+
elif self.catalog == "preview":
|
|
141
|
+
share_id = self.cloud_options.get("share_id")
|
|
142
|
+
presigned_url = recce_cloud.get_presigned_url_by_share_id(
|
|
143
|
+
method=PresignedUrlMethod.DOWNLOAD, share_id=share_id
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
with tempfile.NamedTemporaryFile() as tmp:
|
|
147
|
+
from .cloud import s3_sse_c_headers
|
|
148
|
+
|
|
149
|
+
headers = s3_sse_c_headers(password) if password else None
|
|
150
|
+
response = requests.get(presigned_url, headers=headers)
|
|
151
|
+
if response.status_code == 404:
|
|
152
|
+
self.error_message = "The state file is not found in Recce Cloud."
|
|
153
|
+
return None
|
|
154
|
+
elif response.status_code != 200:
|
|
155
|
+
self.error_message = response.text
|
|
156
|
+
raise RecceException(
|
|
157
|
+
f"{response.status_code} Failed to download the state file from Recce Cloud. The password could be wrong."
|
|
158
|
+
)
|
|
159
|
+
with open(tmp.name, "wb") as f:
|
|
160
|
+
f.write(response.content)
|
|
161
|
+
|
|
162
|
+
file_type = SupportedFileTypes.GZIP if self.catalog == "github" else SupportedFileTypes.FILE
|
|
163
|
+
return RecceState.from_file(tmp.name, file_type=file_type)
|
|
164
|
+
|
|
165
|
+
def _export_state_to_cloud(self) -> Tuple[Union[str, None], str]:
|
|
166
|
+
if self.catalog == "github":
|
|
167
|
+
if (self.pr_info is None) or (self.pr_info.id is None) or (self.pr_info.repository is None):
|
|
168
|
+
raise RecceException("Cannot get the pull request information from GitHub.")
|
|
169
|
+
elif self.catalog == "preview":
|
|
170
|
+
pass
|
|
171
|
+
|
|
172
|
+
check_status = CheckDAO().status()
|
|
173
|
+
metadata = {
|
|
174
|
+
"total_checks": check_status.get("total", 0),
|
|
175
|
+
"approved_checks": check_status.get("approved", 0),
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
logger.info("Store recce state to Recce Cloud")
|
|
179
|
+
message = self._export_state_to_recce_cloud(metadata=metadata)
|
|
180
|
+
metadata = self._get_metadata_from_recce_cloud()
|
|
181
|
+
state_etag = metadata.get("etag") if metadata else None
|
|
182
|
+
if message:
|
|
183
|
+
logger.warning(message)
|
|
184
|
+
return message, state_etag
|
|
185
|
+
|
|
186
|
+
def _export_state_to_recce_cloud(self, metadata: dict = None) -> Union[str, None]:
|
|
187
|
+
import tempfile
|
|
188
|
+
|
|
189
|
+
import requests
|
|
190
|
+
|
|
191
|
+
if self.catalog == "github":
|
|
192
|
+
presigned_url = RecceCloud(token=self.token).get_presigned_url_by_github_repo(
|
|
193
|
+
method=PresignedUrlMethod.UPLOAD,
|
|
194
|
+
repository=self.pr_info.repository,
|
|
195
|
+
artifact_name=RECCE_STATE_COMPRESSED_FILE,
|
|
196
|
+
pr_id=self.pr_info.id,
|
|
197
|
+
metadata=metadata,
|
|
198
|
+
)
|
|
199
|
+
elif self.catalog == "preview":
|
|
200
|
+
share_id = self.cloud_options.get("share_id")
|
|
201
|
+
presigned_url = RecceCloud(token=self.token).get_presigned_url_by_share_id(
|
|
202
|
+
method=PresignedUrlMethod.UPLOAD,
|
|
203
|
+
share_id=share_id,
|
|
204
|
+
metadata=metadata,
|
|
205
|
+
)
|
|
206
|
+
compress_passwd = self.cloud_options.get("password")
|
|
207
|
+
if compress_passwd:
|
|
208
|
+
headers = s3_sse_c_headers(compress_passwd)
|
|
209
|
+
else:
|
|
210
|
+
headers = {}
|
|
211
|
+
|
|
212
|
+
if metadata:
|
|
213
|
+
headers["x-amz-tagging"] = urlencode(metadata)
|
|
214
|
+
with tempfile.NamedTemporaryFile() as tmp:
|
|
215
|
+
if self.catalog == "github":
|
|
216
|
+
file_type = SupportedFileTypes.GZIP
|
|
217
|
+
elif self.catalog == "preview":
|
|
218
|
+
file_type = SupportedFileTypes.FILE
|
|
219
|
+
self._export_state_to_file(tmp.name, file_type=file_type)
|
|
220
|
+
|
|
221
|
+
with open(tmp.name, "rb") as fd:
|
|
222
|
+
response = requests.put(presigned_url, data=fd.read(), headers=headers)
|
|
223
|
+
if response.status_code not in [200, 204]:
|
|
224
|
+
self.error_message = response.text
|
|
225
|
+
return "Failed to upload the state file to Recce Cloud. Reason: " + response.text
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class RecceCloudStateManager:
|
|
230
|
+
error_message: str
|
|
231
|
+
hint_message: str
|
|
232
|
+
|
|
233
|
+
# It is a class to upload, download and purge the state file on Recce Cloud.
|
|
234
|
+
|
|
235
|
+
def __init__(self, cloud_options: Optional[Dict[str, str]] = None):
|
|
236
|
+
self.cloud_options = cloud_options or {}
|
|
237
|
+
self.pr_info = None
|
|
238
|
+
self.error_message = None
|
|
239
|
+
self.hint_message = None
|
|
240
|
+
self.github_token = self.cloud_options.get("github_token")
|
|
241
|
+
|
|
242
|
+
if not self.github_token:
|
|
243
|
+
raise RecceException(RECCE_CLOUD_TOKEN_MISSING.error_message)
|
|
244
|
+
self.pr_info = fetch_pr_metadata(cloud=True, github_token=self.github_token)
|
|
245
|
+
if self.pr_info.id is None:
|
|
246
|
+
raise RecceException("Cannot get the pull request information from GitHub.")
|
|
247
|
+
|
|
248
|
+
def verify(self) -> bool:
|
|
249
|
+
if self.github_token is None:
|
|
250
|
+
self.error_message = RECCE_CLOUD_TOKEN_MISSING.error_message
|
|
251
|
+
self.hint_message = RECCE_CLOUD_TOKEN_MISSING.hint_message
|
|
252
|
+
return False
|
|
253
|
+
if self.cloud_options.get("password") is None:
|
|
254
|
+
self.error_message = RECCE_CLOUD_PASSWORD_MISSING.error_message
|
|
255
|
+
self.hint_message = RECCE_CLOUD_PASSWORD_MISSING.hint_message
|
|
256
|
+
return False
|
|
257
|
+
return True
|
|
258
|
+
|
|
259
|
+
@property
|
|
260
|
+
def error_and_hint(self) -> (Union[str, None], Union[str, None]):
|
|
261
|
+
return self.error_message, self.hint_message
|
|
262
|
+
|
|
263
|
+
def _check_state_in_recce_cloud(self) -> bool:
|
|
264
|
+
return RecceCloud(token=self.github_token).check_artifacts_exists(self.pr_info)
|
|
265
|
+
|
|
266
|
+
def check_cloud_state_exists(self) -> bool:
|
|
267
|
+
return self._check_state_in_recce_cloud()
|
|
268
|
+
|
|
269
|
+
def _upload_state_to_recce_cloud(self, state: RecceState, metadata: dict = None) -> Union[str, None]:
|
|
270
|
+
import tempfile
|
|
271
|
+
|
|
272
|
+
import requests
|
|
273
|
+
|
|
274
|
+
presigned_url = RecceCloud(token=self.github_token).get_presigned_url_by_github_repo(
|
|
275
|
+
method=PresignedUrlMethod.UPLOAD,
|
|
276
|
+
repository=self.pr_info.repository,
|
|
277
|
+
artifact_name=RECCE_STATE_COMPRESSED_FILE,
|
|
278
|
+
pr_id=self.pr_info.id,
|
|
279
|
+
metadata=metadata,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
compress_passwd = self.cloud_options.get("password")
|
|
283
|
+
headers = s3_sse_c_headers(compress_passwd)
|
|
284
|
+
with tempfile.NamedTemporaryFile() as tmp:
|
|
285
|
+
state.to_file(tmp.name, file_type=SupportedFileTypes.GZIP)
|
|
286
|
+
response = requests.put(presigned_url, data=open(tmp.name, "rb").read(), headers=headers)
|
|
287
|
+
if response.status_code != 200:
|
|
288
|
+
return f"Failed to upload the state file to Recce Cloud. Reason: {response.text}"
|
|
289
|
+
return "The state file is uploaded to Recce Cloud."
|
|
290
|
+
|
|
291
|
+
def upload_state_to_cloud(self, state: RecceState) -> Union[str, None]:
|
|
292
|
+
if (self.pr_info is None) or (self.pr_info.id is None) or (self.pr_info.repository is None):
|
|
293
|
+
raise RecceException("Cannot get the pull request information from GitHub.")
|
|
294
|
+
|
|
295
|
+
checks = state.checks
|
|
296
|
+
|
|
297
|
+
metadata = {
|
|
298
|
+
"total_checks": len(checks),
|
|
299
|
+
"approved_checks": len([c for c in checks if c.is_checked]),
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return self._upload_state_to_recce_cloud(state, metadata)
|
|
303
|
+
|
|
304
|
+
def _download_state_from_recce_cloud(self, filepath):
|
|
305
|
+
import io
|
|
306
|
+
|
|
307
|
+
import requests
|
|
308
|
+
|
|
309
|
+
presigned_url = RecceCloud(token=self.github_token).get_presigned_url_by_github_repo(
|
|
310
|
+
method=PresignedUrlMethod.DOWNLOAD,
|
|
311
|
+
repository=self.pr_info.repository,
|
|
312
|
+
artifact_name=RECCE_STATE_COMPRESSED_FILE,
|
|
313
|
+
pr_id=self.pr_info.id,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
password = self.cloud_options.get("password")
|
|
317
|
+
if password is None:
|
|
318
|
+
raise RecceException(RECCE_CLOUD_PASSWORD_MISSING.error_message)
|
|
319
|
+
|
|
320
|
+
headers = s3_sse_c_headers(password)
|
|
321
|
+
response = requests.get(presigned_url, headers=headers)
|
|
322
|
+
|
|
323
|
+
if response.status_code != 200:
|
|
324
|
+
raise RecceException(
|
|
325
|
+
f"{response.status_code} Failed to download the state file from Recce Cloud. The password could be wrong."
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
byte_stream = io.BytesIO(response.content)
|
|
329
|
+
gzip_io = file_io_factory(SupportedFileTypes.GZIP)
|
|
330
|
+
decompressed_content = gzip_io.read_fileobj(byte_stream)
|
|
331
|
+
|
|
332
|
+
dirs = os.path.dirname(filepath)
|
|
333
|
+
if dirs:
|
|
334
|
+
os.makedirs(dirs, exist_ok=True)
|
|
335
|
+
with open(filepath, "wb") as f:
|
|
336
|
+
f.write(decompressed_content)
|
|
337
|
+
|
|
338
|
+
def download_state_from_cloud(self, filepath: str) -> Union[str, None]:
|
|
339
|
+
if (self.pr_info is None) or (self.pr_info.id is None) or (self.pr_info.repository is None):
|
|
340
|
+
raise RecceException("Cannot get the pull request information from GitHub.")
|
|
341
|
+
|
|
342
|
+
logger.debug("Download state file from Recce Cloud...")
|
|
343
|
+
return self._download_state_from_recce_cloud(filepath)
|
|
344
|
+
|
|
345
|
+
def _purge_state_from_recce_cloud(self) -> (bool, str):
|
|
346
|
+
try:
|
|
347
|
+
RecceCloud(token=self.github_token).purge_artifacts(self.pr_info)
|
|
348
|
+
except RecceCloudException as e:
|
|
349
|
+
return False, e.reason
|
|
350
|
+
return True, None
|
|
351
|
+
|
|
352
|
+
def purge_cloud_state(self) -> (bool, str):
|
|
353
|
+
return self._purge_state_from_recce_cloud()
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
class RecceShareStateManager:
|
|
357
|
+
error_message: str
|
|
358
|
+
hint_message: str
|
|
359
|
+
|
|
360
|
+
# It is a class to share state file on Recce Cloud.
|
|
361
|
+
|
|
362
|
+
def __init__(self, auth_options: Optional[Dict[str, str]] = None):
|
|
363
|
+
self.auth_options = auth_options or {}
|
|
364
|
+
self.error_message = None
|
|
365
|
+
self.hint_message = None
|
|
366
|
+
|
|
367
|
+
def verify(self) -> bool:
|
|
368
|
+
if get_recce_api_token() is None:
|
|
369
|
+
self.error_message = RECCE_API_TOKEN_MISSING.error_message
|
|
370
|
+
self.hint_message = RECCE_API_TOKEN_MISSING.hint_message
|
|
371
|
+
return False
|
|
372
|
+
return True
|
|
373
|
+
|
|
374
|
+
@property
|
|
375
|
+
def error_and_hint(self) -> (Union[str, None], Union[str, None]):
|
|
376
|
+
return self.error_message, self.hint_message
|
|
377
|
+
|
|
378
|
+
def share_state(self, file_name: str, state: RecceState) -> Dict:
|
|
379
|
+
import tempfile
|
|
380
|
+
|
|
381
|
+
with tempfile.NamedTemporaryFile() as tmp:
|
|
382
|
+
state.to_file(tmp.name, file_type=SupportedFileTypes.FILE)
|
|
383
|
+
response = RecceCloud(token=get_recce_api_token()).share_state(file_name, open(tmp.name, "rb"))
|
|
384
|
+
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 Recc API token is provided",
|
|
25
|
+
hint_message="Please login to Recce Cloud and copy the API token from the settings page",
|
|
26
|
+
)
|
recce/state/local.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
from typing import Optional, Tuple, Union
|
|
4
|
+
|
|
5
|
+
from .state import RecceState
|
|
6
|
+
from .state_loader import RecceStateLoader
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger("uvicorn")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FileStateLoader(RecceStateLoader):
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
review_mode: bool = False,
|
|
15
|
+
state_file: Optional[str] = None,
|
|
16
|
+
initial_state: Optional[RecceState] = None,
|
|
17
|
+
):
|
|
18
|
+
super().__init__(review_mode=review_mode, state_file=state_file, initial_state=initial_state)
|
|
19
|
+
|
|
20
|
+
def verify(self) -> bool:
|
|
21
|
+
if self.review_mode is True and self.state_file is None:
|
|
22
|
+
self.error_message = "Recce can not launch without a state file."
|
|
23
|
+
self.hint_message = "Please provide a state file in the command argument."
|
|
24
|
+
return False
|
|
25
|
+
return True
|
|
26
|
+
|
|
27
|
+
def _load_state(self) -> Tuple[RecceState, str]:
|
|
28
|
+
state = RecceState.from_file(self.state_file) if self.state_file else None
|
|
29
|
+
state_tag = None
|
|
30
|
+
return state, state_tag
|
|
31
|
+
|
|
32
|
+
def _export_state(self, state: RecceState = None) -> Tuple[Union[str, None], str]:
|
|
33
|
+
"""
|
|
34
|
+
Store the state to a file. Store happens when terminating the server or run instance.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
if self.state_file is None:
|
|
38
|
+
return "No state file is provided. Skip storing the state.", None
|
|
39
|
+
|
|
40
|
+
logger.info(f"Store recce state to '{self.state_file}'")
|
|
41
|
+
message = self._export_state_to_file(self.state_file)
|
|
42
|
+
tag = None
|
|
43
|
+
|
|
44
|
+
return message, tag
|
|
45
|
+
|
|
46
|
+
def purge(self) -> bool:
|
|
47
|
+
if self.state_file is not None:
|
|
48
|
+
try:
|
|
49
|
+
os.remove(self.state_file)
|
|
50
|
+
return True
|
|
51
|
+
except Exception as e:
|
|
52
|
+
self.error_message = f"Failed to remove the state file: {e}"
|
|
53
|
+
return False
|
|
54
|
+
else:
|
|
55
|
+
self.error_message = "No state file is provided. Skip removing the state file."
|
|
56
|
+
return False
|
recce/state/state.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Define the type to serialize/de-serialize the state of the recce instance."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from recce import get_version
|
|
11
|
+
from recce.exceptions import RecceException
|
|
12
|
+
from recce.git import current_branch
|
|
13
|
+
from recce.models.types import Check, Run
|
|
14
|
+
from recce.pull_request import PullRequestInfo
|
|
15
|
+
from recce.util.io import SupportedFileTypes, file_io_factory
|
|
16
|
+
from recce.util.pydantic_model import pydantic_model_dump, pydantic_model_json_dump
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("uvicorn")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class GitRepoInfo(BaseModel):
|
|
22
|
+
branch: Optional[str] = None
|
|
23
|
+
|
|
24
|
+
@staticmethod
|
|
25
|
+
def from_current_repositroy():
|
|
26
|
+
branch = current_branch()
|
|
27
|
+
if branch is None:
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
return GitRepoInfo(branch=branch)
|
|
31
|
+
|
|
32
|
+
def to_dict(self):
|
|
33
|
+
return pydantic_model_dump(self)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class RecceStateMetadata(BaseModel):
|
|
37
|
+
schema_version: str = "v0"
|
|
38
|
+
recce_version: str = Field(default_factory=lambda: get_version())
|
|
39
|
+
generated_at: str = Field(default_factory=lambda: datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ArtifactsRoot(BaseModel):
|
|
43
|
+
"""
|
|
44
|
+
Root of the artifacts.
|
|
45
|
+
|
|
46
|
+
base: artifacts of the base env. key is file name, value is dict
|
|
47
|
+
current: artifacts of the current env. key is file name, value is dict
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
base: Dict[str, Optional[dict]] = {}
|
|
51
|
+
current: Dict[str, Optional[dict]] = {}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class RecceState(BaseModel):
|
|
55
|
+
metadata: Optional[RecceStateMetadata] = None
|
|
56
|
+
runs: Optional[List[Run]] = Field(default_factory=list)
|
|
57
|
+
checks: Optional[List[Check]] = Field(default_factory=list)
|
|
58
|
+
artifacts: ArtifactsRoot = ArtifactsRoot(base={}, current={})
|
|
59
|
+
git: Optional[GitRepoInfo] = None
|
|
60
|
+
pull_request: Optional[PullRequestInfo] = None
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def from_json(json_content: str):
|
|
64
|
+
dict_data = json.loads(json_content)
|
|
65
|
+
state = RecceState(**dict_data)
|
|
66
|
+
metadata = state.metadata
|
|
67
|
+
if metadata:
|
|
68
|
+
if metadata.schema_version is None:
|
|
69
|
+
pass
|
|
70
|
+
if metadata.schema_version == "v0":
|
|
71
|
+
pass
|
|
72
|
+
else:
|
|
73
|
+
raise RecceException(f"Unsupported state file version: {metadata.schema_version}")
|
|
74
|
+
return state
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def from_file(file_path: str, file_type: SupportedFileTypes = SupportedFileTypes.FILE):
|
|
78
|
+
"""
|
|
79
|
+
Load the state from a recce state file.
|
|
80
|
+
"""
|
|
81
|
+
from pathlib import Path
|
|
82
|
+
|
|
83
|
+
logger.debug(f"Load state file from: '{file_path}'")
|
|
84
|
+
if not Path(file_path).is_file():
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
io = file_io_factory(file_type)
|
|
88
|
+
json_content = io.read(file_path)
|
|
89
|
+
return RecceState.from_json(json_content)
|
|
90
|
+
|
|
91
|
+
def to_json(self):
|
|
92
|
+
return pydantic_model_json_dump(self)
|
|
93
|
+
|
|
94
|
+
def to_file(self, file_path: str, file_type: SupportedFileTypes = SupportedFileTypes.FILE):
|
|
95
|
+
|
|
96
|
+
json_data = self.to_json()
|
|
97
|
+
io = file_io_factory(file_type)
|
|
98
|
+
|
|
99
|
+
io.write(file_path, json_data)
|
|
100
|
+
return f"The state file is stored at '{file_path}'"
|
|
101
|
+
|
|
102
|
+
def _merge_run(self, run: Run):
|
|
103
|
+
for r in self.runs:
|
|
104
|
+
if r.run_id == run.run_id:
|
|
105
|
+
break
|
|
106
|
+
else:
|
|
107
|
+
self.runs.append(run)
|
|
108
|
+
|
|
109
|
+
def _merge_check(self, check: Check):
|
|
110
|
+
for c in self.checks:
|
|
111
|
+
if c.check_id == check.check_id:
|
|
112
|
+
c.merge(check)
|
|
113
|
+
break
|
|
114
|
+
else:
|
|
115
|
+
self.checks.append(check)
|
|
116
|
+
|
|
117
|
+
def _merge_artifacts(self, artifacts: ArtifactsRoot):
|
|
118
|
+
self.artifacts.merge(artifacts)
|