From 6e59a2dfdfef217d32135a84d116c141b0c98278 Mon Sep 17 00:00:00 2001 From: Brock Date: Mon, 8 Dec 2025 12:07:42 -0800 Subject: [PATCH 1/6] BUG: pydatetime + Timedelta[non-nano] incorrect results --- doc/source/whatsnew/v3.0.0.rst | 1 + pandas/_libs/tslibs/timedeltas.pyx | 18 ++++++++++++++++-- .../tests/scalar/timedelta/test_arithmetic.py | 15 +++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst index bebd928924214..c0a0b17707f1f 100644 --- a/doc/source/whatsnew/v3.0.0.rst +++ b/doc/source/whatsnew/v3.0.0.rst @@ -1131,6 +1131,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..b9055dc653a28 100644 --- a/pandas/_libs/tslibs/timedeltas.pyx +++ b/pandas/_libs/tslibs/timedeltas.pyx @@ -1022,9 +1022,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)) diff --git a/pandas/tests/scalar/timedelta/test_arithmetic.py b/pandas/tests/scalar/timedelta/test_arithmetic.py index 20e46bbbe0803..d9eda82155b06 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", [ From 3af431f43287c5fbc5bb205f412eb28744322864 Mon Sep 17 00:00:00 2001 From: Brock Date: Mon, 8 Dec 2025 12:16:40 -0800 Subject: [PATCH 2/6] pd.Timedelta(integer, unit=unit) give the requested unit --- doc/source/whatsnew/v3.0.0.rst | 2 ++ pandas/_libs/tslibs/timedeltas.pyx | 32 +++++++++++++------ pandas/tests/arithmetic/test_timedelta64.py | 5 --- pandas/tests/frame/indexing/test_mask.py | 7 ++-- pandas/tests/frame/indexing/test_setitem.py | 4 +-- pandas/tests/frame/test_constructors.py | 2 +- pandas/tests/io/json/test_pandas.py | 4 +-- .../tests/scalar/timedelta/test_arithmetic.py | 2 +- .../scalar/timedelta/test_constructors.py | 22 ++++++++++--- pandas/tests/tools/test_to_datetime.py | 3 +- 10 files changed, 53 insertions(+), 30 deletions(-) diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst index c0a0b17707f1f..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` diff --git a/pandas/_libs/tslibs/timedeltas.pyx b/pandas/_libs/tslibs/timedeltas.pyx index b9055dc653a28..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) @@ -2230,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 d9eda82155b06..7816661fb1bd9 100644 --- a/pandas/tests/scalar/timedelta/test_arithmetic.py +++ b/pandas/tests/scalar/timedelta/test_arithmetic.py @@ -119,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)) From 996ce52ecab8d39c9143fb9d07433df1b583e45b Mon Sep 17 00:00:00 2001 From: Brock Date: Mon, 8 Dec 2025 08:32:12 -0800 Subject: [PATCH 3/6] to_timedelta with ints result unit try to match input unit --- pandas/core/arrays/timedeltas.py | 4 +++- pandas/tests/groupby/methods/test_quantile.py | 9 ++++----- pandas/tests/io/json/test_pandas.py | 3 ++- pandas/tests/scalar/timedelta/test_constructors.py | 7 ++++++- pandas/tests/tools/test_to_datetime.py | 2 +- pandas/tests/tools/test_to_timedelta.py | 4 ++-- 6 files changed, 18 insertions(+), 11 deletions(-) diff --git a/pandas/core/arrays/timedeltas.py b/pandas/core/arrays/timedeltas.py index 514ad28f698d6..d2a379c7a5298 100644 --- a/pandas/core/arrays/timedeltas.py +++ b/pandas/core/arrays/timedeltas.py @@ -1205,7 +1205,9 @@ def _ints_to_td64ns(data, unit: str = "ns") -> tuple[np.ndarray, bool]: dtype_str = f"timedelta64[{unit}]" data = data.view(dtype_str) - data = astype_overflowsafe(data, dtype=TD64NS_DTYPE) + new_dtype = get_supported_dtype(data.dtype) + if new_dtype != data.dtype: + data = astype_overflowsafe(data, dtype=new_dtype) # the astype conversion makes a copy, so we can avoid re-copying later copy_made = True diff --git a/pandas/tests/groupby/methods/test_quantile.py b/pandas/tests/groupby/methods/test_quantile.py index 1b4a26919af44..5d9f0063df1e6 100644 --- a/pandas/tests/groupby/methods/test_quantile.py +++ b/pandas/tests/groupby/methods/test_quantile.py @@ -363,15 +363,14 @@ def test_groupby_quantile_allNA_column(dtype): def test_groupby_timedelta_quantile(): # GH: 29485 - df = DataFrame( - {"value": pd.to_timedelta(np.arange(4), unit="s"), "group": [1, 1, 2, 2]} - ) + tdi = pd.to_timedelta(np.arange(4), unit="s").as_unit("us") + df = DataFrame({"value": tdi, "group": [1, 1, 2, 2]}) result = df.groupby("group").quantile(0.99) expected = DataFrame( { "value": [ - pd.Timedelta("0 days 00:00:00.990000").as_unit("ns"), - pd.Timedelta("0 days 00:00:02.990000").as_unit("ns"), + pd.Timedelta("0 days 00:00:00.990000"), + pd.Timedelta("0 days 00:00:02.990000"), ] }, index=Index([1, 2], name="group"), diff --git a/pandas/tests/io/json/test_pandas.py b/pandas/tests/io/json/test_pandas.py index 683b25d425163..2677692c216a6 100644 --- a/pandas/tests/io/json/test_pandas.py +++ b/pandas/tests/io/json/test_pandas.py @@ -1156,7 +1156,8 @@ def test_timedelta(self): with tm.assert_produces_warning(Pandas4Warning, match=msg): json = frame.to_json() - tm.assert_frame_equal(frame, read_json(StringIO(json)).apply(converter)) + result = read_json(StringIO(json)).apply(converter) + tm.assert_frame_equal(frame.astype("m8[ms]"), result) def test_timedelta2(self): frame = DataFrame( diff --git a/pandas/tests/scalar/timedelta/test_constructors.py b/pandas/tests/scalar/timedelta/test_constructors.py index 9785074c08734..522ca3bb3786a 100644 --- a/pandas/tests/scalar/timedelta/test_constructors.py +++ b/pandas/tests/scalar/timedelta/test_constructors.py @@ -155,9 +155,14 @@ def test_unit_deprecated(self, unit, unit_depr): def test_unit_parser(self, unit, np_unit, wrapper): # validate all units, GH 6855, GH 21762 # array-likes + if wrapper is list: + # we go through _objects_to_td64ns which hasn't yet been updated + exp_unit = "ns" + else: + exp_unit = np_unit if np_unit not in ["W", "D", "m"] else "s" expected = TimedeltaIndex( [np.timedelta64(i, np_unit) for i in np.arange(5).tolist()], - dtype="m8[ns]", + dtype=f"m8[{exp_unit}]", ) result = to_timedelta(wrapper(range(5)), unit=unit) diff --git a/pandas/tests/tools/test_to_datetime.py b/pandas/tests/tools/test_to_datetime.py index f08d26ff97bbf..d5beee9f7e87f 100644 --- a/pandas/tests/tools/test_to_datetime.py +++ b/pandas/tests/tools/test_to_datetime.py @@ -2111,7 +2111,7 @@ def test_dataframe_field_aliases_column_subset(self, df, cache, unit): result = to_datetime(df[list(unit.keys())].rename(columns=unit), cache=cache) expected = Series( [Timestamp("20150204 06:58:10"), Timestamp("20160305 07:59:11")], - dtype="M8[ns]", + dtype="M8[us]", ) tm.assert_series_equal(result, expected) diff --git a/pandas/tests/tools/test_to_timedelta.py b/pandas/tests/tools/test_to_timedelta.py index 878f9ecf79ef1..2391b85527223 100644 --- a/pandas/tests/tools/test_to_timedelta.py +++ b/pandas/tests/tools/test_to_timedelta.py @@ -121,7 +121,7 @@ def test_to_timedelta_units_dtypes(self, dtype, unit): # arrays of various dtypes arr = np.array([1] * 5, dtype=dtype) result = to_timedelta(arr, unit=unit) - exp_dtype = "m8[ns]" if dtype == "int64" else "m8[s]" + exp_dtype = "m8[s]" expected = TimedeltaIndex([np.timedelta64(1, unit)] * 5, dtype=exp_dtype) tm.assert_index_equal(result, expected) @@ -277,7 +277,7 @@ def test_to_timedelta_coerce_strings_unit(self): ) def test_to_timedelta_nullable_int64_dtype(self, expected_val, result_val): # GH 35574 - expected = Series([timedelta(days=1), expected_val], dtype="m8[ns]") + expected = Series([timedelta(days=1), expected_val], dtype="m8[s]") result = to_timedelta(Series([1, result_val], dtype="Int64"), unit="days") tm.assert_series_equal(result, expected) From 240d643c01426ad0285a2584c480b2d756773a95 Mon Sep 17 00:00:00 2001 From: Brock Date: Mon, 8 Dec 2025 13:14:44 -0800 Subject: [PATCH 4/6] API: to_timedelta(integers, unit) give requested unit --- pandas/_libs/tslibs/timedeltas.pyx | 21 +++++++++++++++++-- pandas/tests/arithmetic/test_timedelta64.py | 5 +++-- .../indexes/timedeltas/methods/test_shift.py | 4 ++-- .../indexes/timedeltas/test_constructors.py | 3 ++- pandas/tests/resample/test_timedelta.py | 4 +--- .../scalar/timedelta/test_constructors.py | 6 +----- pandas/tests/tools/test_to_timedelta.py | 2 +- 7 files changed, 29 insertions(+), 16 deletions(-) diff --git a/pandas/_libs/tslibs/timedeltas.pyx b/pandas/_libs/tslibs/timedeltas.pyx index ee47d9af48859..54290e7cb3ab8 100644 --- a/pandas/_libs/tslibs/timedeltas.pyx +++ b/pandas/_libs/tslibs/timedeltas.pyx @@ -44,6 +44,7 @@ from pandas._libs.tslibs.conversion cimport ( cast_from_unit, ) from pandas._libs.tslibs.dtypes cimport ( + abbrev_to_npy_unit, c_DEPR_UNITS, get_supported_reso, is_supported_unit, @@ -359,11 +360,16 @@ def array_to_timedelta64( cnp.broadcast mi = cnp.PyArray_MultiIterNew2(result, values) cnp.flatiter it str parsed_unit = parse_timedelta_unit(unit or "ns") - NPY_DATETIMEUNIT item_reso + NPY_DATETIMEUNIT item_reso, int_reso ResoState state = ResoState(creso) bint infer_reso = creso == NPY_DATETIMEUNIT.NPY_FR_GENERIC ndarray iresult = result.view("i8") + if unit is None: + int_reso = NPY_FR_ns + else: + int_reso = get_supported_reso(abbrev_to_npy_unit(parsed_unit)) + if values.descr.type_num != cnp.NPY_OBJECT: # raise here otherwise we segfault below raise TypeError("array_to_timedelta64 'values' must have object dtype") @@ -472,7 +478,18 @@ def array_to_timedelta64( creso = state.creso ival = delta_to_nanoseconds(item, reso=creso) - elif is_integer_object(item) or is_float_object(item): + elif is_integer_object(item): + if item == NPY_NAT: + ival = NPY_NAT + else: + ival = _numeric_to_td64ns(item, parsed_unit, int_reso) + item_reso = int_reso + + state.update_creso(item_reso) + if infer_reso: + creso = state.creso + + elif is_float_object(item): ival = _numeric_to_td64ns(item, parsed_unit, NPY_FR_ns) item_reso = NPY_FR_ns diff --git a/pandas/tests/arithmetic/test_timedelta64.py b/pandas/tests/arithmetic/test_timedelta64.py index 6c8a9333f6d3f..5c37c9a4db6f0 100644 --- a/pandas/tests/arithmetic/test_timedelta64.py +++ b/pandas/tests/arithmetic/test_timedelta64.py @@ -729,11 +729,12 @@ def test_tdi_add_overflow(self): # TODO: Make raised error message more informative and test _NaT = NaT._value + 1 + td = pd.to_timedelta([106580], "D").as_unit("ns") msg = "Overflow in int64 addition" with pytest.raises(OverflowError, match=msg): - pd.to_timedelta([106580], "D") + Timestamp("2000") + td + Timestamp("2000") with pytest.raises(OverflowError, match=msg): - Timestamp("2000") + pd.to_timedelta([106580], "D") + Timestamp("2000") + td with pytest.raises(OverflowError, match=msg): pd.to_timedelta([_NaT]) - Timedelta("1 days") with pytest.raises(OverflowError, match=msg): diff --git a/pandas/tests/indexes/timedeltas/methods/test_shift.py b/pandas/tests/indexes/timedeltas/methods/test_shift.py index 297887596dc82..166eeb671867f 100644 --- a/pandas/tests/indexes/timedeltas/methods/test_shift.py +++ b/pandas/tests/indexes/timedeltas/methods/test_shift.py @@ -49,7 +49,7 @@ def test_tdi_shift_int(self): "5 days 01:00:00", ], freq="D", - dtype="m8[ns]", + dtype="m8[s]", ) tm.assert_index_equal(result, expected) @@ -67,7 +67,7 @@ def test_tdi_shift_nonstandard_freq(self): "10 days 01:00:03", ], freq="D", - dtype="m8[ns]", + dtype="m8[s]", ) tm.assert_index_equal(result, expected) diff --git a/pandas/tests/indexes/timedeltas/test_constructors.py b/pandas/tests/indexes/timedeltas/test_constructors.py index 1a2f7053a98ab..a0be8f4b3c74c 100644 --- a/pandas/tests/indexes/timedeltas/test_constructors.py +++ b/pandas/tests/indexes/timedeltas/test_constructors.py @@ -267,4 +267,5 @@ def test_unit_deprecated(self, unit, unit_depr): with tm.assert_produces_warning(Pandas4Warning, match=msg): tdi = to_timedelta([1, 2], unit=unit_depr) - tm.assert_index_equal(tdi, expected.as_unit("ns")) + exp_unit = unit if unit in ["s", "ms", "us"] else "s" + tm.assert_index_equal(tdi, expected.as_unit(exp_unit)) diff --git a/pandas/tests/resample/test_timedelta.py b/pandas/tests/resample/test_timedelta.py index e0b4248fab210..3bec66e3a1aa2 100644 --- a/pandas/tests/resample/test_timedelta.py +++ b/pandas/tests/resample/test_timedelta.py @@ -101,9 +101,7 @@ def test_resample_categorical_data_with_timedeltaindex(): df = DataFrame({"Group_obj": "A"}, index=pd.to_timedelta(list(range(20)), unit="s")) df["Group"] = df["Group_obj"].astype("category") result = df.resample("10s").agg(lambda x: (x.value_counts().index[0])) - exp_tdi = pd.TimedeltaIndex(np.array([0, 10], dtype="m8[s]"), freq="10s").as_unit( - "ns" - ) + exp_tdi = pd.TimedeltaIndex(np.array([0, 10], dtype="m8[s]"), freq="10s") expected = DataFrame( {"Group_obj": ["A", "A"], "Group": ["A", "A"]}, index=exp_tdi, diff --git a/pandas/tests/scalar/timedelta/test_constructors.py b/pandas/tests/scalar/timedelta/test_constructors.py index 522ca3bb3786a..78190698a115d 100644 --- a/pandas/tests/scalar/timedelta/test_constructors.py +++ b/pandas/tests/scalar/timedelta/test_constructors.py @@ -155,11 +155,7 @@ def test_unit_deprecated(self, unit, unit_depr): def test_unit_parser(self, unit, np_unit, wrapper): # validate all units, GH 6855, GH 21762 # array-likes - if wrapper is list: - # we go through _objects_to_td64ns which hasn't yet been updated - exp_unit = "ns" - else: - exp_unit = np_unit if np_unit not in ["W", "D", "m"] else "s" + exp_unit = np_unit if np_unit not in ["W", "D", "m"] else "s" expected = TimedeltaIndex( [np.timedelta64(i, np_unit) for i in np.arange(5).tolist()], dtype=f"m8[{exp_unit}]", diff --git a/pandas/tests/tools/test_to_timedelta.py b/pandas/tests/tools/test_to_timedelta.py index 2391b85527223..ad63106c36392 100644 --- a/pandas/tests/tools/test_to_timedelta.py +++ b/pandas/tests/tools/test_to_timedelta.py @@ -104,7 +104,7 @@ def test_to_timedelta_units(self): result = TimedeltaIndex( [np.timedelta64(0, "ns"), np.timedelta64(10, "s").astype("m8[ns]")] ) - expected = to_timedelta([0, 10], unit="s") + expected = to_timedelta([0, 10], unit="s").as_unit("ns") tm.assert_index_equal(result, expected) @pytest.mark.parametrize( From 0308e115ac09ee5c9a275f3fd88947e57dd139b6 Mon Sep 17 00:00:00 2001 From: Brock Date: Mon, 8 Dec 2025 19:01:22 -0800 Subject: [PATCH 5/6] update doctests --- pandas/core/arrays/datetimelike.py | 2 +- pandas/core/arrays/timedeltas.py | 12 ++++++------ pandas/core/indexes/accessors.py | 4 ++-- pandas/core/indexes/datetimelike.py | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/pandas/core/arrays/datetimelike.py b/pandas/core/arrays/datetimelike.py index d4ada8360d645..2f5dbe0faf9ca 100644 --- a/pandas/core/arrays/datetimelike.py +++ b/pandas/core/arrays/datetimelike.py @@ -1656,7 +1656,7 @@ def mean(self, *, skipna: bool = True, axis: AxisInt | None = 0): >>> tdelta_idx = pd.to_timedelta([1, 2, 3], unit="D") >>> tdelta_idx TimedeltaIndex(['1 days', '2 days', '3 days'], - dtype='timedelta64[ns]', freq=None) + dtype='timedelta64[s]', freq=None) >>> tdelta_idx.mean() Timedelta('2 days 00:00:00') """ diff --git a/pandas/core/arrays/timedeltas.py b/pandas/core/arrays/timedeltas.py index d2a379c7a5298..8c907307b7994 100644 --- a/pandas/core/arrays/timedeltas.py +++ b/pandas/core/arrays/timedeltas.py @@ -847,7 +847,7 @@ def to_pytimedelta(self) -> npt.NDArray[np.object_]: >>> tdelta_idx = pd.to_timedelta([1, 2, 3], unit="D") >>> tdelta_idx TimedeltaIndex(['1 days', '2 days', '3 days'], - dtype='timedelta64[ns]', freq=None) + dtype='timedelta64[s]', freq=None) >>> tdelta_idx.to_pytimedelta() array([datetime.timedelta(days=1), datetime.timedelta(days=2), datetime.timedelta(days=3)], dtype=object) @@ -880,7 +880,7 @@ def to_pytimedelta(self) -> npt.NDArray[np.object_]: 0 1 days 1 2 days 2 3 days - dtype: timedelta64[ns] + dtype: timedelta64[s] >>> ser.dt.days 0 1 1 2 @@ -915,7 +915,7 @@ def to_pytimedelta(self) -> npt.NDArray[np.object_]: 0 0 days 00:00:01 1 0 days 00:00:02 2 0 days 00:00:03 - dtype: timedelta64[ns] + dtype: timedelta64[s] >>> ser.dt.seconds 0 1 1 2 @@ -927,7 +927,7 @@ def to_pytimedelta(self) -> npt.NDArray[np.object_]: >>> tdelta_idx = pd.to_timedelta([1, 2, 3], unit='s') >>> tdelta_idx TimedeltaIndex(['0 days 00:00:01', '0 days 00:00:02', '0 days 00:00:03'], - dtype='timedelta64[ns]', freq=None) + dtype='timedelta64[s]', freq=None) >>> tdelta_idx.seconds Index([1, 2, 3], dtype='int32')""" ) @@ -955,7 +955,7 @@ def to_pytimedelta(self) -> npt.NDArray[np.object_]: 0 0 days 00:00:00.000001 1 0 days 00:00:00.000002 2 0 days 00:00:00.000003 - dtype: timedelta64[ns] + dtype: timedelta64[us] >>> ser.dt.microseconds 0 1 1 2 @@ -968,7 +968,7 @@ def to_pytimedelta(self) -> npt.NDArray[np.object_]: >>> tdelta_idx TimedeltaIndex(['0 days 00:00:00.000001', '0 days 00:00:00.000002', '0 days 00:00:00.000003'], - dtype='timedelta64[ns]', freq=None) + dtype='timedelta64[us]', freq=None) >>> tdelta_idx.microseconds Index([1, 2, 3], dtype='int32')""" ) diff --git a/pandas/core/indexes/accessors.py b/pandas/core/indexes/accessors.py index 9ead85cb1b2e7..3029a304cdbbe 100644 --- a/pandas/core/indexes/accessors.py +++ b/pandas/core/indexes/accessors.py @@ -489,7 +489,7 @@ def to_pytimedelta(self) -> np.ndarray: 2 2 days 3 3 days 4 4 days - dtype: timedelta64[ns] + dtype: timedelta64[s] >>> s.dt.to_pytimedelta() array([datetime.timedelta(0), datetime.timedelta(days=1), @@ -535,7 +535,7 @@ def components(self) -> DataFrame: 2 0 days 00:00:02 3 0 days 00:00:03 4 0 days 00:00:04 - dtype: timedelta64[ns] + dtype: timedelta64[s] >>> s.dt.components days hours minutes seconds milliseconds microseconds nanoseconds 0 0 0 0 0 0 0 0 diff --git a/pandas/core/indexes/datetimelike.py b/pandas/core/indexes/datetimelike.py index fd061666c1f00..921b67ca1ed69 100644 --- a/pandas/core/indexes/datetimelike.py +++ b/pandas/core/indexes/datetimelike.py @@ -132,7 +132,7 @@ def mean(self, *, skipna: bool = True, axis: int | None = 0): >>> tdelta_idx = pd.to_timedelta([1, 2, 3], unit="D") >>> tdelta_idx TimedeltaIndex(['1 days', '2 days', '3 days'], - dtype='timedelta64[ns]', freq=None) + dtype='timedelta64[s]', freq=None) >>> tdelta_idx.mean() Timedelta('2 days 00:00:00') """ From 4430abda5beeb4b96cb7fc26a911a7a9e6effcc1 Mon Sep 17 00:00:00 2001 From: Brock Date: Tue, 9 Dec 2025 07:59:03 -0800 Subject: [PATCH 6/6] update doctest --- pandas/core/tools/timedeltas.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pandas/core/tools/timedeltas.py b/pandas/core/tools/timedeltas.py index 2dc5e29308214..8d9884e3df50b 100644 --- a/pandas/core/tools/timedeltas.py +++ b/pandas/core/tools/timedeltas.py @@ -171,10 +171,10 @@ def to_timedelta( >>> pd.to_timedelta(np.arange(5), unit="s") TimedeltaIndex(['0 days 00:00:00', '0 days 00:00:01', '0 days 00:00:02', '0 days 00:00:03', '0 days 00:00:04'], - dtype='timedelta64[ns]', freq=None) + dtype='timedelta64[s]', freq=None) >>> pd.to_timedelta(np.arange(5), unit="D") TimedeltaIndex(['0 days', '1 days', '2 days', '3 days', '4 days'], - dtype='timedelta64[ns]', freq=None) + dtype='timedelta64[s]', freq=None) """ if unit is not None: unit = parse_timedelta_unit(unit)