From eea44d7aa56559741e402d59ee6532a805d92328 Mon Sep 17 00:00:00 2001 From: Rouven Bauer Date: Fri, 18 Mar 2022 17:21:00 +0100 Subject: [PATCH 1/3] Fix serialization of DateTime DateTime objects with fixed negative UTC offset were incorrectly serialized and sent to the database. Similar issue to https://github.com/neo4j/neo4j-python-driver/pull/616 --- neo4j/time/hydration.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/neo4j/time/hydration.py b/neo4j/time/hydration.py index 69a3ddcfe..e061004a8 100644 --- a/neo4j/time/hydration.py +++ b/neo4j/time/hydration.py @@ -167,7 +167,8 @@ def seconds_and_nanoseconds(dt): else: # with time offset seconds, nanoseconds = seconds_and_nanoseconds(value) - return Structure(b"F", seconds, nanoseconds, tz.utcoffset(value).seconds) + return Structure(b"F", seconds, nanoseconds, + int(tz.utcoffset(value).total_seconds())) def hydrate_duration(months, days, seconds, nanoseconds): From 865f0486a4eb5320fe2b55ef42c1b89c6e460aef Mon Sep 17 00:00:00 2001 From: Rouven Bauer Date: Fri, 18 Mar 2022 19:03:12 +0100 Subject: [PATCH 2/3] Fix comparison operators in neo4j.time library --- neo4j/time/__init__.py | 217 +++++++++++------- tests/unit/time/test_datetime.py | 333 +++++++++++++++++++++++++++- tests/unit/time/test_dehydration.py | 135 +++++++++++ tests/unit/time/test_time.py | 228 ++++++++++++++++++- 4 files changed, 820 insertions(+), 93 deletions(-) create mode 100644 tests/unit/time/test_dehydration.py diff --git a/neo4j/time/__init__.py b/neo4j/time/__init__.py index 0a76280fa..c91688210 100644 --- a/neo4j/time/__init__.py +++ b/neo4j/time/__init__.py @@ -1783,21 +1783,49 @@ def tzinfo(self): # OPERATIONS # + def _get_both_normalized_ticks(self, other, strict=True): + if (isinstance(other, (time, Time)) + and ((self.utc_offset() is None) + ^ (other.utcoffset() is None))): + if strict: + raise TypeError("can't compare offset-naive and offset-aware " + "times") + else: + return None, None + if isinstance(other, Time): + other_ticks = other.__ticks + elif isinstance(other, time): + other_ticks = int(3600000000000 * other.hour + + 60000000000 * other.minute + + NANO_SECONDS * other.second + + 1000 * other.microsecond) + else: + return None, None + utc_offset = other.utcoffset() + if utc_offset is not None: + other_ticks -= utc_offset.total_seconds() * NANO_SECONDS + self_ticks = self.__ticks + utc_offset = self.utc_offset() + if utc_offset is not None: + self_ticks -= utc_offset.total_seconds() * NANO_SECONDS + return self_ticks, other_ticks + def __hash__(self): """""" - return hash(self.__ticks) ^ hash(self.tzinfo) + if self.__nanosecond % 1000 == 0: + return hash(self.to_native()) + self_ticks = self.__ticks + if self.utc_offset() is not None: + self_ticks -= self.utc_offset().total_seconds() * NANO_SECONDS + return hash(self_ticks) def __eq__(self, other): """`==` comparison with :class:`.Time` or :class:`datetime.time`.""" - if isinstance(other, Time): - return self.__ticks == other.__ticks and self.tzinfo == other.tzinfo - if isinstance(other, time): - other_ticks = (3600000000000 * other.hour - + 60000000000 * other.minute - + NANO_SECONDS * other.second - + 1000 * other.microsecond) - return self.ticks_ns == other_ticks and self.tzinfo == other.tzinfo - return False + self_ticks, other_ticks = self._get_both_normalized_ticks(other, + strict=False) + if self_ticks is None: + return False + return self_ticks == other_ticks def __ne__(self, other): """`!=` comparison with :class:`.Time` or :class:`datetime.time`.""" @@ -1805,51 +1833,31 @@ def __ne__(self, other): def __lt__(self, other): """`<` comparison with :class:`.Time` or :class:`datetime.time`.""" - if isinstance(other, Time): - return (self.tzinfo == other.tzinfo - and self.ticks_ns < other.ticks_ns) - if isinstance(other, time): - if self.tzinfo != other.tzinfo: - return False - other_ticks = 3600 * other.hour + 60 * other.minute + other.second + (other.microsecond / 1000000) - return self.ticks_ns < other_ticks - return NotImplemented + self_ticks, other_ticks = self._get_both_normalized_ticks(other) + if self_ticks is None: + return NotImplemented + return self_ticks < other_ticks def __le__(self, other): """`<=` comparison with :class:`.Time` or :class:`datetime.time`.""" - if isinstance(other, Time): - return (self.tzinfo == other.tzinfo - and self.ticks_ns <= other.ticks_ns) - if isinstance(other, time): - if self.tzinfo != other.tzinfo: - return False - other_ticks = 3600 * other.hour + 60 * other.minute + other.second + (other.microsecond / 1000000) - return self.ticks_ns <= other_ticks - return NotImplemented + self_ticks, other_ticks = self._get_both_normalized_ticks(other) + if self_ticks is None: + return NotImplemented + return self_ticks <= other_ticks def __ge__(self, other): """`>=` comparison with :class:`.Time` or :class:`datetime.time`.""" - if isinstance(other, Time): - return (self.tzinfo == other.tzinfo - and self.ticks_ns >= other.ticks_ns) - if isinstance(other, time): - if self.tzinfo != other.tzinfo: - return False - other_ticks = 3600 * other.hour + 60 * other.minute + other.second + (other.microsecond / 1000000) - return self.ticks_ns >= other_ticks - return NotImplemented + self_ticks, other_ticks = self._get_both_normalized_ticks(other) + if self_ticks is None: + return NotImplemented + return self_ticks >= other_ticks def __gt__(self, other): """`>` comparison with :class:`.Time` or :class:`datetime.time`.""" - if isinstance(other, Time): - return (self.tzinfo == other.tzinfo - and self.ticks_ns >= other.ticks_ns) - if isinstance(other, time): - if self.tzinfo != other.tzinfo: - return False - other_ticks = 3600 * other.hour + 60 * other.minute + other.second + (other.microsecond / 1000000) - return self.ticks_ns >= other_ticks - return NotImplemented + self_ticks, other_ticks = self._get_both_normalized_ticks(other) + if self_ticks is None: + return NotImplemented + return self_ticks > other_ticks def __copy__(self): return self.__new(self.__ticks, self.__hour, self.__minute, @@ -1883,6 +1891,21 @@ def replace(self, **kwargs): nanosecond=kwargs.get("nanosecond", self.__nanosecond), tzinfo=kwargs.get("tzinfo", self.__tzinfo)) + def _utc_offset(self, dt=None): + if self.tzinfo is None: + return None + value = self.tzinfo.utcoffset(dt) + if value is None: + return None + if isinstance(value, timedelta): + s = value.total_seconds() + if not (-86400 < s < 86400): + raise ValueError("utcoffset must be less than a day") + if s % 60 != 0 or value.microseconds != 0: + raise ValueError("utcoffset must be a whole number of minutes") + return value + raise TypeError("utcoffset must be a timedelta") + def utc_offset(self): """Return the UTC offset of this time. @@ -1896,19 +1919,7 @@ def utc_offset(self): :raises TypeError: if `self.tzinfo.utcoffset(self)` does return anything but None or a :class:`datetime.timedelta`. """ - if self.tzinfo is None: - return None - value = self.tzinfo.utcoffset(self) - if value is None: - return None - if isinstance(value, timedelta): - s = value.total_seconds() - if not (-86400 < s < 86400): - raise ValueError("utcoffset must be less than a day") - if s % 60 != 0 or value.microseconds != 0: - raise ValueError("utcoffset must be a whole number of minutes") - return value - raise TypeError("utcoffset must be a timedelta") + return self._utc_offset() def dst(self): """Get the daylight saving time adjustment (DST). @@ -1997,6 +2008,7 @@ def __format__(self, format_spec): """""" raise NotImplementedError() + Time.min = Time(hour=0, minute=0, second=0, nanosecond=0) Time.max = Time(hour=23, minute=59, second=59, nanosecond=999999999) Time.resolution = Duration(nanoseconds=1) @@ -2330,17 +2342,52 @@ def hour_minute_second_nanosecond(self): # OPERATIONS # + def _get_both_normalized(self, other, strict=True): + if (isinstance(other, (datetime, DateTime)) + and ((self.utc_offset() is None) + ^ (other.utcoffset() is None))): + if strict: + raise TypeError("can't compare offset-naive and offset-aware " + "datetimes") + else: + return None, None + self_norm = self + utc_offset = self.utc_offset() + if utc_offset is not None: + self_norm -= utc_offset + self_norm = self_norm.replace(tzinfo=None) + other_norm = other + if isinstance(other, (datetime, DateTime)): + utc_offset = other.utcoffset() + if utc_offset is not None: + other_norm -= utc_offset + other_norm = other_norm.replace(tzinfo=None) + else: + return None, None + return self_norm, other_norm + def __hash__(self): """""" - return hash(self.date()) ^ hash(self.time()) + if self.nanosecond % 1000 == 0: + return hash(self.to_native()) + self_norm = self + utc_offset = self.utc_offset() + if utc_offset is not None: + self_norm -= utc_offset + return hash(self_norm.date()) ^ hash(self_norm.time()) def __eq__(self, other): """ `==` comparison with :class:`.DateTime` or :class:`datetime.datetime`. """ - if isinstance(other, (DateTime, datetime)): + if not isinstance(other, (datetime, DateTime)): + return NotImplemented + if self.utc_offset() == other.utcoffset(): return self.date() == other.date() and self.time() == other.time() - return False + self_norm, other_norm = self._get_both_normalized(other, strict=False) + if self_norm is None: + return False + return self_norm == other_norm def __ne__(self, other): """ @@ -2352,45 +2399,55 @@ def __lt__(self, other): """ `<` comparison with :class:`.DateTime` or :class:`datetime.datetime`. """ - if isinstance(other, (DateTime, datetime)): + if not isinstance(other, (datetime, DateTime)): + return NotImplemented + if self.utc_offset() == other.utcoffset(): if self.date() == other.date(): return self.time() < other.time() - else: - return self.date() < other.date() - return NotImplemented + return self.date() < other.date() + self_norm, other_norm = self._get_both_normalized(other) + return (self_norm.date() < other_norm.date() + or self_norm.time() < other_norm.time()) def __le__(self, other): """ `<=` comparison with :class:`.DateTime` or :class:`datetime.datetime`. """ - if isinstance(other, (DateTime, datetime)): + if not isinstance(other, (datetime, DateTime)): + return NotImplemented + if self.utc_offset() == other.utcoffset(): if self.date() == other.date(): return self.time() <= other.time() - else: - return self.date() < other.date() - return NotImplemented + return self.date() <= other.date() + self_norm, other_norm = self._get_both_normalized(other) + return self_norm <= other_norm def __ge__(self, other): """ `>=` comparison with :class:`.DateTime` or :class:`datetime.datetime`. """ - if isinstance(other, (DateTime, datetime)): + if not isinstance(other, (datetime, DateTime)): + return NotImplemented + if self.utc_offset() == other.utcoffset(): if self.date() == other.date(): return self.time() >= other.time() - else: - return self.date() > other.date() - return NotImplemented + return self.date() >= other.date() + self_norm, other_norm = self._get_both_normalized(other) + return self_norm >= other_norm def __gt__(self, other): """ `>` comparison with :class:`.DateTime` or :class:`datetime.datetime`. """ - if isinstance(other, (DateTime, datetime)): + if not isinstance(other, (datetime, DateTime)): + return NotImplemented + if self.utc_offset() == other.utcoffset(): if self.date() == other.date(): return self.time() > other.time() - else: - return self.date() > other.date() - return NotImplemented + return self.date() > other.date() + self_norm, other_norm = self._get_both_normalized(other) + return (self_norm.date() > other_norm.date() + or self_norm.time() > other_norm.time()) def __add__(self, other): """Add a :class:`datetime.timedelta`. @@ -2494,7 +2551,7 @@ def as_timezone(self, tz): """ if self.tzinfo is None: return self - utc = (self - self.utcoffset()).replace(tzinfo=tz) + utc = (self - self.utc_offset()).replace(tzinfo=tz) return tz.fromutc(utc) def utc_offset(self): @@ -2503,7 +2560,7 @@ def utc_offset(self): See :meth:`.Time.utc_offset`. """ - return self.__time.utc_offset() + return self.__time._utc_offset(self) def dst(self): """Get the daylight saving time adjustment (DST). diff --git a/tests/unit/time/test_datetime.py b/tests/unit/time/test_datetime.py index cc3f1ef21..b0c6bc5f8 100644 --- a/tests/unit/time/test_datetime.py +++ b/tests/unit/time/test_datetime.py @@ -24,11 +24,14 @@ datetime, timedelta, ) +import itertools +import operator import pytest from pytz import ( timezone, FixedOffset, + utc, ) from neo4j.time import ( @@ -45,19 +48,10 @@ 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_london = timezone("Europe/London") +timezone_berlin = timezone("Europe/Berlin") timezone_utc = timezone("UTC") @@ -217,7 +211,7 @@ def test_from_timestamp_with_tz(self): assert t.minute == 0 assert t.second == Decimal("0.0") assert t.nanosecond == 0 - assert t.utcoffset() == timedelta(seconds=-18000) + assert t.utc_offset() == timedelta(seconds=-18000) assert t.dst() == timedelta() assert t.tzname() == "EST" @@ -464,3 +458,318 @@ def test_from_native_case_2(): assert int(dt.second) == native.second assert dt.nanosecond == native.microsecond * 1000 assert dt.tzinfo == FixedOffset(0) + + +@pytest.mark.parametrize(("dt1", "dt2"), ( + ( + datetime(2022, 11, 25, 12, 34, 56, 789123), + DateTime(2022, 11, 25, 12, 34, 56, 789123000) + ), + ( + DateTime(2022, 11, 25, 12, 34, 56, 789123456), + DateTime(2022, 11, 25, 12, 34, 56, 789123456) + ), + ( + datetime(2022, 11, 25, 12, 34, 56, 789123, FixedOffset(1)), + DateTime(2022, 11, 25, 12, 34, 56, 789123000, FixedOffset(1)) + ), + ( + datetime(2022, 11, 25, 12, 34, 56, 789123, FixedOffset(-1)), + DateTime(2022, 11, 25, 12, 34, 56, 789123000, FixedOffset(-1)) + ), + ( + DateTime(2022, 11, 25, 12, 34, 56, 789123456, FixedOffset(1)), + DateTime(2022, 11, 25, 12, 34, 56, 789123456, FixedOffset(1)) + ), + ( + DateTime(2022, 11, 25, 12, 34, 56, 789123456, FixedOffset(-1)), + DateTime(2022, 11, 25, 12, 34, 56, 789123456, FixedOffset(-1)) + ), + ( + DateTime(2022, 11, 25, 12, 35, 56, 789123456, FixedOffset(1)), + DateTime(2022, 11, 25, 12, 34, 56, 789123456, FixedOffset(0)) + ), + ( + # Not testing our library directly, but asserting that Python's + # datetime implementation is aligned with ours. + datetime(2022, 11, 25, 12, 35, 56, 789123, FixedOffset(1)), + datetime(2022, 11, 25, 12, 34, 56, 789123, FixedOffset(0)) + ), + ( + datetime(2022, 11, 25, 12, 35, 56, 789123, FixedOffset(1)), + DateTime(2022, 11, 25, 12, 34, 56, 789123000, FixedOffset(0)) + ), + ( + DateTime(2022, 11, 25, 12, 35, 56, 789123123, FixedOffset(1)), + DateTime(2022, 11, 25, 12, 34, 56, 789123123, FixedOffset(0)) + ), + ( + timezone_london.localize(datetime(2022, 11, 25, 12, 34, 56, 789123)), + timezone_berlin.localize(datetime(2022, 11, 25, 13, 34, 56, 789123)) + ), + ( + timezone_london.localize(datetime(2022, 11, 25, 12, 34, 56, 789123)), + timezone_berlin.localize(DateTime(2022, 11, 25, 13, 34, 56, 789123000)) + ), + ( + timezone_london.localize(DateTime(2022, 1, 25, 12, 34, 56, 789123123)), + timezone_berlin.localize(DateTime(2022, 1, 25, 13, 34, 56, 789123123)) + ), + +)) +def test_equality(dt1, dt2): + assert dt1 == dt2 + assert dt2 == dt1 + assert dt1 <= dt2 + assert dt2 <= dt1 + assert dt1 >= dt2 + assert dt2 >= dt1 + + +@pytest.mark.parametrize(("dt1", "dt2"), ( + ( + datetime(2022, 11, 25, 12, 34, 56, 789123), + DateTime(2022, 11, 25, 12, 34, 56, 789123001) + ), + ( + datetime(2022, 11, 25, 12, 34, 56, 789123), + DateTime(2022, 11, 25, 12, 34, 56, 789124000) + ), + ( + datetime(2022, 11, 25, 12, 34, 56, 789123), + DateTime(2022, 11, 25, 12, 34, 57, 789123000) + ), + ( + datetime(2022, 11, 25, 12, 34, 56, 789123), + DateTime(2022, 11, 25, 12, 35, 56, 789123000) + ), + ( + datetime(2022, 11, 25, 12, 34, 56, 789123), + DateTime(2022, 11, 25, 13, 34, 56, 789123000) + ), + ( + DateTime(2022, 11, 25, 12, 34, 56, 789123456), + DateTime(2022, 11, 25, 12, 34, 56, 789123450) + ), + ( + DateTime(2022, 11, 25, 12, 34, 56, 789123456), + DateTime(2022, 11, 25, 12, 34, 57, 789123456) + ), + ( + DateTime(2022, 11, 25, 12, 34, 56, 789123456), + DateTime(2022, 11, 25, 12, 35, 56, 789123456) + ), + ( + DateTime(2022, 11, 25, 12, 34, 56, 789123456), + DateTime(2022, 11, 25, 13, 34, 56, 789123456) + ), + ( + datetime(2022, 11, 25, 12, 34, 56, 789123, FixedOffset(2)), + DateTime(2022, 11, 25, 12, 34, 56, 789123000, FixedOffset(1)) + ), + ( + datetime(2022, 11, 25, 12, 34, 56, 789123, FixedOffset(-2)), + DateTime(2022, 11, 25, 12, 34, 56, 789123000, FixedOffset(-1)) + ), + ( + datetime(2022, 11, 25, 12, 34, 56, 789123), + DateTime(2022, 11, 25, 12, 34, 56, 789123000, FixedOffset(0)) + ), + ( + DateTime(2022, 11, 25, 12, 34, 56, 789123456, FixedOffset(2)), + DateTime(2022, 11, 25, 12, 34, 56, 789123456, FixedOffset(1)) + ), + ( + DateTime(2022, 11, 25, 12, 34, 56, 789123456, FixedOffset(-2)), + DateTime(2022, 11, 25, 12, 34, 56, 789123456, FixedOffset(-1)) + ), + ( + DateTime(2022, 11, 25, 12, 34, 56, 789123456), + DateTime(2022, 11, 25, 12, 34, 56, 789123456, FixedOffset(0)) + ), + ( + DateTime(2022, 11, 25, 13, 34, 56, 789123456, FixedOffset(1)), + DateTime(2022, 11, 25, 12, 34, 56, 789123456, FixedOffset(0)) + ), + ( + DateTime(2022, 11, 25, 11, 34, 56, 789123456, FixedOffset(1)), + DateTime(2022, 11, 25, 12, 34, 56, 789123456, FixedOffset(0)) + ), +)) +def test_inequality(dt1, dt2): + assert dt1 != dt2 + assert dt2 != dt1 + + +@pytest.mark.parametrize( + ("dt1", "dt2"), + itertools.product( + ( + datetime(2022, 11, 25, 12, 34, 56, 789123), + DateTime(2022, 11, 25, 12, 34, 56, 789123000), + datetime(2022, 11, 25, 12, 34, 56, 789123, FixedOffset(0)), + DateTime(2022, 11, 25, 12, 34, 56, 789123456, FixedOffset(0)), + datetime(2022, 11, 25, 12, 35, 56, 789123, FixedOffset(1)), + DateTime(2022, 11, 25, 12, 35, 56, 789123456, FixedOffset(1)), + datetime(2022, 11, 25, 12, 34, 56, 789123, FixedOffset(-1)), + DateTime(2022, 11, 25, 12, 34, 56, 789123456, FixedOffset(-1)), + datetime(2022, 11, 25, 12, 34, 56, 789123, FixedOffset(60 * -16)), + DateTime(2022, 11, 25, 12, 34, 56, 789123000, + FixedOffset(60 * -16)), + datetime(2022, 11, 25, 11, 34, 56, 789123, FixedOffset(60 * -17)), + DateTime(2022, 11, 25, 11, 34, 56, 789123000, + FixedOffset(60 * -17)), + DateTime(2022, 11, 25, 12, 34, 56, 789123456, + FixedOffset(60 * -16)), + DateTime(2022, 11, 25, 11, 34, 56, 789123456, + FixedOffset(60 * -17)), + ), + repeat=2 + ) +) +def test_hashed_equality(dt1, dt2): + if dt1 == dt2: + s = {dt1} + assert dt1 in s + assert dt2 in s + s = {dt2} + assert dt1 in s + assert dt2 in s + else: + s = {dt1} + assert dt1 in s + assert dt2 not in s + s = {dt2} + assert dt1 not in s + assert dt2 in s + + +@pytest.mark.parametrize(("dt1", "dt2"), ( + itertools.product( + ( + datetime(2022, 11, 25, 12, 34, 56, 789123), + DateTime(2022, 11, 25, 12, 34, 56, 789123000), + DateTime(2022, 11, 25, 12, 34, 56, 789123001), + ), + repeat=2 + ) +)) +@pytest.mark.parametrize("tz", ( + FixedOffset(0), FixedOffset(1), FixedOffset(-1), utc, +)) +@pytest.mark.parametrize("op", ( + operator.lt, operator.le, operator.gt, operator.ge, +)) +def test_comparison_with_only_one_naive_fails(dt1, dt2, tz, op): + dt1 = dt1.replace(tzinfo=tz) + with pytest.raises(TypeError, match="naive"): + op(dt1, dt2) + + +@pytest.mark.parametrize( + ("dt1", "dt2"), + itertools.product( + ( + datetime(2022, 11, 25, 12, 34, 56, 789123), + DateTime(2022, 11, 25, 12, 34, 56, 789123000), + DateTime(2022, 11, 25, 12, 34, 56, 789123001), + ), + repeat=2 + ) +) +@pytest.mark.parametrize("tz", ( + timezone("Europe/Paris"), timezone("Europe/Berlin"), +)) +@pytest.mark.parametrize("op", ( + operator.lt, operator.le, operator.gt, operator.ge, +)) +def test_comparison_with_one_naive_and_not_fixed_tz(dt1, dt2, tz, op): + dt1tz = tz.localize(dt1) + with pytest.raises(TypeError, match="naive"): + op(dt1tz, dt2) + + +@pytest.mark.parametrize(("dt1", "dt2"), ( + ( + datetime(2022, 11, 25, 12, 34, 56, 789123), + datetime(2022, 11, 25, 12, 34, 56, 789124) + ), + ( + DateTime(2022, 11, 25, 12, 34, 56, 789123000), + datetime(2022, 11, 25, 12, 34, 56, 789124) + ), + ( + datetime(2022, 11, 25, 12, 34, 56, 789123), + DateTime(2022, 11, 25, 12, 34, 56, 789124000) + ), + ( + DateTime(2022, 11, 25, 12, 34, 56, 789123000), + DateTime(2022, 11, 25, 12, 34, 56, 789124000) + ), + ( + datetime(2022, 11, 24, 12, 34, 56, 789123), + datetime(2022, 11, 25, 12, 34, 56, 789123) + ), + ( + datetime(2022, 11, 24, 12, 34, 56, 789123), + DateTime(2022, 11, 25, 12, 34, 56, 789123000) + ), + ( + DateTime(2022, 11, 24, 12, 34, 56, 789123123), + DateTime(2022, 11, 25, 12, 34, 56, 789123123) + ), + ( + datetime(2022, 11, 24, 12, 34, 57, 789123), + datetime(2022, 11, 25, 12, 34, 56, 789123) + ), + ( + datetime(2022, 11, 24, 12, 34, 57, 789123), + DateTime(2022, 11, 25, 12, 34, 56, 789123000) + ), + ( + DateTime(2022, 11, 24, 12, 34, 57, 789123123), + DateTime(2022, 11, 25, 12, 34, 56, 789123123) + ), + ( + datetime(2022, 11, 25, 12, 34, 56, 789123, FixedOffset(1)), + datetime(2022, 11, 25, 12, 34, 56, 789124, FixedOffset(1)), + ), + ( + DateTime(2022, 11, 25, 12, 34, 56, 789123000, FixedOffset(1)), + datetime(2022, 11, 25, 12, 34, 56, 789124, FixedOffset(1)), + ), + ( + DateTime(2022, 11, 25, 12, 34, 56, 789123000, FixedOffset(1)), + DateTime(2022, 11, 25, 12, 34, 56, 789124000, FixedOffset(1)), + ), + ( + DateTime(2022, 11, 25, 12, 34, 56, 789123000, FixedOffset(1)), + DateTime(2022, 11, 25, 12, 34, 56, 789123001, FixedOffset(1)), + ), + + ( + datetime(2022, 11, 25, 12, 36, 56, 789123, FixedOffset(1)), + datetime(2022, 11, 25, 12, 34, 56, 789124, FixedOffset(-1)), + ), + ( + DateTime(2022, 11, 25, 12, 36, 56, 789123000, FixedOffset(1)), + datetime(2022, 11, 25, 12, 34, 56, 789124, FixedOffset(-1)), + ), + ( + DateTime(2022, 11, 25, 12, 36, 56, 789123000, FixedOffset(1)), + DateTime(2022, 11, 25, 12, 34, 56, 789124000, FixedOffset(-1)), + ), + ( + DateTime(2022, 11, 25, 12, 36, 56, 789123000, FixedOffset(1)), + DateTime(2022, 11, 25, 12, 34, 56, 789123001, FixedOffset(-1)), + ), +)) +def test_comparison(dt1, dt2): + assert dt1 < dt2 + assert not dt2 < dt1 + assert dt1 <= dt2 + assert not dt2 <= dt1 + assert dt2 > dt1 + assert not dt1 > dt2 + assert dt2 >= dt1 + assert not dt1 >= dt2 diff --git a/tests/unit/time/test_dehydration.py b/tests/unit/time/test_dehydration.py new file mode 100644 index 000000000..7fbd4b23f --- /dev/null +++ b/tests/unit/time/test_dehydration.py @@ -0,0 +1,135 @@ +# Copyright (c) "Neo4j" +# Neo4j Sweden AB [http://neo4j.com] +# +# This file is part of Neo4j. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import datetime +from unittest import TestCase + +import pytz + +from neo4j.data import DataDehydrator +from neo4j.packstream import Structure +from neo4j.time import ( + Date, + DateTime, + Duration, + Time, +) + + +class TestTemporalDehydration(TestCase): + + def setUp(self): + self.dehydrator = DataDehydrator() + + def test_date(self): + date = Date(1991, 8, 24) + struct, = self.dehydrator.dehydrate((date,)) + assert struct == Structure(b"D", 7905) + + def test_native_date(self): + date = datetime.date(1991, 8, 24) + struct, = self.dehydrator.dehydrate((date,)) + assert struct == Structure(b"D", 7905) + + def test_time(self): + time = Time(1, 2, 3, 4, pytz.FixedOffset(60)) + struct, = self.dehydrator.dehydrate((time,)) + assert struct == Structure(b"T", 3723000000004, 3600) + + def test_native_time(self): + time = datetime.time(1, 2, 3, 4, pytz.FixedOffset(60)) + struct, = self.dehydrator.dehydrate((time,)) + assert struct == Structure(b"T", 3723000004000, 3600) + + def test_local_time(self): + time = Time(1, 2, 3, 4) + struct, = self.dehydrator.dehydrate((time,)) + assert struct == Structure(b"t", 3723000000004) + + def test_local_native_time(self): + time = datetime.time(1, 2, 3, 4) + struct, = self.dehydrator.dehydrate((time,)) + assert struct == Structure(b"t", 3723000004000) + + def test_date_time(self): + dt = DateTime(2018, 10, 12, 11, 37, 41, 474716862, + pytz.FixedOffset(60)) + struct, = self.dehydrator.dehydrate((dt,)) + assert struct == Structure(b"F", 1539344261, 474716862, 3600) + + def test_native_date_time(self): + dt = datetime.datetime(2018, 10, 12, 11, 37, 41, 474716, + pytz.FixedOffset(60)) + struct, = self.dehydrator.dehydrate((dt,)) + assert struct == Structure(b"F", 1539344261, 474716000, 3600) + + def test_date_time_negative_offset(self): + dt = DateTime(2018, 10, 12, 11, 37, 41, 474716862, + pytz.FixedOffset(-60)) + struct, = self.dehydrator.dehydrate((dt,)) + assert struct == Structure(b"F", 1539344261, 474716862, -3600) + + def test_native_date_time_negative_offset(self): + dt = datetime.datetime(2018, 10, 12, 11, 37, 41, 474716, + pytz.FixedOffset(-60)) + struct, = self.dehydrator.dehydrate((dt,)) + assert struct == Structure(b"F", 1539344261, 474716000, -3600) + + def test_date_time_zone_id(self): + dt = DateTime(2018, 10, 12, 11, 37, 41, 474716862, + pytz.timezone("Europe/Stockholm")) + struct, = self.dehydrator.dehydrate((dt,)) + assert struct == Structure(b"f", 1539344261, 474716862, + "Europe/Stockholm") + + def test_native_date_time_zone_id(self): + dt = datetime.datetime(2018, 10, 12, 11, 37, 41, 474716, + pytz.timezone("Europe/Stockholm")) + struct, = self.dehydrator.dehydrate((dt,)) + assert struct == Structure(b"f", 1539344261, 474716000, + "Europe/Stockholm") + + def test_local_date_time(self): + dt = DateTime(2018, 10, 12, 11, 37, 41, 474716862) + struct, = self.dehydrator.dehydrate((dt,)) + assert struct == Structure(b"d", 1539344261, 474716862) + + def test_native_local_date_time(self): + dt = datetime.datetime(2018, 10, 12, 11, 37, 41, 474716) + struct, = self.dehydrator.dehydrate((dt,)) + assert struct == Structure(b"d", 1539344261, 474716000) + + def test_duration(self): + duration = Duration(months=1, days=2, seconds=3, nanoseconds=4) + struct, = self.dehydrator.dehydrate((duration,)) + assert struct == Structure(b"E", 1, 2, 3, 4) + + def test_native_duration(self): + duration = datetime.timedelta(days=1, seconds=2, microseconds=3) + struct, = self.dehydrator.dehydrate((duration,)) + assert struct == Structure(b"E", 0, 1, 2, 3000) + + def test_duration_mixed_sign(self): + duration = Duration(months=1, days=-2, seconds=3, nanoseconds=4) + struct, = self.dehydrator.dehydrate((duration,)) + assert struct == Structure(b"E", 1, -2, 3, 4) + + def test_native_duration_mixed_sign(self): + duration = datetime.timedelta(days=-1, seconds=2, microseconds=3) + struct, = self.dehydrator.dehydrate((duration,)) + assert struct == Structure(b"E", 0, -1, 2, 3000) diff --git a/tests/unit/time/test_time.py b/tests/unit/time/test_time.py index 0336781f9..de3faee09 100644 --- a/tests/unit/time/test_time.py +++ b/tests/unit/time/test_time.py @@ -21,12 +21,14 @@ from datetime import time from decimal import Decimal +import itertools +import operator import pytest from pytz import ( - build_tzinfo, timezone, FixedOffset, + utc, ) from neo4j.time import Time @@ -239,3 +241,227 @@ def test_from_native_case_2(self): assert t.minute == native.minute assert t.second == Decimal(native.microsecond) / 1000000 + native.second assert t.tzinfo == FixedOffset(0) + + @pytest.mark.parametrize(("t1", "t2"), ( + (time(12, 34, 56, 789123), Time(12, 34, 56, 789123000)), + (Time(12, 34, 56, 789123456), Time(12, 34, 56, 789123456)), + ( + time(12, 34, 56, 789123, FixedOffset(1)), + Time(12, 34, 56, 789123000, FixedOffset(1)) + ), + ( + time(12, 34, 56, 789123, FixedOffset(-1)), + Time(12, 34, 56, 789123000, FixedOffset(-1)) + ), + ( + Time(12, 34, 56, 789123456, FixedOffset(1)), + Time(12, 34, 56, 789123456, FixedOffset(1)) + ), + ( + Time(12, 34, 56, 789123456, FixedOffset(-1)), + Time(12, 34, 56, 789123456, FixedOffset(-1)) + ), + ( + Time(12, 35, 56, 789123456, FixedOffset(1)), + Time(12, 34, 56, 789123456, FixedOffset(0)) + ), + ( + # Not testing our library directly, but asserting that Python's + # time implementation is aligned with ours. + time(12, 35, 56, 789123, FixedOffset(1)), + time(12, 34, 56, 789123, FixedOffset(0)) + ), + ( + time(12, 35, 56, 789123, FixedOffset(1)), + Time(12, 34, 56, 789123000, FixedOffset(0)) + ), + ( + Time(12, 35, 56, 789123123, FixedOffset(1)), + Time(12, 34, 56, 789123123, FixedOffset(0)) + ), + )) + def test_equality(self, t1, t2): + assert t1 == t2 + assert t2 == t1 + assert t1 <= t2 + assert t2 <= t1 + assert t1 >= t2 + assert t2 >= t1 + + @pytest.mark.parametrize(("t1", "t2"), ( + (time(12, 34, 56, 789123), Time(12, 34, 56, 789123001)), + (time(12, 34, 56, 789123), Time(12, 34, 56, 789124000)), + (time(12, 34, 56, 789123), Time(12, 34, 57, 789123000)), + (time(12, 34, 56, 789123), Time(12, 35, 56, 789123000)), + (time(12, 34, 56, 789123), Time(13, 34, 56, 789123000)), + (Time(12, 34, 56, 789123456), Time(12, 34, 56, 789123450)), + (Time(12, 34, 56, 789123456), Time(12, 34, 57, 789123456)), + (Time(12, 34, 56, 789123456), Time(12, 35, 56, 789123456)), + (Time(12, 34, 56, 789123456), Time(13, 34, 56, 789123456)), + ( + time(12, 34, 56, 789123, FixedOffset(2)), + Time(12, 34, 56, 789123000, FixedOffset(1)) + ), + ( + time(12, 34, 56, 789123, FixedOffset(-2)), + Time(12, 34, 56, 789123000, FixedOffset(-1)) + ), + ( + time(12, 34, 56, 789123), + Time(12, 34, 56, 789123000, FixedOffset(0)) + ), + ( + Time(12, 34, 56, 789123456, FixedOffset(2)), + Time(12, 34, 56, 789123456, FixedOffset(1)) + ), + ( + Time(12, 34, 56, 789123456, FixedOffset(-2)), + Time(12, 34, 56, 789123456, FixedOffset(-1)) + ), + ( + Time(12, 34, 56, 789123456), + Time(12, 34, 56, 789123456, FixedOffset(0)) + ), + ( + Time(13, 34, 56, 789123456, FixedOffset(1)), + Time(12, 34, 56, 789123456, FixedOffset(0)) + ), + ( + Time(11, 34, 56, 789123456, FixedOffset(1)), + Time(12, 34, 56, 789123456, FixedOffset(0)) + ), + )) + def test_inequality(self, t1, t2): + assert t1 != t2 + assert t2 != t1 + + @pytest.mark.parametrize( + ("t1", "t2"), + itertools.product( + ( + time(12, 34, 56, 789123), + Time(12, 34, 56, 789123000), + time(12, 34, 56, 789123, FixedOffset(0)), + Time(12, 34, 56, 789123456, FixedOffset(0)), + time(12, 35, 56, 789123, FixedOffset(1)), + Time(12, 35, 56, 789123456, FixedOffset(1)), + time(12, 34, 56, 789123, FixedOffset(-1)), + Time(12, 34, 56, 789123456, FixedOffset(-1)), + time(12, 34, 56, 789123, FixedOffset(60 * -16)), + Time(12, 34, 56, 789123000, FixedOffset(60 * -16)), + time(11, 34, 56, 789123, FixedOffset(60 * -17)), + Time(11, 34, 56, 789123000, FixedOffset(60 * -17)), + Time(12, 34, 56, 789123456, FixedOffset(60 * -16)), + Time(11, 34, 56, 789123456, FixedOffset(60 * -17)), + ), + repeat=2 + ) + ) + def test_hashed_equality(self, t1, t2): + if t1 == t2: + s = {t1} + assert t1 in s + assert t2 in s + s = {t2} + assert t1 in s + assert t2 in s + else: + s = {t1} + assert t1 in s + assert t2 not in s + s = {t2} + assert t1 not in s + assert t2 in s + + @pytest.mark.parametrize(("t1", "t2"), ( + itertools.product( + ( + time(12, 34, 56, 789123), + Time(12, 34, 56, 789123000), + Time(12, 34, 56, 789123001), + ), + repeat=2 + ) + )) + @pytest.mark.parametrize("tz", ( + FixedOffset(0), FixedOffset(1), FixedOffset(-1), utc, + )) + @pytest.mark.parametrize("op", ( + operator.lt, operator.le, operator.gt, operator.ge, + )) + def test_comparison_with_only_one_naive_fails(self, t1, t2, tz, op): + t1 = t1.replace(tzinfo=tz) + with pytest.raises(TypeError, match="naive"): + op(t1, t2) + + @pytest.mark.parametrize( + ("t1", "t2"), + itertools.product( + ( + time(12, 34, 56, 789123), + Time(12, 34, 56, 789123000), + Time(12, 34, 56, 789123001), + ), + repeat=2 + ) + ) + @pytest.mark.parametrize("tz", ( + timezone("Europe/Paris"), timezone("Europe/Berlin"), + )) + @pytest.mark.parametrize("op", ( + operator.lt, operator.le, operator.gt, operator.ge, + )) + def test_comparison_with_one_naive_and_not_fixed_tz(self, t1, t2, tz, op): + t1tz = t1.replace(tzinfo=tz) + res = op(t1tz, t2) + expected = op(t1, t2) + assert res is expected + + @pytest.mark.parametrize(("t1", "t2"), ( + (time(12, 34, 56, 789123), time(12, 34, 56, 789124)), + (Time(12, 34, 56, 789123000), time(12, 34, 56, 789124)), + (time(12, 34, 56, 789123), Time(12, 34, 56, 789124000)), + (Time(12, 34, 56, 789123000), Time(12, 34, 56, 789124000)), + ( + time(12, 34, 56, 789123, FixedOffset(1)), + time(12, 34, 56, 789124, FixedOffset(1)), + ), + ( + Time(12, 34, 56, 789123000, FixedOffset(1)), + time(12, 34, 56, 789124, FixedOffset(1)), + ), + ( + Time(12, 34, 56, 789123000, FixedOffset(1)), + Time(12, 34, 56, 789124000, FixedOffset(1)), + ), + ( + Time(12, 34, 56, 789123000, FixedOffset(1)), + Time(12, 34, 56, 789123001, FixedOffset(1)), + ), + + ( + time(12, 36, 56, 789123, FixedOffset(1)), + time(12, 34, 56, 789124, FixedOffset(-1)), + ), + ( + Time(12, 36, 56, 789123000, FixedOffset(1)), + time(12, 34, 56, 789124, FixedOffset(-1)), + ), + ( + Time(12, 36, 56, 789123000, FixedOffset(1)), + Time(12, 34, 56, 789124000, FixedOffset(-1)), + ), + ( + Time(12, 36, 56, 789123000, FixedOffset(1)), + Time(12, 34, 56, 789123001, FixedOffset(-1)), + ), + )) + def test_comparison(self, t1, t2): + assert t1 < t2 + assert not t2 < t1 + assert t1 <= t2 + assert not t2 <= t1 + assert t2 > t1 + assert not t1 > t2 + assert t2 >= t1 + assert not t1 >= t2 From 776d049e16e85eab87702e37a427633f65af8c22 Mon Sep 17 00:00:00 2001 From: Rouven Bauer Date: Wed, 30 Mar 2022 12:30:45 +0200 Subject: [PATCH 3/3] Improve compatibility with timezone implementations Not all implementations of timezones play nicely with custom datetime implementations. In such cases, we need to fall back to native datetimes. --- neo4j/time/__init__.py | 121 +++++++++++----- tests/unit/time/__init__.py | 29 +++- tests/unit/time/test_date.py | 39 ++++-- tests/unit/time/test_datetime.py | 227 +++++++++++++++++++++++-------- tests/unit/time/test_time.py | 90 +++++++++--- 5 files changed, 384 insertions(+), 122 deletions(-) diff --git a/neo4j/time/__init__.py b/neo4j/time/__init__.py index c91688210..81d64499c 100644 --- a/neo4j/time/__init__.py +++ b/neo4j/time/__init__.py @@ -26,10 +26,11 @@ from contextlib import contextmanager from datetime import ( - timedelta, date, - time, datetime, + time, + timedelta, + timezone, ) from decimal import ( Decimal, @@ -827,7 +828,7 @@ def __getattr__(self, name): def today(cls, tz=None): """Get the current date. - :param tz: timezone or None to get a local :class:`.Date`. + :param tz: timezone or None to get the local :class:`.Date`. :type tz: datetime.tzinfo or None :rtype: Date @@ -839,11 +840,11 @@ def today(cls, tz=None): if tz is None: return cls.from_clock_time(Clock().local_time(), UnixEpoch) else: - return tz.fromutc( - DateTime.from_clock_time( - Clock().utc_time(), UnixEpoch - ).replace(tzinfo=tz) - ).date() + return ( + DateTime.utc_now() + .replace(tzinfo=timezone.utc).astimezone(tz) + .date() + ) @classmethod def utc_today(cls): @@ -868,14 +869,7 @@ def from_timestamp(cls, timestamp, tz=None): supported by the platform C localtime() function. It’s common for this to be restricted to years from 1970 through 2038. """ - if tz is None: - return cls.from_clock_time( - ClockTime(timestamp) + Clock().local_offset(), UnixEpoch - ) - else: - return tz.fromutc( - DateTime.utc_from_timestamp(timestamp).replace(tzinfo=tz) - ).date() + return cls.from_native(datetime.fromtimestamp(timestamp, tz)) @classmethod def utc_from_timestamp(cls, timestamp): @@ -1487,7 +1481,11 @@ def now(cls, tz=None): if tz is None: return cls.from_clock_time(Clock().local_time(), UnixEpoch) else: - return tz.fromutc(DateTime.from_clock_time(Clock().utc_time(), UnixEpoch)).time().replace(tzinfo=tz) + return ( + DateTime.utc_now() + .replace(tzinfo=timezone.utc).astimezone(tz) + .timetz() + ) @classmethod def utc_now(cls): @@ -1894,7 +1892,12 @@ def replace(self, **kwargs): def _utc_offset(self, dt=None): if self.tzinfo is None: return None - value = self.tzinfo.utcoffset(dt) + try: + value = self.tzinfo.utcoffset(dt) + except TypeError: + # For timezone implementations not compatible with the custom + # datetime implementations, we can't do better than this. + value = self.tzinfo.utcoffset(dt.to_native()) if value is None: return None if isinstance(value, timedelta): @@ -1936,7 +1939,12 @@ def dst(self): """ if self.tzinfo is None: return None - value = self.tzinfo.dst(self) + try: + value = self.tzinfo.dst(self) + except TypeError: + # For timezone implementations not compatible with the custom + # datetime implementations, we can't do better than this. + value = self.tzinfo.dst(self.to_native()) if value is None: return None if isinstance(value, timedelta): @@ -1957,7 +1965,12 @@ def tzname(self): """ if self.tzinfo is None: return None - return self.tzinfo.tzname(self) + try: + return self.tzinfo.tzname(self) + except TypeError: + # For timezone implementations not compatible with the custom + # datetime implementations, we can't do better than this. + return self.tzinfo.tzname(self.to_native()) def to_clock_time(self): """Convert to :class:`.ClockTime`. @@ -1986,8 +1999,8 @@ def iso_format(self): :rtype: str """ s = "%02d:%02d:%02d.%09d" % self.hour_minute_second_nanosecond - if self.tzinfo is not None: - offset = self.tzinfo.utcoffset(self) + offset = self.utc_offset() + if offset is not None: s += "%+03d:%02d" % divmod(offset.total_seconds() // 60, 60) return s @@ -2100,9 +2113,24 @@ def now(cls, tz=None): if tz is None: return cls.from_clock_time(Clock().local_time(), UnixEpoch) else: - return tz.fromutc(cls.from_clock_time( - Clock().utc_time(), UnixEpoch - ).replace(tzinfo=tz)) + try: + return tz.fromutc(cls.from_clock_time( + Clock().utc_time(), UnixEpoch + ).replace(tzinfo=tz)) + except TypeError: + # For timezone implementations not compatible with the custom + # datetime implementations, we can't do better than this. + utc_now = cls.from_clock_time( + Clock().utc_time(), UnixEpoch + ) + utc_now_native = utc_now.to_native() + now_native = tz.fromutc(utc_now_native) + now = cls.from_native(now_native) + return now.replace( + nanosecond=(now.nanosecond + + utc_now.nanosecond + - utc_now_native.microsecond * 1000) + ) @classmethod def utc_now(cls): @@ -2149,8 +2177,9 @@ def from_timestamp(cls, timestamp, tz=None): ClockTime(timestamp) + Clock().local_offset(), UnixEpoch ) else: - return tz.fromutc( - cls.utc_from_timestamp(timestamp).replace(tzinfo=tz) + return ( + cls.utc_from_timestamp(timestamp) + .replace(tzinfo=timezone.utc).astimezone(tz) ) @classmethod @@ -2463,7 +2492,15 @@ def __add__(self, other): time_ = Time.from_ticks_ns(round_half_to_even( seconds * NANO_SECONDS + t.nanoseconds )) - return self.combine(date_, time_) + return self.combine(date_, time_).replace(tzinfo=self.tzinfo) + if isinstance(other, Duration): + t = (self.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) + return self.combine(date_, time_).replace(tzinfo=self.tzinfo) return NotImplemented def __sub__(self, other): @@ -2493,7 +2530,7 @@ def __sub__(self, other): return timedelta(days=days, seconds=t.seconds, microseconds=(t.nanoseconds // 1000)) if isinstance(other, Duration): - return NotImplemented + return self.__add__(-other) if isinstance(other, timedelta): return self.__add__(-other) return NotImplemented @@ -2552,7 +2589,18 @@ def as_timezone(self, tz): if self.tzinfo is None: return self utc = (self - self.utc_offset()).replace(tzinfo=tz) - return tz.fromutc(utc) + try: + return tz.fromutc(utc) + except TypeError: + # For timezone implementations not compatible with the custom + # datetime implementations, we can't do better than this. + native_utc = utc.to_native() + native_res = tz.fromutc(native_utc) + res = self.from_native(native_res) + return res.replace( + nanosecond=(native_res.microsecond * 1000 + + self.nanosecond % 1000) + ) def utc_offset(self): """Get the date times utc offset. @@ -2650,8 +2698,17 @@ def iso_format(self, sep="T"): :rtype: str """ - return "%s%s%s" % (self.date().iso_format(), sep, - self.timetz().iso_format()) + s = "%s%s%s" % (self.date().iso_format(), sep, + self.timetz().iso_format()) + time_tz = self.timetz() + offset = time_tz.utc_offset() + if offset is not None: + # the time component will have taken care of formatting the offset + return s + offset = self.utc_offset() + if offset is not None: + s += "%+03d:%02d" % divmod(offset.total_seconds() // 60, 60) + return s def __repr__(self): """""" diff --git a/tests/unit/time/__init__.py b/tests/unit/time/__init__.py index 0665bdc90..a3998505e 100644 --- a/tests/unit/time/__init__.py +++ b/tests/unit/time/__init__.py @@ -1,6 +1,3 @@ -#!/usr/bin/env python -# coding: utf-8 - # Copyright (c) "Neo4j" # Neo4j Sweden AB [http://neo4j.com] # @@ -17,3 +14,29 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. + + +from neo4j.time.clock_implementations import ( + Clock, + ClockTime, +) + + +# The existence of this class will make the driver's custom date time +# implementation use it instead of a real clock since its precision it higher +# than all the other clocks (only up to nanoseconds). +class FixedClock(Clock): + @classmethod + def available(cls): + return True + + @classmethod + def precision(cls): + return 12 + + @classmethod + def local_offset(cls): + return ClockTime() + + def utc_time(self): + return ClockTime(45296, 789000001) diff --git a/tests/unit/time/test_date.py b/tests/unit/time/test_date.py index 3b34bddff..af9209ecc 100644 --- a/tests/unit/time/test_date.py +++ b/tests/unit/time/test_date.py @@ -19,17 +19,20 @@ # limitations under the License. +import datetime from datetime import date from time import struct_time from unittest import TestCase +import pytest import pytz import copy from neo4j.time import Duration, Date, UnixEpoch, ZeroDate -eastern = pytz.timezone("US/Eastern") +timezone_eastern = pytz.timezone("US/Eastern") +timezone_utc = pytz.utc class TestDate(TestCase): @@ -187,24 +190,12 @@ def test_cannot_use_year_higher_than_9999(self): with self.assertRaises(ValueError): _ = Date(10000, 2, 1) - def test_today(self): - d = Date.today() - self.assertIsInstance(d, Date) - - def test_today_with_tz(self): - d = Date.today(tz=eastern) - self.assertIsInstance(d, Date) - - def test_utc_today(self): - d = Date.utc_today() - self.assertIsInstance(d, Date) - def test_from_timestamp_without_tz(self): d = Date.from_timestamp(0) self.assertEqual(d, Date(1970, 1, 1)) def test_from_timestamp_with_tz(self): - d = Date.from_timestamp(0, tz=eastern) + d = Date.from_timestamp(0, tz=timezone_eastern) self.assertEqual(d, Date(1969, 12, 31)) def test_utc_from_timestamp(self): @@ -545,3 +536,23 @@ def test_date_deep_copy(self): d2 = copy.deepcopy(d) self.assertIsNot(d, d2) self.assertEqual(d, d2) + + +@pytest.mark.parametrize(("tz", "expected"), ( + (None, (1970, 1, 1)), + (timezone_eastern, (1970, 1, 1)), + (timezone_utc, (1970, 1, 1)), + (pytz.FixedOffset(-12 * 60), (1970, 1, 1)), + (datetime.timezone(datetime.timedelta(hours=-12)), (1970, 1, 1)), + (pytz.FixedOffset(-13 * 60), (1969, 12, 31)), + (datetime.timezone(datetime.timedelta(hours=-13)), (1969, 12, 31)), + (pytz.FixedOffset(11 * 60), (1970, 1, 1)), + (datetime.timezone(datetime.timedelta(hours=11)), (1970, 1, 1)), + (pytz.FixedOffset(12 * 60), (1970, 1, 2)), + (datetime.timezone(datetime.timedelta(hours=12)), (1970, 1, 2)), + +)) +def test_today(tz, expected): + d = Date.today(tz=tz) + assert isinstance(d, Date) + assert d.year_month_day == expected diff --git a/tests/unit/time/test_datetime.py b/tests/unit/time/test_datetime.py index b0c6bc5f8..4f98b8c6b 100644 --- a/tests/unit/time/test_datetime.py +++ b/tests/unit/time/test_datetime.py @@ -23,6 +23,7 @@ from datetime import ( datetime, timedelta, + timezone as datetime_timezone, ) import itertools import operator @@ -44,10 +45,7 @@ nano_add, nano_div, ) -from neo4j.time.clock_implementations import ( - Clock, - ClockTime, -) +from neo4j.time.clock_implementations import ClockTime timezone_us_eastern = timezone("US/Eastern") timezone_london = timezone("Europe/London") @@ -60,24 +58,6 @@ def seconds_options(seconds, nanoseconds): yield seconds + nanoseconds / 1000000000, -class FixedClock(Clock): - - @classmethod - def available(cls): - return True - - @classmethod - def precision(cls): - return 12 - - @classmethod - def local_offset(cls): - return ClockTime() - - def utc_time(self): - return ClockTime(45296, 789000000) - - class TestDateTime: def test_zero(self): @@ -149,8 +129,8 @@ def test_today(self): assert t.day == 1 assert t.hour == 12 assert t.minute == 34 - assert t.second == Decimal("56.789000000") - assert t.nanosecond == 789000000 + assert t.second == Decimal("56.789000001") + assert t.nanosecond == 789000001 def test_now_without_tz(self): t = DateTime.now() @@ -159,8 +139,8 @@ def test_now_without_tz(self): assert t.day == 1 assert t.hour == 12 assert t.minute == 34 - assert t.second == Decimal("56.789000000") - assert t.nanosecond == 789000000 + assert t.second == Decimal("56.789000001") + assert t.nanosecond == 789000001 assert t.tzinfo is None def test_now_with_tz(self): @@ -170,34 +150,50 @@ def test_now_with_tz(self): assert t.day == 1 assert t.hour == 7 assert t.minute == 34 - assert t.second == Decimal("56.789000000") - assert t.nanosecond == 789000000 + assert t.second == Decimal("56.789000001") + assert t.nanosecond == 789000001 assert t.utcoffset() == timedelta(seconds=-18000) assert t.dst() == timedelta() assert t.tzname() == "EST" - def test_utc_now(self): - t = DateTime.utc_now() + def test_now_with_utc_tz(self): + t = DateTime.now(timezone_utc) assert t.year == 1970 assert t.month == 1 assert t.day == 1 assert t.hour == 12 assert t.minute == 34 - assert t.second == Decimal("56.789000000") - assert t.nanosecond == 789000000 - assert t.tzinfo is None + assert t.second == Decimal("56.789000001") + assert t.nanosecond == 789000001 + assert t.utcoffset() == timedelta(seconds=0) + assert t.dst() == timedelta() + assert t.tzname() == "UTC" - def test_from_timestamp(self): - t = DateTime.from_timestamp(0) + def test_utc_now(self): + t = DateTime.utc_now() assert t.year == 1970 assert t.month == 1 assert t.day == 1 - assert t.hour == 0 - assert t.minute == 0 - assert t.second == Decimal("0.0") - assert t.nanosecond == 0 + assert t.hour == 12 + assert t.minute == 34 + assert t.second == Decimal("56.789000001") + assert t.nanosecond == 789000001 assert t.tzinfo is None + @pytest.mark.parametrize(("tz", "expected"), ( + (None, (1970, 1, 1, 0, 0, 0, 0)), + (timezone_utc, (1970, 1, 1, 0, 0, 0, 0)), + (datetime_timezone.utc, (1970, 1, 1, 0, 0, 0, 0)), + (FixedOffset(60), (1970, 1, 1, 1, 0, 0, 0)), + (datetime_timezone(timedelta(hours=1)), (1970, 1, 1, 1, 0, 0, 0)), + (timezone_us_eastern, (1969, 12, 31, 19, 0, 0, 0)), + )) + def test_from_timestamp(self, tz, expected): + t = DateTime.from_timestamp(0, tz=tz) + assert t.year_month_day == expected[:3] + assert t.hour_minute_second_nanosecond == expected[3:] + assert str(t.tzinfo) == str(tz) + def test_from_overflowing_timestamp(self): with pytest.raises(ValueError): _ = DateTime.from_timestamp(999999999999999999) @@ -295,21 +291,78 @@ def test_to_native(self): assert dt.minute == native.minute assert 56.789123, nano_add(native.second, nano_div(native.microsecond == 1000000)) - def test_iso_format(self): - dt = DateTime(2018, 10, 1, 12, 34, 56.789123456) - assert "2018-10-01T12:34:56.789123456" == dt.iso_format() - - def test_iso_format_with_trailing_zeroes(self): - dt = DateTime(2018, 10, 1, 12, 34, 56.789) - assert "2018-10-01T12:34:56.789000000" == dt.iso_format() - - def test_iso_format_with_tz(self): - dt = timezone_us_eastern.localize(DateTime(2018, 10, 1, 12, 34, 56.789123456)) - assert "2018-10-01T12:34:56.789123456-04:00" == dt.iso_format() - - def test_iso_format_with_tz_and_trailing_zeroes(self): - dt = timezone_us_eastern.localize(DateTime(2018, 10, 1, 12, 34, 56.789)) - assert "2018-10-01T12:34:56.789000000-04:00" == dt.iso_format() + @pytest.mark.parametrize(("dt", "expected"), ( + ( + DateTime(2018, 10, 1, 12, 34, 56.789123456), + "2018-10-01T12:34:56.789123456" + ), + ( + DateTime(2018, 10, 1, 12, 34, 56, 789123456), + "2018-10-01T12:34:56.789123456" + ), + ( + datetime(2018, 10, 1, 12, 34, 56, 789123), + "2018-10-01T12:34:56.789123" + ), + ( + DateTime(2018, 10, 1, 12, 34, 56.789), + "2018-10-01T12:34:56.789000000" + ), + ( + DateTime(2018, 10, 1, 12, 34, 56, 789000000), + "2018-10-01T12:34:56.789000000" + ), + ( + datetime(2018, 10, 1, 12, 34, 56, 789000), + "2018-10-01T12:34:56.789000" + ), + ( + timezone_us_eastern.localize( + DateTime(2018, 10, 1, 12, 34, 56, 789123456) + ), + "2018-10-01T12:34:56.789123456-04:00" + ), + ( + timezone_us_eastern.localize( + DateTime(2018, 10, 1, 12, 34, 56.789123456) + ), + "2018-10-01T12:34:56.789123456-04:00" + ), + ( + timezone_us_eastern.localize( + datetime(2018, 10, 1, 12, 34, 56, 789123) + ), + "2018-10-01T12:34:56.789123-04:00" + ), + ( + timezone_us_eastern.localize( + DateTime(2018, 10, 1, 12, 34, 56.789) + ), + "2018-10-01T12:34:56.789000000-04:00" + ), + ( + timezone_us_eastern.localize( + DateTime(2018, 10, 1, 12, 34, 56, 789000000) + ), + "2018-10-01T12:34:56.789000000-04:00" + ), + ( + timezone_us_eastern.localize( + datetime(2018, 10, 1, 12, 34, 56, 789000) + ), + "2018-10-01T12:34:56.789000-04:00" + ), + ( + utc.localize(DateTime(2018, 10, 1, 12, 34, 56, 789123456)), + "2018-10-01T12:34:56.789123456+00:00" + ), + ( + utc.localize(datetime(2018, 10, 1, 12, 34, 56, 789123)), + "2018-10-01T12:34:56.789123+00:00" + ), + )) + def test_iso_format(self, dt, expected): + assert dt.isoformat() == expected def test_from_iso_format_hour_only(self): expected = DateTime(2018, 10, 1, 12, 0, 0) @@ -460,6 +513,72 @@ def test_from_native_case_2(): assert dt.tzinfo == FixedOffset(0) +@pytest.mark.parametrize("datetime_cls", (DateTime, datetime)) +def test_transition_to_summertime(datetime_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 += timedelta(hours=1) + + # The native datetime object just bluntly carries over the timezone. You'd + # have to manually convert to UTC, do the calculation, and then convert + # back. Not pretty, but we should make sure our implementation does + 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", (DateTime, datetime)) +@pytest.mark.parametrize("utc_impl", ( + utc, + datetime_timezone(timedelta(0)), +)) +@pytest.mark.parametrize("tz", ( + timezone_berlin, datetime_timezone(timedelta(hours=-1)) +)) +def test_transition_to_summertime_in_utc_space(datetime_cls, utc_impl, tz): + if datetime_cls == DateTime: + dt = datetime_cls(2022, 3, 27, 1, 30, 1, 123456789) + else: + dt = datetime_cls(2022, 3, 27, 1, 30, 1, 123456) + dt = timezone_berlin.localize(dt) + assert isinstance(dt, datetime_cls) + assert dt.utcoffset() == timedelta(hours=1) + time = dt.time() + assert (time.hour, time.minute, int(time.second)) == (1, 30, 1) + if datetime_cls == DateTime: + assert time.nanosecond == 123456789 + else: + assert time.microsecond == 123456 + + dt = dt.astimezone(utc_impl) + assert isinstance(dt, datetime_cls) + assert dt.utcoffset() == timedelta(0) + time = dt.time() + assert (time.hour, time.minute) == (0, 30) + + dt += timedelta(hours=1) + assert isinstance(dt, datetime_cls) + assert dt.utcoffset() == timedelta(0) + time = dt.time() + assert (time.hour, time.minute) == (1, 30) + + dt = dt.astimezone(timezone_berlin) + assert isinstance(dt, datetime_cls) + assert dt.utcoffset() == timedelta(hours=2) + time = dt.time() + assert (time.hour, time.minute) == (3, 30) + if datetime_cls == DateTime: + assert time.nanosecond == 123456789 + else: + assert time.microsecond == 123456 + + @pytest.mark.parametrize(("dt1", "dt2"), ( ( datetime(2022, 11, 25, 12, 34, 56, 789123), diff --git a/tests/unit/time/test_time.py b/tests/unit/time/test_time.py index de3faee09..62d79edd2 100644 --- a/tests/unit/time/test_time.py +++ b/tests/unit/time/test_time.py @@ -19,8 +19,12 @@ # limitations under the License. -from datetime import time from decimal import Decimal +from datetime import ( + time, + timedelta, + timezone as datetime_timezone, +) import itertools import operator @@ -83,18 +87,25 @@ def test_str(self): t = Time(12, 34, 56, 789123456) assert str(t) == "12:34:56.789123456" - def test_now_without_tz(self): - t = Time.now() - assert isinstance(t, Time) - - def test_now_with_tz(self): - t = Time.now(tz=timezone_us_eastern) + @pytest.mark.parametrize(("tz", "expected"), ( + (None, (12, 34, 56, 789000001)), + (timezone_utc, (12, 34, 56, 789000001)), + (datetime_timezone.utc, (12, 34, 56, 789000001)), + (FixedOffset(60), (13, 34, 56, 789000001)), + (datetime_timezone(timedelta(hours=1)), (13, 34, 56, 789000001)), + (timezone_us_eastern, (7, 34, 56, 789000001)), + )) + def test_now(self, tz, expected): + t = Time.now(tz=tz) assert isinstance(t, Time) - assert t.tzinfo == timezone_us_eastern + assert t.hour_minute_second_nanosecond == expected + assert str(t.tzinfo) == str(tz) def test_utc_now(self): t = Time.utc_now() assert isinstance(t, Time) + assert t.hour_minute_second_nanosecond == (12, 34, 56, 789000001) + assert t.tzinfo is None def test_from_native(self): native = time(12, 34, 56, 789123) @@ -111,17 +122,58 @@ def test_to_native(self): assert t.minute == native.minute assert 56.789123 == nano_add(native.second, nano_div(native.microsecond, 1000000)) - def test_iso_format(self): - t = Time(12, 34, 56, 789123456) - assert "12:34:56.789123456" == t.iso_format() - - def test_iso_format_with_trailing_zeroes(self): - t = Time(12, 34, 56, 789000000) - assert "12:34:56.789000000" == t.iso_format() - - def test_iso_format_with_leading_zeroes(self): - t = Time(12, 34, 56, 789) - assert "12:34:56.000000789" == t.iso_format() + @pytest.mark.parametrize(("t", "expected"), ( + ( + Time(12, 34, 56, 789123456), + "12:34:56.789123456" + ), + ( + time(12, 34, 56, 789123), + "12:34:56.789123" + ), + ( + Time(12, 34, 56, 789000000), + "12:34:56.789000000" + ), + ( + time(12, 34, 56, 789000), + "12:34:56.789000" + ), + ( + Time(12, 34, 56, 789), + "12:34:56.000000789" + ), + ( + time(12, 34, 56, 789), + "12:34:56.000789" + ), + ( + Time(12, 34, 56, 789, tzinfo=timezone_utc), + "12:34:56.000000789+00:00" + ), + ( + time(12, 34, 56, 789, tzinfo=timezone_utc), + "12:34:56.000789+00:00" + ), + ( + Time(12, 34, 56, 789, tzinfo=timezone_us_eastern), + "12:34:56.000000789" + ), + ( + time(12, 34, 56, 789, tzinfo=timezone_us_eastern), + "12:34:56.000789" + ), + ( + Time(12, 34, 56, 123456789, tzinfo=FixedOffset(83)), + "12:34:56.123456789+01:23" + ), + ( + time(12, 34, 56, 123456, tzinfo=FixedOffset(83)), + "12:34:56.123456+01:23" + ), + )) + def test_iso_format(self, t, expected): + assert t.isoformat() == expected def test_from_iso_format_hour_only(self): expected = Time(12, 0, 0)