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