From a9196d88e1ca2bec098d76fbf9290d8052eb8b2e Mon Sep 17 00:00:00 2001 From: Robsdedude Date: Fri, 11 Oct 2024 16:48:32 +0200 Subject: [PATCH 1/4] Temporal types: improvements around tzinfo * Docs: clearly state that our temporal types are meant to be paired with `pytz` and `pytz` only. * 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. --- docs/source/conf.py | 1 + docs/source/types/_temporal_overview.rst | 5 ++ src/neo4j/time/__init__.py | 78 ++++++++++++++---------- tests/unit/common/time/test_datetime.py | 33 ++++++++++ 4 files changed, 84 insertions(+), 33 deletions(-) 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..5caf2ae2 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 likely won't 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. diff --git a/src/neo4j/time/__init__.py b/src/neo4j/time/__init__.py index 38987343..4fa663e6 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 From 7de731b65b54ec5416419a8354fe0a09a61b1c78 Mon Sep 17 00:00:00 2001 From: Robsdedude Date: Mon, 14 Oct 2024 12:45:27 +0200 Subject: [PATCH 2/4] Remove outdated remark on time zone support --- docs/source/types/_temporal_overview.rst | 4 ---- 1 file changed, 4 deletions(-) diff --git a/docs/source/types/_temporal_overview.rst b/docs/source/types/_temporal_overview.rst index 5caf2ae2..615b44d2 100644 --- a/docs/source/types/_temporal_overview.rst +++ b/docs/source/types/_temporal_overview.rst @@ -23,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, From cd83a5c181230cbcfa405e14a093a2a5d2160f69 Mon Sep 17 00:00:00 2001 From: Robsdedude Date: Mon, 14 Oct 2024 15:50:00 +0200 Subject: [PATCH 3/4] API docs: editorial change --- docs/source/types/_temporal_overview.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/types/_temporal_overview.rst b/docs/source/types/_temporal_overview.rst index 615b44d2..7a073cea 100644 --- a/docs/source/types/_temporal_overview.rst +++ b/docs/source/types/_temporal_overview.rst @@ -6,7 +6,7 @@ Sub-second values are measured to nanosecond precision and the types are compati .. 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 likely won't work well. + 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. From 63e12cb1a0f96417aa8bec590607ef57a897accc Mon Sep 17 00:00:00 2001 From: Robsdedude Date: Wed, 16 Oct 2024 11:12:27 +0200 Subject: [PATCH 4/4] Fix comment indentation --- src/neo4j/time/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/neo4j/time/__init__.py b/src/neo4j/time/__init__.py index 4fa663e6..4f8ad101 100644 --- a/src/neo4j/time/__init__.py +++ b/src/neo4j/time/__init__.py @@ -1497,7 +1497,7 @@ def _dst( except TypeError: if dt is None: raise - # For timezone implementations not compatible with the custom + # 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: