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.
- recce/VERSION +1 -1
- recce/__init__.py +5 -0
- recce/adapter/dbt_adapter/__init__.py +343 -245
- recce/apis/check_api.py +20 -14
- recce/apis/check_events_api.py +353 -0
- recce/apis/check_func.py +5 -5
- recce/apis/run_func.py +32 -3
- recce/artifact.py +76 -3
- recce/cli.py +705 -82
- recce/config.py +2 -2
- recce/connect_to_cloud.py +1 -1
- recce/core.py +3 -3
- recce/data/404/index.html +2 -0
- recce/data/404.html +2 -22
- recce/data/__next.@lineage.!KHNsb3Qp.__PAGE__.txt +7 -0
- recce/data/__next.@lineage.!KHNsb3Qp.txt +4 -0
- recce/data/__next.__PAGE__.txt +6 -0
- recce/data/__next._full.txt +32 -0
- recce/data/__next._head.txt +8 -0
- recce/data/__next._index.txt +14 -0
- recce/data/__next._tree.txt +8 -0
- recce/data/_next/static/chunks/025a7e3e3f9f40ae.js +1 -0
- recce/data/_next/static/chunks/0ce56d67ef5779ca.js +4 -0
- recce/data/_next/static/chunks/1a6a78780155dac7.js +48 -0
- recce/data/_next/static/chunks/1de8485918b9182a.css +2 -0
- recce/data/_next/static/chunks/1e4b1b50d1e34993.js +1 -0
- recce/data/_next/static/chunks/206d5d181e4c738e.js +1 -0
- recce/data/_next/static/chunks/2c357efc34c5b859.js +25 -0
- recce/data/_next/static/chunks/2e9d95d2d48c479c.js +1 -0
- recce/data/_next/static/chunks/2f016dc4a3edad2e.js +2 -0
- recce/data/_next/static/chunks/313251962d698f7c.js +1 -0
- recce/data/_next/static/chunks/3a9f021f38eb5574.css +1 -0
- recce/data/_next/static/chunks/40079da8d2b8f651.js +1 -0
- recce/data/_next/static/chunks/4599182bffb64661.js +38 -0
- recce/data/_next/static/chunks/4e62f6e184173580.js +1 -0
- recce/data/_next/static/chunks/5c4dfb0d09eaa401.js +1 -0
- recce/data/_next/static/chunks/69e4f06ccfdfc3ac.js +1 -0
- recce/data/_next/static/chunks/6b206cb4707d6bee.js +1 -0
- recce/data/_next/static/chunks/6d8557f062aa4386.css +1 -0
- recce/data/_next/static/chunks/7fbe3650bd83b6b5.js +1 -0
- recce/data/_next/static/chunks/83fa823a825674f6.js +1 -0
- recce/data/_next/static/chunks/848a6c9b5f55f7ed.js +1 -0
- recce/data/_next/static/chunks/859462b0858aef88.css +2 -0
- recce/data/_next/static/chunks/923964f18c87d0f1.css +1 -0
- recce/data/_next/static/chunks/939390f911895d7c.js +48 -0
- recce/data/_next/static/chunks/99a9817237a07f43.js +1 -0
- recce/data/_next/static/chunks/9fed8b4b2b924054.js +5 -0
- recce/data/_next/static/chunks/b6949f6c5892110c.js +1 -0
- recce/data/_next/static/chunks/b851a1d3f8149828.js +1 -0
- recce/data/_next/static/chunks/c734f9ad957de0b4.js +1 -0
- recce/data/_next/static/chunks/cdde321b0ec75717.js +2 -0
- recce/data/_next/static/chunks/d0f91117d77ff844.css +1 -0
- recce/data/_next/static/chunks/d6c8667911c2500f.js +1 -0
- recce/data/_next/static/chunks/da8dab68c02752cf.js +74 -0
- recce/data/_next/static/chunks/dc074049c9d12d97.js +109 -0
- recce/data/_next/static/chunks/ee7f1a8227342421.js +1 -0
- recce/data/_next/static/chunks/fa2f4e56c2fccc73.js +1 -0
- recce/data/_next/static/chunks/turbopack-1fad664f62979b93.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/_next/static/nX-Uz0AH6Tc6hIQUFGqaB/_buildManifest.js +11 -0
- recce/data/_next/static/nX-Uz0AH6Tc6hIQUFGqaB/_clientMiddlewareManifest.json +1 -0
- recce/data/_not-found/__next._full.txt +24 -0
- recce/data/_not-found/__next._head.txt +8 -0
- recce/data/_not-found/__next._index.txt +13 -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 +6 -0
- recce/data/_not-found/index.html +2 -0
- recce/data/_not-found/index.txt +24 -0
- recce/data/auth_callback.html +1 -1
- recce/data/checks/__next.@lineage.__DEFAULT__.txt +7 -0
- recce/data/checks/__next._full.txt +39 -0
- recce/data/checks/__next._head.txt +8 -0
- recce/data/checks/__next._index.txt +14 -0
- recce/data/checks/__next._tree.txt +8 -0
- recce/data/checks/__next.checks.__PAGE__.txt +10 -0
- recce/data/checks/__next.checks.txt +4 -0
- recce/data/checks/index.html +2 -0
- recce/data/checks/index.txt +39 -0
- recce/data/index.html +2 -27
- recce/data/index.txt +32 -8
- recce/data/lineage/__next.@lineage.__DEFAULT__.txt +7 -0
- recce/data/lineage/__next._full.txt +39 -0
- recce/data/lineage/__next._head.txt +8 -0
- recce/data/lineage/__next._index.txt +14 -0
- recce/data/lineage/__next._tree.txt +8 -0
- recce/data/lineage/__next.lineage.__PAGE__.txt +10 -0
- recce/data/lineage/__next.lineage.txt +4 -0
- recce/data/lineage/index.html +2 -0
- recce/data/lineage/index.txt +39 -0
- recce/data/query/__next.@lineage.__DEFAULT__.txt +7 -0
- recce/data/query/__next._full.txt +37 -0
- recce/data/query/__next._head.txt +8 -0
- recce/data/query/__next._index.txt +14 -0
- recce/data/query/__next._tree.txt +8 -0
- recce/data/query/__next.query.__PAGE__.txt +9 -0
- recce/data/query/__next.query.txt +4 -0
- recce/data/query/index.html +2 -0
- recce/data/query/index.txt +37 -0
- recce/event/CONFIG.bak +1 -0
- recce/event/__init__.py +9 -8
- recce/event/collector.py +6 -2
- recce/event/track.py +10 -0
- recce/github.py +1 -1
- recce/mcp_server.py +725 -0
- recce/models/check.py +433 -15
- recce/models/types.py +61 -2
- recce/pull_request.py +1 -1
- recce/run.py +37 -17
- recce/server.py +216 -21
- recce/state/__init__.py +31 -0
- recce/state/cloud.py +644 -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 +25 -3
- recce/tasks/dataframe.py +63 -1
- recce/tasks/query.py +40 -3
- recce/tasks/rowcount.py +4 -1
- recce/tasks/schema.py +4 -1
- recce/tasks/utils.py +147 -0
- recce/tasks/valuediff.py +85 -57
- recce/util/api_token.py +11 -2
- recce/util/breaking.py +10 -1
- recce/util/cll.py +1 -2
- recce/util/cloud/__init__.py +15 -0
- recce/util/cloud/base.py +115 -0
- recce/util/cloud/check_events.py +190 -0
- recce/util/cloud/checks.py +242 -0
- recce/util/io.py +2 -2
- recce/util/lineage.py +19 -18
- recce/util/perf_tracking.py +85 -0
- recce/util/recce_cloud.py +254 -5
- recce/util/startup_perf.py +121 -0
- recce/yaml/__init__.py +2 -2
- {recce_nightly-1.10.0.20250625.dist-info → recce_nightly-1.30.0.20251221.dist-info}/METADATA +91 -71
- recce_nightly-1.30.0.20251221.dist-info/RECORD +183 -0
- {recce_nightly-1.10.0.20250625.dist-info → recce_nightly-1.30.0.20251221.dist-info}/WHEEL +1 -2
- recce/data/_next/static/abCX3x3UoIdRLEDWxx4xd/_buildManifest.js +0 -1
- recce/data/_next/static/chunks/181-acc61ddada3bc0ca.js +0 -43
- recce/data/_next/static/chunks/1bff33f1-1ef85cf5e658a751.js +0 -1
- recce/data/_next/static/chunks/217-879a84d70f7a907c.js +0 -2
- recce/data/_next/static/chunks/29e3cc0d-60045b2e47aa3916.js +0 -1
- recce/data/_next/static/chunks/36e1c10d-8e7be4a6c1f6ab2d.js +0 -1
- recce/data/_next/static/chunks/3998a672-03adacad07b346ac.js +0 -1
- recce/data/_next/static/chunks/3a92ee20-1081c360214f9602.js +0 -1
- recce/data/_next/static/chunks/42-cd3c06533f5fd47c.js +0 -9
- recce/data/_next/static/chunks/450c323b-fd94e7ffaa4a5efa.js +0 -1
- recce/data/_next/static/chunks/47d8844f-929aed9b1c73a905.js +0 -1
- recce/data/_next/static/chunks/608-3b079b544e5d5f5e.js +0 -15
- recce/data/_next/static/chunks/6dc81886-adbfa45836061d79.js +0 -1
- recce/data/_next/static/chunks/7a8a3e83-edf6dc64b5d5f0a5.js +0 -1
- recce/data/_next/static/chunks/7f27ae6c-d5f0438edd5c2a5b.js +0 -1
- recce/data/_next/static/chunks/86730205-cfb14e3f051bab35.js +0 -1
- recce/data/_next/static/chunks/8d700b6a.8bb140898499c512.js +0 -1
- recce/data/_next/static/chunks/92-607cd1af83c41f43.js +0 -1
- recce/data/_next/static/chunks/9746af58-a42b7d169cacadf0.js +0 -1
- recce/data/_next/static/chunks/a30376cd-de84559016d7e133.js +0 -1
- recce/data/_next/static/chunks/app/_not-found/page-01ed58b7f971d311.js +0 -1
- recce/data/_next/static/chunks/app/layout-177a410a97e0d018.js +0 -1
- recce/data/_next/static/chunks/app/page-da6e046a8235dbfc.js +0 -1
- recce/data/_next/static/chunks/b63b1b3f-4282bdcf459e075c.js +0 -1
- recce/data/_next/static/chunks/bbda5537-9ec25eb1dd62348a.js +0 -1
- recce/data/_next/static/chunks/c132bf7d-08cb668a789d6afd.js +0 -1
- recce/data/_next/static/chunks/ce84277d-2e5d1d46910cf052.js +0 -1
- recce/data/_next/static/chunks/febdd86e-c6b525341634b860.js +0 -54
- recce/data/_next/static/chunks/fee69bc6-2dbccaf9b90474e6.js +0 -1
- recce/data/_next/static/chunks/framework-ded83d71b51ce901.js +0 -1
- recce/data/_next/static/chunks/main-app-39061b0166c47f55.js +0 -1
- recce/data/_next/static/chunks/main-b5b3ae20a1405261.js +0 -1
- recce/data/_next/static/chunks/pages/_app-437c455677d62394.js +0 -1
- recce/data/_next/static/chunks/pages/_error-e7650df18ca04bde.js +0 -1
- recce/data/_next/static/chunks/webpack-7b49d5ba7e3a434d.js +0 -1
- recce/data/_next/static/css/17a96168e3a9db13.css +0 -1
- recce/data/_next/static/css/1b121dc4d36aeb4d.css +0 -3
- recce/data/_next/static/css/35c6679a098e1e34.css +0 -1
- recce/data/_next/static/css/951e2e0eea2d4a5b.css +0 -14
- 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/data/_next/static/media/reload-image.79aabb7d.svg +0 -4
- recce/state.py +0 -786
- recce_nightly-1.10.0.20250625.dist-info/RECORD +0 -154
- recce_nightly-1.10.0.20250625.dist-info/top_level.txt +0 -2
- tests/__init__.py +0 -0
- tests/adapter/__init__.py +0 -0
- tests/adapter/dbt_adapter/__init__.py +0 -0
- tests/adapter/dbt_adapter/conftest.py +0 -17
- tests/adapter/dbt_adapter/dbt_test_helper.py +0 -298
- tests/adapter/dbt_adapter/test_dbt_adapter.py +0 -25
- tests/adapter/dbt_adapter/test_dbt_cll.py +0 -384
- tests/adapter/dbt_adapter/test_selector.py +0 -202
- tests/tasks/__init__.py +0 -0
- tests/tasks/conftest.py +0 -4
- tests/tasks/test_histogram.py +0 -129
- tests/tasks/test_lineage.py +0 -55
- tests/tasks/test_preset_checks.py +0 -64
- tests/tasks/test_profile.py +0 -397
- tests/tasks/test_query.py +0 -151
- tests/tasks/test_row_count.py +0 -135
- tests/tasks/test_schema.py +0 -122
- tests/tasks/test_top_k.py +0 -77
- tests/tasks/test_valuediff.py +0 -85
- tests/test_cli.py +0 -133
- tests/test_config.py +0 -43
- tests/test_connect_to_cloud.py +0 -82
- tests/test_core.py +0 -29
- tests/test_dbt.py +0 -36
- tests/test_pull_request.py +0 -130
- tests/test_server.py +0 -104
- tests/test_state.py +0 -134
- tests/test_summary.py +0 -65
- /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/{abCX3x3UoIdRLEDWxx4xd → nX-Uz0AH6Tc6hIQUFGqaB}/_ssgManifest.js +0 -0
- {recce_nightly-1.10.0.20250625.dist-info → recce_nightly-1.30.0.20251221.dist-info}/entry_points.txt +0 -0
- {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
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
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(
|
|
20
|
+
console.print("Please associate with your Recce Cloud account by the following command 'recce connect-to-cloud'.")
|
|
21
|
+
console.print(
|
|
22
|
+
"For more information, please visit: https://docs.reccehq.com/recce-cloud/share-recce-session-securely/#configure-recce-cloud-association-manually"
|
|
23
|
+
)
|
|
21
24
|
|
|
22
25
|
|
|
23
26
|
def prepare_api_token(
|
|
@@ -30,7 +33,13 @@ def prepare_api_token(
|
|
|
30
33
|
# Verify the API token for Recce Cloud Share Link
|
|
31
34
|
api_token = get_recce_api_token()
|
|
32
35
|
new_api_token = kwargs.get("api_token")
|
|
33
|
-
if
|
|
36
|
+
if new_api_token is not None and new_api_token.startswith("rct-"):
|
|
37
|
+
# Task Token
|
|
38
|
+
valid = RecceCloud(new_api_token).verify_token()
|
|
39
|
+
if not valid:
|
|
40
|
+
raise RecceConfigException("Invalid Recce Cloud Task token")
|
|
41
|
+
api_token = new_api_token
|
|
42
|
+
elif api_token != new_api_token and new_api_token is not None:
|
|
34
43
|
# Handle the API token provided by option `--api-token`
|
|
35
44
|
valid = RecceCloud(new_api_token).verify_token()
|
|
36
45
|
if not valid:
|
recce/util/breaking.py
CHANGED
|
@@ -78,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
|
|
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
|
+
]
|
recce/util/cloud/base.py
ADDED
|
@@ -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
|
+
)
|