Skip to content

Dbutson/datatypes #173

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 9 commits into
base: master
Choose a base branch
from
Open
132 changes: 132 additions & 0 deletions pynuodb/calendar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it really the case that there's no Python module that provides these facilities already? Why are we rolling our own?

__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: int, month: int, day: int,validate: bool =False) -> 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(f"Invalid date {year:04}-{month:02}-{day:02} not in Gregorian or Julian Calendar")

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

def day2ymd(daynum: 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)

21 changes: 17 additions & 4 deletions pynuodb/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from os import getpid
import time
import xml.etree.ElementTree as ElementTree
import tzlocal

try:
from typing import Mapping, Optional, Tuple # pylint: disable=unused-import
Expand Down Expand Up @@ -132,10 +133,22 @@ def __init__(self, database=None, # type: Optional[str]
**kwargs)
self.__session.doConnect(params)

params.update({'user': user,
'timezone': time.strftime('%Z'),
'clientProcessId': str(getpid())})

additional_params = {'user': user, 'clientProcessId': str(getpid()) }
timezone_name = None
for key in params:
if key.lower() == 'timezone':
timezone_name = params[key]
break
if not timezone_name:
# from doc: https://pypi.org/project/tzlocal/
# You can also use tzlocal to get the name of your local
# timezone, but only if your system is configured to make
# that possible. tzlocal looks for the timezone name in
# /etc/timezone, /var/db/zoneinfo, /etc/sysconfig/clock
# and /etc/conf.d/clock. If your /etc/localtime is a
# symlink it can also extract the name from that symlink.
additional_params['timezone'] = tzlocal.get_localzone_name()
Comment on lines +138 to +150
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would probably just write this like:

if 'timezone' not in [k.lower() for k in params]:
    params['timezone'] = tzlocal.get_localzone_name()

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

# Set auto commit to false by default per PEP 249
Expand Down
142 changes: 110 additions & 32 deletions pynuodb/datatype.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,17 @@
except ImportError:
pass

from datetime import datetime as Timestamp, date as Date, time as Time
from datetime import datetime as Timestamp, date as Date, time as Time, timezone as TimeZone
from datetime import timedelta as TimeDelta
import decimal
import time
import tzlocal
from .exception import DataError
from .calendar import ymd2day, day2ymd

isP2 = sys.version[0] == '2'

localZoneInfo = tzlocal.get_localzone()

class Binary(bytes):
"""A binary string.
Expand Down Expand Up @@ -78,60 +81,134 @@ def string(self):
"""The old implementation of Binary provided this."""
return self


TICKSDAY=86400
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):
if ticks >= TICKSDAY or ticks <= -TICKSDAY:
dt = TimestampFromTicks(ticks,micro,zoneinfo)
_time = dt.time()
else:
if ticks < 0 and micro:
ticks -= 1
timeticks = ticks % TICKSDAY
hour = timeticks // 3600
sec = timeticks % 3600
min = (sec // 60 )
sec %= 60
micro %= 1000000

# convert time to standard time offset from utc for given timezone. Use
# today's date for offset calculation.

today = Timestamp.now().date()
time = Time(hour=hour,minute=min,second=sec,microsecond=micro,tzinfo=TimeZone.utc)
tstamp = Timestamp.combine(today,time).astimezone(zoneinfo)
dst_offset = tstamp.dst()
if dst_offset:
tstamp -= dst_offset
_time = tstamp.time()
return _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, for now
# let's just return NULL for that case.
if y < 10000:
dt = Timestamp(year=y,month=m,day=d,hour=hour,minute=min,second=sec,microsecond=micro,tzinfo=TimeZone.utc)
dt = dt.astimezone(zoneinfo)
else:
# shift one day.
dt = Timestamp(year=9999,month=12,day=31,hour=hour,
minute=min,second=sec,microsecond=micro,tzinfo=TimeZone.utc)
dt = dt.astimezone(zoneinfo)
# add day back.
dt += TimeDelta(days=1)
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: int, microseconds: int) -> (int, int):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, we must continue to support Python2 so this syntax is illegal. Please use # type: comments instead.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought we were past supporting python3. The pip package says it only support python3. python2 has been dead forever now.

Copy link
Contributor

@madscientist madscientist Apr 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We only support Python3 for customers. However we use the Python driver extensively in all our internal testing and large swathes of this internal testing is all written in Python2. We have very few resources we can expend on rewriting all that (working perfectly well) internal testing infrastructure to Python3. This work is progressing but very very slowly. It's barely possible that sometime next year (2026) we can make a serious push to fully deprecating Python2 support internally. Until then, we have to support both.

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)

# convert time to time relative to connection timezone
# using today as date.

tstamp = Timestamp.combine(Date.today(),value)
if tstamp.tzinfo is None:
tstamp = tstamp.replace(tzinfo=zoneinfo)
else:
tstamp = tstamp.astimezone(zoneinfo)

dst_offset = zoneinfo.dst(tstamp)
utc_offset = zoneinfo.utcoffset(tstamp)
std_offset = dst_offset - utc_offset

timeStruct = TimeDelta(hours=tstamp.hour, minutes=tstamp.minute,
seconds=tstamp.second,
microseconds=tstamp.microsecond)
timeDec = decimal.Decimal(str(timeStruct.total_seconds()))
return (int((timeDec + time.timezone) * 10**abs(timeDec.as_tuple()[2])),
return (int((timeDec + std_offset) * 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 = value.replace(tzinfo=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 Expand Up @@ -191,3 +268,4 @@ def TypeObjectFromNuodb(nuodb_type_name):
if obj is None:
raise DataError('received unknown column type "%s"' % (name))
return obj

Loading