"""The dates module implements classes for representing and
manipulating several date types.
Classes
-------
* BaseDate
* CalendarDateMixin
* JulianDay
* GregorianDate
* HebrewDate
Note
----
All instances of the classes in this module should be treated as read
only. No attributes should be changed once they're created.
"""
from __future__ import division
from datetime import date
from numbers import Number
from pyluach.utils import memoize
[docs]class BaseDate(object):
"""BaseDate is a base class for all date types.
It provides the following arithmetic and comparison operators
common to all child date types.
=================== =============================================
Operation Result
=================== =============================================
d2 = date1 + int New date ``int`` days after date1
d2 = date1 - int New date ``int`` days before date1
int = date1 - date2 Integer equal to the absolute value of the
difference between date1 and date2
date1 > date2 True if date1 occurs later than date2
date1 < date2 True if date1 occurs earlier than date2
date1 == date2 True if date1 occurs on the same day as date2
date1 != date2 True if ``date1 == date2`` is False
date1 >=, <= date2 True if both are True
=================== =============================================
Any child of BaseDate that implements a ``jd`` attribute
representing the Julian Day of that date can be compared to and
diffed with any other valid date type.
"""
def __hash__(self):
return hash(self.jd)
def __add__(self, other):
try:
return JulianDay(self.jd + other)._to_x(self)
except AttributeError:
raise TypeError('You can only add a number to a date.')
def __sub__(self, other):
if isinstance(other, Number):
return JulianDay(self.jd - other)._to_x(self)
try:
return abs(self.jd - other.jd)
except AttributeError:
raise TypeError("""You can only subtract a number or another date
that has a "jd" attribute from a date""")
def __eq__(self, other):
try:
if self.jd == other.jd:
return True
return False
except AttributeError:
raise TypeError(self._error_string)
def __ne__(self, other):
try:
if self.jd != other.jd:
return True
return False
except AttributeError:
raise TypeError(self._error_string)
def __lt__(self, other):
try:
if self.jd < other.jd:
return True
return False
except AttributeError:
raise TypeError(self._error_string)
def __gt__(self, other):
try:
if self.jd > other.jd:
return True
return False
except AttributeError:
raise TypeError(self._error_string)
def __le__(self, other):
try:
if self.jd <= other.jd:
return True
return False
except AttributeError:
raise TypeError(self._error_string)
def __ge__(self, other):
try:
if self.jd >= other.jd:
return True
return False
except AttributeError:
raise TypeError(self._error_string)
[docs] def shabbos(self):
"""Return the Shabbos on or following the date.
Returns
-------
Date
Self if it's Shabbos or else the following Shabbos as
the same date type as operated on.
"""
return self + (7 - self.weekday())
[docs]class CalendarDateMixin(object):
"""CalendarDateMixin is a mixin for Hebrew and Gregorian dates.
Parameters
----------
Year : int
Month : int
day : int
Attributes
----------
year : int
month : int
day : int
jd : float
The equivelant Julian day at midnight.
"""
def __init__(self, year, month, day, jd=None):
"""Initialize a calendar date."""
self.year = year
self.month = month
self.day = day
self._jd = jd
self. _error_string = ('''Only a date with a "jd" attribute can
be compared to a {0}'''.format(
self.__class__.__name__)
)
def __repr__(self):
return '{0}({1}, {2}, {3})'.format(self.__class__.__name__,
self.year,
self.month,
self.day)
def __str__(self):
return '{0:04d}-{1:02d}-{2:02d}'.format(self.year, self.month, self.day)
def __iter__(self):
yield self.year
yield self.month
yield self.day
[docs] def weekday(self):
"""Return day of week as an integer.
Returns
-------
int
An integer representing the day of the week with Sunday as 1
through Saturday as 7.
"""
return int(self.jd+.5+1) % 7 + 1
[docs] def tuple(self):
"""Return date as tuple.
Returns
-------
tuple of ints
A tuple of ints in the form ``(year, month, day)``.
"""
return (self.year, self.month, self.day)
[docs] def dict(self):
"""Return the date as a dictionary.
Returns
-------
Dict
A dictionary in the form
``{'year': int, 'month': int, 'day': int}``.
"""
return {'year': self.year, 'month': self.month, 'day': self.day}
[docs]class JulianDay(BaseDate):
"""A JulianDay object represents a Julian Day at midnight.
Parameters
----------
day : float or int
The julian day. Note that Julian days start at noon so day
number 10 is represented as 9.5 which is day 10 at midnight.
Attributes
----------
day : float
The Julian Day Number at midnight (as *n*.5)
jd : float
Alias for day.
"""
def __init__(self, day):
"""Initialize a JulianDay instance."""
if day-int(day) < .5:
self.day = int(day) - .5
else:
self.day = int(day) + .5
self.jd = self.day
self._error_string = """Only a date with a "jd" attribute can
be compared to a Julian Day instance."""
def __repr__(self):
return 'JulianDay({0})'.format(self.day)
def __str__(self):
return str(self.day)
[docs] def weekday(self):
"""Return weekday of date.
Returns
-------
int
The weekday with Sunday as 1 through Saturday as 7.
"""
return (int(self.day+.5) + 1) % 7 + 1
[docs] @staticmethod
def from_pydate(pydate):
"""Return a `JulianDay` from a python date object.
Parameters
----------
pydate : datetime.date
A python standard library ``datetime.date`` instance
Returns
-------
JulianDay
"""
return GregorianDate.from_pydate(pydate).to_jd()
[docs] @staticmethod
def today():
"""Return instance of current Julian day from timestamp.
Extends the built-in ``datetime.date.today()``.
Returns
-------
JulianDay
A JulianDay instance representing the current Julian day from
the timestamp.
"""
return GregorianDate.today().to_jd()
[docs] def to_greg(self):
"""Convert JulianDay to a Gregorian Date.
Returns
-------
GregorianDate
The equivalent Gregorian date instance.
Notes
-----
This method uses the Fliegel-Van Flandern algorithm.
"""
jd = int(self.day + .5)
L = jd + 68569
n = 4*L // 146097
L = L - (146097*n + 3) // 4
i = (4000 * (L+1)) // 1461001
L = L - ((1461*i) // 4) + 31
j = (80*L) // 2447
day = L - 2447*j // 80
L = j // 11
month = j + 2 - 12*L
year = 100 * (n-49) + i + L
if year < 1:
year -= 1
return GregorianDate(year, month, day, self.day)
[docs] def to_heb(self):
""" Convert to a Hebrew date.
Returns
-------
HebrewDate
The equivalent Hebrew date instance.
"""
if self.day <= 347997:
raise ValueError('Date is before creation')
jd = int(self.day + .5) # Try to account for half day
jd -= 347997
year = int(jd//365) + 2 ## try that to debug early years
first_day = HebrewDate._elapsed_days(year)
while first_day > jd:
year -= 1
first_day = HebrewDate._elapsed_days(year)
months = [7, 8, 9, 10, 11, 12, 13, 1, 2, 3, 4, 5, 6]
if not HebrewDate._is_leap(year):
months.remove(13)
days_remaining = jd - first_day
for month in months:
if days_remaining >= HebrewDate._month_length(year, month):
days_remaining -= HebrewDate._month_length(year, month)
else:
return HebrewDate(year, month, days_remaining + 1, self.day)
def _to_x(self, type_):
"""Return a date object of the given type."""
if isinstance(type_, GregorianDate):
return self.to_greg()
elif isinstance(type_, HebrewDate):
return self.to_heb()
elif isinstance(type_, JulianDay):
return self
[docs] def to_pydate(self):
"""Convert to a datetime.date object.
Returns
-------
datetime.date
A standard library ``datetime.date`` instance.
"""
return self.to_greg().to_pydate()
[docs]class GregorianDate(BaseDate, CalendarDateMixin):
"""A GregorianDate object represents a Gregorian date (year, month, day).
This is an idealized date with the current Gregorian calendar
infinitely extended in both directions.
Parameters
----------
year : int
month : int
day : int
jd : float, optional
This parameter should not be assigned manually.
Attributes
----------
year : int
month : int
day : int
jd : float(property)
The corresponding Julian Day Number at midnight (as *n*.5).
Warnings
--------
Although B.C.E. dates are allowed, they should be treated as
approximations as they may return inconsistent results when converting
between date types and using arithmetic and comparison operators!
"""
def __init__(self, year, month, day, jd=None):
"""Initialize a GregorianDate.
This initializer extends the CalendarDateMixin initializer
adding in date validation specific to Gregorian dates.
"""
if month < 1 or month > 12:
raise ValueError('{0} is an invalid month.'.format(str(month)))
monthlength = self._monthlength(year, month)
if day < 1 or day > monthlength:
raise ValueError('Given month has {0} days.'.format(monthlength))
super(GregorianDate, self).__init__(year, month, day, jd)
@property
def jd(self):
"""Return the corresponding Julian day number.
This property retrieves the corresponding Julian Day as a float
if it was passed into the init method or already calculated, and
if it wasn't, it calculates it and saves it for later retrievals
and returns it.
Returns
-------
float
The Julian day number at midnight.
"""
if self._jd is None:
year = self.year
month = self.month
day = self.day
if year < 0:
year += 1
if month < 3:
year -= 1
month += 12
month += 1
a = year // 100
b = 2 - a + a//4
self._jd = (int(365.25*year) +
int(30.6001*month) + b + day + 1720994.5)
return self._jd
[docs] @staticmethod
def from_pydate(pydate):
"""Return a `GregorianDate` instance from a python date object.
Parameters
----------
pydate : datetime.date
A python standard library ``datetime.date`` instance.
Returns
-------
GregorianDate
"""
return GregorianDate(*pydate.timetuple()[:3])
[docs] @staticmethod
def today():
"""Return a GregorianDate instance for the current day.
This static method wraps the Python standard library's
date.today() method to get the date from the timestamp.
Returns
-------
GregorianDate
The current Gregorian date from the computer's timestamp.
"""
return GregorianDate.from_pydate(date.today())
@staticmethod
def _is_leap(year):
"""Return True if year of date is a leap year, otherwise False."""
if year < 0:
year += 1
if(
(year % 4 == 0) and not
(year % 100 == 0 and year % 400 != 0)
):
return True
return False
[docs] def is_leap(self):
"""Return if the date is in a leap year
Returns
-------
bool
True if the date is in a leap year, False otherwise.
"""
return self._is_leap(self.year)
@classmethod
def _monthlength(cls, year, month):
if month in [1, 3, 5, 7, 8, 10, 12]:
return 31
elif month != 2:
return 30
else:
return 29 if cls._is_leap(year) else 28
[docs] def to_jd(self):
"""Convert to a Julian day.
Returns
-------
JulianDay
The equivalent JulianDay instance.
"""
return JulianDay(self.jd)
[docs] def to_heb(self):
"""Convert to Hebrew date.
Returns
-------
HebrewDate
The equivalent HebrewDate instance.
"""
return self.to_jd().to_heb()
[docs] def to_pydate(self):
"""Convert to a standard library date.
Returns
-------
datetime.date
The equivalent datetime.date instance.
"""
return date(*self.tuple())
[docs]class HebrewDate(BaseDate, CalendarDateMixin):
"""A class for manipulating Hebrew dates.
Parameters
----------
year : int
The Hebrew year. If the year is less than 1 it will raise a
ValueError.
month : int
The Hebrew month starting with Nissan as 1 (and Tishrei as 7).
If there is a second Adar in the year it is represented as 13.
A month below 1 or above the last month will raise a ValueError.
day : int
The Hebrew day of the month. An invalid day will raise a
ValueError.
jd : float, optional
This parameter should not be assigned manually.
Attributes
----------
year : int
month : int
The Hebrew month starting with Nissan as 1 (and Tishrei as 7).
If there is a second Adar it is represented as 13.
day : int
The day of the month.
"""
def __init__(self, year, month, day, jd=None):
"""Initialize a HebrewDate instance.
This initializer extends the CalendarDateMixin adding validation
specific to hebrew dates.
"""
if year < 1:
raise ValueError('Date supplied is before creation.')
if month < 1 or month > 13:
raise ValueError('{0} is an invalid month.'.format(str(month)))
if (not self._is_leap(year)) and month == 13:
raise ValueError('{0} is not a leap year'.format(year))
monthlength = self._month_length(year, month)
if day < 1 or day > monthlength:
raise ValueError('Given month has {0} days.'.format(monthlength))
super(HebrewDate, self).__init__(year, month, day, jd)
@property
def jd(self):
"""Return the corresponding Julian day number.
This property retrieves the corresponding Julian Day as a float
if it was passed into the init method or already calculated, and
if it wasn't, it calculates it, saves it for later retrievals,
and returns it.
Returns
-------
float
The Julian day number at midnight.
"""
if self._jd is None:
months = [7, 8, 9, 10, 11, 12, 13, 1, 2, 3, 4, 5, 6]
if not HebrewDate._is_leap(self.year):
months.remove(13)
jd = HebrewDate._elapsed_days(self.year)
for m in months:
if m != self.month:
jd += HebrewDate._month_length(self.year, m)
else:
self._jd = jd + (self.day-1) + 347996.5
return self._jd
[docs] @staticmethod
def from_pydate(pydate):
"""Return a `HebrewDate` from a python date object.
Parameters
----------
pydate : datetime.date
A python standard library ``datetime.date`` instance
Returns
-------
HebrewDate
"""
return GregorianDate.from_pydate(pydate).to_heb()
[docs] @staticmethod
def today():
"""Return HebrewDate instance for the current day.
This static method wraps the Python standard library's
``date.today()`` method to get the date from the timestamp.
Returns
-------
HebrewDate
The current Hebrew date from the computer's timestamp.
Note
----
This method coverts the Gregorian date from the time stamp to
a Hebrew date, so if it is after nightfall but before
midnight you will have to add one day, ie.
``today = HebrewDate.today() + 1``.
"""
return GregorianDate.today().to_heb()
[docs] def to_jd(self):
"""Convert to a Julian day.
Returns
-------
JulianDay
The equivalent JulianDay instance.
"""
return JulianDay(self.jd)
[docs] def to_greg(self):
"""Convert to a Gregorian date.
Returns
-------
GregorianDate
The equivalent GregorianDate instance.
"""
return self.to_jd().to_greg()
[docs] def to_pydate(self):
"""Convert to a standard library date.
Returns
-------
datetime.date
The equivalent datetime.date instance.
"""
return self.to_greg().to_pydate()
def to_heb(self):
return self
@staticmethod
def _is_leap(year):
if (((7*year) + 1) % 19) < 7:
return True
return False
@classmethod
@memoize(maxlen=100)
def _elapsed_days(cls, year):
months_elapsed = (
(235 * ((year-1) // 19)) + (12 * ((year-1) % 19)) +
(7 * ((year-1) % 19) + 1) // 19
)
parts_elapsed = 204 + 793*(months_elapsed%1080)
hours_elapsed = (5 + 12*months_elapsed + 793*(months_elapsed//1080) +
parts_elapsed//1080)
conjunction_day = 1 + 29*months_elapsed + hours_elapsed//24
conjunction_parts = 1080 * (hours_elapsed%24) + parts_elapsed%1080
if (
(conjunction_parts >= 19440) or
(
(conjunction_day % 7 == 2) and
(conjunction_parts >= 9924) and
(not cls._is_leap(year))
) or
(
(conjunction_day % 7 == 1) and
conjunction_parts >= 16789 and cls._is_leap(year - 1))):
# if all that
alt_day = conjunction_day + 1
else:
alt_day = conjunction_day
if (alt_day % 7) in (0, 3, 5):
alt_day += 1
return alt_day
@classmethod
def _days_in_year(cls, year):
return cls._elapsed_days(year + 1) - cls._elapsed_days(year)
@classmethod
def _long_cheshvan(cls, year):
"""Returns True if Cheshvan has 30 days"""
return cls._days_in_year(year) % 10 == 5
@classmethod
def _short_kislev(cls, year):
"""Returns True if Kislev has 29 days"""
return cls._days_in_year(year) % 10 == 3
@classmethod
def _month_length(cls, year, month):
"""Months start with Nissan (Nissan is 1 and Tishrei is 7)"""
if month in [1, 3, 5, 7, 11]:
return 30
elif month in [2, 4, 6, 10, 13]:
return 29
elif month == 12:
return 30 if cls._is_leap(year) else 29
elif month == 8: # if long Cheshvan return 30, else return 29
return 30 if cls._long_cheshvan(year) else 29
elif month == 9: # if short Kislev return 29, else return 30
return 29 if cls._short_kislev(year) else 30