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/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"]
|
cleanframe/streaming.py
ADDED
|
@@ -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
|
+
]
|