recce-nightly 1.9.0.20250623__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.
Files changed (169) hide show
  1. recce/VERSION +1 -1
  2. recce/__init__.py +5 -0
  3. recce/adapter/dbt_adapter/__init__.py +318 -240
  4. recce/artifact.py +76 -3
  5. recce/cli.py +703 -71
  6. recce/config.py +3 -3
  7. recce/connect_to_cloud.py +138 -0
  8. recce/core.py +3 -3
  9. recce/data/404.html +1 -22
  10. recce/data/__next.__PAGE__.txt +10 -0
  11. recce/data/__next._full.txt +23 -0
  12. recce/data/__next._index.txt +8 -0
  13. recce/data/__next._tree.txt +12 -0
  14. recce/data/_next/static/6LypcDXgyuSaiSCrsmUub/_buildManifest.js +11 -0
  15. recce/data/_next/static/6LypcDXgyuSaiSCrsmUub/_clientMiddlewareManifest.json +1 -0
  16. recce/data/_next/static/chunks/0a2b2dd4b57049c2.js +1 -0
  17. recce/data/_next/static/chunks/19c10d219a6a21ff.js +1 -0
  18. recce/data/_next/static/chunks/24fd885c7180a612.js +1 -0
  19. recce/data/_next/static/chunks/27e66b2eab4adc32.js +19 -0
  20. recce/data/_next/static/chunks/71f88fcc615bf282.js +1 -0
  21. recce/data/_next/static/chunks/917619ab62a32388.js +1 -0
  22. recce/data/_next/static/chunks/93ba5a62932b704f.js +4 -0
  23. recce/data/_next/static/chunks/a43a2a5e06d5a92b.js +1 -0
  24. recce/data/_next/static/chunks/a6c78b24bd8b84fc.js +1 -0
  25. recce/data/_next/static/chunks/b2610ba997ff8c4f.js +110 -0
  26. recce/data/_next/static/chunks/ba2d87265a68599d.css +2 -0
  27. recce/data/_next/static/chunks/c117fd1c1382dd83.js +11 -0
  28. recce/data/_next/static/chunks/c9425ca46eebdde9.js +1 -0
  29. recce/data/_next/static/chunks/cc8a9eadba012be0.css +6 -0
  30. recce/data/_next/static/chunks/e124bccf574a3361.css +1 -0
  31. recce/data/_next/static/chunks/e392ad92847c3e17.js +1 -0
  32. recce/data/_next/static/chunks/e4ce95efe88dae79.js +11 -0
  33. recce/data/_next/static/chunks/e69c777814fea6ed.js +2 -0
  34. recce/data/_next/static/chunks/turbopack-21cfd73037ff57ab.js +3 -0
  35. recce/data/_next/static/media/favicon.a8d38d84.ico +0 -0
  36. recce/data/_next/static/media/montserrat-cyrillic-800-normal.d80d830d.woff2 +0 -0
  37. recce/data/_next/static/media/{montserrat-cyrillic-800-normal.bd5c9f50.woff → montserrat-cyrillic-800-normal.f9d58125.woff} +0 -0
  38. recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.076c2a93.woff2 +0 -0
  39. recce/data/_next/static/media/montserrat-latin-800-normal.cde454cc.woff2 +0 -0
  40. recce/data/_next/static/media/{montserrat-latin-800-normal.fc315020.woff → montserrat-latin-800-normal.d5761935.woff} +0 -0
  41. recce/data/_next/static/media/montserrat-latin-ext-800-normal.40ec0659.woff2 +0 -0
  42. recce/data/_next/static/media/{montserrat-latin-ext-800-normal.2e5381b2.woff → montserrat-latin-ext-800-normal.b671449b.woff} +0 -0
  43. recce/data/_next/static/media/{montserrat-vietnamese-800-normal.20c545e6.woff → montserrat-vietnamese-800-normal.9f7b8541.woff} +0 -0
  44. recce/data/_next/static/media/montserrat-vietnamese-800-normal.f9eb854e.woff2 +0 -0
  45. recce/data/_not-found/__next._full.txt +17 -0
  46. recce/data/_not-found/__next._index.txt +8 -0
  47. recce/data/_not-found/__next._not-found.__PAGE__.txt +5 -0
  48. recce/data/_not-found/__next._not-found.txt +4 -0
  49. recce/data/_not-found/__next._tree.txt +10 -0
  50. recce/data/_not-found.html +1 -0
  51. recce/data/_not-found.txt +17 -0
  52. recce/data/auth_callback.html +68 -0
  53. recce/data/index.html +1 -27
  54. recce/data/index.txt +23 -8
  55. recce/event/__init__.py +9 -8
  56. recce/event/collector.py +6 -2
  57. recce/event/track.py +10 -0
  58. recce/github.py +1 -1
  59. recce/mcp_server.py +632 -0
  60. recce/models/types.py +23 -2
  61. recce/pull_request.py +1 -1
  62. recce/run.py +23 -16
  63. recce/server.py +194 -19
  64. recce/state/__init__.py +31 -0
  65. recce/state/cloud.py +632 -0
  66. recce/state/const.py +26 -0
  67. recce/state/local.py +56 -0
  68. recce/state/state.py +119 -0
  69. recce/state/state_loader.py +174 -0
  70. recce/summary.py +2 -1
  71. recce/tasks/dataframe.py +59 -2
  72. recce/tasks/rowcount.py +4 -1
  73. recce/tasks/schema.py +4 -1
  74. recce/tasks/valuediff.py +1 -1
  75. recce/util/api_token.py +11 -2
  76. recce/util/breaking.py +9 -0
  77. recce/util/cll.py +1 -2
  78. recce/util/io.py +2 -2
  79. recce/util/lineage.py +19 -18
  80. recce/util/perf_tracking.py +85 -0
  81. recce/util/recce_cloud.py +229 -5
  82. recce/yaml/__init__.py +2 -2
  83. recce_cloud/__init__.py +15 -0
  84. recce_cloud/api/__init__.py +17 -0
  85. recce_cloud/api/base.py +104 -0
  86. recce_cloud/api/client.py +150 -0
  87. recce_cloud/api/exceptions.py +26 -0
  88. recce_cloud/api/factory.py +63 -0
  89. recce_cloud/api/github.py +72 -0
  90. recce_cloud/api/gitlab.py +78 -0
  91. recce_cloud/artifact.py +57 -0
  92. recce_cloud/ci_providers/__init__.py +9 -0
  93. recce_cloud/ci_providers/base.py +82 -0
  94. recce_cloud/ci_providers/detector.py +147 -0
  95. recce_cloud/ci_providers/github_actions.py +136 -0
  96. recce_cloud/ci_providers/gitlab_ci.py +130 -0
  97. recce_cloud/cli.py +303 -0
  98. recce_cloud/upload.py +213 -0
  99. {recce_nightly-1.9.0.20250623.dist-info → recce_nightly-1.25.0.20251112a2066.dist-info}/METADATA +31 -27
  100. recce_nightly-1.25.0.20251112a2066.dist-info/RECORD +178 -0
  101. {recce_nightly-1.9.0.20250623.dist-info → recce_nightly-1.25.0.20251112a2066.dist-info}/top_level.txt +1 -0
  102. tests/adapter/dbt_adapter/test_dbt_cll.py +412 -79
  103. tests/recce_cloud/__init__.py +0 -0
  104. tests/recce_cloud/test_ci_providers.py +351 -0
  105. tests/recce_cloud/test_cli.py +372 -0
  106. tests/recce_cloud/test_client.py +273 -0
  107. tests/recce_cloud/test_platform_clients.py +279 -0
  108. tests/test_cli.py +106 -3
  109. tests/test_cli_mcp_optional.py +45 -0
  110. tests/test_cloud_listing_cli.py +324 -0
  111. tests/test_connect_to_cloud.py +82 -0
  112. tests/test_core.py +148 -3
  113. tests/test_mcp_server.py +332 -0
  114. tests/test_server.py +6 -6
  115. tests/test_summary.py +14 -6
  116. recce/data/_next/static/WrRUb3nV8BhAZG_R8kVma/_buildManifest.js +0 -1
  117. recce/data/_next/static/chunks/181-acc61ddada3bc0ca.js +0 -43
  118. recce/data/_next/static/chunks/1bff33f1-1ef85cf5e658a751.js +0 -1
  119. recce/data/_next/static/chunks/217-879a84d70f7a907c.js +0 -2
  120. recce/data/_next/static/chunks/29e3cc0d-60045b2e47aa3916.js +0 -1
  121. recce/data/_next/static/chunks/36e1c10d-8e7be4a6c1f6ab2d.js +0 -1
  122. recce/data/_next/static/chunks/3998a672-03adacad07b346ac.js +0 -1
  123. recce/data/_next/static/chunks/3a92ee20-1081c360214f9602.js +0 -1
  124. recce/data/_next/static/chunks/42-cd3c06533f5fd47c.js +0 -9
  125. recce/data/_next/static/chunks/450c323b-fd94e7ffaa4a5efa.js +0 -1
  126. recce/data/_next/static/chunks/47d8844f-929aed9b1c73a905.js +0 -1
  127. recce/data/_next/static/chunks/608-3b079b544e5d5f5e.js +0 -15
  128. recce/data/_next/static/chunks/6dc81886-adbfa45836061d79.js +0 -1
  129. recce/data/_next/static/chunks/7a8a3e83-edf6dc64b5d5f0a5.js +0 -1
  130. recce/data/_next/static/chunks/7f27ae6c-d5f0438edd5c2a5b.js +0 -1
  131. recce/data/_next/static/chunks/86730205-cfb14e3f051bab35.js +0 -1
  132. recce/data/_next/static/chunks/8d700b6a.8bb140898499c512.js +0 -1
  133. recce/data/_next/static/chunks/92-7ab55ae02606193c.js +0 -1
  134. recce/data/_next/static/chunks/9746af58-a42b7d169cacadf0.js +0 -1
  135. recce/data/_next/static/chunks/a30376cd-de84559016d7e133.js +0 -1
  136. recce/data/_next/static/chunks/app/_not-found/page-01ed58b7f971d311.js +0 -1
  137. recce/data/_next/static/chunks/app/layout-177a410a97e0d018.js +0 -1
  138. recce/data/_next/static/chunks/app/page-59241c42b7dd4fcf.js +0 -1
  139. recce/data/_next/static/chunks/b63b1b3f-4282bdcf459e075c.js +0 -1
  140. recce/data/_next/static/chunks/bbda5537-9ec25eb1dd62348a.js +0 -1
  141. recce/data/_next/static/chunks/c132bf7d-08cb668a789d6afd.js +0 -1
  142. recce/data/_next/static/chunks/ce84277d-2e5d1d46910cf052.js +0 -1
  143. recce/data/_next/static/chunks/febdd86e-c6b525341634b860.js +0 -54
  144. recce/data/_next/static/chunks/fee69bc6-2dbccaf9b90474e6.js +0 -1
  145. recce/data/_next/static/chunks/framework-ded83d71b51ce901.js +0 -1
  146. recce/data/_next/static/chunks/main-app-39061b0166c47f55.js +0 -1
  147. recce/data/_next/static/chunks/main-b5b3ae20a1405261.js +0 -1
  148. recce/data/_next/static/chunks/pages/_app-437c455677d62394.js +0 -1
  149. recce/data/_next/static/chunks/pages/_error-e7650df18ca04bde.js +0 -1
  150. recce/data/_next/static/chunks/webpack-7b49d5ba7e3a434d.js +0 -1
  151. recce/data/_next/static/css/17a96168e3a9db13.css +0 -1
  152. recce/data/_next/static/css/1b121dc4d36aeb4d.css +0 -3
  153. recce/data/_next/static/css/35c6679a098e1e34.css +0 -1
  154. recce/data/_next/static/css/951e2e0eea2d4a5b.css +0 -14
  155. recce/data/_next/static/media/montserrat-cyrillic-800-normal.22628180.woff2 +0 -0
  156. recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.94a63aea.woff2 +0 -0
  157. recce/data/_next/static/media/montserrat-latin-800-normal.6f8fa298.woff2 +0 -0
  158. recce/data/_next/static/media/montserrat-latin-ext-800-normal.013b84f9.woff2 +0 -0
  159. recce/data/_next/static/media/montserrat-vietnamese-800-normal.c0035377.woff2 +0 -0
  160. recce/state.py +0 -785
  161. recce_nightly-1.9.0.20250623.dist-info/RECORD +0 -151
  162. tests/test_state.py +0 -134
  163. /recce/data/_next/static/{WrRUb3nV8BhAZG_R8kVma → 6LypcDXgyuSaiSCrsmUub}/_ssgManifest.js +0 -0
  164. /recce/data/_next/static/chunks/{polyfills-42372ed130431b0a.js → a6dad97d9634a72d.js} +0 -0
  165. /recce/data/_next/static/media/{montserrat-cyrillic-ext-800-normal.e6e0d8d0.woff → montserrat-cyrillic-ext-800-normal.a4fa76b5.woff} +0 -0
  166. /recce/data/_next/static/media/{reload-image.79aabb7d.svg → reload-image.7aa931c7.svg} +0 -0
  167. {recce_nightly-1.9.0.20250623.dist-info → recce_nightly-1.25.0.20251112a2066.dist-info}/WHEEL +0 -0
  168. {recce_nightly-1.9.0.20250623.dist-info → recce_nightly-1.25.0.20251112a2066.dist-info}/entry_points.txt +0 -0
  169. {recce_nightly-1.9.0.20250623.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
- content += f"\nSee PR page: {RECCE_CLOUD_HOST}/{pr_info.repository}/pulls/{pr_info.id}\n"
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[tuple]
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
- return self.changes.affected_root_keys.items
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
- return self.changes.affected_root_keys.items
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
@@ -159,7 +159,7 @@ class ValueDiffTask(Task, ValueDiffMixin):
159
159
  match_status,
160
160
  count(*) as count_records
161
161
  from joined
162
- group by column_name, match_status
162
+ group by 1, 2
163
163
  )
164
164
 
165
165
  select
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(f"Please check your API token from {RECCE_CLOUD_BASE_URL}/settings#tokens")
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 api_token != new_api_token and new_api_token is not None:
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(metaclass=SingletonMeta):
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
- def dfs(current):
10
+ stack = list(node_ids)
11
+ while stack:
12
+ current = stack.pop()
11
13
  if current in visited:
12
- return
14
+ continue
13
15
  visited.add(current)
14
-
15
16
  parents = parent_map.get(current, [])
16
17
  for parent in parents:
17
- upstream.add(parent)
18
- dfs(parent)
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
- def dfs(current):
28
+ stack = list(node_ids)
29
+ while stack:
30
+ current = stack.pop()
31
31
  if current in visited:
32
- return
32
+ continue
33
33
  visited.add(current)
34
-
35
34
  children = child_map.get(current, [])
36
35
  for child in children:
37
- downstream.add(child)
38
- dfs(child)
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
 
@@ -80,3 +76,8 @@ def filter_dependency_maps(
80
76
  c_map[node_id] = {c for c in children if c in relevant_ids}
81
77
 
82
78
  return p_map, c_map
79
+
80
+
81
+ def build_column_key(node_id: str, column_name: str) -> str:
82
+ """Build a unique column key from node name and column name."""
83
+ return f"{node_id}_{column_name}"