From 0d52bb88969be059fa832f6f5beae94ae9d5d193 Mon Sep 17 00:00:00 2001 From: Brock Date: Sun, 3 May 2026 07:42:11 -0700 Subject: [PATCH 1/5] ENH: raise OverflowError on datetime64/timedelta64 arithmetic overflow Addition, subtraction, and integer multiplication of datetime64 and timedelta64 arrays previously wrapped silently on signed-integer overflow, often producing values indistinguishable from NaT. The affected ufunc loops now use the existing safe_add/safe_sub/safe_mul helpers from npy_extint128.h and raise OverflowError instead, matching the behavior of unit-conversion casts (GH-31085). Results that land exactly on NPY_DATETIME_NAT (== INT64_MIN) are also treated as overflow, since they would otherwise be misinterpreted as NaT by downstream code. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../upcoming_changes/XXXXX.compatibility.rst | 9 ++ numpy/_core/src/umath/loops.c.src | 80 ++++++++++++++-- numpy/_core/tests/test_datetime.py | 95 +++++++++++++++++++ 3 files changed, 176 insertions(+), 8 deletions(-) create mode 100644 doc/release/upcoming_changes/XXXXX.compatibility.rst diff --git a/doc/release/upcoming_changes/XXXXX.compatibility.rst b/doc/release/upcoming_changes/XXXXX.compatibility.rst new file mode 100644 index 000000000000..76134b211bf2 --- /dev/null +++ b/doc/release/upcoming_changes/XXXXX.compatibility.rst @@ -0,0 +1,9 @@ +``datetime64``/``timedelta64`` arithmetic raises on overflow +------------------------------------------------------------ + +Addition, subtraction, and integer multiplication of ``datetime64`` and +``timedelta64`` values now raise ``OverflowError`` when the result would +overflow ``int64`` or land on the ``NaT`` sentinel value. Previously these +operations silently wrapped, often producing a value that was +indistinguishable from ``NaT``. This matches the overflow checking already +performed by unit-conversion casts. diff --git a/numpy/_core/src/umath/loops.c.src b/numpy/_core/src/umath/loops.c.src index 1ad9cab4666e..6c9cd402fcc1 100644 --- a/numpy/_core/src/umath/loops.c.src +++ b/numpy/_core/src/umath/loops.c.src @@ -15,6 +15,7 @@ #include "lowlevel_strided_loops.h" #include "loops_utils.h" #include "gil_utils.h" +#include "npy_extint128.h" #include "npy_pycompat.h" @@ -807,6 +808,13 @@ NPY_NO_EXPORT void /**end repeat**/ +/* + * Overflow-checked arithmetic for datetime64/timedelta64. + * + * NPY_DATETIME_NAT == NPY_MIN_INT64. A valid arithmetic result that + * happens to equal NPY_MIN_INT64 would be silently misinterpreted as + * NaT, so we treat it as overflow as well. + */ NPY_NO_EXPORT void DATETIME_Mm_M_add(char **args, npy_intp const *dimensions, npy_intp const *steps, void *NPY_UNUSED(data)) { @@ -817,7 +825,14 @@ DATETIME_Mm_M_add(char **args, npy_intp const *dimensions, npy_intp const *steps *((npy_datetime *)op1) = NPY_DATETIME_NAT; } else { - *((npy_datetime *)op1) = in1 + in2; + char overflow = 0; + const npy_int64 result = safe_add(in1, in2, &overflow); + if (overflow || result == NPY_DATETIME_NAT) { + npy_gil_error(PyExc_OverflowError, + "Overflow in datetime64 addition"); + return; + } + *((npy_datetime *)op1) = result; } } } @@ -832,7 +847,14 @@ DATETIME_mM_M_add(char **args, npy_intp const *dimensions, npy_intp const *steps *((npy_datetime *)op1) = NPY_DATETIME_NAT; } else { - *((npy_datetime *)op1) = in1 + in2; + char overflow = 0; + const npy_int64 result = safe_add(in1, in2, &overflow); + if (overflow || result == NPY_DATETIME_NAT) { + npy_gil_error(PyExc_OverflowError, + "Overflow in datetime64 addition"); + return; + } + *((npy_datetime *)op1) = result; } } } @@ -847,7 +869,14 @@ TIMEDELTA_mm_m_add(char **args, npy_intp const *dimensions, npy_intp const *step *((npy_timedelta *)op1) = NPY_DATETIME_NAT; } else { - *((npy_timedelta *)op1) = in1 + in2; + char overflow = 0; + const npy_int64 result = safe_add(in1, in2, &overflow); + if (overflow || result == NPY_DATETIME_NAT) { + npy_gil_error(PyExc_OverflowError, + "Overflow in timedelta64 addition"); + return; + } + *((npy_timedelta *)op1) = result; } } } @@ -862,7 +891,14 @@ DATETIME_Mm_M_subtract(char **args, npy_intp const *dimensions, npy_intp const * *((npy_datetime *)op1) = NPY_DATETIME_NAT; } else { - *((npy_datetime *)op1) = in1 - in2; + char overflow = 0; + const npy_int64 result = safe_sub(in1, in2, &overflow); + if (overflow || result == NPY_DATETIME_NAT) { + npy_gil_error(PyExc_OverflowError, + "Overflow in datetime64 subtraction"); + return; + } + *((npy_datetime *)op1) = result; } } } @@ -877,7 +913,14 @@ DATETIME_MM_m_subtract(char **args, npy_intp const *dimensions, npy_intp const * *((npy_timedelta *)op1) = NPY_DATETIME_NAT; } else { - *((npy_timedelta *)op1) = in1 - in2; + char overflow = 0; + const npy_int64 result = safe_sub(in1, in2, &overflow); + if (overflow || result == NPY_DATETIME_NAT) { + npy_gil_error(PyExc_OverflowError, + "Overflow in datetime64 subtraction"); + return; + } + *((npy_timedelta *)op1) = result; } } } @@ -892,7 +935,14 @@ TIMEDELTA_mm_m_subtract(char **args, npy_intp const *dimensions, npy_intp const *((npy_timedelta *)op1) = NPY_DATETIME_NAT; } else { - *((npy_timedelta *)op1) = in1 - in2; + char overflow = 0; + const npy_int64 result = safe_sub(in1, in2, &overflow); + if (overflow || result == NPY_DATETIME_NAT) { + npy_gil_error(PyExc_OverflowError, + "Overflow in timedelta64 subtraction"); + return; + } + *((npy_timedelta *)op1) = result; } } } @@ -908,7 +958,14 @@ TIMEDELTA_mq_m_multiply(char **args, npy_intp const *dimensions, npy_intp const *((npy_timedelta *)op1) = NPY_DATETIME_NAT; } else { - *((npy_timedelta *)op1) = in1 * in2; + char overflow = 0; + const npy_int64 result = safe_mul(in1, in2, &overflow); + if (overflow || result == NPY_DATETIME_NAT) { + npy_gil_error(PyExc_OverflowError, + "Overflow in timedelta64 multiplication"); + return; + } + *((npy_timedelta *)op1) = result; } } } @@ -924,7 +981,14 @@ TIMEDELTA_qm_m_multiply(char **args, npy_intp const *dimensions, npy_intp const *((npy_timedelta *)op1) = NPY_DATETIME_NAT; } else { - *((npy_timedelta *)op1) = in1 * in2; + char overflow = 0; + const npy_int64 result = safe_mul(in1, in2, &overflow); + if (overflow || result == NPY_DATETIME_NAT) { + npy_gil_error(PyExc_OverflowError, + "Overflow in timedelta64 multiplication"); + return; + } + *((npy_timedelta *)op1) = result; } } } diff --git a/numpy/_core/tests/test_datetime.py b/numpy/_core/tests/test_datetime.py index 4b57f79476fe..35eae401b6bd 100644 --- a/numpy/_core/tests/test_datetime.py +++ b/numpy/_core/tests/test_datetime.py @@ -1093,6 +1093,101 @@ def test_cast_overflow_safe_unit_conversion(self): with pytest.raises(OverflowError, match="Overflow"): arr_2s_big.astype("datetime64[ns]") + def test_arithmetic_overflow_safe_add_sub(self): + # Add/sub on datetime64/timedelta64 must raise OverflowError instead + # of silently wrapping into NPY_DATETIME_NAT (which is INT64_MIN). + big = np.iinfo(np.int64).max + + # datetime64 + timedelta64 + dt = np.datetime64(big - 1, "s") + td_pos = np.timedelta64(2, "s") + with pytest.raises(OverflowError, match="Overflow"): + dt + td_pos + with pytest.raises(OverflowError, match="Overflow"): + td_pos + dt + + # datetime64 - timedelta64 + dt_neg = np.datetime64(-big + 1, "s") + with pytest.raises(OverflowError, match="Overflow"): + dt_neg - td_pos + + # datetime64 - datetime64 (result is timedelta64) + with pytest.raises(OverflowError, match="Overflow"): + np.datetime64(big, "s") - np.datetime64(-big + 1, "s") + + # timedelta64 + timedelta64 + td_big = np.timedelta64(big - 1, "s") + with pytest.raises(OverflowError, match="Overflow"): + td_big + td_pos + + # timedelta64 - timedelta64 + td_neg = np.timedelta64(-big + 1, "s") + with pytest.raises(OverflowError, match="Overflow"): + td_neg - td_pos + + # Result that lands exactly on NPY_DATETIME_NAT (INT64_MIN) must + # also raise rather than silently corrupt to NaT. + with pytest.raises(OverflowError, match="Overflow"): + np.timedelta64(-big, "s") + np.timedelta64(-1, "s") + with pytest.raises(OverflowError, match="Overflow"): + np.timedelta64(-big, "s") - np.timedelta64(1, "s") + + # NaT must propagate without raising in any of the above. + nat_dt = np.datetime64("NaT", "s") + nat_td = np.timedelta64("NaT", "s") + assert np.isnat(nat_dt + td_pos) + assert np.isnat(td_pos + nat_dt) + assert np.isnat(dt + nat_td) + assert np.isnat(nat_dt - td_pos) + assert np.isnat(nat_dt - dt) + assert np.isnat(nat_td + td_pos) + assert np.isnat(nat_td - td_pos) + + # Valid boundary values should still work. + ok_dt = np.datetime64(big - 1, "s") + np.timedelta64(1, "s") + assert ok_dt == np.datetime64(big, "s") + ok_td = np.timedelta64(big - 1, "s") + np.timedelta64(1, "s") + assert ok_td == np.timedelta64(big, "s") + + # Array-shaped inputs (exercise the actual ufunc loop, not just scalar). + arr = np.array([0, big - 1, -big + 1], dtype="timedelta64[s]") + with pytest.raises(OverflowError, match="Overflow"): + arr + np.timedelta64(2, "s") + with pytest.raises(OverflowError, match="Overflow"): + arr - np.timedelta64(2, "s") + + def test_arithmetic_overflow_safe_multiply(self): + # Integer multiplication of timedelta64 must raise OverflowError on + # signed-integer overflow (or when the result would equal NaT). + big = np.iinfo(np.int64).max + + td = np.timedelta64(big // 2 + 1, "s") + with pytest.raises(OverflowError, match="Overflow"): + td * np.int64(2) + with pytest.raises(OverflowError, match="Overflow"): + np.int64(2) * td + + # Result that lands exactly on NPY_DATETIME_NAT (INT64_MIN): + # (-2**62) * 2 == -2**63 == NaT — must raise rather than corrupt. + td_quarter = np.timedelta64(-(1 << 62), "s") + with pytest.raises(OverflowError, match="Overflow"): + td_quarter * np.int64(2) + + # NaT propagates without raising. + nat = np.timedelta64("NaT", "s") + assert np.isnat(nat * np.int64(5)) + assert np.isnat(np.int64(5) * nat) + + # Valid multiplications still work. + small = np.timedelta64(3, "s") + assert small * np.int64(7) == np.timedelta64(21, "s") + assert np.int64(7) * small == np.timedelta64(21, "s") + + # Array path. + arr = np.array([1, big // 2 + 1], dtype="timedelta64[s]") + with pytest.raises(OverflowError, match="Overflow"): + arr * np.int64(2) + def test_pyobject_roundtrip(self): # All datetime types should be able to roundtrip through object a = np.array([0, 0, 0, 0, 0, 0, 0, 0, 0, From 01c3720c68e3b12628508d502f7c0a779834a5f2 Mon Sep 17 00:00:00 2001 From: Brock Date: Sun, 3 May 2026 07:42:40 -0700 Subject: [PATCH 2/5] DOC: rename whatsnew fragment to use PR number Co-Authored-By: Claude Opus 4.7 (1M context) --- .../{XXXXX.compatibility.rst => 31378.compatibility.rst} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename doc/release/upcoming_changes/{XXXXX.compatibility.rst => 31378.compatibility.rst} (100%) diff --git a/doc/release/upcoming_changes/XXXXX.compatibility.rst b/doc/release/upcoming_changes/31378.compatibility.rst similarity index 100% rename from doc/release/upcoming_changes/XXXXX.compatibility.rst rename to doc/release/upcoming_changes/31378.compatibility.rst From 1d064bc52bec82f13c4c4318a36dc2dceadc9404 Mon Sep 17 00:00:00 2001 From: Brock Date: Mon, 4 May 2026 07:20:29 -0700 Subject: [PATCH 3/5] TST: split overflow-safe arithmetic tests into focused cases Splits the two large overflow tests into five tests, each asserting one property: overflow-raises (add/sub), overflow-raises (multiply), result-equals-NaT corner case, NaT propagation, and valid-boundary regression guard. Addresses reviewer feedback on GH-31378. Co-Authored-By: Claude Opus 4.7 (1M context) --- numpy/_core/tests/test_datetime.py | 125 ++++++++++++++++------------- 1 file changed, 70 insertions(+), 55 deletions(-) diff --git a/numpy/_core/tests/test_datetime.py b/numpy/_core/tests/test_datetime.py index 35eae401b6bd..300ecf5260ef 100644 --- a/numpy/_core/tests/test_datetime.py +++ b/numpy/_core/tests/test_datetime.py @@ -1093,101 +1093,116 @@ def test_cast_overflow_safe_unit_conversion(self): with pytest.raises(OverflowError, match="Overflow"): arr_2s_big.astype("datetime64[ns]") - def test_arithmetic_overflow_safe_add_sub(self): + def test_arithmetic_overflow_raises_add_sub(self): # Add/sub on datetime64/timedelta64 must raise OverflowError instead - # of silently wrapping into NPY_DATETIME_NAT (which is INT64_MIN). + # of silently wrapping past INT64 range. Covers all six loops: + # Mm_M_add, mM_M_add, mm_m_add, Mm_M_subtract, MM_m_subtract, + # mm_m_subtract. One array case is included so the actual ufunc + # loop (not only the scalar fast path) is exercised. big = np.iinfo(np.int64).max - - # datetime64 + timedelta64 - dt = np.datetime64(big - 1, "s") + dt_pos = np.datetime64(big - 1, "s") + dt_neg = np.datetime64(-big + 1, "s") td_pos = np.timedelta64(2, "s") + td_big = np.timedelta64(big - 1, "s") + td_neg = np.timedelta64(-big + 1, "s") + + # datetime64 + timedelta64 (both operand orders) with pytest.raises(OverflowError, match="Overflow"): - dt + td_pos + dt_pos + td_pos with pytest.raises(OverflowError, match="Overflow"): - td_pos + dt + td_pos + dt_pos # datetime64 - timedelta64 - dt_neg = np.datetime64(-big + 1, "s") with pytest.raises(OverflowError, match="Overflow"): dt_neg - td_pos # datetime64 - datetime64 (result is timedelta64) with pytest.raises(OverflowError, match="Overflow"): - np.datetime64(big, "s") - np.datetime64(-big + 1, "s") + np.datetime64(big, "s") - dt_neg # timedelta64 + timedelta64 - td_big = np.timedelta64(big - 1, "s") with pytest.raises(OverflowError, match="Overflow"): td_big + td_pos # timedelta64 - timedelta64 - td_neg = np.timedelta64(-big + 1, "s") with pytest.raises(OverflowError, match="Overflow"): td_neg - td_pos - # Result that lands exactly on NPY_DATETIME_NAT (INT64_MIN) must - # also raise rather than silently corrupt to NaT. - with pytest.raises(OverflowError, match="Overflow"): - np.timedelta64(-big, "s") + np.timedelta64(-1, "s") - with pytest.raises(OverflowError, match="Overflow"): - np.timedelta64(-big, "s") - np.timedelta64(1, "s") - - # NaT must propagate without raising in any of the above. - nat_dt = np.datetime64("NaT", "s") - nat_td = np.timedelta64("NaT", "s") - assert np.isnat(nat_dt + td_pos) - assert np.isnat(td_pos + nat_dt) - assert np.isnat(dt + nat_td) - assert np.isnat(nat_dt - td_pos) - assert np.isnat(nat_dt - dt) - assert np.isnat(nat_td + td_pos) - assert np.isnat(nat_td - td_pos) - - # Valid boundary values should still work. - ok_dt = np.datetime64(big - 1, "s") + np.timedelta64(1, "s") - assert ok_dt == np.datetime64(big, "s") - ok_td = np.timedelta64(big - 1, "s") + np.timedelta64(1, "s") - assert ok_td == np.timedelta64(big, "s") - - # Array-shaped inputs (exercise the actual ufunc loop, not just scalar). + # Array path -- exercises the actual strided ufunc loop. arr = np.array([0, big - 1, -big + 1], dtype="timedelta64[s]") with pytest.raises(OverflowError, match="Overflow"): - arr + np.timedelta64(2, "s") + arr + td_pos with pytest.raises(OverflowError, match="Overflow"): - arr - np.timedelta64(2, "s") + arr - td_pos - def test_arithmetic_overflow_safe_multiply(self): + def test_arithmetic_overflow_raises_multiply(self): # Integer multiplication of timedelta64 must raise OverflowError on - # signed-integer overflow (or when the result would equal NaT). + # signed-integer overflow. Covers TIMEDELTA_mq_m_multiply and + # TIMEDELTA_qm_m_multiply. big = np.iinfo(np.int64).max - td = np.timedelta64(big // 2 + 1, "s") + with pytest.raises(OverflowError, match="Overflow"): td * np.int64(2) with pytest.raises(OverflowError, match="Overflow"): np.int64(2) * td - # Result that lands exactly on NPY_DATETIME_NAT (INT64_MIN): - # (-2**62) * 2 == -2**63 == NaT — must raise rather than corrupt. - td_quarter = np.timedelta64(-(1 << 62), "s") + # Array path -- exercises the actual strided ufunc loop. + arr = np.array([1, big // 2 + 1], dtype="timedelta64[s]") + with pytest.raises(OverflowError, match="Overflow"): + arr * np.int64(2) + + def test_arithmetic_result_equals_nat_raises(self): + # NPY_DATETIME_NAT == INT64_MIN. An arithmetic result that lands + # exactly on INT64_MIN would be silently misinterpreted as NaT, so + # it must raise instead. + big = np.iinfo(np.int64).max + + # add: (-big) + (-1) == INT64_MIN + with pytest.raises(OverflowError, match="Overflow"): + np.timedelta64(-big, "s") + np.timedelta64(-1, "s") + # sub: (-big) - 1 == INT64_MIN with pytest.raises(OverflowError, match="Overflow"): - td_quarter * np.int64(2) + np.timedelta64(-big, "s") - np.timedelta64(1, "s") + # mul: (-2**62) * 2 == INT64_MIN + with pytest.raises(OverflowError, match="Overflow"): + np.timedelta64(-(1 << 62), "s") * np.int64(2) + + def test_arithmetic_nat_propagation(self): + # NaT inputs must pass through every datetime/timedelta arithmetic + # ufunc without raising, even now that overflow checking is on. + dt = np.datetime64(0, "s") + td = np.timedelta64(2, "s") + nat_dt = np.datetime64("NaT", "s") + nat_td = np.timedelta64("NaT", "s") + + # add/sub + assert np.isnat(nat_dt + td) + assert np.isnat(td + nat_dt) + assert np.isnat(dt + nat_td) + assert np.isnat(nat_dt - td) + assert np.isnat(nat_dt - dt) + assert np.isnat(nat_td + td) + assert np.isnat(nat_td - td) - # NaT propagates without raising. - nat = np.timedelta64("NaT", "s") - assert np.isnat(nat * np.int64(5)) - assert np.isnat(np.int64(5) * nat) + # multiply + assert np.isnat(nat_td * np.int64(5)) + assert np.isnat(np.int64(5) * nat_td) + + def test_arithmetic_valid_boundary(self): + # Regression guard: overflow checks must not be too aggressive -- + # values that just barely fit must continue to work. + big = np.iinfo(np.int64).max + + ok_dt = np.datetime64(big - 1, "s") + np.timedelta64(1, "s") + assert ok_dt == np.datetime64(big, "s") + ok_td = np.timedelta64(big - 1, "s") + np.timedelta64(1, "s") + assert ok_td == np.timedelta64(big, "s") - # Valid multiplications still work. small = np.timedelta64(3, "s") assert small * np.int64(7) == np.timedelta64(21, "s") assert np.int64(7) * small == np.timedelta64(21, "s") - # Array path. - arr = np.array([1, big // 2 + 1], dtype="timedelta64[s]") - with pytest.raises(OverflowError, match="Overflow"): - arr * np.int64(2) - def test_pyobject_roundtrip(self): # All datetime types should be able to roundtrip through object a = np.array([0, 0, 0, 0, 0, 0, 0, 0, 0, From cf6ac2648d5e47b7f6fbd0a53628cde84185ab52 Mon Sep 17 00:00:00 2001 From: Brock Date: Wed, 6 May 2026 07:27:48 -0700 Subject: [PATCH 4/5] MAINT: clarify datetime arithmetic OverflowError messages Each of the eight datetime64/timedelta64 add/sub/multiply loops now names both operand types in its OverflowError message (e.g. "datetime64 + timedelta64 addition" rather than the ambiguous "datetime64 addition" that previously appeared in two different loops). Also reword the section-header comment so it clearly applies to every overflow-checked loop below it, not only the next function. Co-Authored-By: Claude Opus 4.7 (1M context) --- numpy/_core/src/umath/loops.c.src | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/numpy/_core/src/umath/loops.c.src b/numpy/_core/src/umath/loops.c.src index 6c9cd402fcc1..bf3142096163 100644 --- a/numpy/_core/src/umath/loops.c.src +++ b/numpy/_core/src/umath/loops.c.src @@ -811,9 +811,11 @@ NPY_NO_EXPORT void /* * Overflow-checked arithmetic for datetime64/timedelta64. * - * NPY_DATETIME_NAT == NPY_MIN_INT64. A valid arithmetic result that - * happens to equal NPY_MIN_INT64 would be silently misinterpreted as - * NaT, so we treat it as overflow as well. + * The datetime64/timedelta64 add, subtract, and integer-multiply loops + * defined below raise OverflowError on signed int64 overflow rather + * than silently wrapping. NPY_DATETIME_NAT == NPY_MIN_INT64, so a + * valid arithmetic result that happens to equal NPY_MIN_INT64 would be + * silently misinterpreted as NaT; we treat that as overflow as well. */ NPY_NO_EXPORT void DATETIME_Mm_M_add(char **args, npy_intp const *dimensions, npy_intp const *steps, void *NPY_UNUSED(data)) @@ -829,7 +831,7 @@ DATETIME_Mm_M_add(char **args, npy_intp const *dimensions, npy_intp const *steps const npy_int64 result = safe_add(in1, in2, &overflow); if (overflow || result == NPY_DATETIME_NAT) { npy_gil_error(PyExc_OverflowError, - "Overflow in datetime64 addition"); + "Overflow in datetime64 + timedelta64 addition"); return; } *((npy_datetime *)op1) = result; @@ -851,7 +853,7 @@ DATETIME_mM_M_add(char **args, npy_intp const *dimensions, npy_intp const *steps const npy_int64 result = safe_add(in1, in2, &overflow); if (overflow || result == NPY_DATETIME_NAT) { npy_gil_error(PyExc_OverflowError, - "Overflow in datetime64 addition"); + "Overflow in timedelta64 + datetime64 addition"); return; } *((npy_datetime *)op1) = result; @@ -873,7 +875,7 @@ TIMEDELTA_mm_m_add(char **args, npy_intp const *dimensions, npy_intp const *step const npy_int64 result = safe_add(in1, in2, &overflow); if (overflow || result == NPY_DATETIME_NAT) { npy_gil_error(PyExc_OverflowError, - "Overflow in timedelta64 addition"); + "Overflow in timedelta64 + timedelta64 addition"); return; } *((npy_timedelta *)op1) = result; @@ -895,7 +897,7 @@ DATETIME_Mm_M_subtract(char **args, npy_intp const *dimensions, npy_intp const * const npy_int64 result = safe_sub(in1, in2, &overflow); if (overflow || result == NPY_DATETIME_NAT) { npy_gil_error(PyExc_OverflowError, - "Overflow in datetime64 subtraction"); + "Overflow in datetime64 - timedelta64 subtraction"); return; } *((npy_datetime *)op1) = result; @@ -917,7 +919,7 @@ DATETIME_MM_m_subtract(char **args, npy_intp const *dimensions, npy_intp const * const npy_int64 result = safe_sub(in1, in2, &overflow); if (overflow || result == NPY_DATETIME_NAT) { npy_gil_error(PyExc_OverflowError, - "Overflow in datetime64 subtraction"); + "Overflow in datetime64 - datetime64 subtraction"); return; } *((npy_timedelta *)op1) = result; @@ -939,7 +941,7 @@ TIMEDELTA_mm_m_subtract(char **args, npy_intp const *dimensions, npy_intp const const npy_int64 result = safe_sub(in1, in2, &overflow); if (overflow || result == NPY_DATETIME_NAT) { npy_gil_error(PyExc_OverflowError, - "Overflow in timedelta64 subtraction"); + "Overflow in timedelta64 - timedelta64 subtraction"); return; } *((npy_timedelta *)op1) = result; @@ -962,7 +964,7 @@ TIMEDELTA_mq_m_multiply(char **args, npy_intp const *dimensions, npy_intp const const npy_int64 result = safe_mul(in1, in2, &overflow); if (overflow || result == NPY_DATETIME_NAT) { npy_gil_error(PyExc_OverflowError, - "Overflow in timedelta64 multiplication"); + "Overflow in timedelta64 * int64 multiplication"); return; } *((npy_timedelta *)op1) = result; @@ -985,7 +987,7 @@ TIMEDELTA_qm_m_multiply(char **args, npy_intp const *dimensions, npy_intp const const npy_int64 result = safe_mul(in1, in2, &overflow); if (overflow || result == NPY_DATETIME_NAT) { npy_gil_error(PyExc_OverflowError, - "Overflow in timedelta64 multiplication"); + "Overflow in int64 * timedelta64 multiplication"); return; } *((npy_timedelta *)op1) = result; From c1d4ad2a92f0a0c4acd04ca9422ee9f599e92ac1 Mon Sep 17 00:00:00 2001 From: Brock Date: Wed, 6 May 2026 07:27:57 -0700 Subject: [PATCH 5/5] TST: expand datetime/timedelta arithmetic overflow coverage Tighten the coverage added in the previous test split: * Isolate the safe_add and safe_mul bounds checks from the result == NPY_DATETIME_NAT short-circuit by adding overflow cases whose wrapped result is *not* INT64_MIN. * Cover the negative-side branch of safe_add (a < 0 && b < INT64_MIN - a) and the negative-multiplier / INT64_MIN-multiplier branches of safe_mul. * Add an array-path case for every signature so the strided ufunc kernel is exercised for Mm_M_add, mM_M_add, Mm_M_subtract, MM_m_subtract, and qm_m_multiply (which previously only had scalar coverage), plus an array case for the result-equals-NaT check. * Add a regression guard that the NaT short-circuit beats the overflow check, so a NaT operand combined with an otherwise-overflowing value still propagates NaT instead of raising. Co-Authored-By: Claude Opus 4.7 (1M context) --- numpy/_core/tests/test_datetime.py | 76 +++++++++++++++++++++++++++--- 1 file changed, 69 insertions(+), 7 deletions(-) diff --git a/numpy/_core/tests/test_datetime.py b/numpy/_core/tests/test_datetime.py index 300ecf5260ef..02e87e284774 100644 --- a/numpy/_core/tests/test_datetime.py +++ b/numpy/_core/tests/test_datetime.py @@ -1097,8 +1097,8 @@ def test_arithmetic_overflow_raises_add_sub(self): # Add/sub on datetime64/timedelta64 must raise OverflowError instead # of silently wrapping past INT64 range. Covers all six loops: # Mm_M_add, mM_M_add, mm_m_add, Mm_M_subtract, MM_m_subtract, - # mm_m_subtract. One array case is included so the actual ufunc - # loop (not only the scalar fast path) is exercised. + # mm_m_subtract. Each loop is exercised both via the scalar fast + # path and via the strided ufunc loop. big = np.iinfo(np.int64).max dt_pos = np.datetime64(big - 1, "s") dt_neg = np.datetime64(-big + 1, "s") @@ -1128,18 +1128,47 @@ def test_arithmetic_overflow_raises_add_sub(self): with pytest.raises(OverflowError, match="Overflow"): td_neg - td_pos - # Array path -- exercises the actual strided ufunc loop. - arr = np.array([0, big - 1, -big + 1], dtype="timedelta64[s]") + # Overflow that does *not* wrap onto NPY_DATETIME_NAT -- isolates + # the safe_add bounds check from the result == NaT short-circuit. + # big + 2 wraps to INT64_MIN + 1, a valid timedelta value. with pytest.raises(OverflowError, match="Overflow"): - arr + td_pos + np.timedelta64(big, "s") + np.timedelta64(2, "s") + # Negative-side branch of safe_add: a < 0 && b < INT64_MIN - a. + # -big + -2 wraps to INT64_MAX - 1, also non-NaT. with pytest.raises(OverflowError, match="Overflow"): - arr - td_pos + np.timedelta64(-big, "s") + np.timedelta64(-2, "s") + + # Array path -- one case per loop so the strided ufunc kernel is + # exercised for every signature, not only mm_m_add/subtract. + arr_td = np.array([0, big - 1, -big + 1], dtype="timedelta64[s]") + # TIMEDELTA_mm_m_add, TIMEDELTA_mm_m_subtract + with pytest.raises(OverflowError, match="Overflow"): + arr_td + td_pos + with pytest.raises(OverflowError, match="Overflow"): + arr_td - td_pos + # DATETIME_Mm_M_add (datetime + timedelta) + arr_dt = np.array([0, big - 1], dtype="datetime64[s]") + arr_td_add = np.array([0, 2], dtype="timedelta64[s]") + with pytest.raises(OverflowError, match="Overflow"): + arr_dt + arr_td_add + # DATETIME_mM_M_add (timedelta + datetime, swapped operand order) + with pytest.raises(OverflowError, match="Overflow"): + arr_td_add + arr_dt + # DATETIME_Mm_M_subtract (datetime - timedelta) + arr_dt_neg = np.array([0, -big + 1], dtype="datetime64[s]") + with pytest.raises(OverflowError, match="Overflow"): + arr_dt_neg - arr_td_add + # DATETIME_MM_m_subtract (datetime - datetime, result timedelta) + arr_dt_big = np.array([0, big], dtype="datetime64[s]") + with pytest.raises(OverflowError, match="Overflow"): + arr_dt_big - arr_dt_neg def test_arithmetic_overflow_raises_multiply(self): # Integer multiplication of timedelta64 must raise OverflowError on # signed-integer overflow. Covers TIMEDELTA_mq_m_multiply and # TIMEDELTA_qm_m_multiply. big = np.iinfo(np.int64).max + int64_min = np.iinfo(np.int64).min td = np.timedelta64(big // 2 + 1, "s") with pytest.raises(OverflowError, match="Overflow"): @@ -1147,10 +1176,29 @@ def test_arithmetic_overflow_raises_multiply(self): with pytest.raises(OverflowError, match="Overflow"): np.int64(2) * td - # Array path -- exercises the actual strided ufunc loop. + # Overflow that does *not* wrap onto NPY_DATETIME_NAT -- isolates + # the safe_mul bounds check from the result == NaT short-circuit. + # big * 2 wraps to -2, a valid (non-NaT) timedelta. + with pytest.raises(OverflowError, match="Overflow"): + np.timedelta64(big, "s") * np.int64(2) + # Negative multiplier branch of safe_mul. + with pytest.raises(OverflowError, match="Overflow"): + np.timedelta64(big, "s") * np.int64(-2) + # INT64_MIN multiplier exercises the b < 0 sub-branch and is + # itself the NaT sentinel; the bounds check must catch it before + # the multiply produces a UB-tainted value. + with pytest.raises(OverflowError, match="Overflow"): + np.timedelta64(2, "s") * np.int64(int64_min) + + # Array path -- one case per loop signature. arr = np.array([1, big // 2 + 1], dtype="timedelta64[s]") + # TIMEDELTA_mq_m_multiply (timedelta * int64) with pytest.raises(OverflowError, match="Overflow"): arr * np.int64(2) + # TIMEDELTA_qm_m_multiply (int64 * timedelta), reversed operand + # order to select the qm signature. + with pytest.raises(OverflowError, match="Overflow"): + np.int64(2) * arr def test_arithmetic_result_equals_nat_raises(self): # NPY_DATETIME_NAT == INT64_MIN. An arithmetic result that lands @@ -1168,6 +1216,12 @@ def test_arithmetic_result_equals_nat_raises(self): with pytest.raises(OverflowError, match="Overflow"): np.timedelta64(-(1 << 62), "s") * np.int64(2) + # Same check via the strided ufunc loop -- the second element + # (-big + -1) == INT64_MIN. + arr = np.array([0, -big], dtype="timedelta64[s]") + with pytest.raises(OverflowError, match="Overflow"): + arr + np.timedelta64(-1, "s") + def test_arithmetic_nat_propagation(self): # NaT inputs must pass through every datetime/timedelta arithmetic # ufunc without raising, even now that overflow checking is on. @@ -1175,6 +1229,7 @@ def test_arithmetic_nat_propagation(self): td = np.timedelta64(2, "s") nat_dt = np.datetime64("NaT", "s") nat_td = np.timedelta64("NaT", "s") + big = np.iinfo(np.int64).max # add/sub assert np.isnat(nat_dt + td) @@ -1189,6 +1244,13 @@ def test_arithmetic_nat_propagation(self): assert np.isnat(nat_td * np.int64(5)) assert np.isnat(np.int64(5) * nat_td) + # Regression guard: the NaT short-circuit must run before the + # overflow check, so a NaT operand combined with a value that + # would otherwise overflow still yields NaT instead of raising. + assert np.isnat(nat_dt + np.timedelta64(big, "s")) + assert np.isnat(np.timedelta64(big, "s") + nat_td) + assert np.isnat(nat_td * np.int64(big)) + def test_arithmetic_valid_boundary(self): # Regression guard: overflow checks must not be too aggressive -- # values that just barely fit must continue to work.