diff --git a/docs/source/conf.py b/docs/source/conf.py index c5b5e17e..0fa60082 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -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 = { diff --git a/docs/source/types/_temporal_overview.rst b/docs/source/types/_temporal_overview.rst index d6f18b8f..7a073cea 100644 --- a/docs/source/types/_temporal_overview.rst +++ b/docs/source/types/_temporal_overview.rst @@ -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 `_. +.. warning:: + The temporal types were designed to be used with `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. @@ -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 `_. 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, diff --git a/src/neo4j/time/__init__.py b/src/neo4j/time/__init__.py index 38987343..4f8ad101 100644 --- a/src/neo4j/time/__init__.py +++ b/src/neo4j/time/__init__.py @@ -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. @@ -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: """ @@ -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`.""" @@ -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) @@ -2809,7 +2821,7 @@ def dst(self) -> timedelta | None: See :meth:`.Time.dst`. """ - return self.__time.dst() + return _dst(self.tzinfo, self) def tzname(self) -> str | None: """ @@ -2817,7 +2829,7 @@ def tzname(self) -> str | None: See :meth:`.Time.tzname`. """ - return self.__time.tzname() + return _tz_name(self.tzinfo, self) def time_tuple(self): raise NotImplementedError diff --git a/tests/unit/common/time/test_datetime.py b/tests/unit/common/time/test_datetime.py index ebc1deb1..46be5ffd 100644 --- a/tests/unit/common/time/test_datetime.py +++ b/tests/unit/common/time/test_datetime.py @@ -20,6 +20,7 @@ import itertools import operator import pickle +import sys import typing as t from datetime import ( datetime, @@ -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