Skip to content

Commit c808b10

Browse files
committed
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.
1 parent a2d997e commit c808b10

File tree

11 files changed

+854
-177
lines changed

11 files changed

+854
-177
lines changed

pynuodb/calendar.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""A module to calculate date from number of days from 1/1/1970.
2+
This uses the Georgian Calendar for dates from 10/15/1582 and
3+
the Julian Calendar fro dates before 10/4/1582.
4+
5+
(C) Copyright 2013-2025 Dassault Systemes SE. All Rights Reserved.
6+
7+
This software is licensed under a BSD 3-Clause License.
8+
See the LICENSE file provided with this software.
9+
10+
Calendar functions for computing year,month,day relative to number
11+
of days from unix epoch (1/1/1970)
12+
- Georgian Calendar for dates from and including 10/15/1582.
13+
- Julian Calendar for dates before and including 10/4/1582.
14+
15+
10/5/1582 - 10/14/1582 are invalid dates. These functions are needed
16+
to map dates same as the calendar function in the nuodb server. python
17+
datetime uses a proleptic Gregorian calendar.
18+
19+
"""
20+
import jdcal
21+
from typing import Tuple # pylint: disable=unused-import
22+
23+
JD_EPOCH = sum(jdcal.gcal2jd(1970, 1, 1))
24+
GREGORIAN_START = (1582, 10, 15)
25+
JULIAN_END = (1582, 10, 4)
26+
27+
28+
def ymd2day(year, month, day):
29+
# type: (int,int,int) -> int
30+
"""
31+
Converts given year , month, day to number of days since unix EPOCH.
32+
year - between 0001-9999
33+
month - 1 - 12
34+
day - 1 - 31 (depending upon month and year)
35+
The calculation will be based upon:
36+
- Georgian Calendar for dates from and including 10/15/1582.
37+
- Julian Calendar for dates before and including 10/4/1582.
38+
Dates between the Julian Calendar and Georgian Calendar don't exist a
39+
ValueError will be raised.
40+
"""
41+
42+
if (year, month, day) >= GREGORIAN_START:
43+
jd = sum(jdcal.gcal2jd(year, month, day))
44+
elif (year, month, day) <= JULIAN_END:
45+
jd = sum(jdcal.jcal2jd(year, month, day))
46+
else:
47+
raise ValueError("Invalid date: the range Oct 5-14, 1582 does not exist")
48+
49+
daynum = int(jd - JD_EPOCH)
50+
if daynum < -719164:
51+
raise ValueError("Invalid date: before 1/1/1")
52+
if daynum > 2932896:
53+
raise ValueError("Invalid date: after 9999/12/31")
54+
return daynum
55+
56+
57+
def day2ymd(daynum):
58+
# type: (int) -> Tuple[int, int, int]
59+
"""
60+
Converts given day number relative to 1970-01-01 to a tuple (year,month,day).
61+
62+
63+
The calculation will be based upon:
64+
- Georgian Calendar for dates from and including 10/15/1582.
65+
- Julian Calendar for dates before and including 10/4/1582.
66+
67+
Dates between the Julian Calendar and Georgian Calendar do not exist.
68+
69+
+----------------------------+
70+
| daynum | (year,month,day) |
71+
|---------+------------------|
72+
| 0 | (1970,1,1) |
73+
| -141427 | (1582,10,15) |
74+
| -141428 | (1582,10,4) |
75+
| -719164 | (1,1,1) |
76+
| 2932896 | (9999,12,31) |
77+
+----------------------------+
78+
"""
79+
if daynum >= -141427 and daynum <= 2932896:
80+
y, m, d, _ = jdcal.jd2gcal(daynum, JD_EPOCH)
81+
elif daynum < -141427 and daynum >= -719614:
82+
y, m, d, _ = jdcal.jd2jcal(daynum, JD_EPOCH)
83+
else:
84+
raise ValueError("Invalid daynum (not between 1/1/1 and 12/31/9999 inclusive).")
85+
86+
return y, m, d

pynuodb/connection.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
import os
1919
import copy
20-
import time
20+
import tzlocal
2121
import xml.etree.ElementTree as ElementTree
2222

2323
try:
@@ -161,10 +161,25 @@ def __init__(self, database=None, # type: Optional[str]
161161
host, port=port, options=options, **kwargs)
162162
self.__session.doConnect(params)
163163

164-
params.update({'user': user,
165-
'timezone': time.strftime('%Z'),
166-
'clientProcessId': str(os.getpid())})
167-
164+
localzone_name = None
165+
for k, v in params.items():
166+
if k.lower() == 'timezone':
167+
localzone_name = v
168+
break
169+
if localzone_name is None:
170+
if hasattr(tzlocal, 'get_localzone_name'):
171+
# tzlocal >= 3.0
172+
params['timezone'] = tzlocal.get_localzone_name()
173+
else:
174+
# tzlocal < 3.0
175+
local_tz = tzlocal.get_localzone()
176+
if local_tz:
177+
params['timezone'] = getattr(local_tz, 'zone')
178+
localzone_name = params['timezone']
179+
180+
params.update({'user': user, 'clientProcessId': str(os.getpid())})
181+
182+
self.__session.timezone_name = localzone_name
168183
self.__session.open_database(database, password, params)
169184

170185
self.__config['client_protocol_id'] = self.__session.protocol_id

pynuodb/datatype.py

Lines changed: 148 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -32,19 +32,59 @@
3232

3333
import sys
3434
import decimal
35-
import time
36-
3735
from datetime import datetime as Timestamp, date as Date, time as Time
36+
from datetime import tzinfo # pylint: disable=unused-import
3837
from datetime import timedelta as TimeDelta
3938

4039
try:
4140
from typing import Tuple, Union # pylint: disable=unused-import
4241
except ImportError:
4342
pass
4443

44+
import tzlocal
4545
from .exception import DataError
46+
from .calendar import ymd2day, day2ymd
47+
48+
# zoneinfo.ZoneInfo is preferred but not introduced into python3.9
49+
if sys.version_info >= (3, 9):
50+
# used for python>=3.9 with support for zoneinfo.ZoneInfo
51+
from zoneinfo import ZoneInfo # pylint: disable=unused-import
52+
from datetime import timezone as TimeZone
53+
54+
def utc_TimeStamp(year, month, day, hour=0, minute=0, second=0, microsecond=0):
55+
# type: (int,int,int,int,int,int,int) -> Timestamp
56+
"""
57+
timezone aware datetime with UTC timezone.
58+
"""
59+
return Timestamp(year=year, month=month, day=day,
60+
hour=hour, minute=minute, second=second,
61+
microsecond=microsecond, tzinfo=TimeZone.utc)
62+
63+
def timezone_aware(tstamp, tz_info):
64+
# type: (Timestamp, tzinfo) -> Timestamp
65+
return tstamp.replace(tzinfo=tz_info)
66+
67+
else:
68+
# used for python<3.9 without support for zoneinfo.ZoneInfo
69+
import pytz as TimeZone
70+
71+
def utc_TimeStamp(year, month, day, hour=0, minute=0, second=0, microsecond=0):
72+
# type: (int,int,int,int,int,int,int) -> Timestamp
73+
"""
74+
timezone aware datetime with UTC timezone.
75+
"""
76+
dt = Timestamp(year=year, month=month, day=day,
77+
hour=hour, minute=minute, second=second, microsecond=microsecond)
78+
return TimeZone.utc.localize(dt, is_dst=None)
79+
80+
def timezone_aware(tstamp, tz_info):
81+
# type: (Timestamp, tzinfo) -> Timestamp
82+
return tz_info.localize(tstamp, is_dst=None)
83+
4684

4785
isP2 = sys.version[0] == '2'
86+
TICKSDAY = 86400
87+
localZoneInfo = tzlocal.get_localzone()
4888

4989

5090
class Binary(bytes):
@@ -83,56 +123,126 @@ def string(self):
83123
def DateFromTicks(ticks):
84124
# type: (int) -> Date
85125
"""Convert ticks to a Date object."""
86-
return Date(*time.localtime(ticks)[:3])
126+
y, m, d = day2ymd(ticks // TICKSDAY)
127+
return Date(year=y, month=m, day=d)
87128

88129

89-
def TimeFromTicks(ticks, micro=0):
90-
# type: (int, int) -> Time
130+
def TimeFromTicks(ticks, micro=0, zoneinfo=localZoneInfo):
131+
# type: (int,int, tzinfo) -> Time
91132
"""Convert ticks to a Time object."""
92-
return Time(*time.localtime(ticks)[3:6] + (micro,))
93-
94-
95-
def TimestampFromTicks(ticks, micro=0):
96-
# type: (int, int) -> Timestamp
133+
seconds = ticks % TICKSDAY
134+
hours = (seconds // 3600) % 24
135+
minutes = (seconds // 60) % 60
136+
seconds = seconds % 60
137+
tstamp = Timestamp.combine(Date(1970, 1, 1),
138+
Time(hour=hours,
139+
minute=minutes,
140+
second=seconds,
141+
microsecond=micro)
142+
)
143+
# remove offset that the engine added
144+
utcoffset = zoneinfo.utcoffset(tstamp)
145+
if utcoffset:
146+
tstamp += utcoffset
147+
# returns naive time , should a timezone-aware time be returned instead
148+
return tstamp.time()
149+
150+
151+
def TimestampFromTicks(ticks, micro=0, zoneinfo=localZoneInfo):
152+
# type: (int, int, tzinfo) -> Timestamp
97153
"""Convert ticks to a Timestamp object."""
98-
return Timestamp(*time.localtime(ticks)[:6] + (micro,))
154+
day = ticks // TICKSDAY
155+
y, m, d = day2ymd(day)
156+
timeticks = ticks % TICKSDAY
157+
hour = timeticks // 3600
158+
sec = timeticks % 3600
159+
min = sec // 60
160+
sec %= 60
161+
162+
# this requires both utc and current session to be between year 1 and year 9999 inclusive.
163+
# nuodb could store a timestamp that is east of utc where utc would be year 10000.
164+
if y < 10000:
165+
dt = utc_TimeStamp(year=y, month=m, day=d, hour=hour,
166+
minute=min, second=sec, microsecond=micro)
167+
dt = dt.astimezone(zoneinfo)
168+
else:
169+
# shift one day.
170+
dt = utc_TimeStamp(year=9999, month=12, day=31, hour=hour,
171+
minute=min, second=sec, microsecond=micro)
172+
dt = dt.astimezone(zoneinfo)
173+
# add day back.
174+
dt += TimeDelta(days=1)
175+
# returns timezone-aware datetime
176+
return dt
99177

100178

101179
def DateToTicks(value):
102180
# type: (Date) -> int
103181
"""Convert a Date object to ticks."""
104-
timeStruct = Date(value.year, value.month, value.day).timetuple()
105-
try:
106-
return int(time.mktime(timeStruct))
107-
except Exception:
108-
raise DataError("Year out of range")
109-
110-
111-
def TimeToTicks(value):
112-
# type: (Time) -> Tuple[int, int]
182+
day = ymd2day(value.year, value.month, value.day)
183+
return day * TICKSDAY
184+
185+
186+
def _packtime(seconds, microseconds):
187+
# type: (int,int) -> Tuple[int,int]
188+
if microseconds:
189+
ndiv = 0
190+
shiftr = 1000000
191+
shiftl = 1
192+
while (microseconds % shiftr):
193+
shiftr //= 10
194+
shiftl *= 10
195+
ndiv += 1
196+
return (seconds * shiftl + microseconds // shiftr, ndiv)
197+
else:
198+
return (seconds, 0)
199+
200+
201+
def TimeToTicks(value, zoneinfo=localZoneInfo):
202+
# type: (Time, tzinfo) -> Tuple[int, int]
113203
"""Convert a Time object to ticks."""
114-
timeStruct = TimeDelta(hours=value.hour, minutes=value.minute,
115-
seconds=value.second,
116-
microseconds=value.microsecond)
117-
timeDec = decimal.Decimal(str(timeStruct.total_seconds()))
118-
return (int((timeDec + time.timezone) * 10**abs(timeDec.as_tuple()[2])),
204+
epoch = Date(1970, 1, 1)
205+
tz_info = value.tzinfo
206+
if not tz_info:
207+
tz_info = zoneinfo
208+
209+
my_time = Timestamp.combine(epoch, Time(hour=value.hour,
210+
minute=value.minute,
211+
second=value.second,
212+
microsecond=value.microsecond
213+
))
214+
my_time = timezone_aware(my_time, tz_info)
215+
216+
utc_time = Timestamp.combine(epoch, Time())
217+
utc_time = timezone_aware(utc_time, TimeZone.utc)
218+
219+
td = my_time - utc_time
220+
221+
# fence time within a day range
222+
if td < TimeDelta(0):
223+
td = td + TimeDelta(days=1)
224+
if td > TimeDelta(days=1):
225+
td = td - TimeDelta(days=1)
226+
227+
timeDec = decimal.Decimal(str(td.total_seconds()))
228+
return (int((timeDec) * 10**abs(timeDec.as_tuple()[2])),
119229
abs(timeDec.as_tuple()[2]))
120230

121231

122-
def TimestampToTicks(value):
123-
# type: (Timestamp) -> Tuple[int, int]
232+
def TimestampToTicks(value, zoneinfo=localZoneInfo):
233+
# type: (Timestamp,tzinfo) -> Tuple[int, int]
124234
"""Convert a Timestamp object to ticks."""
125-
timeStruct = Timestamp(value.year, value.month, value.day, value.hour,
126-
value.minute, value.second).timetuple()
127-
try:
128-
if not value.microsecond:
129-
return (int(time.mktime(timeStruct)), 0)
130-
micro = decimal.Decimal(value.microsecond) / decimal.Decimal(1000000)
131-
t1 = decimal.Decimal(int(time.mktime(timeStruct))) + micro
132-
tlen = len(str(micro)) - 2
133-
return (int(t1 * decimal.Decimal(int(10**tlen))), tlen)
134-
except Exception:
135-
raise DataError("Year out of range")
235+
# if naive timezone then leave date/time but change tzinfo to
236+
# be connection's timezone.
237+
if value.tzinfo is None:
238+
value = timezone_aware(value, zoneinfo)
239+
dt = value.astimezone(TimeZone.utc)
240+
timesecs = ymd2day(dt.year, dt.month, dt.day) * TICKSDAY
241+
timesecs += dt.hour * 3600
242+
timesecs += dt.minute * 60
243+
timesecs += dt.second
244+
packedtime = _packtime(timesecs, dt.microsecond)
245+
return packedtime
136246

137247

138248
class TypeObject(object):

0 commit comments

Comments
 (0)