|
| 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