Date-Time type (was Re: [DB-SIG] DB-API Spec. 1.1a1)

Christian Egli christian.egli@stest.ch
08 Dec 1997 14:12:53 +0100


>>>>> "Marc" == M -A Lemburg <lemburg@uni-duesseldorf.de> writes:

  Marc> As Bill and Jim already pointed out, the current date/time
  Marc> handling in Python is fine when all you care about is a range
  Marc> from 1970 to 2038, but as soon as you want to handle dates
  Marc> beyond that range, you get into trouble.

  Marc> BTW: I'm working on a prototype for a such date type in
  Marc> Python.  I'm not yet sure, but it seems that actually two
  Marc> types will emerge from it: one for fixed points in time and
  Marc> one to express time deltas.

I can't hold myself back any longer:

Edward M. Reingold has written some fine functions relating to
calendars which are included in the Emacs distribution. This code has
been translated by himself to C++ and can be found under
http://emr.cs.uiuc.edu/~reingold/papers/calendar/calendar.C. I tried
to swig this code but had troubles with overloaded operators. But it
was pretty straightforward to translate it into Python.

Only a few of the requirements for a Date-Time type that Jim Fulton
once stated in the db-sig are actually supported and obviously it is
not implemented in C. But maybe some of this code could be scavenged
for a C implementation or somebody manages to swig the above C++ code.

This code is released as is. "It works for me". I don't claim it to be
complete. Nor do I claim that it is the answer (or even an answer) to
the current discussion in the db-sig. See also the TODO section in the
code.

----------------cut here--------------------------------------------------------
#!/bin/env python

"""The following python code is translated from C++ code which is
translated from the Lisp code in ``Calendrical Calculations'' by
Nachum Dershowitz and Edward M. Reingold, Software---Practice &
Experience, vol. 20, no. 9 (September, 1990), pp. 899--928. The C++
code can be obtained from
http://emr.cs.uiuc.edu/~reingold/papers/calendar/calendar.C 

This code is in the public domain, but any use of it
should publically acknowledge its source.

Classes GregorianDate, JulianDate, IsoDate, IslamicDate,
and HebrewDate

TODO:

As Jim Fulton once noted in the db-sig:

> It would be helpful to have a standard date-time implementation for
> use in Python database implementations (and elsewhere, of course).
>  
> I think that the date-time implementation should:
>  
>  1. Support conversion from strings in a very wide variety of formats
>     (e.g. 'Oct, 1, 1994 12:34am EST', '1994/10/1 00:34:21.456')
>  
>  2. Support subtraction of date-times, and addition and 
>     subtraction of dates and numbers.
>  
>  3. Store dates efficiently.
>  
>  4. Store dates immutably.
>  
>  5. Represent dates to a specified minimum precision 
>     (e.g. milliseconds).
>  
>  6. Handle all dates in the Gregorian calendar.  (e.g. there should
>     not be problems storing dates from the 18th or 21st centurys.)
>  
>  7. Provide read-access to date-components (e.g. year, month, second,
>     day-of-week, etc.)
>  
> I'm afraid the implementation should also address issues like:
>  
>  8. Support for time-zones,
>  
>  9. Support for daylight-savings time.

This implementation of Date only fullfills Requirement 2 and 6
and to some degree 7.
"""

__version__ = "1.5"

DayName = ("Sunday", "Monday", "Tuesday", "Wednesday",
	   "Thursday", "Friday", "Saturday")

# Absolute dates

class Date:
    """Absolute date means the number of days elapsed since the Gregorian date
    Sunday, December 31, 1 BC. (Since there was no year 0, the year following
    1 BC is 1 AD.) Thus the Gregorian date January 1, 1 AD is absolute date
    number 1.
    """

    reprFormat = "%(month)s/%(day)s/%(year)s"

    def __init__(self, absoluteDate = None):
	self.abs = absoluteDate
	
    def __repr__(self):
	return self.reprFormat % self.__dict__
    
    def __cmp__(self, other):
	"""Comparison of Dates with Dates returns -1 if self < other,
	0 if self == other or 1 if self > other.
	Comparison of Dates with any other type raises a TypeError exception.
	"""
	if (type(other)  == types.InstanceType and
	    issubclass(other.__class__, Date)):
	    if self.absolute() < other.absolute():
		return -1
	    elif self.absolute() > other.absolute():
		return 1
	    else:
		return 0
	else:
	    raise TypeError, 'Comparison of Dates is only allowed with Dates'
    

    def __add__(self, other):
	"""Addition of Dates with numbers (int and long) returns a new Date.
	Addition of Dates with any other type raises a TypeError exception.
	"""
	import types
	if type(other) in [types.IntType, types.LongType]:
	    return self.__class__(self.absolute() + other)
	else:
	    raise TypeError, 'Addition of Dates is only allowed with numbers'

    __radd__ = __add__

    def __sub__(self, other):
	"""Subtraction of Dates with numbers (int and long) returns a new Date.
	Subtraction of Dates with Dates returns an absolute difference in days.
	Subtraction of Dates with any other type raises a TypeError exception."""
	import types
	if (type(other) == types.InstanceType and
	    issubclass(other.__class__, Date)):
	    return self.absolute() - other.absolute()
	elif type(other) in [types.IntType, types.LongType]:
	    return self.__class__(self.absolute() - other)
	else:
	    raise TypeError, 'Subtraction of Dates is only allowed with Dates or numbers'

    __rsub__ = __sub__


def XdayOnOrBefore(d, x):
    """Absolute date of the x-day on or before absolute date d.
    x=0 means Sunday, x=1 means Monday, and so on.
    
    "Absolute date" means the number of days elapsed since the Gregorian date
    Sunday, December 31, 1 BC. (Since there was no year 0, the year following
    1 BC is 1 AD.) Thus the Gregorian date January 1, 1 AD is absolute date
    number 1.
    """
    
    return (d - ((d - x) % 7))


#  Gregorian dates

def LastDayOfGregorianMonth(month, year):
    """Compute the last date of the month for the Gregorian calendar."""
    
    if (month == 2):
	if ((((year % 4) == 0) and ((year % 100) != 0)) or ((year % 400) == 0)):
	    return 29
	else:
	    return 28
    elif ((month == 4) or (month == 6) or (month == 9) or (month == 11)):
	return 30
    else:
	return 31


def NthXday(n, x, month, year, day = 0):
    """The Gregorian date of nth x-day in month, year before/after optional day.
    x = 0 means Sunday, x = 1 means Monday, and so on.  If n<0, return the nth
    x-day before month day, year (inclusive).  If n>0, return the nth x-day
    after month day, year (inclusive).  If day is omitted or 0, it defaults
    to 1 if n>0, and month's last day otherwise."""

    if (n > 0):
	if (day == 0):
	    day = 1  # default for positive n
	absDate = GregorianDate(day, month, year).absolute()
	return GregorianDate((7 * (n - 1)) + XdayOnOrBefore(6 + absDate, x))
    else:
	if (day == 0):
	    day = LastDayOfGregorianMonth(month, year)  # default for negative n
	absDate = GregorianDate(day, month, year).absolute()
	return GregorianDate((7 * (n + 1)) + XdayOnOrBefore(absDate, x))


class GregorianDate(Date):
    """The Gregorian Calendar is the present day civil calendar in much of
    the world. It was instituted by Pope Gregory when he corrected the
    Julian Calendar by proclaiming that Thursday, October 4, 1582
    C.E. would be followed by Friday, October 15, 1582 C.E. thus skipping
    10 days. Over time most of the countries of world adopted this
    calendar. It corrected for the problems of the Julian Calendar by
    introducing a more complicated leap year structure: Year y is a leap
    year if it is divisible by 400 or it is a year that is divisible by 4
    and not divisible by 100.
    """
    
    def __init__(self, d, m = 0, y = 0):
	"""year   # 1...
	month  # 1 == January, ..., 12 == December
	day    # 1..LastDayOfGregorianMonth(month, year)
	"""
	
	if ((m != 0) and (y != 0)):
	    Date.__init__(self)
	    self.month = m
	    self.day = d
	    self.year = y
	else:
	    Date.__init__(self, d)
	    # Computes the Gregorian date from the absolute date. 
	    # Search forward year by year from approximate year
	    year = d/366
	    while (d >= GregorianDate(1,1,year+1).absolute()):
		year = year + 1
		# Search forward month by month from January
	    month = 1
	    while (d > GregorianDate(LastDayOfGregorianMonth(month,year), month, year).absolute()):
		month = month + 1
	    day = d - GregorianDate(1,month,year).absolute() + 1
	    
	    self.year, self.month, self.day = year, month, day
      
    def absolute(self):
	"""Computes the absolute date from the Gregorian date.
	
	"Absolute date" means the number of days elapsed since the Gregorian date
	Sunday, December 31, 1 BC. (Since there was no year 0, the year following
	1 BC is 1 AD.) Thus the Gregorian date January 1, 1 AD is absolute date
	number 1.
	"""
	if self.abs:
	    return self.abs
	N = self.day	     # days this month
	year = self.year
	month = self.month
	for m in range(month - 1, 0, -1): # days in prior months this year
	    N = N + LastDayOfGregorianMonth(m, year)
	self.abs = (N                    # days this year
		    + 365 * (year - 1)   # days in previous years ignoring leap days
		    + (year - 1)/4       # Julian leap days before this year...
		    - (year - 1)/100     # ...minus prior century years...
		    + (year - 1)/400)    # ...plus prior years divisible by 400
	return self.abs
    

# Julian dates

JulianEpoch = -2 # Absolute date of start of Julian calendar

def LastDayOfJulianMonth(month, year):
    """Compute the last date of the month for the Julian calendar."""
    if (month == 2):
	if ((year % 4) == 0):
	    return 29
	else:
	    return 28
    elif ((month == 4) or (month == 6) or (month == 9) or (month == 11)):
	return 30
    else:
	return 31
	
class JulianDate(Date):
    """The Julian Calendar was the predecessor to the Gregorian Calendar
    as the civil calendar for much of the world. It has a very simple leap
    year structure, all years divisible by 4 are leap years. This is close
    to true but deviates from the average length of the solar year over
    time.  Thus the need for the 10 day correction in 1582.
    """
    
    def __init__(self, d, m = 0, y = 0):
	"""year   # 1...
	month  # 1 == January, ..., 12 == December
	day    # 1..LastDayOfJulianMonth(month, year)
	"""
	
	if ((m != 0) and (y != 0)):
	    Date.__init__(self)
	    self.month = m
	    self.day = d
	    self.year = y
	else:
	    Date.__init__(self, d)
	    # Computes the Julian date from the absolute date.
	    # Search forward year by year from approximate year
	    year = (d + JulianEpoch)/366
	    while (d >= JulianDate(1,1,year+1).absolute()):
		year = year + 1
	    # Search forward month by month from January
	    month = 1
	    while (d > JulianDate(LastDayOfJulianMonth(month,year), month, year).absolute()):
		month = month + 1
	    day = d - JulianDate(1,month,year).absolute() + 1

	    self.year, self.month, self.day = year, month, day
  
    def absolute(self):
	"""Computes the absolute date from the Julian date.
	
	"Absolute date" means the number of days elapsed since the Gregorian date
	Sunday, December 31, 1 BC. (Since there was no year 0, the year following
	1 BC is 1 AD.) Thus the Gregorian date January 1, 1 AD is absolute date
	number 1.
	"""
	if self.abs:
	    return self.abs
	N = self.day                         # days this month
	year = self.year
	
	for m in range(self.month - 1, 0, - 1): # days in prior months this year
	    N = N + LastDayOfJulianMonth(m, year)
	self.abs = (N                     # days this year
		    + 365 * (year - 1)    # days in previous years ignoring leap days
		    + (year - 1)/4        # leap days before this year...
		    + JulianEpoch)       # days elapsed before absolute date 1
	return self.abs


# ISO dates

class IsoDate(Date):
    """The International Organization for Standardization (ISO) produced a
    calendar that is popular in some European countries. A date is
    specified as the ordinal day in the week and the `calendar week' of
    the Gregorian year.
    The `ISO year' corresponds approximately to the Gregorian year, but
    weeks start on Monday and end on Sunday.  The first week of the ISO year is
    the first such week in which at least 4 days are in a year.  The ISO
    commercial DATE has the form (week day year) in which week is in the range
    1..52 and day is in the range 0..6 (1 = Monday, 2 = Tuesday, ..., 0 =
    Sunday).
    """

    reprFormat = "%(week)s-%(day)s-%(year)s"

    def __init__(self, d, w = 0, y = 0):
	"""year  # 1...
	week  # 1..52 or 53
	day   # 1..7
	"""

	if ((w != 0) and (y != 0)):
	    Date.__init__(self)
	    self.week = w
	    self.day = d
	    self.year = y
	else:
	    Date.__init__(self, d)
	    # Computes the ISO date from the absolute date.
	    year = GregorianDate(d - 3).year
	    if (d >= IsoDate(1,1,year+1).absolute()):
		year = year + 1
	    if ((d % 7) == 0):
		day = 7      # Sunday
	    else:
		day = d % 7  # Monday..Saturday
	    week = 1 + (d - IsoDate(1,1,year).absolute()) / 7

	    self.year, self.week, self.day = year, week, day
	
  
    def absolute(self):
	"""Computes the absolute date from the ISO date. """

	if self.abs:
	    return self.abs
	self.abs = (XdayOnOrBefore(GregorianDate(4,1,self.year).absolute(),1) # days in prior years
		    + 7 * (self.week - 1)                          # days in prior weeks this year
		    + (self.day - 1))                              # prior days this week
	return self.abs
    

# Islamic dates

IslamicEpoch = 227014 # Absolute date of start of Islamic calendar

def IslamicLeapYear(year):
    """True if year is an Islamic leap year"""
    
    if ((((11 * year) + 14) % 30) < 11):
	return 1
    else:
	return 0


def LastDayOfIslamicMonth(month, year):
    """Last day in month during year on the Islamic calendar."""
  
    if (((month % 2) == 1) or ((month == 12) and IslamicLeapYear(year))):
	return 30
    else:
	return 29


class IslamicDate(Date):
    """This Islamic Calendar is a strictly lunar calendar making it very easy
    to calculate. No attempt is made to keep the months in line with the
    seasons of the year. Instead they wander through the seasons as the
    years go by. As in the Hebrew Calendar the day begins at sunset which
    we again take to be 6pm local time. Unfortunately the calculations
    provided here are only approximate. Unlike the Hebrew Calendar there
    are many more disparate forms of the Islamic Calendar. Even worse,
    much of the Islamic world still relies on proclamations of the new
    moon by religious authorities instead of on calculations. Thus the
    routines can be in error by a day or two from what is actually
    observed in different parts of the Islamic world.
    """
  
    def __init__(self, d, m = 0, y = 0):
	"""year   # 1...
	month  # 1..13 (12 in a common year)
	day    # 1..LastDayOfIslamicMonth(month,year)
	"""

	if ((m != 0) and (y != 0)):
	    Date.__init__(self)
	    self.month = m
	    self.day = d
	    self.year = y
	else:
	    Date.__init__(self, d)
	    # Computes the Islamic date from the absolute date.
	    if (d <= IslamicEpoch):	# Date is pre-Islamic
		month = 0
		day = 0
		year = 0
	    else:
		# Search forward year by year from approximate year
		year = (d - IslamicEpoch) / 355
		while (d >= IslamicDate(1,1,year+1).absolute()):
		    year = year + 1
		# Search forward month by month from Muharram
		month = 1
		while (d > IslamicDate(LastDayOfIslamicMonth(month,year), month, year).absolute()):
		    month = month + 1
		day = d - IslamicDate(1,month,year).absolute() + 1
	    
	    self.year, self.month, self.day = year, month, day

	    
    def absolute(self):
	"""Computes the absolute date from the Islamic date.
	
	"Absolute date" means the number of days elapsed since the Gregorian date
	Sunday, December 31, 1 BC. (Since there was no year 0, the year following
	1 BC is 1 AD.) Thus the Gregorian date January 1, 1 AD is absolute date
	number 1.
	"""
	
	if self.abs:
	    return self.abs
	self.abs = (self.day                      # days so far this month
		    + 29 * (self.month - 1)       # days so far...
		    + self.month/2                #            ...this year
		    + 354 * (self.year - 1)       # non-leap days in prior years
		    + (3 + (11 * self.year)) / 30 # leap days in prior years
		    + IslamicEpoch)               # days before start of calendar
	return self.abs
    
    
# Hebrew dates

HebrewEpoch = -1373429 # Absolute date of start of Hebrew calendar

def HebrewLeapYear(year):
    """True if year is an Hebrew leap year"""
  
    if ((((7 * year) + 1) % 19) < 7):
	return 1
    else:
	return 0


def LastMonthOfHebrewYear(year):
    """Last month of Hebrew year."""
  
    if (HebrewLeapYear(year)):
	return 13
    else:
	return 12


def HebrewCalendarElapsedDays(year):
    """Number of days elapsed from the Sunday prior to the start of the
    Hebrew calendar to the mean conjunction of Tishri of Hebrew year."""
    
    MonthsElapsed = ((235 * ((year - 1) / 19))           # Months in complete cycles so far.
		     + (12 * ((year - 1) % 19))          # Regular months in this cycle.
		     + (7 * ((year - 1) % 19) + 1) / 19) # Leap months this cycle
    PartsElapsed = 204 + 793 * (MonthsElapsed % 1080)
    HoursElapsed = (5 + 12 * MonthsElapsed + 793 * (MonthsElapsed  / 1080)
		    + PartsElapsed / 1080)
    ConjunctionDay = 1 + 29 * MonthsElapsed + HoursElapsed / 24
    ConjunctionParts = 1080 * (HoursElapsed % 24) + PartsElapsed % 1080
    if ((ConjunctionParts >= 19440)	# If new moon is at or after midday,
	or (((ConjunctionDay % 7) == 2)	# ...or is on a Tuesday...
	    and  (ConjunctionParts >= 9924)	# at 9 hours, 204 parts or later...
	    and not (HebrewLeapYear(year)))	# ...of a common year,
	or (((ConjunctionDay % 7) == 1)	# ...or is on a Monday at...
	    and (ConjunctionParts >= 16789)	# 15 hours, 589 parts or later...
	    and (HebrewLeapYear(year - 1)))):	# at the end of a leap year
	# Then postpone Rosh HaShanah one day
	AlternativeDay = ConjunctionDay + 1
    else:
	AlternativeDay = ConjunctionDay
    if (((AlternativeDay % 7) == 0) # If Rosh HaShanah would occur on Sunday,
	or ((AlternativeDay % 7) == 3)     # or Wednesday,
	or ((AlternativeDay % 7) == 5)):    # or Friday
	# Then postpone it one (more) day
	return (1+ AlternativeDay)
    else:
	return AlternativeDay


def DaysInHebrewYear(year):
    """Number of days in Hebrew year."""
  
    return ((HebrewCalendarElapsedDays(year + 1)) -
	    (HebrewCalendarElapsedDays(year)))


def LongHeshvan(year):
    """True if Heshvan is long in Hebrew year."""
    
    if ((DaysInHebrewYear(year) % 10) == 5):
	return 1
    else:
	return 0


def ShortKislev(year):
    """True if Kislev is short in Hebrew year."""
  
    if ((DaysInHebrewYear(year) % 10) == 3):
	return 1
    else:
	return 0


def LastDayOfHebrewMonth(month, year):
    """Last day of month in Hebrew year."""
    
    if ((month == 2)
      or (month == 4)
      or (month == 6)
      or ((month == 8) and not (LongHeshvan(year)))
      or ((month == 9) and ShortKislev(year))
      or (month == 10)
      or ((month == 12) and not (HebrewLeapYear(year)))
      or (month == 13)):
	return 29
    else:
	return 30


class HebrewDate(Date):
    """The Hebrew Calendar is one of the most complicated calendars. It
    attempts to keep the months strictly lunar cycles and still be in sync
    with a solar year. On top of this are strict guidelines as to what
    days certain religious events must occur. It used to be the case that
    a new month was decreed when a new moon was ``sighted''. For the most
    part this has been turned over to astronomical calculations which are
    more accurate. The new day begins at sunset. For our purposes we start
    new days at 6pm local time. This is a good enough approximation to
    when the sun sets.
    """
  
    def __init__(self, d, m = 0, y = 0):
	"""year   # 1...
	month  # 1..LastMonthOfHebrewYear(year)
	day    # 1..LastDayOfHebrewMonth(month, year)
	"""
	
	if ((m != 0) and (y != 0)):
	    Date.__init__(self)
	    self.month = m
	    self.day = d
	    self.year = y
	else:
	    Date.__init__(self, d)
	    # Computes the Hebrew date from the absolute date.
	    year = (d - HebrewEpoch) / 366 # Approximation from below.
	    # Search forward for year from the approximation.
	    while (d >= HebrewDate(1,7,year + 1).absolute()):
		year = year + 1
	    # Search forward for month from either Tishri or Nisan.
	    if (d < HebrewDate(1, 1, year).absolute()):
		month = 7  #  Start at Tishri
	    else:
		month = 1  #  Start at Nisan
	    while (d > HebrewDate(LastDayOfHebrewMonth(month,year), month, year).absolute()):
		month = month + 1
	    # Calculate the day by subtraction.
	    day = d - HebrewDate(1, month, year).absolute() + 1

	    self.year, self.month, self.day = year, month, day

  
    def absolute(self):
	"""Computes the absolute date of Hebrew date.
	
	"Absolute date" means the number of days elapsed since the Gregorian date
	Sunday, December 31, 1 BC. (Since there was no year 0, the year following
	1 BC is 1 AD.) Thus the Gregorian date January 1, 1 AD is absolute date
	number 1.
	"""

	if self.abs:
	    return self.abs
	DayInYear = self.day # Days so far this month.
	if (self.month < 7): # Before Tishri, so add days in prior months
	    # this year before and after Nisan.
	    m = 7
	    while (m <= (LastMonthOfHebrewYear(self.year))):
		DayInYear = DayInYear + LastDayOfHebrewMonth(m, self.year)
		m = m + 1
	    m = 1
	    while (m < self.month):
		DayInYear = DayInYear + LastDayOfHebrewMonth(m, self.year)
		m = m + 1
	else:
	    # Add days in prior months this year
	    m = 7
	    while (m < self.month):
		DayInYear = DayInYear + LastDayOfHebrewMonth(m, self.year)
		m = m + 1
	self.abs = (DayInYear +
		    (HebrewCalendarElapsedDays(self.year)# Days in prior years.
		     + HebrewEpoch))         # Days elapsed before absolute date 1.
	return self.abs
  

if __name__ == "__main__":

   import string
   while (1):
	y = string.atoi(raw_input("Enter year (>0): "))
	m = string.atoi(raw_input("Enter month (1..12): "))
	d = string.atoi(raw_input("Enter day (1..%s ): " % LastDayOfGregorianMonth(m, y)))

	g = GregorianDate(d,m,y)
	a = g.absolute()
	print "%s = %s = %s" % (g, a, DayName[g.absolute() % 7])
    
	g2 = g + 1
	a = g2.absolute()
	print "%s + 1 = %s = %s" % (g, g2, a)

	g = GregorianDate(a)
	a = g.absolute()
	print "= gregorian date %s = absolute date %s " % (g, a)
    
	j = JulianDate(a)
	a = j.absolute()
	print "= julian date %s = absolute date %s" % (j, a)
    
	i = IsoDate(a)
	a = i.absolute()
	print "= iso date %s = absolute date %s " % (i,a)
    
	h = HebrewDate(a)
	a = h.absolute()
	print "= hebrew date %s = absolute date %s" % (h, a)
    
	isl= IslamicDate(a)
	a = isl.absolute()
	print "= islamic date %s = absolute date %s" % (isl, a)
    
-- 
Christian Egli
Switching Test Solutions AG, Friesenbergstr. 75, CH-8055 Zuerich

                                       

_______________
DB-SIG  - SIG on Tabular Databases in Python

send messages to: db-sig@python.org
administrivia to: db-sig-request@python.org
_______________