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/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"]
|