cleanframe-engine 0.3.0__tar.gz

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 (90) hide show
  1. cleanframe_engine-0.3.0/.gitignore +101 -0
  2. cleanframe_engine-0.3.0/CHANGELOG.md +235 -0
  3. cleanframe_engine-0.3.0/CONTRIBUTING.md +241 -0
  4. cleanframe_engine-0.3.0/LICENSE +201 -0
  5. cleanframe_engine-0.3.0/PKG-INFO +323 -0
  6. cleanframe_engine-0.3.0/README.md +273 -0
  7. cleanframe_engine-0.3.0/SECURITY.md +48 -0
  8. cleanframe_engine-0.3.0/cleanframe/__init__.py +169 -0
  9. cleanframe_engine-0.3.0/cleanframe/__main__.py +5 -0
  10. cleanframe_engine-0.3.0/cleanframe/_util.py +438 -0
  11. cleanframe_engine-0.3.0/cleanframe/_version.py +1 -0
  12. cleanframe_engine-0.3.0/cleanframe/api.py +559 -0
  13. cleanframe_engine-0.3.0/cleanframe/cli.py +688 -0
  14. cleanframe_engine-0.3.0/cleanframe/codegen.py +666 -0
  15. cleanframe_engine-0.3.0/cleanframe/dataio.py +506 -0
  16. cleanframe_engine-0.3.0/cleanframe/detectors/__init__.py +43 -0
  17. cleanframe_engine-0.3.0/cleanframe/detectors/base.py +221 -0
  18. cleanframe_engine-0.3.0/cleanframe/detectors/categories.py +199 -0
  19. cleanframe_engine-0.3.0/cleanframe/detectors/contacts.py +105 -0
  20. cleanframe_engine-0.3.0/cleanframe/detectors/currency.py +112 -0
  21. cleanframe_engine-0.3.0/cleanframe/detectors/dates.py +204 -0
  22. cleanframe_engine-0.3.0/cleanframe/detectors/dedup.py +150 -0
  23. cleanframe_engine-0.3.0/cleanframe/detectors/nulls.py +109 -0
  24. cleanframe_engine-0.3.0/cleanframe/detectors/outliers.py +73 -0
  25. cleanframe_engine-0.3.0/cleanframe/detectors/schema_mapping.py +125 -0
  26. cleanframe_engine-0.3.0/cleanframe/detectors/text.py +105 -0
  27. cleanframe_engine-0.3.0/cleanframe/detectors/units.py +86 -0
  28. cleanframe_engine-0.3.0/cleanframe/diff.py +369 -0
  29. cleanframe_engine-0.3.0/cleanframe/drift.py +283 -0
  30. cleanframe_engine-0.3.0/cleanframe/errors.py +66 -0
  31. cleanframe_engine-0.3.0/cleanframe/executor.py +229 -0
  32. cleanframe_engine-0.3.0/cleanframe/fingerprint.py +83 -0
  33. cleanframe_engine-0.3.0/cleanframe/issues.py +186 -0
  34. cleanframe_engine-0.3.0/cleanframe/llm.py +811 -0
  35. cleanframe_engine-0.3.0/cleanframe/ops.py +1245 -0
  36. cleanframe_engine-0.3.0/cleanframe/planner.py +353 -0
  37. cleanframe_engine-0.3.0/cleanframe/profile.py +413 -0
  38. cleanframe_engine-0.3.0/cleanframe/py.typed +1 -0
  39. cleanframe_engine-0.3.0/cleanframe/quality.py +81 -0
  40. cleanframe_engine-0.3.0/cleanframe/readfix.py +160 -0
  41. cleanframe_engine-0.3.0/cleanframe/recipe.py +398 -0
  42. cleanframe_engine-0.3.0/cleanframe/report.py +345 -0
  43. cleanframe_engine-0.3.0/cleanframe/result.py +144 -0
  44. cleanframe_engine-0.3.0/cleanframe/schema.py +259 -0
  45. cleanframe_engine-0.3.0/cleanframe/streaming.py +354 -0
  46. cleanframe_engine-0.3.0/cleanframe/types.py +119 -0
  47. cleanframe_engine-0.3.0/cleanframe/validate.py +363 -0
  48. cleanframe_engine-0.3.0/cleanframe/workbook.py +370 -0
  49. cleanframe_engine-0.3.0/docs/README.md +24 -0
  50. cleanframe_engine-0.3.0/docs/api-reference.md +284 -0
  51. cleanframe_engine-0.3.0/docs/architecture.md +63 -0
  52. cleanframe_engine-0.3.0/docs/cli.md +207 -0
  53. cleanframe_engine-0.3.0/docs/concepts.md +105 -0
  54. cleanframe_engine-0.3.0/docs/detectors-and-ops.md +76 -0
  55. cleanframe_engine-0.3.0/docs/faq.md +119 -0
  56. cleanframe_engine-0.3.0/docs/getting-started.md +142 -0
  57. cleanframe_engine-0.3.0/docs/installation.md +105 -0
  58. cleanframe_engine-0.3.0/docs/llm.md +121 -0
  59. cleanframe_engine-0.3.0/docs/production.md +210 -0
  60. cleanframe_engine-0.3.0/docs/recipe-spec.md +299 -0
  61. cleanframe_engine-0.3.0/docs/schema-spec.md +115 -0
  62. cleanframe_engine-0.3.0/examples/customer.recipe.yaml +91 -0
  63. cleanframe_engine-0.3.0/examples/customer.schema.yaml +31 -0
  64. cleanframe_engine-0.3.0/examples/messy_customers.csv +7 -0
  65. cleanframe_engine-0.3.0/pyproject.toml +108 -0
  66. cleanframe_engine-0.3.0/tests/conftest.py +50 -0
  67. cleanframe_engine-0.3.0/tests/test_api_report_cli.py +175 -0
  68. cleanframe_engine-0.3.0/tests/test_audit_hardening.py +258 -0
  69. cleanframe_engine-0.3.0/tests/test_cross_platform.py +90 -0
  70. cleanframe_engine-0.3.0/tests/test_drift_codegen.py +56 -0
  71. cleanframe_engine-0.3.0/tests/test_executor_diff.py +75 -0
  72. cleanframe_engine-0.3.0/tests/test_llm_schema.py +179 -0
  73. cleanframe_engine-0.3.0/tests/test_ops.py +156 -0
  74. cleanframe_engine-0.3.0/tests/test_planner.py +88 -0
  75. cleanframe_engine-0.3.0/tests/test_production_safety.py +86 -0
  76. cleanframe_engine-0.3.0/tests/test_profile_detectors.py +136 -0
  77. cleanframe_engine-0.3.0/tests/test_recipe.py +90 -0
  78. cleanframe_engine-0.3.0/tests/test_release_hardening.py +717 -0
  79. cleanframe_engine-0.3.0/tests/test_validate.py +97 -0
  80. cleanframe_engine-0.3.0/tests/test_wave1_codegen.py +104 -0
  81. cleanframe_engine-0.3.0/tests/test_wave1_correctness.py +104 -0
  82. cleanframe_engine-0.3.0/tests/test_wave1_hardening.py +125 -0
  83. cleanframe_engine-0.3.0/tests/test_wave1_lineage.py +75 -0
  84. cleanframe_engine-0.3.0/tests/test_wave1_llm.py +120 -0
  85. cleanframe_engine-0.3.0/tests/test_wave2_scaling.py +72 -0
  86. cleanframe_engine-0.3.0/tests/test_wave2_vectorization.py +79 -0
  87. cleanframe_engine-0.3.0/tests/test_wave3_selection.py +93 -0
  88. cleanframe_engine-0.3.0/tests/test_wave3_workbook.py +84 -0
  89. cleanframe_engine-0.3.0/tests/test_wave4_readfix.py +64 -0
  90. cleanframe_engine-0.3.0/tests/test_wave5_streaming.py +108 -0
@@ -0,0 +1,101 @@
1
+ # Byte-compile / cache
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+
8
+ # Virtual environments
9
+ .venv/
10
+ venv/
11
+ ENV/
12
+ env/
13
+
14
+ # Environment / secrets (never commit)
15
+ .env
16
+ .env.*
17
+ !.env.example
18
+ llm-help.md
19
+ *.pem
20
+ *.key
21
+ credentials.json
22
+ service-account*.json
23
+ .secrets/
24
+
25
+ # Distribution / packaging
26
+ build/
27
+ dist/
28
+ develop-eggs/
29
+ downloads/
30
+ eggs/
31
+ .eggs/
32
+ parts/
33
+ sdist/
34
+ var/
35
+ wheels/
36
+ share/python-wheels/
37
+ *.egg-info/
38
+ .installed.cfg
39
+ *.egg
40
+ MANIFEST
41
+ pip-wheel-metadata/
42
+
43
+ # Test / coverage / linters / type checkers
44
+ .pytest_cache/
45
+ .coverage
46
+ .coverage.*
47
+ htmlcov/
48
+ .tox/
49
+ .nox/
50
+ coverage.xml
51
+ *.cover
52
+ .hypothesis/
53
+ .mypy_cache/
54
+ .dmypy.json
55
+ dmypy.json
56
+ .ruff_cache/
57
+ .cache/
58
+
59
+ # Jupyter
60
+ .ipynb_checkpoints/
61
+
62
+ # IDE / editors
63
+ .idea/
64
+ .vscode/
65
+ *.swp
66
+ *.swo
67
+ *~
68
+ *.code-workspace
69
+
70
+ # OS junk (Windows / macOS / Linux)
71
+ .DS_Store
72
+ .DS_Store?
73
+ ._*
74
+ .Spotlight-V100
75
+ .Trashes
76
+ ehthumbs.db
77
+ Thumbs.db
78
+ Desktop.ini
79
+ $RECYCLE.BIN/
80
+
81
+ # Local run outputs (keep committed examples/)
82
+ /out/
83
+ /output/
84
+ /tmp/
85
+ /temp/
86
+
87
+ # Artifacts CleanFrame writes next to an input file. The sample recipe and schema
88
+ # under examples/ are committed on purpose and are already tracked.
89
+ *.report.html
90
+ *.clean.csv
91
+ *.clean.tsv
92
+ *.clean.xlsx
93
+ *.clean.json
94
+ *.clean.parquet
95
+ *.quarantine.csv
96
+ *.patched.yaml
97
+ *.cf-tmp
98
+ examples/messy_customers.recipe.yaml
99
+ examples/messy_customers.schema.yaml
100
+ !examples/customer.recipe.yaml
101
+ !examples/customer.schema.yaml
@@ -0,0 +1,235 @@
1
+ # Changelog
2
+
3
+ All notable changes to CleanFrame are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.3.0] — 2026-09-04
11
+
12
+ Release-readiness pass driven by a full audit of the CLI, the Python API, packaging and
13
+ the docs. Several fixes change behaviour on purpose: cases that used to pass silently
14
+ now fail loudly, and a few transforms that quietly corrupted data no longer run.
15
+
16
+ ### Security
17
+
18
+ - **Generated code no longer trusts column names.** `generate_code` interpolated the
19
+ source column into a comment and the validation label into a `raise` without escaping,
20
+ so a CSV header containing a newline produced an exported `clean(df)` that executed
21
+ arbitrary statements. Both are now sanitised.
22
+ - **CSV/Excel exports sanitise header labels**, not just cells: a column literally named
23
+ `=CMD()` was written as a live formula.
24
+ - The formula sanitiser no longer escapes plain signed numbers, so a normalised phone
25
+ number (`+919876543210`) and a negative amount (`-1.5`) survive export unchanged.
26
+ - Recipes and schemas reject **duplicate YAML keys** instead of silently keeping the last.
27
+
28
+ ### Fixed — silent data corruption
29
+
30
+ - `normalize_phone` on a numeric column (one blank cell makes pandas read the column as
31
+ float) appended a spurious trailing digit; `9876543210.0` became `98765432100`.
32
+ Trailing extensions (`ext 12`) are no longer fused into the number either.
33
+ - ISO timestamps and dashed identifiers were classified as phone numbers and stripped to
34
+ digits. Phone classification now needs a phone-ish column name or an explicit `+`/`(`
35
+ dial prefix, and clock times and ISO dates are never phone-like.
36
+ - European decimals were parsed with the US convention: `€1.200,50` became `1.2005` while
37
+ the issue reported nothing unparseable. The currency detector now infers the grouping.
38
+ - A fuzzy category merge folded `Unapproved` into `Approved` (and `Unverified` into
39
+ `Verified`). A spelling that is another plus a negation prefix is never merged.
40
+ - A ragged CSV row (one field too many, typically a trailing delimiter) shifted every
41
+ column left because pandas promoted the first field to the index. Reads now pass
42
+ `index_col=False`.
43
+ - `in [Yes, No]` rejected every row: YAML 1.1 read the values as booleans. Membership
44
+ values are parsed as text.
45
+ - Mixed day-first and month-first dates reported `unparsed: 0` while nulling the values
46
+ that no longer matched after the conflicting format was dropped. The count is now taken
47
+ after reconciliation.
48
+ - Punctuation-only values (`-`, `?`) were collapsed into `""` by category clustering.
49
+ - `NA` in a short all-caps code column (Namibia) is treated as ambiguous, not a null.
50
+ - Category canonicalisation prefers short all-caps codes (`CA` over `ca`).
51
+ - A user column named `_cf_quarantine_reason` was overwritten in the quarantine frame.
52
+ - `skiprows` meant different things per format and could eat the CSV header. It now
53
+ counts **data rows** everywhere: an int drops that many leading records, a list names
54
+ 1-based data rows.
55
+ - `profile_dataframe` no longer takes O(n²) time on long text values, so a column holding
56
+ a notes field or a JSON blob does not stall `clean()`. It also survives unhashable
57
+ cells (lists/dicts) and categorical columns.
58
+ - A validation rule written `values: [Yes, No]` matched nothing, because YAML 1.1 reads
59
+ those as booleans. Every spelling of the boolean is now matched, in the executor and in
60
+ the exported code alike.
61
+ - `round: {decimals: 2}` — the documented parameter form — failed to load; only the bare
62
+ `round: 2` worked.
63
+ - The schema dtype alias `str` was listed but not accepted.
64
+ - `excel_sheet_names` left the workbook file handle open, so a later write to the same
65
+ path could fail with a permission error on Windows.
66
+
67
+ ### Fixed — errors that reached users as tracebacks
68
+
69
+ - The CLI has a catch-all: an unexpected exception prints one line plus an invitation to
70
+ re-run with `--debug` (or `CLEANFRAME_DEBUG=1`) instead of a traceback.
71
+ - Clean messages replace tracebacks for: a missing or directory input path on
72
+ `clean`/`report` (the format detector opened the file before the existence check); an
73
+ output path that is a directory, read-only, or locked; malformed recipe YAML through
74
+ `apply`; malformed schema YAML; a recipe or schema in a non-UTF-8 encoding; a
75
+ non-integer recipe `version`; a non-mapping `meta`; a malformed `source_fingerprint`; a
76
+ validation rule with an unsupported parameter; a bad `mode`, `on_drift` or `chunksize`;
77
+ a wrongly-typed `options`, `planner` or `recipe` argument.
78
+ - The encoding ladder ends at latin-1, which cannot fail. Byte values cp1252 leaves
79
+ undefined (a Shift-JIS export, for instance) no longer raise mid-detection.
80
+ - UTF-16/32 files are detected by byte-order mark instead of being reported as binary.
81
+ - Excel writes strip the control characters openpyxl refuses and check its 32,767-character
82
+ cell limit up front, so a failed write no longer leaves a truncated workbook behind.
83
+
84
+ ### Changed
85
+
86
+ - **The PyPI distribution is now `cleanframe-engine`** (the name `cleanframe` belongs to
87
+ an unrelated project). The import package, the console script and every API are
88
+ unchanged: `pip install cleanframe-engine`, then `import cleanframe as cf`.
89
+ - **Exit codes are distinct:** `0` success, `1` data/recipe/output error, `2` usage,
90
+ `3` stopped on drift, `4` validation failed, `70` internal error, `130` interrupted.
91
+ Previously everything but a usage error exited `1`.
92
+ - **Writing output over the input file is refused** unless `overwrite=True` /
93
+ `--overwrite`. A single-sheet clean written back over its own workbook used to leave
94
+ one renamed sheet and delete the rest.
95
+ - Every write goes through a temporary file and is moved into place — cleaned data,
96
+ quarantine, streamed output, recipes, schemas, generated code and HTML reports.
97
+ - `stream_apply` accepts `overwrite=True` (CLI `--overwrite`), which it previously had no
98
+ way to express.
99
+ - **Unknown and misspelled op parameters are rejected at load time** with the list of
100
+ valid names. `parse_date: {format: ...}`, `dedup: {case_insensitive: true}` and
101
+ `to_na: {token: ...}` used to load and do nothing. `cast` targets, `normalize_unit`
102
+ units, `dedup keep` and validation check names are validated at load too.
103
+ - Wrongly-typed parameters are refused rather than coerced: `remove_symbols: 5` used to
104
+ delete every digit 5, and a bare string where a list belongs used to be read one
105
+ character at a time.
106
+ - Schema dtype aliases (`int`, `number`, `text`, `bool`, `id`) are normalised, so they now
107
+ drive the same casts as their canonical spellings instead of being silently ignored.
108
+ - Flags that were silently ignored now error: `--code`/`--report`/`--quarantine` and the
109
+ file-only read flags in workbook mode, `--report` and the selection flags in streaming
110
+ mode. `--chunksize 0` was accepted and quietly ran a whole-frame apply.
111
+ - `llm_exposure="none"` makes no network call at all; it plans with rules and warns.
112
+ Previously it sent the same metadata payload as `metadata` exposure.
113
+ - `on_drift` is validated: a typo used to disable the drift guard silently.
114
+ - Streaming pins column types from the recipe's fingerprint, so a streamed replay renders
115
+ numbers the same way a whole-frame replay does (`2` vs `2.0`), falling back to text with
116
+ a warning if the file no longer matches.
117
+ - `suggest` re-applies the recipe's recorded `read:` binding and accepts the selection
118
+ flags, so the command the drift message recommends now works on workbooks and on
119
+ `;`-separated files instead of reporting every column as drifted.
120
+ - `generate_code` raises when a recipe uses an op or check it cannot reproduce, instead of
121
+ emitting a silently incomplete module (`allow_partial=True` restores the old output).
122
+ - Every warning uses the new `CleanFrameWarning` category and prints as one line from the
123
+ CLI. Filter them with `warnings.simplefilter("ignore", cleanframe.CleanFrameWarning)`.
124
+ - `Mode.coerce` raises `CleanFrameError` rather than `ValueError`.
125
+ - Unsupported input extensions are refused instead of being parsed as CSV.
126
+ - Duplicate and blank CSV header names are refused instead of being renamed by pandas to
127
+ `name.1` / `Unnamed: 3`.
128
+ - Writing legacy `.xls` is refused (pandas emits `.xlsx` bytes under the name).
129
+
130
+ ### Added
131
+
132
+ - `text=True` / `--text` reads every field verbatim, keeping leading zeros, literal `NA`
133
+ and `1e5` exactly as the file has them, and records the choice in the recipe. Without
134
+ it, `clean`/`report` compare a bounded verbatim re-read and warn naming the columns
135
+ pandas' type inference changed.
136
+ - Values an op could not parse are counted per column, logged and warned about.
137
+ - `python -m cleanframe` as an alias for the console script.
138
+ - CLI: `--debug`, `--verbose`, `--sep`, `--encoding`, `--text`, `--no-llm-fallback`,
139
+ `clean --out-dir`, and `-o` on every subcommand that has `--out`.
140
+ - `clean(..., llm_fallback=False)` makes an LLM failure raise instead of degrading.
141
+ - **A model-written step that cannot load no longer costs the whole plan.** It is dropped,
142
+ warned about, and listed in `recipe.meta["llm_dropped"]`, so the rest of the model's
143
+ recipe still runs. Previously one stray parameter, or a `cast` with no target, discarded
144
+ the entire LLM recipe and silently fell back to rules. A response that is not a recipe at
145
+ all still falls back. Ops a mode forbids are recorded in `recipe.meta["llm_blocked_ops"]`.
146
+ - Recipe loading joins an op and its single argument written as two list items
147
+ (`["extract_currency", "cast", "float"]`), a shape models emit regularly. Two real op
148
+ names in a row are never joined.
149
+ - `sep=` / `encoding=` on `clean`, `report`, `apply_recipe` and `infer_schema`;
150
+ `blank_lines=` and `text=` on `read_frame`; `source=`/`overwrite=` on `write_frame`.
151
+ - `OutputError` and `CleanFrameWarning` in the public API.
152
+ - `mypy` runs clean over the package and is part of the dev extra and CI.
153
+ - Release workflow (PyPI trusted publishing), Dependabot, CodeQL, issue and pull-request
154
+ templates, a code of conduct, and a citation file.
155
+
156
+ ## [0.2.0] — 2026-07-19
157
+
158
+ ### Added — production-hardening upgrade (multi-sheet, scaling, selection, format-correction, streaming)
159
+
160
+ - **Multi-sheet Excel workbooks**: `clean_workbook` / `apply_workbook` clean every tab
161
+ independently (one recipe + diff per sheet), collected in a `WorkbookResult` with a
162
+ single reviewable `WorkbookRecipe` (`sheets:` block). `read_frame` now *refuses* to
163
+ silently read only sheet 1 of a multi-sheet workbook — pass `sheet=` or use the
164
+ workbook API. Write-back preserves untouched sheets but **refuses in-place overwrite
165
+ of the source** (formulas/formatting are lost on pandas re-emit) unless `overwrite=True`.
166
+ The `cleanframe clean/apply` CLI auto-routes a multi-sheet `.xlsx`.
167
+ - **Selective ingestion**: `sheet` / `columns` / `nrows` / `skiprows` on
168
+ `read_frame`/`clean`/`report`/`apply`/`infer-schema` and the CLI, recorded in the recipe's
169
+ new `read:` section (recipe v2, backward-compatible) so `apply` re-reads the same slice.
170
+ - **Read-time format auto-correction** (`correct_format=True`, default; `--no-correct` to
171
+ opt out): deterministic encoding fallback (utf-8 → cp1252) and header-consensus delimiter
172
+ detection for CSV-family files, pinned into the recipe `read:` section for replay; refuses
173
+ on an ambiguous delimiter.
174
+ - **Out-of-core streaming replay**: `stream_apply(recipe, in, out, chunksize=)` and
175
+ `cleanframe apply --chunksize N` process files larger than RAM. Row-independent recipes
176
+ stream with byte-identical values and bounded (chunk-sized) memory; global ops
177
+ (`dedup`, aggregate `fill_na`, `cast` to category/datetime, format-less `parse_date`, the
178
+ `unique` validator) are **refused with a clear, named error**. Streaming also honours the
179
+ refuse-on-drift guarantee (checked on a bounded head sample).
180
+ - **In-RAM scaling**: ~44× faster diff extraction (bulk slicing), the executor snapshots only
181
+ op-touched columns (peak memory ≈ input, not 2×), a vectorised `.str` fast-path for text ops
182
+ (byte-identical), and deduplicated profiler signal computation.
183
+
184
+ ### Fixed — correctness & robustness (from a full empirical audit)
185
+
186
+ - **Silent data loss / lineage**: an emitted derived column that overwrites an existing column
187
+ is now tracked in the diff (was reported as add+remove with the change lost); a value rewrite
188
+ on a row later dropped by dedup/validation keeps its change provenance; two ops emitting the
189
+ same column now raise instead of silently clobbering.
190
+ - **Detectors**: ambiguous DD/MM vs MM/DD dates are resolved from the data (no silent day/month
191
+ swap); fuzzy category clustering no longer merges two both-frequent look-alikes
192
+ (`insured`/`uninsured`); datetimes keep their time-of-day; disguised-null tokens that are often
193
+ legitimate (`none`, `-`, `unknown`) are surfaced for review, not auto-converted.
194
+ - **`parse_number`** rejects fused digit groups (`"12ab34"` → NaN) and handles scientific
195
+ notation; format-less `parse_date`/`cast(datetime)` are now deterministic (order-independent).
196
+ - **Crash-hardening**: non-UTF-8 CSVs, ragged/empty files, directories, mislabeled extensions,
197
+ malformed YAML/op params, duplicate/non-string/MultiIndex column names, and a Windows cp1252
198
+ console now raise a clean `CleanFrameError` / render safely instead of a raw traceback.
199
+ - **LLM planner**: any failure (bad JSON, malformed recipe, provider/network error) now falls
200
+ back to the deterministic rules planner as documented — previously some outputs crashed the
201
+ pipeline with an uncaught `KeyError`. Array-form ops are parsed leniently; the `METADATA`
202
+ exposure no longer leaks a raw cell value via a detector message.
203
+ - **Codegen**: generated standalone pandas now reproduces the executor exactly (currency symbols,
204
+ number sign/unicode-minus, NA tokens, unit aliases, `cast` bool/date, validation row-filtering,
205
+ and case-insensitive dedup), rendered from a single source of truth in `cleanframe.ops`.
206
+ - **Schema**: an unknown/typo'd dtype (`"flaot"`) is rejected instead of silently ignored.
207
+
208
+ ### Added — production safety guards for large datasets and untrusted exports
209
+
210
+ - Detector scans sample at most 50,000 non-null values per column (`sample_non_null`)
211
+ - Cell-level diffs cap stored detail at 100,000 changes by default (`max_diff_changes`)
212
+ - CSV/TSV writes escape spreadsheet formula injection by default (`sanitize_csv=True`)
213
+ - Recipe regexes (`replace`, `matches:`) reject oversized / nested-quantifier patterns
214
+ - LLM HTTP clients use a 60s timeout; SAMPLE exposure no longer materialises entire columns
215
+ - Missing recipe columns and LLM fallbacks emit `warnings.warn` (not silent skips)
216
+ - Optional `parquet` extra (`pyarrow`)
217
+ - `cleanframe/py.typed` marker (PEP 561)
218
+ - Example schema + recipe under `examples/`
219
+ - CI workflow (pytest + ruff on Python 3.10–3.13)
220
+ - Full documentation under `docs/` and GitHub Wiki pages under `wiki/`
221
+
222
+ ### Fixed
223
+
224
+ - Units detector threshold now compares against the sampled column size, not the full row count
225
+ - Cross-platform IO: UTF-8 (+ BOM-tolerant reads), LF-only text/CSV writes, auto-create parent dirs
226
+ - Quarantine / `save_all` CSV exports go through `write_frame` (same sanitise + encoding path)
227
+ - Pandas 3 compatibility: detectors recognise default ``str`` / ``string`` dtypes (not only ``object``)
228
+
229
+ ## [0.1.0] — 2026-07-11
230
+
231
+ ### Added
232
+
233
+ - Initial public release: profiler, detectors, rules + optional LLM planner, recipe YAML,
234
+ deterministic executor, validation/quarantine, cell-level diff, schema drift, HTML reports,
235
+ codegen, and CLI.
@@ -0,0 +1,241 @@
1
+ # Contributing to CleanFrame
2
+
3
+ Thank you for helping make messy data reproducible. This guide is short on purpose.
4
+ Read the **Invariants** section once — everything else follows from it.
5
+
6
+ CleanFrame's whole promise is: *AI writes the recipe once; pure pandas replays it
7
+ forever, deterministically.* A change that quietly breaks that promise is worse
8
+ than no change at all. The rules below exist to protect it.
9
+
10
+ ---
11
+
12
+ ## The five invariants (do not break these)
13
+
14
+ 1. **Determinism.** The same input must always produce the same recipe, the same
15
+ cleaned frame, the same diff — on any machine, in any process. That means:
16
+ - No `set` iteration where order matters, no reliance on dict ordering from an
17
+ unstable source, no `random`, no clock, no network in the core path.
18
+ - Sort before you iterate when order is observable. Break ties explicitly
19
+ (see `_canonical` in `detectors/categories.py` for the pattern).
20
+ - There is a test for this (`tests/test_executor_diff.py::test_execution_is_deterministic`).
21
+ Add one for anything new that could vary.
22
+
23
+ 2. **The LLM never touches your data.** The LLM planner (`llm.py`) sends *metadata
24
+ and value pattern sketches only* (unless the caller explicitly opts into a
25
+ sample). It returns a recipe that is parsed through the *same* `Recipe` model
26
+ the rules planner uses. If you extend the LLM path, the model must never receive
27
+ raw cell values by default, and its output must go through `Recipe.from_dict`
28
+ (which validates it) — never straight to the executor.
29
+
30
+ 3. **Recipes round-trip losslessly and idempotently.** `Recipe.from_yaml(r.to_yaml())`
31
+ must equal `r`, and applying `to_yaml` twice must be byte-identical. If you add
32
+ an op with parameters, you must add a `coerce`/`compact` pair such that
33
+ `coerce(compact(params)) == params` (see the op registry contract below).
34
+
35
+ 4. **Nothing is silently imputed or dropped.** Missing values are *reported*, never
36
+ filled unless a human puts `fill_na` in the recipe. Validation failures go to a
37
+ *quarantine* frame with a reason — never deleted. Outliers are flagged, never
38
+ "fixed." When you must bound coverage (top-N, sampling), surface it. Values an op
39
+ cannot parse are counted and warned about, not quietly turned into nulls.
40
+
41
+ This extends to *inputs*: a misspelled op parameter, an unknown validation check
42
+ and a duplicate YAML key are all rejected at load time. If you add a parameter,
43
+ name it in the op's signature (or in `aliases=`) so the loader accepts it — a
44
+ parameter the loader silently ignores is a data-loss bug.
45
+
46
+ 5. **Every changed cell is tracked.** The executor assigns a stable row id and
47
+ tracks column lineage so `CellDiff` can attribute every change. If you add a
48
+ transform that adds/removes/reorders columns or rows, make sure the lineage in
49
+ `executor.py` stays correct and the diff still reconciles.
50
+
51
+ If a change would weaken one of these, open an issue to discuss it first.
52
+
53
+ ---
54
+
55
+ ## Architecture in one screen
56
+
57
+ ```
58
+ DataFrame ─▶ profile.py ─▶ detectors/ ─▶ planner.py ─▶ Recipe (recipe.py)
59
+ (semantic (Issues + (assemble + │
60
+ types) Proposals) order + gate) ▼
61
+ executor.py (pure pandas)
62
+
63
+ ┌──────────────────────────────┼───────────────┐
64
+ ▼ ▼ ▼ ▼
65
+ validate.py diff.py drift.py report.py
66
+ (quarantine) (cell diff) (replay guard) (HTML)
67
+ ```
68
+
69
+ - **`ops.py`** — the vocabulary. Every recipe op is a pure function of a pandas
70
+ object registered here. The deterministic heart.
71
+ - **`detectors/`** — plugins that find problems and *propose* fixes (ops + renames).
72
+ Domain knowledge lives here.
73
+ - **`planner.py`** — turns proposals into a recipe: applies mode policy, resolves
74
+ renames, and — critically — orders ops via `OP_ORDER`.
75
+ - **`executor.py`** — replays a recipe in fixed phases with lineage tracking.
76
+ - **`recipe.py` / `schema.py`** — the durable, human-reviewable artifacts.
77
+ - **`llm.py`** — optional; writes a recipe from metadata only.
78
+
79
+ The dependency direction is strictly downward (detectors import ops, planner
80
+ imports detectors, etc.). Don't introduce an upward import — it will create a
81
+ cycle. When you need a lower layer from a higher one at call time, use a local
82
+ import inside the function (there are a few examples already).
83
+
84
+ ---
85
+
86
+ Demo data lives in `examples/messy_customers.csv` — plus a sample schema and
87
+ recipe (`customer.schema.yaml`, `customer.recipe.yaml`). Full docs:
88
+ [`docs/`](docs/) and the [Wiki](https://github.com/inboxpraveen/Cleanframe/wiki).
89
+
90
+ ---
91
+
92
+ ## Getting set up
93
+
94
+ ```bash
95
+ git clone https://github.com/inboxpraveen/Cleanframe.git
96
+ cd Cleanframe
97
+ pip install -e ".[dev]" # editable install + pytest + openpyxl + ruff + mypy
98
+ pytest # full suite, runs in a few seconds
99
+ ruff check cleanframe tests # lint
100
+ mypy # the package ships py.typed, so this must stay clean
101
+ ```
102
+
103
+ The distribution is published as `cleanframe-engine`; the import package is
104
+ `cleanframe`.
105
+
106
+ Windows note: the code and tests handle currency symbols (`₹`, `€`). If your
107
+ console mangles them, run with `PYTHONUTF8=1`. The CLI sets UTF-8 output itself.
108
+
109
+ ---
110
+
111
+ ## The most common contribution: a new detector
112
+
113
+ Detectors are the point of the plugin system — the community owns the long tail of
114
+ messy-data weirdness. A detector is ~15 lines.
115
+
116
+ ```python
117
+ # cleanframe/detectors/iban.py
118
+ import re
119
+ import pandas as pd
120
+ from ..issues import Issues
121
+ from ..types import Op, Severity
122
+ from .base import DetectorContext, detector
123
+
124
+ IBAN_RE = re.compile(r"^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$")
125
+
126
+ @detector("iban", priority=45) # lower priority runs earlier
127
+ def detect_iban(series: pd.Series, ctx: DetectorContext) -> Issues:
128
+ """One-line summary — shows up in `cleanframe detectors`."""
129
+ issues = Issues()
130
+ cp = ctx.column_profile
131
+ if cp is None or cp.semantic_type not in ("text", "id"):
132
+ return issues # cheap early-out for irrelevant columns
133
+ bad = [v for v in series.dropna() if isinstance(v, str) and not IBAN_RE.match(v.replace(" ", ""))]
134
+ if bad:
135
+ issues.add(
136
+ "invalid_iban",
137
+ f"{len(bad)} value(s) are not valid IBANs",
138
+ severity=Severity.WARNING,
139
+ confidence=1.0,
140
+ evidence={"count": len(bad), "examples": bad[:5]},
141
+ ops=[Op("remove_symbols", {"symbols": [" "]})], # optional proposed fix
142
+ )
143
+ return issues
144
+ ```
145
+
146
+ Then register it by importing the module in `cleanframe/detectors/__init__.py`.
147
+
148
+ Rules for a good detector:
149
+
150
+ - **Return `Issues`.** Use `issues.add(...)`; attach a fix with `ops=[...]` and/or
151
+ `rename_to=...` only when you're confident. `confidence` gates inclusion by mode
152
+ (`strict` needs ≥ 0.85, `auto` ≥ 0.65, `review` ≥ 0.5).
153
+ - **Be deterministic.** Sort before iterating; break ties explicitly. Never let row
154
+ order change your output (test it — feed the column shuffled and assert the same
155
+ proposal).
156
+ - **Don't over-reach.** Propose only *safe, reversible* fixes automatically. Domain
157
+ guesses (e.g. "BLR means Bangalore") belong to the LLM or the human, not rules.
158
+ - **Early-out** on columns that aren't yours (check `ctx.column_profile.semantic_type`).
159
+ - The signature can be `(series)` or `(series, ctx)` — the runner passes `ctx` if you
160
+ declare it. Frame-level detectors use `@detector("name", scope="frame")` and take
161
+ the whole `df`.
162
+
163
+ Add a test in `tests/test_profile_detectors.py` asserting your detector fires (and
164
+ does *not* fire on clean data).
165
+
166
+ ---
167
+
168
+ ## Adding an op (a recipe transform)
169
+
170
+ Ops must be pure and deterministic. If your op takes parameters, you owe a
171
+ `coerce`/`compact` pair so recipes stay minimal *and* round-trip losslessly.
172
+
173
+ ```python
174
+ @register_op(
175
+ "titlecase_words",
176
+ scope="column", # or "frame"
177
+ coerce=lambda raw: {"min_len": (raw or {}).get("min_len", 2)}, # recipe form -> params
178
+ compact=lambda p: _prune(p, {"min_len": 2}), # params -> minimal recipe form
179
+ )
180
+ def titlecase_words(series, min_len=2):
181
+ """Docstring first line shows in `cleanframe ops`."""
182
+ return _apply_str(series, lambda s: " ".join(w.title() if len(w) >= min_len else w for w in s.split()))
183
+ ```
184
+
185
+ Then, if the planner should ever emit it, add its name to **`OP_ORDER`** in
186
+ `planner.py` at the correct position. `OP_ORDER` is the single source of truth for
187
+ op sequencing — getting the position right is how independently-written detectors
188
+ compose safely (whitespace before categories, currency split before symbol
189
+ stripping, casing last). If your op isn't ordered, it runs after all ordered ops.
190
+
191
+ Column ops return a `Series` (or a `ColumnOpResult` if they also emit new columns —
192
+ see `extract_currency`). Frame ops return a `DataFrame` and **must preserve the row
193
+ index** of surviving rows, or the cell diff will misattribute changes.
194
+
195
+ Required test: `coerce(compact(params)) == params` (the parametrized test in
196
+ `tests/test_ops.py` will pick your op up automatically if you give it a sample).
197
+
198
+ ---
199
+
200
+ ## Adding a validator
201
+
202
+ ```python
203
+ from cleanframe.validate import validator
204
+
205
+ @validator("valid_iban")
206
+ def _valid_iban(series):
207
+ return series.isna() | series.astype(str).str.replace(" ", "").str.match(IBAN_RE) # True = passes
208
+ ```
209
+
210
+ Return a boolean pass-mask (`True` means the value is fine; NaN should usually
211
+ pass — `not_null` is the check for missingness). `on_fail` policy is handled for
212
+ you.
213
+
214
+ ---
215
+
216
+ ## Testing & quality bar
217
+
218
+ - **Every PR keeps `pytest` green.** New behavior needs a new test.
219
+ - **Test the invariant, not just the happy path.** For anything order-sensitive, add
220
+ a determinism assertion. For a new op with params, add the round-trip.
221
+ - Prefer small, focused tests using the fixtures in `tests/conftest.py`.
222
+ - `ruff check cleanframe tests` and `mypy` must both pass — CI runs them.
223
+
224
+ ## Style
225
+
226
+ - Match the surrounding code: type hints, `from __future__ import annotations`,
227
+ docstrings that explain *why* (the reader can see *what*).
228
+ - Target Python 3.10+. Keep the core dependencies minimal (pandas, numpy, pyyaml,
229
+ jinja2). Anything heavier goes in an optional extra in `pyproject.toml`.
230
+ - No `print` in library code — return data or `log` to a list. `print` is for the
231
+ CLI only.
232
+
233
+ ## Pull requests
234
+
235
+ 1. Branch from `main`. One logical change per PR.
236
+ 2. Describe the user-visible behavior change and which invariant(s) you considered.
237
+ 3. If you touched the recipe format, the executor, or `OP_ORDER`, say so
238
+ prominently — those are the load-bearing walls.
239
+ 4. Be kind in review. We're all here to make one messy CSV less painful.
240
+
241
+ Questions or a detector idea? Open an issue tagged `good-first-detector`.