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/schema.py ADDED
@@ -0,0 +1,259 @@
1
+ """Target schemas: the shape you want your data to end up in.
2
+
3
+ A schema is optional. When you pass one to :func:`cleanframe.clean`, the planner
4
+ maps messy source columns onto your canonical columns (by fuzzy name + type match,
5
+ with confidence) and derives validation rules from your constraints. When you
6
+ don't have one, :func:`infer_schema` proposes a starting point from a clean-ish
7
+ frame that you then edit and commit.
8
+
9
+ Schemas are plain YAML, deliberately close to the recipe format so the two read
10
+ alike in a PR.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ import pandas as pd
20
+ import yaml
21
+
22
+ from ._util import load_yaml, read_text, snake_case, write_text
23
+ from .errors import SchemaError
24
+ from .profile import COMMON_DATE_FORMATS, _name_hint, profile_dataframe
25
+
26
+ #: Map a profiler semantic type to a schema logical dtype.
27
+ _SEMANTIC_TO_DTYPE = {
28
+ "integer": "integer",
29
+ "float": "float",
30
+ "currency": "float",
31
+ "boolean": "boolean",
32
+ "date": "date",
33
+ "datetime": "datetime",
34
+ "email": "email",
35
+ "phone": "phone",
36
+ "url": "url",
37
+ "categorical": "category",
38
+ "id": "string",
39
+ "text": "string",
40
+ "empty": "string",
41
+ }
42
+
43
+ #: How many distinct values a category may have before we stop enumerating them.
44
+ _MAX_ALLOWED_VALUES = 50
45
+
46
+ #: The logical dtypes a schema column may declare. A typo ("flaot", "emial") would
47
+ #: otherwise be silently ignored (no cast, no warning), so it is rejected at load.
48
+ _VALID_DTYPES = frozenset(
49
+ {
50
+ "string", "str", "text", "integer", "int", "float", "number", "boolean",
51
+ "bool", "date", "datetime", "email", "phone", "url", "category", "id",
52
+ }
53
+ )
54
+
55
+
56
+ #: Spellings that mean the same logical type. Normalised at load so the planner,
57
+ #: which matches on the canonical name, cannot silently skip a cast.
58
+ _DTYPE_ALIASES = {
59
+ "int": "integer",
60
+ "number": "float",
61
+ "text": "string",
62
+ "bool": "boolean",
63
+ "id": "string",
64
+ "str": "string",
65
+ }
66
+
67
+
68
+ def _canonical_dtype(dtype: Any, column: str) -> str:
69
+ if not isinstance(dtype, str):
70
+ raise SchemaError(
71
+ f"Schema column {column!r} dtype must be a string, got {type(dtype).__name__}."
72
+ )
73
+ name = dtype.strip().lower()
74
+ if name not in _VALID_DTYPES:
75
+ raise SchemaError(
76
+ f"Schema column {column!r} has unknown dtype {dtype!r}. "
77
+ f"Valid dtypes: {', '.join(sorted(_VALID_DTYPES))}."
78
+ )
79
+ return _DTYPE_ALIASES.get(name, name)
80
+
81
+
82
+ @dataclass
83
+ class SchemaColumn:
84
+ """One column in a target schema: a name, a logical type, and constraints."""
85
+
86
+ name: str
87
+ dtype: str = "string"
88
+ required: bool = False
89
+ unique: bool = False
90
+ allowed_values: list[Any] | None = None
91
+ date_formats: list[str] | None = None
92
+ min: float | None = None
93
+ max: float | None = None
94
+ aliases: list[str] = field(default_factory=list)
95
+
96
+ def to_dict(self) -> dict:
97
+ out: dict[str, Any] = {"dtype": self.dtype}
98
+ if self.required:
99
+ out["required"] = True
100
+ if self.unique:
101
+ out["unique"] = True
102
+ if self.allowed_values is not None:
103
+ out["allowed_values"] = list(self.allowed_values)
104
+ if self.date_formats:
105
+ out["date_formats"] = list(self.date_formats)
106
+ if self.min is not None:
107
+ out["min"] = self.min
108
+ if self.max is not None:
109
+ out["max"] = self.max
110
+ if self.aliases:
111
+ out["aliases"] = list(self.aliases)
112
+ return out
113
+
114
+ @classmethod
115
+ def from_dict(cls, name: str, raw: Any) -> SchemaColumn:
116
+ if raw is None:
117
+ return cls(name=name)
118
+ if isinstance(raw, str): # shorthand: `col: float`
119
+ return cls(name=name, dtype=_canonical_dtype(raw, name))
120
+ if not isinstance(raw, dict):
121
+ raise SchemaError(f"Schema column {name!r} must be a mapping or a dtype string.")
122
+ aliases = raw.get("aliases", []) or []
123
+ if isinstance(aliases, str):
124
+ aliases = [aliases]
125
+ if not isinstance(aliases, (list, tuple)):
126
+ raise SchemaError(f"Schema column {name!r} aliases must be a list of names.")
127
+ return cls(
128
+ name=name,
129
+ dtype=_canonical_dtype(raw.get("dtype", "string"), name),
130
+ required=bool(raw.get("required", False)),
131
+ unique=bool(raw.get("unique", False)),
132
+ allowed_values=raw.get("allowed_values"),
133
+ date_formats=raw.get("date_formats"),
134
+ min=raw.get("min"),
135
+ max=raw.get("max"),
136
+ aliases=[str(a) for a in aliases],
137
+ )
138
+
139
+
140
+ @dataclass
141
+ class Schema:
142
+ """An ordered set of target columns."""
143
+
144
+ columns: list[SchemaColumn] = field(default_factory=list)
145
+ name: str | None = None
146
+
147
+ def column(self, name: str) -> SchemaColumn | None:
148
+ for c in self.columns:
149
+ if c.name == name:
150
+ return c
151
+ return None
152
+
153
+ @property
154
+ def column_names(self) -> list[str]:
155
+ return [c.name for c in self.columns]
156
+
157
+ # -- serialisation ---------------------------------------------------
158
+ def to_dict(self) -> dict:
159
+ out: dict[str, Any] = {"version": 1}
160
+ if self.name:
161
+ out["name"] = self.name
162
+ out["columns"] = {c.name: c.to_dict() for c in self.columns}
163
+ return out
164
+
165
+ def to_yaml(self) -> str:
166
+ return yaml.safe_dump(
167
+ self.to_dict(), sort_keys=False, allow_unicode=True, default_flow_style=False, width=100
168
+ )
169
+
170
+ def save(self, path: str | Path) -> Path:
171
+ return write_text(path, self.to_yaml())
172
+
173
+ @classmethod
174
+ def from_dict(cls, raw: dict) -> Schema:
175
+ if not isinstance(raw, dict):
176
+ raise SchemaError(
177
+ f"A schema must be a mapping at the top level, got {type(raw).__name__}."
178
+ )
179
+ raw_cols = raw.get("columns", raw if "columns" not in raw and "version" not in raw else {})
180
+ if not isinstance(raw_cols, dict):
181
+ raise SchemaError("Schema 'columns' must be a mapping of name -> spec.")
182
+ columns = [SchemaColumn.from_dict(str(n), spec) for n, spec in raw_cols.items()]
183
+ return cls(columns=columns, name=raw.get("name"))
184
+
185
+ @classmethod
186
+ def load(cls, path: str | Path) -> Schema:
187
+ path = Path(path)
188
+ if not path.exists():
189
+ raise SchemaError(f"Schema not found: {path}")
190
+ if path.is_dir():
191
+ raise SchemaError(f"Schema path is a directory, not a file: {path}")
192
+ return cls.from_dict(load_yaml(read_text(path), error=SchemaError, what="schema"))
193
+
194
+
195
+ def _infer_date_formats(series: pd.Series) -> list[str]:
196
+ """Which of the common date formats actually parse this column (order preserved)."""
197
+ values = series.dropna().astype(str)
198
+ if values.empty:
199
+ return []
200
+ found: list[str] = []
201
+ remaining = values
202
+ for fmt in COMMON_DATE_FORMATS:
203
+ if remaining.empty:
204
+ break
205
+ parsed = pd.to_datetime(remaining, format=fmt, errors="coerce")
206
+ if parsed.notna().any():
207
+ found.append(fmt)
208
+ remaining = remaining[parsed.isna()]
209
+ return found
210
+
211
+
212
+ def infer_schema(df: pd.DataFrame, name: str | None = None) -> Schema:
213
+ """Propose a target :class:`Schema` from a (reasonably clean) dataframe.
214
+
215
+ A convenience for bootstrapping: profile the frame, translate each column's
216
+ semantic type into a logical dtype, and capture obvious constraints (no-nulls
217
+ → ``required``, all-unique → ``unique``, low-cardinality → ``allowed_values``,
218
+ numeric range → ``min``/``max``). Treat the result as a first draft to edit,
219
+ not gospel.
220
+ """
221
+ profile = profile_dataframe(df)
222
+ columns: list[SchemaColumn] = []
223
+ for cp in profile.columns:
224
+ dtype = _SEMANTIC_TO_DTYPE.get(cp.semantic_type, "string")
225
+ # A target schema uses canonical (snake_case) names so that it agrees with
226
+ # the planner's default output names; the original messy name becomes an
227
+ # alias so column mapping still recognises it. Without this, an inferred
228
+ # schema's names would not match the cleaned output and its validations
229
+ # would silently apply to nothing.
230
+ canonical = snake_case(cp.name) or cp.name
231
+ aliases = [cp.name] if canonical != cp.name else []
232
+ # Only infer `unique` for key-like columns. A tiny sample makes *everything*
233
+ # look unique; asserting it on, say, an amount column would wrongly reject
234
+ # valid future rows (and cleaning can even collapse distinct raw strings —
235
+ # "₹1,200" and "₹1200" — into one value).
236
+ key_like = cp.semantic_type in ("id", "email") or _name_hint(cp.name, "id")
237
+ col = SchemaColumn(
238
+ name=canonical,
239
+ dtype=dtype,
240
+ required=cp.null_count == 0 and cp.count > 0,
241
+ unique=cp.is_unique and key_like,
242
+ aliases=aliases,
243
+ )
244
+ if dtype == "category" and cp.count:
245
+ distinct = sorted({str(v) for v in df[cp.name].dropna().unique()})
246
+ if len(distinct) <= _MAX_ALLOWED_VALUES:
247
+ col.allowed_values = distinct
248
+ if dtype in ("date", "datetime"):
249
+ fmts = _infer_date_formats(df[cp.name])
250
+ if fmts:
251
+ col.date_formats = fmts
252
+ if dtype in ("integer", "float") and cp.numeric_stats:
253
+ col.min = cp.numeric_stats["min"]
254
+ col.max = cp.numeric_stats["max"]
255
+ columns.append(col)
256
+ return Schema(columns=columns, name=name)
257
+
258
+
259
+ __all__ = ["Schema", "SchemaColumn", "infer_schema"]
@@ -0,0 +1,354 @@
1
+ """Out-of-core replay: stream a recipe over a file too large to fit in memory.
2
+
3
+ Streaming applies ONLY to recipe *replay* (not planning — profiling/detection are
4
+ whole-frame). It is correct only for **row-independent** recipes: every op and
5
+ validation must produce the same result on a chunk as on the whole frame. That
6
+ subset streams with byte-identical output; anything else is a **hard, named
7
+ refusal**, never a silent divergence — because a misclassified global op would
8
+ silently corrupt output, the exact failure the library exists to prevent.
9
+
10
+ Refused (need the whole column / cross-row state): ``dedup``; ``fill_na`` with a
11
+ mean/median/mode/ffill/bfill strategy; ``cast`` to ``category``/``datetime``/``date``;
12
+ ``parse_date`` *without* explicit formats (format inference is order-dependent);
13
+ the ``unique`` validator; and any op/validator not on the streamable allow-list
14
+ (default-DENY, so an unknown custom op is refused until proven row-independent).
15
+
16
+ Row-ids in the (bounded) diff summary are global — offset by the chunk's start.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ import re
23
+ import warnings
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ import pandas as pd
29
+
30
+ from ._util import check_output_target, sanitize_dataframe_for_csv
31
+ from .errors import CleanFrameError, CleanFrameWarning
32
+ from .executor import execute
33
+ from .recipe import Recipe
34
+ from .types import Mode
35
+
36
+ # Column/frame ops that are provably row-independent (allow-list; default-DENY).
37
+ _STREAMABLE_OPS = frozenset(
38
+ {
39
+ "strip_whitespace", "collapse_whitespace", "lowercase", "uppercase", "title_case",
40
+ "capitalize", "remove_symbols", "replace", "to_na", "normalize_email",
41
+ "normalize_phone", "parse_number", "round", "normalize_values", "extract_currency",
42
+ "normalize_unit", "drop_columns",
43
+ }
44
+ )
45
+ _CMP_RE = re.compile(r"^(>=|<=|==|!=|>|<)\s*-?\d+(?:\.\d+)?$")
46
+ _CSV_STREAM_SUFFIXES = {".csv", ".txt", ".tsv", ""}
47
+
48
+
49
+ def is_op_streamable(op) -> bool:
50
+ """True if applying ``op`` chunk-by-chunk equals applying it to the whole frame."""
51
+ if op.name == "cast":
52
+ return str(op.params.get("to", "")).lower() not in ("category", "datetime", "date")
53
+ if op.name == "parse_date":
54
+ return bool(op.params.get("formats")) # inference over the series is order-dependent
55
+ if op.name == "fill_na":
56
+ return op.params.get("strategy") in (None, "zero", "empty") # constant fill only
57
+ return op.name in _STREAMABLE_OPS
58
+
59
+
60
+ def is_validation_streamable(rule) -> bool:
61
+ """True for row-local checks; ``unique`` (and unknown custom checks) are not."""
62
+ check = rule.check.strip()
63
+ if check == "unique":
64
+ return False
65
+ if check in ("not_null", "valid_email", "valid_url", "valid_phone"):
66
+ return True
67
+ if _CMP_RE.match(check):
68
+ return True
69
+ # Mirror validate.pass_mask membership / regex prefixes (not bare startswith("in")).
70
+ if check == "in" or check.startswith("in ") or check.startswith("in["):
71
+ return True
72
+ if check.startswith("matches") or check.startswith("regex"):
73
+ return True
74
+ return False # unknown custom validator -> refuse (default-DENY)
75
+
76
+
77
+ def check_streamable(recipe: Recipe) -> None:
78
+ """Raise :class:`~cleanframe.errors.CleanFrameError` naming the first op/validation
79
+ that cannot stream. Returns ``None`` if the whole recipe is streamable."""
80
+ for col in recipe.columns:
81
+ for op in col.ops:
82
+ if not is_op_streamable(op):
83
+ raise CleanFrameError(
84
+ f"Recipe is not streamable: op {op.name!r} on column {col.source!r} needs the "
85
+ "whole column (global state). Run it whole-frame with apply_recipe(), or split "
86
+ "the recipe so the streaming part is row-independent."
87
+ )
88
+ for op in recipe.frame_ops:
89
+ if not is_op_streamable(op):
90
+ raise CleanFrameError(
91
+ f"Recipe is not streamable: frame op {op.name!r} needs all rows. "
92
+ "Run it whole-frame with apply_recipe()."
93
+ )
94
+ for rule in recipe.validations:
95
+ if not is_validation_streamable(rule):
96
+ raise CleanFrameError(
97
+ f"Recipe is not streamable: validation {rule.column}:{rule.check} needs all rows "
98
+ "(e.g. 'unique'). Run it whole-frame with apply_recipe()."
99
+ )
100
+
101
+
102
+ def _stream_dtypes(recipe: Recipe):
103
+ """Column dtypes to pin for a stream, taken from the recipe's fingerprint.
104
+
105
+ Chunked reads must pin dtypes or a later chunk can infer a different type from
106
+ an earlier one. Pinning everything to ``str`` does that but renders numbers
107
+ differently from a whole-frame replay (``2`` vs ``2.0``), so the fingerprint's
108
+ recorded types are used when it has them.
109
+ """
110
+ fp = recipe.source_fingerprint if isinstance(recipe.source_fingerprint, dict) else {}
111
+ dtypes = fp.get("dtypes") if isinstance(fp.get("dtypes"), dict) else None
112
+ if not dtypes:
113
+ return str
114
+ pinned: dict[str, Any] = {}
115
+ for col, dtype in dtypes.items():
116
+ name = str(dtype).lower()
117
+ if name.startswith(("int", "uint")):
118
+ pinned[str(col)] = "Int64"
119
+ elif name.startswith("float"):
120
+ pinned[str(col)] = "float64"
121
+ elif name.startswith("bool"):
122
+ pinned[str(col)] = "boolean"
123
+ else:
124
+ pinned[str(col)] = str
125
+ return pinned
126
+
127
+
128
+ def _stream_read_kwargs(recipe: Recipe, *, pin_dtype: bool) -> dict:
129
+ """Build pandas ``read_csv`` kwargs from ``recipe.read``, matching ``apply_recipe``."""
130
+ from .dataio import _pandas_skiprows, _text_kwargs
131
+
132
+ read = dict(recipe.read or {})
133
+ if read.get("sheet") is not None:
134
+ raise CleanFrameError(
135
+ "Streaming does not support Excel sheet selection (recipe.read.sheet). "
136
+ "Use apply_recipe() / cleanframe apply without --chunksize for workbooks."
137
+ )
138
+ kwargs: dict = {
139
+ "encoding": read.get("encoding", "utf-8-sig"),
140
+ "index_col": False,
141
+ }
142
+ if read.get("sep"):
143
+ kwargs["sep"] = read["sep"]
144
+ if read.get("columns") is not None:
145
+ kwargs["usecols"] = list(read["columns"])
146
+ skiprows = _pandas_skiprows(read.get("skiprows"), int(read.get("blank_lines") or 0))
147
+ if skiprows is not None:
148
+ kwargs["skiprows"] = skiprows
149
+ if read.get("nrows") is not None:
150
+ kwargs["nrows"] = read["nrows"]
151
+ if read.get("text"):
152
+ kwargs.update(_text_kwargs())
153
+ elif pin_dtype:
154
+ kwargs["dtype"] = _stream_dtypes(recipe)
155
+ return kwargs
156
+
157
+
158
+ @dataclass
159
+ class StreamSummary:
160
+ """Counts from a streamed replay (no full cell-level diff is kept)."""
161
+
162
+ out_path: Path
163
+ rows_in: int = 0
164
+ rows_out: int = 0
165
+ changed_cells: int = 0
166
+ rows_dropped: int = 0
167
+ rows_quarantined: int = 0
168
+ chunks: int = 0
169
+ quarantine_path: Path | None = None
170
+
171
+ def render(self) -> str:
172
+ base = (
173
+ f"Streamed {self.rows_in:,} → {self.rows_out:,} rows in {self.chunks} chunk(s); "
174
+ f"{self.changed_cells:,} cell(s) changed, {self.rows_dropped:,} dropped."
175
+ )
176
+ if self.rows_quarantined:
177
+ base += f" {self.rows_quarantined:,} quarantined → {self.quarantine_path}."
178
+ return base
179
+
180
+
181
+ def stream_apply(
182
+ recipe: Recipe | str | Path | dict,
183
+ in_path: str | Path,
184
+ out_path: str | Path,
185
+ *,
186
+ chunksize: int = 100_000,
187
+ mode: Mode | str = Mode.REVIEW,
188
+ quarantine_path: str | Path | None = None,
189
+ check_drift: bool = True,
190
+ on_drift: str = "error",
191
+ overwrite: bool = False,
192
+ ) -> StreamSummary:
193
+ """Replay ``recipe`` over ``in_path`` (CSV) in chunks, writing cleaned CSV to
194
+ ``out_path`` without ever holding the whole file in memory.
195
+
196
+ Refuses (raises) if the recipe contains any non-row-independent op/validation, so
197
+ the streamed output is byte-identical to a whole-frame replay (values). Column
198
+ dtypes are pinned to string on read so representation can't drift across chunks.
199
+
200
+ ``recipe.read`` selection (``columns`` / ``sep`` / ``encoding`` / ``skiprows`` /
201
+ ``nrows``) is applied the same way as :func:`apply_recipe`. Output is written to a
202
+ temporary sibling file and atomically replaced onto ``out_path`` when the stream
203
+ completes successfully.
204
+
205
+ Like :func:`apply_recipe`, schema drift is checked first (on a bounded head sample)
206
+ and, by default, refuses (``on_drift="error"``) so a drifted file is never silently
207
+ streamed with a stale recipe.
208
+ """
209
+ if not isinstance(recipe, Recipe):
210
+ from .api import _resolve_recipe
211
+
212
+ recipe = _resolve_recipe(recipe)
213
+ check_streamable(recipe)
214
+
215
+ if isinstance(chunksize, bool) or not isinstance(chunksize, int) or chunksize < 1:
216
+ raise CleanFrameError(
217
+ f"chunksize must be a positive number of rows, got {chunksize!r}."
218
+ )
219
+ in_path = Path(in_path)
220
+ if not in_path.exists():
221
+ raise CleanFrameError(f"Input file not found: {in_path}")
222
+ if not in_path.is_file():
223
+ raise CleanFrameError(f"Input path is not a file (is it a directory?): {in_path}")
224
+ out_path = check_output_target(out_path, in_path, overwrite=overwrite)
225
+ suffix = in_path.suffix.lower()
226
+ if suffix not in _CSV_STREAM_SUFFIXES:
227
+ raise CleanFrameError(
228
+ f"Streaming (--chunksize) only supports CSV-family files, not {suffix or 'this file'}. "
229
+ "Use apply_recipe() for Excel/Parquet/JSON."
230
+ )
231
+ mode = Mode.coerce(mode)
232
+ if on_drift not in ("error", "warn", "ignore"):
233
+ raise CleanFrameError(
234
+ f"on_drift must be one of ['error', 'warn', 'ignore'], got {on_drift!r}."
235
+ )
236
+ head_kwargs = _stream_read_kwargs(recipe, pin_dtype=False)
237
+ read_kwargs = _stream_read_kwargs(recipe, pin_dtype=True)
238
+ if isinstance(read_kwargs.get("dtype"), dict):
239
+ try:
240
+ pd.read_csv(in_path, **{**read_kwargs, "nrows": 5})
241
+ except Exception: # noqa: BLE001 - the file no longer matches the recorded types
242
+ read_kwargs["dtype"] = str
243
+ warnings.warn(
244
+ "CleanFrame: the file does not match the column types recorded in the "
245
+ "recipe; streaming every column as text instead.",
246
+ CleanFrameWarning,
247
+ stacklevel=2,
248
+ )
249
+
250
+ if check_drift and not recipe.source_fingerprint:
251
+ warnings.warn(
252
+ "CleanFrame: recipe has no source_fingerprint — drift check skipped. "
253
+ "Re-plan or stamp a fingerprint for production replay.",
254
+ CleanFrameWarning,
255
+ stacklevel=2,
256
+ )
257
+
258
+ # Drift check on a bounded head (read UN-pinned so dtypes match the recipe's
259
+ # fingerprint, which was built from the original inferred-dtype read).
260
+ if check_drift and recipe.source_fingerprint:
261
+ from .drift import detect_drift
262
+ from .errors import DriftError
263
+ from .fingerprint import DEFAULT_SAMPLE_ROWS
264
+
265
+ try:
266
+ head = pd.read_csv(in_path, nrows=DEFAULT_SAMPLE_ROWS, **head_kwargs)
267
+ except Exception as exc: # noqa: BLE001 - IO boundary
268
+ raise CleanFrameError(
269
+ f"Could not read {in_path.name} to check for drift: {exc}."
270
+ ) from exc
271
+ drift = detect_drift(head, recipe, source=str(in_path))
272
+ if drift.has_drift and (on_drift == "error" or mode is Mode.STRICT):
273
+ raise DriftError(drift.render(), report=drift)
274
+ if drift.has_drift and on_drift == "warn":
275
+ warnings.warn("CleanFrame: " + drift.render(), CleanFrameWarning, stacklevel=2)
276
+
277
+ summary = StreamSummary(out_path=Path(out_path))
278
+ q_path = Path(quarantine_path) if quarantine_path else None
279
+ q_tmp = Path(str(q_path) + ".cf-tmp") if q_path is not None else None
280
+ q_written = False
281
+ first = True
282
+ tmp_out = Path(str(out_path) + ".cf-tmp")
283
+
284
+ try:
285
+ reader = pd.read_csv(in_path, chunksize=chunksize, **read_kwargs)
286
+ except Exception as exc: # noqa: BLE001
287
+ raise CleanFrameError(f"Could not open {in_path.name} for streaming: {exc}.") from exc
288
+
289
+ try:
290
+ for chunk in reader:
291
+ # max_diff_changes=0: count every change but store none (streaming keeps counts,
292
+ # not a full in-memory lineage — the invariant-5 cap, applied globally). The
293
+ # per-chunk "diff truncated" warning is expected here, so silence just that one.
294
+ with warnings.catch_warnings():
295
+ warnings.filterwarnings("ignore", message="CleanFrame: diff detail truncated")
296
+ result = execute(recipe, chunk, mode=mode, max_diff_changes=0)
297
+ cleaned = sanitize_dataframe_for_csv(result.dataframe)
298
+ cleaned.to_csv(
299
+ tmp_out, mode="w" if first else "a", header=first, index=False,
300
+ encoding="utf-8", lineterminator="\n",
301
+ )
302
+ summary.rows_in += len(chunk)
303
+ summary.rows_out += len(cleaned)
304
+ summary.changed_cells += result.diff.changed_cells
305
+ summary.rows_dropped += len(result.diff.dropped_rows)
306
+ summary.chunks += 1
307
+ if result.has_quarantine:
308
+ if q_path is None:
309
+ q_path = Path(out_path).with_suffix(".quarantine.csv")
310
+ q_tmp = Path(str(q_path) + ".cf-tmp")
311
+ assert q_tmp is not None
312
+ q = sanitize_dataframe_for_csv(result.quarantine)
313
+ q.to_csv(
314
+ q_tmp, mode="w" if not q_written else "a", header=not q_written,
315
+ index=False, encoding="utf-8", lineterminator="\n",
316
+ )
317
+ q_written = True
318
+ summary.rows_quarantined += len(result.quarantine)
319
+ first = False
320
+
321
+ if first:
322
+ # Empty input — emit a header-only CSV using the recipe's column projection
323
+ # (or the file's columns) so the output is a valid empty table, not headerless.
324
+ cols = list(read_kwargs.get("usecols") or [])
325
+ if not cols:
326
+ try:
327
+ cols = list(pd.read_csv(in_path, nrows=0, **head_kwargs).columns)
328
+ except Exception: # noqa: BLE001
329
+ cols = [c.source for c in recipe.columns]
330
+ pd.DataFrame(columns=cols).to_csv(
331
+ tmp_out, index=False, encoding="utf-8", lineterminator="\n"
332
+ )
333
+
334
+ os.replace(tmp_out, out_path)
335
+ if q_written and q_path is not None and q_tmp is not None:
336
+ os.replace(q_tmp, q_path)
337
+ except Exception:
338
+ if tmp_out.exists():
339
+ tmp_out.unlink(missing_ok=True)
340
+ if q_tmp is not None and q_tmp.exists():
341
+ q_tmp.unlink(missing_ok=True)
342
+ raise
343
+
344
+ summary.quarantine_path = q_path if q_written else None
345
+ return summary
346
+
347
+
348
+ __all__ = [
349
+ "stream_apply",
350
+ "check_streamable",
351
+ "is_op_streamable",
352
+ "is_validation_streamable",
353
+ "StreamSummary",
354
+ ]