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/__init__.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""CleanFrame — the reproducible data-cleaning engine for Python.
|
|
2
|
+
|
|
3
|
+
import cleanframe as cf
|
|
4
|
+
|
|
5
|
+
result = cf.clean(df, target_schema="customer.yaml", mode="review")
|
|
6
|
+
result.diff.show()
|
|
7
|
+
result.recipe.save("customer.recipe.yaml") # the durable artifact
|
|
8
|
+
clean_df = result.dataframe
|
|
9
|
+
|
|
10
|
+
The LLM (optional) only ever writes the recipe; pure pandas executes it. Same
|
|
11
|
+
input → same output, every time. See the README for the full tour.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from ._version import __version__
|
|
17
|
+
|
|
18
|
+
# -- high-level API --------------------------------------------------------
|
|
19
|
+
from .api import apply_recipe, clean, infer_schema, report, suggest_update
|
|
20
|
+
from .codegen import generate_code
|
|
21
|
+
from .dataio import read_frame, write_frame
|
|
22
|
+
from .detectors import DetectorContext, detector, list_detectors, run_detectors
|
|
23
|
+
|
|
24
|
+
# -- diff / drift / quality ------------------------------------------------
|
|
25
|
+
from .diff import CellChange, CellDiff, compute_diff
|
|
26
|
+
from .drift import DriftFinding, DriftReport, detect_drift
|
|
27
|
+
|
|
28
|
+
# -- errors ----------------------------------------------------------------
|
|
29
|
+
from .errors import (
|
|
30
|
+
BudgetExceeded,
|
|
31
|
+
CleanFrameError,
|
|
32
|
+
CleanFrameWarning,
|
|
33
|
+
DriftError,
|
|
34
|
+
ExecutionError,
|
|
35
|
+
LLMError,
|
|
36
|
+
OpError,
|
|
37
|
+
OutputError,
|
|
38
|
+
RecipeError,
|
|
39
|
+
SchemaError,
|
|
40
|
+
ValidationFailure,
|
|
41
|
+
)
|
|
42
|
+
from .executor import ExecutionResult, execute
|
|
43
|
+
|
|
44
|
+
# -- issues / detectors (the plugin surface) -------------------------------
|
|
45
|
+
from .issues import Issue, Issues, Proposal
|
|
46
|
+
|
|
47
|
+
# -- optional LLM planner --------------------------------------------------
|
|
48
|
+
from .llm import LLMPlanner, get_client, list_providers
|
|
49
|
+
|
|
50
|
+
# -- ops -------------------------------------------------------------------
|
|
51
|
+
from .ops import list_ops, register_op
|
|
52
|
+
|
|
53
|
+
# -- planning / execution --------------------------------------------------
|
|
54
|
+
from .planner import Planner, RulesPlanner, plan_recipe
|
|
55
|
+
|
|
56
|
+
# -- profiling -------------------------------------------------------------
|
|
57
|
+
from .profile import ColumnProfile, DataFrameProfile, profile_dataframe
|
|
58
|
+
from .quality import QualityScore, quality_score
|
|
59
|
+
|
|
60
|
+
# -- recipe / schema -------------------------------------------------------
|
|
61
|
+
from .recipe import ColumnRecipe, Recipe, ValidationRule
|
|
62
|
+
|
|
63
|
+
# -- results / io / codegen ------------------------------------------------
|
|
64
|
+
from .result import CleanResult, CodeArtifact, Report
|
|
65
|
+
from .schema import Schema, SchemaColumn
|
|
66
|
+
|
|
67
|
+
# -- out-of-core streaming replay ------------------------------------------
|
|
68
|
+
from .streaming import StreamSummary, check_streamable, stream_apply
|
|
69
|
+
|
|
70
|
+
# -- core types ------------------------------------------------------------
|
|
71
|
+
from .types import LLMExposure, Mode, Op, Severity
|
|
72
|
+
|
|
73
|
+
# -- validators ------------------------------------------------------------
|
|
74
|
+
from .validate import list_validators, validator
|
|
75
|
+
|
|
76
|
+
# -- multi-sheet workbooks -------------------------------------------------
|
|
77
|
+
from .workbook import (
|
|
78
|
+
WorkbookRecipe,
|
|
79
|
+
WorkbookResult,
|
|
80
|
+
apply_workbook,
|
|
81
|
+
clean_workbook,
|
|
82
|
+
load_recipe,
|
|
83
|
+
read_workbook,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
__all__ = [
|
|
87
|
+
"__version__",
|
|
88
|
+
# api
|
|
89
|
+
"clean",
|
|
90
|
+
"report",
|
|
91
|
+
"apply_recipe",
|
|
92
|
+
"suggest_update",
|
|
93
|
+
"infer_schema",
|
|
94
|
+
# types
|
|
95
|
+
"Mode",
|
|
96
|
+
"Severity",
|
|
97
|
+
"Op",
|
|
98
|
+
"LLMExposure",
|
|
99
|
+
# issues / plugins
|
|
100
|
+
"Issue",
|
|
101
|
+
"Issues",
|
|
102
|
+
"Proposal",
|
|
103
|
+
"detector",
|
|
104
|
+
"validator",
|
|
105
|
+
"register_op",
|
|
106
|
+
"DetectorContext",
|
|
107
|
+
"run_detectors",
|
|
108
|
+
"list_detectors",
|
|
109
|
+
"list_ops",
|
|
110
|
+
"list_validators",
|
|
111
|
+
# profiling
|
|
112
|
+
"profile_dataframe",
|
|
113
|
+
"DataFrameProfile",
|
|
114
|
+
"ColumnProfile",
|
|
115
|
+
# recipe / schema
|
|
116
|
+
"Recipe",
|
|
117
|
+
"ColumnRecipe",
|
|
118
|
+
"ValidationRule",
|
|
119
|
+
"Schema",
|
|
120
|
+
"SchemaColumn",
|
|
121
|
+
# planning / execution
|
|
122
|
+
"Planner",
|
|
123
|
+
"RulesPlanner",
|
|
124
|
+
"LLMPlanner",
|
|
125
|
+
"get_client",
|
|
126
|
+
"list_providers",
|
|
127
|
+
"plan_recipe",
|
|
128
|
+
"execute",
|
|
129
|
+
"ExecutionResult",
|
|
130
|
+
# diff / drift / quality
|
|
131
|
+
"CellDiff",
|
|
132
|
+
"CellChange",
|
|
133
|
+
"compute_diff",
|
|
134
|
+
"detect_drift",
|
|
135
|
+
"DriftReport",
|
|
136
|
+
"DriftFinding",
|
|
137
|
+
"quality_score",
|
|
138
|
+
"QualityScore",
|
|
139
|
+
# results / io / codegen
|
|
140
|
+
"CleanResult",
|
|
141
|
+
"Report",
|
|
142
|
+
"CodeArtifact",
|
|
143
|
+
"read_frame",
|
|
144
|
+
"write_frame",
|
|
145
|
+
"generate_code",
|
|
146
|
+
# workbooks
|
|
147
|
+
"clean_workbook",
|
|
148
|
+
"apply_workbook",
|
|
149
|
+
"read_workbook",
|
|
150
|
+
"load_recipe",
|
|
151
|
+
"WorkbookResult",
|
|
152
|
+
"WorkbookRecipe",
|
|
153
|
+
# streaming
|
|
154
|
+
"stream_apply",
|
|
155
|
+
"check_streamable",
|
|
156
|
+
"StreamSummary",
|
|
157
|
+
# errors
|
|
158
|
+
"CleanFrameError",
|
|
159
|
+
"CleanFrameWarning",
|
|
160
|
+
"RecipeError",
|
|
161
|
+
"OpError",
|
|
162
|
+
"OutputError",
|
|
163
|
+
"ExecutionError",
|
|
164
|
+
"ValidationFailure",
|
|
165
|
+
"DriftError",
|
|
166
|
+
"SchemaError",
|
|
167
|
+
"LLMError",
|
|
168
|
+
"BudgetExceeded",
|
|
169
|
+
]
|
cleanframe/__main__.py
ADDED
cleanframe/_util.py
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
"""Small, dependency-light string helpers shared across modules.
|
|
2
|
+
|
|
3
|
+
Centralised so that column-name normalisation and fuzzy matching behave
|
|
4
|
+
*identically* everywhere they matter — schema mapping, category clustering, and
|
|
5
|
+
drift detection all compare names/values the same way, which keeps confidence
|
|
6
|
+
scores consistent between "planning" and "drift" time.
|
|
7
|
+
|
|
8
|
+
Also hosts production-safety helpers used across detectors, IO, and validation:
|
|
9
|
+
bounded sampling for large columns, regex length guards, and CSV formula escaping.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
from difflib import SequenceMatcher
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import pandas as pd
|
|
21
|
+
import yaml
|
|
22
|
+
|
|
23
|
+
from .errors import CleanFrameError, OutputError
|
|
24
|
+
|
|
25
|
+
_CAMEL_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")
|
|
26
|
+
_NON_ALNUM_RE = re.compile(r"[^0-9a-zA-Z]+")
|
|
27
|
+
|
|
28
|
+
#: Default cap for detector / planner scans over non-null values on large columns.
|
|
29
|
+
DETECTOR_SAMPLE_CAP = 50_000
|
|
30
|
+
|
|
31
|
+
#: Default cap for cell-level diff entries (prevents OOM on wide dirty frames).
|
|
32
|
+
DEFAULT_MAX_DIFF_CHANGES = 100_000
|
|
33
|
+
|
|
34
|
+
#: Reject recipe/user regexes longer than this (ReDoS mitigation).
|
|
35
|
+
MAX_REGEX_PATTERN_LENGTH = 500
|
|
36
|
+
|
|
37
|
+
#: Characters that make a CSV cell look like a spreadsheet formula.
|
|
38
|
+
_CSV_FORMULA_PREFIXES = ("=", "+", "-", "@", "\t", "\r")
|
|
39
|
+
|
|
40
|
+
#: A signed number is not a formula. Escaping it would corrupt normalised phone
|
|
41
|
+
#: numbers (``+919876543210``) and negative amounts (``-1.5``) on every export.
|
|
42
|
+
_SIGNED_NUMBER_RE = re.compile(r"^[+-](?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$")
|
|
43
|
+
|
|
44
|
+
#: Control characters Excel refuses to store, and its per-cell character limit.
|
|
45
|
+
_EXCEL_ILLEGAL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f]")
|
|
46
|
+
EXCEL_MAX_CELL_CHARS = 32_767
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_string_like(series: pd.Series) -> bool:
|
|
50
|
+
"""True for object / string / str / category columns (pandas 1.x–3.x).
|
|
51
|
+
|
|
52
|
+
Pandas 3 defaults inferred text to ``dtype='str'`` (not ``object``). Detectors
|
|
53
|
+
that only checked ``object`` or ``\"string\"`` silently no-op'd on CI.
|
|
54
|
+
"""
|
|
55
|
+
dtype = series.dtype
|
|
56
|
+
if pd.api.types.is_object_dtype(dtype):
|
|
57
|
+
return True
|
|
58
|
+
if pd.api.types.is_string_dtype(dtype):
|
|
59
|
+
return True
|
|
60
|
+
# Categorical (avoid deprecated is_categorical_dtype).
|
|
61
|
+
if isinstance(dtype, pd.CategoricalDtype) or str(dtype) == "category":
|
|
62
|
+
return True
|
|
63
|
+
# Belt-and-suspenders for unusual StringDtype spellings across versions.
|
|
64
|
+
name = str(dtype).lower()
|
|
65
|
+
return name in ("str", "string", "object") or name.startswith("string")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def canonicalize_dtype(dtype: Any) -> str:
|
|
69
|
+
"""Map a pandas dtype to a coarse family for cross-version drift comparison.
|
|
70
|
+
|
|
71
|
+
``object`` / ``string`` / ``str`` (pandas 2 vs 3) collapse to ``string`` so a
|
|
72
|
+
fingerprint recorded under one pandas major doesn't false-alarm under another.
|
|
73
|
+
"""
|
|
74
|
+
name = str(dtype).lower()
|
|
75
|
+
if name in ("object", "str", "string") or name.startswith("string"):
|
|
76
|
+
return "string"
|
|
77
|
+
if name == "category" or name.startswith("category"):
|
|
78
|
+
return "category"
|
|
79
|
+
if "bool" in name:
|
|
80
|
+
return "bool"
|
|
81
|
+
if "int" in name:
|
|
82
|
+
return "int"
|
|
83
|
+
if "float" in name or name == "double":
|
|
84
|
+
return "float"
|
|
85
|
+
if "datetime" in name or name.startswith("date"):
|
|
86
|
+
return "datetime"
|
|
87
|
+
if "timedelta" in name:
|
|
88
|
+
return "timedelta"
|
|
89
|
+
return name
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def snake_case(name: str) -> str:
|
|
93
|
+
"""``"Customer Name"`` / ``"CustomerName"`` / ``"Amt (INR)"`` -> ``customer_name`` / ``amt_inr``."""
|
|
94
|
+
text = _CAMEL_RE.sub("_", str(name))
|
|
95
|
+
text = _NON_ALNUM_RE.sub("_", text)
|
|
96
|
+
return text.strip("_").lower()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def normalize_key(value: str) -> str:
|
|
100
|
+
"""Aggressive normalisation for equality-style comparison (case/space/punct-insensitive)."""
|
|
101
|
+
return _NON_ALNUM_RE.sub("", str(value)).casefold()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def token_set(name: str) -> set[str]:
|
|
105
|
+
return {t for t in snake_case(name).split("_") if t}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def similarity(a: str, b: str) -> float:
|
|
109
|
+
"""A blended name-similarity score in ``[0, 1]``.
|
|
110
|
+
|
|
111
|
+
Combines a character-level ratio (catches typos/abbreviations) with a
|
|
112
|
+
token-overlap ratio (catches word reordering like ``"INR Amount"`` vs
|
|
113
|
+
``"amount_inr"``). Deterministic and symmetric.
|
|
114
|
+
"""
|
|
115
|
+
sa, sb = snake_case(a), snake_case(b)
|
|
116
|
+
if not sa and not sb:
|
|
117
|
+
return 1.0
|
|
118
|
+
if not sa or not sb:
|
|
119
|
+
return 0.0
|
|
120
|
+
if sa == sb:
|
|
121
|
+
return 1.0
|
|
122
|
+
char_ratio = SequenceMatcher(None, sa, sb).ratio()
|
|
123
|
+
ta, tb = token_set(a), token_set(b)
|
|
124
|
+
if ta and tb:
|
|
125
|
+
token_ratio = len(ta & tb) / len(ta | tb)
|
|
126
|
+
else:
|
|
127
|
+
token_ratio = 0.0
|
|
128
|
+
return round(max(char_ratio, 0.5 * char_ratio + 0.5 * token_ratio), 4)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def best_match(target: str, candidates: list[str]) -> tuple[str | None, float]:
|
|
132
|
+
"""Return the ``(candidate, score)`` most similar to ``target`` (deterministic)."""
|
|
133
|
+
best: str | None = None
|
|
134
|
+
best_score = 0.0
|
|
135
|
+
for cand in candidates:
|
|
136
|
+
score = similarity(target, cand)
|
|
137
|
+
# Strict > keeps the first (input-order) candidate on ties -> deterministic.
|
|
138
|
+
if score > best_score:
|
|
139
|
+
best_score = score
|
|
140
|
+
best = cand
|
|
141
|
+
return best, best_score
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def looks_like_code_values(values: Any) -> bool:
|
|
145
|
+
"""True when the values are short all-caps codes, where ``NA`` is Namibia.
|
|
146
|
+
|
|
147
|
+
Used to decide whether a null-looking token is really a null: in a country or
|
|
148
|
+
currency column it is data, and turning it into NaN is a silent loss.
|
|
149
|
+
"""
|
|
150
|
+
tokens = [v.strip() for v in values if isinstance(v, str) and v.strip()]
|
|
151
|
+
if len(tokens) < 3:
|
|
152
|
+
return False
|
|
153
|
+
return all(len(t) <= 3 and t.isalpha() and t.isupper() for t in tokens)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def sample_non_null(series: pd.Series, cap: int = DETECTOR_SAMPLE_CAP) -> list[Any]:
|
|
157
|
+
"""Return up to ``cap`` non-null values in frame order (deterministic head sample).
|
|
158
|
+
|
|
159
|
+
Detectors use this instead of ``series.dropna().tolist()`` so a multi-million-row
|
|
160
|
+
column cannot force a full materialisation into Python lists. Pattern inference
|
|
161
|
+
on the head is sufficient for planning; execution still transforms every row.
|
|
162
|
+
"""
|
|
163
|
+
if cap <= 0:
|
|
164
|
+
return []
|
|
165
|
+
non_null = series.dropna()
|
|
166
|
+
if len(non_null) > cap:
|
|
167
|
+
non_null = non_null.iloc[:cap]
|
|
168
|
+
return non_null.tolist()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
_QUANTIFIED_ALT_RE = re.compile(r"\(([^()]*\|[^()]*)\)\s*(?:[+*]|\{)")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _has_overlapping_alternation(pattern: str) -> bool:
|
|
175
|
+
"""True if a quantified group contains alternatives where one is a prefix of another.
|
|
176
|
+
|
|
177
|
+
``(a|aa)+`` / ``(a|a)*`` backtrack catastrophically; ``(cat|dog)+`` does not.
|
|
178
|
+
Best-effort (single-level groups) — a guard, not a proof.
|
|
179
|
+
"""
|
|
180
|
+
for m in _QUANTIFIED_ALT_RE.finditer(pattern):
|
|
181
|
+
alts = [a for a in m.group(1).split("|")]
|
|
182
|
+
for i, a in enumerate(alts):
|
|
183
|
+
for j, b in enumerate(alts):
|
|
184
|
+
if i != j and a and b.startswith(a):
|
|
185
|
+
return True
|
|
186
|
+
return False
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def safe_compile_regex(pattern: str, *, flags: int = 0) -> re.Pattern[str]:
|
|
190
|
+
"""Compile a user/recipe regex with length and complexity guards.
|
|
191
|
+
|
|
192
|
+
Python's ``re`` engine has no built-in timeout; bounding pattern size and
|
|
193
|
+
rejecting obviously nested quantifiers is the practical ReDoS mitigation for
|
|
194
|
+
recipe-driven ``replace`` / ``matches`` checks on production data.
|
|
195
|
+
"""
|
|
196
|
+
if not isinstance(pattern, str):
|
|
197
|
+
raise ValueError(f"Regex pattern must be a string, got {type(pattern).__name__}.")
|
|
198
|
+
if len(pattern) > MAX_REGEX_PATTERN_LENGTH:
|
|
199
|
+
raise ValueError(
|
|
200
|
+
f"Regex pattern length {len(pattern)} exceeds limit of {MAX_REGEX_PATTERN_LENGTH}."
|
|
201
|
+
)
|
|
202
|
+
# Nested quantifiers like (a+)+ / (a*)* are classic ReDoS shapes.
|
|
203
|
+
if re.search(r"\([^)]*[+*][^)]*\)[+*]", pattern) or re.search(r"\([^)]*[+*]\)\{", pattern):
|
|
204
|
+
raise ValueError(
|
|
205
|
+
"Regex pattern looks like a nested-quantifier ReDoS risk; "
|
|
206
|
+
"simplify it or split into multiple safer checks."
|
|
207
|
+
)
|
|
208
|
+
# An optional inside a quantified group — (a?)+ — can match empty then repeat,
|
|
209
|
+
# another catastrophic-backtracking shape that the check above misses.
|
|
210
|
+
if re.search(r"\([^)]*\?\s*\)\s*[+*]", pattern):
|
|
211
|
+
raise ValueError(
|
|
212
|
+
"Regex pattern has an optional inside a quantified group (e.g. (a?)+), "
|
|
213
|
+
"a catastrophic-backtracking risk; simplify it."
|
|
214
|
+
)
|
|
215
|
+
# Overlapping alternation in a quantified group — (a|aa)+ — where one alternative
|
|
216
|
+
# is a prefix of another. (Non-overlapping alternations like (cat|dog)+ are fine.)
|
|
217
|
+
if _has_overlapping_alternation(pattern):
|
|
218
|
+
raise ValueError(
|
|
219
|
+
"Regex pattern has an overlapping alternation inside a quantifier "
|
|
220
|
+
"(e.g. (a|aa)+), a catastrophic-backtracking risk; simplify it."
|
|
221
|
+
)
|
|
222
|
+
try:
|
|
223
|
+
return re.compile(pattern, flags)
|
|
224
|
+
except re.error as exc:
|
|
225
|
+
raise ValueError(f"Invalid regex pattern: {exc}") from exc
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def sanitize_csv_value(value: Any) -> Any:
|
|
229
|
+
"""Neutralise spreadsheet formula injection in a single CSV/Excel cell.
|
|
230
|
+
|
|
231
|
+
Excel / Google Sheets / LibreOffice treat cells starting with ``=``, ``+``,
|
|
232
|
+
``-``, ``@``, or certain control characters as formulas. Prefixing with a
|
|
233
|
+
single quote forces text interpretation without changing the visible value in
|
|
234
|
+
most spreadsheet UIs.
|
|
235
|
+
"""
|
|
236
|
+
if not isinstance(value, str) or not value:
|
|
237
|
+
return value
|
|
238
|
+
if value[0] not in _CSV_FORMULA_PREFIXES:
|
|
239
|
+
return value
|
|
240
|
+
if _SIGNED_NUMBER_RE.match(value):
|
|
241
|
+
return value
|
|
242
|
+
return "'" + value
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def sanitize_dataframe_for_csv(df: pd.DataFrame) -> pd.DataFrame:
|
|
246
|
+
"""Return a copy of ``df`` with formula-like cells *and headers* escaped.
|
|
247
|
+
|
|
248
|
+
Header labels are escaped too: a column literally named ``=CMD()`` would
|
|
249
|
+
otherwise be written as a live formula in the first row of the export.
|
|
250
|
+
"""
|
|
251
|
+
out = df.copy()
|
|
252
|
+
for col in out.columns:
|
|
253
|
+
series = out[col]
|
|
254
|
+
if is_string_like(series):
|
|
255
|
+
out[col] = series.map(sanitize_csv_value)
|
|
256
|
+
labels = [sanitize_csv_value(str(c)) for c in out.columns]
|
|
257
|
+
if labels != [str(c) for c in out.columns]:
|
|
258
|
+
out.columns = labels
|
|
259
|
+
return out
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def sanitize_dataframe_for_spreadsheet(df: pd.DataFrame) -> pd.DataFrame:
|
|
263
|
+
"""Escape formula-like cells and drop characters Excel refuses to store."""
|
|
264
|
+
out = sanitize_dataframe_for_csv(df)
|
|
265
|
+
for col in out.columns:
|
|
266
|
+
series = out[col]
|
|
267
|
+
if not is_string_like(series):
|
|
268
|
+
continue
|
|
269
|
+
if bool(series.map(lambda v: isinstance(v, str) and len(v) > EXCEL_MAX_CELL_CHARS).any()):
|
|
270
|
+
raise OutputError(
|
|
271
|
+
f"Column {col!r} holds a value longer than Excel's "
|
|
272
|
+
f"{EXCEL_MAX_CELL_CHARS}-character cell limit. Write .csv or .parquet "
|
|
273
|
+
"instead, or shorten the value."
|
|
274
|
+
)
|
|
275
|
+
out[col] = series.map(lambda v: _EXCEL_ILLEGAL_RE.sub("", v) if isinstance(v, str) else v)
|
|
276
|
+
return out
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def ensure_string_columns(df: pd.DataFrame) -> pd.DataFrame:
|
|
280
|
+
"""Return a frame whose column labels are unique strings (copy only if needed).
|
|
281
|
+
|
|
282
|
+
CleanFrame keys lineage, the diff, recipes, and fingerprints by *string* column
|
|
283
|
+
name. Non-string labels (ints, tuples/MultiIndex, ``None``/``NaN``) are coerced
|
|
284
|
+
with ``str`` so they can't crash ``df[label]`` / ``.astype(str)`` downstream. If
|
|
285
|
+
that coercion collides — or the frame already carries duplicate labels — a
|
|
286
|
+
:class:`~cleanframe.errors.CleanFrameError` is raised naming them, because
|
|
287
|
+
silently de-duplicating columns would itself be undeclared data loss.
|
|
288
|
+
"""
|
|
289
|
+
cols = list(df.columns)
|
|
290
|
+
str_cols = [str(c) for c in cols]
|
|
291
|
+
counts: dict[str, int] = {}
|
|
292
|
+
for s in str_cols:
|
|
293
|
+
counts[s] = counts.get(s, 0) + 1
|
|
294
|
+
dups = sorted(s for s, n in counts.items() if n > 1)
|
|
295
|
+
if dups:
|
|
296
|
+
raise CleanFrameError(
|
|
297
|
+
f"Duplicate column name(s): {dups}. CleanFrame needs unique column names — "
|
|
298
|
+
"rename or drop the duplicates before cleaning."
|
|
299
|
+
)
|
|
300
|
+
if str_cols == cols:
|
|
301
|
+
return df
|
|
302
|
+
out = df.copy()
|
|
303
|
+
out.columns = str_cols
|
|
304
|
+
return out
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def ensure_parent(path: str | Path) -> Path:
|
|
308
|
+
"""Validate an output path and create its parent directories if needed."""
|
|
309
|
+
path = Path(path)
|
|
310
|
+
if path.is_dir():
|
|
311
|
+
raise OutputError(
|
|
312
|
+
f"Output path is a directory, not a file: {path}. Pass the file to write to."
|
|
313
|
+
)
|
|
314
|
+
parent = path.parent
|
|
315
|
+
# Path("file.txt").parent is "."; Path(".").parent is also "." — skip useless mkdir.
|
|
316
|
+
if parent.parts and str(parent) not in (".", ""):
|
|
317
|
+
try:
|
|
318
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
319
|
+
except OSError as exc:
|
|
320
|
+
raise OutputError(f"Could not create output directory {parent}: {exc}") from exc
|
|
321
|
+
return path
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def check_output_target(
|
|
325
|
+
path: str | Path, source: str | Path | None = None, *, overwrite: bool = False
|
|
326
|
+
) -> Path:
|
|
327
|
+
"""Prepare an output path, refusing to overwrite the input it came from.
|
|
328
|
+
|
|
329
|
+
Writing cleaned data back over its own source destroys the original before
|
|
330
|
+
anyone can review the diff, so it takes an explicit ``overwrite``.
|
|
331
|
+
"""
|
|
332
|
+
out = ensure_parent(path)
|
|
333
|
+
if source is None or overwrite:
|
|
334
|
+
return out
|
|
335
|
+
try:
|
|
336
|
+
src = Path(source)
|
|
337
|
+
same = src.exists() and out.exists() and out.resolve() == src.resolve()
|
|
338
|
+
except OSError: # pragma: no cover - unresolvable path
|
|
339
|
+
same = False
|
|
340
|
+
if same:
|
|
341
|
+
raise OutputError(
|
|
342
|
+
f"Refusing to overwrite the input file in place: {out}. Write to a different "
|
|
343
|
+
"path, or pass overwrite=True (CLI: --overwrite) to accept losing the original."
|
|
344
|
+
)
|
|
345
|
+
return out
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
class _NoDuplicateLoader(yaml.SafeLoader):
|
|
349
|
+
"""SafeLoader that refuses duplicate mapping keys instead of keeping the last."""
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _construct_unique_mapping(loader, node, deep=False):
|
|
353
|
+
mapping: dict = {}
|
|
354
|
+
for key_node, value_node in node.value:
|
|
355
|
+
key = loader.construct_object(key_node, deep=deep)
|
|
356
|
+
if isinstance(key, list):
|
|
357
|
+
key = tuple(key)
|
|
358
|
+
if key in mapping:
|
|
359
|
+
raise CleanFrameError(
|
|
360
|
+
f"duplicate key {key!r} on line {key_node.start_mark.line + 1} — the second "
|
|
361
|
+
"value would silently win; remove one of them"
|
|
362
|
+
)
|
|
363
|
+
mapping[key] = loader.construct_object(value_node, deep=deep)
|
|
364
|
+
return mapping
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
_NoDuplicateLoader.add_constructor(
|
|
368
|
+
yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _construct_unique_mapping
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def load_yaml(text: str, *, error: type = CleanFrameError, what: str = "input"):
|
|
373
|
+
"""Parse YAML strictly: syntax errors and duplicate keys both become ``error``."""
|
|
374
|
+
try:
|
|
375
|
+
return yaml.load(text, Loader=_NoDuplicateLoader)
|
|
376
|
+
except CleanFrameError as exc:
|
|
377
|
+
raise error(f"Invalid {what} YAML: {exc}") from exc
|
|
378
|
+
except yaml.YAMLError as exc:
|
|
379
|
+
raise error(f"Invalid {what} YAML: {exc}") from exc
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def write_text(path: str | Path, text: str) -> Path:
|
|
383
|
+
"""Write UTF-8 text with ``\\n`` newlines on every OS (no Windows CRLF translation).
|
|
384
|
+
|
|
385
|
+
Recipes, schemas, reports, and generated code must round-trip identically whether
|
|
386
|
+
authored on Windows, macOS, or Linux — forcing ``newline='\\n'`` keeps git diffs
|
|
387
|
+
and byte-identical YAML stable across platforms.
|
|
388
|
+
"""
|
|
389
|
+
path = ensure_parent(path)
|
|
390
|
+
tmp = path.with_name(path.name + ".cf-tmp")
|
|
391
|
+
try:
|
|
392
|
+
tmp.write_text(text, encoding="utf-8", newline="\n")
|
|
393
|
+
os.replace(tmp, path)
|
|
394
|
+
except OSError as exc:
|
|
395
|
+
tmp.unlink(missing_ok=True)
|
|
396
|
+
raise OutputError(f"Could not write {path}: {exc}") from exc
|
|
397
|
+
return path
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def read_text(path: str | Path) -> str:
|
|
401
|
+
"""Read a UTF-8 text file; accept UTF-8 BOM (common on Windows Excel / Notepad)."""
|
|
402
|
+
path = Path(path)
|
|
403
|
+
try:
|
|
404
|
+
return path.read_text(encoding="utf-8-sig")
|
|
405
|
+
except UnicodeDecodeError as exc:
|
|
406
|
+
raise CleanFrameError(
|
|
407
|
+
f"Could not read {path.name} as UTF-8 ({exc}). Recipes and schemas are UTF-8 "
|
|
408
|
+
"text files — re-save it as UTF-8."
|
|
409
|
+
) from exc
|
|
410
|
+
except OSError as exc:
|
|
411
|
+
raise CleanFrameError(f"Could not read {path}: {exc}") from exc
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
__all__ = [
|
|
415
|
+
"snake_case",
|
|
416
|
+
"normalize_key",
|
|
417
|
+
"token_set",
|
|
418
|
+
"similarity",
|
|
419
|
+
"best_match",
|
|
420
|
+
"sample_non_null",
|
|
421
|
+
"looks_like_code_values",
|
|
422
|
+
"safe_compile_regex",
|
|
423
|
+
"sanitize_csv_value",
|
|
424
|
+
"sanitize_dataframe_for_csv",
|
|
425
|
+
"sanitize_dataframe_for_spreadsheet",
|
|
426
|
+
"ensure_parent",
|
|
427
|
+
"check_output_target",
|
|
428
|
+
"load_yaml",
|
|
429
|
+
"ensure_string_columns",
|
|
430
|
+
"write_text",
|
|
431
|
+
"read_text",
|
|
432
|
+
"is_string_like",
|
|
433
|
+
"canonicalize_dtype",
|
|
434
|
+
"DETECTOR_SAMPLE_CAP",
|
|
435
|
+
"DEFAULT_MAX_DIFF_CHANGES",
|
|
436
|
+
"MAX_REGEX_PATTERN_LENGTH",
|
|
437
|
+
"EXCEL_MAX_CELL_CHARS",
|
|
438
|
+
]
|
cleanframe/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.0"
|