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
|
@@ -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,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
|