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.
- recce/VERSION +1 -1
- recce/__init__.py +5 -0
- recce/adapter/dbt_adapter/__init__.py +12 -3
- recce/artifact.py +74 -1
- recce/cli.py +642 -101
- recce/config.py +2 -2
- recce/connect_to_cloud.py +1 -1
- recce/core.py +2 -2
- recce/data/404.html +1 -1
- recce/data/__next.__PAGE__.txt +10 -0
- recce/data/__next._full.txt +23 -0
- recce/data/__next._head.txt +8 -0
- recce/data/__next._index.txt +8 -0
- recce/data/__next._tree.txt +5 -0
- recce/data/_next/static/52aV_JrNUZU6dMFgvTQEO/_buildManifest.js +11 -0
- recce/data/_next/static/52aV_JrNUZU6dMFgvTQEO/_clientMiddlewareManifest.json +1 -0
- recce/data/_next/static/chunks/02b996c7f6a29a06.js +4 -0
- recce/data/_next/static/chunks/19c10d219a6a21ff.js +1 -0
- recce/data/_next/static/chunks/2df9ec28a061971d.js +11 -0
- recce/data/_next/static/chunks/3098c987393bda15.js +1 -0
- recce/data/_next/static/chunks/393dc43e483f717a.css +2 -0
- recce/data/_next/static/chunks/399e8d91a7e45073.js +2 -0
- recce/data/_next/static/chunks/4d0186f631230245.js +1 -0
- recce/data/_next/static/chunks/5794ba9e10a9c060.js +11 -0
- recce/data/_next/static/chunks/715761c929a3f28b.js +110 -0
- recce/data/_next/static/chunks/71f88fcc615bf282.js +1 -0
- recce/data/_next/static/chunks/80d2a95eaf1201ea.js +1 -0
- recce/data/_next/static/chunks/9979c6109bbbee35.js +1 -0
- recce/data/_next/static/chunks/99d638224186c118.js +1 -0
- recce/data/_next/static/chunks/d003eb36240e92f3.js +1 -0
- recce/data/_next/static/chunks/d3167cdfec4fc351.js +1 -0
- recce/data/_next/static/chunks/e124bccf574a3361.css +1 -0
- recce/data/_next/static/chunks/f40141db1bdb46f0.css +6 -0
- recce/data/_next/static/chunks/fcc53a88741a52f9.js +1 -0
- recce/data/_next/static/chunks/turbopack-b1920d28cfb1f28d.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._head.txt +8 -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 +3 -0
- recce/data/_not-found.html +1 -0
- recce/data/_not-found.txt +17 -0
- recce/data/index.html +1 -1
- recce/data/index.txt +21 -23
- recce/event/__init__.py +9 -8
- recce/event/collector.py +3 -1
- recce/event/track.py +10 -0
- recce/github.py +1 -1
- recce/mcp_server.py +716 -0
- recce/models/types.py +35 -2
- recce/pull_request.py +1 -1
- recce/run.py +2 -2
- recce/server.py +105 -3
- 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 +21 -1
- recce/tasks/dataframe.py +63 -1
- recce/tasks/rowcount.py +4 -1
- recce/tasks/schema.py +4 -1
- recce/util/api_token.py +9 -2
- recce/util/breaking.py +1 -1
- recce/util/io.py +2 -2
- recce/util/lineage.py +14 -18
- recce/util/recce_cloud.py +187 -7
- recce/yaml/__init__.py +2 -2
- recce_cloud/__init__.py +24 -0
- recce_cloud/api/__init__.py +17 -0
- recce_cloud/api/base.py +111 -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 +76 -0
- recce_cloud/api/gitlab.py +82 -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 +245 -0
- recce_cloud/upload.py +214 -0
- {recce_nightly-1.15.0.20250806.dist-info → recce_nightly-1.26.0.20251124.dist-info}/METADATA +54 -28
- recce_nightly-1.26.0.20251124.dist-info/RECORD +180 -0
- {recce_nightly-1.15.0.20250806.dist-info → recce_nightly-1.26.0.20251124.dist-info}/top_level.txt +1 -0
- tests/adapter/dbt_adapter/test_dbt_cll.py +4 -2
- 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 +333 -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/Q_5ThPsmamd4VAGXuqwgi/_buildManifest.js +0 -1
- recce/data/_next/static/chunks/0376eeba-3db2196398d62270.js +0 -1
- recce/data/_next/static/chunks/068b80ea-833a129468ee1622.js +0 -1
- recce/data/_next/static/chunks/0ddaf06c-c7961285f66460f6.js +0 -1
- recce/data/_next/static/chunks/1268aea1-6dc1251c01bd724b.js +0 -54
- recce/data/_next/static/chunks/12f8fac4-16838e42d28d45c3.js +0 -1
- recce/data/_next/static/chunks/235b8375-8c84c51d7bd4f6aa.js +0 -1
- recce/data/_next/static/chunks/2541941f-2cd3a7c2d629bd33.js +0 -1
- recce/data/_next/static/chunks/273-f3fa401bd2b6fc91.js +0 -10
- recce/data/_next/static/chunks/2fc37c1e-910deebeb3d77c90.js +0 -1
- recce/data/_next/static/chunks/338-2e7eed5135c64550.js +0 -30
- recce/data/_next/static/chunks/367-ab8b16dd5f8586ca.js +0 -1
- recce/data/_next/static/chunks/3a92ee20-0400ffe460c7c803.js +0 -1
- recce/data/_next/static/chunks/62446465-423c03bb8c1f59b6.js +0 -1
- recce/data/_next/static/chunks/6af7f9e9-60aa8706f49dae45.js +0 -1
- recce/data/_next/static/chunks/6cf54382-49d52ae6e564e2ac.js +0 -1
- recce/data/_next/static/chunks/6dc81886-78e2efe4538794ae.js +0 -1
- recce/data/_next/static/chunks/715e4acc-9e2e6df4eb3809d1.js +0 -1
- recce/data/_next/static/chunks/72-181b430654230f0e.js +0 -1
- recce/data/_next/static/chunks/786-774e3e3ed70a41b3.js +0 -1
- recce/data/_next/static/chunks/8d700b6a.7fe2c8c3f4e333a6.js +0 -1
- recce/data/_next/static/chunks/a69d64b4-d6890125a87b0aba.js +0 -1
- recce/data/_next/static/chunks/ae307f12-01100009689ace61.js +0 -1
- recce/data/_next/static/chunks/app/_not-found/page-c7ef8ed6dc07aaeb.js +0 -1
- recce/data/_next/static/chunks/app/layout-744f0a78e9e50e60.js +0 -1
- recce/data/_next/static/chunks/app/page-e8f798c2ae3f59c2.js +0 -1
- recce/data/_next/static/chunks/c0015c5c-82c219792582c104.js +0 -1
- recce/data/_next/static/chunks/d90cfbaa-e7d779b3912afeec.js +0 -1
- recce/data/_next/static/chunks/e07c302e-cd170429646873e1.js +0 -1
- recce/data/_next/static/chunks/fa5fb511-15fb438349ad5b97.js +0 -1
- recce/data/_next/static/chunks/framework-7950757d31580329.js +0 -1
- recce/data/_next/static/chunks/main-app-4df79eb11c34d43c.js +0 -1
- recce/data/_next/static/chunks/main-cd6c104af638214a.js +0 -1
- recce/data/_next/static/chunks/pages/_app-73008661edbd5e05.js +0 -1
- recce/data/_next/static/chunks/pages/_error-cf8bbdc3cf76c83f.js +0 -1
- recce/data/_next/static/chunks/webpack-84df6dd5ae3cf908.js +0 -1
- recce/data/_next/static/css/188a3a1687e2a064.css +0 -1
- recce/data/_next/static/css/8edca58d4abcf908.css +0 -14
- recce/data/_next/static/css/abdb9814a3dd18bb.css +0 -1
- recce/data/_next/static/css/c21263c1520b615b.css +0 -1
- 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 -865
- recce_nightly-1.15.0.20250806.dist-info/RECORD +0 -156
- tests/test_state.py +0 -134
- /recce/data/_next/static/{Q_5ThPsmamd4VAGXuqwgi → 52aV_JrNUZU6dMFgvTQEO}/_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.15.0.20250806.dist-info → recce_nightly-1.26.0.20251124.dist-info}/WHEEL +0 -0
- {recce_nightly-1.15.0.20250806.dist-info → recce_nightly-1.26.0.20251124.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
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
|
-
|
|
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/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
|
|
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
|
-
|
|
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
|
|