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
@@ -0,0 +1,308 @@
1
+ import decimal
2
+ from abc import ABC, abstractmethod
3
+ from typing import Collection, List, Optional, Tuple, Type, TypeVar, Union
4
+ from datetime import datetime
5
+
6
+ import attrs
7
+
8
+ from data_diff.utils import ArithAlphanumeric, ArithUUID, Unknown
9
+
10
+
11
+ DbPath = Tuple[str, ...]
12
+ DbKey = Union[int, str, bytes, ArithUUID, ArithAlphanumeric]
13
+ DbTime = datetime
14
+
15
+ N = TypeVar("N")
16
+
17
+
18
+ @attrs.frozen(kw_only=True, eq=False, order=False, unsafe_hash=True)
19
+ class Collation:
20
+ """
21
+ A pre-parsed or pre-known record about db collation, per column.
22
+
23
+ The "greater" collation should be used as a target collation for textual PKs
24
+ on both sides of the diff — by coverting the "lesser" collation to self.
25
+
26
+ Snowflake easily absorbs the performance losses, so it has a boost to always
27
+ be greater than any other collation in non-Snowflake databases.
28
+ Other databases need to negotiate which side absorbs the performance impact.
29
+ """
30
+
31
+ # A boost for special databases that are known to absorb the performance dmaage well.
32
+ absorbs_damage: bool = False
33
+
34
+ # Ordinal soring by ASCII/UTF8 (True), or alphabetic as per locale/country/etc (False).
35
+ ordinal: Optional[bool] = None
36
+
37
+ # Lowercase first (aAbBcC or abcABC). Otherwise, uppercase first (AaBbCc or ABCabc).
38
+ lower_first: Optional[bool] = None
39
+
40
+ # 2-letter lower-case locale and upper-case country codes, e.g. en_US. Ignored for ordinals.
41
+ language: Optional[str] = None
42
+ country: Optional[str] = None
43
+
44
+ # There are also space-, punctuation-, width-, kana-(in)sensitivity, so on.
45
+ # Ignore everything not related to xdb alignment. Only case- & accent-sensitivity are common.
46
+ case_sensitive: Optional[bool] = None
47
+ accent_sensitive: Optional[bool] = None
48
+
49
+ # Purely informational, for debugging:
50
+ _source: Union[None, str, Collection[str]] = None
51
+
52
+ def __eq__(self, other: object) -> bool:
53
+ if not isinstance(other, Collation):
54
+ return NotImplemented
55
+ if self.ordinal and other.ordinal:
56
+ # TODO: does it depend on language? what does Albanic_BIN mean in MS SQL?
57
+ return True
58
+ return (
59
+ self.language == other.language
60
+ and (self.country is None or other.country is None or self.country == other.country)
61
+ and self.case_sensitive == other.case_sensitive
62
+ and self.accent_sensitive == other.accent_sensitive
63
+ and self.lower_first == other.lower_first
64
+ )
65
+
66
+ def __ne__(self, other: object) -> bool:
67
+ if not isinstance(other, Collation):
68
+ return NotImplemented
69
+ return not self.__eq__(other)
70
+
71
+ def __gt__(self, other: object) -> bool:
72
+ if not isinstance(other, Collation):
73
+ return NotImplemented
74
+ if self == other:
75
+ return False
76
+ if self.absorbs_damage and not other.absorbs_damage:
77
+ return False
78
+ if other.absorbs_damage and not self.absorbs_damage:
79
+ return True # this one is preferred if it cannot absorb damage as its counterpart can
80
+ if self.ordinal and not other.ordinal:
81
+ return True
82
+ if other.ordinal and not self.ordinal:
83
+ return False
84
+ # TODO: try to align the languages & countries?
85
+ return False
86
+
87
+ def __ge__(self, other: object) -> bool:
88
+ if not isinstance(other, Collation):
89
+ return NotImplemented
90
+ return self == other or self.__gt__(other)
91
+
92
+ def __lt__(self, other: object) -> bool:
93
+ if not isinstance(other, Collation):
94
+ return NotImplemented
95
+ return self != other and not self.__gt__(other)
96
+
97
+ def __le__(self, other: object) -> bool:
98
+ if not isinstance(other, Collation):
99
+ return NotImplemented
100
+ return self == other or not self.__gt__(other)
101
+
102
+
103
+ @attrs.define(frozen=True, kw_only=True)
104
+ class ColType:
105
+ # Arbitrary metadata added and fetched at runtime.
106
+ _notes: List[N] = attrs.field(factory=list, init=False, hash=False, eq=False)
107
+
108
+ def add_note(self, note: N) -> None:
109
+ self._notes.append(note)
110
+
111
+ def get_note(self, cls: Type[N]) -> Optional[N]:
112
+ """Get the latest added note of type ``cls`` or its descendants."""
113
+ for note in reversed(self._notes):
114
+ if isinstance(note, cls):
115
+ return note
116
+ return None
117
+
118
+ @property
119
+ def supported(self) -> bool:
120
+ return True
121
+
122
+
123
+ @attrs.define(frozen=True)
124
+ class PrecisionType(ColType):
125
+ precision: int
126
+ rounds: Union[bool, Unknown] = Unknown
127
+
128
+
129
+ @attrs.define(frozen=True)
130
+ class Boolean(ColType):
131
+ precision = 0
132
+
133
+
134
+ @attrs.define(frozen=True)
135
+ class TemporalType(PrecisionType):
136
+ pass
137
+
138
+
139
+ @attrs.define(frozen=True)
140
+ class Timestamp(TemporalType):
141
+ pass
142
+
143
+
144
+ @attrs.define(frozen=True)
145
+ class TimestampTZ(TemporalType):
146
+ pass
147
+
148
+
149
+ @attrs.define(frozen=True)
150
+ class Datetime(TemporalType):
151
+ pass
152
+
153
+
154
+ @attrs.define(frozen=True)
155
+ class Date(TemporalType):
156
+ pass
157
+
158
+
159
+ @attrs.define(frozen=True)
160
+ class Time(TemporalType):
161
+ pass
162
+
163
+
164
+ @attrs.define(frozen=True)
165
+ class NumericType(ColType):
166
+ # 'precision' signifies how many fractional digits (after the dot) we want to compare
167
+ precision: int
168
+
169
+
170
+ @attrs.define(frozen=True)
171
+ class FractionalType(NumericType):
172
+ pass
173
+
174
+
175
+ @attrs.define(frozen=True)
176
+ class Float(FractionalType):
177
+ python_type = float
178
+
179
+
180
+ @attrs.define(frozen=True)
181
+ class IKey(ABC):
182
+ "Interface for ColType, for using a column as a key in table."
183
+
184
+ @property
185
+ @abstractmethod
186
+ def python_type(self) -> type:
187
+ "Return the equivalent Python type of the key"
188
+
189
+ def make_value(self, value):
190
+ if isinstance(value, self.python_type):
191
+ return value
192
+ return self.python_type(value)
193
+
194
+
195
+ @attrs.define(frozen=True)
196
+ class Decimal(FractionalType, IKey): # Snowflake may use Decimal as a key
197
+ @property
198
+ def python_type(self) -> type:
199
+ if self.precision == 0:
200
+ return int
201
+ return decimal.Decimal
202
+
203
+
204
+ @attrs.define(frozen=True)
205
+ class StringType(ColType):
206
+ python_type = str
207
+ collation: Optional[Collation] = attrs.field(default=None, kw_only=True)
208
+
209
+
210
+ @attrs.define(frozen=True)
211
+ class ColType_UUID(ColType, IKey):
212
+ python_type = ArithUUID
213
+
214
+
215
+ @attrs.define(frozen=True)
216
+ class ColType_Alphanum(ColType, IKey):
217
+ python_type = ArithAlphanumeric
218
+
219
+
220
+ @attrs.define(frozen=True)
221
+ class Native_UUID(ColType_UUID):
222
+ pass
223
+
224
+
225
+ @attrs.define(frozen=True)
226
+ class String_UUID(ColType_UUID, StringType):
227
+ # Case is important for UUIDs stored as regular string, not native UUIDs stored as numbers.
228
+ # We slice them internally as numbers, but render them back to SQL as lower/upper case.
229
+ # None means we do not know for sure, behave as with False, but it might be unreliable.
230
+ lowercase: Optional[bool] = None
231
+ uppercase: Optional[bool] = None
232
+
233
+ def make_value(self, v: str) -> ArithUUID:
234
+ return self.python_type(v, lowercase=self.lowercase, uppercase=self.uppercase)
235
+
236
+
237
+ @attrs.define(frozen=True)
238
+ class String_Alphanum(ColType_Alphanum, StringType):
239
+ @staticmethod
240
+ def test_value(value: str) -> bool:
241
+ try:
242
+ ArithAlphanumeric(value)
243
+ return True
244
+ except ValueError:
245
+ return False
246
+
247
+
248
+ @attrs.define(frozen=True)
249
+ class String_VaryingAlphanum(String_Alphanum):
250
+ pass
251
+
252
+
253
+ @attrs.define(frozen=True)
254
+ class String_FixedAlphanum(String_Alphanum):
255
+ length: int
256
+
257
+ def make_value(self, value):
258
+ if isinstance(value, self.python_type):
259
+ return value
260
+ if len(value) != self.length:
261
+ raise ValueError(f"Expected alphanumeric value of length {self.length}, but got '{value}'.")
262
+ return self.python_type(value, max_len=self.length)
263
+
264
+
265
+ @attrs.define(frozen=True)
266
+ class Text(StringType):
267
+ @property
268
+ def supported(self) -> bool:
269
+ return False
270
+
271
+
272
+ # In majority of DBMSes, it is called JSON/JSONB. Only in Snowflake, it is OBJECT.
273
+ @attrs.define(frozen=True)
274
+ class JSON(ColType):
275
+ pass
276
+
277
+
278
+ @attrs.define(frozen=True)
279
+ class Array(ColType):
280
+ item_type: ColType
281
+
282
+
283
+ # Unlike JSON, structs are not free-form and have a very specific set of fields and their types.
284
+ # We do not parse & use those fields now, but we can do this later.
285
+ # For example, in BigQuery:
286
+ # - https://cloud.google.com/bigquery/docs/reference/standard-sql/data-types#struct_type
287
+ # - https://cloud.google.com/bigquery/docs/reference/standard-sql/lexical#struct_literals
288
+ @attrs.define(frozen=True)
289
+ class Struct(ColType):
290
+ pass
291
+
292
+
293
+ @attrs.define(frozen=True)
294
+ class Integer(NumericType, IKey):
295
+ precision: int = 0
296
+ python_type: type = int
297
+
298
+ def __attrs_post_init__(self) -> None:
299
+ assert self.precision == 0
300
+
301
+
302
+ @attrs.define(frozen=True)
303
+ class UnknownColType(ColType):
304
+ text: str
305
+
306
+ @property
307
+ def supported(self) -> bool:
308
+ return False
@@ -0,0 +1,2 @@
1
+ from data_diff.cloud.datafold_api import DatafoldAPI, TCloudApiDataDiff, TCloudApiOrgMeta
2
+ from data_diff.cloud.data_source import get_or_create_data_source
@@ -0,0 +1,318 @@
1
+ import json
2
+ import time
3
+ from typing import List, Optional, Union, overload
4
+
5
+ import pydantic
6
+ import rich
7
+ from rich.table import Table
8
+ from rich.prompt import Confirm, Prompt, FloatPrompt, IntPrompt, InvalidResponse
9
+ from typing_extensions import Literal
10
+
11
+ from data_diff.cloud.datafold_api import (
12
+ DatafoldAPI,
13
+ TCloudApiDataSourceConfigSchema,
14
+ TCloudApiDataSource,
15
+ TDsConfig,
16
+ TestDataSourceStatus,
17
+ )
18
+ from data_diff.dbt_parser import DbtParser
19
+
20
+
21
+ UNKNOWN_VALUE = "unknown_value"
22
+
23
+
24
+ class TDataSourceTestStage(pydantic.BaseModel):
25
+ name: str
26
+ status: TestDataSourceStatus
27
+ description: str = ""
28
+
29
+
30
+ class TemporarySchemaPrompt(Prompt):
31
+ response_type = str
32
+
33
+ def process_response(self, value: str) -> str:
34
+ """Convert choices to a bool."""
35
+
36
+ if len(value.split(".")) != 2:
37
+ raise InvalidResponse("Temporary schema should have a format <database>.<schema>")
38
+ return value
39
+
40
+
41
+ class ValueRequiredPrompt(Prompt):
42
+ def process_response(self, value: str) -> str:
43
+ value = super().process_response(value)
44
+ if value == UNKNOWN_VALUE or value is None or value == "":
45
+ raise InvalidResponse("Parameter must not be empty")
46
+ return value
47
+
48
+
49
+ def _validate_temp_schema(temp_schema: str) -> None:
50
+ if len(temp_schema.split(".")) != 2:
51
+ raise ValueError("Temporary schema should have a format <database>.<schema>")
52
+
53
+
54
+ def _get_temp_schema(dbt_parser: DbtParser, db_type: str) -> Optional[str]:
55
+ config = dbt_parser.get_datadiff_config()
56
+ config_prod_database = config.prod_database
57
+ config_prod_schema = config.prod_schema
58
+ if config_prod_database is not None and config_prod_schema is not None:
59
+ temp_schema = f"{config_prod_database}.{config_prod_schema}"
60
+ if db_type == "snowflake":
61
+ return temp_schema.upper()
62
+ elif db_type in {"pg", "postgres_aurora", "postgres_aws_rds", "redshift"}:
63
+ return temp_schema.lower()
64
+ return temp_schema
65
+ return
66
+
67
+
68
+ def create_ds_config(
69
+ ds_config: TCloudApiDataSourceConfigSchema,
70
+ data_source_name: str,
71
+ dbt_parser: Optional[DbtParser] = None,
72
+ ) -> TDsConfig:
73
+ options = _parse_ds_credentials(ds_config=ds_config, only_basic_settings=True, dbt_parser=dbt_parser)
74
+
75
+ temp_schema = _get_temp_schema(dbt_parser=dbt_parser, db_type=ds_config.db_type) if dbt_parser else None
76
+ if temp_schema:
77
+ temp_schema = TemporarySchemaPrompt.ask("Temporary schema", default=temp_schema)
78
+ else:
79
+ temp_schema = TemporarySchemaPrompt.ask("Temporary schema (<database>.<schema>)")
80
+
81
+ float_tolerance = FloatPrompt.ask("Float tolerance", default=0.000001)
82
+
83
+ return TDsConfig(
84
+ name=data_source_name,
85
+ type=ds_config.db_type,
86
+ temp_schema=temp_schema,
87
+ float_tolerance=float_tolerance,
88
+ options=options,
89
+ )
90
+
91
+
92
+ @overload
93
+ def _cast_value(value: str, type_: Literal["integer"]) -> int: ...
94
+
95
+
96
+ @overload
97
+ def _cast_value(value: str, type_: Literal["boolean"]) -> bool: ...
98
+
99
+
100
+ @overload
101
+ def _cast_value(value: str, type_: Literal["string"]) -> str: ...
102
+
103
+
104
+ def _cast_value(value: str, type_: str) -> Union[bool, int, str]:
105
+ if type_ == "integer":
106
+ return int(value)
107
+ elif type_ == "boolean":
108
+ return bool(value)
109
+ return value
110
+
111
+
112
+ def _get_data_from_bigquery_json(path: str):
113
+ with open(path, "r") as file:
114
+ return json.load(file)
115
+
116
+
117
+ def _align_dbt_cred_params_with_datafold_params(dbt_creds: dict) -> dict:
118
+ db_type = dbt_creds["type"]
119
+ if db_type == "bigquery":
120
+ method = dbt_creds["method"]
121
+ if method == "service-account":
122
+ data = _get_data_from_bigquery_json(path=dbt_creds["keyfile"])
123
+ dbt_creds["jsonKeyFile"] = json.dumps(data)
124
+ elif method == "service-account-json":
125
+ dbt_creds["jsonKeyFile"] = json.dumps(dbt_creds["keyfile_json"])
126
+ else:
127
+ rich.print(
128
+ f'[red]Cannot extract bigquery credentials from dbt_project.yml for "{method}" type. '
129
+ f"If you want to provide credentials via dbt_project.yml, "
130
+ f'please, use "service-account" or "service-account-json" '
131
+ f"(more in docs: https://docs.getdbt.com/reference/warehouse-setups/bigquery-setup). "
132
+ f"Otherwise, you can provide a path to a json key file or a json key file data as an input."
133
+ )
134
+ dbt_creds["projectId"] = dbt_creds["project"]
135
+ elif db_type == "snowflake":
136
+ dbt_creds["default_db"] = dbt_creds["database"]
137
+ elif db_type == "databricks":
138
+ dbt_creds["http_password"] = dbt_creds["token"]
139
+ dbt_creds["database"] = dbt_creds.get("catalog")
140
+ return dbt_creds
141
+
142
+
143
+ def _parse_ds_credentials(
144
+ ds_config: TCloudApiDataSourceConfigSchema, only_basic_settings: bool = True, dbt_parser: Optional[DbtParser] = None
145
+ ):
146
+ creds = {}
147
+ use_dbt_data = False
148
+ if dbt_parser is not None:
149
+ use_dbt_data = Confirm.ask("Would you like to extract database credentials from dbt profiles.yml?")
150
+ try:
151
+ creds = dbt_parser.get_connection_creds()[0]
152
+ creds = _align_dbt_cred_params_with_datafold_params(dbt_creds=creds)
153
+ except Exception as e:
154
+ rich.print(f"[red]Cannot parse database credentials from dbt profiles.yml. Reason: {e}")
155
+
156
+ ds_options = {}
157
+ basic_required_fields = set(ds_config.config_schema.required)
158
+ for param_name, param_data in ds_config.config_schema.properties.items():
159
+ if only_basic_settings and param_name not in basic_required_fields:
160
+ continue
161
+
162
+ default_value = param_data.get("default", UNKNOWN_VALUE)
163
+ is_password = bool(param_data.get("format"))
164
+
165
+ title = param_data["title"]
166
+ type_ = param_data["type"]
167
+ input_values = {
168
+ "prompt": title,
169
+ "password": is_password,
170
+ }
171
+ if default_value != UNKNOWN_VALUE:
172
+ input_values["default"] = default_value
173
+
174
+ if use_dbt_data:
175
+ value = creds.get(param_name, UNKNOWN_VALUE)
176
+ if value == UNKNOWN_VALUE:
177
+ rich.print(f'[red]Cannot extract "{param_name}" from dbt profiles.yml. Please, type it manually')
178
+ else:
179
+ ds_options[param_name] = _cast_value(value, type_)
180
+ continue
181
+
182
+ if type_ == "integer":
183
+ value = IntPrompt.ask(**input_values)
184
+ elif type_ == "boolean":
185
+ value = Confirm.ask(title)
186
+ else:
187
+ value = ValueRequiredPrompt.ask(**input_values)
188
+
189
+ ds_options[param_name] = value
190
+ return ds_options
191
+
192
+
193
+ def _check_data_source_exists(
194
+ data_sources: List[TCloudApiDataSource],
195
+ data_source_name: str,
196
+ ) -> Optional[TCloudApiDataSource]:
197
+ for ds in data_sources:
198
+ if ds.name == data_source_name:
199
+ return ds
200
+ return None
201
+
202
+
203
+ def _test_data_source(api: DatafoldAPI, data_source_id: int, timeout: int = 64) -> List[TDataSourceTestStage]:
204
+ job_id = api.test_data_source(data_source_id)
205
+
206
+ checked_tests = {"connection", "temp_schema", "schema_download"}
207
+ seconds = 1
208
+ start = time.monotonic()
209
+ results = []
210
+ while True:
211
+ tests = api.check_data_source_test_results(job_id)
212
+ for test in tests:
213
+ if test.name not in checked_tests:
214
+ continue
215
+
216
+ if test.status == "done":
217
+ checked_tests.remove(test.name)
218
+ results.append(
219
+ TDataSourceTestStage(name=test.name, status=test.result.status, description=test.result.message)
220
+ )
221
+
222
+ if not checked_tests:
223
+ break
224
+
225
+ if time.monotonic() - start > timeout:
226
+ for test_name in checked_tests:
227
+ results.append(
228
+ TDataSourceTestStage(
229
+ name=test_name,
230
+ status=TestDataSourceStatus.SKIP,
231
+ description=f"Does not complete in {timeout} seconds",
232
+ )
233
+ )
234
+ break
235
+ time.sleep(seconds)
236
+ seconds *= 2
237
+
238
+ return results
239
+
240
+
241
+ def _render_data_source(data_source: TCloudApiDataSource, title: str = "") -> None:
242
+ table = Table(title=title, min_width=80)
243
+ table.add_column("Parameter", justify="center", style="cyan")
244
+ table.add_column("Value", justify="center", style="magenta")
245
+ table.add_row("ID", str(data_source.id))
246
+ table.add_row("Name", data_source.name)
247
+ table.add_row("Type", data_source.type)
248
+ rich.print(table)
249
+
250
+
251
+ def _render_available_data_sources(data_source_schema_configs: List[TCloudApiDataSourceConfigSchema]) -> None:
252
+ config_names = [ds_config.name for ds_config in data_source_schema_configs]
253
+
254
+ table = Table()
255
+ table.add_column("", justify="center", style="cyan")
256
+ table.add_column("Available data sources", style="magenta")
257
+ for i, db_type in enumerate(config_names, start=1):
258
+ table.add_row(str(i), db_type)
259
+ rich.print(table)
260
+
261
+
262
+ def _render_data_source_test_results(test_results: List[TDataSourceTestStage]) -> None:
263
+ table = Table(title="Test results", min_width=80)
264
+ table.add_column(
265
+ "Test",
266
+ justify="center",
267
+ style="cyan",
268
+ )
269
+ table.add_column("Status", justify="center", style="magenta")
270
+ table.add_column("Description", justify="center", style="magenta")
271
+ for result in test_results:
272
+ table.add_row(result.name, result.status, result.description)
273
+ rich.print(table)
274
+
275
+
276
+ def get_or_create_data_source(api: DatafoldAPI, dbt_parser: Optional[DbtParser] = None) -> int:
277
+ ds_configs = api.get_data_source_schema_config()
278
+ data_sources = api.get_data_sources()
279
+
280
+ _render_available_data_sources(data_source_schema_configs=ds_configs)
281
+ db_type_num = IntPrompt.ask(
282
+ prompt="What data source type do you want to create? Please, select a number",
283
+ choices=list(map(str, range(1, len(ds_configs) + 1))),
284
+ show_choices=False,
285
+ )
286
+
287
+ ds_config = ds_configs[db_type_num - 1]
288
+ default_ds_name = ds_config.name
289
+ rich.print("Press enter to accept the (Default value)")
290
+ ds_name = Prompt.ask("Data source name", default=default_ds_name)
291
+
292
+ ds = _check_data_source_exists(data_sources=data_sources, data_source_name=ds_name)
293
+ if ds is not None:
294
+ _render_data_source(data_source=ds, title=f'Found existing data source for name "{ds.name}"')
295
+ use_existing_ds = Confirm.ask("Would you like to continue with the existing data source?")
296
+ if not use_existing_ds:
297
+ return get_or_create_data_source(api=api, dbt_parser=dbt_parser)
298
+ return ds.id
299
+
300
+ ds_config = create_ds_config(ds_config=ds_config, data_source_name=ds_name, dbt_parser=dbt_parser)
301
+ ds = api.create_data_source(ds_config)
302
+ data_source_url = f"{api.host}/settings/integrations/dwh/{ds.type}/{ds.id}"
303
+ _render_data_source(data_source=ds, title=f"Created a new data source with ID = {ds.id} ({data_source_url})")
304
+
305
+ rich.print(
306
+ "We recommend to run tests for a new data source. "
307
+ "It requires some time but makes sure that the data source is configured correctly."
308
+ )
309
+ run_tests = Confirm.ask("Would you like to run tests?")
310
+ if run_tests:
311
+ test_results = _test_data_source(api=api, data_source_id=ds.id)
312
+ _render_data_source_test_results(test_results=test_results)
313
+ if any(result.status == TestDataSourceStatus.FAILED for result in test_results):
314
+ raise ValueError(
315
+ f"Data source tests failed. Please, try to update or test data source in the UI: {data_source_url}"
316
+ )
317
+
318
+ return ds.id