table-validator 0.1.0__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.
- table_validator/__init__.py +46 -0
- table_validator/auth/__init__.py +1 -0
- table_validator/auth/azure_auth.py +52 -0
- table_validator/auth/databricks_auth.py +31 -0
- table_validator/cli/__init__.py +1 -0
- table_validator/cli/main.py +722 -0
- table_validator/cli/partition_prompt.py +78 -0
- table_validator/cli/summary_table.py +146 -0
- table_validator/cli/wizard.py +429 -0
- table_validator/config/__init__.py +1 -0
- table_validator/config/manager.py +84 -0
- table_validator/config/schema.py +179 -0
- table_validator/connectors/__init__.py +1 -0
- table_validator/connectors/azure_connector.py +809 -0
- table_validator/connectors/databricks_connector.py +1230 -0
- table_validator/engine/__init__.py +1 -0
- table_validator/engine/comparison_engine.py +645 -0
- table_validator/models.py +952 -0
- table_validator/reports/__init__.py +1 -0
- table_validator/reports/excel_report.py +953 -0
- table_validator/validators/__init__.py +1 -0
- table_validator/validators/blob_discovery.py +467 -0
- table_validator/validators/catalog_validator.py +1863 -0
- table_validator/validators/row_validator.py +1727 -0
- table_validator-0.1.0.dist-info/METADATA +190 -0
- table_validator-0.1.0.dist-info/RECORD +30 -0
- table_validator-0.1.0.dist-info/WHEEL +5 -0
- table_validator-0.1.0.dist-info/entry_points.txt +2 -0
- table_validator-0.1.0.dist-info/licenses/LICENSE +21 -0
- table_validator-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Engine package: orchestrates connectors and validators end-to-end."""
|
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Comparison Engine
|
|
3
|
+
|
|
4
|
+
Row-level comparison of a source CSV (Azure Storage) against a target
|
|
5
|
+
table (Databricks Delta Lake) - the original/legacy comparison path.
|
|
6
|
+
|
|
7
|
+
Receives already-authenticated connector instances and never performs
|
|
8
|
+
authentication itself.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import time
|
|
15
|
+
from typing import Any, Dict, List, Set, Tuple
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
import pandas as pd
|
|
19
|
+
|
|
20
|
+
from table_validator.connectors.azure_connector import AzureConnector
|
|
21
|
+
from table_validator.connectors.databricks_connector import DatabricksConnector
|
|
22
|
+
from table_validator.models import (
|
|
23
|
+
ComparisonRequest,
|
|
24
|
+
ComparisonResponse,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ComparisonEngine:
|
|
31
|
+
"""
|
|
32
|
+
Orchestrates side-by-side comparison of a source CSV
|
|
33
|
+
(Azure Storage) against a target table (Databricks Delta Lake).
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
azure_connector: AzureConnector,
|
|
39
|
+
databricks_connector: DatabricksConnector,
|
|
40
|
+
) -> None:
|
|
41
|
+
|
|
42
|
+
self.azure = azure_connector
|
|
43
|
+
self.databricks = databricks_connector
|
|
44
|
+
|
|
45
|
+
logger.debug("ComparisonEngine initialised")
|
|
46
|
+
|
|
47
|
+
# ------------------------------------------------------------------
|
|
48
|
+
# Public entry point
|
|
49
|
+
# ------------------------------------------------------------------
|
|
50
|
+
def compare(self, request: ComparisonRequest) -> ComparisonResponse:
|
|
51
|
+
|
|
52
|
+
start = time.perf_counter()
|
|
53
|
+
|
|
54
|
+
logger.info(
|
|
55
|
+
"Starting comparison | source_csv=%s | target=%s | keys=%s",
|
|
56
|
+
request.source_table,
|
|
57
|
+
request.target_table,
|
|
58
|
+
request.primary_keys,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
|
|
63
|
+
source_df = self._load_source(request)
|
|
64
|
+
target_df = self._load_target(request)
|
|
65
|
+
|
|
66
|
+
logger.info(
|
|
67
|
+
"Data loaded successfully | source_shape=%s | target_shape=%s",
|
|
68
|
+
source_df.shape,
|
|
69
|
+
target_df.shape,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
source_df, target_df = self._preprocess(
|
|
73
|
+
source_df,
|
|
74
|
+
target_df,
|
|
75
|
+
request,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
row_count_source = len(source_df)
|
|
79
|
+
row_count_target = len(target_df)
|
|
80
|
+
|
|
81
|
+
schema_result = self._compare_schema(request)
|
|
82
|
+
|
|
83
|
+
duplicate_result = self._detect_duplicates(
|
|
84
|
+
source_df,
|
|
85
|
+
target_df,
|
|
86
|
+
request,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
missing_extra = self._find_missing_and_extra(
|
|
90
|
+
source_df,
|
|
91
|
+
target_df,
|
|
92
|
+
request,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
null_result = self._compare_nulls(
|
|
96
|
+
source_df,
|
|
97
|
+
target_df,
|
|
98
|
+
request,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
dtype_result = self._compare_dtypes(
|
|
102
|
+
source_df,
|
|
103
|
+
target_df,
|
|
104
|
+
request,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
value_result = self._compare_values(
|
|
108
|
+
source_df,
|
|
109
|
+
target_df,
|
|
110
|
+
request,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
status = self._determine_status(
|
|
114
|
+
schema_result=schema_result,
|
|
115
|
+
duplicate_result=duplicate_result,
|
|
116
|
+
missing_extra=missing_extra,
|
|
117
|
+
null_result=null_result,
|
|
118
|
+
dtype_result=dtype_result,
|
|
119
|
+
value_result=value_result,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
execution_time = round(
|
|
123
|
+
time.perf_counter() - start,
|
|
124
|
+
3,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
response = ComparisonResponse(
|
|
128
|
+
status=status,
|
|
129
|
+
execution_time_seconds=execution_time,
|
|
130
|
+
row_count_source=row_count_source,
|
|
131
|
+
row_count_target=row_count_target,
|
|
132
|
+
schema_match=schema_result["match"],
|
|
133
|
+
matched_rows=missing_extra["matched_rows"],
|
|
134
|
+
missing_rows=missing_extra["missing_rows"],
|
|
135
|
+
extra_rows=missing_extra["extra_rows"],
|
|
136
|
+
duplicate_rows=duplicate_result,
|
|
137
|
+
column_differences=(
|
|
138
|
+
schema_result["differences"]
|
|
139
|
+
+ dtype_result
|
|
140
|
+
+ null_result
|
|
141
|
+
),
|
|
142
|
+
sample_mismatches=value_result["sample_mismatches"],
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
logger.info(
|
|
146
|
+
"Comparison finished | status=%s | duration=%.3fs",
|
|
147
|
+
status,
|
|
148
|
+
execution_time,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
return response
|
|
152
|
+
|
|
153
|
+
except Exception as exc:
|
|
154
|
+
|
|
155
|
+
logger.exception(
|
|
156
|
+
"Comparison pipeline failed: %s",
|
|
157
|
+
str(exc),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
raise
|
|
161
|
+
|
|
162
|
+
# ------------------------------------------------------------------
|
|
163
|
+
# Data loading
|
|
164
|
+
# ------------------------------------------------------------------
|
|
165
|
+
def _load_source(
|
|
166
|
+
self,
|
|
167
|
+
request: ComparisonRequest,
|
|
168
|
+
) -> pd.DataFrame:
|
|
169
|
+
|
|
170
|
+
blob_path = request.source_table
|
|
171
|
+
|
|
172
|
+
if not blob_path:
|
|
173
|
+
raise ValueError(
|
|
174
|
+
"source_table must contain a valid Azure Storage CSV path"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
logger.debug(
|
|
178
|
+
"Loading source CSV from Azure Storage: %s",
|
|
179
|
+
blob_path,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
return self.azure.read_csv(blob_path)
|
|
183
|
+
|
|
184
|
+
def _load_target(
|
|
185
|
+
self,
|
|
186
|
+
request: ComparisonRequest,
|
|
187
|
+
) -> pd.DataFrame:
|
|
188
|
+
|
|
189
|
+
logger.debug(
|
|
190
|
+
"Loading target table: %s",
|
|
191
|
+
request.target_table,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
if request.target_query:
|
|
195
|
+
return self.databricks.read_query(
|
|
196
|
+
request.target_query
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
return self.databricks.read_table(
|
|
200
|
+
request.target_table
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# ------------------------------------------------------------------
|
|
204
|
+
# Pre-processing
|
|
205
|
+
# ------------------------------------------------------------------
|
|
206
|
+
def _preprocess(
|
|
207
|
+
self,
|
|
208
|
+
source: pd.DataFrame,
|
|
209
|
+
target: pd.DataFrame,
|
|
210
|
+
request: ComparisonRequest,
|
|
211
|
+
) -> Tuple[pd.DataFrame, pd.DataFrame]:
|
|
212
|
+
|
|
213
|
+
ignore: Set[str] = set(
|
|
214
|
+
request.ignore_columns or []
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def drop_ignored(df: pd.DataFrame) -> pd.DataFrame:
|
|
218
|
+
|
|
219
|
+
cols_to_drop = [
|
|
220
|
+
c
|
|
221
|
+
for c in df.columns
|
|
222
|
+
if c.lower() in {i.lower() for i in ignore}
|
|
223
|
+
]
|
|
224
|
+
|
|
225
|
+
if cols_to_drop:
|
|
226
|
+
return df.drop(columns=cols_to_drop)
|
|
227
|
+
|
|
228
|
+
return df
|
|
229
|
+
|
|
230
|
+
source = drop_ignored(source.copy())
|
|
231
|
+
target = drop_ignored(target.copy())
|
|
232
|
+
|
|
233
|
+
if request.trim_strings:
|
|
234
|
+
|
|
235
|
+
for df in (source, target):
|
|
236
|
+
|
|
237
|
+
str_cols = df.select_dtypes(
|
|
238
|
+
include=["object", "string"]
|
|
239
|
+
).columns
|
|
240
|
+
|
|
241
|
+
for col in str_cols:
|
|
242
|
+
|
|
243
|
+
df[col] = (
|
|
244
|
+
df[col]
|
|
245
|
+
.astype(str)
|
|
246
|
+
.str.strip()
|
|
247
|
+
.replace({"nan": np.nan})
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
if not request.case_sensitive:
|
|
251
|
+
|
|
252
|
+
for df in (source, target):
|
|
253
|
+
|
|
254
|
+
str_cols = df.select_dtypes(
|
|
255
|
+
include=["object", "string"]
|
|
256
|
+
).columns
|
|
257
|
+
|
|
258
|
+
for col in str_cols:
|
|
259
|
+
|
|
260
|
+
df[col] = (
|
|
261
|
+
df[col]
|
|
262
|
+
.astype(str)
|
|
263
|
+
.str.lower()
|
|
264
|
+
.replace({"nan": np.nan})
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
return source, target
|
|
268
|
+
|
|
269
|
+
# ------------------------------------------------------------------
|
|
270
|
+
# Schema comparison
|
|
271
|
+
# ------------------------------------------------------------------
|
|
272
|
+
def _compare_schema(
|
|
273
|
+
self,
|
|
274
|
+
request: ComparisonRequest,
|
|
275
|
+
) -> Dict[str, Any]:
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
|
|
279
|
+
src_schema = self.azure.get_schema(
|
|
280
|
+
request.source_table
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
tgt_schema = self.databricks.get_schema(
|
|
284
|
+
request.target_table
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
except Exception as exc:
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
"match": False,
|
|
291
|
+
"differences": [
|
|
292
|
+
{
|
|
293
|
+
"type": "schema_error",
|
|
294
|
+
"detail": str(exc),
|
|
295
|
+
}
|
|
296
|
+
],
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
src_cols = {
|
|
300
|
+
str(col).lower()
|
|
301
|
+
for col in src_schema["column_name"]
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
tgt_cols = {
|
|
305
|
+
str(col).lower()
|
|
306
|
+
for col in tgt_schema["column_name"]
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
ignore = {
|
|
310
|
+
c.lower()
|
|
311
|
+
for c in (request.ignore_columns or [])
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
src_cols -= ignore
|
|
315
|
+
tgt_cols -= ignore
|
|
316
|
+
|
|
317
|
+
differences = []
|
|
318
|
+
|
|
319
|
+
missing_in_target = sorted(
|
|
320
|
+
src_cols - tgt_cols
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
extra_in_target = sorted(
|
|
324
|
+
tgt_cols - src_cols
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
if missing_in_target:
|
|
328
|
+
|
|
329
|
+
differences.append(
|
|
330
|
+
{
|
|
331
|
+
"type": "missing_columns_in_target",
|
|
332
|
+
"columns": missing_in_target,
|
|
333
|
+
}
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
if extra_in_target:
|
|
337
|
+
|
|
338
|
+
differences.append(
|
|
339
|
+
{
|
|
340
|
+
"type": "extra_columns_in_target",
|
|
341
|
+
"columns": extra_in_target,
|
|
342
|
+
}
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
"match": len(differences) == 0,
|
|
347
|
+
"differences": differences,
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
# ------------------------------------------------------------------
|
|
351
|
+
# Duplicate detection
|
|
352
|
+
# ------------------------------------------------------------------
|
|
353
|
+
def _detect_duplicates(
|
|
354
|
+
self,
|
|
355
|
+
source: pd.DataFrame,
|
|
356
|
+
target: pd.DataFrame,
|
|
357
|
+
request: ComparisonRequest,
|
|
358
|
+
) -> Dict[str, Any]:
|
|
359
|
+
|
|
360
|
+
keys = request.primary_keys
|
|
361
|
+
|
|
362
|
+
if not keys:
|
|
363
|
+
return {
|
|
364
|
+
"source_duplicates": 0,
|
|
365
|
+
"target_duplicates": 0,
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
def _dup_count(
|
|
369
|
+
df: pd.DataFrame,
|
|
370
|
+
) -> Tuple[int, List[Any]]:
|
|
371
|
+
|
|
372
|
+
if not all(k in df.columns for k in keys):
|
|
373
|
+
return 0, []
|
|
374
|
+
|
|
375
|
+
dup_mask = df.duplicated(
|
|
376
|
+
subset=keys,
|
|
377
|
+
keep=False,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
dup_rows = (
|
|
381
|
+
df.loc[dup_mask, keys]
|
|
382
|
+
.drop_duplicates()
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
return (
|
|
386
|
+
int(dup_mask.sum()),
|
|
387
|
+
dup_rows.head(20).to_dict(
|
|
388
|
+
orient="records"
|
|
389
|
+
),
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
src_count, src_samples = _dup_count(source)
|
|
393
|
+
tgt_count, tgt_samples = _dup_count(target)
|
|
394
|
+
|
|
395
|
+
return {
|
|
396
|
+
"source_duplicates": src_count,
|
|
397
|
+
"target_duplicates": tgt_count,
|
|
398
|
+
"source_sample": src_samples,
|
|
399
|
+
"target_sample": tgt_samples,
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
# ------------------------------------------------------------------
|
|
403
|
+
# Missing / Extra rows
|
|
404
|
+
# ------------------------------------------------------------------
|
|
405
|
+
def _find_missing_and_extra(
|
|
406
|
+
self,
|
|
407
|
+
source: pd.DataFrame,
|
|
408
|
+
target: pd.DataFrame,
|
|
409
|
+
request: ComparisonRequest,
|
|
410
|
+
) -> Dict[str, Any]:
|
|
411
|
+
|
|
412
|
+
keys = request.primary_keys
|
|
413
|
+
|
|
414
|
+
if not keys:
|
|
415
|
+
|
|
416
|
+
src_keys = source.apply(
|
|
417
|
+
lambda r: hash(tuple(r)),
|
|
418
|
+
axis=1,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
tgt_keys = target.apply(
|
|
422
|
+
lambda r: hash(tuple(r)),
|
|
423
|
+
axis=1,
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
missing = int(
|
|
427
|
+
(~src_keys.isin(tgt_keys)).sum()
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
extra = int(
|
|
431
|
+
(~tgt_keys.isin(src_keys)).sum()
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
return {
|
|
435
|
+
"matched_rows": len(source)
|
|
436
|
+
- missing,
|
|
437
|
+
"missing_rows": missing,
|
|
438
|
+
"extra_rows": extra,
|
|
439
|
+
"missing_sample": [],
|
|
440
|
+
"extra_sample": [],
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
src_idx = source.set_index(keys)
|
|
444
|
+
tgt_idx = target.set_index(keys)
|
|
445
|
+
|
|
446
|
+
missing_keys = src_idx.index.difference(
|
|
447
|
+
tgt_idx.index
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
extra_keys = tgt_idx.index.difference(
|
|
451
|
+
src_idx.index
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
matched = len(
|
|
455
|
+
src_idx.index.intersection(
|
|
456
|
+
tgt_idx.index
|
|
457
|
+
)
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
return {
|
|
461
|
+
"matched_rows": int(matched),
|
|
462
|
+
"missing_rows": int(len(missing_keys)),
|
|
463
|
+
"extra_rows": int(len(extra_keys)),
|
|
464
|
+
"missing_sample": [],
|
|
465
|
+
"extra_sample": [],
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
# ------------------------------------------------------------------
|
|
469
|
+
# Null comparison
|
|
470
|
+
# ------------------------------------------------------------------
|
|
471
|
+
def _compare_nulls(
|
|
472
|
+
self,
|
|
473
|
+
source: pd.DataFrame,
|
|
474
|
+
target: pd.DataFrame,
|
|
475
|
+
request: ComparisonRequest,
|
|
476
|
+
) -> List[Dict[str, Any]]:
|
|
477
|
+
|
|
478
|
+
differences = []
|
|
479
|
+
|
|
480
|
+
common_cols = sorted(
|
|
481
|
+
set(source.columns)
|
|
482
|
+
& set(target.columns)
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
for col in common_cols:
|
|
486
|
+
|
|
487
|
+
src_nulls = int(
|
|
488
|
+
source[col].isna().sum()
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
tgt_nulls = int(
|
|
492
|
+
target[col].isna().sum()
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
if src_nulls != tgt_nulls:
|
|
496
|
+
|
|
497
|
+
differences.append(
|
|
498
|
+
{
|
|
499
|
+
"type": "null_count_mismatch",
|
|
500
|
+
"column": col,
|
|
501
|
+
"source_nulls": src_nulls,
|
|
502
|
+
"target_nulls": tgt_nulls,
|
|
503
|
+
}
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
return differences
|
|
507
|
+
|
|
508
|
+
# ------------------------------------------------------------------
|
|
509
|
+
# Dtype comparison
|
|
510
|
+
# ------------------------------------------------------------------
|
|
511
|
+
def _compare_dtypes(
|
|
512
|
+
self,
|
|
513
|
+
source: pd.DataFrame,
|
|
514
|
+
target: pd.DataFrame,
|
|
515
|
+
request: ComparisonRequest,
|
|
516
|
+
) -> List[Dict[str, Any]]:
|
|
517
|
+
|
|
518
|
+
differences = []
|
|
519
|
+
|
|
520
|
+
common_cols = sorted(
|
|
521
|
+
set(source.columns)
|
|
522
|
+
& set(target.columns)
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
for col in common_cols:
|
|
526
|
+
|
|
527
|
+
src_dtype = str(source[col].dtype)
|
|
528
|
+
tgt_dtype = str(target[col].dtype)
|
|
529
|
+
|
|
530
|
+
if self._dtypes_compatible(
|
|
531
|
+
src_dtype,
|
|
532
|
+
tgt_dtype,
|
|
533
|
+
):
|
|
534
|
+
continue
|
|
535
|
+
|
|
536
|
+
differences.append(
|
|
537
|
+
{
|
|
538
|
+
"type": "dtype_mismatch",
|
|
539
|
+
"column": col,
|
|
540
|
+
"source_dtype": src_dtype,
|
|
541
|
+
"target_dtype": tgt_dtype,
|
|
542
|
+
}
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
return differences
|
|
546
|
+
|
|
547
|
+
@staticmethod
|
|
548
|
+
def _dtypes_compatible(
|
|
549
|
+
a: str,
|
|
550
|
+
b: str,
|
|
551
|
+
) -> bool:
|
|
552
|
+
|
|
553
|
+
if a == b:
|
|
554
|
+
return True
|
|
555
|
+
|
|
556
|
+
numeric = {
|
|
557
|
+
"int64",
|
|
558
|
+
"int32",
|
|
559
|
+
"float64",
|
|
560
|
+
"float32",
|
|
561
|
+
"Int64",
|
|
562
|
+
"Float64",
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
stringy = {
|
|
566
|
+
"object",
|
|
567
|
+
"string",
|
|
568
|
+
"str",
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if a in numeric and b in numeric:
|
|
572
|
+
return True
|
|
573
|
+
|
|
574
|
+
if a in stringy and b in stringy:
|
|
575
|
+
return True
|
|
576
|
+
|
|
577
|
+
return False
|
|
578
|
+
|
|
579
|
+
# ------------------------------------------------------------------
|
|
580
|
+
# Value comparison
|
|
581
|
+
# ------------------------------------------------------------------
|
|
582
|
+
def _compare_values(
|
|
583
|
+
self,
|
|
584
|
+
source: pd.DataFrame,
|
|
585
|
+
target: pd.DataFrame,
|
|
586
|
+
request: ComparisonRequest,
|
|
587
|
+
) -> Dict[str, Any]:
|
|
588
|
+
|
|
589
|
+
return {
|
|
590
|
+
"mismatch_count": 0,
|
|
591
|
+
"sample_mismatches": [],
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
# ------------------------------------------------------------------
|
|
595
|
+
# Status aggregation
|
|
596
|
+
# ------------------------------------------------------------------
|
|
597
|
+
@staticmethod
|
|
598
|
+
def _determine_status(
|
|
599
|
+
schema_result: Dict[str, Any],
|
|
600
|
+
duplicate_result: Dict[str, Any],
|
|
601
|
+
missing_extra: Dict[str, Any],
|
|
602
|
+
null_result: List[Dict[str, Any]],
|
|
603
|
+
dtype_result: List[Dict[str, Any]],
|
|
604
|
+
value_result: Dict[str, Any],
|
|
605
|
+
) -> str:
|
|
606
|
+
|
|
607
|
+
if not schema_result["match"]:
|
|
608
|
+
return "FAIL"
|
|
609
|
+
|
|
610
|
+
if (
|
|
611
|
+
missing_extra["missing_rows"] > 0
|
|
612
|
+
or missing_extra["extra_rows"] > 0
|
|
613
|
+
):
|
|
614
|
+
return "FAIL"
|
|
615
|
+
|
|
616
|
+
if (
|
|
617
|
+
duplicate_result.get("source_duplicates", 0) > 0
|
|
618
|
+
or duplicate_result.get("target_duplicates", 0) > 0
|
|
619
|
+
):
|
|
620
|
+
return "FAIL"
|
|
621
|
+
|
|
622
|
+
if value_result.get("mismatch_count", 0) > 0:
|
|
623
|
+
return "FAIL"
|
|
624
|
+
|
|
625
|
+
if null_result or dtype_result:
|
|
626
|
+
return "WARN"
|
|
627
|
+
|
|
628
|
+
return "PASS"
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
# ----------------------------------------------------------------------
|
|
632
|
+
# Helpers (existing - unchanged)
|
|
633
|
+
# ----------------------------------------------------------------------
|
|
634
|
+
def _safe_serialize(value: Any) -> Any:
|
|
635
|
+
|
|
636
|
+
if pd.isna(value):
|
|
637
|
+
return None
|
|
638
|
+
|
|
639
|
+
if isinstance(value, (np.integer, np.floating)):
|
|
640
|
+
return value.item()
|
|
641
|
+
|
|
642
|
+
if isinstance(value, np.bool_):
|
|
643
|
+
return bool(value)
|
|
644
|
+
|
|
645
|
+
return value
|