recce-nightly 0.62.0.20250417__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 +27 -22
- recce/adapter/base.py +11 -14
- recce/adapter/dbt_adapter/__init__.py +845 -461
- recce/adapter/dbt_adapter/dbt_version.py +3 -0
- recce/adapter/sqlmesh_adapter.py +24 -35
- recce/apis/check_api.py +59 -42
- recce/apis/check_events_api.py +353 -0
- recce/apis/check_func.py +41 -35
- recce/apis/run_api.py +25 -19
- recce/apis/run_func.py +64 -25
- recce/artifact.py +119 -51
- recce/cli.py +1301 -324
- recce/config.py +43 -34
- recce/connect_to_cloud.py +138 -0
- recce/core.py +55 -47
- recce/data/404/index.html +2 -0
- recce/data/404.html +2 -1
- 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.f9d58125.woff +0 -0
- recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.076c2a93.woff2 +0 -0
- recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.a4fa76b5.woff +0 -0
- recce/data/_next/static/media/montserrat-latin-800-normal.cde454cc.woff2 +0 -0
- recce/data/_next/static/media/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.b671449b.woff +0 -0
- recce/data/_next/static/media/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 +68 -0
- 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/imgs/reload-image.svg +4 -0
- recce/data/index.html +2 -27
- recce/data/index.txt +32 -7
- 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/diff.py +6 -12
- recce/event/CONFIG.bak +1 -0
- recce/event/__init__.py +86 -74
- recce/event/collector.py +33 -22
- recce/event/track.py +49 -27
- recce/exceptions.py +1 -1
- recce/git.py +7 -7
- recce/github.py +57 -53
- recce/mcp_server.py +725 -0
- recce/models/__init__.py +4 -1
- recce/models/check.py +438 -21
- recce/models/run.py +1 -0
- recce/models/types.py +134 -28
- recce/pull_request.py +27 -25
- recce/run.py +179 -122
- recce/server.py +394 -104
- 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 +196 -149
- recce/tasks/__init__.py +19 -3
- recce/tasks/core.py +11 -13
- recce/tasks/dataframe.py +82 -18
- recce/tasks/histogram.py +69 -34
- recce/tasks/lineage.py +2 -2
- recce/tasks/profile.py +152 -86
- recce/tasks/query.py +180 -89
- recce/tasks/rowcount.py +37 -31
- recce/tasks/schema.py +18 -15
- recce/tasks/top_k.py +35 -35
- recce/tasks/utils.py +147 -0
- recce/tasks/valuediff.py +247 -155
- recce/util/__init__.py +3 -0
- recce/util/api_token.py +80 -0
- recce/util/breaking.py +105 -100
- recce/util/cll.py +274 -219
- 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 +22 -17
- recce/util/lineage.py +65 -16
- recce/util/logger.py +1 -1
- recce/util/onboarding_state.py +45 -0
- recce/util/perf_tracking.py +85 -0
- recce/util/recce_cloud.py +347 -72
- recce/util/singleton.py +4 -4
- recce/util/startup_perf.py +121 -0
- recce/yaml/__init__.py +7 -10
- recce_nightly-1.30.0.20251221.dist-info/METADATA +195 -0
- recce_nightly-1.30.0.20251221.dist-info/RECORD +183 -0
- {recce_nightly-0.62.0.20250417.dist-info → recce_nightly-1.30.0.20251221.dist-info}/WHEEL +1 -2
- recce/data/_next/static/chunks/1f229bf6-d9fe92e56db8d93b.js +0 -1
- recce/data/_next/static/chunks/29e3cc0d-8c150e37dff9631b.js +0 -1
- recce/data/_next/static/chunks/36e1c10d-bb0210cbd6573a8d.js +0 -1
- recce/data/_next/static/chunks/3998a672-eaad84bdd88cc73e.js +0 -1
- recce/data/_next/static/chunks/450c323b-1bb5db526e54435a.js +0 -1
- recce/data/_next/static/chunks/47d8844f-79a1b53c66a7d7ec.js +0 -1
- recce/data/_next/static/chunks/500-e51c92a025a51234.js +0 -65
- recce/data/_next/static/chunks/6dc81886-c94b9b91bc2c3caf.js +0 -1
- recce/data/_next/static/chunks/700-3b65fc3666820d00.js +0 -2
- recce/data/_next/static/chunks/7a8a3e83-d7fa409d97b38b2b.js +0 -1
- recce/data/_next/static/chunks/7f27ae6c-413f6b869a04183a.js +0 -1
- recce/data/_next/static/chunks/9746af58-d74bef4d03eea6ab.js +0 -1
- recce/data/_next/static/chunks/a30376cd-7d806e1602f2dc3a.js +0 -1
- recce/data/_next/static/chunks/app/_not-found/page-8a886fa0855c3105.js +0 -1
- recce/data/_next/static/chunks/app/layout-9102e22cb73f74d6.js +0 -1
- recce/data/_next/static/chunks/app/page-9adc25782272ed2e.js +0 -1
- recce/data/_next/static/chunks/b63b1b3f-7395c74e11a14e95.js +0 -1
- recce/data/_next/static/chunks/c132bf7d-8102037f9ccf372a.js +0 -1
- recce/data/_next/static/chunks/c1ceaa8b-a1e442154d23515e.js +0 -1
- recce/data/_next/static/chunks/cd9f8d63-cf0d5a7b0f7a92e8.js +0 -54
- recce/data/_next/static/chunks/ce84277d-f42c2c58049cea2d.js +0 -1
- recce/data/_next/static/chunks/e24bf851-0f8cbc99656833e7.js +0 -1
- recce/data/_next/static/chunks/fee69bc6-f17d36c080742e74.js +0 -1
- recce/data/_next/static/chunks/framework-ded83d71b51ce901.js +0 -1
- recce/data/_next/static/chunks/main-a0859f1f36d0aa6c.js +0 -1
- recce/data/_next/static/chunks/main-app-0225a2255968e566.js +0 -1
- recce/data/_next/static/chunks/pages/_app-d5672bf3d8b6371b.js +0 -1
- recce/data/_next/static/chunks/pages/_error-ed75be3f25588548.js +0 -1
- recce/data/_next/static/chunks/webpack-567d72f0bc0820d5.js +0 -1
- recce/data/_next/static/css/c9ecb46a4b21c126.css +0 -14
- recce/data/_next/static/media/montserrat-cyrillic-800-normal.22628180.woff2 +0 -0
- recce/data/_next/static/media/montserrat-cyrillic-800-normal.31d693bb.woff +0 -0
- recce/data/_next/static/media/montserrat-cyrillic-ext-800-normal.7e2c1e62.woff +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-800-normal.97e20d5e.woff +0 -0
- recce/data/_next/static/media/montserrat-latin-ext-800-normal.013b84f9.woff2 +0 -0
- recce/data/_next/static/media/montserrat-latin-ext-800-normal.aff52ab0.woff +0 -0
- recce/data/_next/static/media/montserrat-vietnamese-800-normal.5f21869b.woff +0 -0
- recce/data/_next/static/media/montserrat-vietnamese-800-normal.c0035377.woff2 +0 -0
- recce/data/_next/static/qiyFlux77VkhxiceAJe_F/_buildManifest.js +0 -1
- recce/state.py +0 -753
- recce_nightly-0.62.0.20250417.dist-info/METADATA +0 -311
- recce_nightly-0.62.0.20250417.dist-info/RECORD +0 -139
- recce_nightly-0.62.0.20250417.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 -13
- tests/adapter/dbt_adapter/dbt_test_helper.py +0 -283
- tests/adapter/dbt_adapter/test_dbt_adapter.py +0 -40
- tests/adapter/dbt_adapter/test_dbt_cll.py +0 -102
- tests/adapter/dbt_adapter/test_selector.py +0 -177
- tests/tasks/__init__.py +0 -0
- tests/tasks/conftest.py +0 -4
- tests/tasks/test_histogram.py +0 -137
- tests/tasks/test_lineage.py +0 -42
- tests/tasks/test_preset_checks.py +0 -50
- tests/tasks/test_profile.py +0 -73
- tests/tasks/test_query.py +0 -151
- tests/tasks/test_row_count.py +0 -116
- tests/tasks/test_schema.py +0 -99
- tests/tasks/test_top_k.py +0 -73
- tests/tasks/test_valuediff.py +0 -74
- tests/test_cli.py +0 -122
- tests/test_config.py +0 -45
- tests/test_core.py +0 -27
- tests/test_dbt.py +0 -36
- tests/test_pull_request.py +0 -130
- tests/test_server.py +0 -98
- tests/test_state.py +0 -123
- tests/test_summary.py +0 -57
- /recce/data/_next/static/chunks/{polyfills-42372ed130431b0a.js → a6dad97d9634a72d.js} +0 -0
- /recce/data/_next/static/{qiyFlux77VkhxiceAJe_F → nX-Uz0AH6Tc6hIQUFGqaB}/_ssgManifest.js +0 -0
- {recce_nightly-0.62.0.20250417.dist-info → recce_nightly-1.30.0.20251221.dist-info}/entry_points.txt +0 -0
- {recce_nightly-0.62.0.20250417.dist-info → recce_nightly-1.30.0.20251221.dist-info}/licenses/LICENSE +0 -0
recce/tasks/valuediff.py
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
from typing import
|
|
1
|
+
from typing import List, Optional, TypedDict, Union
|
|
2
2
|
|
|
3
3
|
from pydantic import BaseModel
|
|
4
4
|
|
|
5
|
-
from .core import Task, TaskResultDiffer, CheckValidator
|
|
6
|
-
from .dataframe import DataFrame
|
|
7
5
|
from ..core import default_context
|
|
8
6
|
from ..exceptions import RecceException
|
|
9
7
|
from ..models import Check
|
|
8
|
+
from .core import CheckValidator, Task, TaskResultDiffer
|
|
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):
|
|
@@ -26,19 +27,6 @@ class ValueDiffResult(BaseModel):
|
|
|
26
27
|
|
|
27
28
|
|
|
28
29
|
class ValueDiffMixin:
|
|
29
|
-
def _verify_dbt_packages_deps(self, dbt_adapter):
|
|
30
|
-
for macro_name, macro in dbt_adapter.manifest.macros.items():
|
|
31
|
-
if macro.package_name == 'audit_helper':
|
|
32
|
-
break
|
|
33
|
-
else:
|
|
34
|
-
raise RecceException(
|
|
35
|
-
r"Package 'audit_helper' not found. Please refer to the link to install: https://hub.getdbt.com/dbt-labs/audit_helper/")
|
|
36
|
-
|
|
37
|
-
for macro_name, macro in dbt_adapter.manifest.macros.items():
|
|
38
|
-
if macro.package_name == 'dbt_utils' and macro.name == 'generate_surrogate_key':
|
|
39
|
-
self.legacy_surrogate_key = False
|
|
40
|
-
break
|
|
41
|
-
|
|
42
30
|
def _verify_primary_key(self, dbt_adapter, primary_key: Union[str, List[str]], model: str):
|
|
43
31
|
self.update_progress(message=f"Verify primary key: {primary_key}")
|
|
44
32
|
composite = True if isinstance(primary_key, List) else False
|
|
@@ -46,7 +34,21 @@ class ValueDiffMixin:
|
|
|
46
34
|
if composite:
|
|
47
35
|
if len(primary_key) == 0:
|
|
48
36
|
raise RecceException("Primary key cannot be empty")
|
|
49
|
-
sql_template = r"""
|
|
37
|
+
sql_template = r"""
|
|
38
|
+
{%- set column_list = primary_key %}
|
|
39
|
+
{%- set columns_csv = column_list | join(', ') %}
|
|
40
|
+
|
|
41
|
+
with validation_errors as (
|
|
42
|
+
select
|
|
43
|
+
{{ columns_csv }}
|
|
44
|
+
from {{ relation }}
|
|
45
|
+
group by {{ columns_csv }}
|
|
46
|
+
having count(*) > 1
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
select *
|
|
50
|
+
from validation_errors
|
|
51
|
+
"""
|
|
50
52
|
else:
|
|
51
53
|
if primary_key is None or len(primary_key) == 0:
|
|
52
54
|
raise RecceException("Primary key cannot be empty")
|
|
@@ -54,7 +56,6 @@ class ValueDiffMixin:
|
|
|
54
56
|
|
|
55
57
|
# check primary keys
|
|
56
58
|
for base in [True, False]:
|
|
57
|
-
|
|
58
59
|
relation = dbt_adapter.create_relation(model, base)
|
|
59
60
|
context = dict(
|
|
60
61
|
relation=relation,
|
|
@@ -69,31 +70,47 @@ class ValueDiffMixin:
|
|
|
69
70
|
invalids = row[0]
|
|
70
71
|
if invalids > 0:
|
|
71
72
|
raise RecceException(
|
|
72
|
-
f"Invalid primary key: \"{primary_key}\". The column should be unique. Please check by this sql: '{sql}'"
|
|
73
|
+
f"Invalid primary key: \"{primary_key}\". The column should be unique. Please check by this sql: '{sql}'"
|
|
74
|
+
)
|
|
73
75
|
break
|
|
74
76
|
else:
|
|
75
77
|
# it will never happen unless we use a wrong check sql
|
|
76
|
-
raise RecceException(
|
|
78
|
+
raise RecceException("Cannot verify primary key")
|
|
77
79
|
|
|
78
80
|
|
|
79
81
|
class ValueDiffTask(Task, ValueDiffMixin):
|
|
80
|
-
|
|
81
82
|
def __init__(self, params):
|
|
82
83
|
super().__init__()
|
|
83
84
|
self.params = ValueDiffParams(**params)
|
|
84
85
|
self.connection = None
|
|
85
86
|
self.legacy_surrogate_key = True
|
|
86
87
|
|
|
87
|
-
def _query_value_diff(
|
|
88
|
-
|
|
88
|
+
def _query_value_diff(
|
|
89
|
+
self,
|
|
90
|
+
dbt_adapter,
|
|
91
|
+
primary_key: Union[str, List[str]],
|
|
92
|
+
model: str,
|
|
93
|
+
columns: List[str] = None,
|
|
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
|
+
"""
|
|
89
106
|
import agate
|
|
90
107
|
|
|
91
108
|
column_groups = {}
|
|
92
109
|
composite = True if isinstance(primary_key, List) else False
|
|
93
110
|
|
|
94
111
|
if columns is None or len(columns) == 0:
|
|
95
|
-
base_columns = [column.column for column in
|
|
96
|
-
curr_columns = [column.column for column in
|
|
112
|
+
base_columns = [column.column for column in dbt_adapter.get_columns(model, base=True)]
|
|
113
|
+
curr_columns = [column.column for column in dbt_adapter.get_columns(model, base=False)]
|
|
97
114
|
columns = [column for column in base_columns if column in curr_columns]
|
|
98
115
|
completed = 0
|
|
99
116
|
|
|
@@ -106,81 +123,117 @@ class ValueDiffTask(Task, ValueDiffMixin):
|
|
|
106
123
|
columns.insert(0, primary_key)
|
|
107
124
|
|
|
108
125
|
sql_template = r"""
|
|
109
|
-
{
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
) }
|
|
123
|
-
|
|
126
|
+
{%- set default_null_value = "_recce_surrogate_key_null_" -%}
|
|
127
|
+
{%- set fields = [] -%}
|
|
128
|
+
|
|
129
|
+
{%- for field in primary_keys -%}
|
|
130
|
+
{%- do fields.append(
|
|
131
|
+
"coalesce(cast(" ~ field ~ " as " ~ dbt.type_string() ~ "), '" ~ default_null_value ~"')"
|
|
132
|
+
) -%}
|
|
133
|
+
|
|
134
|
+
{%- if not loop.last %}
|
|
135
|
+
{%- do fields.append("'-'") -%}
|
|
136
|
+
{%- endif -%}
|
|
137
|
+
{%- endfor -%}
|
|
138
|
+
|
|
139
|
+
{%- set _pk = dbt.hash(dbt.concat(fields)) -%}
|
|
140
|
+
|
|
141
|
+
with a_query as (
|
|
142
|
+
select {{ _pk }} as _pk, * from {{ base_relation }}
|
|
143
|
+
),
|
|
144
|
+
|
|
145
|
+
b_query as (
|
|
146
|
+
select {{ _pk }} as _pk, * from {{ curr_relation }}
|
|
147
|
+
),
|
|
148
|
+
|
|
149
|
+
joined as (
|
|
150
|
+
select
|
|
151
|
+
coalesce(a_query._pk, b_query._pk) as _pk,
|
|
152
|
+
a_query.{{ column_to_compare }} as a_query_value,
|
|
153
|
+
b_query.{{ column_to_compare }} as b_query_value,
|
|
154
|
+
case
|
|
155
|
+
when a_query.{{ column_to_compare }} = b_query.{{ column_to_compare }} then 'perfect match'
|
|
156
|
+
when a_query.{{ column_to_compare }} is null and b_query.{{ column_to_compare }} is null then 'both are null'
|
|
157
|
+
when a_query._pk is null then 'missing from {{ a_relation_name }}'
|
|
158
|
+
when b_query._pk is null then 'missing from {{ b_relation_name }}'
|
|
159
|
+
when a_query.{{ column_to_compare }} is null then 'value is null in {{ a_relation_name }} only'
|
|
160
|
+
when b_query.{{ column_to_compare }} is null then 'value is null in {{ b_relation_name }} only'
|
|
161
|
+
when a_query.{{ column_to_compare }} != b_query.{{ column_to_compare }} then 'values do not match'
|
|
162
|
+
else 'unknown' -- this should never happen
|
|
163
|
+
end as match_status
|
|
164
|
+
from a_query
|
|
165
|
+
full outer join b_query on a_query._pk = b_query._pk
|
|
166
|
+
),
|
|
167
|
+
|
|
168
|
+
aggregated as (
|
|
169
|
+
select
|
|
170
|
+
'{{ column_to_compare }}' as column_name,
|
|
171
|
+
match_status,
|
|
172
|
+
count(*) as count_records
|
|
173
|
+
from joined
|
|
174
|
+
group by 1, 2
|
|
175
|
+
)
|
|
124
176
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
sql_template = sql_template.replace('__PRIMARY_KEY__', new_primary_key)
|
|
177
|
+
select
|
|
178
|
+
column_name,
|
|
179
|
+
match_status,
|
|
180
|
+
count_records,
|
|
181
|
+
round(100.0 * count_records / sum(count_records) over (), 2) as percent_of_total
|
|
182
|
+
from aggregated
|
|
183
|
+
"""
|
|
133
184
|
|
|
134
185
|
for column in columns:
|
|
135
186
|
self.update_progress(message=f"Diff column: {column}", percentage=completed / len(columns))
|
|
136
187
|
|
|
137
|
-
sql =
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
188
|
+
sql = dbt_adapter.generate_sql(
|
|
189
|
+
sql_template,
|
|
190
|
+
context=dict(
|
|
191
|
+
base_relation=dbt_adapter.create_relation(model, base=True),
|
|
192
|
+
curr_relation=dbt_adapter.create_relation(model, base=False),
|
|
193
|
+
primary_keys=primary_key if composite else [primary_key],
|
|
194
|
+
column_to_compare=column,
|
|
195
|
+
),
|
|
196
|
+
)
|
|
143
197
|
|
|
144
|
-
_, table =
|
|
198
|
+
_, table = dbt_adapter.execute(sql, fetch=True)
|
|
199
|
+
if column not in column_groups:
|
|
200
|
+
column_groups[column] = dict(added=0, removed=0, mismatched=0, matched=0)
|
|
145
201
|
for row in table.rows:
|
|
146
202
|
# data example:
|
|
147
203
|
# ('COLUMN_NAME', 'MATCH_STATUS', 'COUNT_RECORDS', 'PERCENT_OF_TOTAL')
|
|
148
|
-
# ('EVENT_ID', '
|
|
204
|
+
# ('EVENT_ID', 'perfect match', 158601510, Decimal('100.00'))
|
|
149
205
|
column_name, column_state, row_count, total_rate = row
|
|
150
|
-
if
|
|
206
|
+
if "column_name" == row[0].lower():
|
|
151
207
|
# skip column names
|
|
152
208
|
return
|
|
153
209
|
|
|
154
|
-
#
|
|
155
210
|
# sample data like this:
|
|
156
211
|
# https://github.com/dbt-labs/dbt-audit-helper/blob/main/macros/compare_column_values.sql
|
|
157
212
|
#
|
|
158
|
-
# '
|
|
159
|
-
# '
|
|
160
|
-
# '
|
|
161
|
-
# '
|
|
162
|
-
# '
|
|
163
|
-
# '
|
|
164
|
-
# '
|
|
165
|
-
# 'unknown'
|
|
213
|
+
# 'perfect match' -> matched
|
|
214
|
+
# 'both are null' -> matched
|
|
215
|
+
# 'missing from a' -> row added
|
|
216
|
+
# 'missing from b' -> row removed
|
|
217
|
+
# 'value is null in a only' -> mismatched
|
|
218
|
+
# 'value is null in b only' -> mismatched
|
|
219
|
+
# 'values do not match' -> mismatched
|
|
220
|
+
# 'unknown' -> this should never happen
|
|
166
221
|
# end as match_status,
|
|
167
222
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
if 'values do not match' in column_state:
|
|
183
|
-
column_groups[column_name]['mismatched'] += row_count
|
|
223
|
+
state_mappings = {
|
|
224
|
+
"perfect match": "matched",
|
|
225
|
+
"both are null": "matched",
|
|
226
|
+
"missing from a": "added",
|
|
227
|
+
"missing from b": "removed",
|
|
228
|
+
"value is null in a only": "mismatched",
|
|
229
|
+
"value is null in b only": "mismatched",
|
|
230
|
+
"values do not match": "mismatched",
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
# Use the mapping to update counts
|
|
234
|
+
for state, action in state_mappings.items():
|
|
235
|
+
if state in column_state:
|
|
236
|
+
column_groups[column_name][action] += row_count
|
|
184
237
|
|
|
185
238
|
# Cancel as early as possible
|
|
186
239
|
self.check_cancel()
|
|
@@ -188,9 +241,9 @@ class ValueDiffTask(Task, ValueDiffMixin):
|
|
|
188
241
|
completed = completed + 1
|
|
189
242
|
|
|
190
243
|
first = list(column_groups.values())[0]
|
|
191
|
-
added = first[
|
|
192
|
-
removed = first[
|
|
193
|
-
common = first[
|
|
244
|
+
added = first["added"]
|
|
245
|
+
removed = first["removed"]
|
|
246
|
+
common = first["matched"] + first["mismatched"]
|
|
194
247
|
total = common + added + removed
|
|
195
248
|
|
|
196
249
|
row = []
|
|
@@ -200,15 +253,25 @@ class ValueDiffTask(Task, ValueDiffMixin):
|
|
|
200
253
|
# This is incorrect when there are one side null
|
|
201
254
|
# https://github.com/dbt-labs/dbt-audit-helper/blob/main/macros/compare_column_values.sql#L20-L23
|
|
202
255
|
# matched = v['matched']
|
|
203
|
-
matched = common - v[
|
|
256
|
+
matched = common - v["mismatched"]
|
|
204
257
|
rate = None if common == 0 else matched / common
|
|
205
258
|
record = [k, matched, rate]
|
|
206
259
|
row.append(record)
|
|
207
260
|
|
|
208
|
-
column_names = [
|
|
261
|
+
column_names = ["column", "matched", "matched_p"]
|
|
209
262
|
column_types = [agate.Text(), agate.Number(), agate.Number()]
|
|
210
263
|
table = agate.Table(row, column_names=column_names, column_types=column_types)
|
|
211
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
|
+
|
|
212
275
|
return ValueDiffResult(
|
|
213
276
|
summary=ValueDiffResult.Summary(total=total, added=added, removed=removed),
|
|
214
277
|
data=DataFrame.from_agate(table),
|
|
@@ -224,9 +287,6 @@ class ValueDiffTask(Task, ValueDiffMixin):
|
|
|
224
287
|
model: str = self.params.model
|
|
225
288
|
columns: List[str] = self.params.columns
|
|
226
289
|
|
|
227
|
-
self._verify_dbt_packages_deps(dbt_adapter)
|
|
228
|
-
self.check_cancel()
|
|
229
|
-
|
|
230
290
|
self._verify_primary_key(dbt_adapter, primary_key, model)
|
|
231
291
|
self.check_cancel()
|
|
232
292
|
|
|
@@ -243,35 +303,34 @@ class ValueDiffTask(Task, ValueDiffMixin):
|
|
|
243
303
|
|
|
244
304
|
|
|
245
305
|
class ValueDiffTaskResultDiffer(TaskResultDiffer):
|
|
246
|
-
|
|
247
306
|
def _check_result_changed_fn(self, result):
|
|
248
307
|
is_changed = False
|
|
249
|
-
summary = result.get(
|
|
250
|
-
added = summary.get(
|
|
251
|
-
removed = summary.get(
|
|
252
|
-
changes = {
|
|
253
|
-
'column_changed': []
|
|
254
|
-
}
|
|
308
|
+
summary = result.get("summary", {})
|
|
309
|
+
added = summary.get("added", 0)
|
|
310
|
+
removed = summary.get("removed", 0)
|
|
311
|
+
changes = {"column_changed": []}
|
|
255
312
|
|
|
256
313
|
if added > 0:
|
|
257
314
|
is_changed = True
|
|
258
|
-
changes[
|
|
315
|
+
changes["row_added"] = added
|
|
259
316
|
|
|
260
317
|
if removed > 0:
|
|
261
318
|
is_changed = True
|
|
262
|
-
changes[
|
|
319
|
+
changes["row_removed"] = removed
|
|
263
320
|
|
|
264
|
-
row_data = result.get(
|
|
321
|
+
row_data = result.get("data", {}).get("data", [])
|
|
265
322
|
for row in row_data:
|
|
266
323
|
column, matched, matched_p = row
|
|
267
324
|
if float(matched_p) < 1.0:
|
|
268
325
|
# if there is any mismatched, we consider it as changed
|
|
269
326
|
is_changed = True
|
|
270
|
-
changes[
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
327
|
+
changes["column_changed"].append(
|
|
328
|
+
{
|
|
329
|
+
"column": column,
|
|
330
|
+
"matched": matched,
|
|
331
|
+
"matched_p": matched_p,
|
|
332
|
+
}
|
|
333
|
+
)
|
|
275
334
|
|
|
276
335
|
return changes if is_changed else None
|
|
277
336
|
|
|
@@ -287,15 +346,19 @@ class ValueDiffDetailResult(DataFrame):
|
|
|
287
346
|
|
|
288
347
|
|
|
289
348
|
class ValueDiffDetailTask(Task, ValueDiffMixin):
|
|
290
|
-
|
|
291
349
|
def __init__(self, params):
|
|
292
350
|
super().__init__()
|
|
293
351
|
self.params = ValueDiffParams(**params)
|
|
294
352
|
self.connection = None
|
|
295
353
|
self.legacy_surrogate_key = True
|
|
296
354
|
|
|
297
|
-
def _query_value_diff(
|
|
298
|
-
|
|
355
|
+
def _query_value_diff(
|
|
356
|
+
self,
|
|
357
|
+
dbt_adapter,
|
|
358
|
+
primary_key: Union[str, List[str]],
|
|
359
|
+
model: str,
|
|
360
|
+
columns: List[str] = None,
|
|
361
|
+
):
|
|
299
362
|
composite = True if isinstance(primary_key, List) else False
|
|
300
363
|
|
|
301
364
|
if columns is None or len(columns) == 0:
|
|
@@ -312,54 +375,87 @@ class ValueDiffDetailTask(Task, ValueDiffMixin):
|
|
|
312
375
|
columns.insert(0, primary_key)
|
|
313
376
|
|
|
314
377
|
sql_template = r"""
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
+
"""
|
|
425
|
+
|
|
426
|
+
sql = dbt_adapter.generate_sql(
|
|
427
|
+
sql_template,
|
|
428
|
+
context=dict(
|
|
429
|
+
base_relation=dbt_adapter.create_relation(model, base=True),
|
|
430
|
+
curr_relation=dbt_adapter.create_relation(model, base=False),
|
|
431
|
+
primary_keys=primary_key if composite else [primary_key],
|
|
432
|
+
columns=columns,
|
|
433
|
+
limit=1000,
|
|
434
|
+
),
|
|
435
|
+
)
|
|
354
436
|
|
|
355
437
|
_, table = dbt_adapter.execute(sql, fetch=True)
|
|
356
438
|
self.check_cancel()
|
|
357
439
|
|
|
358
|
-
|
|
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)
|
|
359
443
|
|
|
360
|
-
|
|
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
|
|
361
455
|
|
|
456
|
+
def execute(self):
|
|
362
457
|
from recce.adapter.dbt_adapter import DbtAdapter
|
|
458
|
+
|
|
363
459
|
dbt_adapter: DbtAdapter = default_context().adapter
|
|
364
460
|
|
|
365
461
|
with dbt_adapter.connection_named("value diff"):
|
|
@@ -369,9 +465,6 @@ class ValueDiffDetailTask(Task, ValueDiffMixin):
|
|
|
369
465
|
model: str = self.params.model
|
|
370
466
|
columns: List[str] = self.params.columns
|
|
371
467
|
|
|
372
|
-
self._verify_dbt_packages_deps(dbt_adapter)
|
|
373
|
-
self.check_cancel()
|
|
374
|
-
|
|
375
468
|
self._verify_primary_key(dbt_adapter, primary_key, model)
|
|
376
469
|
self.check_cancel()
|
|
377
470
|
|
|
@@ -379,6 +472,7 @@ class ValueDiffDetailTask(Task, ValueDiffMixin):
|
|
|
379
472
|
|
|
380
473
|
def cancel(self):
|
|
381
474
|
from recce.adapter.dbt_adapter import DbtAdapter
|
|
475
|
+
|
|
382
476
|
if self.connection:
|
|
383
477
|
adapter: DbtAdapter = default_context().adapter
|
|
384
478
|
with adapter.connection_named("cancel"):
|
|
@@ -386,9 +480,8 @@ class ValueDiffDetailTask(Task, ValueDiffMixin):
|
|
|
386
480
|
|
|
387
481
|
|
|
388
482
|
class ValueDiffDetailTaskResultDiffer(TaskResultDiffer):
|
|
389
|
-
|
|
390
483
|
def _check_result_changed_fn(self, result):
|
|
391
|
-
diff_data = result.get(
|
|
484
|
+
diff_data = result.get("data")
|
|
392
485
|
if diff_data is None or len(diff_data) == 0:
|
|
393
486
|
return None
|
|
394
487
|
|
|
@@ -397,7 +490,6 @@ class ValueDiffDetailTaskResultDiffer(TaskResultDiffer):
|
|
|
397
490
|
|
|
398
491
|
|
|
399
492
|
class ValueDiffCheckValidator(CheckValidator):
|
|
400
|
-
|
|
401
493
|
def validate_check(self, check: Check):
|
|
402
494
|
try:
|
|
403
495
|
ValueDiffParams(**check.params)
|
recce/util/__init__.py
CHANGED
recce/util/api_token.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import click
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
|
|
4
|
+
from recce import event
|
|
5
|
+
from recce.event import get_recce_api_token, update_recce_api_token
|
|
6
|
+
from recce.exceptions import RecceConfigException
|
|
7
|
+
from recce.util.recce_cloud import (
|
|
8
|
+
RECCE_CLOUD_BASE_URL,
|
|
9
|
+
RecceCloud,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def show_invalid_api_token_message():
|
|
16
|
+
"""
|
|
17
|
+
Show the message when the API token is invalid.
|
|
18
|
+
"""
|
|
19
|
+
console.print("[[red]Error[/red]] Invalid Recce Cloud API token.")
|
|
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
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def prepare_api_token(
|
|
27
|
+
interaction=False,
|
|
28
|
+
**kwargs,
|
|
29
|
+
):
|
|
30
|
+
"""
|
|
31
|
+
Prepare the API token for the request.
|
|
32
|
+
"""
|
|
33
|
+
# Verify the API token for Recce Cloud Share Link
|
|
34
|
+
api_token = get_recce_api_token()
|
|
35
|
+
new_api_token = kwargs.get("api_token")
|
|
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:
|
|
43
|
+
# Handle the API token provided by option `--api-token`
|
|
44
|
+
valid = RecceCloud(new_api_token).verify_token()
|
|
45
|
+
if not valid:
|
|
46
|
+
raise RecceConfigException("Invalid Recce Cloud API token")
|
|
47
|
+
event.log_connected_to_cloud()
|
|
48
|
+
api_token = new_api_token
|
|
49
|
+
update_recce_api_token(api_token)
|
|
50
|
+
console.print(
|
|
51
|
+
"[[green]Success[/green]] User profile has been updated to include the Recce Cloud API Token. "
|
|
52
|
+
"You no longer need to append --api-token to the recce command"
|
|
53
|
+
)
|
|
54
|
+
elif api_token:
|
|
55
|
+
# Verify the API token from the user profile
|
|
56
|
+
valid = RecceCloud(api_token).verify_token()
|
|
57
|
+
if not valid:
|
|
58
|
+
console.print("[[yellow]Warning[/yellow]] Invalid Recce Cloud API token. Skipping the share link.")
|
|
59
|
+
api_token = None
|
|
60
|
+
if valid:
|
|
61
|
+
event.log_connected_to_cloud()
|
|
62
|
+
else:
|
|
63
|
+
# No api_token provided
|
|
64
|
+
if interaction:
|
|
65
|
+
console.print(
|
|
66
|
+
"An API token is required for this feature. This can be obtained in your user account settings.\n"
|
|
67
|
+
f"{RECCE_CLOUD_BASE_URL}/settings#tokens\n"
|
|
68
|
+
"Your API token can be added to '~/.recce/profile.yml' for more convenient sharing."
|
|
69
|
+
)
|
|
70
|
+
api_token = click.prompt("Your Recce API token", type=str, hide_input=True, show_default=False)
|
|
71
|
+
valid = RecceCloud(api_token).verify_token()
|
|
72
|
+
if not valid:
|
|
73
|
+
raise RecceConfigException("Invalid Recce Cloud API token")
|
|
74
|
+
update_recce_api_token(api_token)
|
|
75
|
+
console.print(
|
|
76
|
+
"[[green]Success[/green]] User profile has been updated to include the Recce Cloud API Token. "
|
|
77
|
+
"You no longer need to append --api-token to the recce command"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return api_token
|