Skip to content

Commit a1a4043

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 a1a4043

File tree

11 files changed

+860
-179
lines changed

11 files changed

+860
-179
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 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+
from typing import Tuple # pylint: disable=unused-import
21+
import jdcal
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: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,22 @@
1717

1818
import os
1919
import copy
20-
import time
2120
import xml.etree.ElementTree as ElementTree
2221

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

27+
2828
from . import __version__
2929
from .exception import Error, InterfaceError
3030
from .session import SessionException
3131

3232
from . import cursor
3333
from . import session
3434
from . import encodedsession
35+
from .datatype import LOCALZONE_NAME
3536

3637
apilevel = "2.0"
3738
threadsafety = 1
@@ -161,10 +162,13 @@ def __init__(self, database=None, # type: Optional[str]
161162
host, port=port, options=options, **kwargs)
162163
self.__session.doConnect(params)
163164

164-
params.update({'user': user,
165-
'timezone': time.strftime('%Z'),
166-
'clientProcessId': str(os.getpid())})
165+
# updates params['TimeZone'] if not set and returns
166+
# loalzone_name either params['TimeZone'] or based
167+
# upon tzlocal.
168+
localzone_name = self._init_local_timezone(params)
169+
params.update({'user': user, 'clientProcessId': str(os.getpid())})
167170

171+
self.__session.timezone_name = localzone_name
168172
self.__session.open_database(database, password, params)
169173

170174
self.__config['client_protocol_id'] = self.__session.protocol_id
@@ -186,6 +190,21 @@ def __init__(self, database=None, # type: Optional[str]
186190
else:
187191
self.setautocommit(False)
188192

193+
@staticmethod
194+
def _init_local_timezone(params):
195+
# type: (Dict[str, str]) -> str
196+
# params['timezone'] updated if not set
197+
# returns timezone
198+
localzone_name = None
199+
for k, v in params.items():
200+
if k.lower() == 'timezone':
201+
localzone_name = v
202+
break
203+
if localzone_name is None:
204+
params['timezone'] = LOCALZONE_NAME
205+
localzone_name = LOCALZONE_NAME
206+
return localzone_name
207+
189208
@staticmethod
190209
def _getTE(admin, attributes, options):
191210
# type: (str, Mapping[str, str], Mapping[str, str]) -> Tuple[str, int]

pynuodb/datatype.py

Lines changed: 170 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -32,19 +32,67 @@
3232

3333
import sys
3434
import decimal
35-
import time
36-
3735
from datetime import datetime as Timestamp, date as Date, time as Time
3836
from datetime import timedelta as TimeDelta
37+
from datetime import tzinfo # pylint: disable=unused-import
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 until 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
53+
UTC = timezone.utc
54+
55+
def utc_TimeStamp(year, month, day, hour=0, minute=0, second=0, microsecond=0):
56+
# type: (int, int, int, int, int, int, int) -> Timestamp
57+
"""
58+
timezone aware datetime with UTC timezone.
59+
"""
60+
return Timestamp(year=year, month=month, day=day,
61+
hour=hour, minute=minute, second=second,
62+
microsecond=microsecond, tzinfo=UTC)
63+
64+
def timezone_aware(tstamp, tz_info):
65+
# type: (Timestamp, tzinfo) -> Timestamp
66+
return tstamp.replace(tzinfo=tz_info)
67+
68+
else:
69+
# used for python<3.9 without support for zoneinfo.ZoneInfo
70+
from pytz import utc as UTC
71+
72+
def utc_TimeStamp(year, month, day, hour=0, minute=0, second=0, microsecond=0):
73+
# type: (int, int, int, int, int, int, int) -> Timestamp
74+
"""
75+
timezone aware datetime with UTC timezone.
76+
"""
77+
dt = Timestamp(year=year, month=month, day=day,
78+
hour=hour, minute=minute, second=second, microsecond=microsecond)
79+
return UTC.localize(dt, is_dst=None)
80+
81+
def timezone_aware(tstamp, tz_info):
82+
# type: (Timestamp, tzinfo) -> Timestamp
83+
return tz_info.localize(tstamp, is_dst=None) # type: ignore[attr-defined]
4684

4785
isP2 = sys.version[0] == '2'
86+
TICKSDAY = 86400
87+
LOCALZONE = tzlocal.get_localzone()
88+
89+
if hasattr(tzlocal, 'get_localzone_name'):
90+
# tzlocal >= 3.0
91+
LOCALZONE_NAME = tzlocal.get_localzone_name()
92+
else:
93+
# tzlocal < 3.0
94+
# local_tz is a pytz.tzinfo object. should have zone attribute
95+
LOCALZONE_NAME = getattr(LOCALZONE, 'zone')
4896

4997

5098
class Binary(bytes):
@@ -83,56 +131,138 @@ def string(self):
83131
def DateFromTicks(ticks):
84132
# type: (int) -> Date
85133
"""Convert ticks to a Date object."""
86-
return Date(*time.localtime(ticks)[:3])
134+
y, m, d = day2ymd(ticks // TICKSDAY)
135+
return Date(year=y, month=m, day=d)
87136

88137

89-
def TimeFromTicks(ticks, micro=0):
90-
# type: (int, int) -> Time
138+
def TimeFromTicks(ticks, micro=0, zoneinfo=LOCALZONE):
139+
# type: (int, int, tzinfo) -> Time
91140
"""Convert ticks to a Time object."""
92-
return Time(*time.localtime(ticks)[3:6] + (micro,))
93141

94-
95-
def TimestampFromTicks(ticks, micro=0):
96-
# type: (int, int) -> Timestamp
142+
# NuoDB release <= 7.0, it's possible that ticks is
143+
# expressed as a Timestamp and not just a Time.
144+
# NuoDB release > 7.0, ticks will be between (-TICKSDAY,2*TICKSDAY)
145+
146+
if ticks < -TICKSDAY or ticks > 2 * TICKSDAY:
147+
dt = TimestampFromTicks(ticks, micro, zoneinfo)
148+
return dt.time()
149+
150+
seconds = ticks % TICKSDAY
151+
hours = (seconds // 3600) % 24
152+
minutes = (seconds // 60) % 60
153+
seconds = seconds % 60
154+
tstamp = Timestamp.combine(Date(1970, 1, 1),
155+
Time(hour=hours,
156+
minute=minutes,
157+
second=seconds,
158+
microsecond=micro)
159+
)
160+
# remove offset that the engine added
161+
utcoffset = zoneinfo.utcoffset(tstamp)
162+
if utcoffset:
163+
tstamp += utcoffset
164+
# returns naive time , should a timezone-aware time be returned instead
165+
return tstamp.time()
166+
167+
168+
def TimestampFromTicks(ticks, micro=0, zoneinfo=LOCALZONE):
169+
# type: (int, int, tzinfo) -> Timestamp
97170
"""Convert ticks to a Timestamp object."""
98-
return Timestamp(*time.localtime(ticks)[:6] + (micro,))
171+
day = ticks // TICKSDAY
172+
y, m, d = day2ymd(day)
173+
timeticks = ticks % TICKSDAY
174+
hour = timeticks // 3600
175+
sec = timeticks % 3600
176+
min = sec // 60
177+
sec %= 60
178+
179+
# this requires both utc and current session to be between year 1 and year 9999 inclusive.
180+
# nuodb could store a timestamp that is east of utc where utc would be year 10000.
181+
if y < 10000:
182+
dt = utc_TimeStamp(year=y, month=m, day=d, hour=hour,
183+
minute=min, second=sec, microsecond=micro)
184+
dt = dt.astimezone(zoneinfo)
185+
else:
186+
# shift one day.
187+
dt = utc_TimeStamp(year=9999, month=12, day=31, hour=hour,
188+
minute=min, second=sec, microsecond=micro)
189+
dt = dt.astimezone(zoneinfo)
190+
# add day back.
191+
dt += TimeDelta(days=1)
192+
# returns timezone-aware datetime
193+
return dt
99194

100195

101196
def DateToTicks(value):
102197
# type: (Date) -> int
103198
"""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]
199+
day = ymd2day(value.year, value.month, value.day)
200+
return day * TICKSDAY
201+
202+
203+
def _packtime(seconds, microseconds):
204+
# type: (int, int) -> Tuple[int,int]
205+
if microseconds:
206+
ndiv = 0
207+
shiftr = 1000000
208+
shiftl = 1
209+
while (microseconds % shiftr):
210+
shiftr //= 10
211+
shiftl *= 10
212+
ndiv += 1
213+
return (seconds * shiftl + microseconds // shiftr, ndiv)
214+
else:
215+
return (seconds, 0)
216+
217+
218+
def TimeToTicks(value, zoneinfo=LOCALZONE):
219+
# type: (Time, tzinfo) -> Tuple[int, int]
113220
"""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])),
119-
abs(timeDec.as_tuple()[2]))
120-
121-
122-
def TimestampToTicks(value):
123-
# type: (Timestamp) -> Tuple[int, int]
221+
epoch = Date(1970, 1, 1)
222+
tz_info = value.tzinfo
223+
if not tz_info:
224+
tz_info = zoneinfo
225+
226+
my_time = Timestamp.combine(epoch, Time(hour=value.hour,
227+
minute=value.minute,
228+
second=value.second,
229+
microsecond=value.microsecond
230+
))
231+
my_time = timezone_aware(my_time, tz_info)
232+
233+
utc_time = Timestamp.combine(epoch, Time())
234+
utc_time = timezone_aware(utc_time, UTC)
235+
236+
td = my_time - utc_time
237+
238+
# fence time within a day range
239+
if td < TimeDelta(0):
240+
td = td + TimeDelta(days=1)
241+
if td > TimeDelta(days=1):
242+
td = td - TimeDelta(days=1)
243+
244+
time_dec = decimal.Decimal(str(td.total_seconds()))
245+
exponent = time_dec.as_tuple()[2]
246+
if not isinstance(exponent, int):
247+
# this should not occur
248+
raise ValueError("Invalid exponent in Decimal: %r" % exponent)
249+
return (int(time_dec * 10**abs(exponent)), abs(exponent))
250+
251+
252+
def TimestampToTicks(value, zoneinfo=LOCALZONE):
253+
# type: (Timestamp, tzinfo) -> Tuple[int, int]
124254
"""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")
255+
# if naive timezone then leave date/time but change tzinfo to
256+
# be connection's timezone.
257+
if value.tzinfo is None:
258+
value = timezone_aware(value, zoneinfo)
259+
dt = value.astimezone(UTC)
260+
timesecs = ymd2day(dt.year, dt.month, dt.day) * TICKSDAY
261+
timesecs += dt.hour * 3600
262+
timesecs += dt.minute * 60
263+
timesecs += dt.second
264+
packedtime = _packtime(timesecs, dt.microsecond)
265+
return packedtime
136266

137267

138268
class TypeObject(object):

0 commit comments

Comments
 (0)