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/profile.py
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""Deterministic profiling: what is *in* each column before we touch it.
|
|
2
|
+
|
|
3
|
+
The profiler is the first stage of the pipeline. It computes per-column statistics
|
|
4
|
+
(nulls, cardinality, examples) and — crucially — a best-guess **semantic type**
|
|
5
|
+
(``email``, ``currency``, ``date``, ``categorical``, …) that detectors and schema
|
|
6
|
+
inference build on. Everything here is read-only and reproducible: profiling the
|
|
7
|
+
same frame twice yields identical numbers.
|
|
8
|
+
|
|
9
|
+
Performance note: pattern-matching fractions are computed over a bounded, stable
|
|
10
|
+
*head* sample (:data:`PATTERN_SAMPLE_CAP`) so profiling a million-row file stays
|
|
11
|
+
fast without becoming non-deterministic.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import pandas as pd
|
|
21
|
+
|
|
22
|
+
from .ops import CURRENCY_SYMBOLS, parse_unit_scalar
|
|
23
|
+
|
|
24
|
+
PATTERN_SAMPLE_CAP = 5000
|
|
25
|
+
|
|
26
|
+
#: Values longer than this are never dates/currency/phones/numbers, and scanning
|
|
27
|
+
#: them with the pattern regexes costs O(len^2) — a single long note or JSON blob
|
|
28
|
+
#: would otherwise stall profiling for minutes.
|
|
29
|
+
_MAX_PATTERN_VALUE_LEN = 200
|
|
30
|
+
|
|
31
|
+
# -- reusable patterns -------------------------------------------------------
|
|
32
|
+
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
|
33
|
+
URL_RE = re.compile(r"^(https?://|www\.)\S+$", re.IGNORECASE)
|
|
34
|
+
_DATEISH_RE = re.compile(r"[/.\-]|\d{1,2}\s*[A-Za-z]{3,}|[A-Za-z]{3,}\s*\d{1,2}")
|
|
35
|
+
#: Clock times and ISO dates are not phone numbers, however many digits they hold.
|
|
36
|
+
_TIMESTAMPISH_RE = re.compile(r"\d{4}-\d{2}-\d{2}|\d{1,2}:\d{2}")
|
|
37
|
+
_DIGIT_RE = re.compile(r"\d")
|
|
38
|
+
_BOOL_TOKENS = {"true", "false", "yes", "no", "t", "f", "y", "n"}
|
|
39
|
+
|
|
40
|
+
#: Candidate date formats, ordered most-specific first. Shared with the dates
|
|
41
|
+
#: detector so profiling and planning agree on what "a date" looks like. Pure
|
|
42
|
+
#: all-digit formats are intentionally excluded to avoid classifying plain
|
|
43
|
+
#: integers (``"20240101"``, ``"1200"``) as dates.
|
|
44
|
+
COMMON_DATE_FORMATS = [
|
|
45
|
+
"%Y-%m-%dT%H:%M:%S",
|
|
46
|
+
"%Y-%m-%d %H:%M:%S",
|
|
47
|
+
"%Y-%m-%d",
|
|
48
|
+
"%Y/%m/%d",
|
|
49
|
+
"%d/%m/%Y",
|
|
50
|
+
"%m/%d/%Y",
|
|
51
|
+
"%d-%m-%Y",
|
|
52
|
+
"%m-%d-%Y",
|
|
53
|
+
"%d.%m.%Y",
|
|
54
|
+
"%d/%m/%y",
|
|
55
|
+
"%m/%d/%y",
|
|
56
|
+
"%d-%m-%y",
|
|
57
|
+
"%d %b %Y",
|
|
58
|
+
"%d %B %Y",
|
|
59
|
+
"%b %d, %Y",
|
|
60
|
+
"%B %d, %Y",
|
|
61
|
+
"%d-%b-%Y",
|
|
62
|
+
"%d-%b-%y",
|
|
63
|
+
"%b %d %Y",
|
|
64
|
+
"%d %b %y",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
# Column-name hints (lowercased substrings) that nudge ambiguous classifications.
|
|
68
|
+
_NAME_HINTS = {
|
|
69
|
+
"email": ("email", "e-mail", "mail"),
|
|
70
|
+
"phone": ("phone", "mobile", "contact", "tel", "cell", "whatsapp"),
|
|
71
|
+
"date": ("date", "dob", "day", "created", "updated", "signup", "joined", "timestamp"),
|
|
72
|
+
"currency": ("amount", "price", "cost", "salary", "revenue", "inr", "usd", "paid", "total"),
|
|
73
|
+
"id": ("id", "code", "uuid", "guid", "ref", "sku"),
|
|
74
|
+
"unit": ("weight", "mass", "height", "length", "width", "depth", "volume", "qty", "quantity", "size"),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class ColumnProfile:
|
|
80
|
+
"""Read-only statistics and a semantic-type guess for one column."""
|
|
81
|
+
|
|
82
|
+
name: str
|
|
83
|
+
dtype: str
|
|
84
|
+
count: int # non-null
|
|
85
|
+
null_count: int
|
|
86
|
+
unique_count: int
|
|
87
|
+
semantic_type: str = "text"
|
|
88
|
+
type_confidence: float = 0.0
|
|
89
|
+
sample_values: list[Any] = field(default_factory=list)
|
|
90
|
+
most_common: list[tuple[Any, int]] = field(default_factory=list)
|
|
91
|
+
numeric_stats: dict[str, float] | None = None
|
|
92
|
+
str_len_stats: dict[str, float] | None = None
|
|
93
|
+
signals: dict[str, float] = field(default_factory=dict)
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def n(self) -> int:
|
|
97
|
+
return self.count + self.null_count
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def null_fraction(self) -> float:
|
|
101
|
+
return self.null_count / self.n if self.n else 0.0
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def unique_fraction(self) -> float:
|
|
105
|
+
return self.unique_count / self.count if self.count else 0.0
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def is_unique(self) -> bool:
|
|
109
|
+
return self.count > 0 and self.unique_count == self.count
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def is_constant(self) -> bool:
|
|
113
|
+
return self.unique_count == 1
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class DataFrameProfile:
|
|
118
|
+
"""Profiles for every column plus a few frame-level facts."""
|
|
119
|
+
|
|
120
|
+
n_rows: int
|
|
121
|
+
n_columns: int
|
|
122
|
+
columns: list[ColumnProfile]
|
|
123
|
+
duplicate_row_count: int = 0
|
|
124
|
+
|
|
125
|
+
def column(self, name: str) -> ColumnProfile | None:
|
|
126
|
+
for c in self.columns:
|
|
127
|
+
if c.name == name:
|
|
128
|
+
return c
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
def __iter__(self):
|
|
132
|
+
return iter(self.columns)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
# helpers
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
def _str_sample(series: pd.Series, cap: int = PATTERN_SAMPLE_CAP) -> list[str]:
|
|
139
|
+
"""Bounded, stable list of stringified non-null values."""
|
|
140
|
+
non_null = series.dropna()
|
|
141
|
+
if len(non_null) > cap:
|
|
142
|
+
non_null = non_null.head(cap)
|
|
143
|
+
return [v if isinstance(v, str) else str(v) for v in non_null.tolist()]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _name_hint(column_name: str, kind: str) -> bool:
|
|
147
|
+
low = str(column_name).lower()
|
|
148
|
+
return any(h in low for h in _NAME_HINTS.get(kind, ()))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _frac(matches: int, total: int) -> float:
|
|
152
|
+
return matches / total if total else 0.0
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _looks_date(value: str) -> bool:
|
|
156
|
+
if len(value) > _MAX_PATTERN_VALUE_LEN:
|
|
157
|
+
return False
|
|
158
|
+
return bool(_DATEISH_RE.search(value)) and bool(_DIGIT_RE.search(value))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _matching_dates(values: list[str]) -> list[str]:
|
|
162
|
+
"""Return the subset of ``values`` that look like and parse as a common date."""
|
|
163
|
+
dateish = [v for v in values if _looks_date(v)]
|
|
164
|
+
if not dateish:
|
|
165
|
+
return []
|
|
166
|
+
ser = pd.Series(dateish, dtype="object")
|
|
167
|
+
matched = pd.Series(False, index=ser.index)
|
|
168
|
+
for fmt in COMMON_DATE_FORMATS:
|
|
169
|
+
pending = ser[~matched]
|
|
170
|
+
if pending.empty:
|
|
171
|
+
break
|
|
172
|
+
parsed = pd.to_datetime(pending, format=fmt, errors="coerce")
|
|
173
|
+
matched.loc[pending.index[parsed.notna().to_numpy()]] = True
|
|
174
|
+
return ser[matched].tolist()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _looks_currency(value: str) -> bool:
|
|
178
|
+
if len(value) > _MAX_PATTERN_VALUE_LEN:
|
|
179
|
+
return False
|
|
180
|
+
has_symbol = any(sym in value for sym in CURRENCY_SYMBOLS)
|
|
181
|
+
up = value.upper()
|
|
182
|
+
has_code = any(f" {c}" in f" {up} " or up.strip().endswith(c) or up.strip().startswith(c)
|
|
183
|
+
for c in ("USD", "EUR", "GBP", "INR", "JPY"))
|
|
184
|
+
return (has_symbol or has_code) and bool(_DIGIT_RE.search(value))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _looks_unit(value: str) -> bool:
|
|
188
|
+
if len(value) > _MAX_PATTERN_VALUE_LEN:
|
|
189
|
+
return False
|
|
190
|
+
return parse_unit_scalar(value) is not None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _looks_phone(value: str) -> bool:
|
|
194
|
+
if len(value) > _MAX_PATTERN_VALUE_LEN or _TIMESTAMPISH_RE.search(value):
|
|
195
|
+
return False
|
|
196
|
+
digits = re.sub(r"\D", "", value)
|
|
197
|
+
if not (7 <= len(digits) <= 15):
|
|
198
|
+
return False
|
|
199
|
+
# Reject values that are "just a number" with no phone-like structure and no
|
|
200
|
+
# leading + / 0 — those are more likely quantities than phone numbers.
|
|
201
|
+
structured = bool(re.search(r"[+\-() ]", value)) or value.strip().startswith(("+", "0"))
|
|
202
|
+
return structured
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
_STRICT_NUM_RE = re.compile(r"^[+-]?\d+(\.\d+)?$")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _is_strict_numeric(value: str) -> bool:
|
|
209
|
+
"""True only for genuine numbers (allowing thousands separators/parentheses).
|
|
210
|
+
|
|
211
|
+
Deliberately stricter than :func:`ops._parse_number_scalar`, which strips *any*
|
|
212
|
+
non-digit and would turn an id like ``"U001"`` into ``1``. Here a stray letter
|
|
213
|
+
disqualifies the value, so alphanumeric ids are not mistaken for numbers.
|
|
214
|
+
"""
|
|
215
|
+
if len(value) > _MAX_PATTERN_VALUE_LEN:
|
|
216
|
+
return False
|
|
217
|
+
s = value.strip()
|
|
218
|
+
if s.startswith("(") and s.endswith(")"):
|
|
219
|
+
s = s[1:-1]
|
|
220
|
+
s = s.replace(",", "").replace(" ", "")
|
|
221
|
+
return bool(_STRICT_NUM_RE.match(s))
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _infer_semantic_type(
|
|
225
|
+
name: str, series: pd.Series, count: int, unique_count: int
|
|
226
|
+
) -> tuple[str, float, dict[str, float]]:
|
|
227
|
+
"""Return ``(semantic_type, confidence, signals)`` for a column.
|
|
228
|
+
|
|
229
|
+
Object/string columns are classified by the fraction of sampled values that
|
|
230
|
+
match each pattern, resolved by a fixed priority so the result is stable.
|
|
231
|
+
"""
|
|
232
|
+
signals: dict[str, float] = {}
|
|
233
|
+
if count == 0:
|
|
234
|
+
return "empty", 1.0, signals
|
|
235
|
+
|
|
236
|
+
dtype = series.dtype
|
|
237
|
+
if pd.api.types.is_bool_dtype(dtype):
|
|
238
|
+
return "boolean", 1.0, signals
|
|
239
|
+
if pd.api.types.is_datetime64_any_dtype(dtype):
|
|
240
|
+
return "datetime", 1.0, signals
|
|
241
|
+
if pd.api.types.is_integer_dtype(dtype):
|
|
242
|
+
return "integer", 1.0, signals
|
|
243
|
+
if pd.api.types.is_float_dtype(dtype):
|
|
244
|
+
return "float", 1.0, signals
|
|
245
|
+
|
|
246
|
+
values = _str_sample(series)
|
|
247
|
+
total = len(values)
|
|
248
|
+
if total == 0:
|
|
249
|
+
return "text", 0.0, signals
|
|
250
|
+
|
|
251
|
+
# Evaluate each pattern predicate once per DISTINCT value, weighted by frequency.
|
|
252
|
+
# A 5000-value sample of a low-cardinality column has only a handful of distinct
|
|
253
|
+
# values; scoring each predicate per-occurrence re-does identical work. Fractions
|
|
254
|
+
# are exact (weighted counts), so classification is byte-identical.
|
|
255
|
+
from collections import Counter
|
|
256
|
+
|
|
257
|
+
vcounts = Counter(values)
|
|
258
|
+
distinct = list(vcounts)
|
|
259
|
+
|
|
260
|
+
def _wfrac(pred) -> float:
|
|
261
|
+
return sum(vcounts[v] for v in distinct if pred(v)) / total
|
|
262
|
+
|
|
263
|
+
def _short(v: str) -> bool:
|
|
264
|
+
return len(v) <= _MAX_PATTERN_VALUE_LEN
|
|
265
|
+
|
|
266
|
+
frac_bool = _wfrac(lambda v: _short(v) and v.strip().casefold() in _BOOL_TOKENS)
|
|
267
|
+
frac_email = _wfrac(lambda v: _short(v) and bool(EMAIL_RE.match(v.strip())))
|
|
268
|
+
frac_url = _wfrac(lambda v: _short(v) and bool(URL_RE.match(v.strip())))
|
|
269
|
+
frac_currency = _wfrac(_looks_currency)
|
|
270
|
+
frac_unit = _wfrac(_looks_unit)
|
|
271
|
+
frac_phone = _wfrac(_looks_phone)
|
|
272
|
+
frac_date = sum(vcounts[v] for v in _matching_dates(distinct)) / total
|
|
273
|
+
frac_dial_prefix = _wfrac(lambda v: v.strip().startswith(("+", "(")))
|
|
274
|
+
numeric_distinct = [v for v in distinct if _is_strict_numeric(v)]
|
|
275
|
+
frac_numeric = sum(vcounts[v] for v in numeric_distinct) / total
|
|
276
|
+
numeric_has_decimal = any("." in v for v in numeric_distinct)
|
|
277
|
+
|
|
278
|
+
signals.update(
|
|
279
|
+
bool=round(frac_bool, 3), email=round(frac_email, 3), url=round(frac_url, 3),
|
|
280
|
+
currency=round(frac_currency, 3), unit=round(frac_unit, 3),
|
|
281
|
+
date=round(frac_date, 3), phone=round(frac_phone, 3), numeric=round(frac_numeric, 3),
|
|
282
|
+
dial_prefix=round(frac_dial_prefix, 3),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
uniq_frac = unique_count / count
|
|
286
|
+
|
|
287
|
+
# Fixed-priority resolution. Currency/date beat plain numeric because a money
|
|
288
|
+
# or date column also "parses as a number" once symbols are stripped.
|
|
289
|
+
if frac_bool >= 0.9:
|
|
290
|
+
return "boolean", frac_bool, signals
|
|
291
|
+
if frac_email >= 0.8 or (frac_email >= 0.5 and _name_hint(name, "email")):
|
|
292
|
+
return "email", max(frac_email, 0.8 if _name_hint(name, "email") else frac_email), signals
|
|
293
|
+
if frac_url >= 0.8:
|
|
294
|
+
return "url", frac_url, signals
|
|
295
|
+
if frac_currency >= 0.6:
|
|
296
|
+
return "currency", frac_currency, signals
|
|
297
|
+
if frac_unit >= 0.6 or (frac_unit >= 0.4 and _name_hint(name, "unit")):
|
|
298
|
+
return "unit", max(frac_unit, 0.8 if _name_hint(name, "unit") else frac_unit), signals
|
|
299
|
+
if frac_date >= 0.8 or (frac_date >= 0.5 and _name_hint(name, "date")):
|
|
300
|
+
return "date", max(frac_date, 0.8 if _name_hint(name, "date") else frac_date), signals
|
|
301
|
+
if _name_hint(name, "phone"):
|
|
302
|
+
if frac_phone >= 0.5:
|
|
303
|
+
return "phone", max(frac_phone, 0.8), signals
|
|
304
|
+
elif frac_phone >= 0.8 and frac_dial_prefix >= 0.5:
|
|
305
|
+
# Without a phone-ish column name, only an explicit dial prefix is evidence:
|
|
306
|
+
# timestamps and dashed ids otherwise look identical to a local number.
|
|
307
|
+
return "phone", frac_phone, signals
|
|
308
|
+
if frac_numeric >= 0.95:
|
|
309
|
+
return ("float" if numeric_has_decimal else "integer"), frac_numeric, signals
|
|
310
|
+
if unique_count <= 50 and uniq_frac < 0.5:
|
|
311
|
+
return "categorical", 1.0 - uniq_frac, signals
|
|
312
|
+
if uniq_frac >= 0.95:
|
|
313
|
+
return "id" if _name_hint(name, "id") else "text", uniq_frac, signals
|
|
314
|
+
return "text", 0.5, signals
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _value_counts_stable(series: pd.Series, top: int = 10) -> list[tuple[Any, int]]:
|
|
318
|
+
import heapq
|
|
319
|
+
|
|
320
|
+
try:
|
|
321
|
+
vc = series.dropna().value_counts()
|
|
322
|
+
except TypeError: # unhashable cells (lists/dicts) have no value counts
|
|
323
|
+
return []
|
|
324
|
+
# Deterministic tie-break: count desc, then string of value asc. heapq.nsmallest
|
|
325
|
+
# is sorted(...)[:top] without materialising/sorting every unique value, which
|
|
326
|
+
# matters on high-cardinality columns (millions of near-unique values).
|
|
327
|
+
pairs = heapq.nsmallest(top, vc.items(), key=lambda kv: (-int(kv[1]), str(kv[0])))
|
|
328
|
+
return [(k, int(v)) for k, v in pairs]
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def profile_column(series: pd.Series, name: str | None = None) -> ColumnProfile:
|
|
332
|
+
name = str(series.name if name is None else name)
|
|
333
|
+
n = len(series)
|
|
334
|
+
null_count = int(series.isna().sum())
|
|
335
|
+
count = n - null_count
|
|
336
|
+
non_null = series.dropna()
|
|
337
|
+
try:
|
|
338
|
+
unique_count = int(non_null.nunique())
|
|
339
|
+
except TypeError: # unhashable cells (lists/dicts/sets) — compare by repr
|
|
340
|
+
unique_count = int(non_null.map(repr).nunique())
|
|
341
|
+
|
|
342
|
+
semantic_type, confidence, signals = _infer_semantic_type(name, series, count, unique_count)
|
|
343
|
+
|
|
344
|
+
try:
|
|
345
|
+
sample_values = non_null.drop_duplicates().head(5).tolist()
|
|
346
|
+
except TypeError:
|
|
347
|
+
sample_values = non_null.head(5).tolist()
|
|
348
|
+
most_common = _value_counts_stable(series)
|
|
349
|
+
|
|
350
|
+
numeric_stats = None
|
|
351
|
+
if pd.api.types.is_numeric_dtype(series.dtype) and count:
|
|
352
|
+
numeric = pd.to_numeric(series, errors="coerce")
|
|
353
|
+
numeric_stats = {
|
|
354
|
+
"min": float(numeric.min()),
|
|
355
|
+
"max": float(numeric.max()),
|
|
356
|
+
"mean": float(numeric.mean()),
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
str_len_stats = None
|
|
360
|
+
if semantic_type in ("text", "categorical", "id") and count:
|
|
361
|
+
# Bounded sample: a categorical column cannot be mapped elementwise, and a
|
|
362
|
+
# million-row text column should not be materialised just for three numbers.
|
|
363
|
+
lengths = [len(v) for v in _str_sample(series)]
|
|
364
|
+
if lengths:
|
|
365
|
+
str_len_stats = {
|
|
366
|
+
"min": float(min(lengths)),
|
|
367
|
+
"max": float(max(lengths)),
|
|
368
|
+
"mean": float(sum(lengths) / len(lengths)),
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return ColumnProfile(
|
|
372
|
+
name=name,
|
|
373
|
+
dtype=str(series.dtype),
|
|
374
|
+
count=count,
|
|
375
|
+
null_count=null_count,
|
|
376
|
+
unique_count=unique_count,
|
|
377
|
+
semantic_type=semantic_type,
|
|
378
|
+
type_confidence=round(float(confidence), 3),
|
|
379
|
+
sample_values=sample_values,
|
|
380
|
+
most_common=most_common,
|
|
381
|
+
numeric_stats=numeric_stats,
|
|
382
|
+
str_len_stats=str_len_stats,
|
|
383
|
+
signals=signals,
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def profile_dataframe(df: pd.DataFrame) -> DataFrameProfile:
|
|
388
|
+
"""Profile every column plus frame-level facts (row count, exact-duplicate rows)."""
|
|
389
|
+
from ._util import ensure_string_columns
|
|
390
|
+
|
|
391
|
+
df = ensure_string_columns(df)
|
|
392
|
+
columns = [profile_column(df[c], name=str(c)) for c in df.columns]
|
|
393
|
+
try:
|
|
394
|
+
duplicate_row_count = int(df.duplicated().sum())
|
|
395
|
+
except TypeError: # unhashable cells cannot be compared row-wise
|
|
396
|
+
duplicate_row_count = 0
|
|
397
|
+
return DataFrameProfile(
|
|
398
|
+
n_rows=int(len(df)),
|
|
399
|
+
n_columns=int(df.shape[1]),
|
|
400
|
+
columns=columns,
|
|
401
|
+
duplicate_row_count=duplicate_row_count,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
__all__ = [
|
|
406
|
+
"ColumnProfile",
|
|
407
|
+
"DataFrameProfile",
|
|
408
|
+
"profile_column",
|
|
409
|
+
"profile_dataframe",
|
|
410
|
+
"COMMON_DATE_FORMATS",
|
|
411
|
+
"EMAIL_RE",
|
|
412
|
+
"URL_RE",
|
|
413
|
+
]
|
cleanframe/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Marker file for PEP 561 — CleanFrame ships inline type annotations.
|
cleanframe/quality.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""A single, deterministic data-quality score.
|
|
2
|
+
|
|
3
|
+
Turns a profile + detected issues into a 0–100 score and a letter grade. The
|
|
4
|
+
formula is intentionally simple and documented so the number is explainable in a
|
|
5
|
+
report and stable across runs:
|
|
6
|
+
|
|
7
|
+
* Each issue contributes a penalty = ``severity_weight × affected_fraction``.
|
|
8
|
+
* Exact-duplicate rows add their own penalty.
|
|
9
|
+
* Penalties are averaged over the number of columns (so wide and narrow files land
|
|
10
|
+
on a comparable scale), then ``score = 100 × (1 − avg_penalty)``.
|
|
11
|
+
|
|
12
|
+
It is a heuristic, not a benchmark — good for "is this file better than last
|
|
13
|
+
month's?", not for splitting hairs between 87 and 88.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
|
|
20
|
+
from .issues import Issues
|
|
21
|
+
from .profile import DataFrameProfile
|
|
22
|
+
from .types import Severity
|
|
23
|
+
|
|
24
|
+
_SEVERITY_WEIGHT = {Severity.ERROR: 1.0, Severity.WARNING: 0.4, Severity.INFO: 0.1}
|
|
25
|
+
#: Used when an issue has no count in its evidence — a moderate assumed impact.
|
|
26
|
+
_DEFAULT_FRACTION = 0.25
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class QualityScore:
|
|
31
|
+
score: int
|
|
32
|
+
grade: str
|
|
33
|
+
label: str
|
|
34
|
+
color: str
|
|
35
|
+
penalty: float
|
|
36
|
+
|
|
37
|
+
def __str__(self) -> str: # pragma: no cover - cosmetic
|
|
38
|
+
return f"{self.score}/100 ({self.grade})"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _affected_fraction(evidence: dict, n_rows: int) -> float:
|
|
42
|
+
if n_rows <= 0:
|
|
43
|
+
return _DEFAULT_FRACTION
|
|
44
|
+
for key in ("count", "null_count", "unparsed", "n_failed"):
|
|
45
|
+
if key in evidence and isinstance(evidence[key], (int, float)):
|
|
46
|
+
return min(1.0, evidence[key] / n_rows)
|
|
47
|
+
return _DEFAULT_FRACTION
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _grade(score: int) -> tuple[str, str, str]:
|
|
51
|
+
if score >= 90:
|
|
52
|
+
return "A", "Excellent", "#16a34a"
|
|
53
|
+
if score >= 80:
|
|
54
|
+
return "B", "Good", "#65a30d"
|
|
55
|
+
if score >= 70:
|
|
56
|
+
return "C", "Fair", "#ca8a04"
|
|
57
|
+
if score >= 60:
|
|
58
|
+
return "D", "Poor", "#ea580c"
|
|
59
|
+
return "F", "Needs work", "#dc2626"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def quality_score(profile: DataFrameProfile, issues: Issues) -> QualityScore:
|
|
63
|
+
n_rows = profile.n_rows or 1
|
|
64
|
+
n_cols = max(1, profile.n_columns)
|
|
65
|
+
|
|
66
|
+
penalty = 0.0
|
|
67
|
+
for issue in issues:
|
|
68
|
+
weight = _SEVERITY_WEIGHT.get(issue.severity, 0.2)
|
|
69
|
+
penalty += weight * _affected_fraction(issue.evidence, n_rows)
|
|
70
|
+
|
|
71
|
+
if profile.duplicate_row_count:
|
|
72
|
+
penalty += 0.5 * min(1.0, profile.duplicate_row_count / n_rows)
|
|
73
|
+
|
|
74
|
+
avg_penalty = min(1.0, penalty / n_cols)
|
|
75
|
+
score = int(round(100 * (1 - avg_penalty)))
|
|
76
|
+
score = max(0, min(100, score))
|
|
77
|
+
grade, label, color = _grade(score)
|
|
78
|
+
return QualityScore(score=score, grade=grade, label=label, color=color, penalty=round(penalty, 3))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
__all__ = ["QualityScore", "quality_score"]
|
cleanframe/readfix.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Read-time format auto-correction — deterministic, reviewable, replayable.
|
|
2
|
+
|
|
3
|
+
Where the *content* detectors fix messy values, this fixes a messy *file shape* at
|
|
4
|
+
read time: the wrong delimiter (a ``;``-separated file read as one column) and a
|
|
5
|
+
non-UTF-8 encoding (Excel's Western "Save as CSV"). Every correction is:
|
|
6
|
+
|
|
7
|
+
* **Deterministic** — an encoding fallback ladder (utf-8 → cp1252, which cannot
|
|
8
|
+
fail to decode) and a header-consensus delimiter vote among a fixed candidate
|
|
9
|
+
set. No probabilistic charset sniffing, so the result never drifts across
|
|
10
|
+
dependency/locale versions.
|
|
11
|
+
* **Reviewable + replayable** — the chosen delimiter/encoding are pinned into the
|
|
12
|
+
recipe's ``read:`` section, so :func:`cleanframe.apply_recipe` re-reads the file
|
|
13
|
+
identically and never re-sniffs.
|
|
14
|
+
* **Fail-loud on ambiguity** — if two delimiters split the header equally well, it
|
|
15
|
+
refuses (raises) rather than silently guessing, mirroring the library's
|
|
16
|
+
never-silently-corrupt contract.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import codecs
|
|
22
|
+
import csv as _csv
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from .errors import CleanFrameError
|
|
27
|
+
|
|
28
|
+
_CSV_SUFFIXES = (".csv", ".txt", ".tsv")
|
|
29
|
+
_DELIMITER_CANDIDATES = (",", ";", "\t", "|")
|
|
30
|
+
_HEADER_SAMPLE_LINES = 20
|
|
31
|
+
#: Only the first chunk is read for detection so a multi-GB file isn't loaded to RAM.
|
|
32
|
+
_DETECT_BYTES = 65536
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class ReadReport:
|
|
37
|
+
"""What the read-time corrector detected and changed."""
|
|
38
|
+
|
|
39
|
+
encoding: str = "utf-8"
|
|
40
|
+
delimiter: str = ","
|
|
41
|
+
skipped_blank_lines: int = 0
|
|
42
|
+
notes: list[str] = field(default_factory=list)
|
|
43
|
+
|
|
44
|
+
def as_read_binding(self) -> dict:
|
|
45
|
+
"""The subset to pin into a recipe's ``read:`` section for replay."""
|
|
46
|
+
out: dict = {}
|
|
47
|
+
if self.encoding not in ("utf-8", "utf-8-sig"):
|
|
48
|
+
out["encoding"] = self.encoding
|
|
49
|
+
if self.delimiter != ",":
|
|
50
|
+
out["sep"] = self.delimiter
|
|
51
|
+
if self.skipped_blank_lines:
|
|
52
|
+
out["blank_lines"] = self.skipped_blank_lines
|
|
53
|
+
return out
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _decode(path: Path) -> tuple[str, str]:
|
|
57
|
+
"""Return (sample_text, encoding) via a deterministic ladder: utf-8-sig then cp1252.
|
|
58
|
+
|
|
59
|
+
Only the first :data:`_DETECT_BYTES` are read, so detection stays O(1) memory on
|
|
60
|
+
huge files. A multi-byte UTF-8 char truncated at the read boundary is not treated
|
|
61
|
+
as a decode failure.
|
|
62
|
+
"""
|
|
63
|
+
with open(path, "rb") as fh:
|
|
64
|
+
raw = fh.read(_DETECT_BYTES)
|
|
65
|
+
for bom, codec in (
|
|
66
|
+
(codecs.BOM_UTF32_LE, "utf-32"),
|
|
67
|
+
(codecs.BOM_UTF32_BE, "utf-32"),
|
|
68
|
+
(codecs.BOM_UTF16_LE, "utf-16"),
|
|
69
|
+
(codecs.BOM_UTF16_BE, "utf-16"),
|
|
70
|
+
):
|
|
71
|
+
if raw.startswith(bom):
|
|
72
|
+
return raw.decode(codec, errors="ignore"), codec
|
|
73
|
+
if b"\x00" in raw:
|
|
74
|
+
raise CleanFrameError(
|
|
75
|
+
f"{path.name} looks binary (contains NUL bytes, and no UTF-16/32 byte-order "
|
|
76
|
+
"mark). Pass an explicit encoding= if this really is text, or convert the "
|
|
77
|
+
"file to UTF-8 CSV first."
|
|
78
|
+
)
|
|
79
|
+
try:
|
|
80
|
+
return raw.decode("utf-8-sig"), "utf-8"
|
|
81
|
+
except UnicodeDecodeError as exc:
|
|
82
|
+
if exc.start >= len(raw) - 4: # a char split at the chunk boundary, not a bad file
|
|
83
|
+
return raw[: exc.start].decode("utf-8-sig"), "utf-8"
|
|
84
|
+
# cp1252 leaves five byte values undefined, so latin-1 (which maps all 256) is the
|
|
85
|
+
# final rung: the ladder must never raise for a file that is merely not UTF-8.
|
|
86
|
+
for codec in ("cp1252", "latin-1"):
|
|
87
|
+
try:
|
|
88
|
+
return raw.decode(codec), codec
|
|
89
|
+
except UnicodeDecodeError:
|
|
90
|
+
continue
|
|
91
|
+
return raw.decode("latin-1", errors="replace"), "latin-1"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _pick_delimiter(lines: list[str]) -> str:
|
|
95
|
+
"""Vote for the delimiter that splits every header/sample line into the same
|
|
96
|
+
(>1) number of fields. Comma wins ties-with-comma; a tie between two non-comma
|
|
97
|
+
delimiters is refused."""
|
|
98
|
+
scores: dict[str, int] = {}
|
|
99
|
+
for cand in _DELIMITER_CANDIDATES:
|
|
100
|
+
try:
|
|
101
|
+
counts = [len(row) for row in _csv.reader(lines, delimiter=cand)]
|
|
102
|
+
except _csv.Error:
|
|
103
|
+
continue
|
|
104
|
+
if counts and counts[0] > 1 and all(c == counts[0] for c in counts):
|
|
105
|
+
scores[cand] = counts[0]
|
|
106
|
+
if not scores:
|
|
107
|
+
return "," # nothing splits cleanly → treat as a single-column file
|
|
108
|
+
if "," in scores:
|
|
109
|
+
return "," # comma already works → least-surprising choice
|
|
110
|
+
best = max(scores.values())
|
|
111
|
+
winners = sorted(c for c, s in scores.items() if s == best)
|
|
112
|
+
if len(winners) > 1:
|
|
113
|
+
pretty = [repr(w) for w in winners]
|
|
114
|
+
raise CleanFrameError(
|
|
115
|
+
f"Ambiguous delimiter: {', '.join(pretty)} each split the header into {best} "
|
|
116
|
+
"columns. Pass sep=... (or --sep) explicitly."
|
|
117
|
+
)
|
|
118
|
+
return winners[0]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def detect_csv_options(path: str | Path) -> tuple[dict, ReadReport]:
|
|
122
|
+
"""Detect encoding + delimiter (+ leading blank lines) for a CSV-family file.
|
|
123
|
+
|
|
124
|
+
Returns ``(read_options, report)`` where ``read_options`` is ready to pass to
|
|
125
|
+
:func:`pandas.read_csv` (``encoding``/``sep``/``skiprows``). Raises
|
|
126
|
+
:class:`~cleanframe.errors.CleanFrameError` on an ambiguous delimiter.
|
|
127
|
+
"""
|
|
128
|
+
path = Path(path)
|
|
129
|
+
text, encoding = _decode(path)
|
|
130
|
+
lines = text.splitlines()
|
|
131
|
+
|
|
132
|
+
skip = 0
|
|
133
|
+
while skip < len(lines) and not lines[skip].strip():
|
|
134
|
+
skip += 1
|
|
135
|
+
|
|
136
|
+
sample = [ln for ln in lines[skip : skip + _HEADER_SAMPLE_LINES] if ln.strip()]
|
|
137
|
+
# .tsv is tab by definition — don't second-guess it.
|
|
138
|
+
delimiter = "\t" if path.suffix.lower() == ".tsv" else _pick_delimiter(sample)
|
|
139
|
+
|
|
140
|
+
report = ReadReport(encoding=encoding, delimiter=delimiter, skipped_blank_lines=skip)
|
|
141
|
+
if encoding not in ("utf-8", "utf-8-sig"):
|
|
142
|
+
report.notes.append(f"decoded as {encoding} (file was not valid UTF-8)")
|
|
143
|
+
if delimiter not in (",", "\t"):
|
|
144
|
+
report.notes.append(f"detected delimiter {delimiter!r} (not a comma)")
|
|
145
|
+
if skip:
|
|
146
|
+
report.notes.append(f"skipped {skip} leading blank line(s) before the header")
|
|
147
|
+
|
|
148
|
+
options: dict = {"encoding": "utf-8-sig" if encoding == "utf-8" else encoding}
|
|
149
|
+
if delimiter != ",":
|
|
150
|
+
options["sep"] = delimiter
|
|
151
|
+
if skip:
|
|
152
|
+
options["blank_lines"] = skip
|
|
153
|
+
return options, report
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def is_csv_family(path: str | Path) -> bool:
|
|
157
|
+
return Path(path).suffix.lower() in _CSV_SUFFIXES
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
__all__ = ["ReadReport", "detect_csv_options", "is_csv_family"]
|