From d3fde612b9e6b485825d900b75db04e058eb4917 Mon Sep 17 00:00:00 2001 From: Robsdedude Date: Wed, 2 Oct 2024 10:54:00 +0200 Subject: [PATCH] Fix bugs in neo4j.time.DateTime handling * Fix `DateTime` +/- `Duration` computation being wildly off by considering the days of the `DateTime` since UNIX epoch twice. * Fix `DateTime.__ne__` (inequality operator) incorrectly comparing against non-DateTime-like types. --- neo4j/time/__init__.py | 10 +- tests/unit/time/test_datetime.py | 206 +++++++++++++++++++++++++++++++ 2 files changed, 214 insertions(+), 2 deletions(-) diff --git a/neo4j/time/__init__.py b/neo4j/time/__init__.py index 5b8b50c46..d020d9bee 100644 --- a/neo4j/time/__init__.py +++ b/neo4j/time/__init__.py @@ -2399,6 +2399,8 @@ def __ne__(self, other): """ `!=` comparison with :class:`.DateTime` or :class:`datetime.datetime`. """ + if not isinstance(other, (datetime, DateTime)): + return NotImplemented return not self.__eq__(other) def __lt__(self, other): @@ -2461,6 +2463,8 @@ def __add__(self, other): :rtype: DateTime """ if isinstance(other, timedelta): + if other.total_seconds() == 0: + return self t = (self.to_clock_time() + ClockTime(86400 * other.days + other.seconds, other.microseconds * 1000)) @@ -2471,12 +2475,14 @@ def __add__(self, other): )) return self.combine(date_, time_).replace(tzinfo=self.tzinfo) if isinstance(other, Duration): - t = (self.to_clock_time() + if other == (0, 0, 0, 0): + return self + t = (self.time().to_clock_time() + ClockTime(other.seconds, other.nanoseconds)) days, seconds = symmetric_divmod(t.seconds, 86400) date_ = self.date() + Duration(months=other.months, days=days + other.days) - time_ = Time.from_ticks(seconds * NANO_SECONDS + t.nanoseconds) + time_ = Time.from_ticks_ns(seconds * NANO_SECONDS + t.nanoseconds) return self.combine(date_, time_).replace(tzinfo=self.tzinfo) return NotImplemented diff --git a/tests/unit/time/test_datetime.py b/tests/unit/time/test_datetime.py index 4e33f90c8..3aad9cc93 100644 --- a/tests/unit/time/test_datetime.py +++ b/tests/unit/time/test_datetime.py @@ -51,6 +51,7 @@ timezone_london = timezone("Europe/London") timezone_berlin = timezone("Europe/Berlin") timezone_utc = timezone("UTC") +timezone_utc_p2 = FixedOffset(120) class DateTime(_DateTime): @@ -274,6 +275,211 @@ def test_subtract_native_datetime_2(self, seconds_args): t = dt1 - dt2 assert t == timedelta(days=65, hours=23, seconds=17.914390409) + @pytest.mark.parametrize( + ("dt_early", "delta", "dt_late"), + ( + ( + DateTime(2024, 3, 31, 0, 30, 0), + Duration(nanoseconds=1), + DateTime(2024, 3, 31, 0, 30, 0, 1), + ), + ( + DateTime(2024, 3, 31, 0, 30, 0), + Duration(hours=24), + DateTime(2024, 4, 1, 0, 30, 0), + ), + ( + DateTime(2024, 3, 31, 0, 30, 0), + timedelta(microseconds=1), + DateTime(2024, 3, 31, 0, 30, 0, 1000), + ), + ( + DateTime(2024, 3, 31, 0, 30, 0), + timedelta(hours=24), + DateTime(2024, 4, 1, 0, 30, 0), + ), + ), + ) + @pytest.mark.parametrize( + "tz", + (None, timezone_utc, timezone_utc_p2, timezone_berlin), + ) + def test_add_duration(self, dt_early, delta, dt_late, tz): + if tz is not None: + dt_early = timezone_utc.localize(dt_early).astimezone(tz) + dt_late = timezone_utc.localize(dt_late).astimezone(tz) + assert dt_early + delta == dt_late + + @pytest.mark.parametrize( + ("datetime_cls", "delta_cls"), + ( + (datetime, timedelta), # baseline (what Python's datetime does) + (DateTime, Duration), + (DateTime, timedelta), + ), + ) + def test_transition_to_summertime(self, datetime_cls, delta_cls): + dt = datetime_cls(2022, 3, 27, 1, 30) + dt = timezone_berlin.localize(dt) + assert dt.utcoffset() == timedelta(hours=1) + assert isinstance(dt, datetime_cls) + time = dt.time() + assert (time.hour, time.minute) == (1, 30) + + dt += delta_cls(hours=1) + + # The native datetime object treats timedelta addition as wall time + # addition. This is imo silly, but what Python decided to do. So want + # our implementation to match that. See also: + # https://stackoverflow.com/questions/76583100/is-pytz-deprecated-now-or-in-the-future-in-python + assert dt.utcoffset() == timedelta(hours=1) + assert isinstance(dt, datetime_cls) + time = dt.time() + assert (time.hour, time.minute) == (2, 30) + + @pytest.mark.parametrize( + ("datetime_cls", "delta_cls"), + ( + (datetime, timedelta), # baseline (what Python's datetime does) + (DateTime, Duration), + (DateTime, timedelta), + ), + ) + def test_transition_from_summertime(self, datetime_cls, delta_cls): + dt = datetime_cls(2022, 10, 30, 2, 30) + dt = timezone_berlin.localize(dt, is_dst=True) + assert dt.utcoffset() == timedelta(hours=2) + assert isinstance(dt, datetime_cls) + time = dt.time() + assert (time.hour, time.minute) == (2, 30) + + dt += delta_cls(hours=1) + + # The native datetime object treats timedelta addition as wall time + # addition. This is imo silly, but what Python decided to do. So want + # our implementation to match that. See also: + # https://stackoverflow.com/questions/76583100/is-pytz-deprecated-now-or-in-the-future-in-python + assert dt.utcoffset() == timedelta(hours=2) + assert isinstance(dt, datetime_cls) + time = dt.time() + assert (time.hour, time.minute) == (3, 30) + + @pytest.mark.parametrize( + ("dt1", "dt2"), + ( + ( + DateTime(2018, 4, 27, 23, 0, 17, 914390409), + DateTime(2018, 4, 27, 23, 0, 17, 914390409), + ), + ( + utc.localize(DateTime(2018, 4, 27, 23, 0, 17, 914390409)), + utc.localize(DateTime(2018, 4, 27, 23, 0, 17, 914390409)), + ), + ( + utc.localize(DateTime(2018, 4, 27, 23, 0, 17, 914390409)), + utc.localize( + DateTime(2018, 4, 27, 23, 0, 17, 914390409) + ).astimezone(timezone_berlin), + ), + ), + ) + @pytest.mark.parametrize("native", (True, False)) + def test_eq( self, dt1, dt2, native): + assert isinstance(dt1, DateTime) + assert isinstance(dt2, DateTime) + if native: + dt1 = dt1.replace(nanosecond=dt1.nanosecond // 1000 * 1000) + dt2 = dt2.to_native() + assert dt1 == dt2 + assert dt2 == dt1 + # explicitly test that `not !=` is `==` (different code paths) + assert not dt1 != dt2 + assert not dt2 != dt1 + + @pytest.mark.parametrize( + ("dt1", "dt2", "native"), + ( + # nanosecond difference + ( + DateTime(2018, 4, 27, 23, 0, 17, 914390408), + DateTime(2018, 4, 27, 23, 0, 17, 914390409), + False, + ), + *( + ( + dt1, + DateTime(2018, 4, 27, 23, 0, 17, 914390409), + native, + ) + for dt1 in ( + DateTime(2018, 4, 27, 23, 0, 17, 914391409), + DateTime(2018, 4, 27, 23, 0, 18, 914390409), + DateTime(2018, 4, 27, 23, 1, 17, 914390409), + DateTime(2018, 4, 27, 22, 0, 17, 914390409), + DateTime(2018, 4, 26, 23, 0, 17, 914390409), + DateTime(2018, 5, 27, 23, 0, 17, 914390409), + DateTime(2019, 4, 27, 23, 0, 17, 914390409), + ) + for native in (True, False) + ), + *( + ( + # type ignore: + # https://github.com/python/typeshed/issues/12715 + tz1.localize(dt1, is_dst=None), # type: ignore[arg-type] + tz2.localize( + DateTime(2018, 4, 27, 23, 0, 17, 914390409), + is_dst=None, # type: ignore[arg-type] + ), + native, + ) + for dt1 in ( + DateTime(2018, 4, 27, 23, 0, 17, 914391409), + DateTime(2018, 4, 27, 23, 0, 18, 914390409), + DateTime(2018, 4, 27, 23, 1, 17, 914390409), + DateTime(2018, 4, 27, 22, 0, 17, 914390409), + DateTime(2018, 4, 26, 23, 0, 17, 914390409), + DateTime(2018, 5, 27, 23, 0, 17, 914390409), + DateTime(2019, 4, 27, 23, 0, 17, 914390409), + ) + for native in (True, False) + for tz1, tz2 in itertools.combinations_with_replacement( + (timezone_utc, timezone_utc_p2, timezone_berlin), 2 + ) + ), + ), + ) + def test_ne(self, dt1, dt2, native): + assert isinstance(dt1, DateTime) + assert isinstance(dt2, DateTime) + if native: + dt2 = dt2.to_native() + assert dt1 != dt2 + assert dt2 != dt1 + # explicitly test that `not ==` is `!=` (different code paths) + assert not dt1 == dt2 + assert not dt2 == dt1 + + @pytest.mark.parametrize( + "other", + ( + object(), + 1, + DateTime(2018, 4, 27, 23, 0, 17, 914391409).to_clock_time(), + ( + DateTime(2018, 4, 27, 23, 0, 17, 914391409) + - DateTime(1970, 1, 1) + ), + ), + ) + def test_ne_object(self, other): + dt = DateTime(2018, 4, 27, 23, 0, 17, 914391409) + assert dt != other + assert other != dt + # explicitly test that `not ==` is `!=` (different code paths) + assert not dt == other + assert not other == dt + def test_normalization(self): ndt1 = timezone_us_eastern.normalize(DateTime(2018, 4, 27, 23, 0, 17, tzinfo=timezone_us_eastern)) ndt2 = timezone_us_eastern.normalize(datetime(2018, 4, 27, 23, 0, 17, tzinfo=timezone_us_eastern))