Skip to content

Temporal types: improvements around tzinfo #1104

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Oct 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,7 @@ def setup(app):

intersphinx_mapping = {
"python": ("https://docs.python.org/3", None),
"dateutil": ("https://dateutil.readthedocs.io/en/stable/", None),
}

autodoc_default_options = {
Expand Down
9 changes: 5 additions & 4 deletions docs/source/types/_temporal_overview.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ Temporal data types are implemented by the ``neo4j.time`` module.
It provides a set of types compliant with ISO-8601 and Cypher, which are similar to those found in the built-in ``datetime`` module.
Sub-second values are measured to nanosecond precision and the types are compatible with `pytz <https://pypi.org/project/pytz/>`_.

.. warning::
The temporal types were designed to be used with `pytz <https://pypi.org/project/pytz/>`_.
Other :class:`datetime.tzinfo` implementations (e.g., :class:`datetime.timezone`, :mod:`zoneinfo`, :mod:`dateutil.tz`)
are not supported and are unlikely to work well.

The table below shows the general mappings between Cypher and the temporal types provided by the driver.

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

Sub-second values are measured to nanosecond precision and the types are mostly
compatible with `pytz <https://pypi.org/project/pytz/>`_. Some timezones
(e.g., ``pytz.utc``) work exclusively with the built-in ``datetime.datetime``.

.. Note::
Cypher has built-in support for handling temporal values, and the underlying
database supports storing these temporal values as properties on nodes and relationships,
Expand Down
78 changes: 45 additions & 33 deletions src/neo4j/time/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1487,6 +1487,43 @@ def iso_calendar(self) -> tuple[int, int, int]: ...
time_base_class = object


def _dst(
tz: _tzinfo | None = None, dt: DateTime | None = None
) -> timedelta | None:
if tz is None:
return None
try:
value = tz.dst(dt)
except TypeError:
if dt is None:
raise
# For timezone implementations not compatible with the custom
# datetime implementations, we can't do better than this.
value = tz.dst(dt.to_native()) # type: ignore
if value is None:
return None
if isinstance(value, timedelta):
if value.days != 0:
raise ValueError("dst must be less than a day")
if value.seconds % 60 != 0 or value.microseconds != 0:
raise ValueError("dst must be a whole number of minutes")
return value
raise TypeError("dst must be a timedelta")


def _tz_name(tz: _tzinfo | None, dt: DateTime | None) -> str | None:
if tz is None:
return None
try:
return tz.tzname(dt)
except TypeError:
if dt is None:
raise
# For timezone implementations not compatible with the custom
# datetime implementations, we can't do better than this.
return tz.tzname(dt.to_native())


class Time(time_base_class, metaclass=TimeType):
"""
Time of day.
Expand Down Expand Up @@ -1996,23 +2033,7 @@ def dst(self) -> timedelta | None:
:raises TypeError: if `self.tzinfo.dst(self)` does return anything but
None or a :class:`datetime.timedelta`.
"""
if self.tzinfo is None:
return None
try:
value = self.tzinfo.dst(self) # type: ignore
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()) # type: ignore
if value is None:
return None
if isinstance(value, timedelta):
if value.days != 0:
raise ValueError("dst must be less than a day")
if value.seconds % 60 != 0 or value.microseconds != 0:
raise ValueError("dst must be a whole number of minutes")
return value
raise TypeError("dst must be a timedelta")
return _dst(self.tzinfo, None)

def tzname(self) -> str | None:
"""
Expand All @@ -2021,14 +2042,7 @@ def tzname(self) -> str | None:
:returns: None if the time is local (i.e., has no timezone), else
return `self.tzinfo.tzname(self)`
"""
if self.tzinfo is None:
return None
try:
return self.tzinfo.tzname(self) # type: ignore
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()) # type: ignore
return _tz_name(self.tzinfo, None)

def to_clock_time(self) -> ClockTime:
"""Convert to :class:`.ClockTime`."""
Expand Down Expand Up @@ -2202,16 +2216,14 @@ def now(cls, tz: _tzinfo | None = None) -> DateTime:
if tz is None:
return cls.from_clock_time(Clock().local_time(), UnixEpoch)
else:
utc_now = cls.from_clock_time(
Clock().utc_time(), UnixEpoch
).replace(tzinfo=tz)
try:
return tz.fromutc( # type: ignore
cls.from_clock_time( # type: ignore
Clock().utc_time(), UnixEpoch
).replace(tzinfo=tz)
)
return tz.fromutc(utc_now) # type: ignore
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)
Expand Down Expand Up @@ -2809,15 +2821,15 @@ def dst(self) -> timedelta | None:

See :meth:`.Time.dst`.
"""
return self.__time.dst()
return _dst(self.tzinfo, self)

def tzname(self) -> str | None:
"""
Get the timezone name.

See :meth:`.Time.tzname`.
"""
return self.__time.tzname()
return _tz_name(self.tzinfo, self)

def time_tuple(self):
raise NotImplementedError
Expand Down
33 changes: 33 additions & 0 deletions tests/unit/common/time/test_datetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import itertools
import operator
import pickle
import sys
import typing as t
from datetime import (
datetime,
Expand Down Expand Up @@ -180,6 +181,38 @@ def test_now_with_utc_tz(self) -> None:
assert t.dst() == timedelta()
assert t.tzname() == "UTC"

def test_now_with_timezone_utc_tz(self) -> None:
# not fully supported tzinfo implementation
t = DateTime.now(datetime_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 == 56
assert t.nanosecond == 789000001
assert t.utcoffset() == timedelta(seconds=0)
assert t.dst() is None
assert t.tzname() == "UTC"

if sys.version_info >= (3, 9):

def test_now_with_zoneinfo_utc_tz(self) -> None:
# not fully supported tzinfo implementation
import zoneinfo

t = DateTime.now(zoneinfo.ZoneInfo("UTC"))
assert t.year == 1970
assert t.month == 1
assert t.day == 1
assert t.hour == 12
assert t.minute == 34
assert t.second == 56
assert t.nanosecond == 789000001
assert t.utcoffset() == timedelta(seconds=0)
assert t.dst() == timedelta(seconds=0)
assert t.tzname() == "UTC"

def test_utc_now(self) -> None:
t = DateTime.utc_now()
assert t.year == 1970
Expand Down