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,304 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import enum
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any, Dict, List, Optional, Type, Tuple
|
|
5
|
+
|
|
6
|
+
import attrs
|
|
7
|
+
import pydantic
|
|
8
|
+
import requests
|
|
9
|
+
from typing_extensions import Self
|
|
10
|
+
|
|
11
|
+
from data_diff.errors import DataDiffCloudDiffFailed, DataDiffCloudDiffTimedOut, DataDiffDatasourceIdNotFoundError
|
|
12
|
+
from data_diff.utils import getLogger
|
|
13
|
+
|
|
14
|
+
logger = getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TestDataSourceStatus(str, enum.Enum):
|
|
18
|
+
SUCCESS = "ok"
|
|
19
|
+
FAILED = "error"
|
|
20
|
+
SKIP = "skip"
|
|
21
|
+
UNKNOWN = "unknown"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TCloudApiDataSourceSchema(pydantic.BaseModel):
|
|
25
|
+
title: str
|
|
26
|
+
properties: Dict[str, Dict[str, Any]]
|
|
27
|
+
required: List[str]
|
|
28
|
+
secret: List[str]
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def from_orm(cls, obj: Any) -> Self:
|
|
32
|
+
data_source_types_required_parameters = {
|
|
33
|
+
"bigquery": ["projectId", "jsonKeyFile", "location"],
|
|
34
|
+
"databricks": ["host", "http_password", "database", "http_path"],
|
|
35
|
+
"mysql": ["host", "user", "passwd", "db"],
|
|
36
|
+
"pg": ["host", "user", "port", "password", "dbname"],
|
|
37
|
+
"postgres_aurora": ["host", "user", "port", "password", "dbname"],
|
|
38
|
+
"postgres_aws_rds": ["host", "user", "port", "password", "dbname"],
|
|
39
|
+
"redshift": ["host", "user", "port", "password", "dbname"],
|
|
40
|
+
"snowflake": ["account", "user", "password", "warehouse", "role", "default_db"],
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return cls(
|
|
44
|
+
title=obj["configuration_schema"]["title"],
|
|
45
|
+
properties=obj["configuration_schema"]["properties"],
|
|
46
|
+
required=data_source_types_required_parameters[obj["type"]],
|
|
47
|
+
secret=obj["configuration_schema"]["secret"],
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TCloudApiDataSourceConfigSchema(pydantic.BaseModel):
|
|
52
|
+
name: str
|
|
53
|
+
db_type: str
|
|
54
|
+
config_schema: TCloudApiDataSourceSchema
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TCloudApiDataSource(pydantic.BaseModel):
|
|
58
|
+
id: Optional[int] = None
|
|
59
|
+
name: str
|
|
60
|
+
type: str
|
|
61
|
+
is_paused: Optional[bool] = False
|
|
62
|
+
hidden: Optional[bool] = False
|
|
63
|
+
temp_schema: Optional[str] = None
|
|
64
|
+
disable_schema_indexing: Optional[bool] = False
|
|
65
|
+
disable_profiling: Optional[bool] = False
|
|
66
|
+
catalog_include_list: Optional[str] = None
|
|
67
|
+
catalog_exclude_list: Optional[str] = None
|
|
68
|
+
schema_indexing_schedule: Optional[str] = None
|
|
69
|
+
schema_max_age_s: Optional[int] = None
|
|
70
|
+
profile_schedule: Optional[str] = None
|
|
71
|
+
profile_exclude_list: Optional[str] = None
|
|
72
|
+
profile_include_list: Optional[str] = None
|
|
73
|
+
discourage_manual_profiling: Optional[bool] = False
|
|
74
|
+
lineage_schedule: Optional[str] = None
|
|
75
|
+
float_tolerance: Optional[float] = 0.0
|
|
76
|
+
options: Optional[Dict[str, Any]] = None
|
|
77
|
+
queue_name: Optional[str] = None
|
|
78
|
+
scheduled_queue_name: Optional[str] = None
|
|
79
|
+
groups: Optional[Dict[int, bool]] = None
|
|
80
|
+
view_only: Optional[bool] = False
|
|
81
|
+
created_from: Optional[str] = None
|
|
82
|
+
source: Optional[str] = None
|
|
83
|
+
max_allowed_connections: Optional[int] = None
|
|
84
|
+
last_test: Optional[Any] = None
|
|
85
|
+
secret_id: Optional[int] = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class TDsConfig(pydantic.BaseModel):
|
|
89
|
+
name: str
|
|
90
|
+
type: str
|
|
91
|
+
temp_schema: str
|
|
92
|
+
float_tolerance: float = 0.0
|
|
93
|
+
options: Dict[str, Any]
|
|
94
|
+
disable_schema_indexing: bool = True
|
|
95
|
+
disable_profiling: bool = True
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class TCloudApiDataDiff(pydantic.BaseModel):
|
|
99
|
+
data_source1_id: int
|
|
100
|
+
data_source2_id: int
|
|
101
|
+
table1: List[str]
|
|
102
|
+
table2: List[str]
|
|
103
|
+
pk_columns: List[str]
|
|
104
|
+
filter1: Optional[str] = None
|
|
105
|
+
filter2: Optional[str] = None
|
|
106
|
+
include_columns: Optional[List[str]]
|
|
107
|
+
exclude_columns: Optional[List[str]]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class TCloudApiOrgMeta(pydantic.BaseModel):
|
|
111
|
+
org_id: int
|
|
112
|
+
org_name: str
|
|
113
|
+
user_id: int
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class TSummaryResultPrimaryKeyStats(pydantic.BaseModel):
|
|
117
|
+
total_rows: Tuple[int, int]
|
|
118
|
+
nulls: Tuple[int, int]
|
|
119
|
+
dupes: Tuple[int, int]
|
|
120
|
+
exclusives: Tuple[int, int]
|
|
121
|
+
distincts: Tuple[int, int]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class TSummaryResultColumnDiffStats(pydantic.BaseModel):
|
|
125
|
+
column_name: str
|
|
126
|
+
match: float
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class TSummaryResultValueStats(pydantic.BaseModel):
|
|
130
|
+
total_rows: int
|
|
131
|
+
rows_with_differences: int
|
|
132
|
+
total_values: int
|
|
133
|
+
compared_columns: int
|
|
134
|
+
columns_with_differences: int
|
|
135
|
+
columns_diff_stats: List[TSummaryResultColumnDiffStats]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class TSummaryResultSchemaStats(pydantic.BaseModel):
|
|
139
|
+
columns_mismatched: Tuple[int, int]
|
|
140
|
+
column_type_mismatches: int
|
|
141
|
+
column_reorders: int
|
|
142
|
+
column_counts: Tuple[int, int]
|
|
143
|
+
column_type_differs: List[str]
|
|
144
|
+
exclusive_columns: Tuple[List[str], List[str]]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class TSummaryResultDependencyDetails(pydantic.BaseModel):
|
|
148
|
+
deps: Dict[str, List[Dict]]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class TCloudApiDataDiffSummaryResult(pydantic.BaseModel):
|
|
152
|
+
status: str
|
|
153
|
+
pks: Optional[TSummaryResultPrimaryKeyStats]
|
|
154
|
+
values: Optional[TSummaryResultValueStats]
|
|
155
|
+
schema_: Optional[TSummaryResultSchemaStats]
|
|
156
|
+
deps: Optional[TSummaryResultDependencyDetails]
|
|
157
|
+
|
|
158
|
+
@classmethod
|
|
159
|
+
def from_orm(cls, obj: Any) -> Self:
|
|
160
|
+
pks = TSummaryResultPrimaryKeyStats(**obj["pks"]) if "pks" in obj else None
|
|
161
|
+
values = TSummaryResultValueStats(**obj["values"]) if "values" in obj else None
|
|
162
|
+
deps = TSummaryResultDependencyDetails(**obj["dependencies"]) if "dependencies" in obj else None
|
|
163
|
+
schema = TSummaryResultSchemaStats(**obj["schema"]) if "schema" in obj else None
|
|
164
|
+
return cls(
|
|
165
|
+
status=obj["status"],
|
|
166
|
+
pks=pks,
|
|
167
|
+
values=values,
|
|
168
|
+
schema_=schema,
|
|
169
|
+
deps=deps,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class TCloudDataSourceTestResult(pydantic.BaseModel):
|
|
174
|
+
status: TestDataSourceStatus
|
|
175
|
+
message: str
|
|
176
|
+
outcome: str
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class TCloudApiDataSourceTestResult(pydantic.BaseModel):
|
|
180
|
+
name: str
|
|
181
|
+
status: str
|
|
182
|
+
result: Optional[TCloudDataSourceTestResult]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@attrs.define(frozen=False)
|
|
186
|
+
class DatafoldAPI:
|
|
187
|
+
api_key: str
|
|
188
|
+
headers: str = ""
|
|
189
|
+
host: str = "https://app.datafold.com"
|
|
190
|
+
timeout: int = 30
|
|
191
|
+
|
|
192
|
+
def __attrs_post_init__(self) -> None:
|
|
193
|
+
self.host = self.host.rstrip("/")
|
|
194
|
+
self.headers = {
|
|
195
|
+
"Authorization": f"Key {self.api_key}",
|
|
196
|
+
"Content-Type": "application/json",
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
def make_get_request(self, url: str) -> Any:
|
|
200
|
+
rv = requests.get(url=f"{self.host}/{url}", headers=self.headers, timeout=self.timeout)
|
|
201
|
+
rv.raise_for_status()
|
|
202
|
+
return rv
|
|
203
|
+
|
|
204
|
+
def make_post_request(self, url: str, payload: Any) -> Any:
|
|
205
|
+
rv = requests.post(url=f"{self.host}/{url}", headers=self.headers, json=payload, timeout=self.timeout)
|
|
206
|
+
rv.raise_for_status()
|
|
207
|
+
return rv
|
|
208
|
+
|
|
209
|
+
def get_data_sources(self) -> List[TCloudApiDataSource]:
|
|
210
|
+
rv = self.make_get_request(url="api/v1/data_sources")
|
|
211
|
+
rv.raise_for_status()
|
|
212
|
+
return [TCloudApiDataSource(**item) for item in rv.json()]
|
|
213
|
+
|
|
214
|
+
def get_data_source(self, data_source_id: int) -> TCloudApiDataSource:
|
|
215
|
+
rv = self.make_get_request(url=f"api/v1/data_sources")
|
|
216
|
+
rv.raise_for_status()
|
|
217
|
+
response_json = rv.json()
|
|
218
|
+
datasource = next((datasource for datasource in response_json if datasource["id"] == data_source_id), None)
|
|
219
|
+
if not datasource:
|
|
220
|
+
raise DataDiffDatasourceIdNotFoundError(
|
|
221
|
+
f"Datasource ID: {data_source_id} was not found in your Datafold account!"
|
|
222
|
+
)
|
|
223
|
+
return TCloudApiDataSource(**datasource)
|
|
224
|
+
|
|
225
|
+
def create_data_source(self, config: TDsConfig) -> TCloudApiDataSource:
|
|
226
|
+
payload = config.dict()
|
|
227
|
+
if config.type == "bigquery":
|
|
228
|
+
json_string = payload["options"]["jsonKeyFile"].encode("utf-8")
|
|
229
|
+
payload["options"]["jsonKeyFile"] = base64.b64encode(json_string).decode("utf-8")
|
|
230
|
+
rv = self.make_post_request(url="api/v1/data_sources", payload=payload)
|
|
231
|
+
return TCloudApiDataSource(**rv.json())
|
|
232
|
+
|
|
233
|
+
def get_data_source_schema_config(
|
|
234
|
+
self,
|
|
235
|
+
only_important_properties: bool = False,
|
|
236
|
+
) -> List[TCloudApiDataSourceConfigSchema]:
|
|
237
|
+
rv = self.make_get_request(url="api/v1/data_sources/types")
|
|
238
|
+
return [
|
|
239
|
+
TCloudApiDataSourceConfigSchema(
|
|
240
|
+
name=item["name"],
|
|
241
|
+
db_type=item["type"],
|
|
242
|
+
config_schema=TCloudApiDataSourceSchema.from_orm(obj=item),
|
|
243
|
+
)
|
|
244
|
+
for item in rv.json()
|
|
245
|
+
]
|
|
246
|
+
|
|
247
|
+
def create_data_diff(self, payload: TCloudApiDataDiff) -> int:
|
|
248
|
+
rv = self.make_post_request(url="api/v1/datadiffs", payload=payload.dict())
|
|
249
|
+
return rv.json()["id"]
|
|
250
|
+
|
|
251
|
+
def poll_data_diff_results(self, diff_id: int) -> TCloudApiDataDiffSummaryResult:
|
|
252
|
+
summary_results = None
|
|
253
|
+
start_time = time.monotonic()
|
|
254
|
+
sleep_interval = 3
|
|
255
|
+
max_sleep_interval = 20
|
|
256
|
+
max_wait_time = 300
|
|
257
|
+
|
|
258
|
+
diff_url = f"{self.host}/datadiffs/{diff_id}/overview"
|
|
259
|
+
while not summary_results:
|
|
260
|
+
logger.debug("Polling Datafold for results...")
|
|
261
|
+
response = self.make_get_request(url=f"api/v1/datadiffs/{diff_id}/summary_results")
|
|
262
|
+
response_json = response.json()
|
|
263
|
+
if response_json["status"] == "success":
|
|
264
|
+
summary_results = response_json
|
|
265
|
+
elif response_json["status"] == "failed":
|
|
266
|
+
raise DataDiffCloudDiffFailed(f"Diff failed: {str(response_json)}")
|
|
267
|
+
|
|
268
|
+
if time.monotonic() - start_time > max_wait_time:
|
|
269
|
+
raise DataDiffCloudDiffTimedOut(
|
|
270
|
+
f"Timed out waiting for diff results. Please, go to the UI for details: {diff_url}"
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
time.sleep(sleep_interval)
|
|
274
|
+
sleep_interval = min(sleep_interval + 1, max_sleep_interval)
|
|
275
|
+
|
|
276
|
+
return TCloudApiDataDiffSummaryResult.from_orm(summary_results)
|
|
277
|
+
|
|
278
|
+
def test_data_source(self, data_source_id: int) -> int:
|
|
279
|
+
rv = self.make_post_request(f"api/v1/data_sources/{data_source_id}/test", {})
|
|
280
|
+
return rv.json()["job_id"]
|
|
281
|
+
|
|
282
|
+
def check_data_source_test_results(self, job_id: int) -> List[TCloudApiDataSourceTestResult]:
|
|
283
|
+
rv = self.make_get_request(f"api/v1/data_sources/test/{job_id}")
|
|
284
|
+
return [
|
|
285
|
+
TCloudApiDataSourceTestResult(
|
|
286
|
+
name=item["step"],
|
|
287
|
+
status=item["status"],
|
|
288
|
+
result=TCloudDataSourceTestResult(
|
|
289
|
+
status=item["result"]["code"].lower(),
|
|
290
|
+
message=item["result"]["message"],
|
|
291
|
+
outcome=item["result"]["outcome"],
|
|
292
|
+
)
|
|
293
|
+
if item["result"] is not None
|
|
294
|
+
else None,
|
|
295
|
+
)
|
|
296
|
+
for item in rv.json()["results"]
|
|
297
|
+
]
|
|
298
|
+
|
|
299
|
+
def get_org_meta(self) -> TCloudApiOrgMeta:
|
|
300
|
+
response = self.make_get_request(f"api/v1/organization/meta")
|
|
301
|
+
response_json = response.json()
|
|
302
|
+
return TCloudApiOrgMeta(
|
|
303
|
+
org_id=response_json["org_id"], org_name=response_json["org_name"], user_id=response_json["user_id"]
|
|
304
|
+
)
|
data_diff/config.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import os
|
|
3
|
+
from typing import Any, Dict
|
|
4
|
+
import toml
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
_ARRAY_FIELDS = (
|
|
8
|
+
"key_columns",
|
|
9
|
+
"columns",
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConfigParseError(Exception):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def is_uri(s: str) -> bool:
|
|
18
|
+
return "://" in s
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _apply_config(config: Dict[str, Any], run_name: str, kw: Dict[str, Any]):
|
|
22
|
+
_resolve_env(config)
|
|
23
|
+
|
|
24
|
+
# Load config
|
|
25
|
+
databases = config.pop("database", {})
|
|
26
|
+
runs = config.pop("run", {})
|
|
27
|
+
if config:
|
|
28
|
+
raise ConfigParseError(f"Unknown option(s): {config}")
|
|
29
|
+
|
|
30
|
+
# Init run_args
|
|
31
|
+
run_args = runs.get("default") or {}
|
|
32
|
+
if run_name:
|
|
33
|
+
if run_name not in runs:
|
|
34
|
+
raise ConfigParseError(f"Cannot find run '{run_name}' in configuration.")
|
|
35
|
+
run_args.update(runs[run_name])
|
|
36
|
+
else:
|
|
37
|
+
run_name = "default"
|
|
38
|
+
|
|
39
|
+
if kw.get("database1") is not None:
|
|
40
|
+
for attr in ("table1", "database2", "table2"):
|
|
41
|
+
if kw[attr] is None:
|
|
42
|
+
raise ValueError(f"Specified database1 but not {attr}. Must specify all 4 arguments, or neither.")
|
|
43
|
+
|
|
44
|
+
for index in "12":
|
|
45
|
+
run_args[index] = {attr: kw.pop(f"{attr}{index}") for attr in ("database", "table")}
|
|
46
|
+
|
|
47
|
+
# Make sure array fields are decoded as list, since array fields in toml are decoded as list, but TableSegment object requires tuple type.
|
|
48
|
+
for field in _ARRAY_FIELDS:
|
|
49
|
+
if isinstance(run_args.get(field), list):
|
|
50
|
+
run_args[field] = tuple(run_args[field])
|
|
51
|
+
|
|
52
|
+
# Process databases + tables
|
|
53
|
+
for index in "12":
|
|
54
|
+
try:
|
|
55
|
+
args = run_args.pop(index)
|
|
56
|
+
except KeyError:
|
|
57
|
+
raise ConfigParseError(
|
|
58
|
+
f"Could not find source #{index}: Expecting a key of '{index}' containing '.database' and '.table'."
|
|
59
|
+
)
|
|
60
|
+
for attr in ("database", "table"):
|
|
61
|
+
if attr not in args:
|
|
62
|
+
raise ConfigParseError(f"Running 'run.{run_name}': Connection #{index} is missing attribute '{attr}'.")
|
|
63
|
+
|
|
64
|
+
database = args.pop("database")
|
|
65
|
+
table = args.pop("table")
|
|
66
|
+
threads = args.pop("threads", None)
|
|
67
|
+
if args:
|
|
68
|
+
raise ConfigParseError(f"Unexpected attributes for connection #{index}: {args}")
|
|
69
|
+
|
|
70
|
+
if not is_uri(database):
|
|
71
|
+
if database not in databases:
|
|
72
|
+
raise ConfigParseError(
|
|
73
|
+
f"Database '{database}' not found in list of databases. Available: {list(databases)}."
|
|
74
|
+
)
|
|
75
|
+
database = dict(databases[database])
|
|
76
|
+
assert isinstance(database, dict)
|
|
77
|
+
if "driver" not in database:
|
|
78
|
+
raise ConfigParseError(f"Database '{database}' did not specify a driver.")
|
|
79
|
+
|
|
80
|
+
run_args[f"database{index}"] = database
|
|
81
|
+
run_args[f"table{index}"] = table
|
|
82
|
+
if threads is not None:
|
|
83
|
+
run_args[f"threads{index}"] = int(threads)
|
|
84
|
+
|
|
85
|
+
# Update keywords
|
|
86
|
+
new_kw = dict(kw) # Set defaults
|
|
87
|
+
new_kw.update(run_args) # Apply config
|
|
88
|
+
new_kw.update({k: v for k, v in kw.items() if v}) # Apply non-empty defaults
|
|
89
|
+
|
|
90
|
+
new_kw["__conf__"] = run_args
|
|
91
|
+
|
|
92
|
+
return new_kw
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# There are no strict requirements for the environment variable name format.
|
|
96
|
+
# But most shells only allow alphanumeric characters and underscores.
|
|
97
|
+
# https://pubs.opengroup.org/onlinepubs/000095399/basedefs/xbd_chap08.html
|
|
98
|
+
# "Environment variable names (...) consist solely of uppercase letters, digits, and the '_' (underscore)"
|
|
99
|
+
_ENV_VAR_PATTERN = r"\$\{([A-Za-z0-9_]+)\}"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _resolve_env(config: Dict[str, Any]) -> None:
|
|
103
|
+
"""
|
|
104
|
+
Resolve environment variables referenced as ${ENV_VAR_NAME}.
|
|
105
|
+
Missing environment variables are replaced with an empty string.
|
|
106
|
+
"""
|
|
107
|
+
for key, value in config.items():
|
|
108
|
+
if isinstance(value, dict):
|
|
109
|
+
_resolve_env(value)
|
|
110
|
+
elif isinstance(value, str):
|
|
111
|
+
config[key] = re.sub(_ENV_VAR_PATTERN, _replace_match, value)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _replace_match(match: re.Match) -> str:
|
|
115
|
+
# Lookup referenced variable in environment.
|
|
116
|
+
# Replace with empty string if not found
|
|
117
|
+
referenced_var = match.group(1) # group(0) is the whole string
|
|
118
|
+
return os.environ.get(referenced_var, "")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def apply_config_from_file(path: str, run_name: str, kw: Dict[str, Any]):
|
|
122
|
+
with open(path) as f:
|
|
123
|
+
return _apply_config(toml.load(f), run_name, kw)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def apply_config_from_string(toml_config: str, run_name: str, kw: Dict[str, Any]):
|
|
127
|
+
return _apply_config(toml.loads(toml_config), run_name, kw)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from data_diff.databases.base import MD5_HEXDIGITS, CHECKSUM_HEXDIGITS, QueryError, ConnectError, BaseDialect, Database
|
|
2
|
+
from data_diff.databases.base import CHECKSUM_OFFSET
|
|
3
|
+
from data_diff.databases._connect import connect as connect
|
|
4
|
+
from data_diff.databases._connect import Connect as Connect
|
|
5
|
+
from data_diff.databases.postgresql import PostgreSQL as PostgreSQL
|
|
6
|
+
from data_diff.databases.mysql import MySQL as MySQL
|
|
7
|
+
from data_diff.databases.oracle import Oracle as Oracle
|
|
8
|
+
from data_diff.databases.snowflake import Snowflake as Snowflake
|
|
9
|
+
from data_diff.databases.bigquery import BigQuery as BigQuery
|
|
10
|
+
from data_diff.databases.redshift import Redshift as Redshift
|
|
11
|
+
from data_diff.databases.presto import Presto as Presto
|
|
12
|
+
from data_diff.databases.databricks import Databricks as Databricks
|
|
13
|
+
from data_diff.databases.trino import Trino as Trino
|
|
14
|
+
from data_diff.databases.clickhouse import Clickhouse as Clickhouse
|
|
15
|
+
from data_diff.databases.vertica import Vertica as Vertica
|
|
16
|
+
from data_diff.databases.duckdb import DuckDB as DuckDB
|
|
17
|
+
from data_diff.databases.mssql import MsSQL as MsSQL
|