recce-nightly 1.15.0.20250806__py3-none-any.whl → 1.26.0.20251124__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of recce-nightly might be problematic. Click here for more details.

Files changed (167) hide show
  1. recce/VERSION +1 -1
  2. recce/__init__.py +5 -0
  3. recce/adapter/dbt_adapter/__init__.py +12 -3
  4. recce/artifact.py +74 -1
  5. recce/cli.py +642 -101
  6. recce/config.py +2 -2
  7. recce/connect_to_cloud.py +1 -1
  8. recce/core.py +2 -2
  9. recce/data/404.html +1 -1
  10. recce/data/__next.__PAGE__.txt +10 -0
  11. recce/data/__next._full.txt +23 -0
  12. recce/data/__next._head.txt +8 -0
  13. recce/data/__next._index.txt +8 -0
  14. recce/data/__next._tree.txt +5 -0
  15. recce/data/_next/static/52aV_JrNUZU6dMFgvTQEO/_buildManifest.js +11 -0
  16. recce/data/_next/static/52aV_JrNUZU6dMFgvTQEO/_clientMiddlewareManifest.json +1 -0
  17. recce/data/_next/static/chunks/02b996c7f6a29a06.js +4 -0
  18. recce/data/_next/static/chunks/19c10d219a6a21ff.js +1 -0
  19. recce/data/_next/static/chunks/2df9ec28a061971d.js +11 -0
  20. recce/data/_next/static/chunks/3098c987393bda15.js +1 -0
  21. recce/data/_next/static/chunks/393dc43e483f717a.css +2 -0
  22. recce/data/_next/static/chunks/399e8d91a7e45073.js +2 -0
  23. recce/data/_next/static/chunks/4d0186f631230245.js +1 -0
  24. recce/data/_next/static/chunks/5794ba9e10a9c060.js +11 -0
  25. recce/data/_next/static/chunks/715761c929a3f28b.js +110 -0
  26. recce/data/_next/static/chunks/71f88fcc615bf282.js +1 -0
  27. recce/data/_next/static/chunks/80d2a95eaf1201ea.js +1 -0
  28. recce/data/_next/static/chunks/9979c6109bbbee35.js +1 -0
  29. recce/data/_next/static/chunks/99d638224186c118.js +1 -0
  30. recce/data/_next/static/chunks/d003eb36240e92f3.js +1 -0
  31. recce/data/_next/static/chunks/d3167cdfec4fc351.js +1 -0
  32. recce/data/_next/static/chunks/e124bccf574a3361.css +1 -0
  33. recce/data/_next/static/chunks/f40141db1bdb46f0.css +6 -0
  34. recce/data/_next/static/chunks/fcc53a88741a52f9.js +1 -0
  35. recce/data/_next/static/chunks/turbopack-b1920d28cfb1f28d.js +3 -0
  36. recce/data/_next/static/media/favicon.a8d38d84.ico +0 -0
  37. recce/data/_next/static/media/montserrat-cyrillic-800-normal.d80d830d.woff2 +0 -0
  38. recce/data/_next/static/media/{montserrat-cyrillic-800-normal.bd5c9f50.woff → montserrat-cyrillic-800-normal.f9d58125.woff} +0 -0
  39. recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.076c2a93.woff2 +0 -0
  40. recce/data/_next/static/media/montserrat-latin-800-normal.cde454cc.woff2 +0 -0
  41. recce/data/_next/static/media/{montserrat-latin-800-normal.fc315020.woff → montserrat-latin-800-normal.d5761935.woff} +0 -0
  42. recce/data/_next/static/media/montserrat-latin-ext-800-normal.40ec0659.woff2 +0 -0
  43. recce/data/_next/static/media/{montserrat-latin-ext-800-normal.2e5381b2.woff → montserrat-latin-ext-800-normal.b671449b.woff} +0 -0
  44. recce/data/_next/static/media/{montserrat-vietnamese-800-normal.20c545e6.woff → montserrat-vietnamese-800-normal.9f7b8541.woff} +0 -0
  45. recce/data/_next/static/media/montserrat-vietnamese-800-normal.f9eb854e.woff2 +0 -0
  46. recce/data/_not-found/__next._full.txt +17 -0
  47. recce/data/_not-found/__next._head.txt +8 -0
  48. recce/data/_not-found/__next._index.txt +8 -0
  49. recce/data/_not-found/__next._not-found.__PAGE__.txt +5 -0
  50. recce/data/_not-found/__next._not-found.txt +4 -0
  51. recce/data/_not-found/__next._tree.txt +3 -0
  52. recce/data/_not-found.html +1 -0
  53. recce/data/_not-found.txt +17 -0
  54. recce/data/index.html +1 -1
  55. recce/data/index.txt +21 -23
  56. recce/event/__init__.py +9 -8
  57. recce/event/collector.py +3 -1
  58. recce/event/track.py +10 -0
  59. recce/github.py +1 -1
  60. recce/mcp_server.py +716 -0
  61. recce/models/types.py +35 -2
  62. recce/pull_request.py +1 -1
  63. recce/run.py +2 -2
  64. recce/server.py +105 -3
  65. recce/state/__init__.py +31 -0
  66. recce/state/cloud.py +632 -0
  67. recce/state/const.py +26 -0
  68. recce/state/local.py +56 -0
  69. recce/state/state.py +119 -0
  70. recce/state/state_loader.py +174 -0
  71. recce/summary.py +21 -1
  72. recce/tasks/dataframe.py +63 -1
  73. recce/tasks/rowcount.py +4 -1
  74. recce/tasks/schema.py +4 -1
  75. recce/util/api_token.py +9 -2
  76. recce/util/breaking.py +1 -1
  77. recce/util/io.py +2 -2
  78. recce/util/lineage.py +14 -18
  79. recce/util/recce_cloud.py +187 -7
  80. recce/yaml/__init__.py +2 -2
  81. recce_cloud/__init__.py +24 -0
  82. recce_cloud/api/__init__.py +17 -0
  83. recce_cloud/api/base.py +111 -0
  84. recce_cloud/api/client.py +150 -0
  85. recce_cloud/api/exceptions.py +26 -0
  86. recce_cloud/api/factory.py +63 -0
  87. recce_cloud/api/github.py +76 -0
  88. recce_cloud/api/gitlab.py +82 -0
  89. recce_cloud/artifact.py +57 -0
  90. recce_cloud/ci_providers/__init__.py +9 -0
  91. recce_cloud/ci_providers/base.py +82 -0
  92. recce_cloud/ci_providers/detector.py +147 -0
  93. recce_cloud/ci_providers/github_actions.py +136 -0
  94. recce_cloud/ci_providers/gitlab_ci.py +130 -0
  95. recce_cloud/cli.py +245 -0
  96. recce_cloud/upload.py +214 -0
  97. {recce_nightly-1.15.0.20250806.dist-info → recce_nightly-1.26.0.20251124.dist-info}/METADATA +54 -28
  98. recce_nightly-1.26.0.20251124.dist-info/RECORD +180 -0
  99. {recce_nightly-1.15.0.20250806.dist-info → recce_nightly-1.26.0.20251124.dist-info}/top_level.txt +1 -0
  100. tests/adapter/dbt_adapter/test_dbt_cll.py +4 -2
  101. tests/recce_cloud/__init__.py +0 -0
  102. tests/recce_cloud/test_ci_providers.py +351 -0
  103. tests/recce_cloud/test_cli.py +372 -0
  104. tests/recce_cloud/test_client.py +273 -0
  105. tests/recce_cloud/test_platform_clients.py +333 -0
  106. tests/test_cli.py +106 -3
  107. tests/test_cli_mcp_optional.py +45 -0
  108. tests/test_cloud_listing_cli.py +324 -0
  109. tests/test_core.py +147 -0
  110. tests/test_mcp_server.py +332 -0
  111. tests/test_server.py +6 -6
  112. tests/test_summary.py +14 -6
  113. recce/data/_next/static/Q_5ThPsmamd4VAGXuqwgi/_buildManifest.js +0 -1
  114. recce/data/_next/static/chunks/0376eeba-3db2196398d62270.js +0 -1
  115. recce/data/_next/static/chunks/068b80ea-833a129468ee1622.js +0 -1
  116. recce/data/_next/static/chunks/0ddaf06c-c7961285f66460f6.js +0 -1
  117. recce/data/_next/static/chunks/1268aea1-6dc1251c01bd724b.js +0 -54
  118. recce/data/_next/static/chunks/12f8fac4-16838e42d28d45c3.js +0 -1
  119. recce/data/_next/static/chunks/235b8375-8c84c51d7bd4f6aa.js +0 -1
  120. recce/data/_next/static/chunks/2541941f-2cd3a7c2d629bd33.js +0 -1
  121. recce/data/_next/static/chunks/273-f3fa401bd2b6fc91.js +0 -10
  122. recce/data/_next/static/chunks/2fc37c1e-910deebeb3d77c90.js +0 -1
  123. recce/data/_next/static/chunks/338-2e7eed5135c64550.js +0 -30
  124. recce/data/_next/static/chunks/367-ab8b16dd5f8586ca.js +0 -1
  125. recce/data/_next/static/chunks/3a92ee20-0400ffe460c7c803.js +0 -1
  126. recce/data/_next/static/chunks/62446465-423c03bb8c1f59b6.js +0 -1
  127. recce/data/_next/static/chunks/6af7f9e9-60aa8706f49dae45.js +0 -1
  128. recce/data/_next/static/chunks/6cf54382-49d52ae6e564e2ac.js +0 -1
  129. recce/data/_next/static/chunks/6dc81886-78e2efe4538794ae.js +0 -1
  130. recce/data/_next/static/chunks/715e4acc-9e2e6df4eb3809d1.js +0 -1
  131. recce/data/_next/static/chunks/72-181b430654230f0e.js +0 -1
  132. recce/data/_next/static/chunks/786-774e3e3ed70a41b3.js +0 -1
  133. recce/data/_next/static/chunks/8d700b6a.7fe2c8c3f4e333a6.js +0 -1
  134. recce/data/_next/static/chunks/a69d64b4-d6890125a87b0aba.js +0 -1
  135. recce/data/_next/static/chunks/ae307f12-01100009689ace61.js +0 -1
  136. recce/data/_next/static/chunks/app/_not-found/page-c7ef8ed6dc07aaeb.js +0 -1
  137. recce/data/_next/static/chunks/app/layout-744f0a78e9e50e60.js +0 -1
  138. recce/data/_next/static/chunks/app/page-e8f798c2ae3f59c2.js +0 -1
  139. recce/data/_next/static/chunks/c0015c5c-82c219792582c104.js +0 -1
  140. recce/data/_next/static/chunks/d90cfbaa-e7d779b3912afeec.js +0 -1
  141. recce/data/_next/static/chunks/e07c302e-cd170429646873e1.js +0 -1
  142. recce/data/_next/static/chunks/fa5fb511-15fb438349ad5b97.js +0 -1
  143. recce/data/_next/static/chunks/framework-7950757d31580329.js +0 -1
  144. recce/data/_next/static/chunks/main-app-4df79eb11c34d43c.js +0 -1
  145. recce/data/_next/static/chunks/main-cd6c104af638214a.js +0 -1
  146. recce/data/_next/static/chunks/pages/_app-73008661edbd5e05.js +0 -1
  147. recce/data/_next/static/chunks/pages/_error-cf8bbdc3cf76c83f.js +0 -1
  148. recce/data/_next/static/chunks/webpack-84df6dd5ae3cf908.js +0 -1
  149. recce/data/_next/static/css/188a3a1687e2a064.css +0 -1
  150. recce/data/_next/static/css/8edca58d4abcf908.css +0 -14
  151. recce/data/_next/static/css/abdb9814a3dd18bb.css +0 -1
  152. recce/data/_next/static/css/c21263c1520b615b.css +0 -1
  153. recce/data/_next/static/media/montserrat-cyrillic-800-normal.22628180.woff2 +0 -0
  154. recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.94a63aea.woff2 +0 -0
  155. recce/data/_next/static/media/montserrat-latin-800-normal.6f8fa298.woff2 +0 -0
  156. recce/data/_next/static/media/montserrat-latin-ext-800-normal.013b84f9.woff2 +0 -0
  157. recce/data/_next/static/media/montserrat-vietnamese-800-normal.c0035377.woff2 +0 -0
  158. recce/state.py +0 -865
  159. recce_nightly-1.15.0.20250806.dist-info/RECORD +0 -156
  160. tests/test_state.py +0 -134
  161. /recce/data/_next/static/{Q_5ThPsmamd4VAGXuqwgi → 52aV_JrNUZU6dMFgvTQEO}/_ssgManifest.js +0 -0
  162. /recce/data/_next/static/chunks/{polyfills-42372ed130431b0a.js → a6dad97d9634a72d.js} +0 -0
  163. /recce/data/_next/static/media/{montserrat-cyrillic-ext-800-normal.e6e0d8d0.woff → montserrat-cyrillic-ext-800-normal.a4fa76b5.woff} +0 -0
  164. /recce/data/_next/static/media/{reload-image.79aabb7d.svg → reload-image.7aa931c7.svg} +0 -0
  165. {recce_nightly-1.15.0.20250806.dist-info → recce_nightly-1.26.0.20251124.dist-info}/WHEEL +0 -0
  166. {recce_nightly-1.15.0.20250806.dist-info → recce_nightly-1.26.0.20251124.dist-info}/entry_points.txt +0 -0
  167. {recce_nightly-1.15.0.20250806.dist-info → recce_nightly-1.26.0.20251124.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
@@ -271,11 +271,24 @@ class LineageGraph:
271
271
  def _build_lineage_graph(base, current) -> LineageGraph:
272
272
  graph = LineageGraph()
273
273
 
274
+ # Get the current package name to filter nodes (from the current manifest metadata)
275
+ package_name = None
276
+ manifest_metadata = current.get("manifest_metadata")
277
+ if manifest_metadata and hasattr(manifest_metadata, "project_name"):
278
+ # The default package name is the project name
279
+ package_name = manifest_metadata.project_name
280
+
274
281
  # Init Graph nodes with base & current nodes
275
282
  for node_id, node_data in base.get("nodes", {}).items():
283
+ # Skip nodes that are not from the current package
284
+ if package_name and node_data.get("package_name") != package_name:
285
+ continue
276
286
  graph.create_node(node_id, node_data, "base")
277
287
 
278
288
  for node_id, node_data in current.get("nodes", {}).items():
289
+ # Skip nodes that are not from the current package
290
+ if package_name and node_data.get("package_name") != package_name:
291
+ continue
279
292
  if node_id not in graph.nodes:
280
293
  node = Node(node_id, node_data, "current")
281
294
  graph.nodes[node_id] = node
@@ -286,9 +299,15 @@ def _build_lineage_graph(base, current) -> LineageGraph:
286
299
  # Build edges
287
300
  for child_id, parents in base.get("parent_map", {}).items():
288
301
  for parent_id in parents:
302
+ if child_id not in graph.nodes or parent_id not in graph.nodes:
303
+ continue
304
+
289
305
  graph.create_edge(parent_id, child_id, "base")
290
306
  for child_id, parents in current.get("parent_map", {}).items():
291
307
  for parent_id in parents:
308
+ if child_id not in graph.nodes or parent_id not in graph.nodes:
309
+ continue
310
+
292
311
  graph.create_edge(parent_id, child_id, "current")
293
312
 
294
313
  return graph
@@ -493,7 +512,8 @@ No changed module was detected.
493
512
 
494
513
  if ctx.state_loader.cloud_mode:
495
514
  pr_info = ctx.state_loader.pr_info
496
- content += f"\nSee PR page: {RECCE_CLOUD_HOST}/{pr_info.repository}/pulls/{pr_info.id}\n"
515
+ # the classic route will be deprecated soon
516
+ content += f"\nSee PR page: {RECCE_CLOUD_HOST}/classic/{pr_info.repository}/pulls/{pr_info.id}\n"
497
517
 
498
518
  return content
499
519
 
recce/tasks/dataframe.py CHANGED
@@ -19,11 +19,34 @@ class DataFrameColumnType(Enum):
19
19
  TIMEDELTA = "timedelta"
20
20
  UNKNOWN = "unknown"
21
21
 
22
+ @classmethod
23
+ def from_string(cls, type_str: str) -> "DataFrameColumnType":
24
+ """Convert string to DataFrameColumnType enum.
25
+
26
+ Args:
27
+ type_str: String representation of the type (e.g., "integer", "text")
28
+
29
+ Returns:
30
+ DataFrameColumnType enum value
31
+ """
32
+ type_str = type_str.lower().strip()
33
+ try:
34
+ return cls(type_str)
35
+ except ValueError:
36
+ return cls.UNKNOWN
37
+
22
38
 
23
39
  class DataFrameColumn(BaseModel):
40
+ key: t.Optional[str] = None
24
41
  name: str
25
42
  type: DataFrameColumnType
26
43
 
44
+ def __init__(self, **data):
45
+ """Initialize DataFrameColumn, auto-setting key=name if key is missing."""
46
+ if "key" not in data or data["key"] is None:
47
+ data["key"] = data.get("name")
48
+ super().__init__(**data)
49
+
27
50
 
28
51
  class DataFrame(BaseModel):
29
52
  columns: t.List[DataFrameColumn]
@@ -64,7 +87,7 @@ class DataFrame(BaseModel):
64
87
  col_type = DataFrameColumnType.INTEGER
65
88
  else:
66
89
  col_type = DataFrameColumnType.UNKNOWN
67
- columns.append(DataFrameColumn(name=col_name, type=col_type))
90
+ columns.append(DataFrameColumn(key=col_name, name=col_name, type=col_type))
68
91
 
69
92
  def _row_values(row):
70
93
  # If the value is Decimal, check if it's finite. If not, convert it to float(xxx) (GitHub issue #476)
@@ -106,3 +129,42 @@ class DataFrame(BaseModel):
106
129
  more=more,
107
130
  )
108
131
  return df
132
+
133
+ @staticmethod
134
+ def from_data(
135
+ columns: t.Dict[str, str],
136
+ data: t.List[tuple],
137
+ limit: t.Optional[int] = None,
138
+ more: t.Optional[bool] = None,
139
+ ):
140
+ """Create a DataFrame from columns and data directly.
141
+
142
+ Args:
143
+ columns: Dict defining the schema where keys are column names and values are type strings.
144
+ Type strings can be: "number", "integer", "text", "boolean", "date", "datetime", "timedelta"
145
+ data: List of rows (each row is a list/tuple/sequence of values)
146
+ limit: Optional limit on the number of rows returned
147
+ more: Optional flag indicating whether there are more rows to fetch
148
+
149
+ Returns:
150
+ DataFrame instance
151
+
152
+ Examples:
153
+ # Using simple dict format
154
+ columns = {"idx": "integer", "name": "text", "impacted": "boolean"}
155
+ data = [[0, "model_a", True], [1, "model_b", False]]
156
+ df = DataFrame.from_data(columns, data)
157
+ """
158
+ # Convert dict columns to DataFrameColumn objects
159
+ processed_columns = []
160
+ for key, type_str in columns.items():
161
+ col_type = DataFrameColumnType.from_string(type_str)
162
+ processed_columns.append(DataFrameColumn(key=key, name=key, type=col_type))
163
+
164
+ df = DataFrame(
165
+ columns=processed_columns,
166
+ data=data,
167
+ limit=limit,
168
+ more=more,
169
+ )
170
+ 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/util/api_token.py CHANGED
@@ -19,7 +19,8 @@ def show_invalid_api_token_message():
19
19
  console.print("[[red]Error[/red]] Invalid Recce Cloud API token.")
20
20
  console.print("Please associate with your Recce Cloud account by the following command 'recce connect-to-cloud'.")
21
21
  console.print(
22
- "For more information, please visit: https://docs.reccehq.com/recce-cloud/share-recce-session-securely/#configure-recce-cloud-association-manually")
22
+ "For more information, please visit: https://docs.reccehq.com/recce-cloud/share-recce-session-securely/#configure-recce-cloud-association-manually"
23
+ )
23
24
 
24
25
 
25
26
  def prepare_api_token(
@@ -32,7 +33,13 @@ def prepare_api_token(
32
33
  # Verify the API token for Recce Cloud Share Link
33
34
  api_token = get_recce_api_token()
34
35
  new_api_token = kwargs.get("api_token")
35
- 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:
36
43
  # Handle the API token provided by option `--api-token`
37
44
  valid = RecceCloud(new_api_token).verify_token()
38
45
  if not valid:
recce/util/breaking.py CHANGED
@@ -91,7 +91,7 @@ def _diff_select_scope(old_scope: Scope, new_scope: Scope, scope_changes_map: di
91
91
  old_select = old_scope.expression # type: exp.Select
92
92
  new_select = new_scope.expression # type: exp.Select
93
93
  for arg_key in old_select.args.keys() | new_select.args.keys():
94
- if arg_key in ["expressions", "with", "from"]:
94
+ if arg_key in ["expressions", "with", "from", "with_", "from_"]:
95
95
  continue
96
96
 
97
97
  if old_select.args.get(arg_key) != new_select.args.get(arg_key):
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