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