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/planner.py ADDED
@@ -0,0 +1,353 @@
1
+ """The planner: turn a profile + detected issues into a concrete :class:`Recipe`.
2
+
3
+ Detectors supply *proposals* (domain knowledge). The planner applies *policy*:
4
+
5
+ * **Confidence gating by mode** — ``strict`` takes only high-confidence fixes,
6
+ ``review`` surfaces almost everything for a human, ``auto`` sits between.
7
+ * **Canonical op ordering** — the single source of truth for what order ops run in
8
+ a column, so whitespace is cleaned before categories are normalised, currency is
9
+ split before its symbols are stripped, and casing is applied last. This is what
10
+ makes independently-authored detectors compose correctly.
11
+ * **Rename resolution** — schema mapping beats a detector's rename (currency's
12
+ ``amount_inr``) beats a default ``snake_case``, with collision handling.
13
+ * **Validation synthesis** — from the target schema's constraints, or (schemaless)
14
+ from the semantic types of the columns.
15
+
16
+ The planner is deliberately an interface (:class:`Planner`) with a deterministic
17
+ :class:`RulesPlanner` implementation. An LLM planner (see :mod:`cleanframe.llm`)
18
+ implements the same interface and returns a :class:`Recipe` validated by the *same*
19
+ model — which is exactly why "the LLM never touches your data": it only ever emits
20
+ a plan the deterministic executor runs.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ from typing import Any, Protocol
27
+
28
+ import pandas as pd
29
+
30
+ from ._util import snake_case
31
+ from .fingerprint import fingerprint_dataframe
32
+ from .issues import Issues
33
+ from .ops import get_op
34
+ from .profile import DataFrameProfile, profile_dataframe
35
+ from .recipe import ColumnRecipe, Recipe, ValidationRule
36
+ from .types import Mode, Op
37
+
38
+ #: Minimum issue confidence for a fix to be included, per mode.
39
+ MODE_THRESHOLDS: dict[Mode, float] = {
40
+ Mode.REVIEW: 0.5,
41
+ Mode.AUTO: 0.65,
42
+ Mode.STRICT: 0.85,
43
+ }
44
+
45
+ #: Canonical execution order for column ops. Ops not listed keep insertion order
46
+ #: after all listed ones. THE contract that lets detectors stay independent.
47
+ OP_ORDER: list[str] = [
48
+ "strip_whitespace",
49
+ "collapse_whitespace",
50
+ "to_na",
51
+ "extract_currency",
52
+ "remove_symbols",
53
+ "normalize_unit",
54
+ "parse_number",
55
+ "round",
56
+ "cast",
57
+ "parse_date",
58
+ "normalize_email",
59
+ "normalize_phone",
60
+ "replace",
61
+ "normalize_values",
62
+ "capitalize",
63
+ "title_case",
64
+ "lowercase",
65
+ "uppercase",
66
+ "fill_na",
67
+ ]
68
+ _ORDER_INDEX = {name: i for i, name in enumerate(OP_ORDER)}
69
+
70
+ # Detector-source ranking for rename resolution (lower wins).
71
+ _RENAME_RANK = {"schema_mapping": 0, "currency": 1}
72
+
73
+
74
+ class Planner(Protocol):
75
+ """Anything that maps (data, profile, issues, schema, mode) to a Recipe."""
76
+
77
+ def plan(
78
+ self,
79
+ df: pd.DataFrame,
80
+ profile: DataFrameProfile,
81
+ issues: Issues,
82
+ *,
83
+ schema: Any | None = None,
84
+ mode: Mode | str = Mode.REVIEW,
85
+ options: dict[str, Any] | None = None,
86
+ ) -> Recipe: ...
87
+
88
+
89
+ def _op_key(op: Op) -> tuple[str, str]:
90
+ return op.name, json.dumps(op.params, sort_keys=True, default=str)
91
+
92
+
93
+ def _finalize_ops(ops: list[Op]) -> list[Op]:
94
+ """De-duplicate, subsume, and canonically order a column's ops."""
95
+ # 1) drop exact duplicates, keep first occurrence
96
+ seen: set[tuple[str, str]] = set()
97
+ unique: list[Op] = []
98
+ for op in ops:
99
+ key = _op_key(op)
100
+ if key in seen:
101
+ continue
102
+ seen.add(key)
103
+ unique.append(op)
104
+
105
+ names = {op.name for op in unique}
106
+ # 2) subsumption: a stronger op makes a weaker one redundant
107
+ drop: set[str] = set()
108
+ if "normalize_email" in names:
109
+ drop |= {"strip_whitespace", "collapse_whitespace", "lowercase"}
110
+ if "collapse_whitespace" in names:
111
+ drop |= {"strip_whitespace"}
112
+ unique = [op for op in unique if op.name not in drop]
113
+
114
+ # 3) canonical order; ties and unlisted ops fall back to original position,
115
+ # so the sort is stable and deterministic.
116
+ ordered = sorted(
117
+ enumerate(unique),
118
+ key=lambda pair: (_ORDER_INDEX.get(pair[1].name, len(OP_ORDER) + pair[0]), pair[0]),
119
+ )
120
+ return [op for _, op in ordered]
121
+
122
+
123
+ class RulesPlanner:
124
+ """Deterministic planner. No LLM, no network — the default and always-available path."""
125
+
126
+ def plan(
127
+ self,
128
+ df: pd.DataFrame,
129
+ profile: DataFrameProfile,
130
+ issues: Issues,
131
+ *,
132
+ schema: Any | None = None,
133
+ mode: Mode | str = Mode.REVIEW,
134
+ options: dict[str, Any] | None = None,
135
+ ) -> Recipe:
136
+ mode = Mode.coerce(mode)
137
+ options = options or {}
138
+ threshold = MODE_THRESHOLDS[mode]
139
+ rename_columns = options.get("rename_columns", True)
140
+
141
+ column_ops: dict[str, list[Op]] = {}
142
+ frame_ops: list[Op] = []
143
+ rename_candidates: dict[str, list[tuple[int, float, str]]] = {}
144
+
145
+ for issue in issues:
146
+ if not issue.has_fix or issue.confidence < threshold:
147
+ continue
148
+ proposal = issue.proposal
149
+ if proposal is None:
150
+ continue
151
+ for op in proposal.ops:
152
+ if get_op(op.name).scope == "frame":
153
+ frame_ops.append(op)
154
+ elif issue.column is not None:
155
+ column_ops.setdefault(issue.column, []).append(op)
156
+ if proposal.rename_to and issue.column is not None:
157
+ rank = _RENAME_RANK.get(issue.detector, 2)
158
+ rename_candidates.setdefault(issue.column, []).append(
159
+ (rank, issue.confidence, proposal.rename_to)
160
+ )
161
+
162
+ renames = self._resolve_renames(df, rename_candidates, rename_columns)
163
+ self._apply_schema_casts(df, schema, column_ops, renames)
164
+
165
+ columns: list[ColumnRecipe] = []
166
+ for col in df.columns:
167
+ col = str(col)
168
+ ops = _finalize_ops(column_ops.get(col, []))
169
+ rename_to = renames.get(col)
170
+ if ops or rename_to:
171
+ columns.append(ColumnRecipe(source=col, rename_to=rename_to, ops=ops))
172
+
173
+ validations = self._build_validations(df, profile, schema, renames, mode)
174
+
175
+ recipe = Recipe(
176
+ columns=columns,
177
+ frame_ops=_dedup_ops(frame_ops),
178
+ validations=validations,
179
+ source_fingerprint=fingerprint_dataframe(df),
180
+ )
181
+ notes = self._notes(issues, mode, threshold)
182
+ recipe.stamp_meta(generated_by="rules", mode=mode.value, notes=notes or None)
183
+ return recipe
184
+
185
+ # -- renames ---------------------------------------------------------
186
+ def _resolve_renames(
187
+ self,
188
+ df: pd.DataFrame,
189
+ candidates: dict[str, list[tuple[int, float, str]]],
190
+ rename_columns: bool,
191
+ ) -> dict[str, str]:
192
+ chosen: dict[str, str] = {}
193
+ for col, cands in candidates.items():
194
+ cands.sort(key=lambda t: (t[0], -t[1], t[2]))
195
+ chosen[col] = cands[0][2]
196
+
197
+ if rename_columns:
198
+ for col in df.columns:
199
+ col = str(col)
200
+ if col not in chosen:
201
+ sn = snake_case(col)
202
+ if sn and sn != col:
203
+ chosen[col] = sn
204
+
205
+ # Collision handling: an output name is claimed at most once. A rename must
206
+ # not clobber another column that is KEEPING its name, so we reserve those
207
+ # up front (this is the case a purely-incremental check misses: e.g. a frame
208
+ # with both "First Name" and "first_name" — the former must not snake_case
209
+ # onto the latter). A colliding rename is dropped; the column keeps its
210
+ # source name, and only if that too is taken do we fall back to a suffix.
211
+ occupied: set[str] = {str(c) for c in df.columns if str(c) not in chosen}
212
+ final: dict[str, str] = {}
213
+ for col in df.columns:
214
+ col = str(col)
215
+ if col not in chosen:
216
+ continue # keeps its original name, already reserved
217
+ target = chosen[col]
218
+ if target not in occupied:
219
+ final[col] = target
220
+ occupied.add(target)
221
+ elif col not in occupied:
222
+ occupied.add(col) # give up the rename, keep the source name
223
+ else:
224
+ suffix = 2
225
+ while f"{target}_{suffix}" in occupied:
226
+ suffix += 1
227
+ final[col] = f"{target}_{suffix}"
228
+ occupied.add(f"{target}_{suffix}")
229
+ return final
230
+
231
+ # -- schema-driven casts (light touch) -------------------------------
232
+ def _apply_schema_casts(
233
+ self,
234
+ df: pd.DataFrame,
235
+ schema: Any | None,
236
+ column_ops: dict[str, list[Op]],
237
+ renames: dict[str, str],
238
+ ) -> None:
239
+ if schema is None:
240
+ return
241
+ output_to_source = {renames.get(str(c), str(c)): str(c) for c in df.columns}
242
+ for scol in getattr(schema, "columns", []):
243
+ src = output_to_source.get(scol.name)
244
+ if src is None:
245
+ continue
246
+ existing = {op.name for op in column_ops.get(src, [])}
247
+ if scol.dtype in ("integer", "float") and not ({"parse_number", "cast"} & existing):
248
+ column_ops.setdefault(src, []).append(Op("cast", {"to": scol.dtype}))
249
+ elif scol.dtype == "integer" and "parse_number" in existing and "cast" not in existing:
250
+ column_ops.setdefault(src, []).append(Op("cast", {"to": "int"}))
251
+ if scol.dtype in ("date", "datetime") and "parse_date" not in existing and scol.date_formats:
252
+ column_ops.setdefault(src, []).append(
253
+ Op("parse_date", {"formats": list(scol.date_formats)})
254
+ )
255
+
256
+ # -- validations -----------------------------------------------------
257
+ def _build_validations(
258
+ self,
259
+ df: pd.DataFrame,
260
+ profile: DataFrameProfile,
261
+ schema: Any | None,
262
+ renames: dict[str, str],
263
+ mode: Mode,
264
+ ) -> list[ValidationRule]:
265
+ on_fail = "error" if mode is Mode.STRICT else "quarantine"
266
+ rules: list[ValidationRule] = []
267
+
268
+ def out(col: str) -> str:
269
+ return renames.get(col, col)
270
+
271
+ if schema is not None:
272
+ output_names = {out(str(c)) for c in df.columns}
273
+ for scol in getattr(schema, "columns", []):
274
+ if scol.name not in output_names:
275
+ continue
276
+ if scol.required:
277
+ rules.append(ValidationRule(scol.name, "not_null", on_fail))
278
+ if scol.unique:
279
+ rules.append(ValidationRule(scol.name, "unique", on_fail))
280
+ if scol.dtype == "email":
281
+ rules.append(ValidationRule(scol.name, "valid_email", on_fail))
282
+ if scol.dtype == "phone":
283
+ rules.append(ValidationRule(scol.name, "valid_phone", on_fail))
284
+ if scol.min is not None:
285
+ rules.append(ValidationRule(scol.name, f">= {scol.min}", on_fail))
286
+ if scol.max is not None:
287
+ rules.append(ValidationRule(scol.name, f"<= {scol.max}", on_fail))
288
+ if scol.allowed_values:
289
+ rules.append(
290
+ ValidationRule(scol.name, "in", on_fail, params={"values": list(scol.allowed_values)})
291
+ )
292
+ else:
293
+ for cp in profile.columns:
294
+ o = out(cp.name)
295
+ if cp.semantic_type == "email":
296
+ rules.append(ValidationRule(o, "valid_email", on_fail))
297
+ elif cp.semantic_type == "phone":
298
+ rules.append(ValidationRule(o, "valid_phone", on_fail))
299
+ return rules
300
+
301
+ def _notes(self, issues: Issues, mode: Mode, threshold: float) -> list[str]:
302
+ notes: list[str] = []
303
+ skipped = [i for i in issues if i.has_fix and i.confidence < threshold]
304
+ if skipped:
305
+ kinds = sorted({i.kind for i in skipped})
306
+ notes.append(
307
+ f"{len(skipped)} low-confidence fix(es) omitted under mode={mode.value} "
308
+ f"(threshold {threshold}): {', '.join(kinds)}"
309
+ )
310
+ unresolved = [
311
+ i for i in issues
312
+ if not i.has_fix and i.severity.rank >= 1 and i.kind not in ("missing_values",)
313
+ ]
314
+ if unresolved:
315
+ notes.append(
316
+ f"{len(unresolved)} issue(s) need review (no automatic fix): "
317
+ + ", ".join(sorted({i.kind for i in unresolved}))
318
+ )
319
+ return notes
320
+
321
+
322
+ def _dedup_ops(ops: list[Op]) -> list[Op]:
323
+ seen: set[tuple[str, str]] = set()
324
+ out: list[Op] = []
325
+ for op in ops:
326
+ key = _op_key(op)
327
+ if key not in seen:
328
+ seen.add(key)
329
+ out.append(op)
330
+ return out
331
+
332
+
333
+ def plan_recipe(
334
+ df: pd.DataFrame,
335
+ *,
336
+ profile: DataFrameProfile | None = None,
337
+ issues: Issues | None = None,
338
+ schema: Any | None = None,
339
+ mode: Mode | str = Mode.REVIEW,
340
+ options: dict[str, Any] | None = None,
341
+ planner: Planner | None = None,
342
+ ) -> Recipe:
343
+ """Convenience: profile + detect + plan in one call using the rules planner by default."""
344
+ from .detectors import run_detectors # local import avoids a cycle
345
+
346
+ profile = profile or profile_dataframe(df)
347
+ if issues is None:
348
+ issues = run_detectors(df, profile=profile, schema=schema, options=options)
349
+ planner = planner or RulesPlanner()
350
+ return planner.plan(df, profile, issues, schema=schema, mode=mode, options=options)
351
+
352
+
353
+ __all__ = ["Planner", "RulesPlanner", "plan_recipe", "MODE_THRESHOLDS", "OP_ORDER"]