recce-nightly 1.10.0.20250625__py3-none-any.whl → 1.30.0.20251221__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 (229) hide show
  1. recce/VERSION +1 -1
  2. recce/__init__.py +5 -0
  3. recce/adapter/dbt_adapter/__init__.py +343 -245
  4. recce/apis/check_api.py +20 -14
  5. recce/apis/check_events_api.py +353 -0
  6. recce/apis/check_func.py +5 -5
  7. recce/apis/run_func.py +32 -3
  8. recce/artifact.py +76 -3
  9. recce/cli.py +705 -82
  10. recce/config.py +2 -2
  11. recce/connect_to_cloud.py +1 -1
  12. recce/core.py +3 -3
  13. recce/data/404/index.html +2 -0
  14. recce/data/404.html +2 -22
  15. recce/data/__next.@lineage.!KHNsb3Qp.__PAGE__.txt +7 -0
  16. recce/data/__next.@lineage.!KHNsb3Qp.txt +4 -0
  17. recce/data/__next.__PAGE__.txt +6 -0
  18. recce/data/__next._full.txt +32 -0
  19. recce/data/__next._head.txt +8 -0
  20. recce/data/__next._index.txt +14 -0
  21. recce/data/__next._tree.txt +8 -0
  22. recce/data/_next/static/chunks/025a7e3e3f9f40ae.js +1 -0
  23. recce/data/_next/static/chunks/0ce56d67ef5779ca.js +4 -0
  24. recce/data/_next/static/chunks/1a6a78780155dac7.js +48 -0
  25. recce/data/_next/static/chunks/1de8485918b9182a.css +2 -0
  26. recce/data/_next/static/chunks/1e4b1b50d1e34993.js +1 -0
  27. recce/data/_next/static/chunks/206d5d181e4c738e.js +1 -0
  28. recce/data/_next/static/chunks/2c357efc34c5b859.js +25 -0
  29. recce/data/_next/static/chunks/2e9d95d2d48c479c.js +1 -0
  30. recce/data/_next/static/chunks/2f016dc4a3edad2e.js +2 -0
  31. recce/data/_next/static/chunks/313251962d698f7c.js +1 -0
  32. recce/data/_next/static/chunks/3a9f021f38eb5574.css +1 -0
  33. recce/data/_next/static/chunks/40079da8d2b8f651.js +1 -0
  34. recce/data/_next/static/chunks/4599182bffb64661.js +38 -0
  35. recce/data/_next/static/chunks/4e62f6e184173580.js +1 -0
  36. recce/data/_next/static/chunks/5c4dfb0d09eaa401.js +1 -0
  37. recce/data/_next/static/chunks/69e4f06ccfdfc3ac.js +1 -0
  38. recce/data/_next/static/chunks/6b206cb4707d6bee.js +1 -0
  39. recce/data/_next/static/chunks/6d8557f062aa4386.css +1 -0
  40. recce/data/_next/static/chunks/7fbe3650bd83b6b5.js +1 -0
  41. recce/data/_next/static/chunks/83fa823a825674f6.js +1 -0
  42. recce/data/_next/static/chunks/848a6c9b5f55f7ed.js +1 -0
  43. recce/data/_next/static/chunks/859462b0858aef88.css +2 -0
  44. recce/data/_next/static/chunks/923964f18c87d0f1.css +1 -0
  45. recce/data/_next/static/chunks/939390f911895d7c.js +48 -0
  46. recce/data/_next/static/chunks/99a9817237a07f43.js +1 -0
  47. recce/data/_next/static/chunks/9fed8b4b2b924054.js +5 -0
  48. recce/data/_next/static/chunks/b6949f6c5892110c.js +1 -0
  49. recce/data/_next/static/chunks/b851a1d3f8149828.js +1 -0
  50. recce/data/_next/static/chunks/c734f9ad957de0b4.js +1 -0
  51. recce/data/_next/static/chunks/cdde321b0ec75717.js +2 -0
  52. recce/data/_next/static/chunks/d0f91117d77ff844.css +1 -0
  53. recce/data/_next/static/chunks/d6c8667911c2500f.js +1 -0
  54. recce/data/_next/static/chunks/da8dab68c02752cf.js +74 -0
  55. recce/data/_next/static/chunks/dc074049c9d12d97.js +109 -0
  56. recce/data/_next/static/chunks/ee7f1a8227342421.js +1 -0
  57. recce/data/_next/static/chunks/fa2f4e56c2fccc73.js +1 -0
  58. recce/data/_next/static/chunks/turbopack-1fad664f62979b93.js +3 -0
  59. recce/data/_next/static/media/favicon.a8d38d84.ico +0 -0
  60. recce/data/_next/static/media/montserrat-cyrillic-800-normal.d80d830d.woff2 +0 -0
  61. recce/data/_next/static/media/{montserrat-cyrillic-800-normal.bd5c9f50.woff → montserrat-cyrillic-800-normal.f9d58125.woff} +0 -0
  62. recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.076c2a93.woff2 +0 -0
  63. recce/data/_next/static/media/montserrat-latin-800-normal.cde454cc.woff2 +0 -0
  64. recce/data/_next/static/media/{montserrat-latin-800-normal.fc315020.woff → montserrat-latin-800-normal.d5761935.woff} +0 -0
  65. recce/data/_next/static/media/montserrat-latin-ext-800-normal.40ec0659.woff2 +0 -0
  66. recce/data/_next/static/media/{montserrat-latin-ext-800-normal.2e5381b2.woff → montserrat-latin-ext-800-normal.b671449b.woff} +0 -0
  67. recce/data/_next/static/media/{montserrat-vietnamese-800-normal.20c545e6.woff → montserrat-vietnamese-800-normal.9f7b8541.woff} +0 -0
  68. recce/data/_next/static/media/montserrat-vietnamese-800-normal.f9eb854e.woff2 +0 -0
  69. recce/data/_next/static/nX-Uz0AH6Tc6hIQUFGqaB/_buildManifest.js +11 -0
  70. recce/data/_next/static/nX-Uz0AH6Tc6hIQUFGqaB/_clientMiddlewareManifest.json +1 -0
  71. recce/data/_not-found/__next._full.txt +24 -0
  72. recce/data/_not-found/__next._head.txt +8 -0
  73. recce/data/_not-found/__next._index.txt +13 -0
  74. recce/data/_not-found/__next._not-found.__PAGE__.txt +5 -0
  75. recce/data/_not-found/__next._not-found.txt +4 -0
  76. recce/data/_not-found/__next._tree.txt +6 -0
  77. recce/data/_not-found/index.html +2 -0
  78. recce/data/_not-found/index.txt +24 -0
  79. recce/data/auth_callback.html +1 -1
  80. recce/data/checks/__next.@lineage.__DEFAULT__.txt +7 -0
  81. recce/data/checks/__next._full.txt +39 -0
  82. recce/data/checks/__next._head.txt +8 -0
  83. recce/data/checks/__next._index.txt +14 -0
  84. recce/data/checks/__next._tree.txt +8 -0
  85. recce/data/checks/__next.checks.__PAGE__.txt +10 -0
  86. recce/data/checks/__next.checks.txt +4 -0
  87. recce/data/checks/index.html +2 -0
  88. recce/data/checks/index.txt +39 -0
  89. recce/data/index.html +2 -27
  90. recce/data/index.txt +32 -8
  91. recce/data/lineage/__next.@lineage.__DEFAULT__.txt +7 -0
  92. recce/data/lineage/__next._full.txt +39 -0
  93. recce/data/lineage/__next._head.txt +8 -0
  94. recce/data/lineage/__next._index.txt +14 -0
  95. recce/data/lineage/__next._tree.txt +8 -0
  96. recce/data/lineage/__next.lineage.__PAGE__.txt +10 -0
  97. recce/data/lineage/__next.lineage.txt +4 -0
  98. recce/data/lineage/index.html +2 -0
  99. recce/data/lineage/index.txt +39 -0
  100. recce/data/query/__next.@lineage.__DEFAULT__.txt +7 -0
  101. recce/data/query/__next._full.txt +37 -0
  102. recce/data/query/__next._head.txt +8 -0
  103. recce/data/query/__next._index.txt +14 -0
  104. recce/data/query/__next._tree.txt +8 -0
  105. recce/data/query/__next.query.__PAGE__.txt +9 -0
  106. recce/data/query/__next.query.txt +4 -0
  107. recce/data/query/index.html +2 -0
  108. recce/data/query/index.txt +37 -0
  109. recce/event/CONFIG.bak +1 -0
  110. recce/event/__init__.py +9 -8
  111. recce/event/collector.py +6 -2
  112. recce/event/track.py +10 -0
  113. recce/github.py +1 -1
  114. recce/mcp_server.py +725 -0
  115. recce/models/check.py +433 -15
  116. recce/models/types.py +61 -2
  117. recce/pull_request.py +1 -1
  118. recce/run.py +37 -17
  119. recce/server.py +216 -21
  120. recce/state/__init__.py +31 -0
  121. recce/state/cloud.py +644 -0
  122. recce/state/const.py +26 -0
  123. recce/state/local.py +56 -0
  124. recce/state/state.py +119 -0
  125. recce/state/state_loader.py +174 -0
  126. recce/summary.py +25 -3
  127. recce/tasks/dataframe.py +63 -1
  128. recce/tasks/query.py +40 -3
  129. recce/tasks/rowcount.py +4 -1
  130. recce/tasks/schema.py +4 -1
  131. recce/tasks/utils.py +147 -0
  132. recce/tasks/valuediff.py +85 -57
  133. recce/util/api_token.py +11 -2
  134. recce/util/breaking.py +10 -1
  135. recce/util/cll.py +1 -2
  136. recce/util/cloud/__init__.py +15 -0
  137. recce/util/cloud/base.py +115 -0
  138. recce/util/cloud/check_events.py +190 -0
  139. recce/util/cloud/checks.py +242 -0
  140. recce/util/io.py +2 -2
  141. recce/util/lineage.py +19 -18
  142. recce/util/perf_tracking.py +85 -0
  143. recce/util/recce_cloud.py +254 -5
  144. recce/util/startup_perf.py +121 -0
  145. recce/yaml/__init__.py +2 -2
  146. {recce_nightly-1.10.0.20250625.dist-info → recce_nightly-1.30.0.20251221.dist-info}/METADATA +91 -71
  147. recce_nightly-1.30.0.20251221.dist-info/RECORD +183 -0
  148. {recce_nightly-1.10.0.20250625.dist-info → recce_nightly-1.30.0.20251221.dist-info}/WHEEL +1 -2
  149. recce/data/_next/static/abCX3x3UoIdRLEDWxx4xd/_buildManifest.js +0 -1
  150. recce/data/_next/static/chunks/181-acc61ddada3bc0ca.js +0 -43
  151. recce/data/_next/static/chunks/1bff33f1-1ef85cf5e658a751.js +0 -1
  152. recce/data/_next/static/chunks/217-879a84d70f7a907c.js +0 -2
  153. recce/data/_next/static/chunks/29e3cc0d-60045b2e47aa3916.js +0 -1
  154. recce/data/_next/static/chunks/36e1c10d-8e7be4a6c1f6ab2d.js +0 -1
  155. recce/data/_next/static/chunks/3998a672-03adacad07b346ac.js +0 -1
  156. recce/data/_next/static/chunks/3a92ee20-1081c360214f9602.js +0 -1
  157. recce/data/_next/static/chunks/42-cd3c06533f5fd47c.js +0 -9
  158. recce/data/_next/static/chunks/450c323b-fd94e7ffaa4a5efa.js +0 -1
  159. recce/data/_next/static/chunks/47d8844f-929aed9b1c73a905.js +0 -1
  160. recce/data/_next/static/chunks/608-3b079b544e5d5f5e.js +0 -15
  161. recce/data/_next/static/chunks/6dc81886-adbfa45836061d79.js +0 -1
  162. recce/data/_next/static/chunks/7a8a3e83-edf6dc64b5d5f0a5.js +0 -1
  163. recce/data/_next/static/chunks/7f27ae6c-d5f0438edd5c2a5b.js +0 -1
  164. recce/data/_next/static/chunks/86730205-cfb14e3f051bab35.js +0 -1
  165. recce/data/_next/static/chunks/8d700b6a.8bb140898499c512.js +0 -1
  166. recce/data/_next/static/chunks/92-607cd1af83c41f43.js +0 -1
  167. recce/data/_next/static/chunks/9746af58-a42b7d169cacadf0.js +0 -1
  168. recce/data/_next/static/chunks/a30376cd-de84559016d7e133.js +0 -1
  169. recce/data/_next/static/chunks/app/_not-found/page-01ed58b7f971d311.js +0 -1
  170. recce/data/_next/static/chunks/app/layout-177a410a97e0d018.js +0 -1
  171. recce/data/_next/static/chunks/app/page-da6e046a8235dbfc.js +0 -1
  172. recce/data/_next/static/chunks/b63b1b3f-4282bdcf459e075c.js +0 -1
  173. recce/data/_next/static/chunks/bbda5537-9ec25eb1dd62348a.js +0 -1
  174. recce/data/_next/static/chunks/c132bf7d-08cb668a789d6afd.js +0 -1
  175. recce/data/_next/static/chunks/ce84277d-2e5d1d46910cf052.js +0 -1
  176. recce/data/_next/static/chunks/febdd86e-c6b525341634b860.js +0 -54
  177. recce/data/_next/static/chunks/fee69bc6-2dbccaf9b90474e6.js +0 -1
  178. recce/data/_next/static/chunks/framework-ded83d71b51ce901.js +0 -1
  179. recce/data/_next/static/chunks/main-app-39061b0166c47f55.js +0 -1
  180. recce/data/_next/static/chunks/main-b5b3ae20a1405261.js +0 -1
  181. recce/data/_next/static/chunks/pages/_app-437c455677d62394.js +0 -1
  182. recce/data/_next/static/chunks/pages/_error-e7650df18ca04bde.js +0 -1
  183. recce/data/_next/static/chunks/webpack-7b49d5ba7e3a434d.js +0 -1
  184. recce/data/_next/static/css/17a96168e3a9db13.css +0 -1
  185. recce/data/_next/static/css/1b121dc4d36aeb4d.css +0 -3
  186. recce/data/_next/static/css/35c6679a098e1e34.css +0 -1
  187. recce/data/_next/static/css/951e2e0eea2d4a5b.css +0 -14
  188. recce/data/_next/static/media/montserrat-cyrillic-800-normal.22628180.woff2 +0 -0
  189. recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.94a63aea.woff2 +0 -0
  190. recce/data/_next/static/media/montserrat-latin-800-normal.6f8fa298.woff2 +0 -0
  191. recce/data/_next/static/media/montserrat-latin-ext-800-normal.013b84f9.woff2 +0 -0
  192. recce/data/_next/static/media/montserrat-vietnamese-800-normal.c0035377.woff2 +0 -0
  193. recce/data/_next/static/media/reload-image.79aabb7d.svg +0 -4
  194. recce/state.py +0 -786
  195. recce_nightly-1.10.0.20250625.dist-info/RECORD +0 -154
  196. recce_nightly-1.10.0.20250625.dist-info/top_level.txt +0 -2
  197. tests/__init__.py +0 -0
  198. tests/adapter/__init__.py +0 -0
  199. tests/adapter/dbt_adapter/__init__.py +0 -0
  200. tests/adapter/dbt_adapter/conftest.py +0 -17
  201. tests/adapter/dbt_adapter/dbt_test_helper.py +0 -298
  202. tests/adapter/dbt_adapter/test_dbt_adapter.py +0 -25
  203. tests/adapter/dbt_adapter/test_dbt_cll.py +0 -384
  204. tests/adapter/dbt_adapter/test_selector.py +0 -202
  205. tests/tasks/__init__.py +0 -0
  206. tests/tasks/conftest.py +0 -4
  207. tests/tasks/test_histogram.py +0 -129
  208. tests/tasks/test_lineage.py +0 -55
  209. tests/tasks/test_preset_checks.py +0 -64
  210. tests/tasks/test_profile.py +0 -397
  211. tests/tasks/test_query.py +0 -151
  212. tests/tasks/test_row_count.py +0 -135
  213. tests/tasks/test_schema.py +0 -122
  214. tests/tasks/test_top_k.py +0 -77
  215. tests/tasks/test_valuediff.py +0 -85
  216. tests/test_cli.py +0 -133
  217. tests/test_config.py +0 -43
  218. tests/test_connect_to_cloud.py +0 -82
  219. tests/test_core.py +0 -29
  220. tests/test_dbt.py +0 -36
  221. tests/test_pull_request.py +0 -130
  222. tests/test_server.py +0 -104
  223. tests/test_state.py +0 -134
  224. tests/test_summary.py +0 -65
  225. /recce/data/_next/static/chunks/{polyfills-42372ed130431b0a.js → a6dad97d9634a72d.js} +0 -0
  226. /recce/data/_next/static/media/{montserrat-cyrillic-ext-800-normal.e6e0d8d0.woff → montserrat-cyrillic-ext-800-normal.a4fa76b5.woff} +0 -0
  227. /recce/data/_next/static/{abCX3x3UoIdRLEDWxx4xd → nX-Uz0AH6Tc6hIQUFGqaB}/_ssgManifest.js +0 -0
  228. {recce_nightly-1.10.0.20250625.dist-info → recce_nightly-1.30.0.20251221.dist-info}/entry_points.txt +0 -0
  229. {recce_nightly-1.10.0.20250625.dist-info → recce_nightly-1.30.0.20251221.dist-info}/licenses/LICENSE +0 -0
recce/tasks/utils.py ADDED
@@ -0,0 +1,147 @@
1
+ """Utility functions for task operations."""
2
+
3
+ from typing import List, Optional
4
+
5
+ from recce.tasks.dataframe import DataFrame
6
+
7
+
8
+ def strip_identifier_quotes(identifier: str) -> str:
9
+ """
10
+ Strip SQL identifier quotes from a column name.
11
+
12
+ Different databases use different quoting styles:
13
+ - Double quotes: "column" (PostgreSQL, Snowflake, etc.)
14
+ - Backticks: `column` (MySQL, BigQuery)
15
+ - Square brackets: [column] (SQL Server)
16
+
17
+ Args:
18
+ identifier: Column name that may be quoted
19
+
20
+ Returns:
21
+ Column name with quotes stripped
22
+
23
+ Examples:
24
+ >>> strip_identifier_quotes('"myColumn"')
25
+ 'myColumn'
26
+ >>> strip_identifier_quotes('`my_column`')
27
+ 'my_column'
28
+ >>> strip_identifier_quotes('[Column Name]')
29
+ 'Column Name'
30
+ >>> strip_identifier_quotes('regular_column')
31
+ 'regular_column'
32
+ """
33
+ if not identifier or len(identifier) < 2:
34
+ return identifier
35
+
36
+ # Check for double quotes
37
+ if identifier.startswith('"') and identifier.endswith('"'):
38
+ return identifier[1:-1]
39
+
40
+ # Check for backticks
41
+ if identifier.startswith("`") and identifier.endswith("`"):
42
+ return identifier[1:-1]
43
+
44
+ # Check for square brackets
45
+ if identifier.startswith("[") and identifier.endswith("]"):
46
+ return identifier[1:-1]
47
+
48
+ return identifier
49
+
50
+
51
+ def normalize_keys_to_columns(
52
+ keys: Optional[List[str]],
53
+ column_keys: List[str],
54
+ ) -> Optional[List[str]]:
55
+ """
56
+ Normalize user-provided keys to match actual column keys from the warehouse.
57
+
58
+ Different warehouses return column names in different cases:
59
+ - Snowflake: UPPERCASE (unless quoted)
60
+ - PostgreSQL/Redshift: lowercase (unless quoted)
61
+ - BigQuery: preserves original case
62
+
63
+ This function first attempts an exact match (for quoted columns that preserve
64
+ case), then falls back to case-insensitive matching to align user input
65
+ with the actual column keys returned by the warehouse.
66
+
67
+ Args:
68
+ keys: User-provided keys (e.g., primary_keys from params)
69
+ column_keys: Actual column keys from the query result
70
+
71
+ Returns:
72
+ List of keys normalized to match column_keys casing,
73
+ or None if keys is None.
74
+ If a key doesn't match any column, it's preserved as-is.
75
+
76
+ Examples:
77
+ >>> normalize_keys_to_columns(["payment_id"], ["PAYMENT_ID", "ORDER_ID"])
78
+ ["PAYMENT_ID"]
79
+
80
+ >>> normalize_keys_to_columns(["ID", "NAME"], ["id", "name", "value"])
81
+ ["id", "name"]
82
+
83
+ >>> normalize_keys_to_columns(["preCommitID"], ["preCommitID", "order_id"])
84
+ ["preCommitID"] # Exact match preserved for quoted columns
85
+
86
+ >>> normalize_keys_to_columns(['"customerID"'], ["customerID", "amount"])
87
+ ["customerID"] # Quotes stripped, then matched
88
+
89
+ >>> normalize_keys_to_columns(['`my_column`'], ["MY_COLUMN"])
90
+ ["MY_COLUMN"] # Backticks stripped, then case-insensitive match
91
+ """
92
+ if keys is None:
93
+ return None
94
+
95
+ # Strip quotes from all keys first - quotes are for SQL execution,
96
+ # but the frontend should receive unquoted column names
97
+ unquoted_keys = [strip_identifier_quotes(key) for key in keys]
98
+
99
+ if not column_keys:
100
+ return unquoted_keys
101
+
102
+ # Build both exact and case-insensitive lookup maps
103
+ exact_key_set = set(column_keys)
104
+ case_insensitive_map = {col.lower(): col for col in column_keys}
105
+
106
+ normalized = []
107
+ for key in unquoted_keys:
108
+ if key in exact_key_set:
109
+ # Exact match - use as-is (handles quoted columns that preserved case)
110
+ normalized.append(key)
111
+ else:
112
+ # Case-insensitive fallback
113
+ actual_key = case_insensitive_map.get(key.lower())
114
+ normalized.append(actual_key if actual_key is not None else key)
115
+
116
+ return normalized
117
+
118
+
119
+ def normalize_boolean_flag_columns(df: "DataFrame") -> "DataFrame":
120
+ """
121
+ Normalize boolean flag columns (in_a, in_b) to lowercase for cross-warehouse consistency.
122
+
123
+ Different warehouses return column names in different cases:
124
+ - Snowflake: IN_A, IN_B (UPPERCASE)
125
+ - PostgreSQL/Redshift: in_a, in_b (lowercase)
126
+ - BigQuery: preserves original case
127
+
128
+ This function ensures these columns are always lowercase in the DataFrame
129
+ sent to the frontend, enabling exact string matching.
130
+
131
+ Args:
132
+ df: DataFrame that may contain IN_A/IN_B columns
133
+
134
+ Returns:
135
+ DataFrame with in_a/in_b columns normalized to lowercase
136
+ """
137
+ from .dataframe import DataFrame, DataFrameColumn
138
+
139
+ normalized_columns = []
140
+ for col in df.columns:
141
+ key_upper = col.key.upper() if col.key else ""
142
+ if key_upper in ("IN_A", "IN_B"):
143
+ normalized_columns.append(DataFrameColumn(key=col.key.lower(), name=col.name.lower(), type=col.type))
144
+ else:
145
+ normalized_columns.append(col)
146
+
147
+ return DataFrame(columns=normalized_columns, data=df.data, limit=df.limit, more=df.more)
recce/tasks/valuediff.py CHANGED
@@ -7,6 +7,7 @@ from ..exceptions import RecceException
7
7
  from ..models import Check
8
8
  from .core import CheckValidator, Task, TaskResultDiffer
9
9
  from .dataframe import DataFrame
10
+ from .utils import normalize_boolean_flag_columns, normalize_keys_to_columns
10
11
 
11
12
 
12
13
  class ValueDiffParams(BaseModel):
@@ -91,6 +92,17 @@ class ValueDiffTask(Task, ValueDiffMixin):
91
92
  model: str,
92
93
  columns: List[str] = None,
93
94
  ):
95
+ """
96
+ Query value diff between base and current relations.
97
+ Compares column values between base and current relations using the primary key.
98
+ Mutates `self.params.primary_key` to normalize primary key names to match actual column names.
99
+
100
+ :param dbt_adapter: The dbt adapter instance.
101
+ :param primary_key: Single column name or list of column names for composite key.
102
+ :param model: The model name to compare.
103
+ :param columns: Optional list of columns to compare. If None, uses common columns.
104
+ :return: ValueDiffResult with summary and per-column match data, or None if invalid.
105
+ """
94
106
  import agate
95
107
 
96
108
  column_groups = {}
@@ -159,7 +171,7 @@ class ValueDiffTask(Task, ValueDiffMixin):
159
171
  match_status,
160
172
  count(*) as count_records
161
173
  from joined
162
- group by column_name, match_status
174
+ group by 1, 2
163
175
  )
164
176
 
165
177
  select
@@ -250,6 +262,16 @@ class ValueDiffTask(Task, ValueDiffMixin):
250
262
  column_types = [agate.Text(), agate.Number(), agate.Number()]
251
263
  table = agate.Table(row, column_names=column_names, column_types=column_types)
252
264
 
265
+ # Normalize primary_key to match actual column keys
266
+ # For ValueDiff, 'columns' refers to the model's column list (from metadata), not a DataFrame result.
267
+ composite = isinstance(primary_key, list)
268
+ if composite:
269
+ self.params.primary_key = normalize_keys_to_columns(primary_key, columns) # columns list from the model
270
+ else:
271
+ normalized = normalize_keys_to_columns([primary_key], columns)
272
+ if normalized:
273
+ self.params.primary_key = normalized[0]
274
+
253
275
  return ValueDiffResult(
254
276
  summary=ValueDiffResult.Summary(total=total, added=added, removed=removed),
255
277
  data=DataFrame.from_agate(table),
@@ -353,61 +375,53 @@ class ValueDiffDetailTask(Task, ValueDiffMixin):
353
375
  columns.insert(0, primary_key)
354
376
 
355
377
  sql_template = r"""
356
- with a_query as (
357
- select {{ columns | join(',\n') }} from {{ base_relation }}
358
- ),
359
-
360
- b_query as (
361
- select {{ columns | join(',\n') }} from {{ curr_relation }}
362
- ),
363
-
364
- a_intersect_b as (
365
- select * from a_query
366
- {{ dbt.intersect() }}
367
- select * from b_query
368
- ),
369
-
370
- a_except_b as (
371
- select * from a_query
372
- {{ dbt.except() }}
373
- select * from b_query
374
- ),
375
-
376
- b_except_a as (
377
- select * from b_query
378
- {{ dbt.except() }}
379
- select * from a_query
380
- ),
381
-
382
- all_records as (
383
- select
384
- *,
385
- true as in_a,
386
- true as in_b
387
- from a_intersect_b
388
-
389
- union all
390
-
391
- select
392
- *,
393
- true as in_a,
394
- false as in_b
395
- from a_except_b
396
-
397
- union all
398
-
399
- select
400
- *,
401
- false as in_a,
402
- true as in_b
403
- from b_except_a
404
- )
405
-
406
- select * from all_records
407
- where not (in_a and in_b)
408
- order by {{ primary_keys | join(',\n') }}, in_a desc, in_b desc
409
- limit {{ limit }}
410
- """
378
+ with a_query as (select {{ columns | join (',\n') }}
379
+ from {{ base_relation }}
380
+ ), b_query as (
381
+ select {{ columns | join (',\n') }}
382
+ from {{ curr_relation }}
383
+ ), a_intersect_b as (
384
+ select *
385
+ from a_query
386
+ {{ dbt.intersect() }}
387
+ select *
388
+ from b_query
389
+ ), a_except_b as (
390
+ select *
391
+ from a_query
392
+ {{ dbt.except() }}
393
+ select *
394
+ from b_query
395
+ ), b_except_a as (
396
+ select *
397
+ from b_query
398
+ {{ dbt.except() }}
399
+ select *
400
+ from a_query
401
+ ), all_records as (
402
+ select
403
+ *, true as in_a, true as in_b
404
+ from a_intersect_b
405
+
406
+ union all
407
+
408
+ select
409
+ *, true as in_a, false as in_b
410
+ from a_except_b
411
+
412
+ union all
413
+
414
+ select
415
+ *, false as in_a, true as in_b
416
+ from b_except_a
417
+ )
418
+
419
+ select *
420
+ from all_records
421
+ where not (in_a and in_b)
422
+ order by {{ primary_keys | join (',\n') }}, in_a desc, in_b desc
423
+ limit {{ limit }}
424
+ """
411
425
 
412
426
  sql = dbt_adapter.generate_sql(
413
427
  sql_template,
@@ -423,7 +437,21 @@ class ValueDiffDetailTask(Task, ValueDiffMixin):
423
437
  _, table = dbt_adapter.execute(sql, fetch=True)
424
438
  self.check_cancel()
425
439
 
426
- return DataFrame.from_agate(table)
440
+ result_df = DataFrame.from_agate(table)
441
+ # Normalize in_a/in_b columns to lowercase for cross-warehouse consistency
442
+ result_df = normalize_boolean_flag_columns(result_df)
443
+
444
+ # Normalize primary_key to match actual column keys from result
445
+ column_keys = [col.key for col in result_df.columns]
446
+ composite = isinstance(primary_key, list)
447
+ if composite:
448
+ self.params.primary_key = normalize_keys_to_columns(primary_key, column_keys)
449
+ else:
450
+ normalized = normalize_keys_to_columns([primary_key], column_keys)
451
+ if normalized:
452
+ self.params.primary_key = normalized[0]
453
+
454
+ return result_df
427
455
 
428
456
  def execute(self):
429
457
  from recce.adapter.dbt_adapter import DbtAdapter
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,11 +78,20 @@ 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
84
93
  for arg_key in old_select.args.keys() | new_select.args.keys():
85
- if arg_key in ["expressions", "with", "from"]:
94
+ if arg_key in ["expressions", "with", "from", "with_", "from_"]:
86
95
  continue
87
96
 
88
97
  if old_select.args.get(arg_key) != new_select.args.get(arg_key):
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
@@ -0,0 +1,15 @@
1
+ """
2
+ Recce Cloud API client modules.
3
+
4
+ This package provides modular access to Recce Cloud API endpoints.
5
+ """
6
+
7
+ from recce.util.cloud.base import CloudBase
8
+ from recce.util.cloud.check_events import CheckEventsCloud
9
+ from recce.util.cloud.checks import ChecksCloud
10
+
11
+ __all__ = [
12
+ "CloudBase",
13
+ "CheckEventsCloud",
14
+ "ChecksCloud",
15
+ ]
@@ -0,0 +1,115 @@
1
+ """
2
+ Base class for Recce Cloud API clients.
3
+
4
+ This module provides the common functionality shared across all cloud API clients.
5
+ """
6
+
7
+ import os
8
+ from typing import Dict, Optional
9
+
10
+ import requests
11
+
12
+ from recce.util.recce_cloud import (
13
+ DOCKER_INTERNAL_URL_PREFIX,
14
+ LOCALHOST_URL_PREFIX,
15
+ RECCE_CLOUD_API_HOST,
16
+ RecceCloudException,
17
+ )
18
+
19
+
20
+ class CloudBase:
21
+ """
22
+ Base class for Recce Cloud API operations.
23
+
24
+ Provides common functionality for making authenticated requests to the Recce Cloud API,
25
+ including request handling, error management, and Docker environment URL conversion.
26
+
27
+ Attributes:
28
+ token: Authentication token (API token or GitHub token)
29
+ token_type: Type of token ("api_token" or "github_token")
30
+ base_url: Base URL for API v1 endpoints
31
+ base_url_v2: Base URL for API v2 endpoints
32
+ """
33
+
34
+ def __init__(self, token: str):
35
+ """
36
+ Initialize the CloudBase client.
37
+
38
+ Args:
39
+ token: Authentication token for Recce Cloud API
40
+
41
+ Raises:
42
+ ValueError: If token is None
43
+ """
44
+ if token is None:
45
+ raise ValueError("Token cannot be None.")
46
+
47
+ self.token = token
48
+ self.token_type = "github_token" if token.startswith(("ghp_", "gho_", "ghu_", "ghs_", "ghr_")) else "api_token"
49
+ self.base_url = f"{RECCE_CLOUD_API_HOST}/api/v1"
50
+ self.base_url_v2 = f"{RECCE_CLOUD_API_HOST}/api/v2"
51
+
52
+ def _request(self, method: str, url: str, headers: Optional[Dict] = None, **kwargs):
53
+ """
54
+ Make an authenticated HTTP request to Recce Cloud API.
55
+
56
+ Args:
57
+ method: HTTP method (GET, POST, PATCH, DELETE, etc.)
58
+ url: Full URL for the request
59
+ headers: Optional additional headers
60
+ **kwargs: Additional arguments passed to requests.request
61
+
62
+ Returns:
63
+ Response object from requests library
64
+ """
65
+ headers = {
66
+ **(headers or {}),
67
+ "Authorization": f"Bearer {self.token}",
68
+ }
69
+ url = self._replace_localhost_with_docker_internal(url)
70
+ return requests.request(method, url, headers=headers, **kwargs)
71
+
72
+ @staticmethod
73
+ def _replace_localhost_with_docker_internal(url: str) -> Optional[str]:
74
+ """
75
+ Convert localhost URLs to docker internal URLs if running in Docker.
76
+
77
+ This is useful for local development when Recce is running inside a Docker container
78
+ and needs to access localhost services on the host machine.
79
+
80
+ Args:
81
+ url: URL that might contain localhost
82
+
83
+ Returns:
84
+ URL with localhost replaced by host.docker.internal if in Docker, otherwise original URL
85
+ """
86
+ if url is None:
87
+ return None
88
+
89
+ if (
90
+ os.environ.get("RECCE_SHARE_INSTANCE_ENV") == "docker"
91
+ or os.environ.get("RECCE_TASK_INSTANCE_ENV") == "docker"
92
+ or os.environ.get("RECCE_INSTANCE_ENV") == "docker"
93
+ ):
94
+ if url.startswith(LOCALHOST_URL_PREFIX):
95
+ return url.replace(LOCALHOST_URL_PREFIX, DOCKER_INTERNAL_URL_PREFIX)
96
+
97
+ return url
98
+
99
+ def _raise_for_status(self, response, message: str):
100
+ """
101
+ Raise RecceCloudException if the response status is not successful.
102
+
103
+ Args:
104
+ response: Response object from requests
105
+ message: Error message to include in the exception
106
+
107
+ Raises:
108
+ RecceCloudException: If response status code is not 2xx
109
+ """
110
+ if not response.ok:
111
+ raise RecceCloudException(
112
+ message=message,
113
+ reason=response.text,
114
+ status_code=response.status_code,
115
+ )