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.
- cleanframe/__init__.py +169 -0
- cleanframe/__main__.py +5 -0
- cleanframe/_util.py +438 -0
- cleanframe/_version.py +1 -0
- cleanframe/api.py +559 -0
- cleanframe/cli.py +688 -0
- cleanframe/codegen.py +666 -0
- cleanframe/dataio.py +506 -0
- cleanframe/detectors/__init__.py +43 -0
- cleanframe/detectors/base.py +221 -0
- cleanframe/detectors/categories.py +199 -0
- cleanframe/detectors/contacts.py +105 -0
- cleanframe/detectors/currency.py +112 -0
- cleanframe/detectors/dates.py +204 -0
- cleanframe/detectors/dedup.py +150 -0
- cleanframe/detectors/nulls.py +109 -0
- cleanframe/detectors/outliers.py +73 -0
- cleanframe/detectors/schema_mapping.py +125 -0
- cleanframe/detectors/text.py +105 -0
- cleanframe/detectors/units.py +86 -0
- cleanframe/diff.py +369 -0
- cleanframe/drift.py +283 -0
- cleanframe/errors.py +66 -0
- cleanframe/executor.py +229 -0
- cleanframe/fingerprint.py +83 -0
- cleanframe/issues.py +186 -0
- cleanframe/llm.py +811 -0
- cleanframe/ops.py +1245 -0
- cleanframe/planner.py +353 -0
- cleanframe/profile.py +413 -0
- cleanframe/py.typed +1 -0
- cleanframe/quality.py +81 -0
- cleanframe/readfix.py +160 -0
- cleanframe/recipe.py +398 -0
- cleanframe/report.py +345 -0
- cleanframe/result.py +144 -0
- cleanframe/schema.py +259 -0
- cleanframe/streaming.py +354 -0
- cleanframe/types.py +119 -0
- cleanframe/validate.py +363 -0
- cleanframe/workbook.py +370 -0
- cleanframe_engine-0.3.0.dist-info/METADATA +323 -0
- cleanframe_engine-0.3.0.dist-info/RECORD +46 -0
- cleanframe_engine-0.3.0.dist-info/WHEEL +4 -0
- cleanframe_engine-0.3.0.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|