mirror of
https://github.com/oDinZu/callirhoe.git
synced 2025-02-22 00:04:52 -05:00
- just launched 2to3-3.7 -- seems to be working! - had to fix string decoding in calmagick.py
458 lines
18 KiB
Python
458 lines
18 KiB
Python
# -*- coding: utf-8 -*-
|
|
|
|
# callirhoe - high quality calendar rendering
|
|
# Copyright (C) 2012-2013 George M. Tzoumas
|
|
|
|
# This program is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with this program. If not, see http://www.gnu.org/licenses/
|
|
|
|
# *****************************************
|
|
# #
|
|
""" holiday support routines """
|
|
# #
|
|
# *****************************************
|
|
|
|
from datetime import date, timedelta
|
|
|
|
def _get_orthodox_easter(year):
|
|
"""compute date of orthodox easter
|
|
@rtype: datetime.date
|
|
"""
|
|
y1, y2, y3 = year % 4 , year % 7, year % 19
|
|
a = 19*y3 + 15
|
|
y4 = a % 30
|
|
b = 2*y1 + 4*y2 + 6*(y4 + 1)
|
|
y5 = b % 7
|
|
r = 1 + 3 + y4 + y5
|
|
return date(year, 3, 31) + timedelta(r)
|
|
# res = date(year, 5, r - 30) if r > 30 else date(year, 4, r)
|
|
# return res
|
|
|
|
def _get_catholic_easter(year):
|
|
"""compute date of catholic easter
|
|
|
|
@rtype: datetime.date
|
|
"""
|
|
a, b, c = year % 19, year // 100, year % 100
|
|
d, e = divmod(b,4)
|
|
f = (b + 8) // 25
|
|
g = (b - f + 1) // 3
|
|
h = (19*a + b - d - g + 15) % 30
|
|
i, k = divmod(c,4)
|
|
l = (32 + 2*e + 2*i - h - k) % 7
|
|
m = (a + 11*h + 22*l) // 451
|
|
emonth,edate = divmod(h + l - 7*m + 114,31)
|
|
return date(year, emonth, edate+1)
|
|
|
|
def _strip_empty(sl):
|
|
"""strip empty strings from list I{sl}
|
|
|
|
@rtype: [str,...]
|
|
"""
|
|
return [z for z in sl if z] if sl else []
|
|
|
|
def _flatten(sl):
|
|
"""join list I{sl} into a comma-separated string
|
|
|
|
@rtype: str
|
|
"""
|
|
if not sl: return None
|
|
return ', '.join(sl)
|
|
|
|
class Holiday(object):
|
|
"""class holding a Holiday object (date is I{not} stored, use L{HolidayProvider} for that)
|
|
|
|
@ivar header_list: string list for header (primary text)
|
|
@ivar footer_list: string list for footer (secondary text)
|
|
@ivar flags: bit combination of {OFF=1, MULTI=2, REMINDER=4}
|
|
|
|
I{OFF}: day off (real holiday)
|
|
|
|
I{MULTI}: multi-day event (used to mark long day ranges,
|
|
not necessarily holidays)
|
|
|
|
I{REMINDER}: do not mark the day as holiday
|
|
|
|
@note: Rendering style is determined considering L{flags} in this order:
|
|
1. OFF
|
|
2. MULTI
|
|
|
|
First flag that matches determines the style.
|
|
"""
|
|
OFF = 1
|
|
MULTI = 2
|
|
REMINDER = 4
|
|
def __init__(self, header = [], footer = [], flags_str = None):
|
|
self.header_list = _strip_empty(header)
|
|
self.footer_list = _strip_empty(footer)
|
|
self.flags = self._parse_flags(flags_str)
|
|
|
|
def merge_with(self, hol_list):
|
|
"""merge a list of holiday objects into this object"""
|
|
for hol in hol_list:
|
|
self.header_list.extend(hol.header_list)
|
|
self.footer_list.extend(hol.footer_list)
|
|
self.flags |= hol.flags
|
|
|
|
def header(self):
|
|
"""return a comma-separated string for L{header_list}
|
|
|
|
@rtype: str
|
|
"""
|
|
return _flatten(self.header_list)
|
|
|
|
def footer(self):
|
|
"""return a comma-separated string for L{footer_list}
|
|
|
|
@rtype: str
|
|
"""
|
|
return _flatten(self.footer_list)
|
|
|
|
def __str__(self):
|
|
"""string representation for debugging purposes
|
|
|
|
@rtype: str
|
|
"""
|
|
return str(self.footer()) + ':' + str(self.header()) + ':' + str(self.flags)
|
|
|
|
def _parse_flags(self, fstr):
|
|
"""return a bit combination of flags, from a comma-separated string list
|
|
|
|
@rtype: int
|
|
"""
|
|
if not fstr: return 0
|
|
fs = fstr.split(',')
|
|
val = 0
|
|
for s in fs:
|
|
if s == 'off': val |= Holiday.OFF
|
|
elif s == 'multi': val |= Holiday.MULTI
|
|
# allow for prefix abbrev.
|
|
elif 'reminder'.startswith(s): val |= Holiday.REMINDER
|
|
return val
|
|
|
|
def _decode_date_str(ddef):
|
|
"""decode a date definition string into a I{(year,month,day)} tuple
|
|
|
|
@param ddef: date definition string of length 2, 4 or 8
|
|
|
|
If C{ddef} is of the form "DD" then tuple (0,0,DD) is returned, which
|
|
stands for any year - any month - day DD.
|
|
|
|
If C{ddef} is of the form "MMDD" then tuple (0,MM,DD) is returned, which
|
|
stands for any year - month MM - day DD.
|
|
|
|
If C{ddef} is of the form "YYYYMMDD" then tuple (YYYY,MM,DD) is returned, which
|
|
stands for year YYYY - month MM - day DD.
|
|
|
|
@rtype: (int,int,int)
|
|
"""
|
|
if len(ddef) == 2:
|
|
return (0,0,int(ddef))
|
|
if len(ddef) == 4:
|
|
return (0,int(ddef[:2]),int(ddef[-2:]))
|
|
if len(ddef) == 8:
|
|
return (int(ddef[:4]),int(ddef[4:6]),int(ddef[-2:]))
|
|
raise ValueError("invalid date definition '%s'" % ddef)
|
|
|
|
class HolidayProvider(object):
|
|
"""class holding the holidays throught the year(s)
|
|
|
|
@ivar annual: dict of events occuring annually, indexed by tuple I{(day,month)}. Note
|
|
each dict entry is actually a list of L{Holiday} objects. This is also true for the other
|
|
instance variables: L{monthly}, L{fixed}, L{orth_easter}, L{george}, L{cath_easter}.
|
|
@ivar monthly: event occuring monthly, indexed by int I{day}
|
|
@ivar fixed: fixed date events, indexed by a C{date()} object
|
|
@ivar orth_easter: dict of events relative to the orthodox easter Sunday, indexed by
|
|
an integer days offset
|
|
@ivar george: events occuring on St George's day (orthodox calendar special computation)
|
|
@ivar cath_easter: dict of events relative to the catholic easter Sunday, indexed by
|
|
an integer days offset
|
|
@ivar cache: for each year requested, all holidays occuring
|
|
within that year (annual, monthly, easter-based etc.) are precomputed and stored into
|
|
dict C{cache}, indexed by a C{date()} object
|
|
@ivar ycache: set holding cached years; each new year requested, triggers a cache-fill
|
|
operation
|
|
"""
|
|
def __init__(self, s_normal, s_weekend, s_holiday, s_weekend_holiday, s_multi, s_weekend_multi, multiday_markers=True):
|
|
"""initialize a C{HolidayProvider} object
|
|
|
|
@param s_normal: style class object for normal (weekday) day cells
|
|
@param s_weekend: style for weekend day cells
|
|
@param s_holiday: style for holiday day cells
|
|
@param s_weekend_holiday: style for holiday cells on weekends
|
|
@param s_multi: style for multi-day holiday weekday cells
|
|
@param s_weekend_multi: style for multi-day holiday weekend cells
|
|
@param multiday_markers: if C{True}, then use end-of-multiday-holiday markers and range markers (with dots),
|
|
otherwise only first day and first-day-of-month are marked
|
|
"""
|
|
self.annual = dict() # key = (d,m)
|
|
self.monthly = dict() # key = d
|
|
self.fixed = dict() # key = date()
|
|
self.orth_easter = dict() # key = daysdelta
|
|
self.george = [] # key = n/a
|
|
self.cath_easter = dict() # key = daysdelta
|
|
self.cache = dict() # key = date()
|
|
self.ycache = set() # key = year
|
|
self.s_normal = s_normal
|
|
self.s_weekend = s_weekend
|
|
self.s_holiday = s_holiday
|
|
self.s_weekend_holiday = s_weekend_holiday
|
|
self.s_multi = s_multi
|
|
self.s_weekend_multi = s_weekend_multi
|
|
self.multiday_markers = multiday_markers
|
|
|
|
def _parse_day_record(self, fields):
|
|
"""return tuple (etype,ddef,footer,header,flags)
|
|
|
|
@rtype: (char,type(ddef),str,str,int)
|
|
@note: I{ddef} is one of the following:
|
|
- None
|
|
- int
|
|
- ((y,m,d),)
|
|
- ((y,m,d),(y,m,d))
|
|
"""
|
|
if len(fields) != 5:
|
|
raise ValueError("Too many fields: " + str(fields))
|
|
for i in range(len(fields)):
|
|
if len(fields[i]) == 0: fields[i] = None
|
|
if fields[0] == 'd':
|
|
if fields[1]:
|
|
if '*' in fields[1]:
|
|
if fields[0] != 'd':
|
|
raise ValueError("multi-day events not allowed with event type '%s'" % fields[0])
|
|
dstr,spanstr = fields[1].split('*')
|
|
if len(dstr) != 8:
|
|
raise ValueError("multi-day events allowed only with full date, not '%s'" % dstr)
|
|
span = int(spanstr)
|
|
y,m,d = _decode_date_str(dstr)
|
|
dt1 = date(y,m,d)
|
|
dt2 = dt1 + timedelta(span-1)
|
|
res = ((y,m,d),(dt2.year,dt2.month,dt2.day))
|
|
elif '-' in fields[1]:
|
|
if fields[0] != 'd':
|
|
raise ValueError("multi-day events not allowed with event type '%s'" % fields[0])
|
|
dstr,dstr2 = fields[1].split('-')
|
|
if len(dstr) != 8:
|
|
raise ValueError("multi-day events allowed only with full date, not '%s'" % dstr)
|
|
y,m,d = _decode_date_str(dstr)
|
|
y2,m2,d2 = _decode_date_str(dstr2)
|
|
res = ((y,m,d),(y2,m2,d2))
|
|
else:
|
|
y,m,d = _decode_date_str(fields[1])
|
|
if len(fields[1]) == 8:
|
|
res = ((y,m,d),(y,m,d))
|
|
else:
|
|
res = ((y,m,d),)
|
|
else:
|
|
res = None
|
|
else:
|
|
res = int(fields[1])
|
|
return (fields[0],res,fields[2],fields[3],fields[4])
|
|
|
|
def _multi_holiday_tuple(self, header, footer, flags):
|
|
"""returns a 4-tuple of L{Holiday} objects representing (beginning, end, first-day-of-month, rest)
|
|
|
|
@param header: passed as C{[header]} of the generated L{Holiday} object
|
|
@param footer: passed as C{[footer]} of the generated L{Holiday} object
|
|
@param flags: C{flags} of the generated L{Holiday} object
|
|
@rtype: (Holiday,Holiday,Holiday,Holiday)
|
|
"""
|
|
if header:
|
|
if self.multiday_markers:
|
|
header_tuple = (header+'..', '..'+header, '..'+header+'..', None)
|
|
else:
|
|
header_tuple = (header, None, header, None)
|
|
else:
|
|
header_tuple = (None, None, None, None)
|
|
if footer:
|
|
if self.multiday_markers:
|
|
footer_tuple = (footer+'..', '..'+footer, '..'+footer+'..', None)
|
|
else:
|
|
footer_tuple = (footer, None, footer, None)
|
|
else:
|
|
footer_tuple = (None, None, None, None)
|
|
return tuple([Holiday([header_tuple[k]], [footer_tuple[k]], flags) for k in range(4)])
|
|
|
|
def load_holiday_file(self, filename):
|
|
"""load a holiday file into the C{HolidayProvider} object
|
|
|
|
B{File Format:}
|
|
- C{type|date*span|footer|header|flags}
|
|
- C{type|date1-date2|footer|header|flags}
|
|
- C{type|date|footer|header|flags}
|
|
|
|
I{type:}
|
|
- C{d}: event occurs annually fixed day/month; I{date}=MMDD
|
|
- C{d}: event occurs monthly, fixed day; I{date}=DD
|
|
- C{d}: fixed day/month/year combination (e.g. deadline, trip, etc.); I{date}=YYYYMMDD
|
|
- C{oe}: Orthodox Easter-dependent holiday, annually; I{date}=integer offset in days
|
|
- C{ge}: Georgios' name day, Orthodox Easter dependent holiday, annually; I{date} field is ignored
|
|
- C{ce}: Catholic Easter holiday; I{date}=integer offset in days
|
|
|
|
I{date*span} and range I{date1-date2} supported only for I{date}=YYYYMMDD (fixed) events
|
|
|
|
I{flags:} comma-separated list of the following:
|
|
1. off
|
|
2. multi
|
|
3. reminder (or any prefix of it)
|
|
|
|
B{Example}::
|
|
|
|
d|0101||New year's|off
|
|
d|0501||Labour day|off
|
|
ce|-2||Good Friday|
|
|
ce|0||Easter|off
|
|
ce|1||Easter Monday|off
|
|
d|20130223-20130310|winter vacations (B)||multi
|
|
|
|
@param filename: file to be loaded
|
|
"""
|
|
with open(filename, 'r') as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line: continue
|
|
if line[0] == '#': continue
|
|
fields = line.split('|')
|
|
etype,ddef,footer,header,flags = self._parse_day_record(fields)
|
|
hol = Holiday([header], [footer], flags)
|
|
if etype == 'd':
|
|
if len(ddef) == 1:
|
|
y,m,d = ddef[0]
|
|
if m > 0: # annual event
|
|
if (d,m) not in self.annual: self.annual[(d,m)] = []
|
|
self.annual[(d,m)].append(hol)
|
|
else: # monthly event
|
|
if d not in self.monthly: self.monthly[d] = []
|
|
self.monthly[d].append(hol)
|
|
else: # fixed date event
|
|
dt1,dt2 = date(*ddef[0]),date(*ddef[1])
|
|
span = (dt2-dt1).days + 1
|
|
if span == 1:
|
|
if dt1 not in self.fixed: self.fixed[dt1] = []
|
|
self.fixed[dt1].append(hol)
|
|
else:
|
|
# properly annotate multi-day events
|
|
hols = self._multi_holiday_tuple(header, footer, flags)
|
|
dt = dt1
|
|
while dt <= dt2:
|
|
if dt not in self.fixed: self.fixed[dt] = []
|
|
if dt == dt1: hol = hols[0]
|
|
elif dt == dt2: hol = hols[1]
|
|
elif dt.day == 1: hol = hols[2]
|
|
else: hol = hols[3]
|
|
self.fixed[dt].append(hol)
|
|
dt += timedelta(1)
|
|
|
|
elif etype == 'oe':
|
|
d = ddef
|
|
if d not in self.orth_easter: self.orth_easter[d] = []
|
|
self.orth_easter[d].append(hol)
|
|
elif etype == 'ge':
|
|
self.george.append(hol)
|
|
elif etype == 'ce':
|
|
d = ddef
|
|
if d not in self.cath_easter: self.cath_easter[d] = []
|
|
self.cath_easter[d].append(hol)
|
|
|
|
def get_holiday(self, y, m, d):
|
|
"""return a L{Holiday} object for the specified date (y,m,d) or C{None} if no holiday is defined
|
|
|
|
@rtype: Holiday
|
|
@note: If year I{y} has not been requested before, the cache is updated first
|
|
with all holidays that belong in I{y}, indexed by C{date()} objects.
|
|
"""
|
|
if y not in self.ycache:
|
|
# fill-in events for year y
|
|
# annual
|
|
for d0,m0 in self.annual:
|
|
dt = date(y,m0,d0)
|
|
if not dt in self.cache: self.cache[dt] = Holiday()
|
|
self.cache[dt].merge_with(self.annual[(d0,m0)])
|
|
# monthly
|
|
for d0 in self.monthly:
|
|
for m0 in range(1,13):
|
|
dt = date(y,m0,d0)
|
|
if not dt in self.cache: self.cache[dt] = Holiday()
|
|
self.cache[dt].merge_with(self.monthly[m0])
|
|
# fixed
|
|
for dt in [z for z in self.fixed if z.year == y]:
|
|
if not dt in self.cache: self.cache[dt] = Holiday()
|
|
self.cache[dt].merge_with(self.fixed[dt])
|
|
# orthodox easter
|
|
edt = _get_orthodox_easter(y)
|
|
for delta in self.orth_easter:
|
|
dt = edt + timedelta(delta)
|
|
if not dt in self.cache: self.cache[dt] = Holiday()
|
|
self.cache[dt].merge_with(self.orth_easter[delta])
|
|
# Georgios day
|
|
if self.george:
|
|
dt = date(y,4,23)
|
|
if edt >= dt: dt = edt + timedelta(1) # >= or > ??
|
|
if not dt in self.cache: self.cache[dt] = Holiday()
|
|
self.cache[dt].merge_with(self.george)
|
|
# catholic easter
|
|
edt = _get_catholic_easter(y)
|
|
for delta in self.cath_easter:
|
|
dt = edt + timedelta(delta)
|
|
if not dt in self.cache: self.cache[dt] = Holiday()
|
|
self.cache[dt].merge_with(self.cath_easter[delta])
|
|
|
|
self.ycache.add(y)
|
|
|
|
dt = date(y,m,d)
|
|
return self.cache[dt] if dt in self.cache else None
|
|
|
|
def get_style(self, flags, dow):
|
|
"""return appropriate style object, depending on I{flags} and I{dow}
|
|
|
|
@rtype: Style
|
|
@param flags: bit combination of holiday flags
|
|
@param dow: day of week
|
|
"""
|
|
if flags & Holiday.OFF:
|
|
return self.s_weekend_holiday if dow >= 5 else self.s_holiday
|
|
if flags & Holiday.MULTI:
|
|
return self.s_weekend_multi if dow >= 5 else self.s_multi
|
|
return self.s_weekend if dow >= 5 else self.s_normal
|
|
|
|
def __call__(self, year, month, dom, dow):
|
|
"""returns (header,footer,day_style)
|
|
|
|
@rtype: (str,str,Style)
|
|
@param month: month (0-12)
|
|
@param dom: day of month (1-31)
|
|
@param dow: day of week (0-6)
|
|
"""
|
|
hol = self.get_holiday(year,month,dom)
|
|
if hol:
|
|
return (hol.header(),hol.footer(),self.get_style(hol.flags,dow))
|
|
else:
|
|
return (None,None,self.get_style(0,dow))
|
|
|
|
if __name__ == '__main__':
|
|
import sys
|
|
hp = HolidayProvider('n', 'w', 'h', 'wh', 'm', 'wm')
|
|
if len(sys.argv) < 3:
|
|
raise SystemExit("Usage: %s YEAR holiday_file ..." % sys.argv[0]);
|
|
y = int(sys.argv[1])
|
|
for f in sys.argv[2:]:
|
|
hp.load_holiday_file(f)
|
|
if y == 0: y = date.today().year
|
|
cur = date(y,1,1)
|
|
d2 = date(y,12,31)
|
|
while cur <= d2:
|
|
y,m,d = cur.year, cur.month, cur.day
|
|
hol = hp.get_holiday(y,m,d)
|
|
if hol: print(cur.strftime("%a %b %d %Y"),hol)
|
|
cur += timedelta(1)
|