Skip to content

Support timezone-aware datetime objects #178

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions pynuodb/calendar.py
Original file line number Diff line number Diff line change
@@ -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
34 changes: 30 additions & 4 deletions pynuodb/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,15 @@

import os
import copy
import time
import xml.etree.ElementTree as ElementTree

try:
from typing import Any, Dict, Mapping, Optional, Tuple # pylint: disable=unused-import
except ImportError:
pass

import tzlocal

from . import __version__
from .exception import Error, InterfaceError
from .session import SessionException
Expand Down Expand Up @@ -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
Expand All @@ -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]
Expand Down
202 changes: 162 additions & 40 deletions pynuodb/datatype.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
Loading