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.
Files changed (54) hide show
  1. collate_data_diff-0.11.2.dist-info/LICENSE +18 -0
  2. collate_data_diff-0.11.2.dist-info/METADATA +77 -0
  3. collate_data_diff-0.11.2.dist-info/RECORD +54 -0
  4. collate_data_diff-0.11.2.dist-info/WHEEL +4 -0
  5. collate_data_diff-0.11.2.dist-info/entry_points.txt +3 -0
  6. data_diff/__init__.py +180 -0
  7. data_diff/__main__.py +618 -0
  8. data_diff/abcs/__init__.py +0 -0
  9. data_diff/abcs/compiler.py +13 -0
  10. data_diff/abcs/database_types.py +308 -0
  11. data_diff/cloud/__init__.py +2 -0
  12. data_diff/cloud/data_source.py +318 -0
  13. data_diff/cloud/datafold_api.py +304 -0
  14. data_diff/config.py +127 -0
  15. data_diff/databases/__init__.py +17 -0
  16. data_diff/databases/_connect.py +306 -0
  17. data_diff/databases/base.py +1291 -0
  18. data_diff/databases/bigquery.py +315 -0
  19. data_diff/databases/clickhouse.py +203 -0
  20. data_diff/databases/databricks.py +248 -0
  21. data_diff/databases/duckdb.py +192 -0
  22. data_diff/databases/mssql.py +229 -0
  23. data_diff/databases/mysql.py +159 -0
  24. data_diff/databases/oracle.py +195 -0
  25. data_diff/databases/postgresql.py +258 -0
  26. data_diff/databases/presto.py +197 -0
  27. data_diff/databases/redshift.py +217 -0
  28. data_diff/databases/snowflake.py +207 -0
  29. data_diff/databases/trino.py +50 -0
  30. data_diff/databases/vertica.py +160 -0
  31. data_diff/dbt.py +604 -0
  32. data_diff/dbt_config_validators.py +65 -0
  33. data_diff/dbt_parser.py +523 -0
  34. data_diff/diff_tables.py +416 -0
  35. data_diff/errors.py +74 -0
  36. data_diff/format.py +359 -0
  37. data_diff/hashdiff_tables.py +264 -0
  38. data_diff/info_tree.py +62 -0
  39. data_diff/joindiff_tables.py +399 -0
  40. data_diff/lexicographic_space.py +240 -0
  41. data_diff/parse_time.py +74 -0
  42. data_diff/py.typed +0 -0
  43. data_diff/queries/__init__.py +0 -0
  44. data_diff/queries/api.py +200 -0
  45. data_diff/queries/ast_classes.py +798 -0
  46. data_diff/queries/base.py +24 -0
  47. data_diff/queries/extras.py +29 -0
  48. data_diff/query_utils.py +56 -0
  49. data_diff/schema.py +52 -0
  50. data_diff/table_segment.py +286 -0
  51. data_diff/thread_utils.py +98 -0
  52. data_diff/tracking.py +237 -0
  53. data_diff/utils.py +625 -0
  54. 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]