Skip to content

Commit 776d049

Browse files
committed
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.
1 parent 865f048 commit 776d049

File tree

5 files changed

+384
-122
lines changed

5 files changed

+384
-122
lines changed

neo4j/time/__init__.py

Lines changed: 89 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,11 @@
2626

2727
from contextlib import contextmanager
2828
from datetime import (
29-
timedelta,
3029
date,
31-
time,
3230
datetime,
31+
time,
32+
timedelta,
33+
timezone,
3334
)
3435
from decimal import (
3536
Decimal,
@@ -827,7 +828,7 @@ def __getattr__(self, name):
827828
def today(cls, tz=None):
828829
"""Get the current date.
829830
830-
:param tz: timezone or None to get a local :class:`.Date`.
831+
:param tz: timezone or None to get the local :class:`.Date`.
831832
:type tz: datetime.tzinfo or None
832833
833834
:rtype: Date
@@ -839,11 +840,11 @@ def today(cls, tz=None):
839840
if tz is None:
840841
return cls.from_clock_time(Clock().local_time(), UnixEpoch)
841842
else:
842-
return tz.fromutc(
843-
DateTime.from_clock_time(
844-
Clock().utc_time(), UnixEpoch
845-
).replace(tzinfo=tz)
846-
).date()
843+
return (
844+
DateTime.utc_now()
845+
.replace(tzinfo=timezone.utc).astimezone(tz)
846+
.date()
847+
)
847848

848849
@classmethod
849850
def utc_today(cls):
@@ -868,14 +869,7 @@ def from_timestamp(cls, timestamp, tz=None):
868869
supported by the platform C localtime() function. It’s common for
869870
this to be restricted to years from 1970 through 2038.
870871
"""
871-
if tz is None:
872-
return cls.from_clock_time(
873-
ClockTime(timestamp) + Clock().local_offset(), UnixEpoch
874-
)
875-
else:
876-
return tz.fromutc(
877-
DateTime.utc_from_timestamp(timestamp).replace(tzinfo=tz)
878-
).date()
872+
return cls.from_native(datetime.fromtimestamp(timestamp, tz))
879873

880874
@classmethod
881875
def utc_from_timestamp(cls, timestamp):
@@ -1487,7 +1481,11 @@ def now(cls, tz=None):
14871481
if tz is None:
14881482
return cls.from_clock_time(Clock().local_time(), UnixEpoch)
14891483
else:
1490-
return tz.fromutc(DateTime.from_clock_time(Clock().utc_time(), UnixEpoch)).time().replace(tzinfo=tz)
1484+
return (
1485+
DateTime.utc_now()
1486+
.replace(tzinfo=timezone.utc).astimezone(tz)
1487+
.timetz()
1488+
)
14911489

14921490
@classmethod
14931491
def utc_now(cls):
@@ -1894,7 +1892,12 @@ def replace(self, **kwargs):
18941892
def _utc_offset(self, dt=None):
18951893
if self.tzinfo is None:
18961894
return None
1897-
value = self.tzinfo.utcoffset(dt)
1895+
try:
1896+
value = self.tzinfo.utcoffset(dt)
1897+
except TypeError:
1898+
# For timezone implementations not compatible with the custom
1899+
# datetime implementations, we can't do better than this.
1900+
value = self.tzinfo.utcoffset(dt.to_native())
18981901
if value is None:
18991902
return None
19001903
if isinstance(value, timedelta):
@@ -1936,7 +1939,12 @@ def dst(self):
19361939
"""
19371940
if self.tzinfo is None:
19381941
return None
1939-
value = self.tzinfo.dst(self)
1942+
try:
1943+
value = self.tzinfo.dst(self)
1944+
except TypeError:
1945+
# For timezone implementations not compatible with the custom
1946+
# datetime implementations, we can't do better than this.
1947+
value = self.tzinfo.dst(self.to_native())
19401948
if value is None:
19411949
return None
19421950
if isinstance(value, timedelta):
@@ -1957,7 +1965,12 @@ def tzname(self):
19571965
"""
19581966
if self.tzinfo is None:
19591967
return None
1960-
return self.tzinfo.tzname(self)
1968+
try:
1969+
return self.tzinfo.tzname(self)
1970+
except TypeError:
1971+
# For timezone implementations not compatible with the custom
1972+
# datetime implementations, we can't do better than this.
1973+
return self.tzinfo.tzname(self.to_native())
19611974

19621975
def to_clock_time(self):
19631976
"""Convert to :class:`.ClockTime`.
@@ -1986,8 +1999,8 @@ def iso_format(self):
19861999
:rtype: str
19872000
"""
19882001
s = "%02d:%02d:%02d.%09d" % self.hour_minute_second_nanosecond
1989-
if self.tzinfo is not None:
1990-
offset = self.tzinfo.utcoffset(self)
2002+
offset = self.utc_offset()
2003+
if offset is not None:
19912004
s += "%+03d:%02d" % divmod(offset.total_seconds() // 60, 60)
19922005
return s
19932006

@@ -2100,9 +2113,24 @@ def now(cls, tz=None):
21002113
if tz is None:
21012114
return cls.from_clock_time(Clock().local_time(), UnixEpoch)
21022115
else:
2103-
return tz.fromutc(cls.from_clock_time(
2104-
Clock().utc_time(), UnixEpoch
2105-
).replace(tzinfo=tz))
2116+
try:
2117+
return tz.fromutc(cls.from_clock_time(
2118+
Clock().utc_time(), UnixEpoch
2119+
).replace(tzinfo=tz))
2120+
except TypeError:
2121+
# For timezone implementations not compatible with the custom
2122+
# datetime implementations, we can't do better than this.
2123+
utc_now = cls.from_clock_time(
2124+
Clock().utc_time(), UnixEpoch
2125+
)
2126+
utc_now_native = utc_now.to_native()
2127+
now_native = tz.fromutc(utc_now_native)
2128+
now = cls.from_native(now_native)
2129+
return now.replace(
2130+
nanosecond=(now.nanosecond
2131+
+ utc_now.nanosecond
2132+
- utc_now_native.microsecond * 1000)
2133+
)
21062134

21072135
@classmethod
21082136
def utc_now(cls):
@@ -2149,8 +2177,9 @@ def from_timestamp(cls, timestamp, tz=None):
21492177
ClockTime(timestamp) + Clock().local_offset(), UnixEpoch
21502178
)
21512179
else:
2152-
return tz.fromutc(
2153-
cls.utc_from_timestamp(timestamp).replace(tzinfo=tz)
2180+
return (
2181+
cls.utc_from_timestamp(timestamp)
2182+
.replace(tzinfo=timezone.utc).astimezone(tz)
21542183
)
21552184

21562185
@classmethod
@@ -2463,7 +2492,15 @@ def __add__(self, other):
24632492
time_ = Time.from_ticks_ns(round_half_to_even(
24642493
seconds * NANO_SECONDS + t.nanoseconds
24652494
))
2466-
return self.combine(date_, time_)
2495+
return self.combine(date_, time_).replace(tzinfo=self.tzinfo)
2496+
if isinstance(other, Duration):
2497+
t = (self.to_clock_time()
2498+
+ ClockTime(other.seconds, other.nanoseconds))
2499+
days, seconds = symmetric_divmod(t.seconds, 86400)
2500+
date_ = self.date() + Duration(months=other.months,
2501+
days=days + other.days)
2502+
time_ = Time.from_ticks(seconds * NANO_SECONDS + t.nanoseconds)
2503+
return self.combine(date_, time_).replace(tzinfo=self.tzinfo)
24672504
return NotImplemented
24682505

24692506
def __sub__(self, other):
@@ -2493,7 +2530,7 @@ def __sub__(self, other):
24932530
return timedelta(days=days, seconds=t.seconds,
24942531
microseconds=(t.nanoseconds // 1000))
24952532
if isinstance(other, Duration):
2496-
return NotImplemented
2533+
return self.__add__(-other)
24972534
if isinstance(other, timedelta):
24982535
return self.__add__(-other)
24992536
return NotImplemented
@@ -2552,7 +2589,18 @@ def as_timezone(self, tz):
25522589
if self.tzinfo is None:
25532590
return self
25542591
utc = (self - self.utc_offset()).replace(tzinfo=tz)
2555-
return tz.fromutc(utc)
2592+
try:
2593+
return tz.fromutc(utc)
2594+
except TypeError:
2595+
# For timezone implementations not compatible with the custom
2596+
# datetime implementations, we can't do better than this.
2597+
native_utc = utc.to_native()
2598+
native_res = tz.fromutc(native_utc)
2599+
res = self.from_native(native_res)
2600+
return res.replace(
2601+
nanosecond=(native_res.microsecond * 1000
2602+
+ self.nanosecond % 1000)
2603+
)
25562604

25572605
def utc_offset(self):
25582606
"""Get the date times utc offset.
@@ -2650,8 +2698,17 @@ def iso_format(self, sep="T"):
26502698
26512699
:rtype: str
26522700
"""
2653-
return "%s%s%s" % (self.date().iso_format(), sep,
2654-
self.timetz().iso_format())
2701+
s = "%s%s%s" % (self.date().iso_format(), sep,
2702+
self.timetz().iso_format())
2703+
time_tz = self.timetz()
2704+
offset = time_tz.utc_offset()
2705+
if offset is not None:
2706+
# the time component will have taken care of formatting the offset
2707+
return s
2708+
offset = self.utc_offset()
2709+
if offset is not None:
2710+
s += "%+03d:%02d" % divmod(offset.total_seconds() // 60, 60)
2711+
return s
26552712

26562713
def __repr__(self):
26572714
""""""

tests/unit/time/__init__.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
#!/usr/bin/env python
2-
# coding: utf-8
3-
41
# Copyright (c) "Neo4j"
52
# Neo4j Sweden AB [http://neo4j.com]
63
#
@@ -17,3 +14,29 @@
1714
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1815
# See the License for the specific language governing permissions and
1916
# limitations under the License.
17+
18+
19+
from neo4j.time.clock_implementations import (
20+
Clock,
21+
ClockTime,
22+
)
23+
24+
25+
# The existence of this class will make the driver's custom date time
26+
# implementation use it instead of a real clock since its precision it higher
27+
# than all the other clocks (only up to nanoseconds).
28+
class FixedClock(Clock):
29+
@classmethod
30+
def available(cls):
31+
return True
32+
33+
@classmethod
34+
def precision(cls):
35+
return 12
36+
37+
@classmethod
38+
def local_offset(cls):
39+
return ClockTime()
40+
41+
def utc_time(self):
42+
return ClockTime(45296, 789000001)

tests/unit/time/test_date.py

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

2121

22+
import datetime
2223
from datetime import date
2324
from time import struct_time
2425
from unittest import TestCase
2526

27+
import pytest
2628
import pytz
2729
import copy
2830

2931
from neo4j.time import Duration, Date, UnixEpoch, ZeroDate
3032

3133

32-
eastern = pytz.timezone("US/Eastern")
34+
timezone_eastern = pytz.timezone("US/Eastern")
35+
timezone_utc = pytz.utc
3336

3437

3538
class TestDate(TestCase):
@@ -187,24 +190,12 @@ def test_cannot_use_year_higher_than_9999(self):
187190
with self.assertRaises(ValueError):
188191
_ = Date(10000, 2, 1)
189192

190-
def test_today(self):
191-
d = Date.today()
192-
self.assertIsInstance(d, Date)
193-
194-
def test_today_with_tz(self):
195-
d = Date.today(tz=eastern)
196-
self.assertIsInstance(d, Date)
197-
198-
def test_utc_today(self):
199-
d = Date.utc_today()
200-
self.assertIsInstance(d, Date)
201-
202193
def test_from_timestamp_without_tz(self):
203194
d = Date.from_timestamp(0)
204195
self.assertEqual(d, Date(1970, 1, 1))
205196

206197
def test_from_timestamp_with_tz(self):
207-
d = Date.from_timestamp(0, tz=eastern)
198+
d = Date.from_timestamp(0, tz=timezone_eastern)
208199
self.assertEqual(d, Date(1969, 12, 31))
209200

210201
def test_utc_from_timestamp(self):
@@ -545,3 +536,23 @@ def test_date_deep_copy(self):
545536
d2 = copy.deepcopy(d)
546537
self.assertIsNot(d, d2)
547538
self.assertEqual(d, d2)
539+
540+
541+
@pytest.mark.parametrize(("tz", "expected"), (
542+
(None, (1970, 1, 1)),
543+
(timezone_eastern, (1970, 1, 1)),
544+
(timezone_utc, (1970, 1, 1)),
545+
(pytz.FixedOffset(-12 * 60), (1970, 1, 1)),
546+
(datetime.timezone(datetime.timedelta(hours=-12)), (1970, 1, 1)),
547+
(pytz.FixedOffset(-13 * 60), (1969, 12, 31)),
548+
(datetime.timezone(datetime.timedelta(hours=-13)), (1969, 12, 31)),
549+
(pytz.FixedOffset(11 * 60), (1970, 1, 1)),
550+
(datetime.timezone(datetime.timedelta(hours=11)), (1970, 1, 1)),
551+
(pytz.FixedOffset(12 * 60), (1970, 1, 2)),
552+
(datetime.timezone(datetime.timedelta(hours=12)), (1970, 1, 2)),
553+
554+
))
555+
def test_today(tz, expected):
556+
d = Date.today(tz=tz)
557+
assert isinstance(d, Date)
558+
assert d.year_month_day == expected

0 commit comments

Comments
 (0)