Skip to content

Commit 610ca2e

Browse files
committed
Temporal types: improvements around tzinfo (#1104)
* Docs: * clearly state that our temporal types are meant to be paired with `pytz` and `pytz` only. * remove outdated remark on time zone support * Code: improve compatibility of `DateTime.now`, `.dst`, and `.tzname` with other `tzinfo` implementations. **IMPORTANT**: those implementations are still not, and will not be fully supported.
1 parent 380c70c commit 610ca2e

File tree

4 files changed

+84
-37
lines changed

4 files changed

+84
-37
lines changed

docs/source/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,7 @@ def setup(app):
355355

356356
intersphinx_mapping = {
357357
"python": ("https://docs.python.org/3", None),
358+
"dateutil": ("https://dateutil.readthedocs.io/en/stable/", None),
358359
}
359360

360361
autodoc_default_options = {

docs/source/types/_temporal_overview.rst

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ Temporal data types are implemented by the ``neo4j.time`` module.
33
It provides a set of types compliant with ISO-8601 and Cypher, which are similar to those found in the built-in ``datetime`` module.
44
Sub-second values are measured to nanosecond precision and the types are compatible with `pytz <https://pypi.org/project/pytz/>`_.
55

6+
.. warning::
7+
The temporal types were designed to be used with `pytz <https://pypi.org/project/pytz/>`_.
8+
Other :class:`datetime.tzinfo` implementations (e.g., :class:`datetime.timezone`, :mod:`zoneinfo`, :mod:`dateutil.tz`)
9+
are not supported and are unlikely to work well.
10+
611
The table below shows the general mappings between Cypher and the temporal types provided by the driver.
712

813
In addition, the built-in temporal types can be passed as parameters and will be mapped appropriately.
@@ -18,10 +23,6 @@ LocalDateTime :class:`neo4j.time.DateTime` :class:`python:datetime.datetime`
1823
Duration :class:`neo4j.time.Duration` :class:`python:datetime.timedelta`
1924
============= ============================ ================================== ============
2025

21-
Sub-second values are measured to nanosecond precision and the types are mostly
22-
compatible with `pytz <https://pypi.org/project/pytz/>`_. Some timezones
23-
(e.g., ``pytz.utc``) work exclusively with the built-in ``datetime.datetime``.
24-
2526
.. Note::
2627
Cypher has built-in support for handling temporal values, and the underlying
2728
database supports storing these temporal values as properties on nodes and relationships,

src/neo4j/time/__init__.py

Lines changed: 45 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1487,6 +1487,43 @@ def iso_calendar(self) -> tuple[int, int, int]: ...
14871487
time_base_class = object
14881488

14891489

1490+
def _dst(
1491+
tz: _tzinfo | None = None, dt: DateTime | None = None
1492+
) -> timedelta | None:
1493+
if tz is None:
1494+
return None
1495+
try:
1496+
value = tz.dst(dt)
1497+
except TypeError:
1498+
if dt is None:
1499+
raise
1500+
# For timezone implementations not compatible with the custom
1501+
# datetime implementations, we can't do better than this.
1502+
value = tz.dst(dt.to_native()) # type: ignore
1503+
if value is None:
1504+
return None
1505+
if isinstance(value, timedelta):
1506+
if value.days != 0:
1507+
raise ValueError("dst must be less than a day")
1508+
if value.seconds % 60 != 0 or value.microseconds != 0:
1509+
raise ValueError("dst must be a whole number of minutes")
1510+
return value
1511+
raise TypeError("dst must be a timedelta")
1512+
1513+
1514+
def _tz_name(tz: _tzinfo | None, dt: DateTime | None) -> str | None:
1515+
if tz is None:
1516+
return None
1517+
try:
1518+
return tz.tzname(dt)
1519+
except TypeError:
1520+
if dt is None:
1521+
raise
1522+
# For timezone implementations not compatible with the custom
1523+
# datetime implementations, we can't do better than this.
1524+
return tz.tzname(dt.to_native())
1525+
1526+
14901527
class Time(time_base_class, metaclass=TimeType):
14911528
"""
14921529
Time of day.
@@ -1996,23 +2033,7 @@ def dst(self) -> timedelta | None:
19962033
:raises TypeError: if `self.tzinfo.dst(self)` does return anything but
19972034
None or a :class:`datetime.timedelta`.
19982035
"""
1999-
if self.tzinfo is None:
2000-
return None
2001-
try:
2002-
value = self.tzinfo.dst(self) # type: ignore
2003-
except TypeError:
2004-
# For timezone implementations not compatible with the custom
2005-
# datetime implementations, we can't do better than this.
2006-
value = self.tzinfo.dst(self.to_native()) # type: ignore
2007-
if value is None:
2008-
return None
2009-
if isinstance(value, timedelta):
2010-
if value.days != 0:
2011-
raise ValueError("dst must be less than a day")
2012-
if value.seconds % 60 != 0 or value.microseconds != 0:
2013-
raise ValueError("dst must be a whole number of minutes")
2014-
return value
2015-
raise TypeError("dst must be a timedelta")
2036+
return _dst(self.tzinfo, None)
20162037

20172038
def tzname(self) -> str | None:
20182039
"""
@@ -2021,14 +2042,7 @@ def tzname(self) -> str | None:
20212042
:returns: None if the time is local (i.e., has no timezone), else
20222043
return `self.tzinfo.tzname(self)`
20232044
"""
2024-
if self.tzinfo is None:
2025-
return None
2026-
try:
2027-
return self.tzinfo.tzname(self) # type: ignore
2028-
except TypeError:
2029-
# For timezone implementations not compatible with the custom
2030-
# datetime implementations, we can't do better than this.
2031-
return self.tzinfo.tzname(self.to_native()) # type: ignore
2045+
return _tz_name(self.tzinfo, None)
20322046

20332047
def to_clock_time(self) -> ClockTime:
20342048
"""Convert to :class:`.ClockTime`."""
@@ -2202,16 +2216,14 @@ def now(cls, tz: _tzinfo | None = None) -> DateTime:
22022216
if tz is None:
22032217
return cls.from_clock_time(Clock().local_time(), UnixEpoch)
22042218
else:
2219+
utc_now = cls.from_clock_time(
2220+
Clock().utc_time(), UnixEpoch
2221+
).replace(tzinfo=tz)
22052222
try:
2206-
return tz.fromutc( # type: ignore
2207-
cls.from_clock_time( # type: ignore
2208-
Clock().utc_time(), UnixEpoch
2209-
).replace(tzinfo=tz)
2210-
)
2223+
return tz.fromutc(utc_now) # type: ignore
22112224
except TypeError:
22122225
# For timezone implementations not compatible with the custom
22132226
# datetime implementations, we can't do better than this.
2214-
utc_now = cls.from_clock_time(Clock().utc_time(), UnixEpoch)
22152227
utc_now_native = utc_now.to_native()
22162228
now_native = tz.fromutc(utc_now_native)
22172229
now = cls.from_native(now_native)
@@ -2809,15 +2821,15 @@ def dst(self) -> timedelta | None:
28092821
28102822
See :meth:`.Time.dst`.
28112823
"""
2812-
return self.__time.dst()
2824+
return _dst(self.tzinfo, self)
28132825

28142826
def tzname(self) -> str | None:
28152827
"""
28162828
Get the timezone name.
28172829
28182830
See :meth:`.Time.tzname`.
28192831
"""
2820-
return self.__time.tzname()
2832+
return _tz_name(self.tzinfo, self)
28212833

28222834
def time_tuple(self):
28232835
raise NotImplementedError

tests/unit/common/time/test_datetime.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import itertools
2121
import operator
2222
import pickle
23+
import sys
2324
import typing as t
2425
from datetime import (
2526
datetime,
@@ -180,6 +181,38 @@ def test_now_with_utc_tz(self) -> None:
180181
assert t.dst() == timedelta()
181182
assert t.tzname() == "UTC"
182183

184+
def test_now_with_timezone_utc_tz(self) -> None:
185+
# not fully supported tzinfo implementation
186+
t = DateTime.now(datetime_timezone.utc)
187+
assert t.year == 1970
188+
assert t.month == 1
189+
assert t.day == 1
190+
assert t.hour == 12
191+
assert t.minute == 34
192+
assert t.second == 56
193+
assert t.nanosecond == 789000001
194+
assert t.utcoffset() == timedelta(seconds=0)
195+
assert t.dst() is None
196+
assert t.tzname() == "UTC"
197+
198+
if sys.version_info >= (3, 9):
199+
200+
def test_now_with_zoneinfo_utc_tz(self) -> None:
201+
# not fully supported tzinfo implementation
202+
import zoneinfo
203+
204+
t = DateTime.now(zoneinfo.ZoneInfo("UTC"))
205+
assert t.year == 1970
206+
assert t.month == 1
207+
assert t.day == 1
208+
assert t.hour == 12
209+
assert t.minute == 34
210+
assert t.second == 56
211+
assert t.nanosecond == 789000001
212+
assert t.utcoffset() == timedelta(seconds=0)
213+
assert t.dst() == timedelta(seconds=0)
214+
assert t.tzname() == "UTC"
215+
183216
def test_utc_now(self) -> None:
184217
t = DateTime.utc_now()
185218
assert t.year == 1970

0 commit comments

Comments
 (0)