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.
- cleanframe_engine-0.3.0/.gitignore +101 -0
- cleanframe_engine-0.3.0/CHANGELOG.md +235 -0
- cleanframe_engine-0.3.0/CONTRIBUTING.md +241 -0
- cleanframe_engine-0.3.0/LICENSE +201 -0
- cleanframe_engine-0.3.0/PKG-INFO +323 -0
- cleanframe_engine-0.3.0/README.md +273 -0
- cleanframe_engine-0.3.0/SECURITY.md +48 -0
- cleanframe_engine-0.3.0/cleanframe/__init__.py +169 -0
- cleanframe_engine-0.3.0/cleanframe/__main__.py +5 -0
- cleanframe_engine-0.3.0/cleanframe/_util.py +438 -0
- cleanframe_engine-0.3.0/cleanframe/_version.py +1 -0
- cleanframe_engine-0.3.0/cleanframe/api.py +559 -0
- cleanframe_engine-0.3.0/cleanframe/cli.py +688 -0
- cleanframe_engine-0.3.0/cleanframe/codegen.py +666 -0
- cleanframe_engine-0.3.0/cleanframe/dataio.py +506 -0
- cleanframe_engine-0.3.0/cleanframe/detectors/__init__.py +43 -0
- cleanframe_engine-0.3.0/cleanframe/detectors/base.py +221 -0
- cleanframe_engine-0.3.0/cleanframe/detectors/categories.py +199 -0
- cleanframe_engine-0.3.0/cleanframe/detectors/contacts.py +105 -0
- cleanframe_engine-0.3.0/cleanframe/detectors/currency.py +112 -0
- cleanframe_engine-0.3.0/cleanframe/detectors/dates.py +204 -0
- cleanframe_engine-0.3.0/cleanframe/detectors/dedup.py +150 -0
- cleanframe_engine-0.3.0/cleanframe/detectors/nulls.py +109 -0
- cleanframe_engine-0.3.0/cleanframe/detectors/outliers.py +73 -0
- cleanframe_engine-0.3.0/cleanframe/detectors/schema_mapping.py +125 -0
- cleanframe_engine-0.3.0/cleanframe/detectors/text.py +105 -0
- cleanframe_engine-0.3.0/cleanframe/detectors/units.py +86 -0
- cleanframe_engine-0.3.0/cleanframe/diff.py +369 -0
- cleanframe_engine-0.3.0/cleanframe/drift.py +283 -0
- cleanframe_engine-0.3.0/cleanframe/errors.py +66 -0
- cleanframe_engine-0.3.0/cleanframe/executor.py +229 -0
- cleanframe_engine-0.3.0/cleanframe/fingerprint.py +83 -0
- cleanframe_engine-0.3.0/cleanframe/issues.py +186 -0
- cleanframe_engine-0.3.0/cleanframe/llm.py +811 -0
- cleanframe_engine-0.3.0/cleanframe/ops.py +1245 -0
- cleanframe_engine-0.3.0/cleanframe/planner.py +353 -0
- cleanframe_engine-0.3.0/cleanframe/profile.py +413 -0
- cleanframe_engine-0.3.0/cleanframe/py.typed +1 -0
- cleanframe_engine-0.3.0/cleanframe/quality.py +81 -0
- cleanframe_engine-0.3.0/cleanframe/readfix.py +160 -0
- cleanframe_engine-0.3.0/cleanframe/recipe.py +398 -0
- cleanframe_engine-0.3.0/cleanframe/report.py +345 -0
- cleanframe_engine-0.3.0/cleanframe/result.py +144 -0
- cleanframe_engine-0.3.0/cleanframe/schema.py +259 -0
- cleanframe_engine-0.3.0/cleanframe/streaming.py +354 -0
- cleanframe_engine-0.3.0/cleanframe/types.py +119 -0
- cleanframe_engine-0.3.0/cleanframe/validate.py +363 -0
- cleanframe_engine-0.3.0/cleanframe/workbook.py +370 -0
- cleanframe_engine-0.3.0/docs/README.md +24 -0
- cleanframe_engine-0.3.0/docs/api-reference.md +284 -0
- cleanframe_engine-0.3.0/docs/architecture.md +63 -0
- cleanframe_engine-0.3.0/docs/cli.md +207 -0
- cleanframe_engine-0.3.0/docs/concepts.md +105 -0
- cleanframe_engine-0.3.0/docs/detectors-and-ops.md +76 -0
- cleanframe_engine-0.3.0/docs/faq.md +119 -0
- cleanframe_engine-0.3.0/docs/getting-started.md +142 -0
- cleanframe_engine-0.3.0/docs/installation.md +105 -0
- cleanframe_engine-0.3.0/docs/llm.md +121 -0
- cleanframe_engine-0.3.0/docs/production.md +210 -0
- cleanframe_engine-0.3.0/docs/recipe-spec.md +299 -0
- cleanframe_engine-0.3.0/docs/schema-spec.md +115 -0
- cleanframe_engine-0.3.0/examples/customer.recipe.yaml +91 -0
- cleanframe_engine-0.3.0/examples/customer.schema.yaml +31 -0
- cleanframe_engine-0.3.0/examples/messy_customers.csv +7 -0
- cleanframe_engine-0.3.0/pyproject.toml +108 -0
- cleanframe_engine-0.3.0/tests/conftest.py +50 -0
- cleanframe_engine-0.3.0/tests/test_api_report_cli.py +175 -0
- cleanframe_engine-0.3.0/tests/test_audit_hardening.py +258 -0
- cleanframe_engine-0.3.0/tests/test_cross_platform.py +90 -0
- cleanframe_engine-0.3.0/tests/test_drift_codegen.py +56 -0
- cleanframe_engine-0.3.0/tests/test_executor_diff.py +75 -0
- cleanframe_engine-0.3.0/tests/test_llm_schema.py +179 -0
- cleanframe_engine-0.3.0/tests/test_ops.py +156 -0
- cleanframe_engine-0.3.0/tests/test_planner.py +88 -0
- cleanframe_engine-0.3.0/tests/test_production_safety.py +86 -0
- cleanframe_engine-0.3.0/tests/test_profile_detectors.py +136 -0
- cleanframe_engine-0.3.0/tests/test_recipe.py +90 -0
- cleanframe_engine-0.3.0/tests/test_release_hardening.py +717 -0
- cleanframe_engine-0.3.0/tests/test_validate.py +97 -0
- cleanframe_engine-0.3.0/tests/test_wave1_codegen.py +104 -0
- cleanframe_engine-0.3.0/tests/test_wave1_correctness.py +104 -0
- cleanframe_engine-0.3.0/tests/test_wave1_hardening.py +125 -0
- cleanframe_engine-0.3.0/tests/test_wave1_lineage.py +75 -0
- cleanframe_engine-0.3.0/tests/test_wave1_llm.py +120 -0
- cleanframe_engine-0.3.0/tests/test_wave2_scaling.py +72 -0
- cleanframe_engine-0.3.0/tests/test_wave2_vectorization.py +79 -0
- cleanframe_engine-0.3.0/tests/test_wave3_selection.py +93 -0
- cleanframe_engine-0.3.0/tests/test_wave3_workbook.py +84 -0
- cleanframe_engine-0.3.0/tests/test_wave4_readfix.py +64 -0
- 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`.
|