From 8d34875b8ca081f3b1d0645b58d4f731d40b951f Mon Sep 17 00:00:00 2001 From: Dirk Butson Date: Fri, 30 May 2025 18:25:02 -0400 Subject: [PATCH] Support timezone-aware datetime objects connection: - Added support for explicit TimeZone connection property datatype: - Use Julian calendar for date/datetime before 1582 Oct 4. When the Gregorian calendar was introduced: . The day after October 4, 1582 (Julian) became October 15, 1582 (Gregorian). . That 10-day jump is not accounted for in C's calendar logic. . Julian calendar handles leap years differently. The utc calendar the engine is using understands this the python functions based on C calendar logic do not. - time.mktime , time.localtime don't support older dates while engine does changing to use calendar above and calculate time, avoid these issues. - use of datetime + timezone should handle daylight savings times and not be off by an hour. tests: - Use connection property TimeZone to change timezone - Support timezone-aware datetime - Use pytz correctly with localize if pytz is used - Use zoneinfo.ZoneInfo if available - added some more tests encodedsession: - Manage and verify TimeZone settings on per connection basis - Allows connections with different TimeZone setting from same application requirements.txt: - Add requirement for tzlocal and jdcal should be available on all supported python versions. There is a change in api between releases , this is handled in the code. --- pynuodb/calendar.py | 86 +++++++ pynuodb/connection.py | 34 ++- pynuodb/datatype.py | 202 ++++++++++++---- pynuodb/encodedsession.py | 90 ++++++- requirements.txt | 2 + setup.py | 2 +- tests/dbapi20.py | 6 +- tests/mock_tzs.py | 108 ++++----- tests/nuodb_basic_test.py | 97 +++----- tests/nuodb_date_time_test.py | 428 ++++++++++++++++++++++++++++++++++ tests/nuodb_types_test.py | 7 +- 11 files changed, 882 insertions(+), 180 deletions(-) create mode 100644 pynuodb/calendar.py create mode 100644 tests/nuodb_date_time_test.py diff --git a/pynuodb/calendar.py b/pynuodb/calendar.py new file mode 100644 index 0000000..c147b4e --- /dev/null +++ b/pynuodb/calendar.py @@ -0,0 +1,86 @@ +"""A module to calculate date from number of days from 1/1/1970. +This uses the Georgian Calendar for dates from 10/15/1582 and +the Julian Calendar fro dates before 10/4/1582. + +(C) Copyright 2013-2025 Dassault Systemes SE. All Rights Reserved. + +This software is licensed under a BSD 3-Clause License. +See the LICENSE file provided with this software. + +Calendar functions for computing year,month,day relative to number +of days from unix epoch (1/1/1970) + - Georgian Calendar for dates from and including 10/15/1582. + - Julian Calendar for dates before and including 10/4/1582. + +10/5/1582 - 10/14/1582 are invalid dates. These functions are needed +to map dates same as the calendar function in the nuodb server. python +datetime uses a proleptic Gregorian calendar. + +""" +from typing import Tuple # pylint: disable=unused-import +import jdcal + +JD_EPOCH = sum(jdcal.gcal2jd(1970, 1, 1)) +GREGORIAN_START = (1582, 10, 15) +JULIAN_END = (1582, 10, 4) + + +def ymd2day(year, month, day): + # type: (int, int, int) -> int + """ + Converts given year , month, day to number of days since unix EPOCH. + year - between 0001-9999 + month - 1 - 12 + day - 1 - 31 (depending upon month and year) + The calculation will be based upon: + - Georgian Calendar for dates from and including 10/15/1582. + - Julian Calendar for dates before and including 10/4/1582. + Dates between the Julian Calendar and Georgian Calendar don't exist a + ValueError will be raised. + """ + + if (year, month, day) >= GREGORIAN_START: + jd = sum(jdcal.gcal2jd(year, month, day)) + elif (year, month, day) <= JULIAN_END: + jd = sum(jdcal.jcal2jd(year, month, day)) + else: + raise ValueError("Invalid date: the range Oct 5-14, 1582 does not exist") + + daynum = int(jd - JD_EPOCH) + if daynum < -719164: + raise ValueError("Invalid date: before 1/1/1") + if daynum > 2932896: + raise ValueError("Invalid date: after 9999/12/31") + return daynum + + +def day2ymd(daynum): + # type: (int) -> Tuple[int, int, int] + """ + Converts given day number relative to 1970-01-01 to a tuple (year,month,day). + + + The calculation will be based upon: + - Georgian Calendar for dates from and including 10/15/1582. + - Julian Calendar for dates before and including 10/4/1582. + + Dates between the Julian Calendar and Georgian Calendar do not exist. + + +----------------------------+ + | daynum | (year,month,day) | + |---------+------------------| + | 0 | (1970,1,1) | + | -141427 | (1582,10,15) | + | -141428 | (1582,10,4) | + | -719164 | (1,1,1) | + | 2932896 | (9999,12,31) | + +----------------------------+ + """ + if daynum >= -141427 and daynum <= 2932896: + y, m, d, _ = jdcal.jd2gcal(daynum, JD_EPOCH) + elif daynum < -141427 and daynum >= -719614: + y, m, d, _ = jdcal.jd2jcal(daynum, JD_EPOCH) + else: + raise ValueError("Invalid daynum (not between 1/1/1 and 12/31/9999 inclusive).") + + return y, m, d diff --git a/pynuodb/connection.py b/pynuodb/connection.py index 4d99ae6..0658903 100644 --- a/pynuodb/connection.py +++ b/pynuodb/connection.py @@ -17,7 +17,6 @@ import os import copy -import time import xml.etree.ElementTree as ElementTree try: @@ -25,6 +24,8 @@ except ImportError: pass +import tzlocal + from . import __version__ from .exception import Error, InterfaceError from .session import SessionException @@ -161,10 +162,13 @@ def __init__(self, database=None, # type: Optional[str] host, port=port, options=options, **kwargs) self.__session.doConnect(params) - params.update({'user': user, - 'timezone': time.strftime('%Z'), - 'clientProcessId': str(os.getpid())}) + # updates params['TimeZone'] if not set and returns + # loalzone_name either params['TimeZone'] or based + # upon tzlocal. + localzone_name = self._init_local_timezone(params) + params.update({'user': user, 'clientProcessId': str(os.getpid())}) + self.__session.timezone_name = localzone_name self.__session.open_database(database, password, params) self.__config['client_protocol_id'] = self.__session.protocol_id @@ -186,6 +190,28 @@ def __init__(self, database=None, # type: Optional[str] else: self.setautocommit(False) + @staticmethod + def _init_local_timezone(params): + # type: (Dict[str, str]) -> str + # params['timezone'] updated if not set + # returns timezone + localzone_name = None + for k, v in params.items(): + if k.lower() == 'timezone': + localzone_name = v + break + if localzone_name is None: + if hasattr(tzlocal, 'get_localzone_name'): + # tzlocal >= 3.0 + params['timezone'] = tzlocal.get_localzone_name() + else: + # tzlocal < 3.0 + local_tz = tzlocal.get_localzone() + if local_tz: + params['timezone'] = getattr(local_tz, 'zone') + localzone_name = params['timezone'] + return localzone_name + @staticmethod def _getTE(admin, attributes, options): # type: (str, Mapping[str, str], Mapping[str, str]) -> Tuple[str, int] diff --git a/pynuodb/datatype.py b/pynuodb/datatype.py index 510382e..9f937b5 100644 --- a/pynuodb/datatype.py +++ b/pynuodb/datatype.py @@ -32,19 +32,60 @@ import sys import decimal -import time - from datetime import datetime as Timestamp, date as Date, time as Time from datetime import timedelta as TimeDelta +from datetime import tzinfo # pylint: disable=unused-import try: from typing import Tuple, Union # pylint: disable=unused-import except ImportError: pass +import tzlocal from .exception import DataError +from .calendar import ymd2day, day2ymd + +# zoneinfo.ZoneInfo is preferred but not introduced into python3.9 +if sys.version_info >= (3, 9): + # used for python>=3.9 with support for zoneinfo.ZoneInfo + from zoneinfo import ZoneInfo # pylint: disable=unused-import + from datetime import timezone + UTC = timezone.utc + + def utc_TimeStamp(year, month, day, hour=0, minute=0, second=0, microsecond=0): + # type: (int, int, int, int, int, int, int) -> Timestamp + """ + timezone aware datetime with UTC timezone. + """ + return Timestamp(year=year, month=month, day=day, + hour=hour, minute=minute, second=second, + microsecond=microsecond, tzinfo=UTC) + + def timezone_aware(tstamp, tz_info): + # type: (Timestamp, tzinfo) -> Timestamp + return tstamp.replace(tzinfo=tz_info) + +else: + # used for python<3.9 without support for zoneinfo.ZoneInfo + from pytz import utc as UTC + + def utc_TimeStamp(year, month, day, hour=0, minute=0, second=0, microsecond=0): + # type: (int, int, int, int, int, int, int) -> Timestamp + """ + timezone aware datetime with UTC timezone. + """ + dt = Timestamp(year=year, month=month, day=day, + hour=hour, minute=minute, second=second, microsecond=microsecond) + return UTC.localize(dt, is_dst=None) + + def timezone_aware(tstamp, tz_info): + # type: (Timestamp, tzinfo) -> Timestamp + return tz_info.localize(tstamp, is_dst=None) # type: ignore[attr-defined] + isP2 = sys.version[0] == '2' +TICKSDAY = 86400 +LOCALZONE = tzlocal.get_localzone() class Binary(bytes): @@ -83,56 +124,137 @@ def string(self): def DateFromTicks(ticks): # type: (int) -> Date """Convert ticks to a Date object.""" - return Date(*time.localtime(ticks)[:3]) + y, m, d = day2ymd(ticks // TICKSDAY) + return Date(year=y, month=m, day=d) -def TimeFromTicks(ticks, micro=0): - # type: (int, int) -> Time +def TimeFromTicks(ticks, micro=0, zoneinfo=LOCALZONE): + # type: (int, int, tzinfo) -> Time """Convert ticks to a Time object.""" - return Time(*time.localtime(ticks)[3:6] + (micro,)) - -def TimestampFromTicks(ticks, micro=0): - # type: (int, int) -> Timestamp + # NuoDB release <= 7.0, it's possible that ticks is + # expressed as a Timestamp and not just a Time. + # NuoDB release > 7.0, ticks will be between (-TICKSDAY,2*TICKSDAY) + + if ticks < -TICKSDAY or ticks > 2 * TICKSDAY: + dt = TimestampFromTicks(ticks, micro, zoneinfo) + return dt.time() + + seconds = ticks % TICKSDAY + hours = (seconds // 3600) % 24 + minutes = (seconds // 60) % 60 + seconds = seconds % 60 + tstamp = Timestamp.combine(Date(1970, 1, 1), + Time(hour=hours, + minute=minutes, + second=seconds, + microsecond=micro) + ) + # remove offset that the engine added + utcoffset = zoneinfo.utcoffset(tstamp) + if utcoffset: + tstamp += utcoffset + # returns naive time , should a timezone-aware time be returned instead + return tstamp.time() + + +def TimestampFromTicks(ticks, micro=0, zoneinfo=LOCALZONE): + # type: (int, int, tzinfo) -> Timestamp """Convert ticks to a Timestamp object.""" - return Timestamp(*time.localtime(ticks)[:6] + (micro,)) + day = ticks // TICKSDAY + y, m, d = day2ymd(day) + timeticks = ticks % TICKSDAY + hour = timeticks // 3600 + sec = timeticks % 3600 + min = sec // 60 + sec %= 60 + + # this requires both utc and current session to be between year 1 and year 9999 inclusive. + # nuodb could store a timestamp that is east of utc where utc would be year 10000. + if y < 10000: + dt = utc_TimeStamp(year=y, month=m, day=d, hour=hour, + minute=min, second=sec, microsecond=micro) + dt = dt.astimezone(zoneinfo) + else: + # shift one day. + dt = utc_TimeStamp(year=9999, month=12, day=31, hour=hour, + minute=min, second=sec, microsecond=micro) + dt = dt.astimezone(zoneinfo) + # add day back. + dt += TimeDelta(days=1) + # returns timezone-aware datetime + return dt def DateToTicks(value): # type: (Date) -> int """Convert a Date object to ticks.""" - timeStruct = Date(value.year, value.month, value.day).timetuple() - try: - return int(time.mktime(timeStruct)) - except Exception: - raise DataError("Year out of range") - - -def TimeToTicks(value): - # type: (Time) -> Tuple[int, int] + day = ymd2day(value.year, value.month, value.day) + return day * TICKSDAY + + +def _packtime(seconds, microseconds): + # type: (int, int) -> Tuple[int,int] + if microseconds: + ndiv = 0 + shiftr = 1000000 + shiftl = 1 + while (microseconds % shiftr): + shiftr //= 10 + shiftl *= 10 + ndiv += 1 + return (seconds * shiftl + microseconds // shiftr, ndiv) + else: + return (seconds, 0) + + +def TimeToTicks(value, zoneinfo=LOCALZONE): + # type: (Time, tzinfo) -> Tuple[int, int] """Convert a Time object to ticks.""" - timeStruct = TimeDelta(hours=value.hour, minutes=value.minute, - seconds=value.second, - microseconds=value.microsecond) - timeDec = decimal.Decimal(str(timeStruct.total_seconds())) - return (int((timeDec + time.timezone) * 10**abs(timeDec.as_tuple()[2])), - abs(timeDec.as_tuple()[2])) - - -def TimestampToTicks(value): - # type: (Timestamp) -> Tuple[int, int] + epoch = Date(1970, 1, 1) + tz_info = value.tzinfo + if not tz_info: + tz_info = zoneinfo + + my_time = Timestamp.combine(epoch, Time(hour=value.hour, + minute=value.minute, + second=value.second, + microsecond=value.microsecond + )) + my_time = timezone_aware(my_time, tz_info) + + utc_time = Timestamp.combine(epoch, Time()) + utc_time = timezone_aware(utc_time, UTC) + + td = my_time - utc_time + + # fence time within a day range + if td < TimeDelta(0): + td = td + TimeDelta(days=1) + if td > TimeDelta(days=1): + td = td - TimeDelta(days=1) + + time_dec = decimal.Decimal(str(td.total_seconds())) + exponent = time_dec.as_tuple()[2] + if not isinstance(exponent, int): + # this should not occur + raise ValueError("Invalid exponent in Decimal: %r" % exponent) + return (int(time_dec * 10**abs(exponent)), abs(exponent)) + +def TimestampToTicks(value, zoneinfo=LOCALZONE): + # type: (Timestamp, tzinfo) -> Tuple[int, int] """Convert a Timestamp object to ticks.""" - timeStruct = Timestamp(value.year, value.month, value.day, value.hour, - value.minute, value.second).timetuple() - try: - if not value.microsecond: - return (int(time.mktime(timeStruct)), 0) - micro = decimal.Decimal(value.microsecond) / decimal.Decimal(1000000) - t1 = decimal.Decimal(int(time.mktime(timeStruct))) + micro - tlen = len(str(micro)) - 2 - return (int(t1 * decimal.Decimal(int(10**tlen))), tlen) - except Exception: - raise DataError("Year out of range") + # if naive timezone then leave date/time but change tzinfo to + # be connection's timezone. + if value.tzinfo is None: + value = timezone_aware(value, zoneinfo) + dt = value.astimezone(UTC) + timesecs = ymd2day(dt.year, dt.month, dt.day) * TICKSDAY + timesecs += dt.hour * 3600 + timesecs += dt.minute * 60 + timesecs += dt.second + packedtime = _packtime(timesecs, dt.microsecond) + return packedtime class TypeObject(object): diff --git a/pynuodb/encodedsession.py b/pynuodb/encodedsession.py index bf92c11..837df69 100644 --- a/pynuodb/encodedsession.py +++ b/pynuodb/encodedsession.py @@ -16,6 +16,7 @@ import decimal import sys import threading +import datetime # pylint: disable=unused-import try: from typing import Any, Collection, Dict, List # pylint: disable=unused-import @@ -23,6 +24,8 @@ except ImportError: pass +import tzlocal + from .exception import DataError, EndOfStream, ProgrammingError from .exception import db_error_handler, BatchError from .session import SessionException @@ -34,6 +37,14 @@ from . import statement from . import result_set +# ZoneInfo is preferred but not introduced until 3.9 +if sys.version_info >= (3, 9): + # preferred python >= 3.9 + from zoneinfo import ZoneInfo +else: + # fallback to pytz if python < 3.9 + from pytz import timezone as ZoneInfo + isP2 = sys.version[0] == '2' REMOVE_FORMAT = 0 @@ -113,6 +124,9 @@ class EncodedSession(session.Session): # pylint: disable=too-many-public-method __dblock = threading.Lock() __databases = {} # type: Dict[str, Dict[int, Tuple[int, int]]] + # timezone to use for this connection, set on open database + __timezone_name = '' # type: str + @staticmethod def reset(): # type: () -> None @@ -159,6 +173,38 @@ def __init__(self, host, service='SQL2', options=None, **kwargs): self.__encryption = False super(EncodedSession, self).__init__(host, service=service, options=options, **kwargs) + if hasattr(tzlocal, 'get_localzone_name'): + # tzlocal >= 3.0 + self.__timezone_name = tzlocal.get_localzone_name() + else: + # tzlocal < 3.0 + local_tz = tzlocal.get_localzone() + self.__timezone_name = getattr(local_tz, 'zone') + + @property + def timezone_name(self): + # type: () -> Optional[str] + """ read name of timezone for this connection """ + return self.__timezone_name + + @timezone_name.setter + def timezone_name(self, tzname): + # type: (str) -> None + try: + # fails if tzname is bad + ZoneInfo(tzname) + except KeyError: + raise ProgrammingError('Invalid TimeZone ' + tzname) + except LookupError: + raise ProgrammingError('Invalid TimeZone ' + tzname) + self.__timezone_name = tzname + + @property + def timezone_info(self): + # type: () -> datetime.tzinfo + """ get a tzinfo for this connection """ + tz_info = ZoneInfo(self.__timezone_name) + return tz_info def open_database(self, db_name, password, parameters): # pylint: disable=too-many-branches,too-many-statements # type: (str, str, Dict[str, str]) -> None @@ -576,7 +622,11 @@ def putScaledInt(self, value): """ # Convert the decimal's notation into decimal value += REMOVE_FORMAT - scale = abs(value.as_tuple()[2]) + exponent = value.as_tuple()[2] + if not isinstance(exponent, int): + # this should not occur + raise ValueError("Invalid exponent in Decimal: %r" % exponent) + scale = abs(exponent) data = crypt.toSignedByteString(int(value * decimal.Decimal(10**scale))) # If our length including the tag is more than 9 bytes we will need to @@ -738,7 +788,7 @@ def putScaledTime(self, value): :type value: datetype.Time """ return self._putScaled(protocol.SCALEDTIMELEN0, - *datatype.TimeToTicks(value)) + *datatype.TimeToTicks(value, self.timezone_info)) def putScaledTimestamp(self, value): # type: (datatype.Timestamp) -> EncodedSession @@ -747,7 +797,7 @@ def putScaledTimestamp(self, value): :type value: datetime.datetime """ return self._putScaled(protocol.SCALEDTIMESTAMPLEN0, - *datatype.TimestampToTicks(value)) + *datatype.TimestampToTicks(value, self.timezone_info)) def putScaledDate(self, value): # type: (datatype.Date) -> EncodedSession @@ -764,7 +814,12 @@ def putScaledCount2(self, value): :type value: decimal.Decimal """ - scale = abs(value.as_tuple()[2]) + exponent = value.as_tuple()[2] + if not isinstance(exponent, int): + # this should not occur + raise ValueError("Invalid exponent in Decimal: %r" % exponent) + scale = abs(exponent) + sign = 1 if value.as_tuple()[0] == 0 else -1 signData = crypt.toSignedByteString(sign) data = crypt.toByteString(int(abs(value) * decimal.Decimal(10**scale))) @@ -974,6 +1029,21 @@ def getClob(self): raise DataError('Not a clob') + @staticmethod + def __unpack(scale, time): + # type: (int, int) -> Tuple[int, int] + shiftr = 10 ** scale + ticks = time // shiftr + fraction = time % shiftr + if scale > 6: + micros = fraction // 10 ** (scale - 6) + else: + micros = fraction * 10 ** (6 - scale) + if micros < 0: + micros %= 1000000 + ticks += 1 + return (ticks, micros) + def getScaledTime(self): # type: () -> datatype.Time """Read the next Scaled Time value off the session. @@ -985,9 +1055,8 @@ def getScaledTime(self): if code >= protocol.SCALEDTIMELEN1 and code <= protocol.SCALEDTIMELEN8: scale = crypt.fromByteString(self._takeBytes(1)) time = crypt.fromSignedByteString(self._takeBytes(code - protocol.SCALEDTIMELEN0)) - ticks = decimal.Decimal(time) / decimal.Decimal(10**scale) - return datatype.TimeFromTicks(round(int(ticks)), - int((ticks % 1) * decimal.Decimal(1000000))) + seconds, micros = self.__unpack(scale, time) + return datatype.TimeFromTicks(seconds, micros, self.timezone_info) raise DataError('Not a scaled time') @@ -1002,9 +1071,8 @@ def getScaledTimestamp(self): if code >= protocol.SCALEDTIMESTAMPLEN1 and code <= protocol.SCALEDTIMESTAMPLEN8: scale = crypt.fromByteString(self._takeBytes(1)) stamp = crypt.fromSignedByteString(self._takeBytes(code - protocol.SCALEDTIMESTAMPLEN0)) - ticks = decimal.Decimal(stamp) / decimal.Decimal(10**scale) - return datatype.TimestampFromTicks(round(int(ticks)), - int((ticks % 1) * decimal.Decimal(1000000))) + seconds, micros = self.__unpack(scale, stamp) + return datatype.TimestampFromTicks(seconds, micros, self.timezone_info) raise DataError('Not a scaled timestamp') @@ -1019,7 +1087,7 @@ def getScaledDate(self): if code >= protocol.SCALEDDATELEN1 and code <= protocol.SCALEDDATELEN8: scale = crypt.fromByteString(self._takeBytes(1)) date = crypt.fromSignedByteString(self._takeBytes(code - protocol.SCALEDDATELEN0)) - return datatype.DateFromTicks(round(date / 10.0 ** scale)) + return datatype.DateFromTicks(date // (10 ** scale)) raise DataError('Not a scaled date') diff --git a/requirements.txt b/requirements.txt index 432c719..93ede57 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,3 @@ +tzlocal pytz>=2015.4 +jdcal>=1.4.1 diff --git a/setup.py b/setup.py index 4955f52..8b2d0e9 100644 --- a/setup.py +++ b/setup.py @@ -43,7 +43,7 @@ url='https://github.com/nuodb/nuodb-python', license='BSD License', long_description=open(readme).read(), - install_requires=['pytz>=2015.4', 'ipaddress'], + install_requires=['pytz>=2015.4', 'ipaddress', 'tzlocal', 'jdcal'], extras_require=dict(crypto='cryptography>=2.6.1'), classifiers=[ 'Development Status :: 5 - Production/Stable', diff --git a/tests/dbapi20.py b/tests/dbapi20.py index 151a145..5f95c52 100644 --- a/tests/dbapi20.py +++ b/tests/dbapi20.py @@ -802,20 +802,20 @@ def test_None(self): def test_Date(self): d1 = self.driver.Date(2002,12,25) - d2 = self.driver.DateFromTicks(time.mktime((2002,12,25,0,0,0,0,0,0))) + d2 = self.driver.DateFromTicks(int(time.mktime((2002,12,25,0,0,0,0,0,0)))) # Can we assume this? API doesn't specify, but it seems implied # self.assertEqual(str(d1),str(d2)) def test_Time(self): t1 = self.driver.Time(13,45,30) - t2 = self.driver.TimeFromTicks(time.mktime((2001,1,1,13,45,30,0,0,0))) + t2 = self.driver.TimeFromTicks(int(time.mktime((2001,1,1,13,45,30,0,0,0)))) # Can we assume this? API doesn't specify, but it seems implied # self.assertEqual(str(t1),str(t2)) def test_Timestamp(self): t1 = self.driver.Timestamp(2002,12,25,13,45,30) t2 = self.driver.TimestampFromTicks( - time.mktime((2002,12,25,13,45,30,0,0,0)) + int(time.mktime((2002,12,25,13,45,30,0,0,0))) ) # Can we assume this? API doesn't specify, but it seems implied # self.assertEqual(str(t1),str(t2)) diff --git a/tests/mock_tzs.py b/tests/mock_tzs.py index d149f9b..0ce8639 100644 --- a/tests/mock_tzs.py +++ b/tests/mock_tzs.py @@ -1,65 +1,55 @@ -#!/usr/bin/env python # -*- coding: utf-8 -*- +""" +(C) Copyright 2013-2025 Dassault Systemes SE. All Rights Reserved. -from datetime import tzinfo -from datetime import datetime -import os +This software is licensed under a BSD 3-Clause License. +See the LICENSE file provided with this software. +""" +from datetime import tzinfo, datetime +import sys +import typing +import tzlocal import pytz -import pynuodb - -if os.path.exists('/etc/timezone'): - with open('/etc/timezone') as tzf: - Local = pytz.timezone(tzf.read().strip()) +if hasattr(tzlocal, 'get_localzone_name'): + # tzlocal >= 3.0 + localzone_name = tzlocal.get_localzone_name() else: - with open('/etc/localtime', 'rb') as tlf: - Local = pytz.build_tzinfo('localtime', tlf) # type: ignore - -UTC = pytz.timezone('UTC') - - -class _MyOffset(tzinfo): - ''' - A timezone class that uses the current offset for all times in the past and - future. The database doesn't return an timezone offset to us, it just - returns the timestamp it has, but cast into the client's current timezone. - This class can be used to do exactly the same thing to the test val. - ''' - def utcoffset(self, dt): - return Local.localize(datetime.now()).utcoffset() - - -MyOffset = _MyOffset() - - -class EscapingTimestamp(pynuodb.Timestamp): - ''' - An EscapingTimestamp is just like a regular pynuodb.Timestamp, except that - it's string representation is a bit of executable SQL that constructs the - correct timestamp on the server side. This is necessary until [DB-2251] is - fixed and we can interpret straight strings of the kind that - pynuodb.Timestamp produces. - ''' - py2sql = { - '%Y': 'YYYY', - '%m': 'MM', - '%d': 'dd', - '%H': 'HH', - '%M': 'mm', - '%S': 'ss', - '%f000': 'SSSSSSSSS', - '%z': 'ZZZZ'} - - def __str__(self): - pyformat = '%Y-%m-%d %H:%M:%S.%f000 %z' - sqlformat = pyformat - for pyspec, sqlspec in self.py2sql.items(): - sqlformat = sqlformat.replace(pyspec, sqlspec) - return "DATE_FROM_STR('%s', '%s')" % (self.strftime(pyformat), sqlformat) - - -if __name__ == '__main__': - print(str(EscapingTimestamp(2014, 7, 15, 23, 59, 58, 72, Local))) - print(repr(EscapingTimestamp(2014, 7, 15, 23, 59, 58, 72, Local))) - print(str(EscapingTimestamp(2014, 12, 15, 23, 59, 58, 72, Local))) + # tzlocal < 3.0 + localzone_name = getattr(tzlocal.get_localzone(),'zone') + +try: + from zoneinfo import ZoneInfo + HAS_ZONEINFO = True +except ImportError: + HAS_ZONEINFO = False + +# Define a type for mypy/static typing +if typing.TYPE_CHECKING: + if sys.version_info >= (3, 9): + from zoneinfo import ZoneInfo as TZType + else: + from pytz.tzinfo import BaseTzInfo as TZType +else: + TZType = tzinfo + + +# Timezone getter function +def get_timezone(name): + # type: (str) -> TZType + """ get tzinfo by name """ + if HAS_ZONEINFO: + return ZoneInfo(name) # type: ignore[return-value] + return pytz.timezone(name) # type: ignore[return-value] + +UTC = get_timezone("UTC") +Local = get_timezone(localzone_name) +TimeZoneInfo = get_timezone + +def localize(dt, tzinfo=Local): # pylint: disable=redefined-outer-name + # type: (datetime, TZType) -> datetime + """ localize naive datetime with given timezone """ + if sys.version_info >= (3, 9): + return dt.replace(tzinfo=tzinfo) + return tzinfo.localize(dt, is_dst=None) diff --git a/tests/nuodb_basic_test.py b/tests/nuodb_basic_test.py index 7673fd3..91b086c 100644 --- a/tests/nuodb_basic_test.py +++ b/tests/nuodb_basic_test.py @@ -16,7 +16,7 @@ from pynuodb.exception import DataError from . import nuodb_base -from .mock_tzs import Local +from .mock_tzs import localize class TestNuoDBBasic(nuodb_base.NuoBase): @@ -498,8 +498,8 @@ def test_date_types(self): test_vals = ( pynuodb.Date(2008, 1, 1), pynuodb.Time(8, 13, 34), - pynuodb.Timestamp(2014, 12, 19, 14, 8, 30, 99, Local), - pynuodb.Timestamp(2014, 7, 23, 6, 22, 19, 88, Local), + localize(pynuodb.Timestamp(2014, 12, 19, 14, 8, 30, 99)), + localize(pynuodb.Timestamp(2014, 7, 23, 6, 22, 19, 88)), ) exc_str = ("insert into typetest (" "date_col, " @@ -666,66 +666,45 @@ def test_param_binary_types(self): finally: con.close() - @pytest.mark.skipif(sys.platform.startswith("win"), - reason="time.tzset() does not work on windows") def test_timezones(self): - oldtz = os.environ.get('TZ') - try: - os.environ['TZ'] = 'EST+05EDT,M4.1.0,M10.5.0' - time.tzset() - - con = self._connect() - cursor = con.cursor() - cursor.execute("drop table typetest if exists") - cursor.execute("create table typetest (id integer GENERATED ALWAYS AS IDENTITY, timestamp_col timestamp)") - vals = (pynuodb.Timestamp(2013, 5, 24, 0, 0, 1),) - cursor.execute("insert into typetest (timestamp_col) values (?)", vals) - con.commit() - con.close() - - os.environ['TZ'] = 'PST+08PDT,M4.1.0,M10.5.0' - time.tzset() - con = self._connect() - cursor = con.cursor() - cursor.execute("select * from typetest") - row = cursor.fetchone() - - assert vals[0].year == row[1].year - assert vals[0].month == row[1].month - assert vals[0].day == row[1].day + 1 - assert vals[0].hour == (row[1].hour + 3) % 24 - assert vals[0].minute == row[1].minute - assert vals[0].second == row[1].second - assert vals[0].microsecond == row[1].microsecond - con.close() - - os.environ['TZ'] = 'CET-01CST,M4.1.0,M10.5.0' - time.tzset() - con = self._connect() - cursor = con.cursor() - cursor.execute("select * from typetest") - row = cursor.fetchone() + con = self._connect(options={'TimeZone':'EST5EDT'}) + cursor = con.cursor() + cursor.execute("drop table typetest if exists") + cursor.execute("create table typetest (id integer GENERATED ALWAYS AS IDENTITY, timestamp_col timestamp)") + vals = (pynuodb.Timestamp(2013, 5, 24, 0, 0, 1),) + cursor.execute("insert into typetest (timestamp_col) values (?)", vals) + con.commit() + con.close() - assert vals[0].year == row[1].year - assert vals[0].month == row[1].month - assert vals[0].day == row[1].day - assert vals[0].hour == (row[1].hour - 6) % 24 - assert vals[0].minute == row[1].minute - assert vals[0].second == row[1].second - assert vals[0].microsecond == row[1].microsecond + con = self._connect(options={'TimeZone':'PST8PDT'}) + cursor = con.cursor() + cursor.execute("select * from typetest") + row = cursor.fetchone() + + assert vals[0].year == row[1].year + assert vals[0].month == row[1].month + assert vals[0].day == row[1].day + 1 + assert vals[0].hour == (row[1].hour + 3) % 24 + assert vals[0].minute == row[1].minute + assert vals[0].second == row[1].second + assert vals[0].microsecond == row[1].microsecond + con.close() + + con = self._connect(options={'TimeZone':'CET'}) + cursor = con.cursor() + cursor.execute("select * from typetest") + row = cursor.fetchone() - cursor.execute("drop table typetest if exists") - con.close() + assert vals[0].year == row[1].year + assert vals[0].month == row[1].month + assert vals[0].day == row[1].day + assert vals[0].hour == (row[1].hour - 6) % 24 + assert vals[0].minute == row[1].minute + assert vals[0].second == row[1].second + assert vals[0].microsecond == row[1].microsecond - finally: - try: - if oldtz is None: - os.environ.pop('TZ') - else: - os.environ['TZ'] = oldtz - time.tzset() - except Exception: - pass + cursor.execute("drop table typetest if exists") + con.close() def test_param_time_micro_types(self): con = self._connect() diff --git a/tests/nuodb_date_time_test.py b/tests/nuodb_date_time_test.py new file mode 100644 index 0000000..a19857c --- /dev/null +++ b/tests/nuodb_date_time_test.py @@ -0,0 +1,428 @@ +# -*- coding: utf-8 -*- +""" +(C) Copyright 2013-2025 Dassault Systemes SE. All Rights Reserved. + +This software is licensed under a BSD 3-Clause License. +See the LICENSE file provided with this software. +""" + +import datetime +from contextlib import closing +from pynuodb.exception import ProgrammingError +from .mock_tzs import localize, UTC, TimeZoneInfo + +from . import nuodb_base + + +class TestNuoDBDateTime(nuodb_base.NuoBase): + """Test datetime with timezone""" + + def test_connect_timezone(self): + # type: () -> None + """ test invalid TimeZone """ + try: + self._connect(options={'TimeZone': 'XYZ'}) + except ProgrammingError: + assert True + else: + assert False + + # another invalid timezone + # this would be handled okay by TE but, not understooded + # by client + try: + self._connect(options={'TimeZone': 'PDT'}) + except ProgrammingError: + assert True + else: + assert False + + def test_nonnaive_timestamps(self): + # type: () -> None + """Test using different timezones with same connection""" + + dt = datetime.datetime(year=1990, month=1, day=1, hour=1, minute=30, second=10) + + # Local timezone is unknown, depends where we run from + + utc_dt = localize(dt, UTC) + local_dt = localize(dt, TimeZoneInfo('America/New_York')) + pst_dt = localize(dt, TimeZoneInfo('America/Los_Angeles')) + + with closing(self._connect(options={'TimeZone': 'America/Chicago'})) as con: + cursor = con.cursor() + cursor.execute("drop table if exists NONNAIVE") + cursor.execute("create table NONNAIVE(tstamp datetime, dtstr string)") + cursor.executemany("insert into NONNAIVE VALUES (?,?)", [ + (utc_dt, utc_dt.isoformat(),), + (local_dt, local_dt.isoformat(),), + (pst_dt, pst_dt.isoformat(),) + ]) + con.commit() + cursor.execute("select tstamp,dtstr from NONNAIVE") + rows = cursor.fetchall() + + # given a timezone, these should equal although the returned + # row is actually connection timezone. + assert rows[0][0] == utc_dt + assert rows[1][0] == local_dt + assert rows[2][0] == pst_dt + assert rows[2][0] - rows[1][0] == datetime.timedelta(seconds=10800) + + assert rows[0][0].astimezone(UTC).isoformat() == rows[0][1] + assert rows[1][0].astimezone(TimeZoneInfo('America/New_York')).isoformat() == rows[1][1] + assert rows[2][0].astimezone(TimeZoneInfo('America/Los_Angeles')).isoformat() == rows[2][1] + + # timezone of return datetime should be same as timezone of connection + tz_info = rows[0][0].tzinfo + if hasattr(tz_info, 'key'): + assert tz_info.key == 'America/Chicago' + if hasattr(tz_info, 'zone'): + assert tz_info.zone == 'America/Chicago' + + def test_pre_1900_date(self): + # type: () -> None + """Test read dates before 1900, did not previous work""" + + with closing(self._connect(options={'TimeZone': 'EST5EDT'})) as con: + cursor = con.cursor() + cursor.execute("create temporary table HISTORY(day date)") + cursor.execute("insert into HISTORY VALUES ('November 19, 1863')") + cursor.execute("select * from HISTORY") + row = cursor.fetchone() + + assert row[0].year == 1863 + assert row[0].month == 11 + assert row[0].day == 19 + + cursor.execute("delete from HISTORY") + cursor.execute("insert into HISTORY VALUES (?)", + (datetime.date(year=1865, month=4, day=14),)) + cursor.execute("select * from HISTORY") + row = cursor.fetchone() + + assert row[0].year == 1865 + assert row[0].month == 4 + assert row[0].day == 14 + + def test_daylight_savings_time(self): + # type: () -> None + """Test read dates either in daylight saving time or not""" + + + with closing(self._connect(options={'TimeZone': 'America/New_York'})) as con: + cursor = con.cursor() + + tz = TimeZoneInfo('America/New_York') + + cursor.execute("select TIMESTAMP'2010-01-01 20:01:21' from DUAL") + row = cursor.fetchone() + nytime = row[0].astimezone(tz) + assert nytime.hour == 20 + assert nytime.dst() == datetime.timedelta(seconds=0) + + cursor.execute("select TIMESTAMP'2010-06-01 20:01:21' from DUAL") + row = cursor.fetchone() + nytime = row[0].astimezone(tz) + assert nytime.hour == 20 + + with closing(self._connect(options={'TimeZone': 'Pacific/Auckland'})) as con: + tz = TimeZoneInfo('Pacific/Auckland') + cursor = con.cursor() + cursor.execute("select TIMESTAMP'2010-01-01 20:01:21' from DUAL") + row = cursor.fetchone() + nztime = row[0] + assert nztime.hour == 20 + + cursor.execute("select TIMESTAMP'2010-06-01 20:01:21' from DUAL") + row = cursor.fetchone() + nztime = row[0] + assert nztime.hour == 20 + assert nztime.dst() == datetime.timedelta(seconds=0) + + + def test_gregorian_date(self): + # type: () -> None + """ + python datetime is based on the proleptic Gregorian + calendar, which extends the Gregorian calendar backward before + its actual adoption. To handle same as NuoDB engine we need + to use Julian calendar before Oct 5, 1582 and Gregorian + calendar after Oct 14, 1582. Note, Oct 5-14, 1582 are not + valid dates per the switch over. + """ + + with closing(self._connect(options={'TimeZone': 'EST5EDT'})) as con: + cursor = con.cursor() + ddl = ( + "create temporary table HISTORY(day string, " + "asdate DATE GENERATED ALWAYS AS (DATE(day)) PERSISTED)" + ) + cursor.execute(ddl) + cursor.execute("insert into HISTORY VALUES ('October 15, 1582')") + cursor.execute("insert into HISTORY VALUES ('October 1, 1582')") + cursor.execute("select DATE(day),asdate from HISTORY") + row = cursor.fetchone() + assert row[0].year == 1582 + assert row[0].month == 10 + assert row[0].day == 15 + assert row[1].year == 1582 + assert row[1].month == 10 + assert row[1].day == 15 + + row = cursor.fetchone() + assert row[0].year == 1582 + assert row[0].month == 10 + assert row[0].day == 1 + assert row[1].year == 1582 + assert row[1].month == 10 + assert row[1].day == 1 + + def test_microseconds(self): + # type: () -> None + """Test timestamps with microseconds set before or after epoch""" + + with closing(self._connect(options={'TimeZone': 'EST5EDT'})) as con: + cursor = con.cursor() + + dt = datetime.datetime(year=1990, month=1, day=1, hour=1, + minute=30, second=10, microsecond=140) + utc_dt = localize(dt, UTC) + est_dt = utc_dt.astimezone(TimeZoneInfo('America/New_York')) + + cursor.execute("create temporary table T(ts TIMESTAMP)") + cursor.execute("insert into T VALUES(?)", (utc_dt,)) + cursor.execute("select ts, EXTRACT(MICROSECOND FROM ts) from T") + row = cursor.fetchone() + + assert row[0] == est_dt + assert row[0] == utc_dt + assert row[0].second == 10 + assert row[0].microsecond == 140 + assert row[1] == 140 + + # less than datetime epoch + dt = datetime.datetime(year=1969, month=1, day=1, hour=1, + minute=30, second=10, microsecond=140) + utc_dt = localize(dt, UTC) + est_dt = utc_dt.astimezone(TimeZoneInfo('America/New_York')) + + cursor.execute("delete from T") + cursor.execute("insert into T VALUES(?)", (utc_dt,)) + cursor.execute("select ts, EXTRACT(MICROSECOND FROM ts) from T") + row = cursor.fetchone() + assert row[0] == est_dt + assert row[0] == utc_dt + assert row[0].microsecond == 140 + assert row[0].second == 10 + # This fails but it's a bug with TE not driver + # assert row[1] == 140 + + def test_time_wraps_read(self): + + with closing(self._connect(options={'TimeZone': 'Pacific/Auckland'})) as con: + cursor = con.cursor() + # GMT is 1990-05-31 21:00:01.2 + cursor.execute("select CAST(TIMESTAMP'1990-06-01 9:00:01.2' AS TIME) from dual") + row = cursor.fetchone() + assert row[0].hour == 9 + assert row[0].minute == 0 + assert row[0].second == 1 + assert row[0].microsecond == 200000 + + + with closing(self._connect(options={'TimeZone': 'Pacific/Honolulu'})) as con: + cursor = con.cursor() + # GMT is 1990-06-02 05:00:01.2 + cursor.execute("select CAST(TIMESTAMP'1990-06-01 19:00:01.2' AS TIME) from dual") + row = cursor.fetchone() + assert row[0].hour == 19 + assert row[0].minute == 0 + assert row[0].second == 1 + assert row[0].microsecond == 200000 + + def test_time_wraps_west_write(self): + # saving time is very problematic. + # time does not store the timezone and + # the timezone is only useful when it's associated + # with a date. So that the offset for daylight savings + # can be accounted for. + # + # in this test the conn timezone is in previous day (west) from + # GMT. given a datetime we store that datetime (into a time field) + # where the input is either. + # 1. naive + # 2. connection timezone aware + # 3. gmt timezone aware + # 4. east of gmt + # + # we store the time using: + # 1. datetime + # 2. string with timezone + # 3. time + # + # case 1 -> te will map datetime to time + # case 2 -> te will map string to time + # case 3 -> client sends time object + # + # the test compares all of case 1 to see if returned + # time is the same. (should be as date included with + # time and timezone) + # + # then compare that what the client sent is same as what + # the server computed (2 compared to 3). These times + # might be off. as in both cases there is no date + # assocated with the timezone and neither client nor + # server can accurately account for daylight savings time. + + WESTTZ = "Pacific/Auckland" # no dst in 1970, dst now + CONNTZ = "America/Chicago" + GMT = "GMT" + + with closing(self._connect(options={'TimeZone': CONNTZ})) as con: + cursor = con.cursor() + cursor.execute('drop table WESTTZ if exists') + cursor.execute("create table WESTTZ ( t TIME, t_as_string TIME, dt TIME)") + + dt = datetime.datetime(year=1990, month=1, day=31, hour=21, + minute=0, second=1, microsecond=200000) + for month in [ 1, 7 ]: + dt = dt.replace(month=month) + dt_time = dt.time() + dt_time_str = dt_time.isoformat() + " " + CONNTZ + + conn_dt = localize(dt, TimeZoneInfo(CONNTZ)) + conn_time = conn_dt.timetz() + conn_time_str = conn_time.isoformat() + " " + CONNTZ + + utc_dt = conn_dt.astimezone(TimeZoneInfo(GMT)) + utc_time = utc_dt.timetz() + # isoformat for utc_time includes timezone info already + utc_time_str = utc_time.isoformat() + + west_dt = utc_dt.astimezone(TimeZoneInfo(WESTTZ)) + west_time = west_dt.timetz() + west_time_str = west_time.isoformat() + " " + WESTTZ + + cursor.execute('insert into WESTTZ VALUES (?,?,?)', + (dt_time, dt_time_str, dt,)) # naive time (dst northern) + cursor.execute('insert into WESTTZ VALUES (?,?,?)', + (conn_time, conn_time_str, conn_dt,)) # connection time (dst northern) + cursor.execute('insert into WESTTZ VALUES (?,?,?)', + (utc_time, utc_time_str, utc_dt,)) # gmt (no dst) + cursor.execute('insert into WESTTZ VALUES (?,?,?)', + (west_time, west_time_str, west_dt,)) # new zealand (dst southern) + + cursor.execute('select t, t_as_string, dt from WESTTZ') + rows = cursor.fetchall() + con.commit() + + # using timestamp to initialize time no issues with daylight savings time + assert rows[0][2] == rows[1][2] + assert rows[2][2] == rows[1][2] + assert rows[3][2] == rows[1][2] + assert rows[4][2] == rows[1][2] + assert rows[5][2] == rows[1][2] + assert rows[6][2] == rows[1][2] + assert rows[7][2] == rows[1][2] + assert rows[0][2].hour == 21 + assert rows[0][2].minute == 0 + assert rows[0][2].second == 1 + assert rows[0][2].microsecond == 200000 + + # confirm time created in client same as + # time created in te via string assignment + assert rows[0][0] == rows[0][1] + assert rows[1][0] == rows[1][1] + assert rows[2][0] == rows[2][1] + assert rows[3][0] == rows[3][1] + assert rows[4][0] == rows[4][1] + assert rows[5][0] == rows[5][1] + assert rows[6][0] == rows[6][1] + assert rows[7][0] == rows[7][1] + + # naive time should be same as connection time + assert rows[0][0] == rows[1][0] + assert rows[4][0] == rows[5][0] + assert rows[0][0].hour == 21 + assert rows[0][0].minute == 0 + assert rows[0][0].second == 1 + assert rows[0][0].microsecond == 200000 + + def test_time_wraps_east_write(self): + # same test as east but now connection timezone is east (next day) of GMT. + + CONNTZ = "Pacific/Auckland" + EASTTZ = "America/Chicago" + GMT = "GMT" + + with closing(self._connect(options={'TimeZone': CONNTZ})) as con: + cursor = con.cursor() + cursor.execute('drop table EASTTZ if exists') + cursor.execute("create table EASTTZ ( t TIME, t_as_string TIME, dt TIME)") + + dt = datetime.datetime(year=1990, month=1, day=31, hour=8, + minute=0, second=1, microsecond=200000) + + for month in [ 1, 7 ]: + dt = dt.replace(month=month) + dt_time = dt.time() + dt_time_str = dt_time.isoformat() + " " + CONNTZ + + conn_dt = localize(dt, TimeZoneInfo(CONNTZ)) + conn_time = conn_dt.timetz() + conn_time_str = conn_time.isoformat() + " " + CONNTZ + + utc_dt = conn_dt.astimezone(TimeZoneInfo(GMT)) + utc_time = utc_dt.timetz() + # isoformat for utc_time includes timezone info already + utc_time_str = utc_time.isoformat() + + east_dt = utc_dt.astimezone(TimeZoneInfo(EASTTZ)) + east_time = east_dt.timetz() + east_time_str = east_time.isoformat() + " " + EASTTZ + + cursor.execute('insert into EASTTZ VALUES (?,?,?)', + (dt_time, dt_time_str, dt,)) # naive time (dst southern) + cursor.execute('insert into EASTTZ VALUES (?,?,?)', + (conn_time, conn_time_str, conn_dt,)) # connection time (dst southern) + cursor.execute('insert into EASTTZ VALUES (?,?,?)', + (utc_time, utc_time_str, utc_dt,)) # gmt (no dst) + cursor.execute('insert into EASTTZ VALUES (?,?,?)', + (east_time, east_time_str, east_dt,)) # chicago (dst northern) + + cursor.execute('select t, t_as_string, dt from EASTTZ') + rows = cursor.fetchall() + con.commit() + + # using timestamp to initialize time no issues with daylight savings time + assert rows[0][2] == rows[1][2] + assert rows[2][2] == rows[1][2] + assert rows[3][2] == rows[1][2] + assert rows[4][2] == rows[1][2] + assert rows[5][2] == rows[1][2] + assert rows[6][2] == rows[1][2] + assert rows[7][2] == rows[1][2] + assert rows[0][2].hour == 8 + assert rows[0][2].minute == 0 + assert rows[0][2].second == 1 + assert rows[0][2].microsecond == 200000 + + # confirm time created in client same as + # time created in te via string assignment + assert rows[0][0] == rows[0][1] + assert rows[1][0] == rows[1][1] + assert rows[2][0] == rows[2][1] + assert rows[3][0] == rows[3][1] + assert rows[4][0] == rows[4][1] + assert rows[5][0] == rows[5][1] + assert rows[6][0] == rows[6][1] + assert rows[7][0] == rows[7][1] + + # naive time should be same as connection time + assert rows[0][0] == rows[1][0] + assert rows[4][0] == rows[5][0] + assert rows[0][0].hour == 8 + assert rows[0][0].minute == 0 + assert rows[0][0].second == 1 + assert rows[0][0].microsecond == 200000 diff --git a/tests/nuodb_types_test.py b/tests/nuodb_types_test.py index ea71984..4cf15fb 100644 --- a/tests/nuodb_types_test.py +++ b/tests/nuodb_types_test.py @@ -7,8 +7,8 @@ import decimal import datetime - from . import nuodb_base +from .mock_tzs import localize class TestNuoDBTypes(nuodb_base.NuoBase): @@ -110,8 +110,9 @@ def test_datetime_types(self): assert len(row) == 4 assert row[0] == datetime.date(2000, 1, 1) assert row[1] == datetime.time(5, 44, 33, 221100) - assert row[2] == datetime.datetime(2000, 1, 1, 5, 44, 33, 221100) - assert row[3] == datetime.datetime(2000, 1, 1, 5, 44, 33, 221100) + assert row[2] == localize(datetime.datetime(2000, 1, 1, 5, 44, 33, 221100)) + assert row[3] == localize(datetime.datetime(2000, 1, 1, 5, 44, 33, 221100)) + def test_null_type(self): con = self._connect()