From 7e7e917577ced4bcd07ebeaa2108ec732227d149 Mon Sep 17 00:00:00 2001 From: Chip Kent Date: Wed, 13 Mar 2024 13:55:06 -0600 Subject: [PATCH 1/3] Make python timezone conversions handle more cases. Resolves #4723 --- py/server/deephaven/time.py | 51 +++++++++++++++++++++++++++++++++--- py/server/tests/test_time.py | 18 +++++++++++-- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/py/server/deephaven/time.py b/py/server/deephaven/time.py index 11c8897dfd9..311e82086fd 100644 --- a/py/server/deephaven/time.py +++ b/py/server/deephaven/time.py @@ -165,6 +165,46 @@ def time_zone_alias_rm(alias: str) -> bool: # region Conversions: Python To Java +def _tzinfo_to_j_time_zone(tzi: datetime.tzinfo, offset: datetime.timedelta) -> TimeZone: + """ + Converts a Python time zone to a Java TimeZone. + + Args: + tzi: time zone info + offset: UTC offset + + Returns: + Java TimeZone + """ + if not tzi: + return None + + # Try to get the time zone from the zone name + try: + return _JDateTimeUtils.parseTimeZone(str(tzi)) + except Exception: + pass + + # Try to get the time zone from the UTC offset + + if not offset: + raise ValueError("Unable to determine the time zone UTC offset") + + if offset.microseconds != 0 or offset.seconds%60 != 0: + raise ValueError(f"Unsupported time zone offset contains fractions of a minute: {offset}") + + ts = offset.total_seconds() + + if ts >= 0: + sign = "+" + else: + sign = "-" + ts = -ts + + hours = int(ts / 3600) + minutes = int((ts % 3600) / 60) + return _JDateTimeUtils.parseTimeZone(f"UTC{sign}{hours:02d}:{minutes:02d}") + def to_j_time_zone(tz: Union[None, TimeZone, str, datetime.tzinfo, datetime.datetime, pandas.Timestamp]) -> \ Optional[TimeZone]: @@ -192,12 +232,15 @@ def to_j_time_zone(tz: Union[None, TimeZone, str, datetime.tzinfo, datetime.date elif isinstance(tz, str): return _JDateTimeUtils.parseTimeZone(tz) elif isinstance(tz, datetime.tzinfo): - return _JDateTimeUtils.parseTimeZone(str(tz)) + return _tzinfo_to_j_time_zone(tz, tz.utcoffset(None) if tz else None) elif isinstance(tz, datetime.datetime): - if not tz.tzname(): - return _JDateTimeUtils.parseTimeZone(tz.astimezone().tzname()) + tzi = tz.tzinfo + rst = _tzinfo_to_j_time_zone(tzi, tzi.utcoffset(tz) if tzi else None) + + if not rst: + raise ValueError("datetime is not time zone aware") - return _JDateTimeUtils.parseTimeZone(tz.tzname()) + return rst else: raise TypeError("Unsupported conversion: " + str(type(tz)) + " -> TimeZone") except TypeError as e: diff --git a/py/server/tests/test_time.py b/py/server/tests/test_time.py index a7ce18d46ae..2864c4cd0ba 100644 --- a/py/server/tests/test_time.py +++ b/py/server/tests/test_time.py @@ -72,8 +72,9 @@ def test_to_j_time_zone(self): self.assertEqual(str(tz), "UTC") pytz = datetime.datetime.now() - tz = to_j_time_zone(pytz) - self.assertEqual(str(tz), "UTC") + with self.assertRaises(DHError): + tz = to_j_time_zone(pytz) + self.fail("Expected DHError") pytz = datetime.datetime.now().astimezone() tz = to_j_time_zone(pytz) @@ -93,6 +94,19 @@ def test_to_j_time_zone(self): tz2 = to_j_time_zone(tz1) self.assertEqual(tz1, tz2) + ts = pd.Timestamp("2022-07-07", tz="America/New_York") + self.assertEqual(to_j_time_zone(ts), to_j_time_zone("America/New_York")) + + dttz = datetime.timezone(offset=datetime.timedelta(hours=5), name="XYZ") + dt = datetime.datetime(2022, 7, 7, 14, 21, 17, 123456, tzinfo=dttz) + self.assertEqual(to_j_time_zone(dttz), to_j_time_zone("UTC+5")) + self.assertEqual(to_j_time_zone(dt), to_j_time_zone("UTC+5")) + + dttz = datetime.timezone(offset=-datetime.timedelta(hours=5), name="XYZ") + dt = datetime.datetime(2022, 7, 7, 14, 21, 17, 123456, tzinfo=dttz) + self.assertEqual(to_j_time_zone(dttz), to_j_time_zone("UTC-5")) + self.assertEqual(to_j_time_zone(dt), to_j_time_zone("UTC-5")) + with self.assertRaises(TypeError): to_j_time_zone(False) self.fail("Expected TypeError") From 4cf265850cd4762141c24e6e3e36eb18f6e26a7e Mon Sep 17 00:00:00 2001 From: Chip Kent Date: Fri, 15 Mar 2024 10:12:30 -0600 Subject: [PATCH 2/3] Responding to review. New unit test. --- py/server/tests/test_time.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/py/server/tests/test_time.py b/py/server/tests/test_time.py index 2864c4cd0ba..907638ca081 100644 --- a/py/server/tests/test_time.py +++ b/py/server/tests/test_time.py @@ -107,6 +107,17 @@ def test_to_j_time_zone(self): self.assertEqual(to_j_time_zone(dttz), to_j_time_zone("UTC-5")) self.assertEqual(to_j_time_zone(dt), to_j_time_zone("UTC-5")) + dttz = datetime.timezone(offset=-datetime.timedelta(hours=5, microseconds=10), name="XYZ") + dt = datetime.datetime(2022, 7, 7, 14, 21, 17, 123456, tzinfo=dttz) + + with self.assertRaises(DHError): + to_j_time_zone(dttz) + self.fail("Expected DHError") + + with self.assertRaises(DHError): + to_j_time_zone(dt) + self.fail("Expected DHError") + with self.assertRaises(TypeError): to_j_time_zone(False) self.fail("Expected TypeError") From bc3cc29a4fd0bb24ece9c2a9a4404baa8c81f995 Mon Sep 17 00:00:00 2001 From: Chip Kent Date: Fri, 15 Mar 2024 15:16:35 -0600 Subject: [PATCH 3/3] Responding to review. More careful support for time zone types. --- py/server/deephaven/time.py | 64 ++++++++++++++++++++++-------------- py/server/tests/test_time.py | 8 ++++- 2 files changed, 47 insertions(+), 25 deletions(-) diff --git a/py/server/deephaven/time.py b/py/server/deephaven/time.py index 311e82086fd..7fb1e3a6419 100644 --- a/py/server/deephaven/time.py +++ b/py/server/deephaven/time.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2016-2023 Deephaven Data Labs and Patent Pending +# Copyright (c) 2016-2024 Deephaven Data Labs and Patent Pending # """ This module defines functions for handling Deephaven date/time data. """ @@ -7,6 +7,8 @@ from __future__ import annotations import datetime +import zoneinfo +import pytz from typing import Union, Optional, Literal import jpy @@ -165,45 +167,59 @@ def time_zone_alias_rm(alias: str) -> bool: # region Conversions: Python To Java -def _tzinfo_to_j_time_zone(tzi: datetime.tzinfo, offset: datetime.timedelta) -> TimeZone: +def _tzinfo_to_j_time_zone(tzi: datetime.tzinfo) -> TimeZone: """ Converts a Python time zone to a Java TimeZone. Args: tzi: time zone info - offset: UTC offset Returns: Java TimeZone """ + if not tzi: return None - # Try to get the time zone from the zone name - try: - return _JDateTimeUtils.parseTimeZone(str(tzi)) - except Exception: - pass + # Handle pytz time zones + + if isinstance(tzi, pytz.tzinfo.BaseTzInfo): + return _JDateTimeUtils.parseTimeZone(tzi.zone) + + # Handle zoneinfo time zones + + if isinstance(tzi, zoneinfo.ZoneInfo): + return _JDateTimeUtils.parseTimeZone(tzi.key) - # Try to get the time zone from the UTC offset + # Handle constant UTC offset time zones (datetime.timezone) - if not offset: - raise ValueError("Unable to determine the time zone UTC offset") + if isinstance(tzi, datetime.timezone): + offset = tzi.utcoffset(None) - if offset.microseconds != 0 or offset.seconds%60 != 0: - raise ValueError(f"Unsupported time zone offset contains fractions of a minute: {offset}") + if offset is None: + raise ValueError("Unable to determine the time zone UTC offset") - ts = offset.total_seconds() + if not offset: + return _JDateTimeUtils.parseTimeZone("UTC") + + if offset.microseconds != 0 or offset.seconds%60 != 0: + raise ValueError(f"Unsupported time zone offset contains fractions of a minute: {offset}") + + ts = offset.total_seconds() + + if ts >= 0: + sign = "+" + else: + sign = "-" + ts = -ts - if ts >= 0: - sign = "+" - else: - sign = "-" - ts = -ts + hours = int(ts / 3600) + minutes = int((ts % 3600) / 60) + return _JDateTimeUtils.parseTimeZone(f"UTC{sign}{hours:02d}:{minutes:02d}") - hours = int(ts / 3600) - minutes = int((ts % 3600) / 60) - return _JDateTimeUtils.parseTimeZone(f"UTC{sign}{hours:02d}:{minutes:02d}") + details = "\n\t".join([f"type={type(tzi).mro()}"] + + [f"obj.{attr}={getattr(tzi, attr)}" for attr in dir(tzi) if not attr.startswith("_")]) + raise TypeError(f"Unsupported conversion: {str(type(tzi))} -> TimeZone\n\tDetails:\n\t{details}") def to_j_time_zone(tz: Union[None, TimeZone, str, datetime.tzinfo, datetime.datetime, pandas.Timestamp]) -> \ @@ -232,10 +248,10 @@ def to_j_time_zone(tz: Union[None, TimeZone, str, datetime.tzinfo, datetime.date elif isinstance(tz, str): return _JDateTimeUtils.parseTimeZone(tz) elif isinstance(tz, datetime.tzinfo): - return _tzinfo_to_j_time_zone(tz, tz.utcoffset(None) if tz else None) + return _tzinfo_to_j_time_zone(tz) elif isinstance(tz, datetime.datetime): tzi = tz.tzinfo - rst = _tzinfo_to_j_time_zone(tzi, tzi.utcoffset(tz) if tzi else None) + rst = _tzinfo_to_j_time_zone(tzi) if not rst: raise ValueError("datetime is not time zone aware") diff --git a/py/server/tests/test_time.py b/py/server/tests/test_time.py index 907638ca081..b04a8da5506 100644 --- a/py/server/tests/test_time.py +++ b/py/server/tests/test_time.py @@ -1,10 +1,11 @@ # -# Copyright (c) 2016-2022 Deephaven Data Labs and Patent Pending +# Copyright (c) 2016-2024 Deephaven Data Labs and Patent Pending # import unittest from time import sleep import datetime +import zoneinfo import pandas as pd import numpy as np @@ -118,6 +119,11 @@ def test_to_j_time_zone(self): to_j_time_zone(dt) self.fail("Expected DHError") + dttz = zoneinfo.ZoneInfo("America/New_York") + dt = datetime.datetime(2022, 7, 7, 14, 21, 17, 123456, tzinfo=dttz) + self.assertEqual(to_j_time_zone(dttz), to_j_time_zone("America/New_York")) + self.assertEqual(to_j_time_zone(dt), to_j_time_zone("America/New_York")) + with self.assertRaises(TypeError): to_j_time_zone(False) self.fail("Expected TypeError")