Skip to content

Commit 3d81299

Browse files
authored
Fix bugs in neo4j.time.DateTime handling (#1101)
* 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.
1 parent 5248556 commit 3d81299

File tree

2 files changed

+214
-2
lines changed

2 files changed

+214
-2
lines changed

neo4j/time/__init__.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2399,6 +2399,8 @@ def __ne__(self, other):
23992399
"""
24002400
`!=` comparison with :class:`.DateTime` or :class:`datetime.datetime`.
24012401
"""
2402+
if not isinstance(other, (datetime, DateTime)):
2403+
return NotImplemented
24022404
return not self.__eq__(other)
24032405

24042406
def __lt__(self, other):
@@ -2461,6 +2463,8 @@ def __add__(self, other):
24612463
:rtype: DateTime
24622464
"""
24632465
if isinstance(other, timedelta):
2466+
if other.total_seconds() == 0:
2467+
return self
24642468
t = (self.to_clock_time()
24652469
+ ClockTime(86400 * other.days + other.seconds,
24662470
other.microseconds * 1000))
@@ -2471,12 +2475,14 @@ def __add__(self, other):
24712475
))
24722476
return self.combine(date_, time_).replace(tzinfo=self.tzinfo)
24732477
if isinstance(other, Duration):
2474-
t = (self.to_clock_time()
2478+
if other == (0, 0, 0, 0):
2479+
return self
2480+
t = (self.time().to_clock_time()
24752481
+ ClockTime(other.seconds, other.nanoseconds))
24762482
days, seconds = symmetric_divmod(t.seconds, 86400)
24772483
date_ = self.date() + Duration(months=other.months,
24782484
days=days + other.days)
2479-
time_ = Time.from_ticks(seconds * NANO_SECONDS + t.nanoseconds)
2485+
time_ = Time.from_ticks_ns(seconds * NANO_SECONDS + t.nanoseconds)
24802486
return self.combine(date_, time_).replace(tzinfo=self.tzinfo)
24812487
return NotImplemented
24822488

tests/unit/time/test_datetime.py

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
timezone_london = timezone("Europe/London")
5252
timezone_berlin = timezone("Europe/Berlin")
5353
timezone_utc = timezone("UTC")
54+
timezone_utc_p2 = FixedOffset(120)
5455

5556

5657
class DateTime(_DateTime):
@@ -274,6 +275,211 @@ def test_subtract_native_datetime_2(self, seconds_args):
274275
t = dt1 - dt2
275276
assert t == timedelta(days=65, hours=23, seconds=17.914390409)
276277

278+
@pytest.mark.parametrize(
279+
("dt_early", "delta", "dt_late"),
280+
(
281+
(
282+
DateTime(2024, 3, 31, 0, 30, 0),
283+
Duration(nanoseconds=1),
284+
DateTime(2024, 3, 31, 0, 30, 0, 1),
285+
),
286+
(
287+
DateTime(2024, 3, 31, 0, 30, 0),
288+
Duration(hours=24),
289+
DateTime(2024, 4, 1, 0, 30, 0),
290+
),
291+
(
292+
DateTime(2024, 3, 31, 0, 30, 0),
293+
timedelta(microseconds=1),
294+
DateTime(2024, 3, 31, 0, 30, 0, 1000),
295+
),
296+
(
297+
DateTime(2024, 3, 31, 0, 30, 0),
298+
timedelta(hours=24),
299+
DateTime(2024, 4, 1, 0, 30, 0),
300+
),
301+
),
302+
)
303+
@pytest.mark.parametrize(
304+
"tz",
305+
(None, timezone_utc, timezone_utc_p2, timezone_berlin),
306+
)
307+
def test_add_duration(self, dt_early, delta, dt_late, tz):
308+
if tz is not None:
309+
dt_early = timezone_utc.localize(dt_early).astimezone(tz)
310+
dt_late = timezone_utc.localize(dt_late).astimezone(tz)
311+
assert dt_early + delta == dt_late
312+
313+
@pytest.mark.parametrize(
314+
("datetime_cls", "delta_cls"),
315+
(
316+
(datetime, timedelta), # baseline (what Python's datetime does)
317+
(DateTime, Duration),
318+
(DateTime, timedelta),
319+
),
320+
)
321+
def test_transition_to_summertime(self, datetime_cls, delta_cls):
322+
dt = datetime_cls(2022, 3, 27, 1, 30)
323+
dt = timezone_berlin.localize(dt)
324+
assert dt.utcoffset() == timedelta(hours=1)
325+
assert isinstance(dt, datetime_cls)
326+
time = dt.time()
327+
assert (time.hour, time.minute) == (1, 30)
328+
329+
dt += delta_cls(hours=1)
330+
331+
# The native datetime object treats timedelta addition as wall time
332+
# addition. This is imo silly, but what Python decided to do. So want
333+
# our implementation to match that. See also:
334+
# https://stackoverflow.com/questions/76583100/is-pytz-deprecated-now-or-in-the-future-in-python
335+
assert dt.utcoffset() == timedelta(hours=1)
336+
assert isinstance(dt, datetime_cls)
337+
time = dt.time()
338+
assert (time.hour, time.minute) == (2, 30)
339+
340+
@pytest.mark.parametrize(
341+
("datetime_cls", "delta_cls"),
342+
(
343+
(datetime, timedelta), # baseline (what Python's datetime does)
344+
(DateTime, Duration),
345+
(DateTime, timedelta),
346+
),
347+
)
348+
def test_transition_from_summertime(self, datetime_cls, delta_cls):
349+
dt = datetime_cls(2022, 10, 30, 2, 30)
350+
dt = timezone_berlin.localize(dt, is_dst=True)
351+
assert dt.utcoffset() == timedelta(hours=2)
352+
assert isinstance(dt, datetime_cls)
353+
time = dt.time()
354+
assert (time.hour, time.minute) == (2, 30)
355+
356+
dt += delta_cls(hours=1)
357+
358+
# The native datetime object treats timedelta addition as wall time
359+
# addition. This is imo silly, but what Python decided to do. So want
360+
# our implementation to match that. See also:
361+
# https://stackoverflow.com/questions/76583100/is-pytz-deprecated-now-or-in-the-future-in-python
362+
assert dt.utcoffset() == timedelta(hours=2)
363+
assert isinstance(dt, datetime_cls)
364+
time = dt.time()
365+
assert (time.hour, time.minute) == (3, 30)
366+
367+
@pytest.mark.parametrize(
368+
("dt1", "dt2"),
369+
(
370+
(
371+
DateTime(2018, 4, 27, 23, 0, 17, 914390409),
372+
DateTime(2018, 4, 27, 23, 0, 17, 914390409),
373+
),
374+
(
375+
utc.localize(DateTime(2018, 4, 27, 23, 0, 17, 914390409)),
376+
utc.localize(DateTime(2018, 4, 27, 23, 0, 17, 914390409)),
377+
),
378+
(
379+
utc.localize(DateTime(2018, 4, 27, 23, 0, 17, 914390409)),
380+
utc.localize(
381+
DateTime(2018, 4, 27, 23, 0, 17, 914390409)
382+
).astimezone(timezone_berlin),
383+
),
384+
),
385+
)
386+
@pytest.mark.parametrize("native", (True, False))
387+
def test_eq( self, dt1, dt2, native):
388+
assert isinstance(dt1, DateTime)
389+
assert isinstance(dt2, DateTime)
390+
if native:
391+
dt1 = dt1.replace(nanosecond=dt1.nanosecond // 1000 * 1000)
392+
dt2 = dt2.to_native()
393+
assert dt1 == dt2
394+
assert dt2 == dt1
395+
# explicitly test that `not !=` is `==` (different code paths)
396+
assert not dt1 != dt2
397+
assert not dt2 != dt1
398+
399+
@pytest.mark.parametrize(
400+
("dt1", "dt2", "native"),
401+
(
402+
# nanosecond difference
403+
(
404+
DateTime(2018, 4, 27, 23, 0, 17, 914390408),
405+
DateTime(2018, 4, 27, 23, 0, 17, 914390409),
406+
False,
407+
),
408+
*(
409+
(
410+
dt1,
411+
DateTime(2018, 4, 27, 23, 0, 17, 914390409),
412+
native,
413+
)
414+
for dt1 in (
415+
DateTime(2018, 4, 27, 23, 0, 17, 914391409),
416+
DateTime(2018, 4, 27, 23, 0, 18, 914390409),
417+
DateTime(2018, 4, 27, 23, 1, 17, 914390409),
418+
DateTime(2018, 4, 27, 22, 0, 17, 914390409),
419+
DateTime(2018, 4, 26, 23, 0, 17, 914390409),
420+
DateTime(2018, 5, 27, 23, 0, 17, 914390409),
421+
DateTime(2019, 4, 27, 23, 0, 17, 914390409),
422+
)
423+
for native in (True, False)
424+
),
425+
*(
426+
(
427+
# type ignore:
428+
# https://github.com/python/typeshed/issues/12715
429+
tz1.localize(dt1, is_dst=None), # type: ignore[arg-type]
430+
tz2.localize(
431+
DateTime(2018, 4, 27, 23, 0, 17, 914390409),
432+
is_dst=None, # type: ignore[arg-type]
433+
),
434+
native,
435+
)
436+
for dt1 in (
437+
DateTime(2018, 4, 27, 23, 0, 17, 914391409),
438+
DateTime(2018, 4, 27, 23, 0, 18, 914390409),
439+
DateTime(2018, 4, 27, 23, 1, 17, 914390409),
440+
DateTime(2018, 4, 27, 22, 0, 17, 914390409),
441+
DateTime(2018, 4, 26, 23, 0, 17, 914390409),
442+
DateTime(2018, 5, 27, 23, 0, 17, 914390409),
443+
DateTime(2019, 4, 27, 23, 0, 17, 914390409),
444+
)
445+
for native in (True, False)
446+
for tz1, tz2 in itertools.combinations_with_replacement(
447+
(timezone_utc, timezone_utc_p2, timezone_berlin), 2
448+
)
449+
),
450+
),
451+
)
452+
def test_ne(self, dt1, dt2, native):
453+
assert isinstance(dt1, DateTime)
454+
assert isinstance(dt2, DateTime)
455+
if native:
456+
dt2 = dt2.to_native()
457+
assert dt1 != dt2
458+
assert dt2 != dt1
459+
# explicitly test that `not ==` is `!=` (different code paths)
460+
assert not dt1 == dt2
461+
assert not dt2 == dt1
462+
463+
@pytest.mark.parametrize(
464+
"other",
465+
(
466+
object(),
467+
1,
468+
DateTime(2018, 4, 27, 23, 0, 17, 914391409).to_clock_time(),
469+
(
470+
DateTime(2018, 4, 27, 23, 0, 17, 914391409)
471+
- DateTime(1970, 1, 1)
472+
),
473+
),
474+
)
475+
def test_ne_object(self, other):
476+
dt = DateTime(2018, 4, 27, 23, 0, 17, 914391409)
477+
assert dt != other
478+
assert other != dt
479+
# explicitly test that `not ==` is `!=` (different code paths)
480+
assert not dt == other
481+
assert not other == dt
482+
277483
def test_normalization(self):
278484
ndt1 = timezone_us_eastern.normalize(DateTime(2018, 4, 27, 23, 0, 17, tzinfo=timezone_us_eastern))
279485
ndt2 = timezone_us_eastern.normalize(datetime(2018, 4, 27, 23, 0, 17, tzinfo=timezone_us_eastern))

0 commit comments

Comments
 (0)