diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst index bebd928924214..7f7336ee50521 100644 --- a/doc/source/whatsnew/v3.0.0.rst +++ b/doc/source/whatsnew/v3.0.0.rst @@ -380,6 +380,8 @@ In cases with mixed-resolution inputs, the highest resolution is used: Similarly, the :class:`Timedelta` constructor and :func:`to_timedelta` with a string input now defaults to a microsecond unit, using nanosecond unit only in cases that actually have nanosecond precision. +Moreover, passing an integer to the :class:`Timedelta` constructor or :func:`to_timedelta` along with a ``unit`` will now return an object with that unit when possible, or the closest-supported unit for non-supported units ("W", "D", "h", "m"). + .. _whatsnew_300.api_breaking.concat_datetime_sorting: :func:`concat` no longer ignores ``sort`` when all objects have a :class:`DatetimeIndex` @@ -1131,6 +1133,7 @@ Timedelta - Accuracy improvement in :meth:`Timedelta.to_pytimedelta` to round microseconds consistently for large nanosecond based Timedelta (:issue:`57841`) - Bug in :class:`Timedelta` constructor failing to raise when passed an invalid keyword (:issue:`53801`) - Bug in :meth:`DataFrame.cumsum` which was raising ``IndexError`` if dtype is ``timedelta64[ns]`` (:issue:`57956`) +- Bug in adding or subtracting a :class:`Timedelta` object with non-nanosecond unit to a python ``datetime.datetime`` object giving incorrect results; this now works correctly for Timedeltas inside the ``datetime.timedelta`` implementation bounds (:issue:`53643`) - Bug in multiplication operations with ``timedelta64`` dtype failing to raise ``TypeError`` when multiplying by ``bool`` objects or dtypes (:issue:`58054`) - Bug in multiplication operations with ``timedelta64`` dtype incorrectly raising when multiplying by numpy-nullable dtypes or pyarrow integer dtypes (:issue:`58054`) diff --git a/pandas/_libs/tslibs/timedeltas.pyx b/pandas/_libs/tslibs/timedeltas.pyx index 1e01ad9246aae..ee47d9af48859 100644 --- a/pandas/_libs/tslibs/timedeltas.pyx +++ b/pandas/_libs/tslibs/timedeltas.pyx @@ -290,22 +290,24 @@ cpdef int64_t delta_to_nanoseconds( ) from err -cdef _numeric_to_td64ns(object item, str unit): +cdef int64_t _numeric_to_td64ns( + object item, str unit, NPY_DATETIMEUNIT out_reso=NPY_FR_ns +): # caller is responsible for checking # assert unit not in ["Y", "y", "M"] # assert is_integer_object(item) or is_float_object(item) if is_integer_object(item) and item == NPY_NAT: - return np.timedelta64(NPY_NAT, "ns") + return NPY_NAT try: - item = cast_from_unit(item, unit) + ival = cast_from_unit(item, unit, out_reso) except OutOfBoundsDatetime as err: + abbrev = npy_unit_to_abbrev(out_reso) raise OutOfBoundsTimedelta( - f"Cannot cast {item} from {unit} to 'ns' without overflow." + f"Cannot cast {item} from {unit} to '{abbrev}' without overflow." ) from err - ts = np.timedelta64(item, "ns") - return ts + return ival # TODO: de-duplicate with DatetimeParseState @@ -352,7 +354,7 @@ def array_to_timedelta64( cdef: Py_ssize_t i, n = values.size ndarray result = np.empty((values).shape, dtype="m8[ns]") - object item, td64ns_obj + object item int64_t ival cnp.broadcast mi = cnp.PyArray_MultiIterNew2(result, values) cnp.flatiter it @@ -471,8 +473,7 @@ def array_to_timedelta64( ival = delta_to_nanoseconds(item, reso=creso) elif is_integer_object(item) or is_float_object(item): - td64ns_obj = _numeric_to_td64ns(item, parsed_unit) - ival = cnp.get_timedelta64_value(td64ns_obj) + ival = _numeric_to_td64ns(item, parsed_unit, NPY_FR_ns) item_reso = NPY_FR_ns state.update_creso(item_reso) @@ -1022,9 +1023,23 @@ cdef _timedelta_from_value_and_reso(cls, int64_t value, NPY_DATETIMEUNIT reso): elif reso == NPY_DATETIMEUNIT.NPY_FR_us: td_base = _Timedelta.__new__(cls, microseconds=int(value)) elif reso == NPY_DATETIMEUNIT.NPY_FR_ms: - td_base = _Timedelta.__new__(cls, milliseconds=0) + if value > -86_399_999_913_600_000 and value < 86_400_000_000_000_000: + # i.e. we are in range for pytimedelta. By passing the + # 'correct' value here we can + # make pydatetime + Timedelta operations work correctly, + # xref GH#53643 + td_base = _Timedelta.__new__(cls, milliseconds=value) + else: + td_base = _Timedelta.__new__(cls, milliseconds=0) elif reso == NPY_DATETIMEUNIT.NPY_FR_s: - td_base = _Timedelta.__new__(cls, seconds=0) + if value > -86_399_999_913_600 and value < 86_400_000_000_000: + # i.e. we are in range for pytimedelta. By passing the + # 'correct' value here we can + # make pydatetime + Timedelta operations work correctly, + # xref GH#53643 + td_base = _Timedelta.__new__(cls, seconds=value) + else: + td_base = _Timedelta.__new__(cls, seconds=0) # Other resolutions are disabled but could potentially be implemented here: # elif reso == NPY_DATETIMEUNIT.NPY_FR_m: # td_base = _Timedelta.__new__(Timedelta, minutes=int(value)) @@ -2216,7 +2231,18 @@ class Timedelta(_Timedelta): elif checknull_with_nat_and_na(value): return NaT - elif is_integer_object(value) or is_float_object(value): + elif is_integer_object(value): + # unit=None is de-facto 'ns' + if value != NPY_NAT: + unit = parse_timedelta_unit(unit) + if unit != "ns": + # Return with the closest-to-supported unit by going through + # the timedelta64 path + td = np.timedelta64(value, unit) + return cls(td) + value = _numeric_to_td64ns(value, unit) + + elif is_float_object(value): # unit=None is de-facto 'ns' unit = parse_timedelta_unit(unit) value = _numeric_to_td64ns(value, unit) diff --git a/pandas/tests/arithmetic/test_timedelta64.py b/pandas/tests/arithmetic/test_timedelta64.py index e2f26bdcf43da..6c8a9333f6d3f 100644 --- a/pandas/tests/arithmetic/test_timedelta64.py +++ b/pandas/tests/arithmetic/test_timedelta64.py @@ -10,7 +10,6 @@ from pandas._libs.tslibs import timezones from pandas.compat import WASM -from pandas.errors import OutOfBoundsDatetime import pandas.util._test_decorators as td import pandas as pd @@ -728,10 +727,6 @@ def test_tdi_add_overflow(self): # See GH#14068 # preliminary test scalar analogue of vectorized tests below # TODO: Make raised error message more informative and test - with pytest.raises(OutOfBoundsDatetime, match="10155196800000000000"): - pd.to_timedelta(106580, "D") + Timestamp("2000") - with pytest.raises(OutOfBoundsDatetime, match="10155196800000000000"): - Timestamp("2000") + pd.to_timedelta(106580, "D") _NaT = NaT._value + 1 msg = "Overflow in int64 addition" diff --git a/pandas/tests/frame/indexing/test_mask.py b/pandas/tests/frame/indexing/test_mask.py index ac648696ead58..64921ffb4f5e8 100644 --- a/pandas/tests/frame/indexing/test_mask.py +++ b/pandas/tests/frame/indexing/test_mask.py @@ -127,14 +127,15 @@ def test_mask_where_dtype_timedelta(): # https://github.com/pandas-dev/pandas/issues/39548 df = DataFrame([Timedelta(i, unit="D") for i in range(5)]) - expected = DataFrame(np.full(5, np.nan, dtype="timedelta64[ns]")) + expected = DataFrame(np.full(5, np.nan, dtype="timedelta64[s]")) tm.assert_frame_equal(df.mask(df.notna()), expected) expected = DataFrame( [np.nan, np.nan, np.nan, Timedelta("3 day"), Timedelta("4 day")], - dtype="m8[ns]", + dtype="m8[s]", ) - tm.assert_frame_equal(df.where(df > Timedelta(2, unit="D")), expected) + result = df.where(df > Timedelta(2, unit="D")) + tm.assert_frame_equal(result, expected) def test_mask_return_dtype(): diff --git a/pandas/tests/frame/indexing/test_setitem.py b/pandas/tests/frame/indexing/test_setitem.py index 517b026757d89..c90e0d70052f1 100644 --- a/pandas/tests/frame/indexing/test_setitem.py +++ b/pandas/tests/frame/indexing/test_setitem.py @@ -1000,8 +1000,8 @@ def test_loc_expansion_with_timedelta_type(self): index=Index([0]), columns=(["a", "b", "c"]), ) - expected["a"] = expected["a"].astype("m8[ns]") - expected["b"] = expected["b"].astype("m8[ns]") + expected["a"] = expected["a"].astype("m8[s]") + expected["b"] = expected["b"].astype("m8[s]") tm.assert_frame_equal(result, expected) def test_setitem_tuple_key_in_empty_frame(self): diff --git a/pandas/tests/frame/test_constructors.py b/pandas/tests/frame/test_constructors.py index 76bf846a5cf2a..c92c9afeffd2b 100644 --- a/pandas/tests/frame/test_constructors.py +++ b/pandas/tests/frame/test_constructors.py @@ -893,7 +893,7 @@ def create_data(constructor): [ (lambda x: np.timedelta64(x, "D"), "m8[s]"), (lambda x: timedelta(days=x), "m8[us]"), - (lambda x: Timedelta(x, "D"), "m8[ns]"), + (lambda x: Timedelta(x, "D"), "m8[s]"), (lambda x: Timedelta(x, "D").as_unit("s"), "m8[s]"), ], ) diff --git a/pandas/tests/io/json/test_pandas.py b/pandas/tests/io/json/test_pandas.py index 5a3ec254c96b0..683b25d425163 100644 --- a/pandas/tests/io/json/test_pandas.py +++ b/pandas/tests/io/json/test_pandas.py @@ -1141,7 +1141,7 @@ def test_timedelta(self): ) with tm.assert_produces_warning(Pandas4Warning, match=msg): result = read_json(StringIO(ser.to_json()), typ="series").apply(converter) - tm.assert_series_equal(result, ser) + tm.assert_series_equal(result, ser.astype("m8[ms]")) ser = Series( [timedelta(23), timedelta(seconds=5)], index=Index([0, 1]), dtype="m8[ns]" @@ -1149,7 +1149,7 @@ def test_timedelta(self): assert ser.dtype == "timedelta64[ns]" with tm.assert_produces_warning(Pandas4Warning, match=msg): result = read_json(StringIO(ser.to_json()), typ="series").apply(converter) - tm.assert_series_equal(result, ser) + tm.assert_series_equal(result, ser.astype("m8[ms]")) frame = DataFrame([timedelta(23), timedelta(seconds=5)], dtype="m8[ns]") assert frame[0].dtype == "timedelta64[ns]" diff --git a/pandas/tests/scalar/timedelta/test_arithmetic.py b/pandas/tests/scalar/timedelta/test_arithmetic.py index 20e46bbbe0803..7816661fb1bd9 100644 --- a/pandas/tests/scalar/timedelta/test_arithmetic.py +++ b/pandas/tests/scalar/timedelta/test_arithmetic.py @@ -35,6 +35,21 @@ class TestTimedeltaAdditionSubtraction: __sub__, __rsub__ """ + def test_td_add_sub_pydatetime(self, unit): + # GH#53643 + td = Timedelta(hours=23).as_unit(unit) + dt = datetime(2016, 1, 1) + + expected = datetime(2016, 1, 1, 23) + result = dt + td + assert result == expected + result = td + dt + assert result == expected + + expected = datetime(2015, 12, 31, 1) + result = dt - td + assert result == expected + @pytest.mark.parametrize( "ten_seconds", [ @@ -104,7 +119,7 @@ def test_td_add_datetimelike_scalar(self, op): def test_td_add_timestamp_overflow(self): ts = Timestamp("1700-01-01").as_unit("ns") - msg = "Cannot cast 259987 from D to 'ns' without overflow." + msg = "Cannot cast 259987 days 00:00:00 to unit='ns' without overflow." with pytest.raises(OutOfBoundsTimedelta, match=msg): ts + Timedelta(13 * 19999, unit="D") diff --git a/pandas/tests/scalar/timedelta/test_constructors.py b/pandas/tests/scalar/timedelta/test_constructors.py index d386ef576ddef..9785074c08734 100644 --- a/pandas/tests/scalar/timedelta/test_constructors.py +++ b/pandas/tests/scalar/timedelta/test_constructors.py @@ -35,6 +35,17 @@ def test_noninteger_microseconds(self): class TestTimedeltaConstructorUnitKeyword: + def test_result_unit(self): + # For supported units, we get result.unit == unit + for unit in ["s", "ms", "us", "ns"]: + td = Timedelta(1, unit=unit) + assert td.unit == unit + + # For non-supported units we get the closest-supported unit + for unit in ["W", "D", "h", "m"]: + td = Timedelta(1, unit=unit) + assert td.unit == "s" + @pytest.mark.parametrize("unit", ["Y", "y", "M"]) def test_unit_m_y_raises(self, unit): msg = "Units 'M', 'Y', and 'y' are no longer supported" @@ -196,7 +207,8 @@ def test_construct_from_kwargs_overflow(): def test_construct_with_weeks_unit_overflow(): # GH#47268 don't silently wrap around - with pytest.raises(OutOfBoundsTimedelta, match="without overflow"): + msg = "1000000000000000000 weeks" + with pytest.raises(OutOfBoundsTimedelta, match=msg): Timedelta(1000000000000000000, unit="W") with pytest.raises(OutOfBoundsTimedelta, match="without overflow"): @@ -284,7 +296,7 @@ def test_from_tick_reso(): def test_construction(): expected = np.timedelta64(10, "D").astype("m8[ns]").view("i8") - assert Timedelta(10, unit="D")._value == expected + assert Timedelta(10, unit="D")._value == expected // 10**9 assert Timedelta(10.0, unit="D")._value == expected assert Timedelta("10 days")._value == expected // 1000 assert Timedelta(days=10)._value == expected // 1000 @@ -464,9 +476,9 @@ def test_overflow_on_construction(): Timedelta(value) # xref GH#17637 - msg = "Cannot cast 139993 from D to 'ns' without overflow" - with pytest.raises(OutOfBoundsTimedelta, match=msg): - Timedelta(7 * 19999, unit="D") + # used to overflows before we changed output unit to "s" + td = Timedelta(7 * 19999, unit="D") + assert td.unit == "s" # used to overflow before non-ns support td = Timedelta(timedelta(days=13 * 19999)) diff --git a/pandas/tests/tools/test_to_datetime.py b/pandas/tests/tools/test_to_datetime.py index 466ac5582dc65..f08d26ff97bbf 100644 --- a/pandas/tests/tools/test_to_datetime.py +++ b/pandas/tests/tools/test_to_datetime.py @@ -3263,7 +3263,8 @@ def test_epoch(self, units, epochs): epoch_1960 = Timestamp(1960, 1, 1) units_from_epochs = np.arange(5, dtype=np.int64) expected = Series( - [pd.Timedelta(x, unit=units) + epoch_1960 for x in units_from_epochs] + [pd.Timedelta(x, unit=units) + epoch_1960 for x in units_from_epochs], + dtype="M8[ns]", ) result = Series(to_datetime(units_from_epochs, unit=units, origin=epochs))