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