From e5f5167e74e345f0138c3a362475120f4d111c2b Mon Sep 17 00:00:00 2001 From: Parin Porecha Date: Sun, 19 Apr 2026 23:06:05 +0000 Subject: [PATCH 1/3] Fix from_timestamp for negative timestamps before 12h before epoch On Windows (and potentially other platforms), datetime.fromtimestamp raises OSError for timestamps earlier than a platform-specific minimum around -43200 (12h before the Unix epoch). This catches the OSError and falls back to computing from the epoch datetime with a timedelta. Adds tests for negative timestamps with UTC, explicit timezone, and microsecond precision. --- src/pendulum/__init__.py | 10 ++++++- tests/datetime/test_create_from_timestamp.py | 28 ++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/pendulum/__init__.py b/src/pendulum/__init__.py index 16ae0865..76617db0 100644 --- a/src/pendulum/__init__.py +++ b/src/pendulum/__init__.py @@ -286,7 +286,15 @@ def from_timestamp(timestamp: int | float, tz: str | Timezone = UTC) -> DateTime """ Create a DateTime instance from a timestamp. """ - dt = _datetime.datetime.fromtimestamp(timestamp, tz=UTC) + try: + dt = _datetime.datetime.fromtimestamp(timestamp, tz=UTC) + except OSError: + # On some platforms (notably Windows), datetime.fromtimestamp + # raises OSError for negative timestamps that are too far from + # the Unix epoch. Fall back to computing the result from the + # epoch and applying the offset manually. + epoch = _datetime.datetime(1970, 1, 1, tzinfo=UTC) + dt = epoch + _datetime.timedelta(seconds=timestamp) dt = datetime( dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second, dt.microsecond diff --git a/tests/datetime/test_create_from_timestamp.py b/tests/datetime/test_create_from_timestamp.py index 121a7c29..5958b5ea 100644 --- a/tests/datetime/test_create_from_timestamp.py +++ b/tests/datetime/test_create_from_timestamp.py @@ -22,3 +22,31 @@ def test_create_from_timestamp_with_timezone(): d = pendulum.from_timestamp(0, timezone("America/Toronto")) assert d.timezone_name == "America/Toronto" assert_datetime(d, 1969, 12, 31, 19, 0, 0) + + +def test_create_from_timestamp_negative(): + """Negative timestamps earlier than 12h before the Unix epoch should work. + + Regression test for issue 956: on Windows (and potentially other + platforms), datetime.fromtimestamp raises OSError for timestamps + below a platform-specific minimum. pendulum should still return a + correct DateTime. + """ + # -43201 is 1 second past the 12h-before-epoch boundary reported in the issue + d = pendulum.from_timestamp(-43201) + assert_datetime(d, 1969, 12, 31, 11, 59, 59) + assert d.timezone_name == "UTC" + + +def test_create_from_timestamp_negative_with_timezone(): + """Negative timestamps with an explicit timezone should also work.""" + d = pendulum.from_timestamp(-43201, "America/Toronto") + assert d.timezone_name == "America/Toronto" + assert_datetime(d, 1969, 12, 31, 6, 59, 59) + + +def test_create_from_timestamp_negative_with_microseconds(): + """Negative float timestamps preserving microseconds.""" + d = pendulum.from_timestamp(-43201.5) + assert_datetime(d, 1969, 12, 31, 11, 59, 58, 500000) + assert d.timezone_name == "UTC" From 4365af0463cf78e77a47338fd7f5358952227433 Mon Sep 17 00:00:00 2001 From: Parin Porecha Date: Mon, 20 Apr 2026 07:15:01 +0000 Subject: [PATCH 2/3] fix: handle negative timestamps in DateTime constructors --- src/pendulum/datetime.py | 14 ++++++++-- tests/datetime/test_behavior.py | 49 +++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/pendulum/datetime.py b/src/pendulum/datetime.py index da89b13d..8aa7100f 100644 --- a/src/pendulum/datetime.py +++ b/src/pendulum/datetime.py @@ -1253,11 +1253,21 @@ def __radd__(self, other: datetime.timedelta) -> Self: def fromtimestamp(cls, t: float, tz: datetime.tzinfo | None = None) -> Self: tzinfo = pendulum._safe_timezone(tz) - return cls.instance(datetime.datetime.fromtimestamp(t, tz=tzinfo), tz=tzinfo) + try: + dt = datetime.datetime.fromtimestamp(t, tz=tzinfo) + except OSError: + dt = (cls._EPOCH + datetime.timedelta(seconds=t)).astimezone(tzinfo) + + return cls.instance(dt, tz=tzinfo) @classmethod def utcfromtimestamp(cls, t: float) -> Self: - return cls.instance(datetime.datetime.utcfromtimestamp(t), tz=None) + try: + dt = datetime.datetime.utcfromtimestamp(t) + except OSError: + dt = datetime.datetime(1970, 1, 1) + datetime.timedelta(seconds=t) + + return cls.instance(dt, tz=None) @classmethod def fromordinal(cls, n: int) -> Self: diff --git a/tests/datetime/test_behavior.py b/tests/datetime/test_behavior.py index a00f807c..55d2c17c 100644 --- a/tests/datetime/test_behavior.py +++ b/tests/datetime/test_behavior.py @@ -1,6 +1,9 @@ from __future__ import annotations +import datetime as datetime_ +import importlib import pickle +import types import zoneinfo from copy import deepcopy @@ -107,6 +110,52 @@ def test_utcfromtimestamp(): assert p == dt +def test_fromtimestamp_falls_back_for_negative_timestamp(monkeypatch): + pendulum_datetime_module = importlib.import_module("pendulum.datetime") + + class FakeDateTime(datetime_.datetime): + @classmethod + def fromtimestamp(cls, t: float, tz: datetime_.tzinfo | None = None): + if t == -43201: + raise OSError("Invalid argument") + + return super().fromtimestamp(t, tz=tz) + + monkeypatch.setattr( + pendulum_datetime_module, + "datetime", + types.SimpleNamespace(datetime=FakeDateTime, timedelta=datetime_.timedelta), + ) + + p = pendulum.DateTime.fromtimestamp(-43201, pendulum.UTC) + + assert p == pendulum.datetime(1969, 12, 31, 11, 59, 59) + assert p.timezone_name == "UTC" + + +def test_utcfromtimestamp_falls_back_for_negative_timestamp(monkeypatch): + pendulum_datetime_module = importlib.import_module("pendulum.datetime") + + class FakeDateTime(datetime_.datetime): + @classmethod + def utcfromtimestamp(cls, t: float): + if t == -43201: + raise OSError("Invalid argument") + + return super().utcfromtimestamp(t) + + monkeypatch.setattr( + pendulum_datetime_module, + "datetime", + types.SimpleNamespace(datetime=FakeDateTime, timedelta=datetime_.timedelta), + ) + + p = pendulum.DateTime.utcfromtimestamp(-43201) + + assert p == pendulum.naive(1969, 12, 31, 11, 59, 59) + assert p.tzinfo is None + + def test_fromordinal(): assert datetime.fromordinal(730120) == pendulum.DateTime.fromordinal(730120) From e07b164051cc3fd9d7f158e40c4baf48294d4968 Mon Sep 17 00:00:00 2001 From: Parin Porecha Date: Mon, 20 Apr 2026 12:59:09 +0000 Subject: [PATCH 3/3] fix: handle OverflowError in timestamp fallbacks --- src/pendulum/__init__.py | 6 +--- src/pendulum/datetime.py | 6 ++-- tests/datetime/test_behavior.py | 12 ++++--- tests/datetime/test_create_from_timestamp.py | 33 ++++++++++++++------ 4 files changed, 36 insertions(+), 21 deletions(-) diff --git a/src/pendulum/__init__.py b/src/pendulum/__init__.py index 76617db0..5603ae75 100644 --- a/src/pendulum/__init__.py +++ b/src/pendulum/__init__.py @@ -288,11 +288,7 @@ def from_timestamp(timestamp: int | float, tz: str | Timezone = UTC) -> DateTime """ try: dt = _datetime.datetime.fromtimestamp(timestamp, tz=UTC) - except OSError: - # On some platforms (notably Windows), datetime.fromtimestamp - # raises OSError for negative timestamps that are too far from - # the Unix epoch. Fall back to computing the result from the - # epoch and applying the offset manually. + except (OSError, OverflowError): epoch = _datetime.datetime(1970, 1, 1, tzinfo=UTC) dt = epoch + _datetime.timedelta(seconds=timestamp) diff --git a/src/pendulum/datetime.py b/src/pendulum/datetime.py index 8aa7100f..c6af6197 100644 --- a/src/pendulum/datetime.py +++ b/src/pendulum/datetime.py @@ -1255,7 +1255,7 @@ def fromtimestamp(cls, t: float, tz: datetime.tzinfo | None = None) -> Self: try: dt = datetime.datetime.fromtimestamp(t, tz=tzinfo) - except OSError: + except (OSError, OverflowError): dt = (cls._EPOCH + datetime.timedelta(seconds=t)).astimezone(tzinfo) return cls.instance(dt, tz=tzinfo) @@ -1264,7 +1264,9 @@ def fromtimestamp(cls, t: float, tz: datetime.tzinfo | None = None) -> Self: def utcfromtimestamp(cls, t: float) -> Self: try: dt = datetime.datetime.utcfromtimestamp(t) - except OSError: + except (OSError, OverflowError): + # Match datetime.datetime.utcfromtimestamp(), which returns a + # naive datetime representing UTC. dt = datetime.datetime(1970, 1, 1) + datetime.timedelta(seconds=t) return cls.instance(dt, tz=None) diff --git a/tests/datetime/test_behavior.py b/tests/datetime/test_behavior.py index 55d2c17c..30be498c 100644 --- a/tests/datetime/test_behavior.py +++ b/tests/datetime/test_behavior.py @@ -6,6 +6,8 @@ import types import zoneinfo +import pytest + from copy import deepcopy from datetime import date from datetime import datetime @@ -110,14 +112,15 @@ def test_utcfromtimestamp(): assert p == dt -def test_fromtimestamp_falls_back_for_negative_timestamp(monkeypatch): +@pytest.mark.parametrize("exception_type", [OSError, OverflowError]) +def test_fromtimestamp_falls_back_for_negative_timestamp(monkeypatch, exception_type): pendulum_datetime_module = importlib.import_module("pendulum.datetime") class FakeDateTime(datetime_.datetime): @classmethod def fromtimestamp(cls, t: float, tz: datetime_.tzinfo | None = None): if t == -43201: - raise OSError("Invalid argument") + raise exception_type("Invalid argument") return super().fromtimestamp(t, tz=tz) @@ -133,14 +136,15 @@ def fromtimestamp(cls, t: float, tz: datetime_.tzinfo | None = None): assert p.timezone_name == "UTC" -def test_utcfromtimestamp_falls_back_for_negative_timestamp(monkeypatch): +@pytest.mark.parametrize("exception_type", [OSError, OverflowError]) +def test_utcfromtimestamp_falls_back_for_negative_timestamp(monkeypatch, exception_type): pendulum_datetime_module = importlib.import_module("pendulum.datetime") class FakeDateTime(datetime_.datetime): @classmethod def utcfromtimestamp(cls, t: float): if t == -43201: - raise OSError("Invalid argument") + raise exception_type("Invalid argument") return super().utcfromtimestamp(t) diff --git a/tests/datetime/test_create_from_timestamp.py b/tests/datetime/test_create_from_timestamp.py index 5958b5ea..1156dc14 100644 --- a/tests/datetime/test_create_from_timestamp.py +++ b/tests/datetime/test_create_from_timestamp.py @@ -1,6 +1,9 @@ from __future__ import annotations +import datetime as datetime_ + import pendulum +import pytest from pendulum import timezone from tests.conftest import assert_datetime @@ -25,28 +28,38 @@ def test_create_from_timestamp_with_timezone(): def test_create_from_timestamp_negative(): - """Negative timestamps earlier than 12h before the Unix epoch should work. - - Regression test for issue 956: on Windows (and potentially other - platforms), datetime.fromtimestamp raises OSError for timestamps - below a platform-specific minimum. pendulum should still return a - correct DateTime. - """ - # -43201 is 1 second past the 12h-before-epoch boundary reported in the issue d = pendulum.from_timestamp(-43201) assert_datetime(d, 1969, 12, 31, 11, 59, 59) assert d.timezone_name == "UTC" def test_create_from_timestamp_negative_with_timezone(): - """Negative timestamps with an explicit timezone should also work.""" d = pendulum.from_timestamp(-43201, "America/Toronto") assert d.timezone_name == "America/Toronto" assert_datetime(d, 1969, 12, 31, 6, 59, 59) def test_create_from_timestamp_negative_with_microseconds(): - """Negative float timestamps preserving microseconds.""" d = pendulum.from_timestamp(-43201.5) assert_datetime(d, 1969, 12, 31, 11, 59, 58, 500000) assert d.timezone_name == "UTC" + + +@pytest.mark.parametrize("exception_type", [OSError, OverflowError]) +def test_create_from_timestamp_falls_back_for_negative_timestamp( + monkeypatch, exception_type +): + class FakeDateTime(datetime_.datetime): + @classmethod + def fromtimestamp(cls, timestamp: float, tz: datetime_.tzinfo | None = None): + if timestamp == -43201: + raise exception_type("Invalid argument") + + return super().fromtimestamp(timestamp, tz=tz) + + monkeypatch.setattr(pendulum._datetime, "datetime", FakeDateTime) + + d = pendulum.from_timestamp(-43201) + + assert_datetime(d, 1969, 12, 31, 11, 59, 59) + assert d.timezone_name == "UTC"