From fe939e3de576b77c4d4e6b33497513cc323e92d2 Mon Sep 17 00:00:00 2001 From: martin bendsoe Date: Mon, 8 Jun 2020 23:45:48 +0200 Subject: [PATCH] fixed rounding error for DateTime and Time --- neo4j/time/__init__.py | 53 +++++++++++++---- neo4j/time/hydration.py | 4 +- tests/integration/test_temporal_types.py | 73 ++++++++++++++++++++++++ tests/unit/time/test_datetime.py | 72 +++++++++++++++++------ 4 files changed, 172 insertions(+), 30 deletions(-) diff --git a/neo4j/time/__init__.py b/neo4j/time/__init__.py index 9b4b0f669..2e541a35c 100644 --- a/neo4j/time/__init__.py +++ b/neo4j/time/__init__.py @@ -23,15 +23,42 @@ a number of utility functions. """ -from datetime import timedelta, date, time, datetime +from datetime import ( + timedelta, + date, + time, + datetime, +) from functools import total_ordering from re import compile as re_compile -from time import gmtime, mktime, struct_time - -from neo4j.time.arithmetic import (nano_add, nano_sub, nano_mul, nano_div, - nano_mod, nano_divmod, - symmetric_divmod, round_half_to_even) -from neo4j.time.metaclasses import DateType, TimeType, DateTimeType +from time import ( + gmtime, + mktime, + struct_time, +) +from decimal import Decimal + +from neo4j.time.arithmetic import ( + nano_add, + nano_sub, + nano_mul, + nano_div, + nano_mod, + nano_divmod, + symmetric_divmod, + round_half_to_even, +) +from neo4j.time.metaclasses import ( + DateType, + TimeType, + DateTimeType, +) + +import logging +from neo4j.debug import watch +watch("neo4j") + +log = logging.getLogger("neo4j") MIN_INT64 = -(2 ** 63) @@ -49,6 +76,8 @@ DURATION_ISO_PATTERN = re_compile(r"^P((\d+)Y)?((\d+)M)?((\d+)D)?" r"(T((\d+)H)?((\d+)M)?((\d+(\.\d+)?)?S)?)?$") +NANO_SECONDS = 1000000000 + def _is_leap_year(year): if year % 4 != 0: @@ -1118,8 +1147,9 @@ def tzname(self): return self.tzinfo.tzname(self) def to_clock_time(self): - seconds, nanoseconds = nano_divmod(self.ticks, 1) - return ClockTime(seconds, 1000000000 * nanoseconds) + seconds, nanoseconds = nano_divmod(self.ticks, 1) # int, float + nanoseconds_int = int(Decimal(str(nanoseconds)) * NANO_SECONDS) # Convert fractions to an integer without losing precision + return ClockTime(seconds, nanoseconds_int) def to_native(self): """ Convert to a native Python `datetime.time` value. @@ -1437,8 +1467,9 @@ def to_clock_time(self): for month in range(1, self.month): total_seconds += 86400 * Date.days_in_month(self.year, month) total_seconds += 86400 * (self.day - 1) - seconds, nanoseconds = nano_divmod(self.__time.ticks, 1) - return ClockTime(total_seconds + seconds, 1000000000 * nanoseconds) + seconds, nanoseconds = nano_divmod(self.__time.ticks, 1) # int, float + nanoseconds_int = int(Decimal(str(nanoseconds)) * NANO_SECONDS) # Convert fractions to an integer without losing precision + return ClockTime(total_seconds + seconds, nanoseconds_int) def to_native(self): """ Convert to a native Python `datetime.datetime` value. diff --git a/neo4j/time/hydration.py b/neo4j/time/hydration.py index d464490d6..32a7bfd90 100644 --- a/neo4j/time/hydration.py +++ b/neo4j/time/hydration.py @@ -142,7 +142,9 @@ def seconds_and_nanoseconds(dt): if isinstance(dt, datetime): dt = DateTime.from_native(dt) zone_epoch = DateTime(1970, 1, 1, tzinfo=dt.tzinfo) - t = dt.to_clock_time() - zone_epoch.to_clock_time() + dt_clock_time = dt.to_clock_time() + zone_epoch_clock_time = zone_epoch.to_clock_time() + t = dt_clock_time - zone_epoch_clock_time return t.seconds, t.nanoseconds tz = value.tzinfo diff --git a/tests/integration/test_temporal_types.py b/tests/integration/test_temporal_types.py index 4092ae2bc..5443043b7 100644 --- a/tests/integration/test_temporal_types.py +++ b/tests/integration/test_temporal_types.py @@ -20,6 +20,8 @@ import pytest +import datetime + from pytz import ( FixedOffset, timezone, @@ -330,3 +332,74 @@ def test_nanosecond_resolution_duration_output(cypher_eval): assert isinstance(value, Duration) assert value == Duration(years=1, months=2, days=3, hours=4, minutes=5, seconds=6.789123456) + + +def test_datetime_parameter_case1(session): + # python -m pytest tests/integration/test_temporal_types.py -s -v -k test_datetime_parameter_case1 + dt1 = session.run("RETURN datetime('2019-10-30T07:54:02.129790001+00:00')").single().value() + assert isinstance(dt1, DateTime) + + dt2 = session.run("RETURN $date_time", date_time=dt1).single().value() + assert isinstance(dt2, DateTime) + + assert dt1 == dt2 + + +def test_datetime_parameter_case2(session): + # python -m pytest tests/integration/test_temporal_types.py -s -v -k test_datetime_parameter_case2 + dt1 = session.run("RETURN datetime('2019-10-30T07:54:02.129790999[UTC]')").single().value() + assert isinstance(dt1, DateTime) + assert dt1.iso_format() == "2019-10-30T07:54:02.129790999+00:00" + + dt2 = session.run("RETURN $date_time", date_time=dt1).single().value() + assert isinstance(dt2, DateTime) + + assert dt1 == dt2 + + +def test_datetime_parameter_case3(session): + # python -m pytest tests/integration/test_temporal_types.py -s -v -k test_datetime_parameter_case1 + dt1 = session.run("RETURN datetime('2019-10-30T07:54:02.129790+00:00')").single().value() + assert isinstance(dt1, DateTime) + + dt2 = session.run("RETURN $date_time", date_time=dt1).single().value() + assert isinstance(dt2, DateTime) + + assert dt1 == dt2 + + +def test_time_parameter_case1(session): + # python -m pytest tests/integration/test_temporal_types.py -s -v -k test_time_parameter_case1 + t1 = session.run("RETURN time('07:54:02.129790001+00:00')").single().value() + assert isinstance(t1, Time) + + t2 = session.run("RETURN $time", time=t1).single().value() + assert isinstance(t2, Time) + + assert t1 == t2 + + +def test_time_parameter_case2(session): + # python -m pytest tests/integration/test_temporal_types.py -s -v -k test_time_parameter_case2 + t1 = session.run("RETURN time('07:54:02.129790999+00:00')").single().value() + assert isinstance(t1, Time) + # assert t1.iso_format() == "07:54:02.129790999+00:00" # TODO: Broken, does not show time_zone_delta +00:00 + time_zone_delta = t1.utc_offset() + assert isinstance(time_zone_delta, datetime.timedelta) + assert time_zone_delta == datetime.timedelta(0) + + t2 = session.run("RETURN $time", time=t1).single().value() + assert isinstance(t2, Time) + + assert t1 == t2 + + +def test_time_parameter_case3(session): + # python -m pytest tests/integration/test_temporal_types.py -s -v -k test_time_parameter_case3 + t1 = session.run("RETURN time('07:54:02.129790+00:00')").single().value() + assert isinstance(t1, Time) + + t2 = session.run("RETURN $time", time=t1).single().value() + assert isinstance(t2, Time) + + assert t1 == t2 \ No newline at end of file diff --git a/tests/unit/time/test_datetime.py b/tests/unit/time/test_datetime.py index 4d004c62b..2c471063a 100644 --- a/tests/unit/time/test_datetime.py +++ b/tests/unit/time/test_datetime.py @@ -19,17 +19,44 @@ # limitations under the License. -from datetime import datetime, timedelta from unittest import TestCase - -from pytz import timezone, FixedOffset - -from neo4j.time import DateTime, MIN_YEAR, MAX_YEAR, Duration -from neo4j.time.arithmetic import nano_add, nano_div -from neo4j.time.clock_implementations import Clock, ClockTime - - -eastern = timezone("US/Eastern") +from datetime import ( + datetime, + timedelta, +) +from pytz import ( + timezone, + FixedOffset, +) + +from neo4j.time import ( + DateTime, + MIN_YEAR, + MAX_YEAR, + Duration, +) +from neo4j.time.arithmetic import ( + nano_add, + nano_div, +) +from neo4j.time.clock_implementations import ( + Clock, + ClockTime, +) +from neo4j.time.hydration import ( + hydrate_date, + dehydrate_date, + hydrate_time, + dehydrate_time, + hydrate_datetime, + dehydrate_datetime, + hydrate_duration, + dehydrate_duration, + dehydrate_timedelta, +) + +timezone_us_eastern = timezone("US/Eastern") +timezone_utc = timezone("UTC") class FixedClock(Clock): @@ -132,7 +159,7 @@ def test_now_without_tz(self): self.assertIsNone(t.tzinfo) def test_now_with_tz(self): - t = DateTime.now(eastern) + t = DateTime.now(timezone_us_eastern) self.assertEqual(t.year, 1970) self.assertEqual(t.month, 1) self.assertEqual(t.day, 1) @@ -168,7 +195,7 @@ def test_from_overflowing_timestamp(self): _ = DateTime.from_timestamp(999999999999999999) def test_from_timestamp_with_tz(self): - t = DateTime.from_timestamp(0, eastern) + t = DateTime.from_timestamp(0, timezone_us_eastern) self.assertEqual(t.year, 1969) self.assertEqual(t.month, 12) self.assertEqual(t.day, 31) @@ -215,13 +242,13 @@ def test_subtract_native_datetime_2(self): self.assertEqual(t, timedelta(days=65, hours=23, seconds=17.914390409)) def test_normalization(self): - ndt1 = eastern.normalize(DateTime(2018, 4, 27, 23, 0, 17, tzinfo=eastern)) - ndt2 = eastern.normalize(datetime(2018, 4, 27, 23, 0, 17, tzinfo=eastern)) + 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)) self.assertEqual(ndt1, ndt2) def test_localization(self): - ldt1 = eastern.localize(datetime(2018, 4, 27, 23, 0, 17)) - ldt2 = eastern.localize(DateTime(2018, 4, 27, 23, 0, 17)) + ldt1 = timezone_us_eastern.localize(datetime(2018, 4, 27, 23, 0, 17)) + ldt2 = timezone_us_eastern.localize(DateTime(2018, 4, 27, 23, 0, 17)) self.assertEqual(ldt1, ldt2) def test_from_native(self): @@ -253,11 +280,11 @@ def test_iso_format_with_trailing_zeroes(self): self.assertEqual("2018-10-01T12:34:56.789000000", dt.iso_format()) def test_iso_format_with_tz(self): - dt = eastern.localize(DateTime(2018, 10, 1, 12, 34, 56.789123456)) + dt = timezone_us_eastern.localize(DateTime(2018, 10, 1, 12, 34, 56.789123456)) self.assertEqual("2018-10-01T12:34:56.789123456-04:00", dt.iso_format()) def test_iso_format_with_tz_and_trailing_zeroes(self): - dt = eastern.localize(DateTime(2018, 10, 1, 12, 34, 56.789)) + dt = timezone_us_eastern.localize(DateTime(2018, 10, 1, 12, 34, 56.789)) self.assertEqual("2018-10-01T12:34:56.789000000-04:00", dt.iso_format()) def test_from_iso_format_hour_only(self): @@ -309,3 +336,12 @@ def test_from_iso_format_with_negative_long_tz(self): expected = DateTime(2018, 10, 1, 12, 34, 56.123456789, tzinfo=FixedOffset(-754)) actual = DateTime.from_iso_format("2018-10-01T12:34:56.123456789-12:34:56.123456") self.assertEqual(expected, actual) + + +def test_potential_rounding_error(): + # python -m pytest tests/unit/time/test_datetime.py -s -v -k test_potential_rounding_error + expected = DateTime(2019, 10, 30, 7, 54, 2.129790999, tzinfo=timezone_utc) + assert expected.iso_format() == "2019-10-30T07:54:02.129790999+00:00" + + actual = DateTime.from_iso_format("2019-10-30T07:54:02.129790999+00:00") + assert expected == actual