cleanframe-engine 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. cleanframe/__init__.py +169 -0
  2. cleanframe/__main__.py +5 -0
  3. cleanframe/_util.py +438 -0
  4. cleanframe/_version.py +1 -0
  5. cleanframe/api.py +559 -0
  6. cleanframe/cli.py +688 -0
  7. cleanframe/codegen.py +666 -0
  8. cleanframe/dataio.py +506 -0
  9. cleanframe/detectors/__init__.py +43 -0
  10. cleanframe/detectors/base.py +221 -0
  11. cleanframe/detectors/categories.py +199 -0
  12. cleanframe/detectors/contacts.py +105 -0
  13. cleanframe/detectors/currency.py +112 -0
  14. cleanframe/detectors/dates.py +204 -0
  15. cleanframe/detectors/dedup.py +150 -0
  16. cleanframe/detectors/nulls.py +109 -0
  17. cleanframe/detectors/outliers.py +73 -0
  18. cleanframe/detectors/schema_mapping.py +125 -0
  19. cleanframe/detectors/text.py +105 -0
  20. cleanframe/detectors/units.py +86 -0
  21. cleanframe/diff.py +369 -0
  22. cleanframe/drift.py +283 -0
  23. cleanframe/errors.py +66 -0
  24. cleanframe/executor.py +229 -0
  25. cleanframe/fingerprint.py +83 -0
  26. cleanframe/issues.py +186 -0
  27. cleanframe/llm.py +811 -0
  28. cleanframe/ops.py +1245 -0
  29. cleanframe/planner.py +353 -0
  30. cleanframe/profile.py +413 -0
  31. cleanframe/py.typed +1 -0
  32. cleanframe/quality.py +81 -0
  33. cleanframe/readfix.py +160 -0
  34. cleanframe/recipe.py +398 -0
  35. cleanframe/report.py +345 -0
  36. cleanframe/result.py +144 -0
  37. cleanframe/schema.py +259 -0
  38. cleanframe/streaming.py +354 -0
  39. cleanframe/types.py +119 -0
  40. cleanframe/validate.py +363 -0
  41. cleanframe/workbook.py +370 -0
  42. cleanframe_engine-0.3.0.dist-info/METADATA +323 -0
  43. cleanframe_engine-0.3.0.dist-info/RECORD +46 -0
  44. cleanframe_engine-0.3.0.dist-info/WHEEL +4 -0
  45. cleanframe_engine-0.3.0.dist-info/entry_points.txt +2 -0
  46. cleanframe_engine-0.3.0.dist-info/licenses/LICENSE +201 -0
cleanframe/ops.py ADDED
@@ -0,0 +1,1245 @@
1
+ """The op registry: every transform a recipe can perform, as pure pandas.
2
+
3
+ This module is the deterministic heart of CleanFrame. A recipe is just an ordered
4
+ list of :class:`~cleanframe.types.Op` names + params; each name resolves here to a
5
+ plain function of a pandas object. There is **no** randomness, no clock, no
6
+ network, and no hidden state — the same op applied to the same data always yields
7
+ the same result. That is the whole promise ("Same input → same output, every
8
+ time"), and it lives or dies in this file.
9
+
10
+ Two op *scopes*:
11
+
12
+ * **column** ops take a :class:`pandas.Series` and return either a Series (the new
13
+ column) or a :class:`ColumnOpResult` (a new Series *plus* extra columns to add,
14
+ used by ``extract_currency``).
15
+ * **frame** ops take a :class:`pandas.DataFrame` and return a DataFrame. They must
16
+ preserve the row index of surviving rows (the executor diffs the index to learn
17
+ which rows were dropped).
18
+
19
+ Adding an op is intentionally a ~15-line affair — see ``CONTRIBUTING.md``.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import inspect
25
+ import math
26
+ import re
27
+ from collections.abc import Callable
28
+ from dataclasses import dataclass, field
29
+ from typing import Any
30
+
31
+ import numpy as np
32
+ import pandas as pd
33
+
34
+ from ._util import safe_compile_regex
35
+ from .errors import OpError, RecipeError
36
+ from .types import Op
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Registry
41
+ # ---------------------------------------------------------------------------
42
+ @dataclass
43
+ class ColumnOpResult:
44
+ """Return value for a column op that also emits *new* columns.
45
+
46
+ ``series`` replaces the column the op ran on; ``emit`` maps new output column
47
+ names to their Series (used by ``extract_currency`` to add ``<col>_currency``).
48
+ """
49
+
50
+ series: pd.Series
51
+ emit: dict[str, pd.Series] = field(default_factory=dict)
52
+
53
+
54
+ @dataclass
55
+ class OpSpec:
56
+ name: str
57
+ func: Callable[..., Any]
58
+ scope: str # "column" | "frame"
59
+ coerce: Callable[[Any], dict] | None = None
60
+ compact: Callable[[dict], Any] | None = None
61
+ doc: str = ""
62
+ #: Parameter names a recipe may set for this op (plus any documented aliases).
63
+ known_params: frozenset[str] = frozenset()
64
+ #: True when a mapping *is* the payload (``normalize_values``), so its keys
65
+ #: are user data rather than parameter names.
66
+ free_form: bool = False
67
+
68
+
69
+ OP_REGISTRY: dict[str, OpSpec] = {}
70
+
71
+
72
+ def register_op(
73
+ name: str,
74
+ *,
75
+ scope: str = "column",
76
+ coerce: Callable[[Any], dict] | None = None,
77
+ compact: Callable[[dict], Any] | None = None,
78
+ aliases: tuple[str, ...] = (),
79
+ free_form: bool = False,
80
+ ) -> Callable[[Callable], Callable]:
81
+ """Register a transform under ``name``. See module docstring for scopes.
82
+
83
+ ``coerce`` maps the compact recipe form to canonical params (load direction).
84
+ ``compact`` is the inverse for serialisation: it maps canonical params back to
85
+ the minimal YAML value (bare string / list / trimmed dict), keeping generated
86
+ recipes as clean as hand-written ones. ``coerce(compact(p))`` must equal ``p``.
87
+ """
88
+
89
+ if scope not in ("column", "frame"):
90
+ raise ValueError(f"scope must be 'column' or 'frame', got {scope!r}")
91
+
92
+ def decorator(func: Callable) -> Callable:
93
+ if name in OP_REGISTRY:
94
+ raise ValueError(f"Op {name!r} is already registered.")
95
+ OP_REGISTRY[name] = OpSpec(
96
+ name=name, func=func, scope=scope, coerce=coerce, compact=compact,
97
+ doc=func.__doc__ or "",
98
+ known_params=frozenset(_signature_params(func)) | frozenset(aliases),
99
+ free_form=free_form,
100
+ )
101
+ return func
102
+
103
+ return decorator
104
+
105
+
106
+ def _signature_params(func: Callable) -> set[str]:
107
+ """Keyword parameter names of an op, minus its leading series/frame argument."""
108
+ try:
109
+ params = [
110
+ p
111
+ for p in inspect.signature(func).parameters.values()
112
+ if p.kind in (p.POSITIONAL_OR_KEYWORD, p.KEYWORD_ONLY)
113
+ ]
114
+ except (TypeError, ValueError): # pragma: no cover - builtins without signatures
115
+ return set()
116
+ return {p.name for p in params[1:]}
117
+
118
+
119
+ def _prune(params: dict, defaults: dict) -> dict:
120
+ """Drop params equal to their documented default (for minimal serialisation)."""
121
+ return {k: v for k, v in params.items() if k not in defaults or v != defaults[k]}
122
+
123
+
124
+ def op_to_compact(op: Op) -> Any:
125
+ """Serialise an :class:`Op` to its minimal recipe form using the op's ``compact``.
126
+
127
+ Falls back to :meth:`Op.to_compact` for ops that declare no custom compactor.
128
+ """
129
+ spec = OP_REGISTRY.get(op.name)
130
+ if spec is None or spec.compact is None:
131
+ return op.to_compact()
132
+ value = spec.compact(op.params)
133
+ if value is None or value == {} or value == "":
134
+ return op.name
135
+ return {op.name: value}
136
+
137
+
138
+ def get_op(name: str) -> OpSpec:
139
+ spec = OP_REGISTRY.get(name)
140
+ if spec is None:
141
+ raise RecipeError(
142
+ f"Unknown op {name!r}. Known ops: {', '.join(sorted(OP_REGISTRY))}."
143
+ )
144
+ return spec
145
+
146
+
147
+ def list_ops(scope: str | None = None) -> list[str]:
148
+ names = [n for n, s in OP_REGISTRY.items() if scope is None or s.scope == scope]
149
+ return sorted(names)
150
+
151
+
152
+ def normalize_op(name: str, raw_params: Any = None) -> Op:
153
+ """Turn a compact op form into a canonical :class:`Op` with a params dict.
154
+
155
+ ``raw_params`` is whatever appeared after the op name in the recipe YAML — a
156
+ dict, a bare scalar/list shorthand, or ``None``. Each op's ``coerce`` function
157
+ (if any) maps that to canonical params.
158
+ """
159
+ spec = get_op(name)
160
+ if isinstance(raw_params, dict) and not spec.free_form and spec.known_params:
161
+ unknown = sorted(str(k) for k in raw_params if str(k) not in spec.known_params)
162
+ if unknown:
163
+ raise RecipeError(
164
+ f"Op {name!r} got unknown parameter(s) {unknown}. Valid parameters: "
165
+ f"{sorted(spec.known_params)}. A misspelled parameter would be ignored."
166
+ )
167
+ if spec.coerce is not None:
168
+ try:
169
+ params = spec.coerce(raw_params)
170
+ except RecipeError:
171
+ raise
172
+ except (KeyError, ValueError, TypeError) as exc:
173
+ # A coerce touched a missing/invalid param — surface it as a RecipeError
174
+ # so the errors.py contract (everything on purpose derives from
175
+ # CleanFrameError) holds and the CLI shows a tidy message, not a traceback.
176
+ detail = f"missing key {exc}" if isinstance(exc, KeyError) else str(exc)
177
+ raise RecipeError(f"Op {name!r} has invalid parameters: {detail}.") from exc
178
+ elif raw_params is None or raw_params == "":
179
+ params = {}
180
+ elif isinstance(raw_params, dict):
181
+ params = dict(raw_params)
182
+ else:
183
+ raise RecipeError(
184
+ f"Op {name!r} expects a mapping of parameters, got {type(raw_params).__name__}."
185
+ )
186
+ return Op(name=name, params=params)
187
+
188
+
189
+ def apply_column_op(op: Op, series: pd.Series) -> ColumnOpResult:
190
+ """Apply a column-scope op to a Series, always returning a :class:`ColumnOpResult`."""
191
+ spec = get_op(op.name)
192
+ if spec.scope != "column":
193
+ raise RecipeError(f"Op {op.name!r} is a frame op; it cannot run on a single column.")
194
+ try:
195
+ out = spec.func(series, **op.params)
196
+ except OpError:
197
+ raise
198
+ except Exception as exc: # noqa: BLE001 - surface any pandas failure as OpError
199
+ raise OpError(f"Op {op.name!r} failed on column {series.name!r}: {exc}") from exc
200
+ if isinstance(out, ColumnOpResult):
201
+ return out
202
+ if isinstance(out, pd.Series):
203
+ return ColumnOpResult(series=out)
204
+ raise OpError(f"Op {op.name!r} returned {type(out).__name__}, expected a Series.")
205
+
206
+
207
+ def apply_frame_op(op: Op, df: pd.DataFrame) -> pd.DataFrame:
208
+ spec = get_op(op.name)
209
+ if spec.scope != "frame":
210
+ raise RecipeError(f"Op {op.name!r} is a column op; it cannot run on the whole frame.")
211
+ try:
212
+ out = spec.func(df, **op.params)
213
+ except OpError:
214
+ raise
215
+ except Exception as exc: # noqa: BLE001
216
+ raise OpError(f"Frame op {op.name!r} failed: {exc}") from exc
217
+ if not isinstance(out, pd.DataFrame):
218
+ raise OpError(f"Frame op {op.name!r} returned {type(out).__name__}, expected a DataFrame.")
219
+ return out
220
+
221
+
222
+ # ---------------------------------------------------------------------------
223
+ # Small helpers (all NaN-preserving and deterministic)
224
+ # ---------------------------------------------------------------------------
225
+ def _is_na(v: Any) -> bool:
226
+ if v is None:
227
+ return True
228
+ if isinstance(v, float):
229
+ return math.isnan(v)
230
+ try:
231
+ return bool(pd.isna(v))
232
+ except (TypeError, ValueError):
233
+ return False
234
+
235
+
236
+ def _is_pure_string(series: pd.Series) -> bool:
237
+ """True if a vectorised ``.str`` is safe: an object column whose non-null values
238
+ are *all* ``str``. ``infer_dtype`` scans in C; only then can ``.str`` not silently
239
+ NaN a stray non-string cell, and object→object keeps the output dtype identical to
240
+ the elementwise map. (Non-object string dtypes stay on the elementwise path so the
241
+ result dtype never changes from the historical behaviour.)"""
242
+ return pd.api.types.is_object_dtype(series.dtype) and pd.api.types.infer_dtype(
243
+ series, skipna=True
244
+ ) in ("string", "empty")
245
+
246
+
247
+ def _apply_str(
248
+ series: pd.Series, fn: Callable[[str], Any], vec: Callable[[pd.Series], pd.Series] | None = None
249
+ ) -> pd.Series:
250
+ """Apply ``fn`` to string cells only; leave NaN and non-strings untouched.
251
+
252
+ The default path is elementwise rather than ``Series.str.*`` — the vectorised
253
+ string accessor coerces every non-string cell (including stray ints in an object
254
+ column) to NaN, which would silently destroy data. When a column is provably
255
+ all-string, the ``vec`` fast-path runs the equivalent ``.str`` chain (byte-identical,
256
+ verified in tests) for a large speed-up on million-row columns.
257
+ """
258
+ if vec is not None and _is_pure_string(series):
259
+ return vec(series)
260
+ if pd.api.types.is_object_dtype(series.dtype):
261
+ # Avoid Series.map(): pandas >= 3 infers StringDtype for pure-string object
262
+ # columns and rewrites None→nan, which would diverge from the .str fast-path.
263
+ return pd.Series(
264
+ [fn(v) if isinstance(v, str) else v for v in series],
265
+ index=series.index,
266
+ dtype=object,
267
+ name=series.name,
268
+ )
269
+ return series.map(lambda v: fn(v) if isinstance(v, str) else v)
270
+
271
+
272
+ # ---------------------------------------------------------------------------
273
+ # Text ops
274
+ # ---------------------------------------------------------------------------
275
+ @register_op("strip_whitespace")
276
+ def strip_whitespace(series: pd.Series) -> pd.Series:
277
+ """Trim leading and trailing whitespace from string cells."""
278
+ return _apply_str(series, str.strip, vec=lambda s: s.str.strip())
279
+
280
+
281
+ _WS_RE = re.compile(r"\s+")
282
+
283
+
284
+ @register_op("collapse_whitespace")
285
+ def collapse_whitespace(series: pd.Series) -> pd.Series:
286
+ """Collapse internal runs of whitespace to a single space, then trim."""
287
+ return _apply_str(
288
+ series,
289
+ lambda s: _WS_RE.sub(" ", s).strip(),
290
+ vec=lambda s: s.str.replace(r"\s+", " ", regex=True).str.strip(),
291
+ )
292
+
293
+
294
+ @register_op("lowercase")
295
+ def lowercase(series: pd.Series) -> pd.Series:
296
+ """Lowercase string cells."""
297
+ return _apply_str(series, str.lower, vec=lambda s: s.str.lower())
298
+
299
+
300
+ @register_op("uppercase")
301
+ def uppercase(series: pd.Series) -> pd.Series:
302
+ """Uppercase string cells."""
303
+ return _apply_str(series, str.upper, vec=lambda s: s.str.upper())
304
+
305
+
306
+ @register_op("title_case")
307
+ def title_case(series: pd.Series) -> pd.Series:
308
+ """Title-case string cells (``"new YORK"`` -> ``"New York"``)."""
309
+ return _apply_str(
310
+ series,
311
+ lambda s: _WS_RE.sub(" ", s).strip().title(),
312
+ vec=lambda s: s.str.replace(r"\s+", " ", regex=True).str.strip().str.title(),
313
+ )
314
+
315
+
316
+ @register_op("capitalize")
317
+ def capitalize(series: pd.Series) -> pd.Series:
318
+ """Capitalize the first letter of each string cell."""
319
+ return _apply_str(series, lambda s: s.strip().capitalize(), vec=lambda s: s.str.strip().str.capitalize())
320
+
321
+
322
+ def _coerce_symbols(raw: Any) -> dict:
323
+ if raw is None:
324
+ return {"symbols": []}
325
+ if isinstance(raw, dict):
326
+ raw = raw.get("symbols", [])
327
+ if isinstance(raw, str):
328
+ return {"symbols": [raw]}
329
+ if isinstance(raw, (list, tuple)):
330
+ bad = [s for s in raw if not isinstance(s, str)]
331
+ if bad:
332
+ raise RecipeError(
333
+ f"Op 'remove_symbols' expects strings, got {bad!r}. Quote them in YAML "
334
+ "so a number is not treated as the digit to delete."
335
+ )
336
+ return {"symbols": list(raw)}
337
+ raise RecipeError(
338
+ f"Op 'remove_symbols' expects a string or a list of strings, got "
339
+ f"{type(raw).__name__}."
340
+ )
341
+
342
+
343
+ @register_op(
344
+ "remove_symbols", coerce=_coerce_symbols, compact=lambda p: p.get("symbols", [])
345
+ )
346
+ def remove_symbols(series: pd.Series, symbols: list[str] | None = None) -> pd.Series:
347
+ """Delete each listed substring from string cells (``["₹", ","]`` etc.)."""
348
+ subs = [str(s) for s in (symbols or [])]
349
+
350
+ def fn(s: str) -> str:
351
+ for sub in subs:
352
+ s = s.replace(sub, "")
353
+ return s
354
+
355
+ return _apply_str(series, fn)
356
+
357
+
358
+ def _coerce_replace(raw: Any) -> dict:
359
+ if not isinstance(raw, dict):
360
+ raise RecipeError("Op 'replace' expects a mapping with 'pattern' and 'repl'.")
361
+ if "pattern" not in raw:
362
+ raise RecipeError("Op 'replace' requires a 'pattern' parameter.")
363
+ return {
364
+ "pattern": raw["pattern"],
365
+ "repl": raw.get("repl", ""),
366
+ "regex": bool(raw.get("regex", True)),
367
+ }
368
+
369
+
370
+ @register_op(
371
+ "replace",
372
+ coerce=_coerce_replace,
373
+ compact=lambda p: _prune(p, {"repl": "", "regex": True}),
374
+ )
375
+ def replace(series: pd.Series, pattern: str, repl: str = "", regex: bool = True) -> pd.Series:
376
+ """Regex (or literal) substitution over string cells."""
377
+ if regex:
378
+ try:
379
+ compiled = safe_compile_regex(pattern)
380
+ except ValueError as exc:
381
+ raise OpError(str(exc)) from exc
382
+ return _apply_str(series, lambda s: compiled.sub(repl, s))
383
+ return _apply_str(series, lambda s: s.replace(pattern, repl))
384
+
385
+
386
+ #: Tokens that commonly stand in for a missing value in exported data.
387
+ DEFAULT_NA_TOKENS = [
388
+ "",
389
+ "na",
390
+ "n/a",
391
+ "n.a.",
392
+ "null",
393
+ "none",
394
+ "nil",
395
+ "nan",
396
+ "-",
397
+ "--",
398
+ "?",
399
+ "unknown",
400
+ "not available",
401
+ "not applicable",
402
+ ]
403
+
404
+
405
+ def _coerce_to_na(raw: Any) -> dict:
406
+ if raw is None:
407
+ return {"tokens": None, "case_insensitive": True}
408
+ if isinstance(raw, dict):
409
+ tokens = raw.get("tokens")
410
+ if isinstance(tokens, str):
411
+ tokens = [tokens]
412
+ if tokens is not None and not isinstance(tokens, (list, tuple)):
413
+ raise RecipeError("Op 'to_na' tokens must be a string or a list of strings.")
414
+ return {
415
+ "tokens": list(tokens) if tokens is not None else None,
416
+ "case_insensitive": bool(raw.get("case_insensitive", True)),
417
+ }
418
+ if isinstance(raw, (list, tuple)):
419
+ return {"tokens": list(raw), "case_insensitive": True}
420
+ return {"tokens": [raw], "case_insensitive": True}
421
+
422
+
423
+ @register_op(
424
+ "to_na",
425
+ coerce=_coerce_to_na,
426
+ compact=lambda p: _prune(p, {"tokens": None, "case_insensitive": True}),
427
+ )
428
+ def to_na(
429
+ series: pd.Series,
430
+ tokens: list[str] | None = None,
431
+ case_insensitive: bool = True,
432
+ ) -> pd.Series:
433
+ """Convert disguised-null tokens (``"NA"``, ``"-"``, ``"unknown"``, …) to real NaN."""
434
+ toks = DEFAULT_NA_TOKENS if tokens is None else [str(t) for t in tokens]
435
+ lookup = {t.strip().casefold() if case_insensitive else t for t in toks}
436
+
437
+ def fn(s: str) -> Any:
438
+ key = s.strip().casefold() if case_insensitive else s
439
+ return np.nan if key in lookup else s
440
+
441
+ return _apply_str(series, fn)
442
+
443
+
444
+ _FILL_NA_STRATEGIES = frozenset(
445
+ {"mean", "median", "mode", "ffill", "pad", "bfill", "backfill", "zero", "empty"}
446
+ )
447
+
448
+
449
+ def _coerce_fill_na(raw: Any) -> dict:
450
+ if isinstance(raw, dict):
451
+ strategy = raw.get("strategy")
452
+ if strategy is not None and str(strategy).lower() not in _FILL_NA_STRATEGIES:
453
+ raise RecipeError(
454
+ f"Op 'fill_na' unknown strategy {strategy!r}. "
455
+ f"Expected one of {sorted(_FILL_NA_STRATEGIES)}."
456
+ )
457
+ return {"value": raw.get("value"), "strategy": strategy}
458
+ # bare scalar -> constant fill value
459
+ return {"value": raw, "strategy": None}
460
+
461
+
462
+ def _compact_fill_na(p: dict) -> dict:
463
+ out: dict[str, Any] = {}
464
+ if p.get("strategy") is not None:
465
+ out["strategy"] = p["strategy"]
466
+ if p.get("value") is not None:
467
+ out["value"] = p["value"]
468
+ return out
469
+
470
+
471
+ @register_op("fill_na", coerce=_coerce_fill_na, compact=_compact_fill_na)
472
+ def fill_na(series: pd.Series, value: Any = None, strategy: str | None = None) -> pd.Series:
473
+ """Fill missing values. Only ever runs when a human put it in the recipe.
474
+
475
+ ``strategy`` is one of ``mean``/``median``/``mode``/``ffill``/``bfill``/``zero``/
476
+ ``empty``; otherwise ``value`` is used as a constant. CleanFrame never *adds*
477
+ this op automatically — missing data is reported, not silently imputed.
478
+ """
479
+ if strategy is None:
480
+ return series.fillna(value)
481
+ strategy = str(strategy).lower()
482
+ if strategy == "mean":
483
+ return series.fillna(pd.to_numeric(series, errors="coerce").mean())
484
+ if strategy == "median":
485
+ return series.fillna(pd.to_numeric(series, errors="coerce").median())
486
+ if strategy == "mode":
487
+ modes = series.dropna().mode()
488
+ if len(modes) == 0:
489
+ return series
490
+ return series.fillna(sorted(modes.tolist(), key=str)[0])
491
+ if strategy in ("ffill", "pad"):
492
+ return series.ffill()
493
+ if strategy in ("bfill", "backfill"):
494
+ return series.bfill()
495
+ if strategy == "zero":
496
+ return series.fillna(0)
497
+ if strategy == "empty":
498
+ return series.fillna("")
499
+ raise OpError(f"Unknown fill_na strategy {strategy!r}.")
500
+
501
+
502
+ # ---------------------------------------------------------------------------
503
+ # Contact ops
504
+ # ---------------------------------------------------------------------------
505
+ @register_op("normalize_email")
506
+ def normalize_email(series: pd.Series) -> pd.Series:
507
+ """Trim and lowercase email addresses (the case-insensitive, safe normalisation)."""
508
+ return _apply_str(series, lambda s: s.strip().lower(), vec=lambda s: s.str.strip().str.lower())
509
+
510
+
511
+ def _coerce_normalize_phone(raw: Any) -> dict:
512
+ if raw is None:
513
+ return {"default_country_code": None}
514
+ if isinstance(raw, str):
515
+ return {"default_country_code": raw}
516
+ if isinstance(raw, dict):
517
+ cc = raw.get("default_country_code", raw.get("country_code", raw.get("region")))
518
+ return {"default_country_code": cc}
519
+ raise RecipeError("Op 'normalize_phone' expects a country code or mapping.")
520
+
521
+
522
+ #: A trailing extension is not part of the number; fusing it corrupts both.
523
+ _PHONE_EXT_RE = re.compile(r"[\s,;]*(?:ext|extn|x|#)\.?\s*\d+\s*$", re.IGNORECASE)
524
+
525
+
526
+ def _phone_text(value: Any) -> str:
527
+ """Stringify a phone cell without inventing digits.
528
+
529
+ A phone column holding one blank cell is read as float64, and ``str(9876543210.0)``
530
+ would append a spurious trailing zero once the dot is stripped.
531
+ """
532
+ if isinstance(value, float) and float(value).is_integer():
533
+ return str(int(value))
534
+ return str(value)
535
+
536
+
537
+ def _normalize_phone_scalar(value: Any, default_cc: str | None) -> Any:
538
+ if _is_na(value):
539
+ return value
540
+ if isinstance(value, bool):
541
+ return np.nan
542
+ s = _PHONE_EXT_RE.sub("", _phone_text(value))
543
+ had_plus = s.strip().startswith("+")
544
+ digits = re.sub(r"\D", "", s)
545
+ if not digits:
546
+ return np.nan
547
+ if had_plus:
548
+ return "+" + digits
549
+ if default_cc:
550
+ cc = re.sub(r"\D", "", str(default_cc))
551
+ if cc and digits.startswith(cc):
552
+ return "+" + digits
553
+ local = digits.lstrip("0")
554
+ return "+" + cc + local
555
+ return digits
556
+
557
+
558
+ @register_op(
559
+ "normalize_phone",
560
+ aliases=("country_code", "region"),
561
+ coerce=_coerce_normalize_phone,
562
+ compact=lambda p: _prune(p, {"default_country_code": None}),
563
+ )
564
+ def normalize_phone(series: pd.Series, default_country_code: str | None = None) -> pd.Series:
565
+ """Best-effort phone normalisation: keep an existing ``+``, strip separators.
566
+
567
+ With ``default_country_code`` (e.g. ``"+91"``), local numbers gain the country
568
+ code (dropping a national trunk ``0``). This is intentionally lightweight — for
569
+ strict E.164 across many regions, plug in a libphonenumber-backed detector.
570
+ """
571
+ return series.map(lambda v: _normalize_phone_scalar(v, default_country_code))
572
+
573
+
574
+ # ---------------------------------------------------------------------------
575
+ # Number ops
576
+ # ---------------------------------------------------------------------------
577
+ def _coerce_parse_number(raw: Any) -> dict:
578
+ raw = raw or {}
579
+ if not isinstance(raw, dict):
580
+ raise RecipeError("Op 'parse_number' expects a mapping of parameters.")
581
+ return {
582
+ "decimal": raw.get("decimal", "."),
583
+ "thousands": raw.get("thousands", ","),
584
+ "symbols": list(raw.get("symbols", [])),
585
+ }
586
+
587
+
588
+ #: A single, well-formed numeric token: optional sign, int/decimal, optional exponent.
589
+ _NUMBER_TOKEN_RE = re.compile(r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?")
590
+
591
+
592
+ def _parse_number_scalar(value: Any, decimal: str, thousands: str, symbols: list[str]) -> float:
593
+ if _is_na(value):
594
+ return np.nan
595
+ if isinstance(value, bool):
596
+ return np.nan
597
+ if isinstance(value, (int, float)):
598
+ return float(value)
599
+ s = str(value).strip().replace("−", "-") # normalise unicode minus
600
+ if s == "":
601
+ return np.nan
602
+ negative = False
603
+ if s.startswith("(") and s.endswith(")"):
604
+ negative = True
605
+ s = s[1:-1]
606
+ for sym in symbols:
607
+ s = s.replace(sym, "")
608
+ if thousands:
609
+ s = s.replace(thousands, "")
610
+ if decimal != ".":
611
+ s = s.replace(decimal, ".")
612
+ s = s.strip()
613
+ # Accounting/ERP trailing minus ("1234.56-") signals a negative.
614
+ trailing_minus = s.endswith("-")
615
+ # Extract ONE well-formed numeric token. If any *other* digits remain (e.g.
616
+ # "12ab34", "10-12"), the string is not a single number — return NaN instead of
617
+ # silently fusing the disjoint digit groups. Leading/trailing unit text
618
+ # ("1200 INR") carries no stray digits, so it is still stripped cleanly.
619
+ m = _NUMBER_TOKEN_RE.search(s)
620
+ if not m:
621
+ return np.nan
622
+ token = m.group(0)
623
+ leftover = s[: m.start()] + s[m.end() :]
624
+ if any(ch.isdigit() for ch in leftover):
625
+ return np.nan
626
+ try:
627
+ result = float(token)
628
+ except ValueError:
629
+ return np.nan
630
+ if negative:
631
+ result = -abs(result)
632
+ elif trailing_minus and not token.startswith("-"):
633
+ result = -result
634
+ return result
635
+
636
+
637
+ @register_op(
638
+ "parse_number",
639
+ coerce=_coerce_parse_number,
640
+ compact=lambda p: _prune(p, {"decimal": ".", "thousands": ",", "symbols": []}),
641
+ )
642
+ def parse_number(
643
+ series: pd.Series,
644
+ decimal: str = ".",
645
+ thousands: str = ",",
646
+ symbols: list[str] | None = None,
647
+ ) -> pd.Series:
648
+ """Parse messy numeric strings (``"₹1,20,000"``, ``"(1,200)"``, ``"1200 INR"``) to floats.
649
+
650
+ Handles currency symbols, thousands separators (incl. Indian grouping), stray
651
+ unit text, and accountant-style parentheses negatives. Unparseable cells become
652
+ NaN. Configure ``decimal``/``thousands`` for European formats.
653
+ """
654
+ syms = [str(s) for s in (symbols or [])]
655
+ return series.map(lambda v: _parse_number_scalar(v, decimal, thousands, syms))
656
+
657
+
658
+ #: Every accepted ``cast`` target. A typo here used to load and fail at replay.
659
+ CAST_TARGETS = frozenset(
660
+ {
661
+ "float", "float64", "number", "int", "integer", "int64", "string", "str",
662
+ "text", "bool", "boolean", "datetime", "date", "category",
663
+ }
664
+ )
665
+
666
+
667
+ def _check_cast_target(to: Any) -> str:
668
+ if not isinstance(to, str) or to.strip().lower() not in CAST_TARGETS:
669
+ raise RecipeError(
670
+ f"Op 'cast' has unknown target {to!r}. Valid targets: "
671
+ f"{', '.join(sorted(CAST_TARGETS))}."
672
+ )
673
+ return to
674
+
675
+
676
+ def _coerce_cast(raw: Any) -> dict:
677
+ if isinstance(raw, dict):
678
+ if "to" not in raw:
679
+ raise RecipeError("Op 'cast' requires a 'to' target type, e.g. `cast: float`.")
680
+ return {"to": _check_cast_target(raw["to"])}
681
+ if isinstance(raw, str):
682
+ return {"to": _check_cast_target(raw)}
683
+ raise RecipeError("Op 'cast' expects a target type, e.g. `cast: float`.")
684
+
685
+
686
+ _TRUE_TOKENS = {"true", "t", "yes", "y", "1"}
687
+ _FALSE_TOKENS = {"false", "f", "no", "n", "0"}
688
+
689
+
690
+ @register_op("cast", coerce=_coerce_cast, compact=lambda p: p["to"])
691
+ def cast(series: pd.Series, to: str) -> pd.Series:
692
+ """Cast a column to ``float``/``int``/``string``/``bool``/``datetime``/``category``.
693
+
694
+ ``int`` and ``bool`` use pandas' nullable dtypes so missing values survive the
695
+ cast instead of raising or being coerced to a sentinel. Note that ``int``
696
+ *rounds* fractional values using banker's rounding (round-half-to-even, e.g.
697
+ ``2.5 → 2``, ``1.5 → 2``) rather than truncating toward zero; the change is
698
+ recorded in the diff. Parse messy numeric strings with ``parse_number`` first.
699
+ """
700
+ to = str(to).lower()
701
+ if to in ("float", "float64", "number"):
702
+ return pd.to_numeric(series, errors="coerce").astype("float64")
703
+ if to in ("int", "integer", "int64"):
704
+ numeric = pd.to_numeric(series, errors="coerce")
705
+ return numeric.round().astype("Int64")
706
+ if to in ("string", "str", "text"):
707
+ return series.astype("string")
708
+ if to in ("bool", "boolean"):
709
+ def to_bool(v: Any) -> Any:
710
+ if _is_na(v):
711
+ return pd.NA
712
+ if isinstance(v, bool):
713
+ return v
714
+ token = str(v).strip().casefold()
715
+ if token in _TRUE_TOKENS:
716
+ return True
717
+ if token in _FALSE_TOKENS:
718
+ return False
719
+ return pd.NA
720
+ return series.map(to_bool).astype("boolean")
721
+ if to in ("datetime", "date"):
722
+ # Deterministic, order-independent parse (see parse_dates_to_datetime).
723
+ return parse_dates_to_datetime(series, None)
724
+ if to == "category":
725
+ return series.astype("category")
726
+ raise OpError(f"Unknown cast target {to!r}.")
727
+
728
+
729
+ def _coerce_round(raw: Any) -> dict:
730
+ if isinstance(raw, dict):
731
+ raw = raw.get("decimals", 0)
732
+ if raw is None:
733
+ raw = 0
734
+ if isinstance(raw, bool) or not isinstance(raw, (int, float, str)):
735
+ raise RecipeError(f"Op 'round' expects a number of decimals, got {raw!r}.")
736
+ return {"decimals": int(raw)}
737
+
738
+
739
+ @register_op(
740
+ "round",
741
+ coerce=_coerce_round,
742
+ compact=lambda p: p.get("decimals", 0),
743
+ )
744
+ def round_op(series: pd.Series, decimals: int = 0) -> pd.Series:
745
+ """Round a numeric column to ``decimals`` places."""
746
+ return pd.to_numeric(series, errors="coerce").round(decimals)
747
+
748
+
749
+ # ---------------------------------------------------------------------------
750
+ # Date ops
751
+ # ---------------------------------------------------------------------------
752
+ def _coerce_parse_date(raw: Any) -> dict:
753
+ raw = raw or {}
754
+ if not isinstance(raw, dict):
755
+ raise RecipeError("Op 'parse_date' expects a mapping of parameters.")
756
+ # `allowed` is the README's alias for `formats`.
757
+ formats = raw.get("formats", raw.get("allowed"))
758
+ if isinstance(formats, str):
759
+ raise RecipeError(
760
+ "Op 'parse_date' formats must be a list, e.g. formats: ['%d/%m/%Y'] — a bare "
761
+ "string would be read one character at a time."
762
+ )
763
+ if formats is not None and not isinstance(formats, (list, tuple)):
764
+ raise RecipeError(
765
+ f"Op 'parse_date' formats must be a list of strftime patterns, got "
766
+ f"{type(formats).__name__}."
767
+ )
768
+ if formats and any(not isinstance(f, str) for f in formats):
769
+ raise RecipeError("Op 'parse_date' formats must all be strings.")
770
+ return {
771
+ "formats": list(formats) if formats else None,
772
+ "dayfirst": bool(raw.get("dayfirst", False)),
773
+ "yearfirst": bool(raw.get("yearfirst", False)),
774
+ "output": raw.get("output", "%Y-%m-%d"),
775
+ }
776
+
777
+
778
+ _DAYFIRST_SLASH = frozenset({"%d/%m/%Y", "%d-%m-%Y", "%d.%m.%Y", "%d/%m/%y", "%d-%m-%y"})
779
+ _MONTHFIRST_SLASH = frozenset({"%m/%d/%Y", "%m-%d-%Y", "%m/%d/%y"})
780
+
781
+
782
+ def _reconcile_date_formats(formats: list[str], dayfirst: bool) -> list[str]:
783
+ """Drop the non-preferred d/m vs m/d family so ambiguous cells cannot swap."""
784
+ has_day = any(f in _DAYFIRST_SLASH for f in formats)
785
+ has_month = any(f in _MONTHFIRST_SLASH for f in formats)
786
+ if not (has_day and has_month):
787
+ return formats
788
+ drop = _MONTHFIRST_SLASH if dayfirst else _DAYFIRST_SLASH
789
+ return [f for f in formats if f not in drop]
790
+
791
+
792
+ def _naive_datetimes(series: pd.Series) -> pd.Series:
793
+ """Force a parse result to tz-naive ``datetime64[ns]``.
794
+
795
+ A value carrying an offset (``2024-01-01T10:00:00Z``) parses to a tz-aware series,
796
+ which cannot be stored in a tz-naive one: older pandas silently turns the column to
797
+ ``object`` and the following ``.dt`` access then fails.
798
+ """
799
+ if getattr(series.dtype, "tz", None) is not None:
800
+ return series.dt.tz_convert("UTC").dt.tz_localize(None)
801
+ if not pd.api.types.is_datetime64_any_dtype(series.dtype):
802
+ converted = pd.to_datetime(series, errors="coerce", utc=True)
803
+ if getattr(converted.dtype, "tz", None) is not None:
804
+ converted = converted.dt.tz_convert("UTC").dt.tz_localize(None)
805
+ return converted
806
+ return series
807
+
808
+
809
+ def parse_dates_to_datetime(
810
+ series: pd.Series,
811
+ formats: list[str] | None,
812
+ dayfirst: bool = False,
813
+ yearfirst: bool = False,
814
+ ) -> pd.Series:
815
+ """Parse to a datetime64 Series (NaT where nothing matched). Shared with drift."""
816
+ flex_fallback = False
817
+ if not formats:
818
+ # No explicit formats: coalesce over the common formats first — deterministic
819
+ # and NOT order-dependent, unlike a bare format-less pd.to_datetime which locks
820
+ # onto the first row's inferred format and silently nulls otherwise-valid dates
821
+ # (and behaves differently across pandas versions).
822
+ from .profile import COMMON_DATE_FORMATS
823
+
824
+ formats = list(COMMON_DATE_FORMATS)
825
+ flex_fallback = True
826
+
827
+ formats = _reconcile_date_formats(list(formats), dayfirst=bool(dayfirst))
828
+
829
+ result = pd.Series(pd.NaT, index=series.index, dtype="datetime64[ns]")
830
+ for fmt in formats:
831
+ mask = result.isna() & series.notna()
832
+ if not mask.any():
833
+ break
834
+ parsed = _naive_datetimes(pd.to_datetime(series[mask], format=fmt, errors="coerce"))
835
+ result.loc[mask] = parsed
836
+
837
+ if flex_fallback:
838
+ remaining = result.isna() & series.notna()
839
+ if remaining.any():
840
+ import warnings
841
+
842
+ with warnings.catch_warnings():
843
+ warnings.simplefilter("ignore") # the dayfirst inference warning is expected here
844
+ flex = pd.to_datetime(
845
+ series[remaining], errors="coerce", dayfirst=dayfirst, yearfirst=yearfirst
846
+ )
847
+ result.loc[remaining] = _naive_datetimes(flex)
848
+ return _naive_datetimes(result)
849
+
850
+
851
+ @register_op(
852
+ "parse_date",
853
+ aliases=("allowed",),
854
+ coerce=_coerce_parse_date,
855
+ compact=lambda p: _prune(
856
+ p, {"formats": None, "dayfirst": False, "yearfirst": False, "output": "%Y-%m-%d"}
857
+ ),
858
+ )
859
+ def parse_date(
860
+ series: pd.Series,
861
+ formats: list[str] | None = None,
862
+ dayfirst: bool = False,
863
+ yearfirst: bool = False,
864
+ output: str = "%Y-%m-%d",
865
+ ) -> pd.Series:
866
+ """Parse mixed-format dates and normalise them.
867
+
868
+ Each declared ``format`` is tried in order (coalescing), so a column mixing
869
+ ``31/01/2024`` and ``2024-01-31`` resolves cleanly. With no formats given,
870
+ falls back to dateutil parsing honouring ``dayfirst``. ``output`` is a strftime
871
+ pattern (default ISO ``%Y-%m-%d``); pass ``"datetime"`` to keep datetime dtype.
872
+ """
873
+ dt = parse_dates_to_datetime(series, formats, dayfirst=dayfirst, yearfirst=yearfirst)
874
+ if str(output).lower() in ("datetime", "raw", "none"):
875
+ return dt
876
+ fmt = "%Y-%m-%d" if str(output).lower() in ("iso", "date") else output
877
+ return dt.dt.strftime(fmt).where(dt.notna(), np.nan)
878
+
879
+
880
+ # ---------------------------------------------------------------------------
881
+ # Category ops
882
+ # ---------------------------------------------------------------------------
883
+ def _coerce_normalize_values(raw: Any) -> dict:
884
+ if isinstance(raw, dict) and ("map" in raw and isinstance(raw["map"], dict)):
885
+ return {
886
+ "map": dict(raw["map"]),
887
+ "case_insensitive": bool(raw.get("case_insensitive", False)),
888
+ }
889
+ if isinstance(raw, dict):
890
+ # The whole mapping is the value map (README shorthand).
891
+ return {"map": dict(raw), "case_insensitive": False}
892
+ raise RecipeError("Op 'normalize_values' expects a mapping of old -> new values.")
893
+
894
+
895
+ def _compact_normalize_values(p: dict) -> Any:
896
+ if p.get("case_insensitive"):
897
+ return {"map": p.get("map", {}), "case_insensitive": True}
898
+ return p.get("map", {})
899
+
900
+
901
+ @register_op(
902
+ "normalize_values",
903
+ coerce=_coerce_normalize_values,
904
+ compact=_compact_normalize_values,
905
+ free_form=True,
906
+ )
907
+ def normalize_values(
908
+ series: pd.Series,
909
+ map: dict[Any, Any] | None = None, # noqa: A002 - matches recipe key name
910
+ case_insensitive: bool = False,
911
+ ) -> pd.Series:
912
+ """Canonicalise category variants via an explicit ``old -> new`` mapping."""
913
+ mapping = map or {}
914
+ if case_insensitive:
915
+ folded = {str(k).strip().casefold(): v for k, v in mapping.items()}
916
+
917
+ def fn(v: Any) -> Any:
918
+ if _is_na(v):
919
+ return v
920
+ return folded.get(str(v).strip().casefold(), v)
921
+
922
+ return series.map(fn)
923
+
924
+ def fn_exact(v: Any) -> Any:
925
+ if _is_na(v):
926
+ return v
927
+ return mapping.get(v, mapping.get(str(v), v))
928
+
929
+ return series.map(fn_exact)
930
+
931
+
932
+ # ---------------------------------------------------------------------------
933
+ # Currency extraction (column op that emits an extra column)
934
+ # ---------------------------------------------------------------------------
935
+ #: Symbol / trailing-code -> ISO 4217 code. Deliberately small and explicit.
936
+ CURRENCY_SYMBOLS = {
937
+ "₹": "INR",
938
+ "$": "USD",
939
+ "€": "EUR",
940
+ "£": "GBP",
941
+ "¥": "JPY",
942
+ "₩": "KRW",
943
+ "₽": "RUB",
944
+ "R$": "BRL",
945
+ "¢": "USD",
946
+ }
947
+ _CODE_RE = re.compile(r"\b([A-Z]{3})\b")
948
+
949
+
950
+ def _coerce_extract_currency(raw: Any) -> dict:
951
+ raw = raw or {}
952
+ if isinstance(raw, str):
953
+ return {"to": raw, "default": None}
954
+ if not isinstance(raw, dict):
955
+ raise RecipeError("Op 'extract_currency' expects a target column name or mapping.")
956
+ to = raw.get("to")
957
+ if to is not None and not isinstance(to, str):
958
+ raise RecipeError("Op 'extract_currency' target column name must be a string.")
959
+ return {"to": to, "default": raw.get("default")}
960
+
961
+
962
+ def _detect_currency_scalar(value: Any, default: str | None) -> Any:
963
+ if _is_na(value):
964
+ return default if default is not None else np.nan
965
+ s = str(value)
966
+ for symbol, code in CURRENCY_SYMBOLS.items():
967
+ if symbol in s:
968
+ return code
969
+ m = _CODE_RE.search(s.upper())
970
+ if m and m.group(1) in _KNOWN_CODES:
971
+ return m.group(1)
972
+ return default if default is not None else np.nan
973
+
974
+
975
+ _KNOWN_CODES = set(CURRENCY_SYMBOLS.values()) | {
976
+ "USD", "EUR", "GBP", "INR", "JPY", "CNY", "AUD", "CAD", "CHF", "SGD",
977
+ "HKD", "NZD", "SEK", "NOK", "DKK", "ZAR", "AED", "SAR", "KRW", "RUB", "BRL",
978
+ }
979
+
980
+
981
+ def _compact_extract_currency(p: dict) -> Any:
982
+ if p.get("default") is None:
983
+ return p.get("to")
984
+ return _prune(p, {"default": None})
985
+
986
+
987
+ @register_op(
988
+ "extract_currency",
989
+ scope="column",
990
+ coerce=_coerce_extract_currency,
991
+ compact=_compact_extract_currency,
992
+ )
993
+ def extract_currency(
994
+ series: pd.Series,
995
+ to: str | None = None,
996
+ default: str | None = None,
997
+ ) -> ColumnOpResult:
998
+ """Read the currency out of a money column into a new ISO-code column.
999
+
1000
+ Returns the source column **unchanged** (so a following ``parse_number`` still
1001
+ sees ``"₹1,20,000"``) plus a new column named ``to`` (default ``<col>_currency``)
1002
+ holding ``"INR"``, ``"USD"``, … This is the one op that adds a column, which is
1003
+ why it returns a :class:`ColumnOpResult`.
1004
+ """
1005
+ target = to or f"{series.name}_currency"
1006
+ codes = series.map(lambda v: _detect_currency_scalar(v, default))
1007
+ codes.name = target
1008
+ return ColumnOpResult(series=series, emit={target: codes})
1009
+
1010
+
1011
+ # ---------------------------------------------------------------------------
1012
+ # Frame ops
1013
+ # ---------------------------------------------------------------------------
1014
+ def _coerce_dedup(raw: Any) -> dict:
1015
+ raw = raw or {}
1016
+ if isinstance(raw, (list, tuple)):
1017
+ return {"subset": list(raw), "keep": "first", "ignore_case": False}
1018
+ if not isinstance(raw, dict):
1019
+ raise RecipeError("Op 'dedup' expects a mapping or a list of subset columns.")
1020
+ keep = raw.get("keep", "first")
1021
+ if keep is False or str(keep).lower() == "false":
1022
+ keep = False
1023
+ if keep not in ("first", "last", False):
1024
+ raise RecipeError(
1025
+ f"Op 'dedup' keep must be 'first', 'last' or false, got {keep!r}."
1026
+ )
1027
+ subset = raw.get("subset")
1028
+ if isinstance(subset, str):
1029
+ subset = [subset]
1030
+ if subset is not None and not isinstance(subset, (list, tuple)):
1031
+ raise RecipeError("Op 'dedup' subset must be a column name or a list of names.")
1032
+ return {
1033
+ "subset": list(subset) if subset else None,
1034
+ "keep": keep,
1035
+ "ignore_case": bool(raw.get("ignore_case", False)),
1036
+ }
1037
+
1038
+
1039
+ @register_op(
1040
+ "dedup",
1041
+ scope="frame",
1042
+ coerce=_coerce_dedup,
1043
+ compact=lambda p: _prune(p, {"subset": None, "keep": "first", "ignore_case": False}),
1044
+ )
1045
+ def dedup(
1046
+ df: pd.DataFrame,
1047
+ subset: list[str] | None = None,
1048
+ keep: Any = "first",
1049
+ ignore_case: bool = False,
1050
+ ) -> pd.DataFrame:
1051
+ """Drop duplicate rows, optionally keyed on ``subset`` and case-insensitively.
1052
+
1053
+ Row index is preserved for surviving rows so the executor can attribute the
1054
+ dropped rows in the cell-level diff.
1055
+ """
1056
+ if subset:
1057
+ missing = [c for c in subset if c not in df.columns]
1058
+ if missing:
1059
+ raise OpError(f"dedup subset references unknown column(s): {missing}")
1060
+ if not ignore_case:
1061
+ return df.drop_duplicates(subset=subset, keep=keep)
1062
+
1063
+ # Case/whitespace-insensitive: build a normalized key frame, dedup on it.
1064
+ key_cols = subset if subset else list(df.columns)
1065
+ key = df[key_cols].apply(
1066
+ lambda col: col.map(lambda v: v.strip().casefold() if isinstance(v, str) else v)
1067
+ )
1068
+ mask = ~key.duplicated(keep=keep if keep is not False else False)
1069
+ return df[mask]
1070
+
1071
+
1072
+ def _coerce_drop_columns(raw: Any) -> dict:
1073
+ if isinstance(raw, str):
1074
+ return {"columns": [raw]}
1075
+ if isinstance(raw, (list, tuple)):
1076
+ cols = list(raw)
1077
+ elif isinstance(raw, dict):
1078
+ cols = list(raw.get("columns", []))
1079
+ else:
1080
+ raise RecipeError("Op 'drop_columns' expects a column name or list.")
1081
+ if any(not isinstance(c, str) for c in cols):
1082
+ raise RecipeError("Op 'drop_columns' expects column names as strings.")
1083
+ return {"columns": cols}
1084
+
1085
+
1086
+ @register_op(
1087
+ "drop_columns",
1088
+ scope="frame",
1089
+ coerce=_coerce_drop_columns,
1090
+ compact=lambda p: p.get("columns", []),
1091
+ )
1092
+ def drop_columns(df: pd.DataFrame, columns: list[str]) -> pd.DataFrame:
1093
+ """Drop the named columns (ignoring any that are already absent)."""
1094
+ present = [c for c in columns if c in df.columns]
1095
+ return df.drop(columns=present)
1096
+
1097
+
1098
+ # ---------------------------------------------------------------------------
1099
+ # Units
1100
+ # ---------------------------------------------------------------------------
1101
+ # Conversion factors to a family base unit (g, m, L). Keys are lowercased.
1102
+ UNIT_FAMILIES: dict[str, dict[str, float]] = {
1103
+ "mass": {"kg": 1000.0, "g": 1.0, "mg": 0.001, "lb": 453.59237, "oz": 28.349523125},
1104
+ "length": {
1105
+ "km": 1000.0, "m": 1.0, "cm": 0.01, "mm": 0.001,
1106
+ "in": 0.0254, "ft": 0.3048, "yd": 0.9144,
1107
+ },
1108
+ "volume": {"l": 1.0, "ml": 0.001, "gal": 3.785411784},
1109
+ }
1110
+ _UNIT_TO_FAMILY: dict[str, str] = {
1111
+ u: fam for fam, units in UNIT_FAMILIES.items() for u in units
1112
+ }
1113
+ _UNIT_ALIASES = {"litre": "l", "liter": "l", "litres": "l", "liters": "l", "grams": "g", "kilos": "kg"}
1114
+ _UNIT_VALUE_RE = re.compile(
1115
+ r"^\s*([+-]?\d+(?:[.,]\d+)?)\s*([A-Za-z]+)\s*$"
1116
+ )
1117
+
1118
+
1119
+ def parse_unit_scalar(value: Any) -> tuple[float, str] | None:
1120
+ """Parse ``"5kg"`` / ``"5000 g"`` → ``(5.0, "kg")``. Returns ``None`` if not a unit value."""
1121
+ if _is_na(value):
1122
+ return None
1123
+ if isinstance(value, (int, float, bool)):
1124
+ return None
1125
+ m = _UNIT_VALUE_RE.match(str(value))
1126
+ if not m:
1127
+ return None
1128
+ num_s, unit_s = m.group(1), m.group(2).casefold()
1129
+ unit_s = _UNIT_ALIASES.get(unit_s, unit_s)
1130
+ if unit_s not in _UNIT_TO_FAMILY:
1131
+ return None
1132
+ num_s = num_s.replace(",", ".")
1133
+ try:
1134
+ return float(num_s), unit_s
1135
+ except ValueError:
1136
+ return None
1137
+
1138
+
1139
+ def _check_unit(to: str) -> str:
1140
+ unit = _UNIT_ALIASES.get(str(to).casefold(), str(to).casefold())
1141
+ if unit not in _UNIT_TO_FAMILY:
1142
+ raise RecipeError(
1143
+ f"Op 'normalize_unit' has unknown target unit {to!r}. Known units: "
1144
+ f"{', '.join(sorted(_UNIT_TO_FAMILY))}."
1145
+ )
1146
+ return unit
1147
+
1148
+
1149
+ def _coerce_normalize_unit(raw: Any) -> dict:
1150
+ if isinstance(raw, str):
1151
+ return {"to": _check_unit(raw), "emit_unit_column": None}
1152
+ raw = raw or {}
1153
+ if not isinstance(raw, dict):
1154
+ raise RecipeError("Op 'normalize_unit' expects a target unit string or mapping.")
1155
+ to = _check_unit(raw.get("to", "g"))
1156
+ emit = raw.get("emit_unit_column")
1157
+ if emit is not None and not isinstance(emit, str):
1158
+ raise RecipeError("Op 'normalize_unit' emit_unit_column must be a column name.")
1159
+ return {"to": to, "emit_unit_column": emit}
1160
+
1161
+
1162
+ def _compact_normalize_unit(p: dict) -> Any:
1163
+ if p.get("emit_unit_column"):
1164
+ return _prune(p, {"emit_unit_column": None})
1165
+ return p.get("to", "g")
1166
+
1167
+
1168
+ @register_op(
1169
+ "normalize_unit",
1170
+ coerce=_coerce_normalize_unit,
1171
+ compact=_compact_normalize_unit,
1172
+ )
1173
+ def normalize_unit(
1174
+ series: pd.Series,
1175
+ to: str = "g",
1176
+ emit_unit_column: str | None = None,
1177
+ ) -> ColumnOpResult | pd.Series:
1178
+ """Convert mixed unit strings (``"5kg"``, ``"5000 g"``, ``"5 KG"``) to a single unit.
1179
+
1180
+ Values that are already bare numbers are treated as already in ``to``. Unparseable
1181
+ cells become NaN. Optionally emit the original unit code into ``emit_unit_column``.
1182
+ """
1183
+ to = _UNIT_ALIASES.get(str(to).casefold(), str(to).casefold())
1184
+ if to not in _UNIT_TO_FAMILY:
1185
+ raise OpError(f"Unknown target unit {to!r}.")
1186
+ target_family = _UNIT_TO_FAMILY[to]
1187
+ target_factor = UNIT_FAMILIES[target_family][to]
1188
+
1189
+ amounts: list[float] = []
1190
+ units: list[Any] = []
1191
+ for v in series.tolist():
1192
+ parsed = parse_unit_scalar(v)
1193
+ if parsed is None:
1194
+ if isinstance(v, (int, float)) and not isinstance(v, bool) and not _is_na(v):
1195
+ amounts.append(float(v))
1196
+ units.append(to)
1197
+ elif isinstance(v, str) and _is_strict_looking_number(v):
1198
+ try:
1199
+ amounts.append(float(v.replace(",", ".").strip()))
1200
+ units.append(to)
1201
+ except ValueError:
1202
+ amounts.append(np.nan)
1203
+ units.append(None)
1204
+ else:
1205
+ amounts.append(np.nan)
1206
+ units.append(None)
1207
+ continue
1208
+ amount, unit = parsed
1209
+ family = _UNIT_TO_FAMILY[unit]
1210
+ if family != target_family:
1211
+ amounts.append(np.nan)
1212
+ units.append(unit)
1213
+ continue
1214
+ base = amount * UNIT_FAMILIES[family][unit]
1215
+ amounts.append(base / target_factor)
1216
+ units.append(unit)
1217
+
1218
+ out = pd.Series(amounts, index=series.index, dtype="float64")
1219
+ if emit_unit_column:
1220
+ return ColumnOpResult(out, emit={emit_unit_column: pd.Series(units, index=series.index)})
1221
+ return out
1222
+
1223
+
1224
+ def _is_strict_looking_number(value: str) -> bool:
1225
+ s = value.strip().replace(",", ".")
1226
+ return bool(re.match(r"^[+-]?\d+(\.\d+)?$", s))
1227
+
1228
+
1229
+ __all__ = [
1230
+ "OP_REGISTRY",
1231
+ "OpSpec",
1232
+ "ColumnOpResult",
1233
+ "register_op",
1234
+ "get_op",
1235
+ "list_ops",
1236
+ "normalize_op",
1237
+ "apply_column_op",
1238
+ "apply_frame_op",
1239
+ "parse_dates_to_datetime",
1240
+ "parse_unit_scalar",
1241
+ "CAST_TARGETS",
1242
+ "UNIT_FAMILIES",
1243
+ "CURRENCY_SYMBOLS",
1244
+ "DEFAULT_NA_TOKENS",
1245
+ ]