Skip to content

Commit 2c14bde

Browse files
fixed rounding error for DateTime and Time (#412)
1 parent f0fa64d commit 2c14bde

File tree

4 files changed

+172
-30
lines changed

4 files changed

+172
-30
lines changed

neo4j/time/__init__.py

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,42 @@
2323
a number of utility functions.
2424
"""
2525

26-
from datetime import timedelta, date, time, datetime
26+
from datetime import (
27+
timedelta,
28+
date,
29+
time,
30+
datetime,
31+
)
2732
from functools import total_ordering
2833
from re import compile as re_compile
29-
from time import gmtime, mktime, struct_time
30-
31-
from neo4j.time.arithmetic import (nano_add, nano_sub, nano_mul, nano_div,
32-
nano_mod, nano_divmod,
33-
symmetric_divmod, round_half_to_even)
34-
from neo4j.time.metaclasses import DateType, TimeType, DateTimeType
34+
from time import (
35+
gmtime,
36+
mktime,
37+
struct_time,
38+
)
39+
from decimal import Decimal
40+
41+
from neo4j.time.arithmetic import (
42+
nano_add,
43+
nano_sub,
44+
nano_mul,
45+
nano_div,
46+
nano_mod,
47+
nano_divmod,
48+
symmetric_divmod,
49+
round_half_to_even,
50+
)
51+
from neo4j.time.metaclasses import (
52+
DateType,
53+
TimeType,
54+
DateTimeType,
55+
)
56+
57+
import logging
58+
from neo4j.debug import watch
59+
watch("neo4j")
60+
61+
log = logging.getLogger("neo4j")
3562

3663

3764
MIN_INT64 = -(2 ** 63)
@@ -49,6 +76,8 @@
4976
DURATION_ISO_PATTERN = re_compile(r"^P((\d+)Y)?((\d+)M)?((\d+)D)?"
5077
r"(T((\d+)H)?((\d+)M)?((\d+(\.\d+)?)?S)?)?$")
5178

79+
NANO_SECONDS = 1000000000
80+
5281

5382
def _is_leap_year(year):
5483
if year % 4 != 0:
@@ -1118,8 +1147,9 @@ def tzname(self):
11181147
return self.tzinfo.tzname(self)
11191148

11201149
def to_clock_time(self):
1121-
seconds, nanoseconds = nano_divmod(self.ticks, 1)
1122-
return ClockTime(seconds, 1000000000 * nanoseconds)
1150+
seconds, nanoseconds = nano_divmod(self.ticks, 1) # int, float
1151+
nanoseconds_int = int(Decimal(str(nanoseconds)) * NANO_SECONDS) # Convert fractions to an integer without losing precision
1152+
return ClockTime(seconds, nanoseconds_int)
11231153

11241154
def to_native(self):
11251155
""" Convert to a native Python `datetime.time` value.
@@ -1437,8 +1467,9 @@ def to_clock_time(self):
14371467
for month in range(1, self.month):
14381468
total_seconds += 86400 * Date.days_in_month(self.year, month)
14391469
total_seconds += 86400 * (self.day - 1)
1440-
seconds, nanoseconds = nano_divmod(self.__time.ticks, 1)
1441-
return ClockTime(total_seconds + seconds, 1000000000 * nanoseconds)
1470+
seconds, nanoseconds = nano_divmod(self.__time.ticks, 1) # int, float
1471+
nanoseconds_int = int(Decimal(str(nanoseconds)) * NANO_SECONDS) # Convert fractions to an integer without losing precision
1472+
return ClockTime(total_seconds + seconds, nanoseconds_int)
14421473

14431474
def to_native(self):
14441475
""" Convert to a native Python `datetime.datetime` value.

neo4j/time/hydration.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,9 @@ def seconds_and_nanoseconds(dt):
142142
if isinstance(dt, datetime):
143143
dt = DateTime.from_native(dt)
144144
zone_epoch = DateTime(1970, 1, 1, tzinfo=dt.tzinfo)
145-
t = dt.to_clock_time() - zone_epoch.to_clock_time()
145+
dt_clock_time = dt.to_clock_time()
146+
zone_epoch_clock_time = zone_epoch.to_clock_time()
147+
t = dt_clock_time - zone_epoch_clock_time
146148
return t.seconds, t.nanoseconds
147149

148150
tz = value.tzinfo

tests/integration/test_temporal_types.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020

2121

2222
import pytest
23+
import datetime
24+
2325
from pytz import (
2426
FixedOffset,
2527
timezone,
@@ -330,3 +332,74 @@ def test_nanosecond_resolution_duration_output(cypher_eval):
330332
assert isinstance(value, Duration)
331333
assert value == Duration(years=1, months=2, days=3, hours=4,
332334
minutes=5, seconds=6.789123456)
335+
336+
337+
def test_datetime_parameter_case1(session):
338+
# python -m pytest tests/integration/test_temporal_types.py -s -v -k test_datetime_parameter_case1
339+
dt1 = session.run("RETURN datetime('2019-10-30T07:54:02.129790001+00:00')").single().value()
340+
assert isinstance(dt1, DateTime)
341+
342+
dt2 = session.run("RETURN $date_time", date_time=dt1).single().value()
343+
assert isinstance(dt2, DateTime)
344+
345+
assert dt1 == dt2
346+
347+
348+
def test_datetime_parameter_case2(session):
349+
# python -m pytest tests/integration/test_temporal_types.py -s -v -k test_datetime_parameter_case2
350+
dt1 = session.run("RETURN datetime('2019-10-30T07:54:02.129790999[UTC]')").single().value()
351+
assert isinstance(dt1, DateTime)
352+
assert dt1.iso_format() == "2019-10-30T07:54:02.129790999+00:00"
353+
354+
dt2 = session.run("RETURN $date_time", date_time=dt1).single().value()
355+
assert isinstance(dt2, DateTime)
356+
357+
assert dt1 == dt2
358+
359+
360+
def test_datetime_parameter_case3(session):
361+
# python -m pytest tests/integration/test_temporal_types.py -s -v -k test_datetime_parameter_case1
362+
dt1 = session.run("RETURN datetime('2019-10-30T07:54:02.129790+00:00')").single().value()
363+
assert isinstance(dt1, DateTime)
364+
365+
dt2 = session.run("RETURN $date_time", date_time=dt1).single().value()
366+
assert isinstance(dt2, DateTime)
367+
368+
assert dt1 == dt2
369+
370+
371+
def test_time_parameter_case1(session):
372+
# python -m pytest tests/integration/test_temporal_types.py -s -v -k test_time_parameter_case1
373+
t1 = session.run("RETURN time('07:54:02.129790001+00:00')").single().value()
374+
assert isinstance(t1, Time)
375+
376+
t2 = session.run("RETURN $time", time=t1).single().value()
377+
assert isinstance(t2, Time)
378+
379+
assert t1 == t2
380+
381+
382+
def test_time_parameter_case2(session):
383+
# python -m pytest tests/integration/test_temporal_types.py -s -v -k test_time_parameter_case2
384+
t1 = session.run("RETURN time('07:54:02.129790999+00:00')").single().value()
385+
assert isinstance(t1, Time)
386+
# assert t1.iso_format() == "07:54:02.129790999+00:00" # TODO: Broken, does not show time_zone_delta +00:00
387+
time_zone_delta = t1.utc_offset()
388+
assert isinstance(time_zone_delta, datetime.timedelta)
389+
assert time_zone_delta == datetime.timedelta(0)
390+
391+
t2 = session.run("RETURN $time", time=t1).single().value()
392+
assert isinstance(t2, Time)
393+
394+
assert t1 == t2
395+
396+
397+
def test_time_parameter_case3(session):
398+
# python -m pytest tests/integration/test_temporal_types.py -s -v -k test_time_parameter_case3
399+
t1 = session.run("RETURN time('07:54:02.129790+00:00')").single().value()
400+
assert isinstance(t1, Time)
401+
402+
t2 = session.run("RETURN $time", time=t1).single().value()
403+
assert isinstance(t2, Time)
404+
405+
assert t1 == t2

tests/unit/time/test_datetime.py

Lines changed: 54 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,44 @@
1919
# limitations under the License.
2020

2121

22-
from datetime import datetime, timedelta
2322
from unittest import TestCase
24-
25-
from pytz import timezone, FixedOffset
26-
27-
from neo4j.time import DateTime, MIN_YEAR, MAX_YEAR, Duration
28-
from neo4j.time.arithmetic import nano_add, nano_div
29-
from neo4j.time.clock_implementations import Clock, ClockTime
30-
31-
32-
eastern = timezone("US/Eastern")
23+
from datetime import (
24+
datetime,
25+
timedelta,
26+
)
27+
from pytz import (
28+
timezone,
29+
FixedOffset,
30+
)
31+
32+
from neo4j.time import (
33+
DateTime,
34+
MIN_YEAR,
35+
MAX_YEAR,
36+
Duration,
37+
)
38+
from neo4j.time.arithmetic import (
39+
nano_add,
40+
nano_div,
41+
)
42+
from neo4j.time.clock_implementations import (
43+
Clock,
44+
ClockTime,
45+
)
46+
from neo4j.time.hydration import (
47+
hydrate_date,
48+
dehydrate_date,
49+
hydrate_time,
50+
dehydrate_time,
51+
hydrate_datetime,
52+
dehydrate_datetime,
53+
hydrate_duration,
54+
dehydrate_duration,
55+
dehydrate_timedelta,
56+
)
57+
58+
timezone_us_eastern = timezone("US/Eastern")
59+
timezone_utc = timezone("UTC")
3360

3461

3562
class FixedClock(Clock):
@@ -132,7 +159,7 @@ def test_now_without_tz(self):
132159
self.assertIsNone(t.tzinfo)
133160

134161
def test_now_with_tz(self):
135-
t = DateTime.now(eastern)
162+
t = DateTime.now(timezone_us_eastern)
136163
self.assertEqual(t.year, 1970)
137164
self.assertEqual(t.month, 1)
138165
self.assertEqual(t.day, 1)
@@ -168,7 +195,7 @@ def test_from_overflowing_timestamp(self):
168195
_ = DateTime.from_timestamp(999999999999999999)
169196

170197
def test_from_timestamp_with_tz(self):
171-
t = DateTime.from_timestamp(0, eastern)
198+
t = DateTime.from_timestamp(0, timezone_us_eastern)
172199
self.assertEqual(t.year, 1969)
173200
self.assertEqual(t.month, 12)
174201
self.assertEqual(t.day, 31)
@@ -215,13 +242,13 @@ def test_subtract_native_datetime_2(self):
215242
self.assertEqual(t, timedelta(days=65, hours=23, seconds=17.914390409))
216243

217244
def test_normalization(self):
218-
ndt1 = eastern.normalize(DateTime(2018, 4, 27, 23, 0, 17, tzinfo=eastern))
219-
ndt2 = eastern.normalize(datetime(2018, 4, 27, 23, 0, 17, tzinfo=eastern))
245+
ndt1 = timezone_us_eastern.normalize(DateTime(2018, 4, 27, 23, 0, 17, tzinfo=timezone_us_eastern))
246+
ndt2 = timezone_us_eastern.normalize(datetime(2018, 4, 27, 23, 0, 17, tzinfo=timezone_us_eastern))
220247
self.assertEqual(ndt1, ndt2)
221248

222249
def test_localization(self):
223-
ldt1 = eastern.localize(datetime(2018, 4, 27, 23, 0, 17))
224-
ldt2 = eastern.localize(DateTime(2018, 4, 27, 23, 0, 17))
250+
ldt1 = timezone_us_eastern.localize(datetime(2018, 4, 27, 23, 0, 17))
251+
ldt2 = timezone_us_eastern.localize(DateTime(2018, 4, 27, 23, 0, 17))
225252
self.assertEqual(ldt1, ldt2)
226253

227254
def test_from_native(self):
@@ -253,11 +280,11 @@ def test_iso_format_with_trailing_zeroes(self):
253280
self.assertEqual("2018-10-01T12:34:56.789000000", dt.iso_format())
254281

255282
def test_iso_format_with_tz(self):
256-
dt = eastern.localize(DateTime(2018, 10, 1, 12, 34, 56.789123456))
283+
dt = timezone_us_eastern.localize(DateTime(2018, 10, 1, 12, 34, 56.789123456))
257284
self.assertEqual("2018-10-01T12:34:56.789123456-04:00", dt.iso_format())
258285

259286
def test_iso_format_with_tz_and_trailing_zeroes(self):
260-
dt = eastern.localize(DateTime(2018, 10, 1, 12, 34, 56.789))
287+
dt = timezone_us_eastern.localize(DateTime(2018, 10, 1, 12, 34, 56.789))
261288
self.assertEqual("2018-10-01T12:34:56.789000000-04:00", dt.iso_format())
262289

263290
def test_from_iso_format_hour_only(self):
@@ -309,3 +336,12 @@ def test_from_iso_format_with_negative_long_tz(self):
309336
expected = DateTime(2018, 10, 1, 12, 34, 56.123456789, tzinfo=FixedOffset(-754))
310337
actual = DateTime.from_iso_format("2018-10-01T12:34:56.123456789-12:34:56.123456")
311338
self.assertEqual(expected, actual)
339+
340+
341+
def test_potential_rounding_error():
342+
# python -m pytest tests/unit/time/test_datetime.py -s -v -k test_potential_rounding_error
343+
expected = DateTime(2019, 10, 30, 7, 54, 2.129790999, tzinfo=timezone_utc)
344+
assert expected.iso_format() == "2019-10-30T07:54:02.129790999+00:00"
345+
346+
actual = DateTime.from_iso_format("2019-10-30T07:54:02.129790999+00:00")
347+
assert expected == actual

0 commit comments

Comments
 (0)