cleanframe-engine 0.3.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.
- cleanframe/__init__.py +169 -0
- cleanframe/__main__.py +5 -0
- cleanframe/_util.py +438 -0
- cleanframe/_version.py +1 -0
- cleanframe/api.py +559 -0
- cleanframe/cli.py +688 -0
- cleanframe/codegen.py +666 -0
- cleanframe/dataio.py +506 -0
- cleanframe/detectors/__init__.py +43 -0
- cleanframe/detectors/base.py +221 -0
- cleanframe/detectors/categories.py +199 -0
- cleanframe/detectors/contacts.py +105 -0
- cleanframe/detectors/currency.py +112 -0
- cleanframe/detectors/dates.py +204 -0
- cleanframe/detectors/dedup.py +150 -0
- cleanframe/detectors/nulls.py +109 -0
- cleanframe/detectors/outliers.py +73 -0
- cleanframe/detectors/schema_mapping.py +125 -0
- cleanframe/detectors/text.py +105 -0
- cleanframe/detectors/units.py +86 -0
- cleanframe/diff.py +369 -0
- cleanframe/drift.py +283 -0
- cleanframe/errors.py +66 -0
- cleanframe/executor.py +229 -0
- cleanframe/fingerprint.py +83 -0
- cleanframe/issues.py +186 -0
- cleanframe/llm.py +811 -0
- cleanframe/ops.py +1245 -0
- cleanframe/planner.py +353 -0
- cleanframe/profile.py +413 -0
- cleanframe/py.typed +1 -0
- cleanframe/quality.py +81 -0
- cleanframe/readfix.py +160 -0
- cleanframe/recipe.py +398 -0
- cleanframe/report.py +345 -0
- cleanframe/result.py +144 -0
- cleanframe/schema.py +259 -0
- cleanframe/streaming.py +354 -0
- cleanframe/types.py +119 -0
- cleanframe/validate.py +363 -0
- cleanframe/workbook.py +370 -0
- cleanframe_engine-0.3.0.dist-info/METADATA +323 -0
- cleanframe_engine-0.3.0.dist-info/RECORD +46 -0
- cleanframe_engine-0.3.0.dist-info/WHEEL +4 -0
- cleanframe_engine-0.3.0.dist-info/entry_points.txt +2 -0
- cleanframe_engine-0.3.0.dist-info/licenses/LICENSE +201 -0
cleanframe/types.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Shared enums and small value types used across CleanFrame.
|
|
2
|
+
|
|
3
|
+
Kept dependency-free (stdlib only) so every other module can import it without
|
|
4
|
+
risk of an import cycle.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Mode(str, Enum):
|
|
15
|
+
"""How aggressively CleanFrame commits to fixes and how it reacts to problems.
|
|
16
|
+
|
|
17
|
+
``str`` mixin so a plain ``"review"`` string round-trips through configs and
|
|
18
|
+
YAML transparently (``Mode("review") == "review"``).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
#: Build the recipe and cleaned frame, quarantine validation failures, and
|
|
22
|
+
#: surface everything for a human to approve. Nothing is written to disk
|
|
23
|
+
#: automatically. This is the default.
|
|
24
|
+
REVIEW = "review"
|
|
25
|
+
|
|
26
|
+
#: For unattended pipelines: only fixes at or above a higher confidence
|
|
27
|
+
#: threshold than ``review`` are planned (see ``planner.MODE_THRESHOLDS``).
|
|
28
|
+
AUTO = "auto"
|
|
29
|
+
|
|
30
|
+
#: Zero tolerance. Validation failures and schema drift raise instead of
|
|
31
|
+
#: being quarantined or silently dropped, and only the highest-confidence
|
|
32
|
+
#: fixes are planned at all — lower-confidence proposals are omitted.
|
|
33
|
+
STRICT = "strict"
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def coerce(cls, value: Mode | str) -> Mode:
|
|
37
|
+
if isinstance(value, cls):
|
|
38
|
+
return value
|
|
39
|
+
try:
|
|
40
|
+
return cls(str(value).strip().lower())
|
|
41
|
+
except ValueError as exc:
|
|
42
|
+
from .errors import CleanFrameError
|
|
43
|
+
|
|
44
|
+
valid = ", ".join(m.value for m in cls)
|
|
45
|
+
raise CleanFrameError(
|
|
46
|
+
f"Unknown mode {value!r}. Expected one of: {valid}"
|
|
47
|
+
) from exc
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Severity(str, Enum):
|
|
51
|
+
"""Ordered severity for detected issues and validation results."""
|
|
52
|
+
|
|
53
|
+
INFO = "info"
|
|
54
|
+
WARNING = "warning"
|
|
55
|
+
ERROR = "error"
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def rank(self) -> int:
|
|
59
|
+
return {"info": 0, "warning": 1, "error": 2}[self.value]
|
|
60
|
+
|
|
61
|
+
def __lt__(self, other: object) -> bool:
|
|
62
|
+
if isinstance(other, Severity):
|
|
63
|
+
return self.rank < other.rank
|
|
64
|
+
return NotImplemented
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class LLMExposure(str, Enum):
|
|
68
|
+
"""How much of your data an LLM planner is permitted to see.
|
|
69
|
+
|
|
70
|
+
Mirrors the privacy tiers documented in the README. ``NONE`` keeps everything
|
|
71
|
+
on your machine; higher tiers progressively expand what metadata may leave it,
|
|
72
|
+
and never include raw cell values unless you explicitly opt into ``SAMPLE``.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
#: Rules-only. No network calls, nothing leaves the machine.
|
|
76
|
+
NONE = "none"
|
|
77
|
+
#: LLM sees column names, dtypes, and value *patterns* (regex sketches) only.
|
|
78
|
+
METADATA = "metadata"
|
|
79
|
+
#: LLM additionally sees an anonymized, shuffled sample the caller approved.
|
|
80
|
+
SAMPLE = "sample"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class Op:
|
|
85
|
+
"""A single transformation step: an op name plus its parameters.
|
|
86
|
+
|
|
87
|
+
``Op`` is the atomic unit of a recipe. It is deliberately dumb — it carries a
|
|
88
|
+
name and a params dict, and knows how to render itself back to the compact
|
|
89
|
+
YAML form. The mapping from name to a concrete pandas function lives in the op
|
|
90
|
+
registry (:mod:`cleanframe.ops`); the mapping to executable Python source lives
|
|
91
|
+
in :mod:`cleanframe.codegen`. Keeping ``Op`` free of behaviour is what lets the
|
|
92
|
+
exact same object flow from detector → planner → recipe → executor → codegen.
|
|
93
|
+
|
|
94
|
+
The compact form used in recipe YAML is either a bare string (no params) or a
|
|
95
|
+
single-key mapping ``{name: params}``::
|
|
96
|
+
|
|
97
|
+
strip_whitespace # -> Op("strip_whitespace", {})
|
|
98
|
+
{remove_symbols: ["₹", ","]} # -> Op("remove_symbols", {"symbols": [...]})
|
|
99
|
+
{cast: float} # -> Op("cast", {"to": "float"})
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
name: str
|
|
103
|
+
params: dict[str, Any] = field(default_factory=dict)
|
|
104
|
+
|
|
105
|
+
def to_compact(self) -> Any:
|
|
106
|
+
"""Render to the compact YAML form (bare string when there are no params)."""
|
|
107
|
+
if not self.params:
|
|
108
|
+
return self.name
|
|
109
|
+
return {self.name: dict(self.params)}
|
|
110
|
+
|
|
111
|
+
def with_params(self, **updates: Any) -> Op:
|
|
112
|
+
merged = dict(self.params)
|
|
113
|
+
merged.update(updates)
|
|
114
|
+
return Op(self.name, merged)
|
|
115
|
+
|
|
116
|
+
def __repr__(self) -> str: # pragma: no cover - cosmetic
|
|
117
|
+
if not self.params:
|
|
118
|
+
return f"Op({self.name!r})"
|
|
119
|
+
return f"Op({self.name!r}, {self.params!r})"
|
cleanframe/validate.py
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""Post-clean validation with per-rule failure policies.
|
|
2
|
+
|
|
3
|
+
Validation runs *after* the recipe's transforms. Each rule computes a pass/fail
|
|
4
|
+
mask over a column; the rule's ``on_fail`` decides what happens to failing rows:
|
|
5
|
+
|
|
6
|
+
``quarantine`` (default)
|
|
7
|
+
Move the row into a side ``quarantine`` frame with a reason — never silently
|
|
8
|
+
dropped, never corrupting the clean output.
|
|
9
|
+
``error``
|
|
10
|
+
Raise :class:`~cleanframe.errors.ValidationFailure`. ``strict`` mode promotes
|
|
11
|
+
*every* policy to ``error``.
|
|
12
|
+
``warn`` / ``drop`` / ``null``
|
|
13
|
+
Log only / discard the row / blank just the offending cell.
|
|
14
|
+
|
|
15
|
+
Checks are pluggable, like detectors::
|
|
16
|
+
|
|
17
|
+
@cf.validator("valid_iban")
|
|
18
|
+
def _(series): return series.isna() | series.str.match(IBAN_RE)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
import warnings
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
import numpy as np
|
|
30
|
+
import pandas as pd
|
|
31
|
+
import yaml
|
|
32
|
+
|
|
33
|
+
from ._util import safe_compile_regex
|
|
34
|
+
from .errors import CleanFrameWarning, RecipeError, ValidationFailure
|
|
35
|
+
from .profile import EMAIL_RE, URL_RE
|
|
36
|
+
from .recipe import ValidationRule
|
|
37
|
+
from .types import Mode
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Validator registry (named checks)
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
#: name -> function(series, **params) -> boolean pass-mask (True = ok).
|
|
43
|
+
VALIDATOR_REGISTRY: dict[str, Callable[..., pd.Series]] = {}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def validator(name: str) -> Callable[[Callable], Callable]:
|
|
47
|
+
"""Register a named validation check. The function returns a pass-mask."""
|
|
48
|
+
|
|
49
|
+
def decorator(func: Callable) -> Callable:
|
|
50
|
+
if name in VALIDATOR_REGISTRY:
|
|
51
|
+
raise ValueError(f"Validator {name!r} is already registered.")
|
|
52
|
+
VALIDATOR_REGISTRY[name] = func
|
|
53
|
+
return func
|
|
54
|
+
|
|
55
|
+
return decorator
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def list_validators() -> list[str]:
|
|
59
|
+
return sorted(VALIDATOR_REGISTRY)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_PHONE_DIGITS = re.compile(r"\D")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@validator("not_null")
|
|
66
|
+
def _not_null(series: pd.Series) -> pd.Series:
|
|
67
|
+
return series.notna()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@validator("unique")
|
|
71
|
+
def _unique(series: pd.Series) -> pd.Series:
|
|
72
|
+
duplicated = series.duplicated(keep=False) & series.notna()
|
|
73
|
+
return ~duplicated
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@validator("valid_email")
|
|
77
|
+
def _valid_email(series: pd.Series) -> pd.Series:
|
|
78
|
+
def ok(v: Any) -> bool:
|
|
79
|
+
return bool(EMAIL_RE.match(str(v).strip().lower()))
|
|
80
|
+
|
|
81
|
+
return series.isna() | series.map(ok)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@validator("valid_url")
|
|
85
|
+
def _valid_url(series: pd.Series) -> pd.Series:
|
|
86
|
+
return series.isna() | series.map(lambda v: bool(URL_RE.match(str(v).strip())))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@validator("valid_phone")
|
|
90
|
+
def _valid_phone(series: pd.Series) -> pd.Series:
|
|
91
|
+
def ok(v: Any) -> bool:
|
|
92
|
+
return 7 <= len(_PHONE_DIGITS.sub("", str(v))) <= 15
|
|
93
|
+
|
|
94
|
+
return series.isna() | series.map(ok)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# Expression checks (comparisons / membership / regex)
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
_CMP_RE = re.compile(r"^(>=|<=|==|!=|>|<)\s*(-?\d+(?:\.\d+)?)$")
|
|
101
|
+
_CMP_OPS: dict[str, Callable[[Any, float], Any]] = {
|
|
102
|
+
">=": lambda s, t: s >= t,
|
|
103
|
+
"<=": lambda s, t: s <= t,
|
|
104
|
+
">": lambda s, t: s > t,
|
|
105
|
+
"<": lambda s, t: s < t,
|
|
106
|
+
"==": lambda s, t: s == t,
|
|
107
|
+
"!=": lambda s, t: s != t,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _comparison_mask(series: pd.Series, op: str, threshold: float) -> pd.Series:
|
|
112
|
+
numeric = pd.to_numeric(series, errors="coerce")
|
|
113
|
+
orig_na = series.isna()
|
|
114
|
+
satisfies = _CMP_OPS[op](numeric, threshold)
|
|
115
|
+
satisfies = satisfies.fillna(False).astype(bool)
|
|
116
|
+
return orig_na | (numeric.notna() & satisfies)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _membership_values(rule: ValidationRule) -> list[Any]:
|
|
120
|
+
values = rule.params.get("values")
|
|
121
|
+
if values is not None:
|
|
122
|
+
return [values] if isinstance(values, str) else list(values)
|
|
123
|
+
rest = rule.check[2:].strip() # drop leading "in"
|
|
124
|
+
# BaseLoader keeps every scalar a string: YAML 1.1 would read `in [Yes, No]`
|
|
125
|
+
# as booleans and then reject every literal "Yes"/"No" in the data.
|
|
126
|
+
parsed = yaml.load(rest, Loader=yaml.BaseLoader) if rest else []
|
|
127
|
+
return list(parsed) if isinstance(parsed, (list, tuple)) else [parsed]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
#: YAML 1.1 reads a bare ``Yes``/``No``/``On``/``Off`` as a boolean, so a rule written
|
|
131
|
+
#: ``values: [Yes, No]`` arrives here as ``[True, False]``. Accept every spelling of
|
|
132
|
+
#: the boolean rather than rejecting the literal text the data actually holds.
|
|
133
|
+
_BOOL_SPELLINGS: dict[bool, tuple[str, ...]] = {
|
|
134
|
+
True: ("True", "true", "TRUE", "Yes", "yes", "YES", "On", "on", "Y", "y", "1"),
|
|
135
|
+
False: ("False", "false", "FALSE", "No", "no", "NO", "Off", "off", "N", "n", "0"),
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _membership_mask(series: pd.Series, values: list[Any]) -> pd.Series:
|
|
140
|
+
as_str: set[str] = set()
|
|
141
|
+
for value in values:
|
|
142
|
+
if isinstance(value, bool):
|
|
143
|
+
as_str.update(_BOOL_SPELLINGS[value])
|
|
144
|
+
else:
|
|
145
|
+
as_str.add(str(value))
|
|
146
|
+
return series.isna() | series.isin(values) | series.astype(str).isin(as_str)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _regex_mask(series: pd.Series, pattern: str) -> pd.Series:
|
|
150
|
+
try:
|
|
151
|
+
compiled = safe_compile_regex(pattern)
|
|
152
|
+
except ValueError as exc:
|
|
153
|
+
raise RecipeError(str(exc)) from exc
|
|
154
|
+
return series.isna() | series.map(lambda v: bool(compiled.search(str(v))))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
_EXPRESSION_PREFIXES = ("in ", "in[", "matches", "regex")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def check_is_known(check: str) -> None:
|
|
161
|
+
"""Raise :class:`RecipeError` for a check that is neither registered nor an expression.
|
|
162
|
+
|
|
163
|
+
Called at recipe *load* time so a typo like ``valid_emial`` fails before the
|
|
164
|
+
recipe is applied to production data.
|
|
165
|
+
"""
|
|
166
|
+
text = str(check).strip()
|
|
167
|
+
if not text:
|
|
168
|
+
raise RecipeError("A validation rule needs a non-empty 'check'.")
|
|
169
|
+
if text in VALIDATOR_REGISTRY or _CMP_RE.match(text) or text == "in":
|
|
170
|
+
return
|
|
171
|
+
if text.startswith(_EXPRESSION_PREFIXES):
|
|
172
|
+
return
|
|
173
|
+
raise RecipeError(
|
|
174
|
+
f"Unknown validation check {check!r}. Known: {', '.join(list_validators())}, "
|
|
175
|
+
"comparisons (>= 0), 'in [...]', 'matches: <regex>'. Register a custom check "
|
|
176
|
+
"with @cleanframe.validator before loading the recipe."
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def pass_mask(rule: ValidationRule, series: pd.Series) -> pd.Series:
|
|
181
|
+
"""Compute the boolean pass-mask (True = passes) for ``rule`` over ``series``."""
|
|
182
|
+
check = rule.check.strip()
|
|
183
|
+
if check in VALIDATOR_REGISTRY:
|
|
184
|
+
try:
|
|
185
|
+
mask = VALIDATOR_REGISTRY[check](series, **rule.params)
|
|
186
|
+
except TypeError as exc:
|
|
187
|
+
raise RecipeError(
|
|
188
|
+
f"Validation {check!r} on column {rule.column!r} does not accept "
|
|
189
|
+
f"parameter(s) {sorted(rule.params)}: {exc}"
|
|
190
|
+
) from exc
|
|
191
|
+
return mask.astype(bool)
|
|
192
|
+
|
|
193
|
+
cmp = _CMP_RE.match(check)
|
|
194
|
+
if cmp:
|
|
195
|
+
return _comparison_mask(series, cmp.group(1), float(cmp.group(2))).astype(bool)
|
|
196
|
+
|
|
197
|
+
if check == "in" or check.startswith("in ") or check.startswith("in["):
|
|
198
|
+
return _membership_mask(series, _membership_values(rule)).astype(bool)
|
|
199
|
+
|
|
200
|
+
if check.startswith("matches") or check.startswith("regex"):
|
|
201
|
+
pattern = rule.params.get("pattern") or re.sub(r"^(matches|regex):?\s*", "", check)
|
|
202
|
+
return _regex_mask(series, pattern).astype(bool)
|
|
203
|
+
|
|
204
|
+
raise RecipeError(
|
|
205
|
+
f"Unknown validation check {check!r}. Known: {', '.join(list_validators())}, "
|
|
206
|
+
"comparisons (>= 0), 'in [...]', 'matches: <regex>'."
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
# Results + application
|
|
212
|
+
# ---------------------------------------------------------------------------
|
|
213
|
+
@dataclass
|
|
214
|
+
class ValidationResult:
|
|
215
|
+
column: str
|
|
216
|
+
check: str
|
|
217
|
+
on_fail: str
|
|
218
|
+
n_failed: int
|
|
219
|
+
failed_row_ids: list[int]
|
|
220
|
+
found: bool = True
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def passed(self) -> bool:
|
|
224
|
+
return self.n_failed == 0
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@dataclass
|
|
228
|
+
class ValidationOutcome:
|
|
229
|
+
dataframe: pd.DataFrame
|
|
230
|
+
quarantine: pd.DataFrame
|
|
231
|
+
results: list[ValidationResult]
|
|
232
|
+
removed_rows: list[tuple[int, str]] = field(default_factory=list)
|
|
233
|
+
log: list[str] = field(default_factory=list)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
QUARANTINE_REASON_COL = "_cf_quarantine_reason"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _reason_column(df: pd.DataFrame) -> str:
|
|
240
|
+
"""A free name for the quarantine reason, so a user column is never overwritten."""
|
|
241
|
+
name = QUARANTINE_REASON_COL
|
|
242
|
+
suffix = 2
|
|
243
|
+
while name in df.columns:
|
|
244
|
+
name = f"{QUARANTINE_REASON_COL}_{suffix}"
|
|
245
|
+
suffix += 1
|
|
246
|
+
return name
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def evaluate(rule: ValidationRule, df: pd.DataFrame) -> ValidationResult:
|
|
250
|
+
if rule.column not in df.columns:
|
|
251
|
+
return ValidationResult(rule.column, rule.check, rule.on_fail, 0, [], found=False)
|
|
252
|
+
series = df[rule.column]
|
|
253
|
+
ok = pass_mask(rule, series)
|
|
254
|
+
fail_mask = ~ok.to_numpy()
|
|
255
|
+
failed_ids = [int(i) for i in series.index[fail_mask]]
|
|
256
|
+
return ValidationResult(rule.column, rule.check, rule.on_fail, len(failed_ids), failed_ids)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _effective_policy(on_fail: str, mode: Mode) -> str:
|
|
260
|
+
# strict = zero tolerance: ANY failure raises, including drop/null which would
|
|
261
|
+
# otherwise silently discard rows or blank cells — the opposite of "fail loud".
|
|
262
|
+
if mode is Mode.STRICT and on_fail != "error":
|
|
263
|
+
return "error"
|
|
264
|
+
return on_fail
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def apply_validations(
|
|
268
|
+
df: pd.DataFrame, rules: list[ValidationRule], mode: Mode | str = Mode.REVIEW
|
|
269
|
+
) -> ValidationOutcome:
|
|
270
|
+
"""Evaluate all rules against a snapshot, then apply their failure policies."""
|
|
271
|
+
mode = Mode.coerce(mode)
|
|
272
|
+
work = df.copy()
|
|
273
|
+
results = [evaluate(rule, work) for rule in rules]
|
|
274
|
+
|
|
275
|
+
error_failures: list[ValidationResult] = []
|
|
276
|
+
null_actions: list[tuple[str, list[int]]] = []
|
|
277
|
+
quarantine_ids: set[int] = set()
|
|
278
|
+
drop_ids: set[int] = set()
|
|
279
|
+
reasons: dict[int, list[str]] = {}
|
|
280
|
+
log: list[str] = []
|
|
281
|
+
|
|
282
|
+
for rule, res in zip(rules, results, strict=True):
|
|
283
|
+
if not res.found:
|
|
284
|
+
if mode is Mode.STRICT:
|
|
285
|
+
raise ValidationFailure(
|
|
286
|
+
f"Validation failed under strict policy: column {res.column!r} "
|
|
287
|
+
f"for check {res.check!r} is missing from the frame.",
|
|
288
|
+
failures=[res],
|
|
289
|
+
)
|
|
290
|
+
log.append(f"validation skipped: column {res.column!r} not found")
|
|
291
|
+
warnings.warn(
|
|
292
|
+
f"CleanFrame: validation {res.check!r} skipped — column {res.column!r} is "
|
|
293
|
+
"not in the data. Use mode='strict' to fail instead.",
|
|
294
|
+
CleanFrameWarning,
|
|
295
|
+
stacklevel=2,
|
|
296
|
+
)
|
|
297
|
+
continue
|
|
298
|
+
if res.passed:
|
|
299
|
+
continue
|
|
300
|
+
action = _effective_policy(rule.on_fail, mode)
|
|
301
|
+
label = f"{res.column}:{res.check}"
|
|
302
|
+
if action == "error":
|
|
303
|
+
error_failures.append(res)
|
|
304
|
+
elif action == "warn":
|
|
305
|
+
log.append(f"warn: {res.n_failed} row(s) failed {label}")
|
|
306
|
+
elif action == "null":
|
|
307
|
+
null_actions.append((res.column, res.failed_row_ids))
|
|
308
|
+
log.append(f"null: blanked {res.n_failed} cell(s) failing {label}")
|
|
309
|
+
elif action == "drop":
|
|
310
|
+
drop_ids.update(res.failed_row_ids)
|
|
311
|
+
for rid in res.failed_row_ids:
|
|
312
|
+
reasons.setdefault(rid, []).append(label)
|
|
313
|
+
log.append(f"drop: removed {res.n_failed} row(s) failing {label}")
|
|
314
|
+
else: # quarantine (default)
|
|
315
|
+
quarantine_ids.update(res.failed_row_ids)
|
|
316
|
+
for rid in res.failed_row_ids:
|
|
317
|
+
reasons.setdefault(rid, []).append(label)
|
|
318
|
+
log.append(f"quarantine: held {res.n_failed} row(s) failing {label}")
|
|
319
|
+
|
|
320
|
+
if error_failures:
|
|
321
|
+
detail = "; ".join(f"{r.column}:{r.check} ({r.n_failed} failed)" for r in error_failures)
|
|
322
|
+
raise ValidationFailure(
|
|
323
|
+
f"Validation failed under {mode.value} policy: {detail}", failures=error_failures
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
# Snapshot quarantine BEFORE null-blanking so a row that fails both a null rule
|
|
327
|
+
# and a quarantine rule is preserved with its original (failing) value for the
|
|
328
|
+
# human to inspect — not blanked to NaN.
|
|
329
|
+
quarantine_df = pd.DataFrame()
|
|
330
|
+
if quarantine_ids:
|
|
331
|
+
ordered = sorted(quarantine_ids)
|
|
332
|
+
quarantine_df = work.loc[ordered].copy()
|
|
333
|
+
quarantine_df[_reason_column(work)] = ["; ".join(reasons[i]) for i in ordered]
|
|
334
|
+
|
|
335
|
+
for col, ids in null_actions:
|
|
336
|
+
work.loc[ids, col] = np.nan
|
|
337
|
+
|
|
338
|
+
removed = quarantine_ids | drop_ids
|
|
339
|
+
removed_rows = [(rid, "; ".join(reasons.get(rid, ["validation"]))) for rid in sorted(removed)]
|
|
340
|
+
if removed:
|
|
341
|
+
work = work.drop(index=sorted(removed))
|
|
342
|
+
|
|
343
|
+
return ValidationOutcome(
|
|
344
|
+
dataframe=work,
|
|
345
|
+
quarantine=quarantine_df,
|
|
346
|
+
results=results,
|
|
347
|
+
removed_rows=removed_rows,
|
|
348
|
+
log=log,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
__all__ = [
|
|
353
|
+
"ValidationResult",
|
|
354
|
+
"ValidationOutcome",
|
|
355
|
+
"validator",
|
|
356
|
+
"list_validators",
|
|
357
|
+
"check_is_known",
|
|
358
|
+
"evaluate",
|
|
359
|
+
"apply_validations",
|
|
360
|
+
"pass_mask",
|
|
361
|
+
"QUARANTINE_REASON_COL",
|
|
362
|
+
"VALIDATOR_REGISTRY",
|
|
363
|
+
]
|