Skip to content

Commit c4ffef4

Browse files
fix(textclean): an unknown field type must not lose its protection silently (#474)
config_for_field matched the declared semantic_type exactly -- case-sensitive and untrimmed -- and returned the caller's base config on no match. The failure mode ran backwards for a safety-oriented library: a recognised structural type was protected from lossy transformation, while an unrecognised one received it in full. Declaring more about a column bought less protection. With TextCleanConfig(case="lower", remove_punctuation=True): identifier / ticker / email 'Sekr3t-P@ss!!!' preserved password / api_key / secret 'sekr3tpss' Ticker / TICKER / 'ticker ' 'sekr3tpss' identifer (typo) / e-mail 'sekr3tpss' So a near-miss in the type name quietly downgraded a protected column, and a credential-bearing column named by a type outside the vocabulary was mangled. The lookup is now normalised (casefold + strip), so Ticker, TICKER and "ticker " resolve to ticker. A type that is still unrecognised warns when a lossy option is active, which is the courtesy fieldcheck already extends for an unknown semantic_type; the message names the column's type, the specific options that will run, and the known types. Deliberately limited: - Every lossy option is opt-in, so a default fd.clean never reached this path and is unchanged. - The default config has no lossy option, so no warning fires on the common path -- verified by a test, since the suite treats freshdata warnings as errors. - semantic_type=None is not a misspelling and stays silent. - Behaviour for an unrecognised type is otherwise unchanged. Whether it should instead default to the structural (lossless) config is a real behaviour change and is left for a decision rather than taken here. Worth noting textclean's 28 known types are a superset of both fieldcheck._KNOWN_SEMANTIC_TYPES (23) and SEMANTIC_TYPES (17), so this was lookup strictness rather than a third taxonomy. 21 new tests; full suite 6643 passed / 0 failed, coverage 93.90%. Co-authored-by: Kevin Costner <kiran.gangalakunta@gmail.com>
1 parent 6f53878 commit c4ffef4

3 files changed

Lines changed: 217 additions & 34 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ adheres to [Semantic Versioning](https://semver.org/).
77
## [Unreleased]
88

99
### Fixed
10+
- An unrecognised `semantic_type` no longer receives *more* text cleaning than
11+
a recognised one. `textclean.config_for_field` matched the declared type
12+
exactly — case-sensitively and untrimmed — and fell through to the caller's
13+
config on no match, so `"Ticker"` was cleaned more aggressively than
14+
`"ticker"`, and a column declared `"password"` or `"api_key"` was case-folded
15+
and stripped of punctuation while `"identifier"` was protected. The lookup is
16+
now normalised (casefolded and trimmed), and an unrecognised type warns when
17+
a lossy option is active, as `fieldcheck` already does for an unknown
18+
`semantic_type`. Only opt-in options (`case`, `remove_punctuation`,
19+
`strip_html`, `strip_urls`, `max_char_repeat`, `max_length`) were ever
20+
affected, so a default `fd.clean` is unchanged and the default path stays
21+
warning-free.
1022
- Currency parsing no longer assumes a US locale for every currency. Every
1123
comma was deleted and the dot was taken as the decimal point regardless of
1224
the currency present, so `"EUR 1.200,50"` read as **1.2005** — a thousand-fold

‎src/freshdata/textclean.py‎

Lines changed: 117 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import html as _html
2222
import re
2323
import unicodedata
24+
import warnings
2425
from collections.abc import Callable, Mapping
2526
from dataclasses import dataclass, field, replace
2627
from html.parser import HTMLParser
@@ -45,12 +46,23 @@
4546
_IRREGULAR_WS_RE = re.compile("[\u00a0\u202f\u2000-\u200a\u2007\u3000]")
4647
_WS_RE = re.compile(r"\s+")
4748
# Unicode punctuation → ASCII equivalents (smart quotes, dashes, ellipsis).
48-
_PUNCT_MAP = str.maketrans({
49-
"‘": "'", "’": "'", "‚": "'", "′": "'",
50-
"“": '"', "”": '"', "„": '"', "″": '"',
51-
"–": "-", "—": "-", "―": "-", "−": "-",
52-
"…": "...",
53-
})
49+
_PUNCT_MAP = str.maketrans(
50+
{
51+
"‘": "'",
52+
"’": "'",
53+
"‚": "'",
54+
"′": "'",
55+
"“": '"',
56+
"”": '"',
57+
"„": '"',
58+
"″": '"',
59+
"–": "-",
60+
"—": "-",
61+
"―": "-",
62+
"−": "-",
63+
"…": "...",
64+
}
65+
)
5466

5567

5668
class _TextExtractor(HTMLParser):
@@ -121,21 +133,64 @@ def __post_init__(self) -> None:
121133

122134
#: Field types whose values are structural — punctuation, casing and length
123135
#: are meaningful, so lossy operations are withheld even if configured.
124-
_STRUCTURAL_TYPES = frozenset({
125-
"numeric", "integer", "float", "currency_amount", "rate", "percentage",
126-
"identifier", "account_number", "national_id", "postal_code",
127-
"email", "url", "phone", "date_like", "date", "datetime",
128-
"stock_ticker", "ticker", "category_code", "boolean_like",
129-
})
130-
_ENTITY_TYPES = frozenset({
131-
"person_name", "company_name", "entity_name", "city", "country", "address",
132-
})
136+
_STRUCTURAL_TYPES = frozenset(
137+
{
138+
"numeric",
139+
"integer",
140+
"float",
141+
"currency_amount",
142+
"rate",
143+
"percentage",
144+
"identifier",
145+
"account_number",
146+
"national_id",
147+
"postal_code",
148+
"email",
149+
"url",
150+
"phone",
151+
"date_like",
152+
"date",
153+
"datetime",
154+
"stock_ticker",
155+
"ticker",
156+
"category_code",
157+
"boolean_like",
158+
}
159+
)
160+
_ENTITY_TYPES = frozenset(
161+
{
162+
"person_name",
163+
"company_name",
164+
"entity_name",
165+
"city",
166+
"country",
167+
"address",
168+
}
169+
)
133170
#: Content-bearing types where typography *is* content: an em-dash, a curly
134171
#: quote or a prime mark (12″) in a product name or a comment carries meaning,
135172
#: so the punctuation→ASCII mapping is withheld for them.
136173
_CONTENT_TYPES = frozenset({"free_text", "text"}) | _ENTITY_TYPES
137174

138175

176+
def _lossy_options(cfg: TextCleanConfig) -> list[str]:
177+
"""Names of the opt-in, information-destroying options enabled on *cfg*."""
178+
enabled = []
179+
if cfg.case is not None:
180+
enabled.append(f"case={cfg.case!r}")
181+
if cfg.remove_punctuation:
182+
enabled.append("remove_punctuation")
183+
if cfg.strip_html:
184+
enabled.append("strip_html")
185+
if cfg.strip_urls:
186+
enabled.append("strip_urls")
187+
if cfg.max_char_repeat is not None:
188+
enabled.append("max_char_repeat")
189+
if cfg.max_length is not None:
190+
enabled.append("max_length")
191+
return enabled
192+
193+
139194
def config_for_field(
140195
semantic_type: str | None,
141196
base: TextCleanConfig | None = None,
@@ -149,17 +204,38 @@ def config_for_field(
149204
mapping only runs on untyped or structural fields.
150205
"""
151206
cfg = base or TextCleanConfig()
152-
if semantic_type in _STRUCTURAL_TYPES:
207+
# Match on a normalized name: "Ticker", "TICKER" and "ticker " all name the
208+
# same field type, and an exact-only lookup silently downgraded them to the
209+
# unrestricted config -- i.e. a near-miss removed protection rather than
210+
# adding it.
211+
key = semantic_type.strip().casefold() if isinstance(semantic_type, str) else None
212+
if key in _STRUCTURAL_TYPES:
153213
return replace(
154-
cfg, strip_html=False, strip_urls=False, case=None,
155-
remove_punctuation=False, max_char_repeat=None, max_length=cfg.max_length,
214+
cfg,
215+
strip_html=False,
216+
strip_urls=False,
217+
case=None,
218+
remove_punctuation=False,
219+
max_char_repeat=None,
220+
max_length=cfg.max_length,
156221
)
157-
if semantic_type in _ENTITY_TYPES:
222+
if key in _ENTITY_TYPES:
158223
case = cfg.case if cfg.case == "title" else None
159-
return replace(cfg, remove_punctuation=False, case=case,
160-
normalize_punctuation=False)
161-
if semantic_type in _CONTENT_TYPES:
224+
return replace(cfg, remove_punctuation=False, case=case, normalize_punctuation=False)
225+
if key in _CONTENT_TYPES:
162226
return replace(cfg, normalize_punctuation=False)
227+
if key is not None and _lossy_options(cfg):
228+
# An unrecognized type keeps the caller's config, which means a lossy
229+
# option applies in full. Say so, the way fieldcheck already warns for
230+
# an unknown semantic_type, so a misspelling is not silent.
231+
warnings.warn(
232+
f"unknown semantic_type {semantic_type!r} for text cleaning: no "
233+
f"field-specific protection applies, so lossy options "
234+
f"({', '.join(_lossy_options(cfg))}) run on this column in full. "
235+
f"Known types: {sorted(_STRUCTURAL_TYPES | _ENTITY_TYPES | _CONTENT_TYPES)}",
236+
UserWarning,
237+
stacklevel=2,
238+
)
163239
return cfg
164240

165241

@@ -211,9 +287,9 @@ def step(name: str, new: str) -> None:
211287
step("strip_urls", _URL_RE.sub(" ", out))
212288
if cfg.strip_control_chars:
213289
cleaned = "".join(
214-
c for c in out
215-
if not (unicodedata.category(c) == "Cc" and c not in "\t\n\r")
216-
and c not in _BIDI_MARKS
290+
c
291+
for c in out
292+
if not (unicodedata.category(c) == "Cc" and c not in "\t\n\r") and c not in _BIDI_MARKS
217293
)
218294
step("strip_control_chars", cleaned)
219295
if cfg.strip_zero_width:
@@ -225,8 +301,10 @@ def step(name: str, new: str) -> None:
225301
pattern = r"(.)\1{" + str(n) + ",}"
226302
step("collapse_repeats", re.sub(pattern, lambda m: m.group(1) * n, out))
227303
if cfg.remove_punctuation:
228-
step("remove_punctuation", "".join(
229-
c for c in out if not unicodedata.category(c).startswith("P")))
304+
step(
305+
"remove_punctuation",
306+
"".join(c for c in out if not unicodedata.category(c).startswith("P")),
307+
)
230308
if cfg.case:
231309
step(f"case_{cfg.case}", getattr(out, cfg.case)())
232310
for name, fn in cfg.custom:
@@ -327,8 +405,9 @@ def clean_text(
327405
types = dict(field_types or {})
328406

329407
for col in cols:
330-
cfg = config_for_field(types[col], config) if col in types else (
331-
config or TextCleanConfig())
408+
cfg = (
409+
config_for_field(types[col], config) if col in types else (config or TextCleanConfig())
410+
)
332411
series = df[col]
333412
report.values_seen += int(series.notna().sum())
334413
# ponytail: per-cell python loop; vectorize per-op if profiling demands
@@ -344,11 +423,15 @@ def clean_text(
344423
if result.changed:
345424
positions.append(pos)
346425
cleaned_values.append(result.cleaned)
347-
report.changes.append({
348-
"row": idx, "column": str(col),
349-
"original": val, "cleaned": result.cleaned,
350-
"transforms": list(result.transforms),
351-
})
426+
report.changes.append(
427+
{
428+
"row": idx,
429+
"column": str(col),
430+
"original": val,
431+
"cleaned": result.cleaned,
432+
"transforms": list(result.transforms),
433+
}
434+
)
352435
if positions:
353436
new_col = series.copy()
354437
new_col.iloc[positions] = cleaned_values

‎tests/test_field_type_lookup.py‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""An unrecognised field type must not silently lose protection (FD2-004).
2+
3+
``config_for_field`` matched the declared ``semantic_type`` exactly -- case
4+
sensitive and untrimmed -- and fell through to the caller's base config on no
5+
match. The failure mode ran backwards for a safety-oriented library: a
6+
recognised structural type was protected from lossy transformation, while an
7+
unrecognised one received it in full. So ``"Ticker"`` was cleaned more
8+
aggressively than ``"ticker"``, and a column declared ``"password"`` was
9+
case-folded and stripped of punctuation.
10+
11+
Every lossy option is opt-in, so a default ``fd.clean`` was never affected.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import warnings
17+
18+
import pytest
19+
20+
from freshdata.textclean import TextCleanConfig, clean_text_value, config_for_field
21+
22+
LOSSY = TextCleanConfig(case="lower", remove_punctuation=True)
23+
SECRET = "Sekr3t-P@ss!!!"
24+
25+
26+
@pytest.mark.parametrize("declared", ["ticker", "Ticker", "TICKER", " ticker ", "\tticker\n"])
27+
def test_case_and_whitespace_variants_resolve_to_the_same_protection(declared):
28+
"""'Ticker' names the same field type as 'ticker'."""
29+
assert config_for_field(declared, LOSSY).case is None
30+
assert clean_text_value(SECRET, config_for_field(declared, LOSSY)).cleaned == SECRET
31+
32+
33+
@pytest.mark.parametrize("declared", ["email", "EMAIL", "Person_Name", "IDENTIFIER", "Free_Text"])
34+
def test_other_types_normalise_too(declared):
35+
"""The normalisation is not special-cased to one type."""
36+
with warnings.catch_warnings():
37+
warnings.simplefilter("error")
38+
config_for_field(declared, LOSSY) # must not warn: all are known
39+
40+
41+
def test_a_structural_type_still_refuses_lossy_options():
42+
assert clean_text_value(SECRET, config_for_field("identifier", LOSSY)).cleaned == SECRET
43+
44+
45+
def test_free_text_still_accepts_them():
46+
"""The protection must not become blanket: free text really is free text."""
47+
assert clean_text_value(SECRET, config_for_field("free_text", LOSSY)).cleaned == "sekr3tpss"
48+
49+
50+
@pytest.mark.parametrize("declared", ["password", "api_key", "secret", "identifer", "e-mail"])
51+
def test_an_unknown_type_warns_when_a_lossy_option_is_active(declared):
52+
"""Silence let a misspelling quietly downgrade a column.
53+
54+
fieldcheck already warns for an unknown semantic_type; this is the same
55+
courtesy on the cleaning side.
56+
"""
57+
with pytest.warns(UserWarning, match="unknown semantic_type"):
58+
config_for_field(declared, LOSSY)
59+
60+
61+
def test_the_warning_names_the_offending_options_and_the_known_types():
62+
with pytest.warns(UserWarning) as caught:
63+
config_for_field("password", LOSSY)
64+
message = str(caught[0].message)
65+
assert "password" in message
66+
assert "case='lower'" in message and "remove_punctuation" in message
67+
assert "ticker" in message # the known-type list is included
68+
69+
70+
def test_no_warning_on_the_default_path():
71+
"""A default config has no lossy option, so an unknown type is harmless."""
72+
with warnings.catch_warnings():
73+
warnings.simplefilter("error")
74+
config_for_field("password", TextCleanConfig())
75+
config_for_field(None, TextCleanConfig())
76+
77+
78+
def test_none_is_not_reported_as_an_unknown_type():
79+
"""No declaration is not a misspelling; it must stay quiet."""
80+
with warnings.catch_warnings():
81+
warnings.simplefilter("error")
82+
assert config_for_field(None, LOSSY).case == "lower"
83+
84+
85+
def test_a_non_string_type_does_not_crash():
86+
with warnings.catch_warnings():
87+
warnings.simplefilter("ignore")
88+
assert config_for_field(42, LOSSY).case == "lower" # type: ignore[arg-type]

0 commit comments

Comments
 (0)