cleanframe-engine 0.3.0__tar.gz → 0.4.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 (107) hide show
  1. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/CHANGELOG.md +162 -0
  2. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/CONTRIBUTING.md +29 -3
  3. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/PKG-INFO +40 -16
  4. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/README.md +35 -15
  5. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/SECURITY.md +13 -6
  6. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/cleanframe/__init__.py +8 -0
  7. cleanframe_engine-0.4.0/cleanframe/_numparse.py +233 -0
  8. cleanframe_engine-0.4.0/cleanframe/_textparse.py +110 -0
  9. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/cleanframe/_util.py +71 -2
  10. cleanframe_engine-0.4.0/cleanframe/_version.py +1 -0
  11. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/cleanframe/api.py +110 -26
  12. cleanframe_engine-0.4.0/cleanframe/checks.py +127 -0
  13. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/cleanframe/cli.py +214 -22
  14. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/cleanframe/codegen.py +202 -121
  15. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/cleanframe/dataio.py +291 -6
  16. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/cleanframe/detectors/categories.py +33 -2
  17. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/cleanframe/detectors/currency.py +67 -14
  18. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/cleanframe/detectors/dates.py +2 -4
  19. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/cleanframe/detectors/dedup.py +11 -1
  20. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/cleanframe/detectors/text.py +41 -0
  21. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/cleanframe/diff.py +27 -0
  22. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/cleanframe/drift.py +56 -0
  23. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/cleanframe/executor.py +32 -5
  24. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/cleanframe/fingerprint.py +11 -7
  25. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/cleanframe/llm.py +88 -25
  26. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/cleanframe/ops.py +341 -155
  27. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/cleanframe/planner.py +3 -1
  28. cleanframe_engine-0.4.0/cleanframe/plugins.py +87 -0
  29. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/cleanframe/profile.py +3 -27
  30. cleanframe_engine-0.4.0/cleanframe/readfix.py +310 -0
  31. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/cleanframe/recipe.py +30 -4
  32. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/cleanframe/report.py +11 -6
  33. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/cleanframe/streaming.py +253 -60
  34. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/cleanframe/types.py +24 -4
  35. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/cleanframe/validate.py +12 -86
  36. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/cleanframe/workbook.py +37 -2
  37. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/docs/README.md +2 -1
  38. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/docs/api-reference.md +31 -11
  39. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/docs/architecture.md +10 -3
  40. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/docs/cli.md +32 -2
  41. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/docs/detectors-and-ops.md +28 -2
  42. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/docs/faq.md +3 -1
  43. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/docs/getting-started.md +1 -0
  44. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/docs/installation.md +31 -2
  45. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/docs/llm.md +27 -11
  46. cleanframe_engine-0.4.0/docs/plugins.md +119 -0
  47. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/docs/production.md +70 -24
  48. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/docs/recipe-spec.md +43 -18
  49. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/pyproject.toml +6 -2
  50. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/tests/test_audit_hardening.py +8 -9
  51. cleanframe_engine-0.4.0/tests/test_cli_json_logging.py +63 -0
  52. cleanframe_engine-0.4.0/tests/test_docs_in_sync.py +49 -0
  53. cleanframe_engine-0.4.0/tests/test_fix_llm_trust.py +149 -0
  54. cleanframe_engine-0.4.0/tests/test_fix_numbers_and_parity.py +415 -0
  55. cleanframe_engine-0.4.0/tests/test_fix_reading.py +287 -0
  56. cleanframe_engine-0.4.0/tests/test_fix_streaming.py +148 -0
  57. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/tests/test_llm_schema.py +2 -2
  58. cleanframe_engine-0.4.0/tests/test_plugins.py +149 -0
  59. cleanframe_engine-0.4.0/tests/test_properties.py +212 -0
  60. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/tests/test_release_hardening.py +10 -0
  61. cleanframe_engine-0.4.0/tests/test_review_round2.py +257 -0
  62. cleanframe_engine-0.4.0/tests/test_unique_mapping.py +91 -0
  63. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/tests/test_wave1_hardening.py +2 -3
  64. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/tests/test_wave2_vectorization.py +1 -2
  65. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/tests/test_wave5_streaming.py +5 -2
  66. cleanframe_engine-0.3.0/cleanframe/_version.py +0 -1
  67. cleanframe_engine-0.3.0/cleanframe/readfix.py +0 -160
  68. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/.gitignore +0 -0
  69. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/LICENSE +0 -0
  70. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/cleanframe/__main__.py +0 -0
  71. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/cleanframe/detectors/__init__.py +0 -0
  72. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/cleanframe/detectors/base.py +0 -0
  73. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/cleanframe/detectors/contacts.py +0 -0
  74. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/cleanframe/detectors/nulls.py +0 -0
  75. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/cleanframe/detectors/outliers.py +0 -0
  76. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/cleanframe/detectors/schema_mapping.py +0 -0
  77. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/cleanframe/detectors/units.py +0 -0
  78. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/cleanframe/errors.py +0 -0
  79. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/cleanframe/issues.py +0 -0
  80. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/cleanframe/py.typed +0 -0
  81. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/cleanframe/quality.py +0 -0
  82. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/cleanframe/result.py +0 -0
  83. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/cleanframe/schema.py +0 -0
  84. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/docs/concepts.md +0 -0
  85. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/docs/schema-spec.md +0 -0
  86. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/examples/customer.recipe.yaml +0 -0
  87. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/examples/customer.schema.yaml +0 -0
  88. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/examples/messy_customers.csv +0 -0
  89. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/tests/conftest.py +0 -0
  90. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/tests/test_api_report_cli.py +0 -0
  91. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/tests/test_cross_platform.py +0 -0
  92. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/tests/test_drift_codegen.py +0 -0
  93. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/tests/test_executor_diff.py +0 -0
  94. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/tests/test_ops.py +0 -0
  95. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/tests/test_planner.py +0 -0
  96. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/tests/test_production_safety.py +0 -0
  97. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/tests/test_profile_detectors.py +0 -0
  98. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/tests/test_recipe.py +0 -0
  99. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/tests/test_validate.py +0 -0
  100. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/tests/test_wave1_codegen.py +0 -0
  101. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/tests/test_wave1_correctness.py +0 -0
  102. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/tests/test_wave1_lineage.py +0 -0
  103. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/tests/test_wave1_llm.py +0 -0
  104. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/tests/test_wave2_scaling.py +0 -0
  105. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/tests/test_wave3_selection.py +0 -0
  106. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/tests/test_wave3_workbook.py +0 -0
  107. {cleanframe_engine-0.3.0 → cleanframe_engine-0.4.0}/tests/test_wave4_readfix.py +0 -0
@@ -7,6 +7,162 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] — 2026-09-30
11
+
12
+ A correctness release. An empirical review (26 hard-input probes, a 1M/3M-row benchmark and a
13
+ docs audit that ran every example) found places where CleanFrame produced a **wrong number, a
14
+ wrong merge or a wrong file with no warning**. Every one is fixed, each has a regression test, and
15
+ the invariants that should have caught them now have property-based tests.
16
+
17
+ ### Fixed — silent wrong results
18
+
19
+ - **A new number format no longer replays silently.** Replaying a `.`-decimal recipe on `€1.200,50`
20
+ produced `1.2005`, exit 0, no warning. Numbers are now parsed by structure (below), and `apply_recipe`
21
+ raises a `number_format_drift` finding (exit 3) when digit-bearing values stop fitting the recipe's
22
+ `decimal`/`thousands` convention (a small share is INFO, so a stray `12ab34` is not a stop).
23
+ - **Signs were dropped.** `-$5`, `-€3.50`, `-₹500` and `$(8)` parsed as positives. `parse_number` now finds
24
+ the sign wherever it is written (`-5`, `-$5`, `$-5`, `5-`, `(5)`, `$(5)`, unicode minus).
25
+ - **`€2,5` became 25 and `€999,99` became 99999** whenever one value in the column looked US-grouped.
26
+ Grouping is now validated (3-digit or Indian lakh) and the currency detector infers comma-decimal only
27
+ from unambiguous evidence; values that fit neither convention become a counted, warned null.
28
+ - **The category detector merged different numbers**, in every mode including `strict`: `-1,200`→`1,200`,
29
+ `12.00`→`1,200`, `inf`→`-inf`, `A1`→`A-1`. Values that differ in sign, decimal or grouping are never one category.
30
+ - **`normalize_unit` read `1,500 g` as 1.5 g** (1000x off). Ambiguous numbers are refused (null + warning);
31
+ `1,5 kg` and `0.500 kg` still parse, and `1.5e3 g` now does too.
32
+ - **`cast: int` rounded IDs above 2**53** through float64; integer text is now converted exactly.
33
+ - **The cell diff missed precision loss** (`9007199254740993` → `...992.0`) and `1` → `True`, because numpy
34
+ compared `int64` with `float64` after promotion. Both are now reported.
35
+ - **`R$`, `A$`, `C$`, `HK$`, `NZ$`, `S$` were all labelled USD**: symbols are matched longest-first, and `₺ ₫ ₱ ₪ ฿` were added.
36
+ - **Auto-dedup treated `1`, `True` and `1.0` as the same value**, and collapsed a one-column frame to its
37
+ distinct values. Booleans stay distinct from numbers, and one-column frames are reported, not deduplicated.
38
+ - **Offset timestamps were shifted to the UTC calendar day** (`2024-01-01T02:00+05:30` → `2023-12-31`); a
39
+ single-offset column now keeps its wall-clock date.
40
+ - **`title_case` produced `3Rd Street` and `Don'T`**, and **`normalize_phone` turned `0044…` into `+91442…` and `12345` into
41
+ `+9112345`**. Ordinals and apostrophes are handled; `00` is read as `+`; numbers too short to be phones never get a country code invented.
42
+ - **Any non-UTF-8 file was accepted as latin-1** (Shift-JIS became mojibake with exit 0). The encoding ladder
43
+ can now refuse: a file that is neither UTF-8, cp1252 nor plausibly a single-byte Latin text raises with the likely
44
+ encodings and `--encoding` advice.
45
+ - **`read_frame` silently truncated `a\x00b` to `a`**; it now raises like `clean`.
46
+ - **Generated code diverged from the executor**: `05/01/2024` read as Jan 5 instead of May 1, offset timestamps
47
+ crashed it, `to_na case_insensitive: false` was ignored, `normalize_values` skipped non-string cells, integer column
48
+ labels and missing columns raised `KeyError`. Exported pandas now **embeds the executor's own helper source**
49
+ for numbers, units, ints, dates and title case, and skips missing columns like the executor.
50
+ - **`stream_apply` leaked raw pandas errors** on a late chunk that did not fit the recorded dtypes (exit 70), and its
51
+ output could differ from `apply_recipe` (`2` vs `2.0`, drift missed beyond the first 200 rows). It now makes a first
52
+ bounded-memory pass, checks drift across every row, and writes byte-identical output or refuses with a named error.
53
+ Streaming also stops writing a quarantine file nobody asked for.
54
+ - **`suggest --update` "learned" a competing reading of dates that already parse** (`03/03/2026` → `%m/%d/%Y`) and could
55
+ not fix the README's own `Jan 5, 26` example. It now learns only from values the recipe cannot parse.
56
+ - **YAML 1.1 traps in hand-written recipes.** `yes:`, `no:`, `null:`, `010:`, `12:30:` keys were read as `True`, `False`,
57
+ `None`, `8`, `750` and the column was skipped with only a warning. Plain keys now stay text; `to_na: [no, null]` is refused
58
+ with a "quote it" hint.
59
+ - **`cleanframe clean` silently overwrote a hand-edited recipe.** It now refuses to replace a recipe that differs from
60
+ the one just planned unless `--overwrite` is given (re-running over unchanged input is still fine).
61
+ - `Recipe.from_yaml(r.to_yaml()) == r` was `False` for every generated recipe: equality now compares the canonical serialisation.
62
+ - **Money in `1.234` shape is never guessed.** With no other evidence `€1.234` could be 1234 (German) or 1.234 (Irish); the currency detector now reports `ambiguous_number_format` and proposes nothing, instead of picking one and being 1000x wrong.
63
+ - **Currency codes:** an explicit code beats a symbol (`$5 MXN` is pesos), `$`-family symbols glued to letters are no longer mislabelled (`AR$`, `MX$`, `NT$`, `CL$`, `CO$` added; an unknown `XY$5` is "no currency", not USD), `Rs` is INR, and more ISO codes are recognised.
64
+ - **`normalize_unit` recognises the spellings people write** (`lbs`, `pounds`, `kgs`, `ounces`, `inches`, `feet`, `gallons`, …).
65
+ - **A deliberate `to_na` was reported as "values became missing because an op could not parse them"**; it is now logged as intentional and only real parse failures warn.
66
+ - **A derived column could be named `None_currency`** when `extract_currency` followed an op that returned an unnamed Series; op results keep the column name.
67
+ - **`stream_apply` silently dropped the extra field of a row wider than the header** (pandas' chunked reader truncates it); streaming now refuses such a file, as `apply_recipe` does.
68
+ - `dedup` / `drop_columns` accept numeric-looking column names (`subset: [2024]`); `on_fail: null` explains the YAML trap; the Excel writer warns when it removes illegal control characters; `read_frame` on a `;`-delimited file with commas in the text says "semicolon-delimited" instead of suggesting `header_row`; a lone `-`/`+` is no longer formula-escaped; a percent or currency symbol keeps `10 %` distinct from `10` in category clustering.
69
+
70
+ ### Security & trust
71
+
72
+ - **`OPENAI_API_KEY` is no longer sent to other providers.** Each provider reads only its own key variable.
73
+ - **LLM plans can never contain `fill_na` or `drop_columns`** (any mode); they are stripped with a warning, closing a
74
+ hole in the "nothing silently imputed or dropped" invariant. An LLM recipe is also dry-run on the first rows and falls back to the rules planner if it cannot run.
75
+ - `sample` exposure is documented as what it is (values partly redacted, no approval step) and warns which columns are sent; `llm_exposure` typos raise a clear error; `max_tokens_budget` without `llm=` warns.
76
+ - ReDoS-prone `replace` patterns and regex back-references to missing groups are refused **at load**.
77
+
78
+ ### Added
79
+
80
+ - **Plugins that travel.** `cleanframe.plugins`: entry points (`cleanframe.plugins` group), `CLEANFRAME_PLUGINS`,
81
+ `--plugin MODULE`, `cf.load_plugins()`, and lazy discovery when a recipe names an unknown op.
82
+ `register_op(codegen=, helpers=, streamable=)` lets a custom op export as standalone pandas and stream (default-deny). See the new plugin guide.
83
+ - **`--json`**: one machine-readable summary object on stdout (human output to stderr) for every command, on success and failure — including usage errors (`status: usage_error`, exit 2), and `ops`/`detectors` list themselves. A workbook `apply --chunksize` is now refused instead of ignored.
84
+ - **`normalize_unicode` op + `unicode` detector**: NFC-normalises text and removes zero-width spaces, word joiners, BOMs and non-breaking spaces (zero-width joiners are kept). It fixes `café` typed two ways splitting a category, and invisible characters that `strip` leaves behind.
85
+ - **Logging**: the library logs through `logging.getLogger("cleanframe")` (silent `NullHandler`); `--verbose` enables INFO.
86
+ - **`header_row=` / `--header-row`** for files with title rows above the header, with a suggested value when the header looks misplaced; read-time warnings for ragged rows and `Total` footer rows; hidden Excel sheets stay hidden on write-back; warnings for uncached formulas and merged cells.
87
+ - **Property-based tests (Hypothesis)** for the parser, generated-code parity, determinism, recipe round-trip, "nothing silently dropped" and streaming parity; `scripts/sync_wiki.py` + a test that keeps `docs/` and `wiki/` identical.
88
+ - More date formats (`%b %d, %y`, `%B %d, %y`, `%d %b, %Y`, `%d.%m.%y`, `%Y.%m.%d`) — appended, so existing recipes are unaffected.
89
+ - A `detect` extra (`charset-normalizer`) that ranks candidate encodings in the refusal message.
90
+ - Unknown `options=` keys warn with a did-you-mean.
91
+
92
+ ### Performance
93
+
94
+ - Pure text ops (`parse_number`, `normalize_unit`, `normalize_phone`, `normalize_values`, `extract_currency`, explicit-format `parse_date`, and the email/url/phone/regex validators) now run **once per distinct value** of an all-string column and fan the results out — identical output, 15-25x faster on repetitive columns (1M rows: `parse_number` 4.5 s → 0.17 s, `normalize_unit` 3.8 s → 0.26 s). Measured end to end on a realistic 18-column, 1M-row file: `clean()` 42 s → 29 s, replay 28 s → 16 s.
95
+ - Peak memory is unchanged and is now documented honestly: the frame plus the op-touched columns, about 2x when most columns are cleaned. Use `stream_apply` for row-independent recipes on files that do not fit.
96
+
97
+ ### Changed
98
+
99
+ - **Behaviour changes worth knowing when upgrading:**
100
+ - Numbers with invalid or ambiguous grouping now become a warned null instead of a guess (`2,5` under `thousands=","`, `1,500 g`).
101
+ - Streaming reads the file twice, and writes a quarantine file only when `quarantine_path` is given.
102
+ - Offset timestamps keep their wall-clock date; mixed-offset columns are converted to UTC.
103
+ - `title_case` no longer capitalises after apostrophes or digits; `normalize_phone` treats `00` as `+`.
104
+ - `to_na` tokens, `replace` pattern/repl and `parse_number` separators must be text; `version: true` and unknown option keys are rejected.
105
+ - `Recipe` defines `__eq__` on its canonical form (it was already unhashable).
106
+ - Streaming `apply` measured at ~270 MB peak for 600k rows at `chunksize=50_000` (docs updated with the baseline).
107
+ - The validation check registry moved into `cleanframe.checks` (internal; removes the last import cycle).
108
+
109
+ ## [0.3.1] — 2026-09-04
110
+
111
+ First follow-up to the 0.3.0 release, from the CI and code-scanning results that
112
+ only appear once a project is public.
113
+
114
+ ### Fixed
115
+
116
+ - **`Severity` comparisons were alphabetical, not ordered.** Only `__lt__` was
117
+ defined, so the `str` mixin answered everything else: `Severity.ERROR >
118
+ Severity.INFO` returned `False` because `"error" < "info"` as text. All four
119
+ comparisons are now defined on the severity rank. Library code always used
120
+ `.rank` explicitly, so nothing internal was affected, but any caller comparing
121
+ severities directly got wrong answers. Found by CodeQL's incomplete-ordering
122
+ query.
123
+ - **`mypy` failed in CI** with `numpy/__init__.pyi:737: error: Type statement is
124
+ only supported in Python 3.12 and greater`. The `python_version = "3.10"` pin
125
+ also governs how dependency stubs are parsed, and numpy 2.5's stubs use syntax
126
+ that needs 3.12. Removed the pin; minimum-version support is still checked by
127
+ ruff's `target-version` and by the Python 3.10 test job.
128
+ - **The wiki sync workflow could not push.** GitHub Actions' built-in token can
129
+ clone a wiki but not write to it, so the job committed and then failed on
130
+ authentication. It now requires the `WIKI_TOKEN` secret and stops up front with
131
+ an explanatory message. `wiki/README.md` claimed no token was needed; corrected.
132
+ - Two module-level import cycles removed: `profile` with `ops`, and `recipe` with
133
+ `validate`. `COMMON_DATE_FORMATS` now lives in `ops`, which owns date parsing,
134
+ and is re-exported from `profile` so every existing import keeps working.
135
+ `validate` imports `ValidationRule` for annotations only.
136
+ - Empty exception handlers in `fingerprint` and `report` replaced with a helper
137
+ that returns whether a cell is missing, so the intent is in the code rather
138
+ than in a comment beside `pass`.
139
+ - Redundant function-level `import re` in the dates detector removed; it now
140
+ reuses the module's compiled pattern instead of recompiling per call.
141
+
142
+ ### Changed
143
+
144
+ - Workflow actions updated: checkout to v7, setup-python to v7, upload-artifact
145
+ to v7, download-artifact to v8, codeql-action to v4. Clears the Node 20
146
+ deprecation warnings.
147
+ - Dependabot groups the `github-actions` ecosystem, so action bumps arrive as one
148
+ pull request instead of one per action.
149
+ - CodeQL analyses the library and skips `tests`. Test fixtures are deliberately
150
+ hostile (catastrophic-backtracking patterns, injection payloads, handlers that
151
+ assert something must not raise) and are not part of the wheel, so reporting
152
+ them buries real findings. Configuration lives in
153
+ `.github/codeql/codeql-config.yml`.
154
+ - Protocol methods on `LLMClient` and `Planner` carry a docstring instead of a
155
+ bare `...`.
156
+ - Installation docs link the package page, show how to pin a version, and mention
157
+ `cleanframe --version`. `CHANGELOG.md` gained the Keep a Changelog link
158
+ definitions now that releases are tagged.
159
+
160
+ ### Verified
161
+
162
+ 335 tests pass on Python 3.10 with the declared minimum pins (pandas 1.5.3,
163
+ numpy 1.23.5), on Python 3.13 with pandas 2.3.3, and on Python 3.13 with pandas
164
+ 3.0.5 and numpy 2.5.2, which is what a fresh `pip install` resolves today.
165
+
10
166
  ## [0.3.0] — 2026-09-04
11
167
 
12
168
  Release-readiness pass driven by a full audit of the CLI, the Python API, packaging and
@@ -233,3 +389,9 @@ now fail loudly, and a few transforms that quietly corrupted data no longer run.
233
389
  - Initial public release: profiler, detectors, rules + optional LLM planner, recipe YAML,
234
390
  deterministic executor, validation/quarantine, cell-level diff, schema drift, HTML reports,
235
391
  codegen, and CLI.
392
+
393
+ [Unreleased]: https://github.com/inboxpraveen/Cleanframe/compare/v0.3.1...HEAD
394
+ [0.3.1]: https://github.com/inboxpraveen/Cleanframe/releases/tag/v0.3.1
395
+ [0.3.0]: https://github.com/inboxpraveen/Cleanframe/releases/tag/v0.3.0
396
+ [0.2.0]: https://github.com/inboxpraveen/Cleanframe/commits/main
397
+ [0.1.0]: https://github.com/inboxpraveen/Cleanframe/commits/main
@@ -9,7 +9,7 @@ than no change at all. The rules below exist to protect it.
9
9
 
10
10
  ---
11
11
 
12
- ## The five invariants (do not break these)
12
+ ## The six invariants (do not break these)
13
13
 
14
14
  1. **Determinism.** The same input must always produce the same recipe, the same
15
15
  cleaned frame, the same diff — on any machine, in any process. That means:
@@ -43,11 +43,25 @@ than no change at all. The rules below exist to protect it.
43
43
  name it in the op's signature (or in `aliases=`) so the loader accepts it — a
44
44
  parameter the loader silently ignores is a data-loss bug.
45
45
 
46
+ It applies to *guessing* as well: when a value is ambiguous (`1,500 g` — 1500 g or
47
+ 1.5 g?) or malformed for the declared convention (`2,5` under `thousands=","`), the
48
+ result is a counted, warned null, never a plausible-looking number. Wrong numbers
49
+ are worse than missing ones. Likewise a clustering/merge rule must never fold together
50
+ values that differ in sign, decimal or grouping (`-300`/`300`).
51
+
46
52
  5. **Every changed cell is tracked.** The executor assigns a stable row id and
47
53
  tracks column lineage so `CellDiff` can attribute every change. If you add a
48
54
  transform that adds/removes/reorders columns or rows, make sure the lineage in
49
55
  `executor.py` stays correct and the diff still reconciles.
50
56
 
57
+ 6. **Exported code equals the executor — because it is the same code.** The pandas that
58
+ `result.code` writes must produce exactly what `apply_recipe` produces. Do not
59
+ re-implement an op's logic in `codegen.py`: put shared scalar logic in a small
60
+ self-contained module (`_numparse.py`, `_textparse.py`) or an existing `ops.py` helper
61
+ and *embed its source* with `inspect.getsource`, as codegen already does. Two
62
+ implementations drift; one implementation cannot. `tests/test_properties.py` fuzzes
63
+ this, and `tests/test_fix_numbers_and_parity.py` pins the known cases.
64
+
51
65
  If a change would weaken one of these, open an issue to discuss it first.
52
66
 
53
67
  ---
@@ -74,6 +88,9 @@ DataFrame ─▶ profile.py ─▶ detectors/ ─▶ planner.py ─▶ Recipe (r
74
88
  renames, and — critically — orders ops via `OP_ORDER`.
75
89
  - **`executor.py`** — replays a recipe in fixed phases with lineage tracking.
76
90
  - **`recipe.py` / `schema.py`** — the durable, human-reviewable artifacts.
91
+ - **`checks.py`** — the validation check registry, plus the guard that rejects an
92
+ unknown check name at load time. It lives apart from `validate.py` so `recipe.py`
93
+ can use the guard without an import cycle.
77
94
  - **`llm.py`** — optional; writes a recipe from metadata only.
78
95
 
79
96
  The dependency direction is strictly downward (detectors import ops, planner
@@ -218,6 +235,14 @@ you.
218
235
  - **Every PR keeps `pytest` green.** New behavior needs a new test.
219
236
  - **Test the invariant, not just the happy path.** For anything order-sensitive, add
220
237
  a determinism assertion. For a new op with params, add the round-trip.
238
+ - **Fuzz the invariants.** `tests/test_properties.py` uses [Hypothesis](https://hypothesis.readthedocs.io/)
239
+ to check parser totality/sign, generated-code == executor, determinism, recipe round-trip,
240
+ "nothing silently dropped" and streaming == whole-frame. CI runs a small deterministic
241
+ sample; to hunt for new bugs locally run
242
+ `CLEANFRAME_PROP_EXAMPLES=2000 CLEANFRAME_PROP_RANDOM=1 pytest tests/test_properties.py`.
243
+ If it finds one, add the minimal case as a normal example test as well.
244
+ - **New op?** Decide `codegen=` / `streamable=` deliberately (see the [plugin guide](docs/plugins.md));
245
+ both default to "refuse", which is safe.
221
246
  - Prefer small, focused tests using the fixtures in `tests/conftest.py`.
222
247
  - `ruff check cleanframe tests` and `mypy` must both pass — CI runs them.
223
248
 
@@ -227,8 +252,9 @@ you.
227
252
  docstrings that explain *why* (the reader can see *what*).
228
253
  - Target Python 3.10+. Keep the core dependencies minimal (pandas, numpy, pyyaml,
229
254
  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.
255
+ - No `print` in library code — return data, add to the run `log`, or use
256
+ `logging.getLogger(__name__)` (the package installs a `NullHandler`). `print` is for the
257
+ CLI only, and under `--json` the CLI keeps stdout for the one JSON summary object.
232
258
 
233
259
  ## Pull requests
234
260
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cleanframe-engine
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: The reproducible data-cleaning engine for Python. AI writes the recipe once; the recipe runs forever — deterministic, diffable, reviewable.
5
5
  Project-URL: Homepage, https://github.com/inboxpraveen/Cleanframe
6
6
  Project-URL: Repository, https://github.com/inboxpraveen/Cleanframe
@@ -29,10 +29,14 @@ Requires-Dist: python-dateutil>=2.8
29
29
  Requires-Dist: pyyaml>=6.0
30
30
  Provides-Extra: all
31
31
  Requires-Dist: anthropic>=0.30; extra == 'all'
32
+ Requires-Dist: charset-normalizer>=3; extra == 'all'
32
33
  Requires-Dist: openai>=1.0; extra == 'all'
33
34
  Requires-Dist: openpyxl>=3.0; extra == 'all'
34
35
  Requires-Dist: pyarrow>=12.0; extra == 'all'
36
+ Provides-Extra: detect
37
+ Requires-Dist: charset-normalizer>=3; extra == 'detect'
35
38
  Provides-Extra: dev
39
+ Requires-Dist: hypothesis>=6.80; extra == 'dev'
36
40
  Requires-Dist: mypy>=1.8; extra == 'dev'
37
41
  Requires-Dist: openpyxl>=3.0; extra == 'dev'
38
42
  Requires-Dist: pytest-cov>=4.0; extra == 'dev'
@@ -147,8 +151,10 @@ If the new file's schema drifted (a renamed column, a new currency format), Clea
147
151
  ```
148
152
  ⚠ Schema drift detected in new_customers.csv
149
153
  • Column "Amt (INR)" is new — 94% match to recipe column "amount_inr"
150
- • 312 values in "signup_date" match no allowed date format (new: "Jan 5, 26")
151
- Run `cleanframe suggest new_customers.csv --recipe customer.recipe.yaml --update` to review a patch.
154
+ • 312 value(s) in "signup_date" match no allowed date format (new: 'Jan 5, 26')
155
+ • 40 of 312 numeric value(s) in "Amount" do not fit the recipe's number format
156
+ (decimal '.', thousands ','; new: '€1.200,50')
157
+ Run `cleanframe suggest new_customers.csv --recipe <recipe.yaml> --update` to review a patch.
152
158
  ```
153
159
 
154
160
  ## Bring your own API key (or no key at all)
@@ -159,30 +165,34 @@ CleanFrame is LLM-optional and provider-agnostic.
159
165
  |---|---|---|
160
166
  | **Rules-only** (default) | Deterministic detectors + heuristics | Never |
161
167
  | **Metadata** | LLM sees column names, dtypes, and *value patterns* (regex sketches) — never raw values | Only metadata |
162
- | **Sample** | LLM sees an anonymized, shuffled sample you approve | Only the approved sample |
168
+ | **Sample** | LLM sees a small shuffled sample of *values*. Emails, phones and long strings are redacted; other short values (names, cities) are sent verbatim. Passing `llm_exposure="sample"` is the opt-in — there is no separate approval step | Sample values, per the redaction above |
163
169
  | **Replay** | Saved recipes | Never — recipes need no LLM |
164
170
 
165
171
  - Keys come from environment variables (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`,
166
- `OPENROUTER_API_KEY`, `GROQ_API_KEY`, …). CleanFrame never stores, logs, or
167
- transmits them.
172
+ `OPENROUTER_API_KEY`, `GROQ_API_KEY`, …). CleanFrame never stores or logs them, and
173
+ each provider reads **only its own** variable — `OPENAI_API_KEY` is never sent to
174
+ another provider.
168
175
  - Any provider that speaks OpenAI Chat Completions works out of the box —
169
176
  Anthropic (native), OpenAI, OpenRouter, Groq, Together, Fireworks, DeepSeek,
170
177
  Mistral, Google Gemini, xAI, Perplexity, Cohere, plus local Ollama / LM Studio
171
178
  or any custom `OPENAI_BASE_URL`. Spec format: `provider/model`
172
179
  (e.g. `openrouter/anthropic/claude-sonnet-4`, `groq/llama-3.3-70b-versatile`).
173
- - Hard cost cap: `cf.clean(..., max_tokens_budget=50_000)` aborts planning before it gets expensive.
180
+ - Cost cap: `cf.clean(..., max_tokens_budget=50_000)` is checked before each call (against an
181
+ estimate) and again against the usage the provider reports — leave headroom, since a call
182
+ that overshoots has already been billed.
174
183
  - Fully offline / air-gapped operation is a supported first-class mode, not a degraded one.
175
184
 
176
185
  ## What it cleans
177
186
 
178
187
  | Problem | Example |
179
188
  |---|---|
189
+ | Invisible characters | zero-width spaces, BOMs, non-breaking spaces and decomposed accents (`café` typed two ways) → one normalised form |
180
190
  | Column name chaos | `Cust Name`, `customer_name`, `CustomerName` → `customer_name` |
181
191
  | Date formats | `12/01/24`, `1 Jan 2024`, `2024-01-01` → ISO dates |
182
- | Currency & numbers | `₹1,20,000`, `$1,200`, `1200 INR` → typed floats + currency column |
183
- | Category variants | `Bengaluru` / `Bangalore` / `BLR` → one canonical value |
184
- | Emails & phones | validation, normalization, country codes |
185
- | Duplicates | exact + fuzzy matching with reviewable merge proposals |
192
+ | Currency & numbers | `₹1,20,000`, `$1,200`, `-$5`, `(1,200)`, `1.234,56`, `1200 INR` → typed floats (+ a currency column); a value whose grouping is invalid or ambiguous becomes a *counted, warned* null, never a guess |
193
+ | Category variants | `Bengaluru` / `bengaluru ` / `BENGALURU` and typos (`Banglore`) → one canonical value; abbreviations such as `BLR` need a seed map or an LLM |
194
+ | Emails & phones | validation, normalization, `00`/`+` international prefixes, optional default country code |
195
+ | Duplicates | exact duplicates are dropped (tracked in the diff); fuzzy near-duplicates and key collisions are *reported* with keep/drop proposals for you to act on |
186
196
  | Missing values | detected and explained; strategies proposed, never silently applied |
187
197
  | Units | `5kg`, `5000 g`, `5 KG` → normalized |
188
198
  | Schema mapping | messy file → your target schema, with confidence scores |
@@ -196,14 +206,18 @@ CleanFrame is built for pipelines, not just demos:
196
206
  - **Multi-sheet workbooks** — clean every Excel tab into one reviewable recipe; write-back refuses to overwrite the source in place
197
207
  - **Out-of-core streaming** — replay row-independent recipes over larger-than-RAM CSVs at chunk-bounded memory
198
208
  - **Format auto-detection** — encoding (utf-8 → cp1252) and delimiter sniffed at read time, pinned into the recipe for replay
199
- - **Bounded memory** — detector sampling (50k values) + capped cell-diff detail (100k); the diff snapshots only op-touched columns (peak ≈ input, not 2× the frame)
209
+ - **Bounded memory** — detector sampling (50k values) + capped cell-diff detail (100k); the diff snapshots the op-touched columns (peak is the frame plus those columns — about 2× when most columns are cleaned; use streaming for files that do not fit)
200
210
  - **Safe CSV exports** — spreadsheet formula injection escaped by default
201
211
  - **Regex guards** — oversized / nested-quantifier patterns rejected
202
212
  - **Visible degradation** — missing columns, unparseable values and LLM fallbacks warn instead of failing silently
203
213
  - **Quarantine, don't delete** — validation failures are held with reasons
204
214
  - **Verbatim reads** — `text=True` keeps leading zeros, literal `NA` and `1e5` exactly as the file has them; otherwise CleanFrame warns about what pandas' type inference changed on read
205
215
  - **Never overwrites its input** — writing output over the source file takes an explicit `--overwrite`, and every write lands via a temporary file
206
- - **Scriptable exits** — distinct exit codes for usage errors, data errors, drift stops and validation failures
216
+ - **Scriptable exits** — distinct exit codes for usage errors, data errors, drift stops and validation failures; `--json` prints one machine-readable summary on stdout
217
+ - **Number-format drift** — a file whose numbers stop fitting the recipe (`€1.200,50` against a `.`-decimal recipe) stops the replay instead of yielding `1.2005`
218
+ - **Strict recipes** — unknown ops/params, duplicate keys and bad regexes fail at *load*; plain YAML keys like `yes`, `010` or `12:30` stay column names
219
+ - **Generated code = executor** — exported pandas embeds the executor's own parsers, and property-based tests check they agree
220
+ - **Plugins that travel** — custom ops/detectors load from entry points, `--plugin` or `CLEANFRAME_PLUGINS`, and can opt in to code export and streaming
207
221
 
208
222
  See the [Production Guide](https://github.com/inboxpraveen/Cleanframe/wiki/Production-Guide).
209
223
 
@@ -271,13 +285,21 @@ Profiler ─→ Issue Detectors ─→ Planner ──────────→
271
285
  Validator · Cell-diff · HTML report
272
286
  ```
273
287
 
274
- Detectors are plugins — write your own in ~30 lines:
288
+ Detectors and ops are plugins — write your own in ~30 lines:
275
289
 
276
290
  ```python
291
+ @cf.register_op("normalize_iban", streamable=True,
292
+ codegen=lambda p, col: [f"df[{col!r}] = df[{col!r}].str.replace(' ', '').str.upper()"])
293
+ def normalize_iban(series: pd.Series) -> pd.Series:
294
+ return series.str.replace(" ", "").str.upper()
295
+
277
296
  @cf.detector("iban")
278
- def detect_iban(series: pd.Series) -> cf.Issues: ...
297
+ def detect_iban(series: pd.Series) -> cf.Issues: ... # propose cf.Op("normalize_iban")
279
298
  ```
280
299
 
300
+ Package it under the `cleanframe.plugins` entry-point group and `cleanframe apply` in CI
301
+ finds it — see the [plugin guide](https://github.com/inboxpraveen/Cleanframe/wiki/Plugins).
302
+
281
303
  ## Install
282
304
 
283
305
  ```bash
@@ -291,7 +313,7 @@ pip install "cleanframe-engine[all]"
291
313
  pip install "cleanframe-engine @ git+https://github.com/inboxpraveen/Cleanframe"
292
314
  ```
293
315
 
294
- Python 3.10+. The distribution is `cleanframe-engine`; the import package is
316
+ Python 3.10+. The distribution is [`cleanframe-engine`](https://pypi.org/project/cleanframe-engine/); the import package is
295
317
  `cleanframe` (`import cleanframe as cf`), and the CLI is `cleanframe` (or
296
318
  `python -m cleanframe`).
297
319
 
@@ -305,6 +327,8 @@ Python 3.10+. The distribution is `cleanframe-engine`; the import package is
305
327
  - [x] Selective ingestion (sheet / columns / rows)
306
328
  - [x] Out-of-core streaming replay (larger-than-RAM CSVs)
307
329
  - [x] Read-time format auto-correction (encoding + delimiter)
330
+ - [x] Plugin discovery; custom ops that export and stream
331
+ - [x] `--json` run summaries, library logging, property-based invariant tests
308
332
  - [ ] `MessyData-100` public benchmark + leaderboard
309
333
  - [ ] pandera / GX / dbt exporters
310
334
  - [ ] Polars backend
@@ -97,8 +97,10 @@ If the new file's schema drifted (a renamed column, a new currency format), Clea
97
97
  ```
98
98
  ⚠ Schema drift detected in new_customers.csv
99
99
  • Column "Amt (INR)" is new — 94% match to recipe column "amount_inr"
100
- • 312 values in "signup_date" match no allowed date format (new: "Jan 5, 26")
101
- Run `cleanframe suggest new_customers.csv --recipe customer.recipe.yaml --update` to review a patch.
100
+ • 312 value(s) in "signup_date" match no allowed date format (new: 'Jan 5, 26')
101
+ • 40 of 312 numeric value(s) in "Amount" do not fit the recipe's number format
102
+ (decimal '.', thousands ','; new: '€1.200,50')
103
+ Run `cleanframe suggest new_customers.csv --recipe <recipe.yaml> --update` to review a patch.
102
104
  ```
103
105
 
104
106
  ## Bring your own API key (or no key at all)
@@ -109,30 +111,34 @@ CleanFrame is LLM-optional and provider-agnostic.
109
111
  |---|---|---|
110
112
  | **Rules-only** (default) | Deterministic detectors + heuristics | Never |
111
113
  | **Metadata** | LLM sees column names, dtypes, and *value patterns* (regex sketches) — never raw values | Only metadata |
112
- | **Sample** | LLM sees an anonymized, shuffled sample you approve | Only the approved sample |
114
+ | **Sample** | LLM sees a small shuffled sample of *values*. Emails, phones and long strings are redacted; other short values (names, cities) are sent verbatim. Passing `llm_exposure="sample"` is the opt-in — there is no separate approval step | Sample values, per the redaction above |
113
115
  | **Replay** | Saved recipes | Never — recipes need no LLM |
114
116
 
115
117
  - Keys come from environment variables (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`,
116
- `OPENROUTER_API_KEY`, `GROQ_API_KEY`, …). CleanFrame never stores, logs, or
117
- transmits them.
118
+ `OPENROUTER_API_KEY`, `GROQ_API_KEY`, …). CleanFrame never stores or logs them, and
119
+ each provider reads **only its own** variable — `OPENAI_API_KEY` is never sent to
120
+ another provider.
118
121
  - Any provider that speaks OpenAI Chat Completions works out of the box —
119
122
  Anthropic (native), OpenAI, OpenRouter, Groq, Together, Fireworks, DeepSeek,
120
123
  Mistral, Google Gemini, xAI, Perplexity, Cohere, plus local Ollama / LM Studio
121
124
  or any custom `OPENAI_BASE_URL`. Spec format: `provider/model`
122
125
  (e.g. `openrouter/anthropic/claude-sonnet-4`, `groq/llama-3.3-70b-versatile`).
123
- - Hard cost cap: `cf.clean(..., max_tokens_budget=50_000)` aborts planning before it gets expensive.
126
+ - Cost cap: `cf.clean(..., max_tokens_budget=50_000)` is checked before each call (against an
127
+ estimate) and again against the usage the provider reports — leave headroom, since a call
128
+ that overshoots has already been billed.
124
129
  - Fully offline / air-gapped operation is a supported first-class mode, not a degraded one.
125
130
 
126
131
  ## What it cleans
127
132
 
128
133
  | Problem | Example |
129
134
  |---|---|
135
+ | Invisible characters | zero-width spaces, BOMs, non-breaking spaces and decomposed accents (`café` typed two ways) → one normalised form |
130
136
  | Column name chaos | `Cust Name`, `customer_name`, `CustomerName` → `customer_name` |
131
137
  | Date formats | `12/01/24`, `1 Jan 2024`, `2024-01-01` → ISO dates |
132
- | Currency & numbers | `₹1,20,000`, `$1,200`, `1200 INR` → typed floats + currency column |
133
- | Category variants | `Bengaluru` / `Bangalore` / `BLR` → one canonical value |
134
- | Emails & phones | validation, normalization, country codes |
135
- | Duplicates | exact + fuzzy matching with reviewable merge proposals |
138
+ | Currency & numbers | `₹1,20,000`, `$1,200`, `-$5`, `(1,200)`, `1.234,56`, `1200 INR` → typed floats (+ a currency column); a value whose grouping is invalid or ambiguous becomes a *counted, warned* null, never a guess |
139
+ | Category variants | `Bengaluru` / `bengaluru ` / `BENGALURU` and typos (`Banglore`) → one canonical value; abbreviations such as `BLR` need a seed map or an LLM |
140
+ | Emails & phones | validation, normalization, `00`/`+` international prefixes, optional default country code |
141
+ | Duplicates | exact duplicates are dropped (tracked in the diff); fuzzy near-duplicates and key collisions are *reported* with keep/drop proposals for you to act on |
136
142
  | Missing values | detected and explained; strategies proposed, never silently applied |
137
143
  | Units | `5kg`, `5000 g`, `5 KG` → normalized |
138
144
  | Schema mapping | messy file → your target schema, with confidence scores |
@@ -146,14 +152,18 @@ CleanFrame is built for pipelines, not just demos:
146
152
  - **Multi-sheet workbooks** — clean every Excel tab into one reviewable recipe; write-back refuses to overwrite the source in place
147
153
  - **Out-of-core streaming** — replay row-independent recipes over larger-than-RAM CSVs at chunk-bounded memory
148
154
  - **Format auto-detection** — encoding (utf-8 → cp1252) and delimiter sniffed at read time, pinned into the recipe for replay
149
- - **Bounded memory** — detector sampling (50k values) + capped cell-diff detail (100k); the diff snapshots only op-touched columns (peak ≈ input, not 2× the frame)
155
+ - **Bounded memory** — detector sampling (50k values) + capped cell-diff detail (100k); the diff snapshots the op-touched columns (peak is the frame plus those columns — about 2× when most columns are cleaned; use streaming for files that do not fit)
150
156
  - **Safe CSV exports** — spreadsheet formula injection escaped by default
151
157
  - **Regex guards** — oversized / nested-quantifier patterns rejected
152
158
  - **Visible degradation** — missing columns, unparseable values and LLM fallbacks warn instead of failing silently
153
159
  - **Quarantine, don't delete** — validation failures are held with reasons
154
160
  - **Verbatim reads** — `text=True` keeps leading zeros, literal `NA` and `1e5` exactly as the file has them; otherwise CleanFrame warns about what pandas' type inference changed on read
155
161
  - **Never overwrites its input** — writing output over the source file takes an explicit `--overwrite`, and every write lands via a temporary file
156
- - **Scriptable exits** — distinct exit codes for usage errors, data errors, drift stops and validation failures
162
+ - **Scriptable exits** — distinct exit codes for usage errors, data errors, drift stops and validation failures; `--json` prints one machine-readable summary on stdout
163
+ - **Number-format drift** — a file whose numbers stop fitting the recipe (`€1.200,50` against a `.`-decimal recipe) stops the replay instead of yielding `1.2005`
164
+ - **Strict recipes** — unknown ops/params, duplicate keys and bad regexes fail at *load*; plain YAML keys like `yes`, `010` or `12:30` stay column names
165
+ - **Generated code = executor** — exported pandas embeds the executor's own parsers, and property-based tests check they agree
166
+ - **Plugins that travel** — custom ops/detectors load from entry points, `--plugin` or `CLEANFRAME_PLUGINS`, and can opt in to code export and streaming
157
167
 
158
168
  See the [Production Guide](https://github.com/inboxpraveen/Cleanframe/wiki/Production-Guide).
159
169
 
@@ -221,13 +231,21 @@ Profiler ─→ Issue Detectors ─→ Planner ──────────→
221
231
  Validator · Cell-diff · HTML report
222
232
  ```
223
233
 
224
- Detectors are plugins — write your own in ~30 lines:
234
+ Detectors and ops are plugins — write your own in ~30 lines:
225
235
 
226
236
  ```python
237
+ @cf.register_op("normalize_iban", streamable=True,
238
+ codegen=lambda p, col: [f"df[{col!r}] = df[{col!r}].str.replace(' ', '').str.upper()"])
239
+ def normalize_iban(series: pd.Series) -> pd.Series:
240
+ return series.str.replace(" ", "").str.upper()
241
+
227
242
  @cf.detector("iban")
228
- def detect_iban(series: pd.Series) -> cf.Issues: ...
243
+ def detect_iban(series: pd.Series) -> cf.Issues: ... # propose cf.Op("normalize_iban")
229
244
  ```
230
245
 
246
+ Package it under the `cleanframe.plugins` entry-point group and `cleanframe apply` in CI
247
+ finds it — see the [plugin guide](https://github.com/inboxpraveen/Cleanframe/wiki/Plugins).
248
+
231
249
  ## Install
232
250
 
233
251
  ```bash
@@ -241,7 +259,7 @@ pip install "cleanframe-engine[all]"
241
259
  pip install "cleanframe-engine @ git+https://github.com/inboxpraveen/Cleanframe"
242
260
  ```
243
261
 
244
- Python 3.10+. The distribution is `cleanframe-engine`; the import package is
262
+ Python 3.10+. The distribution is [`cleanframe-engine`](https://pypi.org/project/cleanframe-engine/); the import package is
245
263
  `cleanframe` (`import cleanframe as cf`), and the CLI is `cleanframe` (or
246
264
  `python -m cleanframe`).
247
265
 
@@ -255,6 +273,8 @@ Python 3.10+. The distribution is `cleanframe-engine`; the import package is
255
273
  - [x] Selective ingestion (sheet / columns / rows)
256
274
  - [x] Out-of-core streaming replay (larger-than-RAM CSVs)
257
275
  - [x] Read-time format auto-correction (encoding + delimiter)
276
+ - [x] Plugin discovery; custom ops that export and stream
277
+ - [x] `--json` run summaries, library logging, property-based invariant tests
258
278
  - [ ] `MessyData-100` public benchmark + leaderboard
259
279
  - [ ] pandera / GX / dbt exporters
260
280
  - [ ] Polars backend
@@ -4,9 +4,9 @@
4
4
 
5
5
  | Version | Supported |
6
6
  |---------|-----------|
7
- | 0.3.x | Yes |
8
- | 0.2.x | Best-effort |
9
- | 0.1.x | No |
7
+ | 0.4.x | Yes |
8
+ | 0.3.x | Security fixes only |
9
+ | < 0.3 | No (never published under the `cleanframe-engine` name) |
10
10
 
11
11
  ## Reporting a vulnerability
12
12
 
@@ -28,15 +28,17 @@ and disclosure timeline with you.
28
28
  ## Design guarantees relevant to security
29
29
 
30
30
  - **No `eval` / `exec`** in the library path. Recipes are data, not code.
31
- - Recipes and schemas are loaded with `yaml.safe_load`.
31
+ - Recipes and schemas are loaded with a `SafeLoader` subclass (no arbitrary Python object construction).
32
32
  - HTML reports use Jinja2 autoescaping.
33
- - The LLM planner never receives raw cell values in the default `metadata` exposure.
33
+ - The LLM planner never receives raw cell values in the default `metadata` exposure, and each
34
+ provider reads only its own API-key environment variable (an `OPENAI_API_KEY` is never sent to another provider).
35
+ - An LLM-authored recipe can never contain `fill_na` or `drop_columns`; they are stripped with a warning.
34
36
  - CSV/Excel exports sanitise formula-like cells **and header labels** (`=`, `@`, and
35
37
  `+`/`-` followed by anything that is not a plain number) by default. A signed
36
38
  number is left alone so normalised phone numbers and negative amounts survive.
37
39
  - Generated standalone pandas escapes column names and validation labels, so a
38
40
  crafted CSV header cannot inject code into the exported module.
39
- - User-supplied regex patterns in recipes are length- and complexity-bounded.
41
+ - User-supplied regex patterns in recipes are length- and complexity-bounded, and refused at load time.
40
42
  - Recipes and schemas reject duplicate YAML keys instead of silently keeping the last.
41
43
  - Cleaned output never overwrites its own input file without an explicit opt-in.
42
44
 
@@ -46,3 +48,8 @@ CleanFrame is a **data-cleaning library**, not a sandbox. Callers who pass untru
46
48
  file paths, recipes, or schemas still control the host filesystem and process.
47
49
  Treat recipe YAML from untrusted sources like any other untrusted config: review it
48
50
  before applying it to production data.
51
+
52
+ **Plugins run code.** `cleanframe.plugins` entry points, `CLEANFRAME_PLUGINS` and `--plugin MODULE` import Python
53
+ modules, exactly like installing a package. Only install plugins you trust; set `CLEANFRAME_NO_PLUGINS=1` (or
54
+ pass `--no-plugins`) to disable automatic discovery. A recipe cannot load a plugin by itself: it can only *name* an
55
+ op, which is resolved against the plugins already installed or explicitly requested.
@@ -13,6 +13,8 @@ input → same output, every time. See the README for the full tour.
13
13
 
14
14
  from __future__ import annotations
15
15
 
16
+ import logging as _logging
17
+
16
18
  from ._version import __version__
17
19
 
18
20
  # -- high-level API --------------------------------------------------------
@@ -52,6 +54,7 @@ from .ops import list_ops, register_op
52
54
 
53
55
  # -- planning / execution --------------------------------------------------
54
56
  from .planner import Planner, RulesPlanner, plan_recipe
57
+ from .plugins import load_plugins
55
58
 
56
59
  # -- profiling -------------------------------------------------------------
57
60
  from .profile import ColumnProfile, DataFrameProfile, profile_dataframe
@@ -83,6 +86,10 @@ from .workbook import (
83
86
  read_workbook,
84
87
  )
85
88
 
89
+ # A library must not configure logging. Applications opt in with
90
+ # ``logging.getLogger("cleanframe").setLevel(...)`` (or the CLI's --verbose).
91
+ _logging.getLogger("cleanframe").addHandler(_logging.NullHandler())
92
+
86
93
  __all__ = [
87
94
  "__version__",
88
95
  # api
@@ -103,6 +110,7 @@ __all__ = [
103
110
  "detector",
104
111
  "validator",
105
112
  "register_op",
113
+ "load_plugins",
106
114
  "DetectorContext",
107
115
  "run_detectors",
108
116
  "list_detectors",