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