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.
Files changed (46) hide show
  1. cleanframe/__init__.py +169 -0
  2. cleanframe/__main__.py +5 -0
  3. cleanframe/_util.py +438 -0
  4. cleanframe/_version.py +1 -0
  5. cleanframe/api.py +559 -0
  6. cleanframe/cli.py +688 -0
  7. cleanframe/codegen.py +666 -0
  8. cleanframe/dataio.py +506 -0
  9. cleanframe/detectors/__init__.py +43 -0
  10. cleanframe/detectors/base.py +221 -0
  11. cleanframe/detectors/categories.py +199 -0
  12. cleanframe/detectors/contacts.py +105 -0
  13. cleanframe/detectors/currency.py +112 -0
  14. cleanframe/detectors/dates.py +204 -0
  15. cleanframe/detectors/dedup.py +150 -0
  16. cleanframe/detectors/nulls.py +109 -0
  17. cleanframe/detectors/outliers.py +73 -0
  18. cleanframe/detectors/schema_mapping.py +125 -0
  19. cleanframe/detectors/text.py +105 -0
  20. cleanframe/detectors/units.py +86 -0
  21. cleanframe/diff.py +369 -0
  22. cleanframe/drift.py +283 -0
  23. cleanframe/errors.py +66 -0
  24. cleanframe/executor.py +229 -0
  25. cleanframe/fingerprint.py +83 -0
  26. cleanframe/issues.py +186 -0
  27. cleanframe/llm.py +811 -0
  28. cleanframe/ops.py +1245 -0
  29. cleanframe/planner.py +353 -0
  30. cleanframe/profile.py +413 -0
  31. cleanframe/py.typed +1 -0
  32. cleanframe/quality.py +81 -0
  33. cleanframe/readfix.py +160 -0
  34. cleanframe/recipe.py +398 -0
  35. cleanframe/report.py +345 -0
  36. cleanframe/result.py +144 -0
  37. cleanframe/schema.py +259 -0
  38. cleanframe/streaming.py +354 -0
  39. cleanframe/types.py +119 -0
  40. cleanframe/validate.py +363 -0
  41. cleanframe/workbook.py +370 -0
  42. cleanframe_engine-0.3.0.dist-info/METADATA +323 -0
  43. cleanframe_engine-0.3.0.dist-info/RECORD +46 -0
  44. cleanframe_engine-0.3.0.dist-info/WHEEL +4 -0
  45. cleanframe_engine-0.3.0.dist-info/entry_points.txt +2 -0
  46. 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
+ ]