pandas-dev/pandas · #70765

BUG: read_csv pyarrow engine silently wrapped out-of-range integer dtype (GH#38013)

jbrockmendel · merged Oct 19, 20264 files · 146 + / 6 −
doc/source/whatsnew/v3.2.0.rst1 + / 0 −
@@ -227,6 +227,7 @@ I/O - Bug in :func:`read_csv` silently misparsing non-ASCII ``thousands`` and ``decimal`` markers with the C engine; these now fall back to the Python engine with a :class:`errors.ParserWarning`, or raise ``ValueError`` if ``engine="c"`` is explicitly specified (:issue:`71158`) - Bug in :func:`read_csv` where a ``dtype`` given for a ``parse_dates`` column undid the date parsing, returning strings, objects, integers or categories instead of datetimes (:issue:`57512`) - Bug in :func:`read_csv` with ``engine="c"`` and ``comment`` not skipping a line containing only whitespace before the comment character (:issue:`55916`)+- Bug in :func:`read_csv` with ``engine="pyarrow"`` silently wrapping values outside the range of the requested integer ``dtype`` instead of raising (:issue:`38013`) - Bug in :func:`read_csv` with ``engine="python"`` and ``index_col`` reading a date like ``"20180101"`` as nanoseconds since the epoch when ``dtype`` is a pyarrow timestamp dtype (:issue:`24542`) - Bug in :func:`read_csv`, :func:`read_excel`, :func:`read_fwf` and :func:`read_html` returning wrong column labels and treating header rows as data when ``header`` was a list not in increasing order, such as ``header=[1, 0]`` (:issue:`47011`) - Bug in :func:`read_csv`, :meth:`DataFrame.to_csv` and other I/O functions raising ``URLError`` for ``sftp://`` URLs instead of reading or writing them with ``fsspec`` (:issue:`46765`)
pandas/io/_util.py61 + / 1 −
@@ -26,6 +26,8 @@ from pandas.errors import Pandas4Warning  from pandas.core.dtypes.common import pandas_dtype+from pandas.core.dtypes.dtypes import BaseMaskedDtype+from pandas.core.dtypes.missing import isna  import pandas as pd @@ -42,6 +44,7 @@     from pandas._typing import (         DtypeArg,         DtypeBackend,+        DtypeObj,     )      from pandas.core.dtypes.base import ExtensionDtype@@ -213,10 +216,12 @@ def _post_convert_dtypes(             dtype = pandas_dtype(dtype)          try:-            df = df.astype(dtype)+            casted = df.astype(dtype)         except TypeError as err:             # GH#44901 reraise to keep api consistent             raise ValueError(str(err)) from err+        validate_integer_casts(df, casted)+        df = casted          # GH#56136 IntegerDtype was used above to avoid lossy float64         #  conversion in pyarrow; convert back to numpy now that the data@@ -271,6 +276,61 @@ def _post_convert_dtypes(     return df  +def _numpy_dtype(dtype: DtypeObj) -> np.dtype | None:+    # the numpy dtype underlying a column's dtype, if any+    if isinstance(dtype, np.dtype):+        return dtype+    if isinstance(dtype, pd.SparseDtype):+        return dtype.subtype+    if isinstance(dtype, (BaseMaskedDtype, pd.ArrowDtype)):+        return dtype.numpy_dtype+    return None+++def _can_wrap(old: DtypeObj, new: DtypeObj) -> bool:+    """+    Whether casting from ``old`` to ``new`` can wrap around an integer value.+    """+    old_np = _numpy_dtype(old)+    new_np = _numpy_dtype(new)+    if old_np is None or new_np is None:+        return False+    if new_np.kind not in "iu" or old_np.kind not in "iuf":+        return False+    # NA cannot wrap, so e.g. Int64 -> int64 is as safe as int64 -> int64+    return not np.can_cast(old_np, new_np)+++def validate_integer_casts(original: pd.DataFrame, casted: pd.DataFrame) -> None:+    """+    Apply validate_integer_cast to each column of a DataFrame cast.+    """+    for i, (old, new) in enumerate(zip(original.dtypes, casted.dtypes, strict=True)):+        # check dtypes first; iloc on every column is slow for wide frames+        if _can_wrap(old, new):+            validate_integer_cast(original.iloc[:, i], new)+++def validate_integer_cast(original: pd.Series, dtype: DtypeObj) -> None:+    """+    Raise if casting a column to an integer ``dtype`` wrapped around (GH#38013).++    Discarding a float's fractional part is allowed, as in the python engine.+    """+    if not _can_wrap(original.dtype, dtype):+        return+    lo, hi = original.min(), original.max()+    if isna(lo):+        return+    info = np.iinfo(_numpy_dtype(dtype))+    # int() truncates exactly, so e.g. 2.0**63 does not equal int64 max+    if int(lo) < info.min or int(hi) > info.max:+        raise ValueError(+            f"cannot safely convert passed user dtype of {dtype} for "+            f"{_numpy_dtype(original.dtype)} dtyped data in column {original.name}"+        )++ def _maybe_convert_string_to_object(     data: pd.Series | pd.Index, ) -> pd.Series | pd.Index | None:
pandas/io/parsers/arrow_parser_wrapper.py11 + / 3 −
@@ -30,7 +30,11 @@  from pandas.core.arrays.arrow.array import to_pyarrow_type -from pandas.io._util import arrow_table_to_pandas+from pandas.io._util import (+    arrow_table_to_pandas,+    validate_integer_cast,+    validate_integer_casts,+) from pandas.io.common import mangle_dupe_names from pandas.io.parsers.base_parser import (     ParserBase,@@ -436,7 +440,9 @@ def _finalize_index(self, frame: DataFrame, multi_index_named: bool) -> DataFram                             and pandas_dtype(new_dtype).kind != "M"                             and parse_as_dates                         ):-                            frame[key] = frame[key].astype(new_dtype)+                            casted = frame[key].astype(new_dtype)+                            validate_integer_cast(frame[key], casted.dtype)+                            frame[key] = casted                         del self.dtype[key]              if self.dtype is not None and not isinstance(self.dtype, dict):@@ -495,10 +501,12 @@ def _finalize_dtype(                     if col not in parse_dates_cols                 }             try:-                frame = frame.astype(self.dtype)+                casted = frame.astype(self.dtype)             except TypeError as err:                 # GH#44901 reraise to keep api consistent                 raise ValueError(str(err)) from err+            validate_integer_casts(frame, casted)+            frame = casted         return frame      def _finalize_pandas_output(
pandas/tests/io/parser/dtypes/test_dtypes_basic.py73 + / 2 −
@@ -1051,7 +1051,6 @@ def test_leading_zeros_preserved_with_dtype_dict(all_parsers):     tm.assert_frame_equal(result, expected)  -@xfail_pyarrow  # pyarrow engine casts the parsed frame; wraps, or raises ValueError @pytest.mark.parametrize("dtype_backend", [lib.no_default, "numpy_nullable", "pyarrow"]) @pytest.mark.parametrize(     "dtype, err, msg",@@ -1069,19 +1068,25 @@ def test_out_of_range_integer_dtype_raises(all_parsers, dtype, err, msg, dtype_b     if dtype_backend == "pyarrow":         pytest.importorskip("pyarrow")     parser = all_parsers+    if parser.engine == "pyarrow":+        # this engine raises ValueError for the nullable dtypes too+        err = ValueError+        msg = "cannot safely (cast non-equivalent|convert passed user dtype)"     data = "x\n-1\n257\n"     with pytest.raises(err, match=msg):         parser.read_csv(StringIO(data), dtype={"x": dtype}, dtype_backend=dtype_backend)  -@xfail_pyarrow  # pyarrow engine casts the parsed frame; wraps, or raises ValueError @pytest.mark.parametrize(     "data, dtype",     [         # only fits uint64, so the c engine falls back to _try_uint64         ("x\n18446744073709551615\n1\n", "uint8"),         # the requested dtype is the int64 that overflowed into the fallback         ("x\n18446744073709551615\n1\n", "int64"),+        # one past the int64 max; the pyarrow engine reads it as a float+        #  whose cast to int64 can saturate rather than wrap+        ("x\n9223372036854775808\n", "int64"),         # parses as float rather than int, which both engines must check too         ("x\n300.0\n1.0\n", "uint8"),         ("x\n257.0\n1.0\n", "int8"),@@ -1103,3 +1108,69 @@ def test_wrap_object_columns_only_wraps_object():     result = wrap_object_columns({"a": ints, "b": strings}, dtype, pd.RangeIndex(2))     assert result["a"] is ints     tm.assert_series_equal(result["b"], pd.Series(strings, dtype=object))+++@pytest.mark.parametrize("dtype_backend", [lib.no_default, "numpy_nullable", "pyarrow"])+@pytest.mark.parametrize("index_col", [None, 0])+def test_out_of_range_int32_dtype_raises(all_parsers, index_col, dtype_backend):+    # GH#38013 with a non-default dtype_backend, the pyarrow engine casts+    #  index columns separately+    if dtype_backend == "pyarrow":+        pytest.importorskip("pyarrow")+    parser = all_parsers+    data = "x,y\n10000000000,0\n"+    with pytest.raises(ValueError, match="cannot safely convert passed user dtype"):+        parser.read_csv(+            StringIO(data),+            dtype={"x": "int32"},+            index_col=index_col,+            dtype_backend=dtype_backend,+        )+++@pytest.mark.parametrize(+    "data, dtype, dtype_backend, expected",+    [+        ("x\n1e16\n", "int64[pyarrow]", lib.no_default, 10**16),+        ("x\n1e16\n", "Int64", "pyarrow", 10**16),+        ("x\n9223372036854775808\n", "uint64[pyarrow]", lib.no_default, 2**63),+    ],+)+def test_in_range_integer_above_float_precision(+    all_parsers, data, dtype, dtype_backend, expected+):+    # GH#38013 in-range values above 2**53 must not be rejected+    pytest.importorskip("pyarrow")+    parser = all_parsers+    result = parser.read_csv(+        StringIO(data), dtype={"x": dtype}, dtype_backend=dtype_backend+    )+    assert result["x"].tolist() == [expected]+++def test_sparse_integer_dtype(pyarrow_parser_only):+    # GH#38013 the overflow check covers sparse dtypes by their subtype+    parser = pyarrow_parser_only+    result = parser.read_csv(+        StringIO("x\n1\n300\n"), dtype={"x": pd.SparseDtype("int64")}+    )+    expected = pd.DataFrame({"x": pd.arrays.SparseArray([1, 300], dtype="int64")})+    tm.assert_frame_equal(result, expected)+    with pytest.raises(ValueError, match="cannot safely convert passed user dtype"):+        parser.read_csv(StringIO("x\n1\n300\n"), dtype={"x": pd.SparseDtype("int8")})+++def test_integer_dtype_truncates_float(pyarrow_parser_only):+    # GH#38013 dropping the fractional part is not a wrap, as with the python+    #  engine+    parser = pyarrow_parser_only+    result = parser.read_csv(StringIO("x\n1.5\n127.9\n-128.9\n"), dtype={"x": "int8"})+    assert result["x"].tolist() == [1, 127, -128]+++def test_uint64_max_read_as_float_raises(pyarrow_parser_only):+    # GH#38013 a pyarrow inference limitation, not desired behavior: pyarrow+    #  reads this value as the float 2**64, which is out of range for uint64+    parser = pyarrow_parser_only+    with pytest.raises(ValueError, match="cannot safely convert passed user dtype"):+        parser.read_csv(StringIO("x\n18446744073709551615\n"), dtype={"x": "uint64"})