pandas-dev/pandas · #68926

BUG: astype/construction from float to datetime64 is an unchecked narrowing

jbrockmendel · merged Oct 2, 20269 files · 259 + / 11 −
doc/source/whatsnew/v3.2.0.rst1 + / 0 −
@@ -129,6 +129,7 @@ Categorical  Datetimelike ^^^^^^^^^^^^+- Bug in :meth:`DataFrame.astype`, :meth:`Series.astype`, :meth:`Index.astype`, :class:`DatetimeIndex`, :func:`array` and :func:`to_datetime` where ``float`` dtype data outside the ``int64`` range, such as ``inf``, silently became a valid-looking ``datetime64``/``timedelta64`` value or ``NaT``; these now raise ``OutOfBoundsDatetime``/``OutOfBoundsTimedelta`` (:issue:`68926`) - Bug in :meth:`DatetimeIndex.strftime`, :meth:`Series.dt.strftime`, and :meth:`DataFrame.to_csv` with ``date_format`` on Windows silently replacing values that :meth:`Timestamp.strftime` cannot format (e.g. ``%y`` for years before 1900) with their default string representation; these now raise ``ValueError`` (:issue:`58178`)  Timedelta
pandas/core/arrays/datetimes.py11 + / 2 −
@@ -60,6 +60,7 @@ from pandas.util._exceptions import find_stack_level from pandas.util._validators import validate_inclusive +from pandas.core.dtypes.astype import raise_if_float_outside_int64 from pandas.core.dtypes.common import (     DT64NS_DTYPE,     INT64_DTYPE,@@ -2643,10 +2644,11 @@ def _sequence_to_dt64(     Raises     ------     TypeError : PeriodDType data is passed+    OutOfBoundsDatetime : float data outside the int64 domain is passed     """      # By this point we are assured to have either a numpy array or Index-    data, copy = maybe_convert_dtype(data, copy, tz=tz)+    data, copy = maybe_convert_dtype(data, copy, tz=tz, out_unit=out_unit)     data_dtype = getattr(data, "dtype", None)      out_dtype = DT64NS_DTYPE@@ -2837,7 +2839,9 @@ def objects_to_datetime64(         raise TypeError(result)  -def maybe_convert_dtype(data, copy: bool, tz: tzinfo | None = None):+def maybe_convert_dtype(+    data, copy: bool, tz: tzinfo | None = None, out_unit: str | None = None+):     """     Convert data based on dtype conventions, issuing     errors where appropriate.@@ -2847,6 +2851,8 @@ def maybe_convert_dtype(data, copy: bool, tz: tzinfo | None = None):     data : np.ndarray or pd.Index     copy : bool     tz : tzinfo or None, default None+    out_unit : str or None, default None+        Resolution the float data is destined for; named in the error message.      Returns     -------@@ -2856,12 +2862,15 @@ def maybe_convert_dtype(data, copy: bool, tz: tzinfo | None = None):     Raises     ------     TypeError : PeriodDType data is passed+    OutOfBoundsDatetime : float data outside the int64 domain is passed     """     if not hasattr(data, "dtype"):         # e.g. collections.deque         return data, copy      if is_float_dtype(data.dtype):+        out_dtype = DT64NS_DTYPE if out_unit is None else np.dtype(f"M8[{out_unit}]")+        raise_if_float_outside_int64(np.asarray(data), out_dtype)         # pre-2.0 we treated these as wall-times, inconsistent with ints         # GH#23675, GH#45573 deprecated to treat symmetrically with integer dtypes.         # Note: data.astype(np.int64) fails ARM tests, see
pandas/core/arrays/masked.py14 + / 2 −
@@ -34,7 +34,10 @@ ) from pandas.util._exceptions import find_stack_level -from pandas.core.dtypes.astype import astype_is_view+from pandas.core.dtypes.astype import (+    astype_is_view,+    raise_if_float_outside_int64,+) from pandas.core.dtypes.base import ExtensionDtype from pandas.core.dtypes.cast import (     construct_1d_object_array_from_listlike,@@ -782,10 +785,19 @@ def astype(self, dtype: AstypeArg, copy: bool = True) -> ArrayLike:             na_value = np.nan         elif dtype.kind == "M":             unit = np.datetime_data(dtype)[0]-            na_value = np.datetime64("NaT", unit)  # type: ignore[call-overload]+            if unit == "generic":+                # numpy deprecates a unitless NaT; the cast is rejected+                #  downstream, so the sentinel goes unused+                na_value = lib.no_default+            else:+                na_value = np.datetime64("NaT", unit)  # type: ignore[call-overload]         else:             na_value = lib.no_default +        if self.dtype.kind == "f" and dtype.kind in "mM":+            # to_numpy narrows through int64 unchecked (GH#68926)+            raise_if_float_outside_int64(self._data, dtype, mask=self._mask)+         # to_numpy will also raise, but we get somewhat nicer exception messages here         if dtype.kind in "iu" and self._hasna:             raise ValueError("cannot convert NA to integer")
pandas/core/arrays/timedeltas.py4 + / 5 −
@@ -51,6 +51,7 @@ from pandas.util._decorators import set_module from pandas.util._validators import validate_endpoints +from pandas.core.dtypes.astype import float_outside_int64 from pandas.core.dtypes.common import (     TD64NS_DTYPE,     is_float_dtype,@@ -1315,11 +1316,9 @@ def sequence_to_td64ns(             # On ARM, float-to-int64 overflow saturates to INT64_MAX             # instead of wrapping, which makes the data == int_data             # check pass incorrectly for OOB values like float(2**63).-            # Exclude values outside the int64 domain from the check.-            i64 = np.iinfo(np.int64)-            in_int64_range = (data >= np.float64(i64.min)) & (-                data < np.float64(i64.max)-            )+            # Exclude values outside the int64 domain from the check; NaN is+            # not outside it by that predicate, but mask already covers NaN.+            in_int64_range = ~float_outside_int64(data)             all_round = (mask | (in_int64_range & (data == int_data))).all()             if all_round:                 result = sequence_to_td64ns(
pandas/core/dtypes/astype.py43 + / 0 −
@@ -15,6 +15,11 @@ import numpy as np  from pandas._libs import lib+from pandas._libs.tslibs import (+    OutOfBoundsDatetime,+    OutOfBoundsTimedelta,+    iNaT,+) from pandas.errors import IntCastingNaNError  from pandas.core.dtypes.common import (@@ -74,6 +79,9 @@ def _astype_nansafe(     ------     ValueError         The dtype was a datetime64/timedelta64 dtype, but it had no unit.+    OutOfBoundsDatetime or OutOfBoundsTimedelta+        The dtype was a datetime64/timedelta64 dtype and the float data fell+        outside the int64 domain.     """      # dispatch on extension dtype if needed@@ -134,6 +142,9 @@ def _astype_nansafe(         )         raise ValueError(msg) +    if np.issubdtype(arr.dtype, np.floating) and dtype.kind in "mM":+        raise_if_float_outside_int64(arr, dtype)+     if copy or object in (arr.dtype, dtype):         # Explicit copy, or required since NumPy can't view from / to object.         return arr.astype(dtype, copy=True)@@ -162,6 +173,38 @@ def astype_float_to_int_nansafe(         return values.astype(dtype, copy=copy)  +def float_outside_int64(values: np.ndarray) -> np.ndarray:+    """+    Mask of floats that a narrowing to int64 would alias to NaT or saturate.+    """+    # NaN compares False both ways. float(iNaT) is admitted and round-trips+    #  to NaT, matching cast_from_unit_vectorized.+    return (values >= np.float64(2**63)) | (values < np.float64(iNaT))+++def raise_if_float_outside_int64(+    values: np.ndarray, dtype: np.dtype, mask: np.ndarray | None = None+) -> None:+    """+    Reject floats that a cast to the given datetime64/timedelta64 dtype would+    alias to NaT or saturate to an in-bounds-looking value.++    Entries where ``mask`` is True are ignored.+    """+    unit, step = np.datetime_data(dtype)+    if step != 1 or unit == "generic":+        # unitless and multiplier dtypes are rejected regardless of the values+        #  (multiplier: GH#25611), so the saturated value never surfaces+        return+    oob = float_outside_int64(values)+    if mask is not None:+        oob &= ~mask+    if oob.any():+        bad = values[oob][0]+        err = OutOfBoundsDatetime if dtype.kind == "M" else OutOfBoundsTimedelta+        raise err(f"cannot convert input {bad} with the unit '{unit}'")++ def astype_array(values: ArrayLike, dtype: DtypeObj, copy: bool = False) -> ArrayLike:     """     Cast array (ndarray or ExtensionArray) to the new dtype.
pandas/core/tools/datetimes.py11 + / 0 −
@@ -54,6 +54,7 @@ from pandas.util._decorators import set_module from pandas.util._exceptions import find_stack_level +from pandas.core.dtypes.astype import float_outside_int64 from pandas.core.dtypes.common import (     ensure_object,     is_bool_dtype,@@ -474,6 +475,16 @@ def _convert_listlike_datetimes(             npvalues = np.full(len(arg), np.datetime64("NaT", "ns"))             return DatetimeIndex(npvalues, name=name)         raise+    except OutOfBoundsDatetime:+        if errors == "raise":+            raise+        # GH#68926 coerce just the out-of-range entries; NaN maps to NaT+        oob = float_outside_int64(np.asarray(arg))+        if not oob.any():+            # defensive+            raise+        arg = np.where(oob, np.nan, arg)+        arg, _ = maybe_convert_dtype(arg, copy=False, tz=libtimezones.maybe_get_tz(tz))      arg = ensure_object(arg) 
pandas/tests/indexes/datetimes/test_constructors.py26 + / 1 −
@@ -7,6 +7,7 @@ ) from functools import partial from operator import attrgetter+import re import zoneinfo  import dateutil@@ -19,7 +20,11 @@     astype_overflowsafe,     timezones, )-from pandas.errors import Pandas4Warning+from pandas.errors import (+    OutOfBoundsDatetime,+    OutOfBoundsTimedelta,+    Pandas4Warning,+)  import pandas as pd import pandas._testing as tm@@ -1366,3 +1371,23 @@ def test_dti_noniso_ambiguous_pytz_raises():         pd.Timestamp(dtstr, tz=tz)     with pytest.raises(ValueError, match="2023-11-05 01:30:00"):         pd.DatetimeIndex([dtstr], tz=tz)+++@pytest.mark.parametrize("value", [np.inf, -np.inf, 1e30, -1e30])+@pytest.mark.parametrize("unit", ["s", "ns"])+def test_dti_constructor_float_out_of_bounds(value, unit):+    # GH#68926 the float is narrowed to int64, where an out-of-range value+    #  saturates to a real-looking timestamp or lands on the NaT sentinel+    arr = np.array([value])+    dtype = f"M8[{unit}]"+    msg = re.escape(f"cannot convert input {value} with the unit '{unit}'")++    with pytest.raises(OutOfBoundsDatetime, match=msg):+        pd.DatetimeIndex(arr, dtype=dtype)+    with pytest.raises(OutOfBoundsDatetime, match=msg):+        pd.Series(arr, dtype=dtype)+    with pytest.raises(OutOfBoundsDatetime, match=msg):+        pd.array(arr, dtype=dtype)+    # the timedelta64 peer already raised; the datetime64 message now matches it+    with pytest.raises(OutOfBoundsTimedelta, match=msg):+        pd.TimedeltaIndex(arr, dtype=f"m8[{unit}]")
pandas/tests/series/methods/test_astype.py101 + / 1 −
@@ -3,14 +3,20 @@     timedelta, ) from importlib import reload+import re import string import sys+import warnings  import numpy as np import pytest  from pandas._libs.tslibs import iNaT-from pandas.errors import Pandas4Warning+from pandas.errors import (+    OutOfBoundsDatetime,+    OutOfBoundsTimedelta,+    Pandas4Warning,+) import pandas.util._test_decorators as td  import pandas as pd@@ -772,3 +778,97 @@ def test_astype_object_to_datetimelike_no_unit(dtype):      with pytest.raises(ValueError, match="dtype with no precision is not allowed"):         ser.astype(dtype)+++@pytest.mark.parametrize("value", [np.inf, -np.inf, 1e30, -1e30, float(2**63)])+@pytest.mark.parametrize("unit", ["s", "ms", "us", "ns"])+def test_astype_float_to_datetimelike_out_of_bounds(value, unit):+    # GH#68926 the cast narrows to int64, where an out-of-range float saturates+    #  to a real-looking timestamp or lands on the NaT sentinel+    ser = pd.Series([value])++    msg = f"cannot convert input {value} with the unit '{unit}'"+    with pytest.raises(OutOfBoundsDatetime, match=re.escape(msg)):+        ser.astype(f"M8[{unit}]")+    with pytest.raises(OutOfBoundsTimedelta, match=re.escape(msg)):+        ser.astype(f"m8[{unit}]")+++@pytest.mark.parametrize("dtype", ["M8[ns]", "m8[ns]", "datetime64[ns, UTC]"])+def test_astype_float_to_datetimelike_in_bounds_unchanged(dtype):+    # GH#68926 in-range floats still truncate toward zero, NaN and the NaT+    #  sentinel still give NaT+    largest = np.nextafter(np.float64(2**63), 0)+    ser = pd.Series([1.5, -1.5, np.nan, float(iNaT), largest])++    result = ser.astype(dtype)++    expected = pd.Series([1, -1, iNaT, iNaT, int(largest)]).astype(dtype)+    tm.assert_series_equal(result, expected)+++@pytest.mark.parametrize("dtype", ["Float64", "Float32"])+@pytest.mark.parametrize("spelling", ["astype", "constructor", "index"])+def test_astype_masked_float_to_datetime64_out_of_bounds(dtype, spelling):+    # GH#68926 masked input goes through the same unchecked int64 cast+    arr = pd.array([np.inf], dtype=dtype)++    with pytest.raises(OutOfBoundsDatetime, match="cannot convert input inf"):+        if spelling == "astype":+            pd.Series(arr).astype("M8[ns]")+        elif spelling == "constructor":+            pd.Series(arr, dtype="M8[ns]")+        else:+            pd.DatetimeIndex(arr)+++def test_astype_masked_float_to_timedelta64_out_of_bounds():+    # GH#68926 masked astype narrows through to_numpy, bypassing _astype_nansafe+    arr = pd.array([np.inf], dtype="Float64")++    with pytest.raises(OutOfBoundsTimedelta, match="cannot convert input inf"):+        pd.Series(arr).astype("m8[ns]")+++def test_astype_masked_float_to_datetime64_in_bounds_unchanged():+    # GH#68926 the guard only looks at the unmasked values, so NA and an+    #  out-of-range float parked behind the mask both still give NaT+    arr = pd.arrays.FloatingArray(+        np.array([1.5, 0.0, np.inf]), np.array([False, True, True])+    )++    result = pd.Series(arr).astype("M8[ns]")++    expected = pd.Series([1, iNaT, iNaT]).astype("M8[ns]")+    tm.assert_series_equal(result, expected)+++@pytest.mark.parametrize("dtype", ["M8", "m8"])+def test_astype_masked_float_to_datetimelike_no_unit(dtype):+    # GH#68926 the out-of-range check must not preempt the no-unit error+    ser = pd.Series(pd.array([np.inf], dtype="Float64"))++    with pytest.raises(TypeError, match="values must have a unit specified"):+        ser.astype(dtype)+++@pytest.mark.parametrize("dtype", ["M8[10s]", "m8[10s]"])+@pytest.mark.parametrize("box", ["numpy", "masked"])+def test_astype_float_to_datetimelike_multiplier_dtype(dtype, box):+    # GH#68926 a multiplier dtype is refused whatever the values, so the+    #  out-of-range guard must not preempt it with the multiplier stripped off+    data = np.array([np.inf]) if box == "numpy" else pd.array([np.inf], dtype="Float64")++    with warnings.catch_warnings():+        # numpy may warn on the saturating cast before pandas rejects the dtype+        warnings.simplefilter("ignore", RuntimeWarning)+        with pytest.raises(ValueError, match="multiplier are not supported"):+            pd.Series(data).astype(dtype)+++def test_astype_float32_to_datetime64_out_of_bounds():+    # GH#68926 every float width narrows to int64, not just float64+    ser = pd.Series(np.array([np.inf], dtype=np.float32))++    with pytest.raises(OutOfBoundsDatetime, match="cannot convert input inf"):+        ser.astype("M8[ns]")
pandas/tests/tools/test_to_datetime.py48 + / 0 −
@@ -4986,3 +4986,51 @@ def test_to_datetime_quarterly_string_warns_once_mixed_resolution_tzaware():     ]     assert len(quarter_warnings) == 1     assert result[0] == pd.Timestamp("2014-04-01", tz="UTC")+++@pytest.mark.parametrize("box", [np.array, pd.Series])+def test_to_datetime_float_out_of_bounds_coerce(box):+    # GH#68926 one out-of-range float must not take the whole array down; the+    #  timedelta64 peer already coerces per element+    arg = box([1.0, np.inf, 2.0])++    result = pd.to_datetime(arg, errors="coerce")++    expected = pd.DatetimeIndex(+        ["1970-01-01 00:00:00.000000001", "NaT", "1970-01-01 00:00:00.000000002"]+    )+    if box is pd.Series:+        tm.assert_series_equal(result, pd.Series(expected))+    else:+        tm.assert_index_equal(result, expected)+++def test_to_datetime_float_all_out_of_bounds_coerce():+    # GH#68926 an all-out-of-range input gets the same resolution as all-NaN+    result = pd.to_datetime(np.array([np.inf, -np.inf]), errors="coerce")++    expected = pd.to_datetime(np.array([np.nan, np.nan]), errors="coerce")+    assert expected.dtype == "M8[s]"+    tm.assert_index_equal(result, expected)+++def test_to_datetime_float_out_of_bounds_raises():+    # GH#68926 errors="raise" keeps the message naming the offending value+    msg = re.escape("cannot convert input inf with the unit 'ns'")+    with pytest.raises(OutOfBoundsDatetime, match=msg):+        pd.to_datetime(np.array([1.0, np.inf]))+++def test_to_datetime_float_out_of_bounds_coerce_with_format():+    # GH#68926 the coerced entries must parse like NaN under a format too+    msg = "Parsing integer or float values with a format"+    with tm.assert_produces_warning(Pandas4Warning, match=msg):+        result = pd.to_datetime(+            np.array([2020.0, np.inf]), format="%Y", errors="coerce"+        )+    with tm.assert_produces_warning(Pandas4Warning, match=msg):+        expected = pd.to_datetime(+            np.array([2020.0, np.nan]), format="%Y", errors="coerce"+        )++    tm.assert_index_equal(result, expected)