recce-nightly 1.16.0.20250813__py3-none-any.whl → 1.16.0.20250814__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/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)