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/workbook.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""Multi-sheet Excel workbooks: clean every tab, keep the rest, write them all back.
|
|
2
|
+
|
|
3
|
+
A workbook is handled as a set of *independent* single-frame cleans — each sheet
|
|
4
|
+
gets its own :class:`~cleanframe.recipe.Recipe` and :class:`~cleanframe.result.CleanResult`
|
|
5
|
+
(so per-sheet row-ids never collide) collected into a :class:`WorkbookResult`. The
|
|
6
|
+
matching :class:`WorkbookRecipe` is one reviewable YAML with a ``sheets:`` block.
|
|
7
|
+
|
|
8
|
+
Write-back caveat (important): re-emitting a workbook through pandas preserves the
|
|
9
|
+
*data* of every sheet, but NOT formulas, cell formatting, merged cells, multi-row
|
|
10
|
+
headers, or charts — untouched sheets are round-tripped through pandas, not
|
|
11
|
+
byte-copied. To avoid destroying such a workbook, :meth:`WorkbookResult.save_data`
|
|
12
|
+
refuses to overwrite the source file in place unless ``overwrite=True``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import warnings
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import pandas as pd
|
|
24
|
+
import yaml
|
|
25
|
+
|
|
26
|
+
from ._util import ensure_parent, load_yaml, read_text, write_text
|
|
27
|
+
from ._version import __version__
|
|
28
|
+
from .dataio import excel_sheet_names
|
|
29
|
+
from .errors import CleanFrameError, CleanFrameWarning, RecipeError
|
|
30
|
+
from .recipe import Recipe
|
|
31
|
+
from .result import CleanResult
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Workbook recipe (one YAML, one recipe per sheet)
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
@dataclass
|
|
38
|
+
class WorkbookRecipe:
|
|
39
|
+
"""An ordered set of per-sheet recipes. The sheet name is the key — a per-sheet
|
|
40
|
+
:class:`Recipe` never carries its own ``read.sheet`` (no duplicate source of truth)."""
|
|
41
|
+
|
|
42
|
+
sheets: dict[str, Recipe] = field(default_factory=dict)
|
|
43
|
+
meta: dict[str, Any] = field(default_factory=dict)
|
|
44
|
+
|
|
45
|
+
def to_dict(self) -> dict:
|
|
46
|
+
out: dict[str, Any] = {"version": 2, "sheets": {n: r.to_dict() for n, r in self.sheets.items()}}
|
|
47
|
+
if self.meta:
|
|
48
|
+
out["meta"] = self.meta
|
|
49
|
+
return out
|
|
50
|
+
|
|
51
|
+
def to_yaml(self) -> str:
|
|
52
|
+
header = (
|
|
53
|
+
"# CleanFrame workbook recipe — one recipe per sheet.\n"
|
|
54
|
+
"# Docs: https://github.com/inboxpraveen/Cleanframe/wiki\n"
|
|
55
|
+
)
|
|
56
|
+
return header + yaml.safe_dump(
|
|
57
|
+
self.to_dict(), sort_keys=False, allow_unicode=True, default_flow_style=False, width=100
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def save(self, path: str | Path) -> Path:
|
|
61
|
+
return write_text(path, self.to_yaml())
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_dict(cls, raw: dict) -> WorkbookRecipe:
|
|
65
|
+
if not isinstance(raw, dict) or "sheets" not in raw:
|
|
66
|
+
raise RecipeError("A workbook recipe must have a top-level 'sheets' mapping.")
|
|
67
|
+
if not isinstance(raw["sheets"], dict):
|
|
68
|
+
raise RecipeError("Workbook 'sheets' must be a mapping of sheet name -> recipe.")
|
|
69
|
+
sheets = {str(n): Recipe.from_dict(spec or {}) for n, spec in raw["sheets"].items()}
|
|
70
|
+
return cls(sheets=sheets, meta=raw.get("meta", {}) or {})
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def load(cls, path: str | Path) -> WorkbookRecipe:
|
|
74
|
+
path = Path(path)
|
|
75
|
+
if not path.exists():
|
|
76
|
+
raise RecipeError(f"Recipe not found: {path}")
|
|
77
|
+
if path.is_dir():
|
|
78
|
+
raise RecipeError(f"Recipe path is a directory, not a file: {path}")
|
|
79
|
+
return cls.from_dict(load_yaml(read_text(path), error=RecipeError, what="recipe"))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def load_recipe(path: str | Path) -> Recipe | WorkbookRecipe:
|
|
83
|
+
"""Load a recipe file, returning a :class:`WorkbookRecipe` if it has a ``sheets:``
|
|
84
|
+
block, else a single :class:`~cleanframe.recipe.Recipe`."""
|
|
85
|
+
path = Path(path)
|
|
86
|
+
if not path.exists():
|
|
87
|
+
raise RecipeError(f"Recipe not found: {path}")
|
|
88
|
+
if path.is_dir():
|
|
89
|
+
raise RecipeError(f"Recipe path is a directory, not a file: {path}")
|
|
90
|
+
data = load_yaml(read_text(path), error=RecipeError, what="recipe")
|
|
91
|
+
if isinstance(data, dict) and "sheets" in data:
|
|
92
|
+
return WorkbookRecipe.from_dict(data)
|
|
93
|
+
return Recipe.from_dict(data)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
# Workbook result
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
@dataclass
|
|
100
|
+
class WorkbookResult:
|
|
101
|
+
"""The outcome of cleaning a workbook: a :class:`CleanResult` per cleaned sheet,
|
|
102
|
+
plus the untouched sheets (kept verbatim for write-back), in workbook order."""
|
|
103
|
+
|
|
104
|
+
sheets: dict[str, CleanResult]
|
|
105
|
+
untouched: dict[str, pd.DataFrame] = field(default_factory=dict)
|
|
106
|
+
sheet_order: list[str] = field(default_factory=list)
|
|
107
|
+
source: str | None = None
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def recipe(self) -> WorkbookRecipe:
|
|
111
|
+
return WorkbookRecipe(
|
|
112
|
+
sheets={n: r.recipe for n, r in self.sheets.items()},
|
|
113
|
+
meta={"created_with": f"cleanframe {__version__}", "source": self.source},
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def frames(self) -> dict[str, pd.DataFrame]:
|
|
118
|
+
"""Cleaned frame per cleaned sheet (does not include untouched sheets)."""
|
|
119
|
+
return {n: r.dataframe for n, r in self.sheets.items()}
|
|
120
|
+
|
|
121
|
+
def save_recipe(self, path: str | Path) -> Path:
|
|
122
|
+
return self.recipe.save(path)
|
|
123
|
+
|
|
124
|
+
def save_data(self, path: str | Path, *, overwrite: bool = False) -> Path:
|
|
125
|
+
"""Write every sheet (cleaned where cleaned, original otherwise) to one .xlsx.
|
|
126
|
+
|
|
127
|
+
Refuses to overwrite the *source* workbook in place unless ``overwrite=True``
|
|
128
|
+
— re-emitting through pandas keeps data but drops formulas/formatting, so an
|
|
129
|
+
in-place rewrite could silently destroy a rich workbook.
|
|
130
|
+
"""
|
|
131
|
+
path = Path(path)
|
|
132
|
+
if (
|
|
133
|
+
self.source
|
|
134
|
+
and Path(self.source).exists()
|
|
135
|
+
and path.resolve() == Path(self.source).resolve()
|
|
136
|
+
and not overwrite
|
|
137
|
+
):
|
|
138
|
+
raise CleanFrameError(
|
|
139
|
+
"Refusing to overwrite the source workbook in place — formulas and "
|
|
140
|
+
"formatting are lost when pandas re-emits a sheet. Write to a new path, "
|
|
141
|
+
"or pass overwrite=True if you accept a data-only rewrite."
|
|
142
|
+
)
|
|
143
|
+
from ._util import sanitize_dataframe_for_spreadsheet
|
|
144
|
+
|
|
145
|
+
ensure_parent(path)
|
|
146
|
+
tmp = path.with_name(path.name + ".cf-tmp")
|
|
147
|
+
used_sheet_names: dict[str, str] = {}
|
|
148
|
+
try:
|
|
149
|
+
with pd.ExcelWriter(tmp, engine="openpyxl") as xl:
|
|
150
|
+
for name in self.sheet_order:
|
|
151
|
+
if name in self.sheets:
|
|
152
|
+
frame = sanitize_dataframe_for_spreadsheet(self.sheets[name].dataframe)
|
|
153
|
+
elif name in self.untouched:
|
|
154
|
+
frame = sanitize_dataframe_for_spreadsheet(self.untouched[name])
|
|
155
|
+
else:
|
|
156
|
+
continue
|
|
157
|
+
sheet_name = name[:31]
|
|
158
|
+
if sheet_name in used_sheet_names and used_sheet_names[sheet_name] != name:
|
|
159
|
+
raise CleanFrameError(
|
|
160
|
+
f"Excel sheet name collision after 31-char truncation: "
|
|
161
|
+
f"{name!r} and {used_sheet_names[sheet_name]!r} both become "
|
|
162
|
+
f"{sheet_name!r}. Rename one of the sheets before writing."
|
|
163
|
+
)
|
|
164
|
+
used_sheet_names[sheet_name] = name
|
|
165
|
+
frame.to_excel(xl, sheet_name=sheet_name, index=False)
|
|
166
|
+
os.replace(tmp, path)
|
|
167
|
+
except ImportError as exc: # pragma: no cover
|
|
168
|
+
tmp.unlink(missing_ok=True)
|
|
169
|
+
raise CleanFrameError(
|
|
170
|
+
"Writing .xlsx requires openpyxl. Try `pip install cleanframe-engine[excel]`."
|
|
171
|
+
) from exc
|
|
172
|
+
except Exception:
|
|
173
|
+
tmp.unlink(missing_ok=True)
|
|
174
|
+
raise
|
|
175
|
+
return path
|
|
176
|
+
|
|
177
|
+
def summary(self) -> str:
|
|
178
|
+
lines = [f"Workbook: {self.source or '<in-memory>'} ({len(self.sheet_order)} sheet(s))"]
|
|
179
|
+
for name in self.sheet_order:
|
|
180
|
+
if name in self.sheets:
|
|
181
|
+
d = self.sheets[name].diff.summary()
|
|
182
|
+
lines.append(
|
|
183
|
+
f" ✓ {name}: {d['changed_cells']} cell(s) changed, "
|
|
184
|
+
f"{d['rows_before']} → {d['rows_after']} rows"
|
|
185
|
+
)
|
|
186
|
+
else:
|
|
187
|
+
lines.append(f" · {name}: untouched")
|
|
188
|
+
return "\n".join(lines)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ---------------------------------------------------------------------------
|
|
192
|
+
# Reading + cleaning + applying
|
|
193
|
+
# ---------------------------------------------------------------------------
|
|
194
|
+
def _sheet_selection(sheets: Any) -> list[str] | None:
|
|
195
|
+
if sheets is None:
|
|
196
|
+
return None
|
|
197
|
+
if isinstance(sheets, str):
|
|
198
|
+
raise CleanFrameError(
|
|
199
|
+
f"sheets= must be a list of sheet names, not the single string {sheets!r} "
|
|
200
|
+
"(a string would be read one character at a time)."
|
|
201
|
+
)
|
|
202
|
+
return [str(s) for s in sheets]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def read_workbook(
|
|
206
|
+
path: str | Path, sheets: list[str] | None = None, *, text: bool = False
|
|
207
|
+
) -> dict[str, pd.DataFrame]:
|
|
208
|
+
"""Read all (or the named) sheets of a workbook into an ordered dict.
|
|
209
|
+
|
|
210
|
+
``text=True`` reads every cell verbatim. Otherwise pandas' type inference is
|
|
211
|
+
compared against a bounded verbatim read and anything it changed is reported,
|
|
212
|
+
because that happens before any recipe runs and no diff can show it.
|
|
213
|
+
"""
|
|
214
|
+
path = Path(path)
|
|
215
|
+
selected = _sheet_selection(sheets)
|
|
216
|
+
names = excel_sheet_names(path)
|
|
217
|
+
want = selected if selected else names
|
|
218
|
+
missing = [s for s in want if s not in names]
|
|
219
|
+
if missing:
|
|
220
|
+
raise CleanFrameError(f"Sheet(s) {missing} not found in {path.name} (has {names}).")
|
|
221
|
+
try:
|
|
222
|
+
from .dataio import _text_kwargs
|
|
223
|
+
|
|
224
|
+
data = pd.read_excel(path, sheet_name=want, **(_text_kwargs() if text else {}))
|
|
225
|
+
except Exception as exc: # noqa: BLE001
|
|
226
|
+
raise CleanFrameError(f"Could not read workbook {path.name}: {exc}.") from exc
|
|
227
|
+
if not isinstance(data, dict): # single sheet requested as a bare name
|
|
228
|
+
data = {want[0]: data}
|
|
229
|
+
frames = {name: data[name] for name in want}
|
|
230
|
+
if not text:
|
|
231
|
+
_warn_workbook_losses(path, want, frames)
|
|
232
|
+
return frames
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _warn_workbook_losses(
|
|
236
|
+
path: Path, want: list[str], frames: dict[str, pd.DataFrame], limit: int = 200
|
|
237
|
+
) -> None:
|
|
238
|
+
from .dataio import _text_kwargs, compare_for_losses
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
verbatim = pd.read_excel(path, sheet_name=want, nrows=limit, **_text_kwargs())
|
|
242
|
+
except Exception: # noqa: BLE001 - the advisory is never worth failing over
|
|
243
|
+
return
|
|
244
|
+
if not isinstance(verbatim, dict):
|
|
245
|
+
verbatim = {want[0]: verbatim}
|
|
246
|
+
notes: list[str] = []
|
|
247
|
+
for name in want:
|
|
248
|
+
losses = compare_for_losses(frames[name], verbatim.get(name, pd.DataFrame()))
|
|
249
|
+
notes += [f"{name}.{col}: {why}" for col, why in sorted(losses.items())]
|
|
250
|
+
if notes:
|
|
251
|
+
warnings.warn(
|
|
252
|
+
"CleanFrame: pandas type inference changed values while reading "
|
|
253
|
+
f"{path.name} ({'; '.join(notes)}). Pass text=True to read every cell "
|
|
254
|
+
"verbatim.",
|
|
255
|
+
CleanFrameWarning,
|
|
256
|
+
stacklevel=3,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _load_all_sheets(
|
|
261
|
+
data: str | Path | dict, *, text: bool = False
|
|
262
|
+
) -> tuple[dict[str, pd.DataFrame], list[str], str | None]:
|
|
263
|
+
if isinstance(data, dict):
|
|
264
|
+
frames = {str(k): v for k, v in data.items()}
|
|
265
|
+
return frames, list(frames), None
|
|
266
|
+
path = Path(data)
|
|
267
|
+
order = excel_sheet_names(path)
|
|
268
|
+
frames = read_workbook(path, order, text=text)
|
|
269
|
+
return frames, order, str(path)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def clean_workbook(
|
|
273
|
+
data: str | Path | dict,
|
|
274
|
+
*,
|
|
275
|
+
sheets: list[str] | None = None,
|
|
276
|
+
target_schema: Any = None,
|
|
277
|
+
schema: Any = None,
|
|
278
|
+
llm: Any = None,
|
|
279
|
+
mode: Any = "review",
|
|
280
|
+
options: dict[str, Any] | None = None,
|
|
281
|
+
text: bool = False,
|
|
282
|
+
**clean_kwargs: Any,
|
|
283
|
+
) -> WorkbookResult:
|
|
284
|
+
"""Clean every sheet of a workbook independently and return a :class:`WorkbookResult`.
|
|
285
|
+
|
|
286
|
+
``sheets`` limits which tabs are cleaned; the rest are kept verbatim for write-back.
|
|
287
|
+
``text=True`` reads every cell of every sheet verbatim and records that in each
|
|
288
|
+
sheet's recipe, so :func:`apply_workbook` replays the same read.
|
|
289
|
+
"""
|
|
290
|
+
from .api import clean
|
|
291
|
+
|
|
292
|
+
frames, order, source = _load_all_sheets(data, text=text)
|
|
293
|
+
selected = _sheet_selection(sheets) or order
|
|
294
|
+
missing = [s for s in selected if s not in frames]
|
|
295
|
+
if missing:
|
|
296
|
+
raise CleanFrameError(f"Sheet(s) {missing} not found (have {order}).")
|
|
297
|
+
|
|
298
|
+
results: dict[str, CleanResult] = {}
|
|
299
|
+
untouched: dict[str, pd.DataFrame] = {}
|
|
300
|
+
for name in order:
|
|
301
|
+
frame = frames[name]
|
|
302
|
+
if name in selected:
|
|
303
|
+
results[name] = clean(
|
|
304
|
+
frame,
|
|
305
|
+
target_schema=target_schema,
|
|
306
|
+
schema=schema,
|
|
307
|
+
llm=llm,
|
|
308
|
+
mode=mode,
|
|
309
|
+
options=options,
|
|
310
|
+
text=text,
|
|
311
|
+
source=f"{source}#{name}" if source else name,
|
|
312
|
+
**clean_kwargs,
|
|
313
|
+
)
|
|
314
|
+
else:
|
|
315
|
+
untouched[name] = frame
|
|
316
|
+
return WorkbookResult(sheets=results, untouched=untouched, sheet_order=order, source=source)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def apply_workbook(
|
|
320
|
+
data: str | Path | dict,
|
|
321
|
+
recipe: WorkbookRecipe | str | Path | dict,
|
|
322
|
+
*,
|
|
323
|
+
mode: Any = "review",
|
|
324
|
+
check_drift: bool = True,
|
|
325
|
+
on_drift: str = "error",
|
|
326
|
+
) -> WorkbookResult:
|
|
327
|
+
"""Replay a :class:`WorkbookRecipe` across a workbook's sheets (no LLM)."""
|
|
328
|
+
from .api import apply_recipe
|
|
329
|
+
|
|
330
|
+
wr = _resolve_workbook_recipe(recipe)
|
|
331
|
+
# Replay the read the recipes were planned with, or a verbatim sheet becomes a
|
|
332
|
+
# type-inferred one and the values stop matching.
|
|
333
|
+
text = any(bool((r.read or {}).get("text")) for r in wr.sheets.values())
|
|
334
|
+
frames, order, source = _load_all_sheets(data, text=text)
|
|
335
|
+
results: dict[str, CleanResult] = {}
|
|
336
|
+
untouched: dict[str, pd.DataFrame] = {}
|
|
337
|
+
for name in order:
|
|
338
|
+
frame = frames[name]
|
|
339
|
+
if name in wr.sheets:
|
|
340
|
+
results[name] = apply_recipe(
|
|
341
|
+
frame,
|
|
342
|
+
wr.sheets[name],
|
|
343
|
+
mode=mode,
|
|
344
|
+
check_drift=check_drift,
|
|
345
|
+
on_drift=on_drift,
|
|
346
|
+
source=f"{source}#{name}" if source else name,
|
|
347
|
+
)
|
|
348
|
+
else:
|
|
349
|
+
untouched[name] = frame
|
|
350
|
+
return WorkbookResult(sheets=results, untouched=untouched, sheet_order=order, source=source)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _resolve_workbook_recipe(recipe: WorkbookRecipe | str | Path | dict) -> WorkbookRecipe:
|
|
354
|
+
if isinstance(recipe, WorkbookRecipe):
|
|
355
|
+
return recipe
|
|
356
|
+
if isinstance(recipe, (str, Path)):
|
|
357
|
+
return WorkbookRecipe.load(recipe)
|
|
358
|
+
if isinstance(recipe, dict):
|
|
359
|
+
return WorkbookRecipe.from_dict(recipe)
|
|
360
|
+
raise CleanFrameError(f"Unsupported workbook recipe type: {type(recipe).__name__}.")
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
__all__ = [
|
|
364
|
+
"WorkbookRecipe",
|
|
365
|
+
"WorkbookResult",
|
|
366
|
+
"clean_workbook",
|
|
367
|
+
"apply_workbook",
|
|
368
|
+
"read_workbook",
|
|
369
|
+
"load_recipe",
|
|
370
|
+
]
|