Skip to content

Commit 096fa9b

Browse files
test(dtypes): cover the protection and boolean paths, and pin three crashes (#500)
* test(dtypes): cover the protection and boolean paths, and pin three crashes Two of four mutation lanes on steps/dtypes.py. 22 tests. Every candidate mutant was first confirmed against the FULL 7370-test suite rather than the targeted subset, because a mutant that survives a subset may still be covered elsewhere. That filter removed 6 of 18 candidates -- a third of what a subset-only run would have reported as gaps. Only full-suite survivors got tests. The real gaps were concentrated in refine_numeric_after_semantic, which was reachable from no existing test at all: only cleaner.py calls it, and only after an applied numeric semantic repair. Two mutants live there, including one where the preserve_leading_zeros=False opt-out silently stops working. The rest cover the boolean vocabulary subset test, the NA-fill on match masks, the literal (non-regex) decimal replacement, and the dateish screen answering "no" when it has nothing to inspect. Three mutants are proven equivalent by execution rather than argument: the int64 fit guard is unreachable because the integrality check rejects every non-finite value first (line trace over 22 adversarial series, zero hits on the guard), and two mask-normalisation comparisons are no-ops on pandas 2.3.3 (exhaustive enumeration of every mask pattern over {True, False, NA} for lengths 0-5 against 6 target series, 2562 configurations, zero divergences). Both are scoped to this pandas version and say so. Also pins three crashes found while building those adversarial inputs, all reachable from fd.clean on default settings and none of them mutants: duplicate index labels break the formatted-number rescue, and a complex value beside text crashes the numeric finalizer. Pinned as characterization tests so a fix has to come past them; neither is fixed here. The complex-crash test asserts the finalizer directly, not through fd.clean: the end-to-end route is order-dependent and an fd.clean-level pytest.raises was genuinely flaky under randomised ordering. A companion test records that order dependence and asserts only what holds either way -- the frame comes back unchanged, or it raises, never a silently coerced column. Tests only; no src change. * test(dtypes): keep the new tests version-portable across pandas 1.5 and 2.x CI's py3.9 lane (pandas 1.5.3) failed on three assertions, none of which tested freshdata: two illustrate what pandas would do without the dateish screen, and pandas 1.x answers differently -- a bare int coerces to NaT rather than Timestamp(42), and format="mixed" did not exist before 2.0. Both are now gated on PANDAS_MAJOR >= 2, keeping their documentation value on modern pandas without asserting third-party behaviour that varies. The third was a third spelling of the complex-modulo crash: pandas 1.x raises "can't mod complex numbers" where 2.x raises the numpy ufunc error or Python's unsupported-operand error. The assertion now accepts any of the three, because the point is that it raises at all, not how it phrases it. Verified on both lanes locally this time: py3.9/pandas 1.5.3 and py3.12/pandas 2.3.3, 22 passed on each.
1 parent e1520d7 commit 096fa9b

2 files changed

Lines changed: 640 additions & 0 deletions

File tree

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
"""Type inference: the flags that decide when a column is *silently* retyped.
2+
3+
Every test here pins one decision that ``steps/dtypes.py`` makes with a
4+
boolean literal or a subset/identity comparison — the switches that say
5+
"this text is a boolean vocabulary", "this cell matches the formatted-number
6+
pattern", "this column contains separator noise", "this separator is a
7+
literal, not a regex". They are the cheap-looking constants whose flip turns
8+
a conservative *leave it as text* into a silent conversion (or the reverse),
9+
which is exactly the class of change no assertion in ``test_dtypes.py``
10+
notices.
11+
12+
Companion to ``test_dtypes.py``: that file covers the happy paths and the
13+
threshold/quarantine behaviour; this one covers the boundaries those tests
14+
step around.
15+
"""
16+
17+
import datetime as dt
18+
19+
import pandas as pd
20+
import pytest
21+
22+
import freshdata as fd
23+
from freshdata._util import sample_series
24+
from freshdata.config import CleanConfig
25+
from freshdata.steps.dtypes import (
26+
_BOOL_WORDS,
27+
_finalize_numeric,
28+
_number_format,
29+
_rescue_formatted,
30+
_to_numeric_or_none,
31+
suggest_conversion,
32+
)
33+
34+
35+
def clean1(values, **options):
36+
"""Clean a single-column frame and return the resulting column."""
37+
return fd.clean(pd.DataFrame({"v": values}), **options)["v"]
38+
39+
40+
def is_string(dtype) -> bool:
41+
return pd.api.types.is_object_dtype(dtype) or isinstance(dtype, pd.StringDtype)
42+
43+
44+
# ── boolean vocabulary: the subset test is a *subset-or-equal* test ──────────
45+
46+
47+
def test_column_holding_the_entire_boolean_vocabulary_is_still_boolean():
48+
"""A column whose values are *exactly* the true/false vocabulary — all
49+
eight spellings, nothing else — is the most boolean column there is. The
50+
detection asks ``values <= _BOOL_WORDS``; a strict ``<`` would reject the
51+
one column that uses the whole vocabulary while accepting every subset of
52+
it, so the conversion would depend on which spellings happen to appear.
53+
"""
54+
words = ["true", "t", "yes", "y", "false", "f", "no", "n"]
55+
assert {w.casefold() for w in words} == _BOOL_WORDS # the equality case
56+
57+
s = clean1(words)
58+
assert s.dtype == bool
59+
assert s.tolist() == [True, True, True, True, False, False, False, False]
60+
61+
62+
def test_full_vocabulary_with_mixed_case_and_a_missing_cell_still_boolean():
63+
"""The equality case again, through ``casefold`` and with a missing cell
64+
so the nullable ``boolean`` dtype (not ``bool``) is the result."""
65+
words = ["TRUE", "T", "Yes", "Y", "False", "f", "NO", "n", None]
66+
s = clean1(words, drop_empty_rows=False)
67+
assert s.dtype == "boolean"
68+
assert s.tolist()[:8] == [True, True, True, True, False, False, False, False]
69+
assert pd.isna(s.iloc[8])
70+
71+
72+
# ── the decimal separator is replaced literally, never as a regex ────────────
73+
74+
75+
@pytest.mark.parametrize("separator", ["|", "^"])
76+
def test_decimal_separator_is_replaced_literally_not_as_a_regex(separator):
77+
"""``cleanup`` rewrites the locale decimal separator to ``"."`` with
78+
``regex=False``. The separator is caller-supplied and may be a regex
79+
metacharacter: ``"|"`` as a pattern matches the empty string at every
80+
position and ``"^"`` matches the start, so a regex replace would sprinkle
81+
dots through the value instead of swapping the separator.
82+
"""
83+
_, _, cleanup = _number_format(CleanConfig(decimal=separator, thousands=","))
84+
values = [f"1{separator}5", f"2{separator}25", f"3{separator}75", f"10{separator}0"]
85+
assert cleanup(pd.Series(values, dtype=object)).tolist() == [
86+
"1.5", "2.25", "3.75", "10.0",
87+
]
88+
89+
s = clean1(values, decimal=separator)
90+
assert s.dtype == "float64"
91+
assert s.tolist() == [1.5, 2.25, 3.75, 10.0]
92+
93+
94+
def test_comma_decimal_locale_is_unaffected_by_the_literal_replace():
95+
"""The ordinary European locale keeps working: ``","`` is not a
96+
metacharacter, so it is the metacharacter separators above that pin the
97+
``regex=False`` flag, not this case."""
98+
s = clean1(["1.234,56", "2.000,00", "3.500,75"], decimal=",", thousands=".")
99+
assert s.dtype == "float64"
100+
assert s.tolist() == [1234.56, 2000.0, 3500.75]
101+
102+
103+
# ── a cell that is not readable text is neither "noise" nor a "match" ────────
104+
105+
106+
def test_unreadable_cells_do_not_count_as_separator_noise():
107+
"""The formatted-number second pass is only attempted when the *sample*
108+
actually contains a currency/separator character (``na=False``: a cell
109+
with no text view is not evidence of noise).
110+
111+
``b"\\xff"`` is a non-UTF-8 BLOB, so the text view of the column has a
112+
missing cell (see ``_text_view``). Counting that cell as noise starts the
113+
formatted-number pass on a column that has no separators at all, and
114+
``"+ 12"`` — which plain ``to_numeric`` rejects — is then rewritten to a
115+
number, so the BLOB is quarantined as an unparseable casualty. The column
116+
must stay text instead.
117+
"""
118+
values = [b"\xff", "+ 12", "+ 34", "+ 56"]
119+
target, converted, n_coerced = suggest_conversion(
120+
pd.Series(values, dtype=object), CleanConfig(numeric_threshold=0.7)
121+
)
122+
assert (target, converted, n_coerced) == ("none", None, 0)
123+
124+
out, report = fd.clean(
125+
pd.DataFrame({"v": values}), numeric_threshold=0.7, return_report=True,
126+
)
127+
assert is_string(out["v"].dtype)
128+
assert out["v"].tolist() == values # the BLOB is neither decoded nor nulled
129+
assert "v" not in report.coerced_cells
130+
131+
132+
def test_unreadable_cell_is_not_a_formatted_number_straggler():
133+
"""``_rescue_formatted`` re-parses the values a plain ``to_numeric``
134+
nulled, but only those that match the formatted-number pattern. A
135+
non-UTF-8 cell has no text view at all (``_text_view`` yields a missing
136+
cell for it), and a missing cell is not a match — so for a column whose
137+
only casualties are unreadable/word values there is nothing to rescue and
138+
the parse is handed back untouched, not rewritten.
139+
"""
140+
formatted_re, _, cleanup = _number_format(CleanConfig())
141+
values = ["1", "2", "abc", b"\xff"]
142+
143+
s = pd.Series(values, dtype=object)
144+
parsed = _to_numeric_or_none(s)
145+
# Untouched means untouched: nothing matched, so the same object comes
146+
# back rather than a rewritten copy.
147+
assert _rescue_formatted(s, parsed, formatted_re, cleanup) is parsed
148+
149+
# Second, independent check that no write happens: with repeated index
150+
# labels a write through ``.loc`` cannot succeed, so a rescue attempt on
151+
# the unreadable cell would raise instead of returning the parse.
152+
dup = pd.Series(values, index=[0, 0, 1, 1], dtype=object)
153+
dup_parsed = _to_numeric_or_none(dup)
154+
out = _rescue_formatted(dup, dup_parsed, formatted_re, cleanup)
155+
assert out.tolist()[:2] == [1.0, 2.0]
156+
assert out.isna().tolist() == [False, False, True, True]
157+
158+
# Control: a real formatted straggler in the same shape *is* rescued.
159+
with_straggler = pd.Series(["1", "2", "$1,234.56", b"\xff"], dtype=object)
160+
rescued = _rescue_formatted(
161+
with_straggler, _to_numeric_or_none(with_straggler), formatted_re, cleanup,
162+
)
163+
assert rescued.tolist()[:3] == [1.0, 2.0, 1234.56]
164+
165+
166+
def test_missing_cells_are_not_formatted_number_matches():
167+
"""A missing cell has no text, so it cannot match the formatted-number
168+
pattern (``fillna(False)``). If it did, a column where *no* value matches
169+
would still enter the formatted-number pass, and a sample-based rejection
170+
would be silently overturned by the presence of a missing value.
171+
172+
The column below is deliberately one the cheap sample pre-screen rejects:
173+
``sample_size=1`` draws the single unparseable value, so the full-column
174+
parse is never attempted. ``"1e5"`` parses as a number but never matches
175+
the formatted-number pattern (no exponent in it), so the only "match" a
176+
mutated fill could produce is the missing cell.
177+
"""
178+
s = pd.Series(["1e5"] * 19 + ["$x", None], dtype="string")
179+
nonnull = s.dropna()
180+
181+
def seed_sampling(value):
182+
return next(
183+
r for r in range(50) if sample_series(nonnull, 1, r).tolist() == [value]
184+
)
185+
186+
# Sample the unparseable value: the pre-screen declines the column, and
187+
# the missing cell must not overturn that by posing as a match.
188+
declined = CleanConfig(sample_size=1, random_state=seed_sampling("$x"))
189+
assert suggest_conversion(s, declined) == ("none", None, 0)
190+
191+
# Control, same column: sample a parseable value and the column converts,
192+
# so it is the sample — not the missing cell — that decides.
193+
accepted = CleanConfig(sample_size=1, random_state=seed_sampling("1e5"))
194+
target, converted, _ = suggest_conversion(s, accepted)
195+
assert target == "numeric"
196+
assert converted.tolist()[0] == 100000
197+
198+
199+
# ── defects found while writing the tests above: pinned, not fixed ──────────
200+
201+
202+
def test_duplicate_index_labels_currently_break_the_formatted_number_rescue():
203+
"""Pinned defect — current behaviour, deliberately NOT fixed here.
204+
205+
``_rescue_formatted`` writes the rescued values back with
206+
``parsed.loc[rescued.index] = rescued.to_numpy()``. When the frame's index
207+
repeats a label, ``.loc`` expands that label to *every* row carrying it, so
208+
the assignment length stops matching the values and the whole clean raises
209+
``ValueError`` instead of converting the column. The identical frame with a
210+
unique index converts, which is what makes this a defect rather than a
211+
documented limitation.
212+
"""
213+
values = [str(i) for i in range(19)] + ["$1,234.56"]
214+
repeated = pd.DataFrame({"v": values}, index=list(range(10)) * 2)
215+
with pytest.raises(ValueError, match="list-like indexer"):
216+
fd.clean(repeated, verbose=False)
217+
218+
s = clean1(values) # control: same values, unique index
219+
assert s.dtype == "float64"
220+
assert s.tolist()[-1] == 1234.56
221+
222+
223+
def test_complex_value_beside_text_currently_crashes_the_numeric_finalizer():
224+
"""Pinned defect — current behaviour, deliberately NOT fixed here.
225+
226+
``_finalize_numeric``'s integrality check is ``nonnull % 1 == 0``, and
227+
``complex`` has no ``%``, so a complex value that reaches the finalizer
228+
raises ``TypeError`` instead of the column being left alone. A column of
229+
complex values *alone* is declined earlier (``infer_dtype`` reports
230+
``"complex"``, which is not a text-ish kind); one complex value beside text
231+
makes the column ``"mixed"``, which is how it gets there.
232+
233+
Asserted against ``_finalize_numeric`` directly rather than through
234+
``fd.clean``. The end-to-end route is **order-dependent**: the same
235+
``fd.clean(...)`` call raises in a fresh process but does not after certain
236+
other work has happened in the same process, so an ``fd.clean``-level
237+
``pytest.raises`` here was genuinely flaky under randomised test ordering.
238+
The unit-level behaviour is stable, and it is the defect. See
239+
``test_the_end_to_end_route_to_this_crash_is_order_dependent``.
240+
"""
241+
# Three spellings of the same failure across pandas/numpy versions: object
242+
# dtype falls back to Python's ``%`` ("unsupported operand type(s)"), a
243+
# numpy complex array hits the ufunc ("ufunc 'remainder' not supported"),
244+
# and pandas 1.x raises its own ("can't mod complex numbers"). The point is
245+
# that it raises at all.
246+
with pytest.raises(TypeError, match="remainder|unsupported operand|mod complex"):
247+
_finalize_numeric(pd.Series([complex(1, 2), 3], dtype=object))
248+
249+
# An all-complex column is declined before reaching the finalizer, which is
250+
# the behaviour the mixed column should have as well.
251+
out = fd.clean(pd.DataFrame({"v": [complex(1, 2), complex(3, 4)]}), verbose=False)
252+
assert str(out["v"].dtype) == "complex128"
253+
254+
255+
def test_the_end_to_end_route_to_this_crash_is_order_dependent():
256+
"""Records an unexplained order dependence rather than hiding it.
257+
258+
In a fresh process ``fd.clean`` on a complex-beside-text column raises the
259+
``TypeError`` above. After some other pandas work in the same process it
260+
does not — the column stops being routed to the numeric path. Both
261+
``pd.api.types.infer_dtype`` (always ``"mixed"``) and ``_finalize_numeric``
262+
(always raises) were checked and are stable, so the divergence is upstream
263+
of the finalizer in ``clean``'s routing. The mechanism was not identified.
264+
265+
This test asserts only what is stable in either state: the frame is
266+
returned unchanged, or the call raises ``TypeError`` — never a silently
267+
coerced column that has lost the complex value.
268+
"""
269+
frame = pd.DataFrame({"v": [complex(1, 2), "abc", "3"]})
270+
try:
271+
out = fd.clean(frame, verbose=False)
272+
except TypeError as exc:
273+
assert any(m in str(exc) for m in ("remainder", "unsupported operand", "mod complex"))
274+
return
275+
assert out["v"].tolist() == [complex(1, 2), "abc", "3"]
276+
277+
def test_date_objects_with_a_missing_cell_still_normalize_to_datetime64():
278+
"""``infer_dtype(s, skipna=True)`` is what makes a column of ``date``
279+
objects report as ``"date"`` even when a cell is missing. Inferring with
280+
the missing cell included reports ``"mixed"``, which sends the column
281+
through the *text* heuristics — and those look for date-shaped strings,
282+
find none (these are ``date`` objects, not text), and leave the column as
283+
object. One missing cell would then decide the dtype of the column.
284+
"""
285+
values = [dt.date(2021, 1, 5), None, dt.date(2021, 3, 9)]
286+
s = clean1(values, drop_empty_rows=False)
287+
assert str(s.dtype).startswith("datetime64")
288+
assert s.isna().tolist() == [False, True, False]
289+
assert s.tolist()[0] == pd.Timestamp("2021-01-05")
290+
291+
values = [dt.datetime(2021, 1, 5, 9, 30), pd.NaT, dt.datetime(2021, 3, 9, 10, 0)]
292+
s = clean1(values, drop_empty_rows=False)
293+
assert str(s.dtype).startswith("datetime64")
294+
assert s.tolist()[2] == pd.Timestamp("2021-03-09 10:00")

0 commit comments

Comments
 (0)