Skip to content

Commit 621b8dc

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 4725ce1 commit 621b8dc

File tree

5 files changed

+358
-117
lines changed

5 files changed

+358
-117
lines changed

neo4j/time/__init__.py

Lines changed: 87 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
datetime,
2828
time,
2929
timedelta,
30+
timezone,
3031
)
3132
from functools import total_ordering
3233
from re import compile as re_compile
@@ -778,7 +779,7 @@ def __getattr__(self, name):
778779
def today(cls, tz=None):
779780
"""Get the current date.
780781
781-
:param tz: timezone or None to get a local :class:`.Date`.
782+
:param tz: timezone or None to get the local :class:`.Date`.
782783
:type tz: datetime.tzinfo or None
783784
784785
:rtype: Date
@@ -790,11 +791,11 @@ def today(cls, tz=None):
790791
if tz is None:
791792
return cls.from_clock_time(Clock().local_time(), UnixEpoch)
792793
else:
793-
return tz.fromutc(
794-
DateTime.from_clock_time(
795-
Clock().utc_time(), UnixEpoch
796-
).replace(tzinfo=tz)
797-
).date()
794+
return (
795+
DateTime.utc_now()
796+
.replace(tzinfo=timezone.utc).astimezone(tz)
797+
.date()
798+
)
798799

799800
@classmethod
800801
def utc_today(cls):
@@ -819,14 +820,7 @@ def from_timestamp(cls, timestamp, tz=None):
819820
supported by the platform C localtime() function. It’s common for
820821
this to be restricted to years from 1970 through 2038.
821822
"""
822-
if tz is None:
823-
return cls.from_clock_time(
824-
ClockTime(timestamp) + Clock().local_offset(), UnixEpoch
825-
)
826-
else:
827-
return tz.fromutc(
828-
DateTime.utc_from_timestamp(timestamp).replace(tzinfo=tz)
829-
).date()
823+
return cls.from_native(datetime.fromtimestamp(timestamp, tz))
830824

831825
@classmethod
832826
def utc_from_timestamp(cls, timestamp):
@@ -1427,7 +1421,11 @@ def now(cls, tz=None):
14271421
if tz is None:
14281422
return cls.from_clock_time(Clock().local_time(), UnixEpoch)
14291423
else:
1430-
return tz.fromutc(DateTime.from_clock_time(Clock().utc_time(), UnixEpoch)).time().replace(tzinfo=tz)
1424+
return (
1425+
DateTime.utc_now()
1426+
.replace(tzinfo=timezone.utc).astimezone(tz)
1427+
.timetz()
1428+
)
14311429

14321430
@classmethod
14331431
def utc_now(cls):
@@ -1768,7 +1766,12 @@ def replace(self, **kwargs):
17681766
def _utc_offset(self, dt=None):
17691767
if self.tzinfo is None:
17701768
return None
1771-
value = self.tzinfo.utcoffset(dt)
1769+
try:
1770+
value = self.tzinfo.utcoffset(dt)
1771+
except TypeError:
1772+
# For timezone implementations not compatible with the custom
1773+
# datetime implementations, we can't do better than this.
1774+
value = self.tzinfo.utcoffset(dt.to_native())
17721775
if value is None:
17731776
return None
17741777
if isinstance(value, timedelta):
@@ -1810,7 +1813,12 @@ def dst(self):
18101813
"""
18111814
if self.tzinfo is None:
18121815
return None
1813-
value = self.tzinfo.dst(self)
1816+
try:
1817+
value = self.tzinfo.dst(self)
1818+
except TypeError:
1819+
# For timezone implementations not compatible with the custom
1820+
# datetime implementations, we can't do better than this.
1821+
value = self.tzinfo.dst(self.to_native())
18141822
if value is None:
18151823
return None
18161824
if isinstance(value, timedelta):
@@ -1831,7 +1839,12 @@ def tzname(self):
18311839
"""
18321840
if self.tzinfo is None:
18331841
return None
1834-
return self.tzinfo.tzname(self)
1842+
try:
1843+
return self.tzinfo.tzname(self)
1844+
except TypeError:
1845+
# For timezone implementations not compatible with the custom
1846+
# datetime implementations, we can't do better than this.
1847+
return self.tzinfo.tzname(self.to_native())
18351848

18361849
def to_clock_time(self):
18371850
"""Convert to :class:`.ClockTime`.
@@ -1860,8 +1873,8 @@ def iso_format(self):
18601873
:rtype: str
18611874
"""
18621875
s = "%02d:%02d:%02d.%09d" % self.hour_minute_second_nanosecond
1863-
if self.tzinfo is not None:
1864-
offset = self.tzinfo.utcoffset(self)
1876+
offset = self.utc_offset()
1877+
if offset is not None:
18651878
s += "%+03d:%02d" % divmod(offset.total_seconds() // 60, 60)
18661879
return s
18671880

@@ -1970,9 +1983,24 @@ def now(cls, tz=None):
19701983
if tz is None:
19711984
return cls.from_clock_time(Clock().local_time(), UnixEpoch)
19721985
else:
1973-
return tz.fromutc(cls.from_clock_time(
1974-
Clock().utc_time(), UnixEpoch
1975-
).replace(tzinfo=tz))
1986+
try:
1987+
return tz.fromutc(cls.from_clock_time(
1988+
Clock().utc_time(), UnixEpoch
1989+
).replace(tzinfo=tz))
1990+
except TypeError:
1991+
# For timezone implementations not compatible with the custom
1992+
# datetime implementations, we can't do better than this.
1993+
utc_now = cls.from_clock_time(
1994+
Clock().utc_time(), UnixEpoch
1995+
)
1996+
utc_now_native = utc_now.to_native()
1997+
now_native = tz.fromutc(utc_now_native)
1998+
now = cls.from_native(now_native)
1999+
return now.replace(
2000+
nanosecond=(now.nanosecond
2001+
+ utc_now.nanosecond
2002+
- utc_now_native.microsecond * 1000)
2003+
)
19762004

19772005
@classmethod
19782006
def utc_now(cls):
@@ -2019,8 +2047,9 @@ def from_timestamp(cls, timestamp, tz=None):
20192047
ClockTime(timestamp) + Clock().local_offset(), UnixEpoch
20202048
)
20212049
else:
2022-
return tz.fromutc(
2023-
cls.utc_from_timestamp(timestamp).replace(tzinfo=tz)
2050+
return (
2051+
cls.utc_from_timestamp(timestamp)
2052+
.replace(tzinfo=timezone.utc).astimezone(tz)
20242053
)
20252054

20262055
@classmethod
@@ -2326,7 +2355,15 @@ def __add__(self, other):
23262355
time_ = Time.from_ticks(round_half_to_even(
23272356
seconds * NANO_SECONDS + t.nanoseconds
23282357
))
2329-
return self.combine(date_, time_)
2358+
return self.combine(date_, time_).replace(tzinfo=self.tzinfo)
2359+
if isinstance(other, Duration):
2360+
t = (self.to_clock_time()
2361+
+ ClockTime(other.seconds, other.nanoseconds))
2362+
days, seconds = symmetric_divmod(t.seconds, 86400)
2363+
date_ = self.date() + Duration(months=other.months,
2364+
days=days + other.days)
2365+
time_ = Time.from_ticks(seconds * NANO_SECONDS + t.nanoseconds)
2366+
return self.combine(date_, time_).replace(tzinfo=self.tzinfo)
23302367
return NotImplemented
23312368

23322369
def __sub__(self, other):
@@ -2356,7 +2393,7 @@ def __sub__(self, other):
23562393
return timedelta(days=days, seconds=t.seconds,
23572394
microseconds=(t.nanoseconds // 1000))
23582395
if isinstance(other, Duration):
2359-
return NotImplemented
2396+
return self.__add__(-other)
23602397
if isinstance(other, timedelta):
23612398
return self.__add__(-other)
23622399
return NotImplemented
@@ -2415,7 +2452,18 @@ def as_timezone(self, tz):
24152452
if self.tzinfo is None:
24162453
return self
24172454
utc = (self - self.utc_offset()).replace(tzinfo=tz)
2418-
return tz.fromutc(utc)
2455+
try:
2456+
return tz.fromutc(utc)
2457+
except TypeError:
2458+
# For timezone implementations not compatible with the custom
2459+
# datetime implementations, we can't do better than this.
2460+
native_utc = utc.to_native()
2461+
native_res = tz.fromutc(native_utc)
2462+
res = self.from_native(native_res)
2463+
return res.replace(
2464+
nanosecond=(native_res.microsecond * 1000
2465+
+ self.nanosecond % 1000)
2466+
)
24192467

24202468
def utc_offset(self):
24212469
"""Get the date times utc offset.
@@ -2513,8 +2561,17 @@ def iso_format(self, sep="T"):
25132561
25142562
:rtype: str
25152563
"""
2516-
return "%s%s%s" % (self.date().iso_format(), sep,
2517-
self.timetz().iso_format())
2564+
s = "%s%s%s" % (self.date().iso_format(), sep,
2565+
self.timetz().iso_format())
2566+
time_tz = self.timetz()
2567+
offset = time_tz.utc_offset()
2568+
if offset is not None:
2569+
# the time component will have taken care of formatting the offset
2570+
return s
2571+
offset = self.utc_offset()
2572+
if offset is not None:
2573+
s += "%+03d:%02d" % divmod(offset.total_seconds() // 60, 60)
2574+
return s
25182575

25192576
def __repr__(self):
25202577
""""""

tests/unit/common/time/__init__.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,29 @@
1414
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1515
# See the License for the specific language governing permissions and
1616
# 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/common/time/test_date.py

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,12 @@
1717

1818

1919
import copy
20+
import datetime
2021
from datetime import date
2122
from time import struct_time
2223
from unittest import TestCase
2324

25+
import pytest
2426
import pytz
2527

2628
from neo4j.time import (
@@ -31,7 +33,8 @@
3133
)
3234

3335

34-
eastern = pytz.timezone("US/Eastern")
36+
timezone_eastern = pytz.timezone("US/Eastern")
37+
timezone_utc = pytz.utc
3538

3639

3740
class TestDate(TestCase):
@@ -189,24 +192,12 @@ def test_cannot_use_year_higher_than_9999(self):
189192
with self.assertRaises(ValueError):
190193
_ = Date(10000, 2, 1)
191194

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

208199
def test_from_timestamp_with_tz(self):
209-
d = Date.from_timestamp(0, tz=eastern)
200+
d = Date.from_timestamp(0, tz=timezone_eastern)
210201
self.assertEqual(d, Date(1969, 12, 31))
211202

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

0 commit comments

Comments
 (0)