recce-nightly 1.10.0.20250629__py3-none-any.whl → 1.25.0.20251112a2066__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- recce/VERSION +1 -1
- recce/__init__.py +5 -0
- recce/adapter/dbt_adapter/__init__.py +116 -74
- recce/artifact.py +76 -3
- recce/cli.py +665 -69
- recce/config.py +2 -2
- recce/connect_to_cloud.py +1 -1
- recce/core.py +3 -3
- recce/data/404.html +1 -22
- recce/data/__next.__PAGE__.txt +10 -0
- recce/data/__next._full.txt +23 -0
- recce/data/__next._index.txt +8 -0
- recce/data/__next._tree.txt +12 -0
- recce/data/_next/static/6LypcDXgyuSaiSCrsmUub/_buildManifest.js +11 -0
- recce/data/_next/static/6LypcDXgyuSaiSCrsmUub/_clientMiddlewareManifest.json +1 -0
- recce/data/_next/static/chunks/0a2b2dd4b57049c2.js +1 -0
- recce/data/_next/static/chunks/19c10d219a6a21ff.js +1 -0
- recce/data/_next/static/chunks/24fd885c7180a612.js +1 -0
- recce/data/_next/static/chunks/27e66b2eab4adc32.js +19 -0
- recce/data/_next/static/chunks/71f88fcc615bf282.js +1 -0
- recce/data/_next/static/chunks/917619ab62a32388.js +1 -0
- recce/data/_next/static/chunks/93ba5a62932b704f.js +4 -0
- recce/data/_next/static/chunks/a43a2a5e06d5a92b.js +1 -0
- recce/data/_next/static/chunks/a6c78b24bd8b84fc.js +1 -0
- recce/data/_next/static/chunks/b2610ba997ff8c4f.js +110 -0
- recce/data/_next/static/chunks/ba2d87265a68599d.css +2 -0
- recce/data/_next/static/chunks/c117fd1c1382dd83.js +11 -0
- recce/data/_next/static/chunks/c9425ca46eebdde9.js +1 -0
- recce/data/_next/static/chunks/cc8a9eadba012be0.css +6 -0
- recce/data/_next/static/chunks/e124bccf574a3361.css +1 -0
- recce/data/_next/static/chunks/e392ad92847c3e17.js +1 -0
- recce/data/_next/static/chunks/e4ce95efe88dae79.js +11 -0
- recce/data/_next/static/chunks/e69c777814fea6ed.js +2 -0
- recce/data/_next/static/chunks/turbopack-21cfd73037ff57ab.js +3 -0
- recce/data/_next/static/media/favicon.a8d38d84.ico +0 -0
- recce/data/_next/static/media/montserrat-cyrillic-800-normal.d80d830d.woff2 +0 -0
- recce/data/_next/static/media/{montserrat-cyrillic-800-normal.bd5c9f50.woff → montserrat-cyrillic-800-normal.f9d58125.woff} +0 -0
- recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.076c2a93.woff2 +0 -0
- recce/data/_next/static/media/montserrat-latin-800-normal.cde454cc.woff2 +0 -0
- recce/data/_next/static/media/{montserrat-latin-800-normal.fc315020.woff → montserrat-latin-800-normal.d5761935.woff} +0 -0
- recce/data/_next/static/media/montserrat-latin-ext-800-normal.40ec0659.woff2 +0 -0
- recce/data/_next/static/media/{montserrat-latin-ext-800-normal.2e5381b2.woff → montserrat-latin-ext-800-normal.b671449b.woff} +0 -0
- recce/data/_next/static/media/{montserrat-vietnamese-800-normal.20c545e6.woff → montserrat-vietnamese-800-normal.9f7b8541.woff} +0 -0
- recce/data/_next/static/media/montserrat-vietnamese-800-normal.f9eb854e.woff2 +0 -0
- recce/data/_not-found/__next._full.txt +17 -0
- recce/data/_not-found/__next._index.txt +8 -0
- recce/data/_not-found/__next._not-found.__PAGE__.txt +5 -0
- recce/data/_not-found/__next._not-found.txt +4 -0
- recce/data/_not-found/__next._tree.txt +10 -0
- recce/data/_not-found.html +1 -0
- recce/data/_not-found.txt +17 -0
- recce/data/auth_callback.html +1 -1
- recce/data/index.html +1 -27
- recce/data/index.txt +23 -8
- recce/event/__init__.py +9 -8
- recce/event/collector.py +6 -2
- recce/event/track.py +10 -0
- recce/github.py +1 -1
- recce/mcp_server.py +632 -0
- recce/models/types.py +23 -2
- recce/pull_request.py +1 -1
- recce/run.py +23 -16
- recce/server.py +165 -11
- recce/state/__init__.py +31 -0
- recce/state/cloud.py +632 -0
- recce/state/const.py +26 -0
- recce/state/local.py +56 -0
- recce/state/state.py +119 -0
- recce/state/state_loader.py +174 -0
- recce/summary.py +2 -1
- recce/tasks/dataframe.py +59 -2
- recce/tasks/rowcount.py +4 -1
- recce/tasks/schema.py +4 -1
- recce/tasks/valuediff.py +1 -1
- recce/util/api_token.py +11 -2
- recce/util/breaking.py +9 -0
- recce/util/cll.py +1 -2
- recce/util/io.py +2 -2
- recce/util/lineage.py +14 -18
- recce/util/perf_tracking.py +85 -0
- recce/util/recce_cloud.py +229 -5
- recce/yaml/__init__.py +2 -2
- recce_cloud/__init__.py +15 -0
- recce_cloud/api/__init__.py +17 -0
- recce_cloud/api/base.py +104 -0
- recce_cloud/api/client.py +150 -0
- recce_cloud/api/exceptions.py +26 -0
- recce_cloud/api/factory.py +63 -0
- recce_cloud/api/github.py +72 -0
- recce_cloud/api/gitlab.py +78 -0
- recce_cloud/artifact.py +57 -0
- recce_cloud/ci_providers/__init__.py +9 -0
- recce_cloud/ci_providers/base.py +82 -0
- recce_cloud/ci_providers/detector.py +147 -0
- recce_cloud/ci_providers/github_actions.py +136 -0
- recce_cloud/ci_providers/gitlab_ci.py +130 -0
- recce_cloud/cli.py +303 -0
- recce_cloud/upload.py +213 -0
- {recce_nightly-1.10.0.20250629.dist-info → recce_nightly-1.25.0.20251112a2066.dist-info}/METADATA +31 -27
- recce_nightly-1.25.0.20251112a2066.dist-info/RECORD +178 -0
- {recce_nightly-1.10.0.20250629.dist-info → recce_nightly-1.25.0.20251112a2066.dist-info}/top_level.txt +1 -0
- tests/adapter/dbt_adapter/test_dbt_cll.py +68 -17
- tests/recce_cloud/__init__.py +0 -0
- tests/recce_cloud/test_ci_providers.py +351 -0
- tests/recce_cloud/test_cli.py +372 -0
- tests/recce_cloud/test_client.py +273 -0
- tests/recce_cloud/test_platform_clients.py +279 -0
- tests/test_cli.py +106 -3
- tests/test_cli_mcp_optional.py +45 -0
- tests/test_cloud_listing_cli.py +324 -0
- tests/test_core.py +147 -0
- tests/test_mcp_server.py +332 -0
- tests/test_server.py +6 -6
- tests/test_summary.py +14 -6
- recce/data/_next/static/Mrb9CZ3toH6Q8xrzNzCrg/_buildManifest.js +0 -1
- recce/data/_next/static/chunks/181-acc61ddada3bc0ca.js +0 -43
- recce/data/_next/static/chunks/1bff33f1-1ef85cf5e658a751.js +0 -1
- recce/data/_next/static/chunks/217-879a84d70f7a907c.js +0 -2
- recce/data/_next/static/chunks/29e3cc0d-60045b2e47aa3916.js +0 -1
- recce/data/_next/static/chunks/36e1c10d-8e7be4a6c1f6ab2d.js +0 -1
- recce/data/_next/static/chunks/3998a672-03adacad07b346ac.js +0 -1
- recce/data/_next/static/chunks/3a92ee20-1081c360214f9602.js +0 -1
- recce/data/_next/static/chunks/41-f30276c289169376.js +0 -9
- recce/data/_next/static/chunks/450c323b-fd94e7ffaa4a5efa.js +0 -1
- recce/data/_next/static/chunks/47d8844f-929aed9b1c73a905.js +0 -1
- recce/data/_next/static/chunks/608-3b079b544e5d5f5e.js +0 -15
- recce/data/_next/static/chunks/6dc81886-adbfa45836061d79.js +0 -1
- recce/data/_next/static/chunks/7a8a3e83-edf6dc64b5d5f0a5.js +0 -1
- recce/data/_next/static/chunks/7f27ae6c-d5f0438edd5c2a5b.js +0 -1
- recce/data/_next/static/chunks/86730205-cfb14e3f051bab35.js +0 -1
- recce/data/_next/static/chunks/8d700b6a.8bb140898499c512.js +0 -1
- recce/data/_next/static/chunks/92-68460b15fe448f33.js +0 -1
- recce/data/_next/static/chunks/9746af58-a42b7d169cacadf0.js +0 -1
- recce/data/_next/static/chunks/a30376cd-de84559016d7e133.js +0 -1
- recce/data/_next/static/chunks/app/_not-found/page-01ed58b7f971d311.js +0 -1
- recce/data/_next/static/chunks/app/layout-292f035bb0d2a98e.js +0 -1
- recce/data/_next/static/chunks/app/page-598f8acc82179d01.js +0 -1
- recce/data/_next/static/chunks/b63b1b3f-4282bdcf459e075c.js +0 -1
- recce/data/_next/static/chunks/bbda5537-9ec25eb1dd62348a.js +0 -1
- recce/data/_next/static/chunks/c132bf7d-08cb668a789d6afd.js +0 -1
- recce/data/_next/static/chunks/ce84277d-2e5d1d46910cf052.js +0 -1
- recce/data/_next/static/chunks/febdd86e-c6b525341634b860.js +0 -54
- recce/data/_next/static/chunks/fee69bc6-2dbccaf9b90474e6.js +0 -1
- recce/data/_next/static/chunks/framework-ded83d71b51ce901.js +0 -1
- recce/data/_next/static/chunks/main-app-39061b0166c47f55.js +0 -1
- recce/data/_next/static/chunks/main-b5b3ae20a1405261.js +0 -1
- recce/data/_next/static/chunks/pages/_app-437c455677d62394.js +0 -1
- recce/data/_next/static/chunks/pages/_error-e7650df18ca04bde.js +0 -1
- recce/data/_next/static/chunks/webpack-7b49d5ba7e3a434d.js +0 -1
- recce/data/_next/static/css/17a96168e3a9db13.css +0 -1
- recce/data/_next/static/css/35c6679a098e1e34.css +0 -1
- recce/data/_next/static/css/951e2e0eea2d4a5b.css +0 -14
- recce/data/_next/static/css/a2b12b4ba4227f0a.css +0 -3
- recce/data/_next/static/media/montserrat-cyrillic-800-normal.22628180.woff2 +0 -0
- recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.94a63aea.woff2 +0 -0
- recce/data/_next/static/media/montserrat-latin-800-normal.6f8fa298.woff2 +0 -0
- recce/data/_next/static/media/montserrat-latin-ext-800-normal.013b84f9.woff2 +0 -0
- recce/data/_next/static/media/montserrat-vietnamese-800-normal.c0035377.woff2 +0 -0
- recce/state.py +0 -786
- recce_nightly-1.10.0.20250629.dist-info/RECORD +0 -154
- tests/test_state.py +0 -134
- /recce/data/_next/static/{Mrb9CZ3toH6Q8xrzNzCrg → 6LypcDXgyuSaiSCrsmUub}/_ssgManifest.js +0 -0
- /recce/data/_next/static/chunks/{polyfills-42372ed130431b0a.js → a6dad97d9634a72d.js} +0 -0
- /recce/data/_next/static/media/{montserrat-cyrillic-ext-800-normal.e6e0d8d0.woff → montserrat-cyrillic-ext-800-normal.a4fa76b5.woff} +0 -0
- /recce/data/_next/static/media/{reload-image.79aabb7d.svg → reload-image.7aa931c7.svg} +0 -0
- {recce_nightly-1.10.0.20250629.dist-info → recce_nightly-1.25.0.20251112a2066.dist-info}/WHEEL +0 -0
- {recce_nightly-1.10.0.20250629.dist-info → recce_nightly-1.25.0.20251112a2066.dist-info}/entry_points.txt +0 -0
- {recce_nightly-1.10.0.20250629.dist-info → recce_nightly-1.25.0.20251112a2066.dist-info}/licenses/LICENSE +0 -0
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,119 @@
|
|
|
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_repository() -> Optional["GitRepoInfo"]:
|
|
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
|
+
|
|
68
|
+
if metadata:
|
|
69
|
+
if metadata.schema_version is None:
|
|
70
|
+
pass
|
|
71
|
+
if metadata.schema_version == "v0":
|
|
72
|
+
pass
|
|
73
|
+
else:
|
|
74
|
+
raise RecceException(f"Unsupported state file version: {metadata.schema_version}")
|
|
75
|
+
return state
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def from_file(file_path: str, file_type: SupportedFileTypes = SupportedFileTypes.FILE):
|
|
79
|
+
"""
|
|
80
|
+
Load the state from a recce state file.
|
|
81
|
+
"""
|
|
82
|
+
from pathlib import Path
|
|
83
|
+
|
|
84
|
+
logger.debug(f"Load state file from: '{file_path}'")
|
|
85
|
+
if not Path(file_path).is_file():
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
io = file_io_factory(file_type)
|
|
89
|
+
json_content = io.read(file_path)
|
|
90
|
+
return RecceState.from_json(json_content)
|
|
91
|
+
|
|
92
|
+
def to_json(self):
|
|
93
|
+
return pydantic_model_json_dump(self)
|
|
94
|
+
|
|
95
|
+
def to_file(self, file_path: str, file_type: SupportedFileTypes = SupportedFileTypes.FILE):
|
|
96
|
+
|
|
97
|
+
json_data = self.to_json()
|
|
98
|
+
io = file_io_factory(file_type)
|
|
99
|
+
|
|
100
|
+
io.write(file_path, json_data)
|
|
101
|
+
return f"The state file is stored at '{file_path}'"
|
|
102
|
+
|
|
103
|
+
def _merge_run(self, run: Run):
|
|
104
|
+
for r in self.runs:
|
|
105
|
+
if r.run_id == run.run_id:
|
|
106
|
+
break
|
|
107
|
+
else:
|
|
108
|
+
self.runs.append(run)
|
|
109
|
+
|
|
110
|
+
def _merge_check(self, check: Check):
|
|
111
|
+
for c in self.checks:
|
|
112
|
+
if c.check_id == check.check_id:
|
|
113
|
+
c.merge(check)
|
|
114
|
+
break
|
|
115
|
+
else:
|
|
116
|
+
self.checks.append(check)
|
|
117
|
+
|
|
118
|
+
def _merge_artifacts(self, artifacts: ArtifactsRoot):
|
|
119
|
+
self.artifacts.merge(artifacts)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import threading
|
|
3
|
+
import time
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from typing import Dict, Literal, Optional, Tuple, Union, final
|
|
6
|
+
|
|
7
|
+
from recce.exceptions import RecceException
|
|
8
|
+
from recce.pull_request import fetch_pr_metadata
|
|
9
|
+
|
|
10
|
+
from ..util.io import SupportedFileTypes, file_io_factory
|
|
11
|
+
from .const import RECCE_API_TOKEN_MISSING
|
|
12
|
+
from .state import RecceState
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("uvicorn")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RecceStateLoader(ABC):
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
review_mode: bool = False,
|
|
21
|
+
cloud_mode: bool = False,
|
|
22
|
+
state_file: Optional[str] = None,
|
|
23
|
+
cloud_options: Optional[Dict[str, str]] = None,
|
|
24
|
+
initial_state: Optional[RecceState] = None,
|
|
25
|
+
):
|
|
26
|
+
self.review_mode = review_mode
|
|
27
|
+
self.cloud_mode = cloud_mode
|
|
28
|
+
self.state_file = state_file
|
|
29
|
+
self.cloud_options = cloud_options or {}
|
|
30
|
+
self.error_message = None
|
|
31
|
+
self.hint_message = None
|
|
32
|
+
self.state: RecceState | None = initial_state
|
|
33
|
+
self.state_lock = threading.Lock()
|
|
34
|
+
self.state_etag = None
|
|
35
|
+
self.pr_info = None
|
|
36
|
+
self.catalog: Literal["github", "preview", "snapshot"] = "github"
|
|
37
|
+
self.share_id = None
|
|
38
|
+
self.session_id = None
|
|
39
|
+
|
|
40
|
+
if self.cloud_mode:
|
|
41
|
+
if self.cloud_options.get("github_token"):
|
|
42
|
+
self.catalog = "github"
|
|
43
|
+
self.pr_info = fetch_pr_metadata(
|
|
44
|
+
cloud=self.cloud_mode, github_token=self.cloud_options.get("github_token")
|
|
45
|
+
)
|
|
46
|
+
if self.pr_info.id is None:
|
|
47
|
+
raise RecceException("Cannot get the pull request information from GitHub.")
|
|
48
|
+
elif self.cloud_options.get("api_token"):
|
|
49
|
+
if self.cloud_options.get("session_id"):
|
|
50
|
+
self.catalog = "session"
|
|
51
|
+
self.session_id = self.cloud_options.get("session_id")
|
|
52
|
+
else:
|
|
53
|
+
self.catalog = "preview"
|
|
54
|
+
self.share_id = self.cloud_options.get("share_id")
|
|
55
|
+
else:
|
|
56
|
+
raise RecceException(RECCE_API_TOKEN_MISSING.error_message)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def token(self):
|
|
60
|
+
return self.cloud_options.get("github_token") or self.cloud_options.get("api_token")
|
|
61
|
+
|
|
62
|
+
@abstractmethod
|
|
63
|
+
def verify(self) -> bool:
|
|
64
|
+
"""
|
|
65
|
+
Verify the state loader configuration.
|
|
66
|
+
Returns:
|
|
67
|
+
bool: True if the configuration is valid, False otherwise.
|
|
68
|
+
"""
|
|
69
|
+
raise NotImplementedError("Subclasses must implement this method.")
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def error_and_hint(self) -> (Union[str, None], Union[str, None]):
|
|
73
|
+
return self.error_message, self.hint_message
|
|
74
|
+
|
|
75
|
+
def update(self, state: RecceState):
|
|
76
|
+
self.state = state
|
|
77
|
+
|
|
78
|
+
@final
|
|
79
|
+
def load(self, refresh=False) -> RecceState:
|
|
80
|
+
if self.state is not None and refresh is False:
|
|
81
|
+
return self.state
|
|
82
|
+
self.state_lock.acquire()
|
|
83
|
+
try:
|
|
84
|
+
self.state, self.state_etag = self._load_state()
|
|
85
|
+
finally:
|
|
86
|
+
self.state_lock.release()
|
|
87
|
+
return self.state
|
|
88
|
+
|
|
89
|
+
@abstractmethod
|
|
90
|
+
def _load_state(self) -> Tuple[RecceState, str]:
|
|
91
|
+
"""
|
|
92
|
+
Load the state from the specified source (file or cloud).
|
|
93
|
+
Returns:
|
|
94
|
+
RecceState: The loaded state object.
|
|
95
|
+
str: The etag of the state file (if applicable).
|
|
96
|
+
"""
|
|
97
|
+
raise NotImplementedError("Subclasses must implement this method.")
|
|
98
|
+
|
|
99
|
+
def save_as(self, state_file: str, state: RecceState = None):
|
|
100
|
+
if self.cloud_mode:
|
|
101
|
+
raise Exception("Cannot save the state to Recce Cloud.")
|
|
102
|
+
|
|
103
|
+
self.state_file = state_file
|
|
104
|
+
self.export(state)
|
|
105
|
+
|
|
106
|
+
@final
|
|
107
|
+
def export(self, state: RecceState = None) -> Union[str, None]:
|
|
108
|
+
if state is not None:
|
|
109
|
+
self.update(state)
|
|
110
|
+
|
|
111
|
+
start_time = time.time()
|
|
112
|
+
self.state_lock.acquire()
|
|
113
|
+
try:
|
|
114
|
+
message, state_etag = self._export_state()
|
|
115
|
+
self.state_etag = state_etag
|
|
116
|
+
end_time = time.time()
|
|
117
|
+
elapsed_time = end_time - start_time
|
|
118
|
+
finally:
|
|
119
|
+
self.state_lock.release()
|
|
120
|
+
logger.info(f"Store state completed in {elapsed_time:.2f} seconds")
|
|
121
|
+
return message
|
|
122
|
+
|
|
123
|
+
@abstractmethod
|
|
124
|
+
def _export_state(self) -> Tuple[Union[str, None], str]:
|
|
125
|
+
"""
|
|
126
|
+
Export the current Recce state to a file or cloud storage.
|
|
127
|
+
Returns:
|
|
128
|
+
str: A message indicating the result of the export operation.
|
|
129
|
+
str: The etag of the exported state file (if applicable).
|
|
130
|
+
"""
|
|
131
|
+
raise NotImplementedError("Subclasses must implement this method.")
|
|
132
|
+
|
|
133
|
+
def _export_state_to_file(self, file_path: str, file_type: SupportedFileTypes = SupportedFileTypes.FILE) -> str:
|
|
134
|
+
"""
|
|
135
|
+
Store the state to a file. Store happens when terminating the server or run instance.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
json_data = self.state.to_json()
|
|
139
|
+
io = file_io_factory(file_type)
|
|
140
|
+
|
|
141
|
+
io.write(file_path, json_data)
|
|
142
|
+
return f"The state file is stored at '{file_path}'"
|
|
143
|
+
|
|
144
|
+
def refresh(self):
|
|
145
|
+
new_state = self.load(refresh=True)
|
|
146
|
+
return new_state
|
|
147
|
+
|
|
148
|
+
def check_conflict(self) -> bool:
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
def info(self) -> dict:
|
|
152
|
+
if self.state is None:
|
|
153
|
+
self.error_message = "No state is loaded."
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
state_info = {
|
|
157
|
+
"mode": "cloud" if self.cloud_mode else "local",
|
|
158
|
+
"source": None,
|
|
159
|
+
}
|
|
160
|
+
if self.cloud_mode:
|
|
161
|
+
state_info["source"] = "Recce Cloud"
|
|
162
|
+
state_info["pull_request"] = self.pr_info
|
|
163
|
+
else:
|
|
164
|
+
state_info["source"] = self.state_file
|
|
165
|
+
return state_info
|
|
166
|
+
|
|
167
|
+
@abstractmethod
|
|
168
|
+
def purge(self) -> bool:
|
|
169
|
+
"""
|
|
170
|
+
Purge the state file or cloud storage.
|
|
171
|
+
Returns:
|
|
172
|
+
bool: True if the purge was successful, False otherwise.
|
|
173
|
+
"""
|
|
174
|
+
raise NotImplementedError("Subclasses must implement this method.")
|
recce/summary.py
CHANGED
|
@@ -493,7 +493,8 @@ No changed module was detected.
|
|
|
493
493
|
|
|
494
494
|
if ctx.state_loader.cloud_mode:
|
|
495
495
|
pr_info = ctx.state_loader.pr_info
|
|
496
|
-
|
|
496
|
+
# the classic route will be deprecated soon
|
|
497
|
+
content += f"\nSee PR page: {RECCE_CLOUD_HOST}/classic/{pr_info.repository}/pulls/{pr_info.id}\n"
|
|
497
498
|
|
|
498
499
|
return content
|
|
499
500
|
|
recce/tasks/dataframe.py
CHANGED
|
@@ -2,6 +2,7 @@ import json
|
|
|
2
2
|
import typing as t
|
|
3
3
|
from decimal import Decimal
|
|
4
4
|
from enum import Enum
|
|
5
|
+
from typing import Sequence
|
|
5
6
|
|
|
6
7
|
if t.TYPE_CHECKING:
|
|
7
8
|
import agate
|
|
@@ -19,15 +20,32 @@ class DataFrameColumnType(Enum):
|
|
|
19
20
|
TIMEDELTA = "timedelta"
|
|
20
21
|
UNKNOWN = "unknown"
|
|
21
22
|
|
|
23
|
+
@classmethod
|
|
24
|
+
def from_string(cls, type_str: str) -> "DataFrameColumnType":
|
|
25
|
+
"""Convert string to DataFrameColumnType enum.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
type_str: String representation of the type (e.g., "integer", "text")
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
DataFrameColumnType enum value
|
|
32
|
+
"""
|
|
33
|
+
type_str = type_str.lower().strip()
|
|
34
|
+
try:
|
|
35
|
+
return cls(type_str)
|
|
36
|
+
except ValueError:
|
|
37
|
+
return cls.UNKNOWN
|
|
38
|
+
|
|
22
39
|
|
|
23
40
|
class DataFrameColumn(BaseModel):
|
|
41
|
+
key: str
|
|
24
42
|
name: str
|
|
25
43
|
type: DataFrameColumnType
|
|
26
44
|
|
|
27
45
|
|
|
28
46
|
class DataFrame(BaseModel):
|
|
29
47
|
columns: t.List[DataFrameColumn]
|
|
30
|
-
data: t.List[
|
|
48
|
+
data: t.List[Sequence]
|
|
31
49
|
limit: t.Optional[int] = Field(None, description="Limit the number of rows returned")
|
|
32
50
|
more: t.Optional[bool] = Field(None, description="Whether there are more rows to fetch")
|
|
33
51
|
|
|
@@ -64,7 +82,7 @@ class DataFrame(BaseModel):
|
|
|
64
82
|
col_type = DataFrameColumnType.INTEGER
|
|
65
83
|
else:
|
|
66
84
|
col_type = DataFrameColumnType.UNKNOWN
|
|
67
|
-
columns.append(DataFrameColumn(name=col_name, type=col_type))
|
|
85
|
+
columns.append(DataFrameColumn(key=col_name, name=col_name, type=col_type))
|
|
68
86
|
|
|
69
87
|
def _row_values(row):
|
|
70
88
|
# If the value is Decimal, check if it's finite. If not, convert it to float(xxx) (GitHub issue #476)
|
|
@@ -106,3 +124,42 @@ class DataFrame(BaseModel):
|
|
|
106
124
|
more=more,
|
|
107
125
|
)
|
|
108
126
|
return df
|
|
127
|
+
|
|
128
|
+
@staticmethod
|
|
129
|
+
def from_data(
|
|
130
|
+
columns: t.Dict[str, str],
|
|
131
|
+
data: t.List[Sequence],
|
|
132
|
+
limit: t.Optional[int] = None,
|
|
133
|
+
more: t.Optional[bool] = None,
|
|
134
|
+
):
|
|
135
|
+
"""Create a DataFrame from columns and data directly.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
columns: Dict defining the schema where keys are column names and values are type strings.
|
|
139
|
+
Type strings can be: "number", "integer", "text", "boolean", "date", "datetime", "timedelta"
|
|
140
|
+
data: List of rows (each row is a list/tuple/sequence of values)
|
|
141
|
+
limit: Optional limit on the number of rows returned
|
|
142
|
+
more: Optional flag indicating whether there are more rows to fetch
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
DataFrame instance
|
|
146
|
+
|
|
147
|
+
Examples:
|
|
148
|
+
# Using simple dict format
|
|
149
|
+
columns = {"idx": "integer", "name": "text", "impacted": "boolean"}
|
|
150
|
+
data = [[0, "model_a", True], [1, "model_b", False]]
|
|
151
|
+
df = DataFrame.from_data(columns, data)
|
|
152
|
+
"""
|
|
153
|
+
# Convert dict columns to DataFrameColumn objects
|
|
154
|
+
processed_columns = []
|
|
155
|
+
for key, type_str in columns.items():
|
|
156
|
+
col_type = DataFrameColumnType.from_string(type_str)
|
|
157
|
+
processed_columns.append(DataFrameColumn(key=key, name=key, type=col_type))
|
|
158
|
+
|
|
159
|
+
df = DataFrame(
|
|
160
|
+
columns=processed_columns,
|
|
161
|
+
data=data,
|
|
162
|
+
limit=limit,
|
|
163
|
+
more=more,
|
|
164
|
+
)
|
|
165
|
+
return df
|
recce/tasks/rowcount.py
CHANGED
|
@@ -263,7 +263,10 @@ class RowCountDiffResultDiffer(TaskResultDiffer):
|
|
|
263
263
|
|
|
264
264
|
def _get_changed_nodes(self) -> Union[List[str], None]:
|
|
265
265
|
if self.changes:
|
|
266
|
-
|
|
266
|
+
# Both affected_root_keys of deepdiff v7 (OrderedSet) and v8 (SetOrdered) are iterable
|
|
267
|
+
# Convert to list directly
|
|
268
|
+
return list(self.changes.affected_root_keys)
|
|
269
|
+
return None
|
|
267
270
|
|
|
268
271
|
|
|
269
272
|
class RowCountDiffCheckValidator(CheckValidator):
|
recce/tasks/schema.py
CHANGED
|
@@ -45,7 +45,10 @@ class SchemaDiffResultDiffer:
|
|
|
45
45
|
|
|
46
46
|
def _get_changed_nodes(self) -> Union[List[str], None]:
|
|
47
47
|
if self.changes:
|
|
48
|
-
|
|
48
|
+
# Both affected_root_keys of deepdiff v7 (OrderedSet) and v8 (SetOrdered) are iterable
|
|
49
|
+
# Convert to list directly
|
|
50
|
+
return list(self.changes.affected_root_keys)
|
|
51
|
+
return None
|
|
49
52
|
|
|
50
53
|
|
|
51
54
|
class SchemaDiffParams(BaseModel):
|
recce/tasks/valuediff.py
CHANGED
recce/util/api_token.py
CHANGED
|
@@ -17,7 +17,10 @@ def show_invalid_api_token_message():
|
|
|
17
17
|
Show the message when the API token is invalid.
|
|
18
18
|
"""
|
|
19
19
|
console.print("[[red]Error[/red]] Invalid Recce Cloud API token.")
|
|
20
|
-
console.print(
|
|
20
|
+
console.print("Please associate with your Recce Cloud account by the following command 'recce connect-to-cloud'.")
|
|
21
|
+
console.print(
|
|
22
|
+
"For more information, please visit: https://docs.reccehq.com/recce-cloud/share-recce-session-securely/#configure-recce-cloud-association-manually"
|
|
23
|
+
)
|
|
21
24
|
|
|
22
25
|
|
|
23
26
|
def prepare_api_token(
|
|
@@ -30,7 +33,13 @@ def prepare_api_token(
|
|
|
30
33
|
# Verify the API token for Recce Cloud Share Link
|
|
31
34
|
api_token = get_recce_api_token()
|
|
32
35
|
new_api_token = kwargs.get("api_token")
|
|
33
|
-
if
|
|
36
|
+
if new_api_token is not None and new_api_token.startswith("rct-"):
|
|
37
|
+
# Task Token
|
|
38
|
+
valid = RecceCloud(new_api_token).verify_token()
|
|
39
|
+
if not valid:
|
|
40
|
+
raise RecceConfigException("Invalid Recce Cloud Task token")
|
|
41
|
+
api_token = new_api_token
|
|
42
|
+
elif api_token != new_api_token and new_api_token is not None:
|
|
34
43
|
# Handle the API token provided by option `--api-token`
|
|
35
44
|
valid = RecceCloud(new_api_token).verify_token()
|
|
36
45
|
if not valid:
|
recce/util/breaking.py
CHANGED
|
@@ -78,6 +78,15 @@ def _diff_select_scope(old_scope: Scope, new_scope: Scope, scope_changes_map: di
|
|
|
78
78
|
if change.category == "breaking":
|
|
79
79
|
change_category = "breaking"
|
|
80
80
|
|
|
81
|
+
# check if the upstream scopes sources table are the same
|
|
82
|
+
if len(old_scope.sources) != len(new_scope.sources):
|
|
83
|
+
change_category = "breaking"
|
|
84
|
+
else:
|
|
85
|
+
old_source_tables = [s.name for s in old_scope.sources.values() if isinstance(s, exp.Table)]
|
|
86
|
+
new_source_tables = [s.name for s in new_scope.sources.values() if isinstance(s, exp.Table)]
|
|
87
|
+
if sorted(old_source_tables) != sorted(new_source_tables):
|
|
88
|
+
change_category = "breaking"
|
|
89
|
+
|
|
81
90
|
# check if non-select expressions are the same
|
|
82
91
|
old_select = old_scope.expression # type: exp.Select
|
|
83
92
|
new_select = new_scope.expression # type: exp.Select
|
recce/util/cll.py
CHANGED
|
@@ -10,7 +10,6 @@ from sqlglot.optimizer.qualify import qualify
|
|
|
10
10
|
|
|
11
11
|
from recce.exceptions import RecceException
|
|
12
12
|
from recce.models.types import CllColumn, CllColumnDep
|
|
13
|
-
from recce.util import SingletonMeta
|
|
14
13
|
|
|
15
14
|
CllResult = Tuple[
|
|
16
15
|
List[CllColumnDep], # Model to column dependencies
|
|
@@ -19,7 +18,7 @@ CllResult = Tuple[
|
|
|
19
18
|
|
|
20
19
|
|
|
21
20
|
@dataclass
|
|
22
|
-
class CLLPerformanceTracking
|
|
21
|
+
class CLLPerformanceTracking:
|
|
23
22
|
lineage_start = None
|
|
24
23
|
lineage_elapsed = None
|
|
25
24
|
column_lineage_start = None
|
recce/util/io.py
CHANGED
|
@@ -37,12 +37,12 @@ class AbstractFileIO(metaclass=ABCMeta):
|
|
|
37
37
|
class FileIO(AbstractFileIO, ABC):
|
|
38
38
|
@staticmethod
|
|
39
39
|
def write(path: str, data: str, **kwargs):
|
|
40
|
-
with open(path, "w") as f:
|
|
40
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
41
41
|
f.write(data)
|
|
42
42
|
|
|
43
43
|
@staticmethod
|
|
44
44
|
def read(path: str, **kwargs) -> str:
|
|
45
|
-
with open(path, "r") as f:
|
|
45
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
46
46
|
return f.read()
|
|
47
47
|
|
|
48
48
|
|
recce/util/lineage.py
CHANGED
|
@@ -7,19 +7,17 @@ def find_upstream(node_ids: Iterable, parent_map):
|
|
|
7
7
|
visited = set()
|
|
8
8
|
upstream = set()
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
stack = list(node_ids)
|
|
11
|
+
while stack:
|
|
12
|
+
current = stack.pop()
|
|
11
13
|
if current in visited:
|
|
12
|
-
|
|
14
|
+
continue
|
|
13
15
|
visited.add(current)
|
|
14
|
-
|
|
15
16
|
parents = parent_map.get(current, [])
|
|
16
17
|
for parent in parents:
|
|
17
|
-
upstream
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
for node_id in node_ids:
|
|
21
|
-
dfs(node_id)
|
|
22
|
-
|
|
18
|
+
if parent not in upstream:
|
|
19
|
+
upstream.add(parent)
|
|
20
|
+
stack.append(parent)
|
|
23
21
|
return upstream
|
|
24
22
|
|
|
25
23
|
|
|
@@ -27,19 +25,17 @@ def find_downstream(node_ids: Iterable, child_map):
|
|
|
27
25
|
visited = set()
|
|
28
26
|
downstream = set()
|
|
29
27
|
|
|
30
|
-
|
|
28
|
+
stack = list(node_ids)
|
|
29
|
+
while stack:
|
|
30
|
+
current = stack.pop()
|
|
31
31
|
if current in visited:
|
|
32
|
-
|
|
32
|
+
continue
|
|
33
33
|
visited.add(current)
|
|
34
|
-
|
|
35
34
|
children = child_map.get(current, [])
|
|
36
35
|
for child in children:
|
|
37
|
-
downstream
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
for node_id in node_ids:
|
|
41
|
-
dfs(node_id)
|
|
42
|
-
|
|
36
|
+
if child not in downstream:
|
|
37
|
+
downstream.add(child)
|
|
38
|
+
stack.append(child)
|
|
43
39
|
return downstream
|
|
44
40
|
|
|
45
41
|
|