"""The dates module implements classes for representing and
manipulating several date types.
Contents
--------
* :class:`~pyluach.dates.BaseDate`
* :class:`~pyluach.dates.CalendarDateMixin`
* :class:`~pyluach.dates.JulianDay`
* :class:`~pyluach.dates.GregorianDate`
* :class:`~pyluach.dates.HebrewDate`
Notes
-----
All instances of the classes in this module should be treated as read
only. No attributes should be changed once they're created.
"""
from datetime import date
from numbers import Number
from functools import lru_cache
[docs]class BaseDate:
"""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:
"""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.
Notes
-----
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
@staticmethod
def _elapsed_months(year):
return (235 * year - 234) // 19
@classmethod
@lru_cache(maxsize=100)
def _elapsed_days(cls, year):
months_elapsed = cls._elapsed_months(year)
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