From e5b78e2378a4db9d6e976d7dc569af13f902ccee Mon Sep 17 00:00:00 2001 From: Chip Kent <5250374+chipkent@users.noreply.github.com> Date: Mon, 18 Mar 2024 14:14:05 -0600 Subject: [PATCH] Make python timezone conversions handle more cases (#5249) * Make python timezone conversions handle more cases. Resolves #4723 * Responding to review. New unit test. * Responding to review. More careful support for time zone types. --- py/server/deephaven/time.py | 67 +++++++++++++++++++++++++++++++++--- py/server/tests/test_time.py | 35 +++++++++++++++++-- 2 files changed, 96 insertions(+), 6 deletions(-) diff --git a/py/server/deephaven/time.py b/py/server/deephaven/time.py index d3789aa1be1..12731f20738 100644 --- a/py/server/deephaven/time.py +++ b/py/server/deephaven/time.py @@ -5,6 +5,8 @@ """ This module defines functions for handling Deephaven date/time data. """ import datetime +import zoneinfo +import pytz from typing import Union, Optional, Literal import jpy @@ -163,6 +165,60 @@ def time_zone_alias_rm(alias: str) -> bool: # region Conversions: Python To Java +def _tzinfo_to_j_time_zone(tzi: datetime.tzinfo) -> TimeZone: + """ + Converts a Python time zone to a Java TimeZone. + + Args: + tzi: time zone info + + Returns: + Java TimeZone + """ + + if not tzi: + return None + + # 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) + + # Handle constant UTC offset time zones (datetime.timezone) + + if isinstance(tzi, datetime.timezone): + offset = tzi.utcoffset(None) + + if offset is None: + raise ValueError("Unable to determine the time zone UTC offset") + + 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 + + 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]) -> \ Optional[TimeZone]: @@ -190,12 +246,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) 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) + + 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 371d85fca8d..b04a8da5506 100644 --- a/py/server/tests/test_time.py +++ b/py/server/tests/test_time.py @@ -5,6 +5,7 @@ import unittest from time import sleep import datetime +import zoneinfo import pandas as pd import numpy as np @@ -72,8 +73,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 +95,35 @@ 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")) + + 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") + + 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")