Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions doc/release/upcoming_changes/31378.compatibility.rst
Original file line number Diff line number Diff line change
@@ -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.
82 changes: 74 additions & 8 deletions numpy/_core/src/umath/loops.c.src
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -807,6 +808,15 @@ NPY_NO_EXPORT void

/**end repeat**/

/*
* Overflow-checked arithmetic for datetime64/timedelta64.
*
* 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.
*/
Comment thread
ngoldbaum marked this conversation as resolved.
NPY_NO_EXPORT void
DATETIME_Mm_M_add(char **args, npy_intp const *dimensions, npy_intp const *steps, void *NPY_UNUSED(data))
{
Expand All @@ -817,7 +827,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 + timedelta64 addition");
return;
}
*((npy_datetime *)op1) = result;
}
}
}
Expand All @@ -832,7 +849,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 timedelta64 + datetime64 addition");
return;
}
*((npy_datetime *)op1) = result;
}
}
}
Expand All @@ -847,7 +871,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 + timedelta64 addition");
return;
}
*((npy_timedelta *)op1) = result;
}
}
}
Expand All @@ -862,7 +893,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 - timedelta64 subtraction");
return;
}
*((npy_datetime *)op1) = result;
}
}
}
Expand All @@ -877,7 +915,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 - datetime64 subtraction");
return;
}
*((npy_timedelta *)op1) = result;
}
}
}
Expand All @@ -892,7 +937,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 - timedelta64 subtraction");
return;
}
*((npy_timedelta *)op1) = result;
}
}
}
Expand All @@ -908,7 +960,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);
Comment thread
ngoldbaum marked this conversation as resolved.
if (overflow || result == NPY_DATETIME_NAT) {
npy_gil_error(PyExc_OverflowError,
"Overflow in timedelta64 * int64 multiplication");
return;
}
*((npy_timedelta *)op1) = result;
}
}
}
Expand All @@ -924,7 +983,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 int64 * timedelta64 multiplication");
return;
}
*((npy_timedelta *)op1) = result;
}
}
}
Expand Down
172 changes: 172 additions & 0 deletions numpy/_core/tests/test_datetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -1093,6 +1093,178 @@ def test_cast_overflow_safe_unit_conversion(self):
with pytest.raises(OverflowError, match="Overflow"):
arr_2s_big.astype("datetime64[ns]")

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. 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")
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_pos + td_pos
with pytest.raises(OverflowError, match="Overflow"):
td_pos + dt_pos

# datetime64 - timedelta64
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") - dt_neg

# timedelta64 + timedelta64
with pytest.raises(OverflowError, match="Overflow"):
td_big + td_pos

# timedelta64 - timedelta64
with pytest.raises(OverflowError, match="Overflow"):
td_neg - td_pos

# 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"):
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"):
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"):
td * np.int64(2)
with pytest.raises(OverflowError, match="Overflow"):
np.int64(2) * td

# 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
# 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"):
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)

# 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.
dt = np.datetime64(0, "s")
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)
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)

# multiply
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.
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")

small = np.timedelta64(3, "s")
assert small * np.int64(7) == np.timedelta64(21, "s")
assert np.int64(7) * small == np.timedelta64(21, "s")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you generate a C coverage report and scrutinize the C code you changed in this PR to make sure all code paths are covered, including the new error paths? You can generate a C coverage report with spin test --gcov --gcov-format=html if you have gcov installed. You can probably ask Claude to help you run the coverage tests and fill in coverage gaps. I asked for a review of this code and got the following comments, I didn't actually verify any of the claims from Claude:

Details
  1. safe_add overflow that does not land on NaT — none of the current add tests isolate this branch. Add e.g.:

INT64_MAX + 2 wraps to INT64_MIN + 1 (valid timedelta), so only safe_add can catch it

with pytest.raises(OverflowError, match="Overflow"):
np.timedelta64(big, "s") + np.timedelta64(2, "s")

  1. (Note: np.datetime64(big, "s") - dt_neg already isolates safe_sub — that test wraps to -3, not NaT — so subtract is fine.)
  2. Same for safe_mul — every multiply overflow test lands on NaT. Add:
    with pytest.raises(OverflowError, match="Overflow"):
    np.timedelta64(big, "s") * np.int64(2)
  3. Negative-side overflow in add (a < 0 && b < NPY_MIN_INT64 - a branch of safe_add):
    with pytest.raises(OverflowError, match="Overflow"):
    np.timedelta64(-big, "s") + np.timedelta64(-2, "s")
  4. Negative multiplier and INT64_MIN multiplier:
    with pytest.raises(OverflowError, match="Overflow"):
    np.timedelta64(big, "s") * np.int64(-2)
    with pytest.raises(OverflowError, match="Overflow"):
    np.timedelta64(2, "s") * np.int64(np.iinfo(np.int64).min)
  5. NaT short-circuit beats overflow check — a regression guard:
    assert np.isnat(np.datetime64('NaT', 's') + np.timedelta64(big, 's'))
  6. Result-equals-NaT in the array path. Scalar coverage is good, but the strided loop should also raise, not wrap:
    arr = np.array([0, -big], dtype="timedelta64[s]")
    with pytest.raises(OverflowError, match="Overflow"):
    arr + np.timedelta64(-1, "s")
  7. Asymmetric array coverage. Array tests currently exercise only mm_m_add, mm_m_subtract, mq_m_multiply. Five of the eight loops have no
    array-path coverage:
    - DATETIME_Mm_M_add (arr_M + arr_m)
    - DATETIME_mM_M_add (arr_m + arr_M)
    - DATETIME_Mm_M_subtract (arr_M - arr_m)
    - DATETIME_MM_m_subtract (arr_M - arr_M)
    - TIMEDELTA_qm_m_multiply (int_arr * arr_m)

The comment claims the array case "exercises the actual strided ufunc loop," but that's only true for the loops that are exercised. At least one
array test per loop would close the gap.

Nothing here is a correctness blocker — the implementation is solid. The added-test items above mostly tighten the safety net rather than expose
bugs I can see by inspection.


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,
Expand Down
Loading