collate-data-diff 0.11.2__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.
- collate_data_diff-0.11.2.dist-info/LICENSE +18 -0
- collate_data_diff-0.11.2.dist-info/METADATA +77 -0
- collate_data_diff-0.11.2.dist-info/RECORD +54 -0
- collate_data_diff-0.11.2.dist-info/WHEEL +4 -0
- collate_data_diff-0.11.2.dist-info/entry_points.txt +3 -0
- data_diff/__init__.py +180 -0
- data_diff/__main__.py +618 -0
- data_diff/abcs/__init__.py +0 -0
- data_diff/abcs/compiler.py +13 -0
- data_diff/abcs/database_types.py +308 -0
- data_diff/cloud/__init__.py +2 -0
- data_diff/cloud/data_source.py +318 -0
- data_diff/cloud/datafold_api.py +304 -0
- data_diff/config.py +127 -0
- data_diff/databases/__init__.py +17 -0
- data_diff/databases/_connect.py +306 -0
- data_diff/databases/base.py +1291 -0
- data_diff/databases/bigquery.py +315 -0
- data_diff/databases/clickhouse.py +203 -0
- data_diff/databases/databricks.py +248 -0
- data_diff/databases/duckdb.py +192 -0
- data_diff/databases/mssql.py +229 -0
- data_diff/databases/mysql.py +159 -0
- data_diff/databases/oracle.py +195 -0
- data_diff/databases/postgresql.py +258 -0
- data_diff/databases/presto.py +197 -0
- data_diff/databases/redshift.py +217 -0
- data_diff/databases/snowflake.py +207 -0
- data_diff/databases/trino.py +50 -0
- data_diff/databases/vertica.py +160 -0
- data_diff/dbt.py +604 -0
- data_diff/dbt_config_validators.py +65 -0
- data_diff/dbt_parser.py +523 -0
- data_diff/diff_tables.py +416 -0
- data_diff/errors.py +74 -0
- data_diff/format.py +359 -0
- data_diff/hashdiff_tables.py +264 -0
- data_diff/info_tree.py +62 -0
- data_diff/joindiff_tables.py +399 -0
- data_diff/lexicographic_space.py +240 -0
- data_diff/parse_time.py +74 -0
- data_diff/py.typed +0 -0
- data_diff/queries/__init__.py +0 -0
- data_diff/queries/api.py +200 -0
- data_diff/queries/ast_classes.py +798 -0
- data_diff/queries/base.py +24 -0
- data_diff/queries/extras.py +29 -0
- data_diff/query_utils.py +56 -0
- data_diff/schema.py +52 -0
- data_diff/table_segment.py +286 -0
- data_diff/thread_utils.py +98 -0
- data_diff/tracking.py +237 -0
- data_diff/utils.py +625 -0
- data_diff/version.py +1 -0
data_diff/dbt.py
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
from contextlib import nullcontext
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import time
|
|
6
|
+
from typing import List, Optional, Dict, Tuple, Union
|
|
7
|
+
import keyring
|
|
8
|
+
import pydantic
|
|
9
|
+
import rich
|
|
10
|
+
from rich.prompt import Prompt
|
|
11
|
+
from rich.markdown import Markdown
|
|
12
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
13
|
+
|
|
14
|
+
from data_diff.errors import (
|
|
15
|
+
DataDiffCustomSchemaNoConfigError,
|
|
16
|
+
DataDiffDbtProjectVarsNotFoundError,
|
|
17
|
+
DataDiffNoAPIKeyError,
|
|
18
|
+
DataDiffNoDatasourceIdError,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from data_diff import connect_to_table, diff_tables, Algorithm
|
|
22
|
+
from data_diff.cloud import DatafoldAPI, TCloudApiDataDiff, TCloudApiOrgMeta
|
|
23
|
+
from data_diff.dbt_parser import DbtParser, TDatadiffConfig
|
|
24
|
+
from data_diff.diff_tables import DiffResultWrapper
|
|
25
|
+
from data_diff.format import jsonify, jsonify_error
|
|
26
|
+
from data_diff.tracking import (
|
|
27
|
+
bool_ask_for_email,
|
|
28
|
+
bool_notify_about_extension,
|
|
29
|
+
create_email_signup_event_json,
|
|
30
|
+
set_entrypoint_name,
|
|
31
|
+
set_dbt_user_id,
|
|
32
|
+
set_dbt_version,
|
|
33
|
+
set_dbt_project_id,
|
|
34
|
+
create_end_event_json,
|
|
35
|
+
create_start_event_json,
|
|
36
|
+
send_event_json,
|
|
37
|
+
is_tracking_enabled,
|
|
38
|
+
)
|
|
39
|
+
from data_diff.utils import (
|
|
40
|
+
dbt_diff_string_template,
|
|
41
|
+
getLogger,
|
|
42
|
+
columns_added_template,
|
|
43
|
+
columns_removed_template,
|
|
44
|
+
no_differences_template,
|
|
45
|
+
columns_type_changed_template,
|
|
46
|
+
run_as_daemon,
|
|
47
|
+
truncate_error,
|
|
48
|
+
print_version_info,
|
|
49
|
+
LogStatusHandler,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
logger = getLogger(__name__)
|
|
53
|
+
CLOUD_DOC_URL = "https://docs.datafold.com/development_testing/cloud"
|
|
54
|
+
DATAFOLD_TRIAL_URL = "https://app.datafold.com/org-signup"
|
|
55
|
+
DATAFOLD_INSTRUCTIONS_URL = "https://docs.datafold.com/development_testing/datafold_cloud"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TDiffVars(pydantic.BaseModel):
|
|
59
|
+
dev_path: List[str]
|
|
60
|
+
prod_path: List[str]
|
|
61
|
+
primary_keys: List[str]
|
|
62
|
+
connection: Dict[str, Optional[str]]
|
|
63
|
+
threads: Optional[int] = None
|
|
64
|
+
where_filter: Optional[str] = None
|
|
65
|
+
include_columns: List[str]
|
|
66
|
+
exclude_columns: List[str]
|
|
67
|
+
dbt_model: Optional[str] = None
|
|
68
|
+
stats_flag: bool = False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def dbt_diff(
|
|
72
|
+
profiles_dir_override: Optional[str] = None,
|
|
73
|
+
project_dir_override: Optional[str] = None,
|
|
74
|
+
is_cloud: bool = False,
|
|
75
|
+
dbt_selection: Optional[str] = None,
|
|
76
|
+
json_output: bool = False,
|
|
77
|
+
state: Optional[str] = None,
|
|
78
|
+
log_status_handler: Optional[LogStatusHandler] = None,
|
|
79
|
+
where_flag: Optional[str] = None,
|
|
80
|
+
stats_flag: bool = False,
|
|
81
|
+
columns_flag: Optional[Tuple[str]] = None,
|
|
82
|
+
production_database_flag: Optional[str] = None,
|
|
83
|
+
production_schema_flag: Optional[str] = None,
|
|
84
|
+
) -> None:
|
|
85
|
+
print_version_info()
|
|
86
|
+
set_entrypoint_name(os.getenv("DATAFOLD_TRIGGERED_BY", "CLI-dbt"))
|
|
87
|
+
dbt_parser = DbtParser(profiles_dir_override, project_dir_override, state)
|
|
88
|
+
models = dbt_parser.get_models(dbt_selection)
|
|
89
|
+
config = dbt_parser.get_datadiff_config()
|
|
90
|
+
_initialize_events(dbt_parser.dbt_user_id, dbt_parser.dbt_version, dbt_parser.dbt_project_id)
|
|
91
|
+
|
|
92
|
+
if not state and not (config.prod_database or config.prod_schema):
|
|
93
|
+
doc_url = "https://docs.datafold.com/development_testing/open_source#configure-your-dbt-project"
|
|
94
|
+
raise DataDiffDbtProjectVarsNotFoundError(
|
|
95
|
+
f"""vars: data_diff: section not found in dbt_project.yml.\n\nTo solve this, please configure your dbt project: \n{doc_url}\n\nOr specify a production manifest using the `--state` flag."""
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if is_cloud:
|
|
99
|
+
api = _initialize_api()
|
|
100
|
+
# exit so the user can set the key
|
|
101
|
+
if not api:
|
|
102
|
+
return
|
|
103
|
+
org_meta = api.get_org_meta()
|
|
104
|
+
if config.datasource_id is None:
|
|
105
|
+
rich.print("[red]Data source ID not found in dbt_project.yml")
|
|
106
|
+
raise DataDiffNoDatasourceIdError(
|
|
107
|
+
f"Datasource ID not found. Please include it as a dbt variable in the dbt_project.yml. \nInstructions: {CLOUD_DOC_URL}\n\nvars:\n data_diff:\n datasource_id: 1234"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
data_source = api.get_data_source(config.datasource_id)
|
|
111
|
+
dbt_parser.set_casing_policy_for(connection_type=data_source.type)
|
|
112
|
+
rich.print("[green][bold]\nDiffs in progress...[/][/]\n")
|
|
113
|
+
|
|
114
|
+
else:
|
|
115
|
+
dbt_parser.set_connection()
|
|
116
|
+
|
|
117
|
+
futures = {}
|
|
118
|
+
|
|
119
|
+
with log_status_handler.status if log_status_handler else nullcontext(), ThreadPoolExecutor(
|
|
120
|
+
max_workers=dbt_parser.threads
|
|
121
|
+
) as executor:
|
|
122
|
+
for model in models:
|
|
123
|
+
if log_status_handler:
|
|
124
|
+
log_status_handler.set_prefix(f"Diffing {model.alias} \n")
|
|
125
|
+
|
|
126
|
+
diff_vars = _get_diff_vars(
|
|
127
|
+
dbt_parser,
|
|
128
|
+
config,
|
|
129
|
+
model,
|
|
130
|
+
where_flag,
|
|
131
|
+
stats_flag,
|
|
132
|
+
columns_flag,
|
|
133
|
+
production_database_flag,
|
|
134
|
+
production_schema_flag,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# we won't always have a prod path when using state
|
|
138
|
+
# when the model DNE in prod manifest, skip the model diff
|
|
139
|
+
if (
|
|
140
|
+
state and len(diff_vars.prod_path) < 2
|
|
141
|
+
): # < 2 because some providers like databricks can legitimately have *only* 2
|
|
142
|
+
diff_output_str = _diff_output_base(".".join(diff_vars.dev_path), ".".join(diff_vars.prod_path))
|
|
143
|
+
diff_output_str += "[green]New model: nothing to diff![/] \n"
|
|
144
|
+
rich.print(diff_output_str)
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
if diff_vars.primary_keys:
|
|
148
|
+
if is_cloud:
|
|
149
|
+
future = executor.submit(
|
|
150
|
+
_cloud_diff, diff_vars, config.datasource_id, api, org_meta, log_status_handler
|
|
151
|
+
)
|
|
152
|
+
else:
|
|
153
|
+
future = executor.submit(_local_diff, diff_vars, json_output, log_status_handler)
|
|
154
|
+
futures[future] = model
|
|
155
|
+
else:
|
|
156
|
+
if json_output:
|
|
157
|
+
print(
|
|
158
|
+
json.dumps(
|
|
159
|
+
jsonify_error(
|
|
160
|
+
table1=diff_vars.prod_path,
|
|
161
|
+
table2=diff_vars.dev_path,
|
|
162
|
+
dbt_model=diff_vars.dbt_model,
|
|
163
|
+
error="No primary key found. Add uniqueness tests, meta, or tags.",
|
|
164
|
+
)
|
|
165
|
+
),
|
|
166
|
+
flush=True,
|
|
167
|
+
)
|
|
168
|
+
else:
|
|
169
|
+
rich.print(
|
|
170
|
+
_diff_output_base(".".join(diff_vars.dev_path), ".".join(diff_vars.prod_path))
|
|
171
|
+
+ "Skipped due to unknown primary key. Add uniqueness tests, meta, or tags.\n"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
for future in as_completed(futures):
|
|
175
|
+
model = futures[future]
|
|
176
|
+
try:
|
|
177
|
+
future.result() # if error occurred, it will be raised here
|
|
178
|
+
except Exception as e:
|
|
179
|
+
logger.error(f"An error occurred during the execution of a diff task: {model.unique_id} - {e}")
|
|
180
|
+
|
|
181
|
+
_extension_notification()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _get_diff_vars(
|
|
185
|
+
dbt_parser: "DbtParser",
|
|
186
|
+
config: TDatadiffConfig,
|
|
187
|
+
model,
|
|
188
|
+
where_flag: Optional[str] = None,
|
|
189
|
+
stats_flag: bool = False,
|
|
190
|
+
columns_flag: Optional[Tuple[str]] = None,
|
|
191
|
+
production_database_flag: Optional[str] = None,
|
|
192
|
+
production_schema_flag: Optional[str] = None,
|
|
193
|
+
) -> TDiffVars:
|
|
194
|
+
cli_columns = list(columns_flag) if columns_flag else []
|
|
195
|
+
dev_database = model.database
|
|
196
|
+
dev_schema = model.schema_
|
|
197
|
+
dev_alias = prod_alias = model.alias
|
|
198
|
+
primary_keys = dbt_parser.get_pk_from_model(model, dbt_parser.unique_columns, "primary-key")
|
|
199
|
+
|
|
200
|
+
# prod path is constructed via configuration or the prod manifest via --state
|
|
201
|
+
if dbt_parser.prod_manifest_obj:
|
|
202
|
+
prod_database, prod_schema, prod_alias = _get_prod_path_from_manifest(model, dbt_parser.prod_manifest_obj)
|
|
203
|
+
else:
|
|
204
|
+
prod_database, prod_schema = _get_prod_path_from_config(config, model, dev_database, dev_schema)
|
|
205
|
+
|
|
206
|
+
# cli flags take precedence over any project level config
|
|
207
|
+
prod_database = production_database_flag or prod_database
|
|
208
|
+
prod_schema = production_schema_flag or prod_schema
|
|
209
|
+
|
|
210
|
+
if dbt_parser.requires_upper:
|
|
211
|
+
dev_qualified_list = [x.upper() for x in [dev_database, dev_schema, dev_alias] if x]
|
|
212
|
+
prod_qualified_list = [x.upper() for x in [prod_database, prod_schema, prod_alias] if x]
|
|
213
|
+
primary_keys = [x.upper() for x in primary_keys]
|
|
214
|
+
else:
|
|
215
|
+
dev_qualified_list = [x for x in [dev_database, dev_schema, dev_alias] if x]
|
|
216
|
+
prod_qualified_list = [x for x in [prod_database, prod_schema, prod_alias] if x]
|
|
217
|
+
|
|
218
|
+
datadiff_model_config = dbt_parser.get_datadiff_model_config(model.meta)
|
|
219
|
+
|
|
220
|
+
return TDiffVars(
|
|
221
|
+
dbt_model=model.unique_id,
|
|
222
|
+
dev_path=dev_qualified_list,
|
|
223
|
+
prod_path=prod_qualified_list,
|
|
224
|
+
primary_keys=primary_keys,
|
|
225
|
+
connection=dbt_parser.connection,
|
|
226
|
+
threads=dbt_parser.threads,
|
|
227
|
+
# cli flags take precedence over any model level config
|
|
228
|
+
where_filter=where_flag or datadiff_model_config.where_filter,
|
|
229
|
+
include_columns=cli_columns or datadiff_model_config.include_columns,
|
|
230
|
+
exclude_columns=[] if cli_columns else datadiff_model_config.exclude_columns,
|
|
231
|
+
stats_flag=stats_flag,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _get_prod_path_from_config(config, model, dev_database, dev_schema) -> Tuple[str, str]:
|
|
236
|
+
# "custom" dbt config database
|
|
237
|
+
if model.config.database:
|
|
238
|
+
prod_database = model.config.database
|
|
239
|
+
elif config.prod_database:
|
|
240
|
+
prod_database = config.prod_database
|
|
241
|
+
else:
|
|
242
|
+
prod_database = dev_database
|
|
243
|
+
|
|
244
|
+
# prod schema name differs from dev schema name
|
|
245
|
+
if config.prod_schema:
|
|
246
|
+
custom_schema = model.config.schema_
|
|
247
|
+
|
|
248
|
+
# the model has a custom schema config(schema='some_schema')
|
|
249
|
+
if custom_schema:
|
|
250
|
+
if not config.prod_custom_schema:
|
|
251
|
+
raise DataDiffCustomSchemaNoConfigError(
|
|
252
|
+
f"Found a custom schema on model {model.name}, but no value for\nvars:\n data_diff:\n prod_custom_schema:\nPlease set a value or utilize the `--state` flag!\n\n"
|
|
253
|
+
+ "For more details see: https://docs.datafold.com/development_testing/open_source"
|
|
254
|
+
)
|
|
255
|
+
prod_schema = config.prod_custom_schema.replace("<custom_schema>", custom_schema)
|
|
256
|
+
# no custom schema, use the default
|
|
257
|
+
else:
|
|
258
|
+
prod_schema = config.prod_schema
|
|
259
|
+
else:
|
|
260
|
+
prod_schema = dev_schema
|
|
261
|
+
return prod_database, prod_schema
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _get_prod_path_from_manifest(model, prod_manifest) -> Union[Tuple[str, str, str], Tuple[None, None, None]]:
|
|
265
|
+
prod_database = None
|
|
266
|
+
prod_schema = None
|
|
267
|
+
prod_alias = None
|
|
268
|
+
prod_model = prod_manifest.nodes.get(model.unique_id, None)
|
|
269
|
+
if prod_model:
|
|
270
|
+
prod_database = prod_model.database
|
|
271
|
+
prod_schema = prod_model.schema_
|
|
272
|
+
prod_alias = prod_model.alias
|
|
273
|
+
return prod_database, prod_schema, prod_alias
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _local_diff(
|
|
277
|
+
diff_vars: TDiffVars, json_output: bool = False, log_status_handler: Optional[LogStatusHandler] = None
|
|
278
|
+
) -> None:
|
|
279
|
+
if log_status_handler:
|
|
280
|
+
log_status_handler.diff_started(diff_vars.dev_path[-1])
|
|
281
|
+
dev_qualified_str = ".".join(diff_vars.dev_path)
|
|
282
|
+
prod_qualified_str = ".".join(diff_vars.prod_path)
|
|
283
|
+
diff_output_str = _diff_output_base(dev_qualified_str, prod_qualified_str)
|
|
284
|
+
|
|
285
|
+
table1 = connect_to_table(diff_vars.connection, prod_qualified_str, tuple(diff_vars.primary_keys))
|
|
286
|
+
table2 = connect_to_table(diff_vars.connection, dev_qualified_str, tuple(diff_vars.primary_keys))
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
table1_columns = table1.get_schema()
|
|
290
|
+
# Not ideal, but we don't have more specific exceptions yet
|
|
291
|
+
except Exception as ex:
|
|
292
|
+
logger.debug(ex)
|
|
293
|
+
diff_output_str += "[red]New model or no access to prod table.[/] \n"
|
|
294
|
+
rich.print(diff_output_str)
|
|
295
|
+
return
|
|
296
|
+
|
|
297
|
+
table2_columns = table2.get_schema()
|
|
298
|
+
|
|
299
|
+
table1_column_names = set(table1_columns.keys())
|
|
300
|
+
table2_column_names = set(table2_columns.keys())
|
|
301
|
+
column_set = table1_column_names.intersection(table2_column_names)
|
|
302
|
+
columns_added = table2_column_names.difference(table1_column_names)
|
|
303
|
+
columns_removed = table1_column_names.difference(table2_column_names)
|
|
304
|
+
# col type is i = 1 in tuple
|
|
305
|
+
columns_type_changed = {
|
|
306
|
+
k for k, v in table2_columns.items() if k in table1_columns and v.data_type != table1_columns[k].data_type
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
diff_output_str += f"Primary Keys: {diff_vars.primary_keys} \n"
|
|
310
|
+
|
|
311
|
+
if diff_vars.where_filter:
|
|
312
|
+
diff_output_str += f"Where Filter: '{str(diff_vars.where_filter)}' \n"
|
|
313
|
+
|
|
314
|
+
if diff_vars.include_columns:
|
|
315
|
+
diff_output_str += f"Included Columns: {diff_vars.include_columns} \n"
|
|
316
|
+
|
|
317
|
+
if diff_vars.exclude_columns:
|
|
318
|
+
diff_output_str += f"Excluded Columns: {diff_vars.exclude_columns} \n"
|
|
319
|
+
|
|
320
|
+
if columns_removed:
|
|
321
|
+
diff_output_str += columns_removed_template(columns_removed)
|
|
322
|
+
|
|
323
|
+
if columns_added:
|
|
324
|
+
diff_output_str += columns_added_template(columns_added)
|
|
325
|
+
|
|
326
|
+
if columns_type_changed:
|
|
327
|
+
diff_output_str += columns_type_changed_template(columns_type_changed)
|
|
328
|
+
column_set = column_set.difference(columns_type_changed)
|
|
329
|
+
|
|
330
|
+
column_set = column_set - set(diff_vars.primary_keys)
|
|
331
|
+
|
|
332
|
+
if diff_vars.include_columns:
|
|
333
|
+
column_set = {x for x in column_set if x.upper() in [y.upper() for y in diff_vars.include_columns]}
|
|
334
|
+
|
|
335
|
+
if diff_vars.exclude_columns:
|
|
336
|
+
column_set = {x for x in column_set if x.upper() not in [y.upper() for y in diff_vars.exclude_columns]}
|
|
337
|
+
|
|
338
|
+
extra_columns = tuple(column_set)
|
|
339
|
+
|
|
340
|
+
diff: DiffResultWrapper = diff_tables(
|
|
341
|
+
table1,
|
|
342
|
+
table2,
|
|
343
|
+
threaded=True,
|
|
344
|
+
algorithm=Algorithm.JOINDIFF,
|
|
345
|
+
extra_columns=extra_columns,
|
|
346
|
+
where=diff_vars.where_filter,
|
|
347
|
+
skip_null_keys=True,
|
|
348
|
+
)
|
|
349
|
+
if json_output:
|
|
350
|
+
# drain the iterator to get accumulated stats in diff.info_tree
|
|
351
|
+
try:
|
|
352
|
+
list(diff)
|
|
353
|
+
except Exception as e:
|
|
354
|
+
print(
|
|
355
|
+
json.dumps(
|
|
356
|
+
jsonify_error(list(table1.table_path), list(table2.table_path), diff_vars.dbt_model, str(e))
|
|
357
|
+
),
|
|
358
|
+
flush=True,
|
|
359
|
+
)
|
|
360
|
+
return
|
|
361
|
+
|
|
362
|
+
dataset1_columns = [
|
|
363
|
+
(info.column_name, info.data_type, table1.database.dialect.parse_type(table1.table_path, info))
|
|
364
|
+
for info in table1_columns.values()
|
|
365
|
+
]
|
|
366
|
+
dataset2_columns = [
|
|
367
|
+
(info.column_name, info.data_type, table2.database.dialect.parse_type(table2.table_path, info))
|
|
368
|
+
for info in table2_columns.values()
|
|
369
|
+
]
|
|
370
|
+
|
|
371
|
+
print(
|
|
372
|
+
json.dumps(
|
|
373
|
+
jsonify(
|
|
374
|
+
diff,
|
|
375
|
+
dbt_model=diff_vars.dbt_model,
|
|
376
|
+
dataset1_columns=dataset1_columns,
|
|
377
|
+
dataset2_columns=dataset2_columns,
|
|
378
|
+
with_summary=True,
|
|
379
|
+
columns_diff={
|
|
380
|
+
"added": columns_added,
|
|
381
|
+
"removed": columns_removed,
|
|
382
|
+
"changed": columns_type_changed,
|
|
383
|
+
},
|
|
384
|
+
stats_only=diff_vars.stats_flag,
|
|
385
|
+
)
|
|
386
|
+
),
|
|
387
|
+
flush=True,
|
|
388
|
+
)
|
|
389
|
+
return
|
|
390
|
+
|
|
391
|
+
if list(diff):
|
|
392
|
+
diff_output_str += f"{diff.get_stats_string(is_dbt=True)} \n"
|
|
393
|
+
rich.print(diff_output_str)
|
|
394
|
+
else:
|
|
395
|
+
diff_output_str += no_differences_template()
|
|
396
|
+
rich.print(diff_output_str)
|
|
397
|
+
|
|
398
|
+
if log_status_handler:
|
|
399
|
+
log_status_handler.diff_finished(diff_vars.dev_path[-1])
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _initialize_api() -> Optional[DatafoldAPI]:
|
|
403
|
+
datafold_host = os.environ.get("DATAFOLD_HOST")
|
|
404
|
+
if datafold_host is None:
|
|
405
|
+
datafold_host = "https://app.datafold.com"
|
|
406
|
+
datafold_host = datafold_host.rstrip("/")
|
|
407
|
+
rich.print(f"Cloud datafold host: {datafold_host}")
|
|
408
|
+
|
|
409
|
+
api_key = os.environ.get("DATAFOLD_API_KEY")
|
|
410
|
+
if not api_key:
|
|
411
|
+
rich.print("[red]API key not found. Getting from the keyring service")
|
|
412
|
+
api_key = keyring.get_password("data-diff", "DATAFOLD_API_KEY")
|
|
413
|
+
if not api_key:
|
|
414
|
+
raise DataDiffNoAPIKeyError(
|
|
415
|
+
f"API key not found. Please follow the steps at {CLOUD_DOC_URL} to use the --cloud flag."
|
|
416
|
+
)
|
|
417
|
+
rich.print("Saving the API key to the system keyring service")
|
|
418
|
+
try:
|
|
419
|
+
keyring.set_password("data-diff", "DATAFOLD_API_KEY", api_key)
|
|
420
|
+
except Exception as e:
|
|
421
|
+
rich.print(f"[red]Failed when saving the API key to the system keyring service. Reason: {e}")
|
|
422
|
+
|
|
423
|
+
return DatafoldAPI(api_key=api_key, host=datafold_host)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _cloud_diff(
|
|
427
|
+
diff_vars: TDiffVars,
|
|
428
|
+
datasource_id: int,
|
|
429
|
+
api: DatafoldAPI,
|
|
430
|
+
org_meta: TCloudApiOrgMeta,
|
|
431
|
+
log_status_handler: Optional[LogStatusHandler] = None,
|
|
432
|
+
) -> None:
|
|
433
|
+
if log_status_handler:
|
|
434
|
+
log_status_handler.diff_started(diff_vars.dev_path[-1])
|
|
435
|
+
diff_output_str = _diff_output_base(".".join(diff_vars.dev_path), ".".join(diff_vars.prod_path))
|
|
436
|
+
payload = TCloudApiDataDiff(
|
|
437
|
+
data_source1_id=datasource_id,
|
|
438
|
+
data_source2_id=datasource_id,
|
|
439
|
+
table1=diff_vars.prod_path,
|
|
440
|
+
table2=diff_vars.dev_path,
|
|
441
|
+
pk_columns=diff_vars.primary_keys,
|
|
442
|
+
filter1=diff_vars.where_filter,
|
|
443
|
+
filter2=diff_vars.where_filter,
|
|
444
|
+
include_columns=diff_vars.include_columns,
|
|
445
|
+
exclude_columns=diff_vars.exclude_columns,
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
if is_tracking_enabled():
|
|
449
|
+
event_json = create_start_event_json({"is_cloud": True, "datasource_id": datasource_id})
|
|
450
|
+
run_as_daemon(send_event_json, event_json)
|
|
451
|
+
|
|
452
|
+
start = time.monotonic()
|
|
453
|
+
error = None
|
|
454
|
+
diff_id = None
|
|
455
|
+
diff_url = None
|
|
456
|
+
try:
|
|
457
|
+
diff_id = api.create_data_diff(payload=payload)
|
|
458
|
+
diff_url = f"{api.host}/datadiffs/{diff_id}/overview"
|
|
459
|
+
rich.print(f"{diff_vars.dev_path[-1]}: {diff_url}")
|
|
460
|
+
|
|
461
|
+
if diff_id is None:
|
|
462
|
+
raise Exception(f"Api response did not contain a diff_id")
|
|
463
|
+
|
|
464
|
+
diff_results = api.poll_data_diff_results(diff_id)
|
|
465
|
+
|
|
466
|
+
rows_added_count = diff_results.pks.exclusives[1]
|
|
467
|
+
rows_removed_count = diff_results.pks.exclusives[0]
|
|
468
|
+
|
|
469
|
+
rows_updated = diff_results.values.rows_with_differences
|
|
470
|
+
total_rows_table1 = diff_results.pks.total_rows[0]
|
|
471
|
+
total_rows_table2 = diff_results.pks.total_rows[1]
|
|
472
|
+
total_rows_diff = total_rows_table2 - total_rows_table1
|
|
473
|
+
|
|
474
|
+
rows_unchanged = int(total_rows_table1) - int(rows_updated) - int(rows_removed_count)
|
|
475
|
+
diff_percent_list = {
|
|
476
|
+
x.column_name: f"{str(round(100.00 - x.match, 2))}%"
|
|
477
|
+
for x in diff_results.values.columns_diff_stats
|
|
478
|
+
if x.match != 100.0
|
|
479
|
+
}
|
|
480
|
+
columns_added = set(diff_results.schema_.exclusive_columns[1])
|
|
481
|
+
columns_removed = set(diff_results.schema_.exclusive_columns[0])
|
|
482
|
+
column_type_changes = diff_results.schema_.column_type_differs
|
|
483
|
+
|
|
484
|
+
diff_output_str += f"Primary Keys: {diff_vars.primary_keys} \n"
|
|
485
|
+
if diff_vars.where_filter:
|
|
486
|
+
diff_output_str += f"Where Filter: '{str(diff_vars.where_filter)}' \n"
|
|
487
|
+
|
|
488
|
+
if diff_vars.include_columns:
|
|
489
|
+
diff_output_str += f"Included Columns: {diff_vars.include_columns} \n"
|
|
490
|
+
|
|
491
|
+
if diff_vars.exclude_columns:
|
|
492
|
+
diff_output_str += f"Excluded Columns: {diff_vars.exclude_columns} \n"
|
|
493
|
+
|
|
494
|
+
if columns_removed:
|
|
495
|
+
diff_output_str += columns_removed_template(columns_removed)
|
|
496
|
+
|
|
497
|
+
if columns_added:
|
|
498
|
+
diff_output_str += columns_added_template(columns_added)
|
|
499
|
+
|
|
500
|
+
if column_type_changes:
|
|
501
|
+
diff_output_str += columns_type_changed_template(column_type_changes)
|
|
502
|
+
|
|
503
|
+
deps_impacts = {
|
|
504
|
+
key: len(value) + sum(len(item.get("BiHtSync", [])) for item in value) if key == "hightouch" else len(value)
|
|
505
|
+
for key, value in diff_results.deps.deps.items()
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if any([rows_added_count, rows_removed_count, rows_updated]):
|
|
509
|
+
diff_output = dbt_diff_string_template(
|
|
510
|
+
total_rows_table1=total_rows_table1,
|
|
511
|
+
total_rows_table2=total_rows_table2,
|
|
512
|
+
total_rows_diff=total_rows_diff,
|
|
513
|
+
rows_added=rows_added_count,
|
|
514
|
+
rows_removed=rows_removed_count,
|
|
515
|
+
rows_updated=rows_updated,
|
|
516
|
+
rows_unchanged=str(rows_unchanged),
|
|
517
|
+
deps_impacts=deps_impacts,
|
|
518
|
+
is_cloud=True,
|
|
519
|
+
extra_info_dict=diff_percent_list,
|
|
520
|
+
extra_info_str="Value Changed:",
|
|
521
|
+
)
|
|
522
|
+
diff_output_str += f"\n{diff_url}\n {diff_output} \n"
|
|
523
|
+
rich.print(diff_output_str)
|
|
524
|
+
else:
|
|
525
|
+
diff_output_str += f"\n{diff_url}\n{no_differences_template()}\n"
|
|
526
|
+
rich.print(diff_output_str)
|
|
527
|
+
|
|
528
|
+
if log_status_handler:
|
|
529
|
+
log_status_handler.diff_finished(diff_vars.dev_path[-1])
|
|
530
|
+
except BaseException as ex: # Catch KeyboardInterrupt too
|
|
531
|
+
error = ex
|
|
532
|
+
finally:
|
|
533
|
+
# we don't currently have much of this information
|
|
534
|
+
# but I imagine a future iteration of this _cloud method
|
|
535
|
+
# will poll for results
|
|
536
|
+
if is_tracking_enabled():
|
|
537
|
+
err_message = truncate_error(repr(error))
|
|
538
|
+
event_json = create_end_event_json(
|
|
539
|
+
is_success=error is None,
|
|
540
|
+
runtime_seconds=time.monotonic() - start,
|
|
541
|
+
data_source_1_type="",
|
|
542
|
+
data_source_2_type="",
|
|
543
|
+
table1_count=0,
|
|
544
|
+
table2_count=0,
|
|
545
|
+
diff_count=0,
|
|
546
|
+
error=err_message,
|
|
547
|
+
diff_id=diff_id,
|
|
548
|
+
is_cloud=True,
|
|
549
|
+
org_id=org_meta.org_id,
|
|
550
|
+
org_name=org_meta.org_name,
|
|
551
|
+
user_id=org_meta.user_id,
|
|
552
|
+
)
|
|
553
|
+
send_event_json(event_json)
|
|
554
|
+
|
|
555
|
+
if error:
|
|
556
|
+
rich.print(diff_output_str)
|
|
557
|
+
if diff_id:
|
|
558
|
+
diff_url = f"{api.host}/datadiffs/{diff_id}/overview"
|
|
559
|
+
rich.print(f"{diff_url} \n")
|
|
560
|
+
logger.error(error)
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _diff_output_base(dev_path: str, prod_path: str) -> str:
|
|
564
|
+
return f"\n[blue]{prod_path}[/] <> [green]{dev_path}[/] \n"
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _initialize_events(dbt_user_id: Optional[str], dbt_version: Optional[str], dbt_project_id: Optional[str]) -> None:
|
|
568
|
+
set_dbt_user_id(dbt_user_id)
|
|
569
|
+
set_dbt_version(dbt_version)
|
|
570
|
+
set_dbt_project_id(dbt_project_id)
|
|
571
|
+
_email_signup()
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _email_signup() -> None:
|
|
575
|
+
email_regex = r"^[\w\.\+-]+@[\w\.-]+\.\w+$"
|
|
576
|
+
prompt = "\nWould you like to be notified when a new data-diff version is available?\n\nEnter email or leave blank to opt out (we'll only ask once).\n"
|
|
577
|
+
|
|
578
|
+
if bool_ask_for_email():
|
|
579
|
+
while True:
|
|
580
|
+
email_input = Prompt.ask(
|
|
581
|
+
prompt=prompt,
|
|
582
|
+
default="",
|
|
583
|
+
show_default=False,
|
|
584
|
+
)
|
|
585
|
+
email = email_input.strip()
|
|
586
|
+
|
|
587
|
+
if email == "" or re.match(email_regex, email):
|
|
588
|
+
break
|
|
589
|
+
|
|
590
|
+
prompt = ""
|
|
591
|
+
rich.print("[red]Invalid email. Please enter a valid email or leave it blank to opt out.[/]")
|
|
592
|
+
|
|
593
|
+
if email:
|
|
594
|
+
event_json = create_email_signup_event_json(email)
|
|
595
|
+
run_as_daemon(send_event_json, event_json)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _extension_notification() -> None:
|
|
599
|
+
if bool_notify_about_extension():
|
|
600
|
+
message = "\n\nHaving a good time diffing?\n\nMake sure to check out the free Datafold Cloud Trial for an evolved experience:\n\n- value-level diffs\n- column-level lineage\n"
|
|
601
|
+
rich.print(message)
|
|
602
|
+
rich.print(Markdown(f"[Sign Up Here]({DATAFOLD_TRIAL_URL})"))
|
|
603
|
+
rich.print("")
|
|
604
|
+
rich.print(Markdown(f"[Follow the instructions to get started]({DATAFOLD_INSTRUCTIONS_URL})"))
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from typing import List, Dict, Optional, Any
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ManifestJsonConfig(BaseModel):
|
|
7
|
+
class Metadata(BaseModel):
|
|
8
|
+
dbt_version: str = Field(..., regex=r"^\d+\.\d+\.\d+([a-zA-Z0-9]+)?$")
|
|
9
|
+
project_id: Optional[str]
|
|
10
|
+
user_id: Optional[str]
|
|
11
|
+
|
|
12
|
+
class Nodes(BaseModel):
|
|
13
|
+
class Config(BaseModel):
|
|
14
|
+
database: Optional[str]
|
|
15
|
+
schema_: Optional[str] = Field(..., alias="schema")
|
|
16
|
+
tags: List[str]
|
|
17
|
+
|
|
18
|
+
class Column(BaseModel):
|
|
19
|
+
meta: Dict[str, Any]
|
|
20
|
+
tags: List[str]
|
|
21
|
+
|
|
22
|
+
class TestMetadata(BaseModel):
|
|
23
|
+
name: str
|
|
24
|
+
kwargs: Dict[str, Any]
|
|
25
|
+
|
|
26
|
+
class DependsOn(BaseModel):
|
|
27
|
+
macros: List[str] = []
|
|
28
|
+
nodes: List[str] = []
|
|
29
|
+
|
|
30
|
+
unique_id: str
|
|
31
|
+
resource_type: str
|
|
32
|
+
name: str
|
|
33
|
+
alias: str
|
|
34
|
+
database: Optional[str]
|
|
35
|
+
schema_: str = Field(..., alias="schema")
|
|
36
|
+
columns: Optional[Dict[str, Column]]
|
|
37
|
+
meta: Dict[str, Any]
|
|
38
|
+
config: Config
|
|
39
|
+
tags: List[str]
|
|
40
|
+
test_metadata: Optional[TestMetadata]
|
|
41
|
+
depends_on: DependsOn
|
|
42
|
+
|
|
43
|
+
metadata: Metadata
|
|
44
|
+
nodes: Dict[str, Nodes]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class RunResultsJsonConfig(BaseModel):
|
|
48
|
+
class Metadata(BaseModel):
|
|
49
|
+
dbt_version: str = Field(..., regex=r"^\d+\.\d+\.\d+([a-zA-Z0-9]+)?$")
|
|
50
|
+
|
|
51
|
+
class Results(BaseModel):
|
|
52
|
+
class Status(Enum):
|
|
53
|
+
success = "success"
|
|
54
|
+
error = "error"
|
|
55
|
+
skipped = "skipped"
|
|
56
|
+
pass_ = "pass"
|
|
57
|
+
fail = "fail"
|
|
58
|
+
warn = "warn"
|
|
59
|
+
runtime_error = "runtime error"
|
|
60
|
+
|
|
61
|
+
status: Status
|
|
62
|
+
unique_id: str = Field("...")
|
|
63
|
+
|
|
64
|
+
metadata: Metadata
|
|
65
|
+
results: List[Results]
|