pandas-dev/pandas · #71076

BUG: ArrowExtensionArray silently truncated fractional values set from a list into an integer dtype

jbrockmendel · merged Oct 24, 20263 files · 100 + / 11 −
doc/source/whatsnew/v3.2.0.rst1 + / 1 −
@@ -320,7 +320,7 @@ ExtensionArray ^^^^^^^^^^^^^^ - Bug in :func:`read_csv` and other parsing of strings into a decimal :class:`ArrowDtype` raising ``ArrowInvalid`` instead of parsing the values (:issue:`69838`) - Bug in :meth:`Series.mode` and :meth:`DataFrame.mode` with ``dropna=False`` raising ``TypeError`` for ``"boolean"`` dtype (:issue:`70487`)-- Bug in :meth:`Series.replace`, :meth:`Series.where`, :meth:`Series.fillna`, item assignment and related methods on a PyArrow-backed integer dtype silently truncating a fractional value (``replace(2, 1.5)`` stored ``1``); these now raise ``TypeError``, while :meth:`Index.where`, :meth:`Index.putmask` and :meth:`Index.fillna` upcast, matching the nullable integer dtypes (:issue:`68638`)+- Bug in :meth:`Series.replace`, :meth:`Series.where`, :meth:`Series.fillna`, item assignment and related methods on a PyArrow-backed integer dtype silently truncating a fractional value, including one passed in a list (``replace(2, 1.5)`` stored ``1``); these now raise ``TypeError``, while :meth:`Index.where`, :meth:`Index.putmask` and :meth:`Index.fillna` upcast, matching the nullable integer dtypes (:issue:`68638`, :issue:`71076`) - Bug in arithmetic and logical operations on PyArrow-backed data raising ``pyarrow.ArrowInvalid`` or ``pyarrow.ArrowTypeError`` instead of ``TypeError`` when the other operand contains objects PyArrow cannot convert (:issue:`62682`) - Bug in comparisons between PyArrow-backed data and objects PyArrow cannot convert raising ``pyarrow.ArrowInvalid`` or ``pyarrow.ArrowTypeError``; ``==``/``!=`` now compare elementwise, ordered comparisons raise ``TypeError``, and a missing value on either side propagates instead of answering ``False`` (:issue:`62682`) 
pandas/core/arrays/arrow/array.py60 + / 3 −
@@ -3470,10 +3470,12 @@ def _validate_setitem_value(self, value):         pa_type = self._pa_array.type         if pa.types.is_integer(pa_type):             # GH#68638 pyarrow truncates the fractional part instead of refusing-            #  the value; the masked dtypes raise+            #  the value; the masked dtypes raise. Its safe cast catches a numpy+            #  array but not a list, so list-likes are checked here too             value = lib.item_from_zerodim(value)-            if _truncates_to_integer(value):-                raise TypeError(f"Invalid value '{value!s}' for dtype '{self.dtype}'")+            bad = _first_truncated_to_integer(value)+            if bad is not None:+                raise TypeError(f"Invalid value '{bad!s}' for dtype '{self.dtype}'")         self_temporal = _is_temporal_pa_type(pa_type)         if self_temporal is not None:             value_temporal = _is_temporal_value(value)@@ -4844,6 +4846,61 @@ def _truncates_to_integer(value) -> bool:     return False  +def _may_truncate_to_integer(values) -> bool:+    """+    Whether ``values`` could hold anything an integer-typed pyarrow column would+    truncate. Cheaper than inspecting the entries. See GH#71076.+    """+    return lib.infer_dtype(values, skipna=True) not in (+        "integer",+        "boolean",+        "string",+        "empty",+    )+++def _first_truncated_to_integer(value):+    """+    The first entry of ``value`` an integer-typed pyarrow column would truncate,+    or None if there is none. See GH#71076.+    """+    if not is_list_like(value) or isinstance(value, (pa.Array, pa.ChunkedArray)):+        # a pyarrow container is cast, not converted, so pyarrow rejects a lossy+        #  value itself; see test_setitem_lossy_float_pyarrow_container_raises+        return value if _truncates_to_integer(value) else None++    dtype = getattr(value, "dtype", None)+    if getattr(dtype, "kind", None) in ("i", "u", "b"):+        # skip materializing a container whose own dtype has nothing to drop+        return None+    if isinstance(value, (list, tuple)) and not _may_truncate_to_integer(value):+        # same, for a container that has no dtype to ask+        return None++    if isinstance(value, np.ndarray):+        arr = value+    else:+        try:+            arr = np.asarray(value)+        except ValueError:+            # ragged, handled the way _box_pa_array handles it+            arr = construct_1d_object_array_from_listlike(value)+    if arr.dtype.kind == "f":+        lossy = ~isna(arr) & ((arr != np.trunc(arr)) | np.isinf(arr))+        return arr[lossy].flat[0] if lossy.any() else None+    if arr.dtype.kind != "O":+        # pyarrow refuses every other numpy dtype that could have one+        return None+    flat = arr.ravel()+    if not _may_truncate_to_integer(flat):+        return None+    for item in flat:+        item = lib.item_from_zerodim(item)+        if _truncates_to_integer(item):+            return item+    return None++ def transpose_homogeneous_pyarrow(     arrays: Sequence[ArrowExtensionArray], ) -> list[ArrowExtensionArray]:
pandas/tests/arrays/arrow/test_indexing.py39 + / 7 −
@@ -637,12 +637,13 @@ def test_replace_lossy_float_raises(box):         lambda ser: ser.mask([False, True, False], 1.5),         lambda ser: ser.fillna(1.5),         lambda ser: ser.clip(lower=1.5),+        lambda ser: ser.update(pd.Series([1.5])),         lambda ser: ser.reindex([0, 1, 5], fill_value=1.5),     ],-    ids=["where", "mask", "fillna", "clip", "reindex"],+    ids=["where", "mask", "fillna", "clip", "update", "reindex"], ) def test_lossy_float_raises(op):-    # GH#68638 methods that pass a scalar through _validate_setitem_value+    # GH#68638 callers that route through _validate_setitem_value     ser = pd.Series([1, None, 3], dtype="int64[pyarrow]")     with pytest.raises(TypeError, match="Invalid value '1.5'"):         op(ser)@@ -657,23 +658,47 @@ def test_lossy_float_raises(op):         Fraction(3, 2),         float("inf"),         Decimal("Infinity"),+        [9.5, 8.5],+        (9.5, 8.5),+        np.array([9.5, 8.5]),+        np.array([np.inf, 1.0]),+        [Decimal("1.5"), Decimal("2")],+        [Fraction(3, 2), 8],     ], ) def test_setitem_lossy_float_raises(value):     # GH#68638 pyarrow truncated a fractional value; a non-finite one raised-    #  ArrowInvalid+    #  ArrowInvalid. GH#71076 pa.array's safe cast catches a numpy array but+    #  truncates a list     arr = pd.array([1, 2, 3], dtype="int64[pyarrow]")     with pytest.raises(TypeError, match="Invalid value"):-        arr[1] = value+        arr[:2] = value     assert list(arr) == [1, 2, 3]  -@pytest.mark.parametrize("value", [4, 4.0, Decimal("4"), np.array(4), Fraction(8, 2)])+@pytest.mark.parametrize(+    "box",+    [pa.array, lambda values: pa.chunked_array([values])],+    ids=["array", "chunked"],+)+def test_setitem_lossy_float_pyarrow_container_raises(box):+    # GH#71076 a pyarrow container is skipped by the check because pyarrow's own+    #  cast already refuses it, so it raises ArrowInvalid rather than TypeError+    arr = pd.array([1, 2, 3], dtype="int64[pyarrow]")+    with pytest.raises(pa.ArrowInvalid, match="truncated"):+        arr[:2] = box([9.5, 8.5])+    assert list(arr) == [1, 2, 3]+++@pytest.mark.parametrize(+    "value",+    [4, 4.0, Decimal("4"), np.array(4), Fraction(8, 2), [4, 4.0], np.array([4.0, 4.0])],+) def test_setitem_integral_value_still_accepted(value):     # GH#68638 only a fractional value is refused     arr = pd.array([1, 2, 3], dtype="int64[pyarrow]")-    arr[1] = value-    assert arr[1] == 4+    arr[:2] = value+    assert list(arr) == [4, 4, 3]   @pytest.mark.parametrize(@@ -701,6 +726,13 @@ def test_index_fillna_lossy_float_upcasts():     tm.assert_index_equal(result, expected)  +def test_fillna_lossy_float_list_like_raises():+    # GH#71076 a list-like fill value is checked too, not only a scalar+    arr = pd.array([1, None, 3], dtype="int64[pyarrow]")+    with pytest.raises(TypeError, match="Invalid value '8.5'"):+        arr.fillna([9.0, 8.5, 7.0])++ def test_arrow_string_accepts_pyarrow_string_scalar():     # GH#71075 Series.where stored a character code of the string     arr = pd.array(["a", None], dtype="string[pyarrow]")