qualipilot 2.0.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.
- qualipilot/__init__.py +16 -0
- qualipilot/checker.py +195 -0
- qualipilot/checks/__init__.py +28 -0
- qualipilot/checks/base.py +61 -0
- qualipilot/checks/cardinality.py +42 -0
- qualipilot/checks/duplicates.py +28 -0
- qualipilot/checks/freshness.py +77 -0
- qualipilot/checks/linkage.py +73 -0
- qualipilot/checks/missing.py +43 -0
- qualipilot/checks/outliers.py +67 -0
- qualipilot/checks/ranges.py +63 -0
- qualipilot/checks/types.py +25 -0
- qualipilot/cli.py +434 -0
- qualipilot/engines/__init__.py +95 -0
- qualipilot/engines/base.py +118 -0
- qualipilot/engines/cudf_engine.py +147 -0
- qualipilot/engines/dask_engine.py +152 -0
- qualipilot/engines/duckdb_engine.py +283 -0
- qualipilot/engines/pandas_engine.py +139 -0
- qualipilot/engines/polars_engine.py +203 -0
- qualipilot/engines/spark_engine.py +245 -0
- qualipilot/lakehouse.py +116 -0
- qualipilot/lambda_handler.py +95 -0
- qualipilot/linking/__init__.py +27 -0
- qualipilot/linking/blocking.py +127 -0
- qualipilot/linking/cluster.py +54 -0
- qualipilot/linking/comparisons.py +133 -0
- qualipilot/linking/config.py +63 -0
- qualipilot/linking/duckdb_linker.py +248 -0
- qualipilot/linking/em.py +212 -0
- qualipilot/linking/linker.py +261 -0
- qualipilot/llm/__init__.py +46 -0
- qualipilot/llm/base.py +23 -0
- qualipilot/llm/bedrock.py +105 -0
- qualipilot/llm/null_provider.py +14 -0
- qualipilot/llm/ollama.py +72 -0
- qualipilot/llm/openai_compat.py +76 -0
- qualipilot/logging_setup.py +89 -0
- qualipilot/models/__init__.py +1 -0
- qualipilot/models/config.py +145 -0
- qualipilot/models/results.py +101 -0
- qualipilot/py.typed +0 -0
- qualipilot/reporting/__init__.py +6 -0
- qualipilot/reporting/html.py +84 -0
- qualipilot/reporting/markdown.py +77 -0
- qualipilot-2.0.0.dist-info/METADATA +301 -0
- qualipilot-2.0.0.dist-info/RECORD +50 -0
- qualipilot-2.0.0.dist-info/WHEEL +4 -0
- qualipilot-2.0.0.dist-info/entry_points.txt +2 -0
- qualipilot-2.0.0.dist-info/licenses/LICENSE +5 -0
qualipilot/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Production-grade data quality checker with pluggable LLM backends."""
|
|
2
|
+
|
|
3
|
+
from qualipilot.checker import DataQualityChecker
|
|
4
|
+
from qualipilot.models.config import CheckConfig, LLMConfig, QualipilotConfig
|
|
5
|
+
from qualipilot.models.results import CheckResult, QualityReport
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"CheckConfig",
|
|
9
|
+
"CheckResult",
|
|
10
|
+
"DataQualityChecker",
|
|
11
|
+
"LLMConfig",
|
|
12
|
+
"QualipilotConfig",
|
|
13
|
+
"QualityReport",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
__version__ = "2.0.0"
|
qualipilot/checker.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Orchestrator tying engines, checks and LLM reporting together."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
from qualipilot.checks import (
|
|
12
|
+
CardinalityCheck,
|
|
13
|
+
Check,
|
|
14
|
+
CheckContext,
|
|
15
|
+
DataTypesCheck,
|
|
16
|
+
DuplicatesCheck,
|
|
17
|
+
FreshnessCheck,
|
|
18
|
+
LinkageCheck,
|
|
19
|
+
MissingValuesCheck,
|
|
20
|
+
OutliersCheck,
|
|
21
|
+
RangesCheck,
|
|
22
|
+
)
|
|
23
|
+
from qualipilot.engines import build_engine
|
|
24
|
+
from qualipilot.models.config import QualipilotConfig
|
|
25
|
+
from qualipilot.models.results import (
|
|
26
|
+
CheckResult,
|
|
27
|
+
DatasetStats,
|
|
28
|
+
QualityReport,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from qualipilot.engines.base import Engine
|
|
33
|
+
from qualipilot.llm.base import LLMProvider
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DataQualityChecker:
|
|
39
|
+
"""Run configurable data quality checks against a dataframe/file.
|
|
40
|
+
|
|
41
|
+
Example:
|
|
42
|
+
>>> import pandas as pd
|
|
43
|
+
>>> from qualipilot import DataQualityChecker, QualipilotConfig
|
|
44
|
+
>>> df = pd.read_csv("orders.csv")
|
|
45
|
+
>>> checker = DataQualityChecker(df, QualipilotConfig())
|
|
46
|
+
>>> report = checker.run()
|
|
47
|
+
>>> print(report.to_json())
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
data: Any,
|
|
53
|
+
config: QualipilotConfig | None = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
self.config = config or QualipilotConfig()
|
|
56
|
+
self.engine: Engine = build_engine(data, kind=self.config.engine)
|
|
57
|
+
logger.info(
|
|
58
|
+
"initialised checker with %s engine over %d rows",
|
|
59
|
+
self.engine.name,
|
|
60
|
+
self.engine.row_count(),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def run(self) -> QualityReport:
|
|
64
|
+
"""Run every enabled check and return the aggregate report."""
|
|
65
|
+
ctx = CheckContext(engine=self.engine, config=self.config.checks)
|
|
66
|
+
results: list[CheckResult] = []
|
|
67
|
+
for check in self._build_check_list():
|
|
68
|
+
logger.info("running check: %s", check.name)
|
|
69
|
+
result = check.run(ctx)
|
|
70
|
+
logger.info(
|
|
71
|
+
"check %s finished in %.3fs (severity=%s)",
|
|
72
|
+
result.name,
|
|
73
|
+
result.duration_seconds,
|
|
74
|
+
result.severity,
|
|
75
|
+
)
|
|
76
|
+
results.append(result)
|
|
77
|
+
|
|
78
|
+
dataset = DatasetStats(
|
|
79
|
+
row_count=self.engine.row_count(),
|
|
80
|
+
column_count=len(self.engine.columns()),
|
|
81
|
+
columns=self.engine.columns(),
|
|
82
|
+
dtypes=self.engine.dtypes(),
|
|
83
|
+
engine=self.engine.name,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
report = QualityReport(
|
|
87
|
+
dataset=dataset,
|
|
88
|
+
results=results,
|
|
89
|
+
config_hash=_config_fingerprint(self.config),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
llm_report = self._maybe_render_llm_report(report)
|
|
93
|
+
if llm_report:
|
|
94
|
+
report.llm_report = llm_report
|
|
95
|
+
|
|
96
|
+
if self.config.output_path is not None:
|
|
97
|
+
self.save(report, self.config.output_path)
|
|
98
|
+
|
|
99
|
+
return report
|
|
100
|
+
|
|
101
|
+
# ---- helpers ------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
def _build_check_list(self) -> list[Check]:
|
|
104
|
+
cfg = self.config.checks
|
|
105
|
+
checks: list[Check] = []
|
|
106
|
+
if cfg.missing_values:
|
|
107
|
+
checks.append(MissingValuesCheck())
|
|
108
|
+
if cfg.duplicates:
|
|
109
|
+
checks.append(DuplicatesCheck())
|
|
110
|
+
if cfg.data_types:
|
|
111
|
+
checks.append(DataTypesCheck())
|
|
112
|
+
if cfg.outliers:
|
|
113
|
+
checks.append(OutliersCheck())
|
|
114
|
+
if cfg.ranges:
|
|
115
|
+
checks.append(RangesCheck())
|
|
116
|
+
if cfg.cardinality:
|
|
117
|
+
checks.append(CardinalityCheck())
|
|
118
|
+
if cfg.freshness:
|
|
119
|
+
checks.append(FreshnessCheck())
|
|
120
|
+
if cfg.linkage is not None:
|
|
121
|
+
checks.append(LinkageCheck())
|
|
122
|
+
return checks
|
|
123
|
+
|
|
124
|
+
def _maybe_render_llm_report(self, report: QualityReport) -> str | None:
|
|
125
|
+
provider_name = self.config.llm.provider
|
|
126
|
+
if provider_name == "none":
|
|
127
|
+
return None
|
|
128
|
+
provider = _build_llm_provider(self.config.llm)
|
|
129
|
+
prompt = _build_llm_prompt(report)
|
|
130
|
+
try:
|
|
131
|
+
return provider.generate(
|
|
132
|
+
system=self.config.llm.system_prompt, user=prompt
|
|
133
|
+
)
|
|
134
|
+
except Exception as exc:
|
|
135
|
+
# llm problems should not fail the check pipeline
|
|
136
|
+
logger.error("llm report generation failed: %s", exc)
|
|
137
|
+
return f"LLM report failed: {exc}"
|
|
138
|
+
|
|
139
|
+
@staticmethod
|
|
140
|
+
def save(report: QualityReport, path: str | Path) -> None:
|
|
141
|
+
"""Persist the JSON report to disk (utf-8, pretty-printed)."""
|
|
142
|
+
target = Path(path)
|
|
143
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
144
|
+
target.write_text(report.to_json(), encoding="utf-8")
|
|
145
|
+
logger.info("report written to %s", target)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _config_fingerprint(cfg: QualipilotConfig) -> str:
|
|
149
|
+
"""Stable SHA-1 of the config, handy for report dedup in pipelines."""
|
|
150
|
+
payload = cfg.model_dump_json(exclude={"output_path"})
|
|
151
|
+
return hashlib.sha1(
|
|
152
|
+
payload.encode("utf-8"), usedforsecurity=False
|
|
153
|
+
).hexdigest()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _build_llm_provider(cfg: Any) -> LLMProvider:
|
|
157
|
+
# late import so the optional boto3/openai deps stay optional
|
|
158
|
+
from qualipilot.llm import build_provider
|
|
159
|
+
|
|
160
|
+
return build_provider(cfg)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _build_llm_prompt(report: QualityReport) -> str:
|
|
164
|
+
# compact the report before shipping to the model so we do not
|
|
165
|
+
# waste tokens on huge sample arrays
|
|
166
|
+
compact = {
|
|
167
|
+
"dataset": report.dataset.model_dump(),
|
|
168
|
+
"results": [
|
|
169
|
+
{
|
|
170
|
+
"name": r.name,
|
|
171
|
+
"severity": r.severity,
|
|
172
|
+
"duration_seconds": round(r.duration_seconds, 3),
|
|
173
|
+
"summary": _summarise_payload(r.payload),
|
|
174
|
+
"error": r.error,
|
|
175
|
+
}
|
|
176
|
+
for r in report.results
|
|
177
|
+
],
|
|
178
|
+
}
|
|
179
|
+
return (
|
|
180
|
+
"Analyse this data quality report and produce actionable "
|
|
181
|
+
"findings. Keep samples out; focus on what to fix.\n\n"
|
|
182
|
+
+ json.dumps(compact, default=str)
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _summarise_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
|
187
|
+
"""Strip large sample arrays so prompts stay cheap."""
|
|
188
|
+
keep: dict[str, Any] = {}
|
|
189
|
+
for key, value in payload.items():
|
|
190
|
+
if isinstance(value, list):
|
|
191
|
+
# keep length only, drop row-level samples
|
|
192
|
+
keep[key] = {"count": len(value)}
|
|
193
|
+
else:
|
|
194
|
+
keep[key] = value
|
|
195
|
+
return keep
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Individual data quality checks.
|
|
2
|
+
|
|
3
|
+
Each module implements a single ``Check`` subclass. The orchestrator
|
|
4
|
+
in ``qualipilot.checker`` picks which to run based on ``CheckConfig``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from qualipilot.checks.base import Check, CheckContext
|
|
8
|
+
from qualipilot.checks.cardinality import CardinalityCheck
|
|
9
|
+
from qualipilot.checks.duplicates import DuplicatesCheck
|
|
10
|
+
from qualipilot.checks.freshness import FreshnessCheck
|
|
11
|
+
from qualipilot.checks.linkage import LinkageCheck
|
|
12
|
+
from qualipilot.checks.missing import MissingValuesCheck
|
|
13
|
+
from qualipilot.checks.outliers import OutliersCheck
|
|
14
|
+
from qualipilot.checks.ranges import RangesCheck
|
|
15
|
+
from qualipilot.checks.types import DataTypesCheck
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"CardinalityCheck",
|
|
19
|
+
"Check",
|
|
20
|
+
"CheckContext",
|
|
21
|
+
"DataTypesCheck",
|
|
22
|
+
"DuplicatesCheck",
|
|
23
|
+
"FreshnessCheck",
|
|
24
|
+
"LinkageCheck",
|
|
25
|
+
"MissingValuesCheck",
|
|
26
|
+
"OutliersCheck",
|
|
27
|
+
"RangesCheck",
|
|
28
|
+
]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Base types shared by every check."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from qualipilot.engines.base import Engine
|
|
11
|
+
from qualipilot.models.config import CheckConfig
|
|
12
|
+
from qualipilot.models.results import CheckResult
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(slots=True)
|
|
16
|
+
class CheckContext:
|
|
17
|
+
"""Immutable inputs handed to every check."""
|
|
18
|
+
|
|
19
|
+
engine: Engine
|
|
20
|
+
config: CheckConfig
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Check(ABC):
|
|
24
|
+
"""Base class for data quality checks.
|
|
25
|
+
|
|
26
|
+
Subclasses implement ``_execute`` which returns the serialisable
|
|
27
|
+
payload. The base class times execution, captures exceptions, and
|
|
28
|
+
assigns the severity.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
name: str
|
|
32
|
+
|
|
33
|
+
def run(self, ctx: CheckContext) -> CheckResult:
|
|
34
|
+
"""Execute the check, wrapping timing and error handling."""
|
|
35
|
+
start = time.perf_counter()
|
|
36
|
+
try:
|
|
37
|
+
severity, payload = self._execute(ctx)
|
|
38
|
+
except Exception as exc:
|
|
39
|
+
return CheckResult(
|
|
40
|
+
name=self.name,
|
|
41
|
+
severity="error",
|
|
42
|
+
duration_seconds=time.perf_counter() - start,
|
|
43
|
+
payload={},
|
|
44
|
+
error=f"{type(exc).__name__}: {exc}",
|
|
45
|
+
)
|
|
46
|
+
return CheckResult(
|
|
47
|
+
name=self.name,
|
|
48
|
+
severity=severity,
|
|
49
|
+
duration_seconds=time.perf_counter() - start,
|
|
50
|
+
payload=payload,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def _execute(self, ctx: CheckContext) -> tuple[str, dict[str, Any]]:
|
|
55
|
+
"""Run the check logic.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Tuple of ``(severity, payload)``. Severity is ``"ok"``,
|
|
59
|
+
``"warn"`` or ``"error"``. Payload is a JSON-serialisable
|
|
60
|
+
dict stored on the resulting ``CheckResult``.
|
|
61
|
+
"""
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Cardinality / uniqueness profile.
|
|
2
|
+
|
|
3
|
+
Helps spot accidentally-constant columns and highly categorical ones
|
|
4
|
+
that should be bucketed. Reports distinct count plus the top-10 values
|
|
5
|
+
for every column.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from qualipilot.checks.base import Check, CheckContext
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CardinalityCheck(Check):
|
|
16
|
+
name = "cardinality"
|
|
17
|
+
|
|
18
|
+
def _execute(self, ctx: CheckContext) -> tuple[str, dict[str, Any]]:
|
|
19
|
+
total_rows = ctx.engine.row_count() or 1
|
|
20
|
+
report: list[dict[str, Any]] = []
|
|
21
|
+
any_constant = False
|
|
22
|
+
|
|
23
|
+
for col in ctx.engine.columns():
|
|
24
|
+
try:
|
|
25
|
+
distinct = ctx.engine.distinct_count(col)
|
|
26
|
+
except Exception: # pragma: no cover
|
|
27
|
+
# some engines fail on nested dtypes, we log + move on
|
|
28
|
+
continue
|
|
29
|
+
top = ctx.engine.top_values(col, n=10)
|
|
30
|
+
if distinct <= 1 and total_rows > 1:
|
|
31
|
+
any_constant = True
|
|
32
|
+
report.append(
|
|
33
|
+
{
|
|
34
|
+
"column": col,
|
|
35
|
+
"distinct_count": distinct,
|
|
36
|
+
"unique_ratio": round(distinct / total_rows, 6),
|
|
37
|
+
"top_values": top,
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
severity = "warn" if any_constant else "ok"
|
|
42
|
+
return severity, {"per_column": report}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Duplicate row check.
|
|
2
|
+
|
|
3
|
+
Uses a global duplicate count so distributed backends do not
|
|
4
|
+
under-count the way ``map_partitions(.duplicated())`` would.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from qualipilot.checks.base import Check, CheckContext
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DuplicatesCheck(Check):
|
|
15
|
+
name = "duplicates"
|
|
16
|
+
|
|
17
|
+
def _execute(self, ctx: CheckContext) -> tuple[str, dict[str, Any]]:
|
|
18
|
+
subset = ctx.config.duplicate_subset
|
|
19
|
+
total = ctx.engine.duplicate_count(subset=subset)
|
|
20
|
+
sample = ctx.engine.sample_duplicates(
|
|
21
|
+
n=ctx.config.sample_size, subset=subset
|
|
22
|
+
)
|
|
23
|
+
severity = "warn" if total > 0 else "ok"
|
|
24
|
+
return severity, {
|
|
25
|
+
"total_duplicate_rows": total,
|
|
26
|
+
"subset": subset,
|
|
27
|
+
"sample": sample,
|
|
28
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Data freshness check.
|
|
2
|
+
|
|
3
|
+
Flags datasets where the latest timestamp in configured columns is
|
|
4
|
+
older than ``freshness_max_age_hours``. Useful for scheduled batch
|
|
5
|
+
jobs that should never publish stale snapshots.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import UTC, datetime, timedelta
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from qualipilot.checks.base import Check, CheckContext
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FreshnessCheck(Check):
|
|
17
|
+
name = "freshness"
|
|
18
|
+
|
|
19
|
+
def _execute(self, ctx: CheckContext) -> tuple[str, dict[str, Any]]:
|
|
20
|
+
cols = ctx.config.freshness_columns or ctx.engine.datetime_columns()
|
|
21
|
+
if not cols:
|
|
22
|
+
return "ok", {"per_column": []}
|
|
23
|
+
|
|
24
|
+
max_age = timedelta(hours=ctx.config.freshness_max_age_hours)
|
|
25
|
+
now = datetime.now(UTC)
|
|
26
|
+
report: list[dict[str, Any]] = []
|
|
27
|
+
any_stale = False
|
|
28
|
+
|
|
29
|
+
for col in cols:
|
|
30
|
+
max_ts = ctx.engine.max_datetime(col)
|
|
31
|
+
if max_ts is None:
|
|
32
|
+
report.append(
|
|
33
|
+
{
|
|
34
|
+
"column": col,
|
|
35
|
+
"max_timestamp": None,
|
|
36
|
+
"is_stale": True,
|
|
37
|
+
"note": "no non-null values",
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
any_stale = True
|
|
41
|
+
continue
|
|
42
|
+
|
|
43
|
+
# normalise naive timestamps so subtraction is safe
|
|
44
|
+
ts = _as_aware(max_ts)
|
|
45
|
+
age = now - ts
|
|
46
|
+
stale = age > max_age
|
|
47
|
+
if stale:
|
|
48
|
+
any_stale = True
|
|
49
|
+
report.append(
|
|
50
|
+
{
|
|
51
|
+
"column": col,
|
|
52
|
+
"max_timestamp": ts.isoformat(),
|
|
53
|
+
"age_hours": round(age.total_seconds() / 3600, 3),
|
|
54
|
+
"is_stale": stale,
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
severity = "error" if any_stale else "ok"
|
|
59
|
+
return severity, {"per_column": report}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _as_aware(value: Any) -> datetime:
|
|
63
|
+
"""Coerce engine-returned timestamps into timezone-aware datetime."""
|
|
64
|
+
# pandas.Timestamp, polars Datetime and stdlib datetime all satisfy
|
|
65
|
+
# the duck-typing we need; pandas ts has .to_pydatetime()
|
|
66
|
+
dt: datetime
|
|
67
|
+
if hasattr(value, "to_pydatetime"):
|
|
68
|
+
dt = value.to_pydatetime()
|
|
69
|
+
elif isinstance(value, datetime):
|
|
70
|
+
dt = value
|
|
71
|
+
else:
|
|
72
|
+
# last-resort coerce via iso parsing for exotic types
|
|
73
|
+
dt = datetime.fromisoformat(str(value))
|
|
74
|
+
|
|
75
|
+
if dt.tzinfo is None:
|
|
76
|
+
dt = dt.replace(tzinfo=UTC)
|
|
77
|
+
return dt
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Optional probabilistic duplicate check.
|
|
2
|
+
|
|
3
|
+
Unlike ``DuplicatesCheck`` (exact row match), this runs the
|
|
4
|
+
Fellegi-Sunter linker on a user-supplied comparison spec and reports
|
|
5
|
+
the number of probable duplicate clusters.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from qualipilot.checks.base import Check, CheckContext
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LinkageCheck(Check):
|
|
16
|
+
"""Report probabilistic duplicate clusters.
|
|
17
|
+
|
|
18
|
+
Active only when ``CheckConfig.linkage`` is populated.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
name = "linkage"
|
|
22
|
+
|
|
23
|
+
def _execute(self, ctx: CheckContext) -> tuple[str, dict[str, Any]]:
|
|
24
|
+
link_cfg = ctx.config.linkage
|
|
25
|
+
if link_cfg is None:
|
|
26
|
+
return "ok", {"skipped": True}
|
|
27
|
+
|
|
28
|
+
# deferred import so the core install stays slim
|
|
29
|
+
from qualipilot.linking import RecordLinker
|
|
30
|
+
|
|
31
|
+
# engine-agnostic: always pull a polars frame for the linker.
|
|
32
|
+
# the per-engine cost is tiny compared with the linkage run.
|
|
33
|
+
pl_df = _engine_to_polars(ctx.engine)
|
|
34
|
+
result = RecordLinker(pl_df, link_cfg).run()
|
|
35
|
+
summary = result.summary()
|
|
36
|
+
|
|
37
|
+
# clusters of size > 1 are the "probable duplicate groups"
|
|
38
|
+
counts: dict[int, int] = {}
|
|
39
|
+
for cid in result.clusters.values():
|
|
40
|
+
counts[cid] = counts.get(cid, 0) + 1
|
|
41
|
+
multi = sum(1 for sz in counts.values() if sz > 1)
|
|
42
|
+
total_records_in_dupe_group = sum(
|
|
43
|
+
sz for sz in counts.values() if sz > 1
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
severity = "warn" if multi > 0 else "ok"
|
|
47
|
+
return severity, {
|
|
48
|
+
**summary,
|
|
49
|
+
"duplicate_clusters": multi,
|
|
50
|
+
"records_in_duplicate_groups": total_records_in_dupe_group,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _engine_to_polars(engine: Any) -> Any:
|
|
55
|
+
"""Return the underlying frame as polars regardless of engine."""
|
|
56
|
+
# PolarsEngine stores a DataFrame directly
|
|
57
|
+
name = getattr(engine, "name", "")
|
|
58
|
+
if name == "polars":
|
|
59
|
+
return engine._df
|
|
60
|
+
if name == "pandas":
|
|
61
|
+
import polars as pl
|
|
62
|
+
|
|
63
|
+
return pl.from_pandas(engine._df)
|
|
64
|
+
# dask / cudf — round-trip via pandas
|
|
65
|
+
import polars as pl
|
|
66
|
+
|
|
67
|
+
underlying = engine._df
|
|
68
|
+
materialised = (
|
|
69
|
+
underlying.compute()
|
|
70
|
+
if hasattr(underlying, "compute")
|
|
71
|
+
else underlying.to_pandas()
|
|
72
|
+
)
|
|
73
|
+
return pl.from_pandas(materialised)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Missing value check.
|
|
2
|
+
|
|
3
|
+
Reports per-column null counts and percentages. Severity rules:
|
|
4
|
+
* any nulls -> warn
|
|
5
|
+
* columns where >50% are null -> error
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from qualipilot.checks.base import Check, CheckContext
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MissingValuesCheck(Check):
|
|
16
|
+
name = "missing_values"
|
|
17
|
+
|
|
18
|
+
def _execute(self, ctx: CheckContext) -> tuple[str, dict[str, Any]]:
|
|
19
|
+
nulls = ctx.engine.null_counts()
|
|
20
|
+
total_rows = ctx.engine.row_count() or 1
|
|
21
|
+
stats = [
|
|
22
|
+
{
|
|
23
|
+
"column": col,
|
|
24
|
+
"null_count": int(count),
|
|
25
|
+
"null_percentage": round((count / total_rows) * 100, 4),
|
|
26
|
+
}
|
|
27
|
+
for col, count in nulls.items()
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
total_nulls = sum(s["null_count"] for s in stats)
|
|
31
|
+
worst = max((s["null_percentage"] for s in stats), default=0.0)
|
|
32
|
+
|
|
33
|
+
severity = "ok"
|
|
34
|
+
if worst > 50:
|
|
35
|
+
severity = "error"
|
|
36
|
+
elif total_nulls > 0:
|
|
37
|
+
severity = "warn"
|
|
38
|
+
|
|
39
|
+
return severity, {
|
|
40
|
+
"total_null_count": total_nulls,
|
|
41
|
+
"worst_column_pct": worst,
|
|
42
|
+
"per_column": stats,
|
|
43
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Outlier check using the IQR rule.
|
|
2
|
+
|
|
3
|
+
Computes Q1 and Q3 for every numeric column in a single pass via the
|
|
4
|
+
engine's batched ``quantiles`` API, then counts/samples values outside
|
|
5
|
+
``[Q1 - k*IQR, Q3 + k*IQR]``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from qualipilot.checks.base import Check, CheckContext
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class OutliersCheck(Check):
|
|
17
|
+
name = "outliers"
|
|
18
|
+
|
|
19
|
+
def _execute(self, ctx: CheckContext) -> tuple[str, dict[str, Any]]:
|
|
20
|
+
numeric = ctx.engine.numeric_columns()
|
|
21
|
+
if not numeric:
|
|
22
|
+
return "ok", {"per_column": []}
|
|
23
|
+
|
|
24
|
+
qmap = ctx.engine.quantiles(numeric, qs=(0.25, 0.75))
|
|
25
|
+
k = ctx.config.outlier_iqr_multiplier
|
|
26
|
+
|
|
27
|
+
report: list[dict[str, Any]] = []
|
|
28
|
+
any_outliers = False
|
|
29
|
+
for col in numeric:
|
|
30
|
+
q1 = qmap[col][0.25]
|
|
31
|
+
q3 = qmap[col][0.75]
|
|
32
|
+
if _is_nan(q1) or _is_nan(q3):
|
|
33
|
+
# constant or empty column, nothing to flag
|
|
34
|
+
continue
|
|
35
|
+
iqr = q3 - q1
|
|
36
|
+
low = q1 - k * iqr
|
|
37
|
+
high = q3 + k * iqr
|
|
38
|
+
count = ctx.engine.count_outside(col, low, high)
|
|
39
|
+
sample = (
|
|
40
|
+
ctx.engine.sample_outside(
|
|
41
|
+
col, low, high, ctx.config.sample_size
|
|
42
|
+
)
|
|
43
|
+
if count
|
|
44
|
+
else []
|
|
45
|
+
)
|
|
46
|
+
if count:
|
|
47
|
+
any_outliers = True
|
|
48
|
+
report.append(
|
|
49
|
+
{
|
|
50
|
+
"column": col,
|
|
51
|
+
"lower_bound": low,
|
|
52
|
+
"upper_bound": high,
|
|
53
|
+
"outlier_count": count,
|
|
54
|
+
"sample": sample,
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
severity = "warn" if any_outliers else "ok"
|
|
59
|
+
return severity, {"per_column": report}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _is_nan(value: float) -> bool:
|
|
63
|
+
# protects against None-like sentinels alongside real nan floats
|
|
64
|
+
try:
|
|
65
|
+
return math.isnan(value)
|
|
66
|
+
except TypeError:
|
|
67
|
+
return False
|