diff --git a/src/pendulum/__init__.py b/src/pendulum/__init__.py index 16ae0865..5603ae75 100644 --- a/src/pendulum/__init__.py +++ b/src/pendulum/__init__.py @@ -286,7 +286,11 @@ 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, OverflowError): + 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/src/pendulum/datetime.py b/src/pendulum/datetime.py index da89b13d..c6af6197 100644 --- a/src/pendulum/datetime.py +++ b/src/pendulum/datetime.py @@ -1253,11 +1253,23 @@ 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, OverflowError): + 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, 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) @classmethod def fromordinal(cls, n: int) -> Self: diff --git a/tests/datetime/test_behavior.py b/tests/datetime/test_behavior.py index a00f807c..30be498c 100644 --- a/tests/datetime/test_behavior.py +++ b/tests/datetime/test_behavior.py @@ -1,8 +1,13 @@ from __future__ import annotations +import datetime as datetime_ +import importlib import pickle +import types import zoneinfo +import pytest + from copy import deepcopy from datetime import date from datetime import datetime @@ -107,6 +112,54 @@ def test_utcfromtimestamp(): assert p == dt +@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 exception_type("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" + + +@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 exception_type("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) diff --git a/tests/datetime/test_create_from_timestamp.py b/tests/datetime/test_create_from_timestamp.py index 121a7c29..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 @@ -22,3 +25,41 @@ 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(): + 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(): + 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(): + 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"