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/__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
@@ -0,0 +1,5 @@
1
+ """Allow ``python -m cleanframe`` as an alias for the ``cleanframe`` console script."""
2
+
3
+ from .cli import main
4
+
5
+ raise SystemExit(main())
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"