Skip to content

Commit a6a3188

Browse files
jbrockmendelclaude
andcommitted
BUG: Timestamp landing on the NaT sentinel silently gave NaT (GH#66510)
A wall time whose int64 representation is exactly the NaT sentinel (-2**63) came back as NaT, or as a Timestamp holding the sentinel that reported pd.isna(...) as False while reading back as NaT once placed in a Series. npy_datetimestruct_to_datetime returns the sentinel rather than raising, and the later bounds checks are all skipped for NPY_NAT. Also report an out-of-range shift to UTC as OutOfBoundsDatetime instead of leaking a raw OverflowError from the int64 coercion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 9828540 commit a6a3188

5 files changed

Lines changed: 109 additions & 3 deletions

File tree

doc/source/whatsnew/v3.1.0.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,8 @@ Datetimelike
334334
- Bug in :class:`ArrowExtensionArray` where adding a :class:`DateOffset` to a ``date32[pyarrow]`` or ``date64[pyarrow]`` Series raised an ``ArrowTypeError`` (:issue:`57168`)
335335
- Bug in :class:`DatetimeIndex` constructor raising ``ValueError`` when passing equivalent but not equal frequencies (e.g. ``QS-FEB`` vs ``QS-MAY``) (:issue:`61086`)
336336
- Bug in :class:`DatetimeIndex` raising ``AttributeError`` when comparing against Arrow date types (date32, date64) (:issue:`62051`)
337+
- Bug in :class:`Timestamp` constructor and :meth:`Timestamp.replace` where a value landing exactly on the ``NaT`` sentinel — whether from a ``nanosecond`` or ``microsecond`` argument or from shifting a timezone-aware wall time to UTC — silently produced ``NaT`` (or a :class:`Timestamp` that reported itself as non-null but read back as ``NaT`` once placed in a :class:`Series`) instead of raising :class:`OutOfBoundsDatetime` (:issue:`66510`)
338+
- Bug in :class:`Timestamp` constructor and :meth:`Timestamp.replace` where shifting a timezone-aware value to UTC past the representable range raised a raw ``OverflowError`` instead of :class:`OutOfBoundsDatetime`, e.g. ``Timestamp.max.replace(tzinfo=timezone(timedelta(hours=-9)))`` (:issue:`66510`)
337339
- Bug in :class:`Timestamp` constructor where passing ``np.str_`` objects would fail in Cython string parsing (:issue:`48974`)
338340
- Bug in :class:`Timestamp` constructor where strings with a negative year of fewer than 4 digits (e.g. ``"-111-01-01"``) silently dropped the leading ``"-"`` and were parsed as a positive year; BC dates with 1-4 digit years now parse correctly, matching :class:`numpy.datetime64` (:issue:`55954`)
339341
- Bug in :class:`Timestamp` constructor, :class:`Timedelta` constructor, :func:`to_datetime`, and :func:`to_timedelta` with non-round ``float`` input and ``unit`` failing to raise when the value is just outside the representable bounds (:issue:`57366`)

pandas/_libs/tslibs/conversion.pyx

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,13 @@ cdef class _TSObject:
443443
return self.value
444444

445445

446+
cdef _raise_out_of_bounds(npy_datetimestruct *dts, NPY_DATETIMEUNIT reso):
447+
attrname = npy_unit_to_attrname[reso]
448+
raise OutOfBoundsDatetime(
449+
f"Out of bounds {attrname} timestamp: {dts_to_iso_string(dts)}"
450+
)
451+
452+
446453
cdef _TSObject convert_to_tsobject(object ts, tzinfo tz, str unit,
447454
bint dayfirst, bint yearfirst, int32_t nanos=0):
448455
"""
@@ -492,6 +499,10 @@ cdef _TSObject convert_to_tsobject(object ts, tzinfo tz, str unit,
492499
obj.value = tz_localize_to_utc_single(
493500
obj.value, tz, ambiguous="raise", nonexistent=None, creso=reso
494501
)
502+
if obj.value == NPY_NAT:
503+
# an in-bounds wall time can land on the sentinel once
504+
# shifted to UTC
505+
_raise_out_of_bounds(&obj.dts, reso)
495506
elif is_integer_object(ts) or (is_float_object(ts) and ts.is_integer()):
496507
try:
497508
ts = <int64_t>ts
@@ -617,12 +628,31 @@ cdef _TSObject convert_datetime_to_tsobject(
617628
obj.value = npy_datetimestruct_to_datetime(reso, &obj.dts)
618629
except OverflowError as err:
619630
attrname = npy_unit_to_attrname[reso]
620-
raise OutOfBoundsDatetime(f"Out of bounds {attrname} timestamp") from err
631+
raise OutOfBoundsDatetime(
632+
f"Out of bounds {attrname} timestamp: {dts_to_iso_string(&obj.dts)}"
633+
) from err
634+
635+
if obj.value == NPY_NAT:
636+
# Reachable via `nanos`, which Timestamp.replace supplies. This is the
637+
# *wall* time, not the result: it is out of bounds at this reso, and
638+
# npy_datetimestruct_to_datetime hands back the sentinel for it
639+
# instead of raising.
640+
_raise_out_of_bounds(&obj.dts, reso)
621641

622642
if obj.tzinfo is not None and not is_utc(obj.tzinfo):
623643
offset = get_utcoffset(obj.tzinfo, ts)
624644
pps = periods_per_second(reso)
625-
obj.value -= int(offset.total_seconds() * pps)
645+
# The shift to UTC can leave the representable range; report that as
646+
# OutOfBoundsDatetime rather than leaking a raw OverflowError from the
647+
# int64 coercion (cf. GH#65353, which fixed a silent wrap in the C
648+
# string parsers).
649+
if checked_sub(obj.value, int(offset.total_seconds() * pps), &obj.value):
650+
_raise_out_of_bounds(&obj.dts, reso)
651+
652+
if obj.value == NPY_NAT:
653+
# ...and an in-bounds wall time can land on the sentinel once
654+
# shifted, which the guard above cannot see.
655+
_raise_out_of_bounds(&obj.dts, reso)
626656

627657
check_overflows(obj, reso)
628658
return obj

pandas/_libs/tslibs/timestamps.pyx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3691,6 +3691,14 @@ default 'raise'
36913691
raise OutOfBoundsDatetime(
36923692
f"Out of bounds timestamp: {fmt} with frequency '{self.unit}'"
36933693
) from err
3694+
if ts.value == NPY_NAT:
3695+
# npy_datetimestruct_to_datetime hands back the sentinel for this
3696+
# one wall time instead of raising; keeping it would build a
3697+
# Timestamp that reports itself non-null but reads back as NaT.
3698+
fmt = dts_to_iso_string(&dts)
3699+
raise OutOfBoundsDatetime(
3700+
f"Out of bounds timestamp: {fmt} with frequency '{self.unit}'"
3701+
)
36943702
ts.dts = dts
36953703
ts.creso = creso
36963704
ts.fold = fold

pandas/tests/scalar/timestamp/methods/test_replace.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
from datetime import datetime
1+
from datetime import (
2+
datetime,
3+
timedelta,
4+
timezone,
5+
)
26
import zoneinfo
37

48
from dateutil.tz import gettz
@@ -31,6 +35,42 @@ def test_replace_out_of_pydatetime_bounds(self):
3135
assert result.year == 99_999
3236
assert result._value == Timestamp(np.datetime64("99999-01-01", "ms"))._value
3337

38+
def test_replace_microsecond_lands_on_nat_sentinel(self):
39+
# GH#66510 this wall time maps to the NaT sentinel; keeping it would
40+
# build a Timestamp that reports itself non-null but reads back as NaT
41+
ts = Timestamp(np.datetime64(-(2**63) + 1, "us"))
42+
with pytest.raises(OutOfBoundsDatetime, match="Out of bounds"):
43+
ts.replace(microsecond=224192)
44+
45+
@pytest.mark.parametrize(
46+
"wall, tz",
47+
[
48+
("1677-09-21 00:12:43.145224193", None),
49+
("1677-09-21 00:12:43.145224193", "UTC"),
50+
# non-zero offset: the shift to UTC is what lands on the sentinel
51+
("1677-09-21 09:31:42.145224193", "Asia/Tokyo"),
52+
],
53+
)
54+
def test_replace_nanosecond_lands_on_nat_sentinel(self, wall, tz):
55+
# GH#66510
56+
ts = Timestamp(wall, tz=tz)
57+
58+
with pytest.raises(OutOfBoundsDatetime, match="Out of bounds"):
59+
ts.replace(nanosecond=192)
60+
61+
# neighbours: below is out of bounds, above is representable
62+
with pytest.raises(OutOfBoundsDatetime, match="Out of bounds"):
63+
ts.replace(nanosecond=191)
64+
assert ts.replace(nanosecond=194)._value == ts._value + 1
65+
66+
@pytest.mark.parametrize("offset_hours", [9, -9])
67+
def test_replace_tzinfo_shift_out_of_bounds(self, offset_hours):
68+
# GH#66510 the shift to UTC leaves the representable range; that must be
69+
# an OutOfBoundsDatetime, not a raw OverflowError from the int64 coercion
70+
ts = Timestamp.max if offset_hours < 0 else Timestamp.min
71+
with pytest.raises(OutOfBoundsDatetime, match="Out of bounds"):
72+
ts.replace(tzinfo=timezone(timedelta(hours=offset_hours)))
73+
3474
def test_replace_non_nano(self):
3575
ts = Timestamp._from_value_and_reso(
3676
91514880000000000, NpyDatetimeUnit.NPY_FR_us.value, None

pandas/tests/scalar/timestamp/test_constructors.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1192,3 +1192,29 @@ def test_timestamp_iso8601_offset_out_of_bounds(tz):
11921192
msg = "Out of bounds nanosecond timestamp"
11931193
with pytest.raises(OutOfBoundsDatetime, match=msg):
11941194
Timestamp("2262-04-11T20:00:00.000000000-11:00", tz=tz)
1195+
1196+
1197+
@pytest.mark.parametrize("unit", ["ns", "us", "ms", "s"])
1198+
def test_timestamp_constructor_nat_sentinel_tz_shift(unit):
1199+
# GH#66510 a wall time whose shift to UTC lands on the NaT sentinel used to
1200+
# come back as NaT. `unit` is the creso handed to tz_localize_to_utc_single;
1201+
# the offset can only be positive here, since a negative one puts the wall
1202+
# time itself below int64 min.
1203+
offset_hours = 9
1204+
per_second = {"ns": 10**9, "us": 10**6, "ms": 10**3, "s": 1}[unit]
1205+
ts_input = np.datetime64(-(2**63) + offset_hours * 3600 * per_second, unit)
1206+
tz = timezone(timedelta(hours=offset_hours))
1207+
1208+
with pytest.raises(OutOfBoundsDatetime, match="Out of bounds .* timestamp"):
1209+
Timestamp(ts_input, tz=tz)
1210+
1211+
1212+
@pytest.mark.parametrize(
1213+
"ts_input, tz", [(Timestamp.max, "US/Pacific"), (Timestamp.min, "Asia/Tokyo")]
1214+
)
1215+
def test_timestamp_constructor_tz_shift_out_of_bounds(ts_input, tz):
1216+
# GH#66510 shifting the wall time to UTC leaves the representable range,
1217+
# which must be OutOfBoundsDatetime rather than a raw OverflowError from
1218+
# the int64 coercion
1219+
with pytest.raises(OutOfBoundsDatetime, match="Out of bounds nanosecond timestamp"):
1220+
Timestamp(ts_input, tz=tz)

0 commit comments

Comments
 (0)