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
141 changes: 141 additions & 0 deletions pynuodb/calendar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@

__doc__ = """
From: https://aa.usno.navy.mil/downloads/c15_usb_online.pdf

15.4.2 Rules for the Civil Use of the Gregorian Calendar

The Gregorian calendar uses the same months with the numbers of days
as it predecessor, the Julian calendar (see Table 15.5). Days are
counted from the first day of each month. Years are counted from the
initial epoch defined by Dionysius Exiguus (see 15.1.8), and each
begins on January 1. A common year has 365 days but a leap year has
366, with an intercalary day, designated February 29, preceding March
1. Leap years are determined according to the following rule:

Every year that is exactly divisible by 4 is a leap year, except for
years that are exactly divisible by 100, but these centurial years
are leap years if they are exactly divisible by 400.

As a result, the year 2000 was a leap year, whereas 1900 and 2100 are
not.

The epoch of the Gregorian calendar, (1 January 1) was Monday, 1
January 3 in the Julian calendar or Julian Day Number 1721426.

The algorithm's for ymd2day and day2ymd are based off of.
- https://github.com/SETI/rms-julian/blob/main/julian/calendar.py
"""

#EPOCH 1/1/1970

# from: https://aa.usno.navy.mil/data/JulianDate
# Julian Dates
# Sunday 1 B.C. February 29 00:00:00.0 1721116.500000
# Monday 1 B.C. March 1 00:00:00.0 1721117.500000
# Thursday A.D. 1970 January 1 00:00:00.0 2440587.500000 (day 0 for our calculations)
# Thursday A.D. 1582 October 4 00:00:00.0 2299159.500000 (last day of julian calendar)
# Friday A.D. 1582 October 15 00:00:00.0 2299160.500000 (first day of gregorian calendar)

# used to calculate daynum (relative to 1970-01-01) for dates before and including 1582 Oct 4
_FEB29_1BCE_JULIAN = 1721116 - 2440587
# used to calculate daynum (relative to 1970-01-01) for dates after and including 1582 Oct 15
_FEB29_1BCE_GREGORIAN = _FEB29_1BCE_JULIAN + 2
# used to shift daynum to calculate (y,m,d) relative to 1/1/1 using julian calendar
_MAR01_1BCE_JULIAN = - (_FEB29_1BCE_JULIAN + 1)
# daynum when gregorian calendar went into effect relative to 1/1/1970
_GREGORIAN_DAY1 = 2299160 - 2440587


def ymd2day(year, month, day, validate = False):
# type: (int,int,int,bool) -> 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 and
ValueError will be raised.

If validate = true then ValueError is raised if year,month,day is not a valid
date and within year range.
"""

mm = (month+9)%12
yy = year - mm//10
d = day

day_as_int = year*10000+month*100+day
if day_as_int > 15821014:
# use Georgian Calendar, leap year every 4 years except centuries that are not divisible by 400
# 1900 - not yeap year
# 2000 - yeap year
daynum = (365*yy + yy//4 - yy//100 + yy//400) + (mm * 306 + 5)//10 + d + _FEB29_1BCE_GREGORIAN
elif day_as_int < 15821005:
# Julian Calendar, leap year ever 4 years
daynum = (365*yy + yy//4) + (mm * 306 + 5)//10 + d + _FEB29_1BCE_JULIAN
else:
raise ValueError("Invalid date {year:04d}-{month:02d}-{day:02d} not in Gregorian or Julian Calendar".format(
year=year,
month=month,
day=day
))

if validate:
if day2ymd(daynum) != (year,month,day):
raise ValueError("Invalid date {year:04d}-{month:02d}-{day:02d}".format(
year=year,
month=month,
day=day
))
return daynum

def day2ymd(daynum):
# type: (int) -> (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) |
| -719614 | (1,1,1) |
| 2932896 | (9999,12,31) |
+----------------------------+

"""

# before 1/1/1 or after 9999/12/25
# if daynum < -719164 or daynum > 2932896:
# raise ValueError(f"Invalid daynum {daynum} before 0001-01-01 or after 9999-12-31")

# In Julian Calender 0001-01-03 is (JD 1721426).
if daynum < _GREGORIAN_DAY1:
g = daynum + _MAR01_1BCE_JULIAN
y = (100 * g + 75) // 36525
doy = g - (365*y + y//4)
else:
# In Georgian Calender 0001-01-01 is (JD 1721426).
g = daynum + _MAR01_1BCE_JULIAN - 2
y = (10000*g + 14780)//3652425 # 365.2425 avg. number days in year.
doy = g - (365*y + y//4 - y//100 + y//400)
if doy < 0:
y -= 1
doy = g - (365*y + y//4 - y//100 + y//400)
m0 = (100 * doy + 52)//3060
m = (m0+2)%12 + 1
y += (m0+2)//12
d = doy - (m0*306+5)//10 + 1
return (y,m,d)

14 changes: 11 additions & 3 deletions pynuodb/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@

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

try:
Expand Down Expand Up @@ -149,9 +151,15 @@ 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())})
if 'timezone' not in [k.lower() for k in params]:
if hasattr(tzlocal, 'get_localzone_name'):
params['timezone'] = tzlocal.get_localzone_name()
else:
local_tz = tzlocal.get_localzone()
if local_tz:
params['timezone'] = local_tz.zone

params.update({'user': user, 'clientProcessId': str(os.getpid()) })

self.__session.open_database(database, password, params)

Expand Down
170 changes: 137 additions & 33 deletions pynuodb/datatype.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,45 @@
except ImportError:
pass

import tzlocal
from .exception import DataError
from .calendar import ymd2day, day2ymd

isP2 = sys.version[0] == '2'
try:
from zoneinfo import ZoneInfo
from datetime import timezone as TimeZone

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=TimeZone.utc)

def timezone_aware(tstamp,tzinfo):
return tstamp.replace(tzinfo=tzinfo)

except ImportError:
import pytz as TimeZone

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 TimeZone.utc.localize(dt, is_dst=None)

def timezone_aware(tstamp,tzinfo):
return tzinfo.localize(tstamp, is_dst=None)


isP2 = sys.version[0] == '2'
TICKSDAY=86400
localZoneInfo = tzlocal.get_localzone()

class Binary(bytes):
"""A binary string.
Expand Down Expand Up @@ -83,56 +118,125 @@ 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):
def TimeFromTicks(ticks, micro=0, zoneinfo = localZoneInfo):
# type: (int, int) -> Time
"""Convert ticks to a Time object."""
return Time(*time.localtime(ticks)[3:6] + (micro,))


def TimestampFromTicks(ticks, micro=0):
seconds = ticks % TICKSDAY
hours = (seconds // 3600) % 24
minutes = (seconds // 60) % 60
seconds = seconds % 60
microseconds = micro % 1000000
tstamp = Timestamp.combine(Date(1970,1,1),
Time(hour=hours,
minute=minutes,
second=seconds,
microsecond=microseconds)
)
# remove offset that the engine added
tstamp = tstamp + zoneinfo.utcoffset(tstamp)
# returns naive time , should a timezone-aware time be returned instead
return tstamp.time()


def TimestampFromTicks(ticks, micro=0,zoneinfo = localZoneInfo):
# type: (int, int) -> 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):
day = ymd2day(value.year, value.month, value.day)
return day * TICKSDAY


def packtime(seconds, microseconds):
# type: (int,int) -> (int,int)
if microseconds:
ndiv=0
msecs = microseconds
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 = localZoneInfo):
# type: (Time) -> 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])),
epoch=Date(1970,1,1)
tzinfo = value.tzinfo
if not tzinfo:
tzinfo = 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,tzinfo)

utc_time = Timestamp.combine(epoch,Time())
utc_time = timezone_aware(utc_time,TimeZone.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)

timeDec = decimal.Decimal(str(td.total_seconds()))
return (int((timeDec) * 10**abs(timeDec.as_tuple()[2])),
abs(timeDec.as_tuple()[2]))


def TimestampToTicks(value):
def TimestampToTicks(value, zoneinfo = localZoneInfo):
# type: (Timestamp) -> 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(TimeZone.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