Ecclesiastical calendar in Python
The snippet can be accessed without any authentication.
Authored by
Daphne Preston-Kendal
Uses the calendar of the Revised Table of Lessons 1922 and the festivals from the 1662 Prayer Book, but almost all the rules are encoded in the tables at the top, and it should be flexible enough to accommodate the rules of just about any Anglican province. (The Roman calendar is considerably more complex and probably cannot easily be entered into these tables.)
Copyright © 2018. Licenced under the EUPL v1.2. If these licence terms are not acceptable to you, contact me to enquire about commercial licencing.
kalendar.py 15.28 KiB
import bisect
from collections import namedtuple
from datetime import date, timedelta
### TABLES FOR THE CHRISTIAN YEAR ###
## TODO: make this some kind of modular system so that different kalendars can be loaded
last_sunday_name = "Sunday Next before Advent"
advent_sundays = [
'Advent I',
'Advent II',
'Advent III',
'Advent IV',
]
# feasts only go in this list if there are Sundays which happen relative to them
fixed_feasts = [
('Christmas Day', (25, 12)),
('Epiphany', (6, 1)),
]
fixed_feast_sundays = {
'Christmas Day': ['Christmas I', 'Christmas II'],
'Epiphany': ['Epiphany I', 'Epiphany II', 'Epiphany III', 'Epiphany IV', 'Epiphany V', 'Epiphany VI'],
}
red_letter_days = [
('St Thomas', (21, 12)),
('St Stephen', (26, 12)),
('St John', (27, 12)),
('Holy Innocents', (28, 12)),
('Circumcision', (1, 1)),
('Conversion of St Paul', (25, 1)),
('Purification', (2, 2)),
('St Matthias', (24, 2)),
('Annunciation', (25, 3)),
('St Mark', (25, 4)),
('St Philip and St James', (1, 5)),
('St Barnabas', (11, 6)),
('St John the Baptist', (24, 6)),
('St Peter', (29, 6)),
('St Mary Magdalene', (22, 7)),
('St James', (25, 7)),
('Transfiguration', (6, 8)),
('St Bartholomew', (24, 8)),
('St Matthew', (21, 9)),
('St Michael', (29, 9)),
('St Luke', (18, 10)),
('St Simon and St Jude', (28, 10)),
('All Saints', (1, 11)),
('St Andrew', (30, 11)),
]
black_letter_days = [
('St Lucian', (8, 1)),
('St Hilary', (13, 1)),
('St Prisca', (18, 1)),
('St Fabian', (20, 1)),
('St Agnes', (21, 1)),
('St Vincent', (22, 1)),
('St Agatha', (5, 2)),
('St Valentine', (14, 2)),
('St David', (1, 3)),
('St Chad', (2, 3)),
('St Perpetua', (7, 3)),
('St Gregory', (12, 3)),
('St Edward the Martyr', (18, 3)),
('St Benedict', (21, 3)),
('St Richard of Chichester', (3, 4)),
('St Ambrose', (4, 4)),
('St Alphege', (19, 4)),
('St George', (23, 4)),
('Invention of the Holy Cross', (3, 5)),
('St John at the Latin Gate', (6, 5)),
('St Dunstan', (19, 5)),
('St Augustine of Canterbury', (26, 5)),
('St Bede', (27, 5)),
('St Nicomede', (1, 6)),
('St Boniface', (5, 6)),
('St Alban', (17, 6)),
('Translation of King Edward', (20, 6)),
('Visitation', (2, 7)),
('Translation of St Martin', (4, 7)),
('St Swithun', (15, 7)),
('St Margaret', (20, 6)),
('St Anne', (26, 7)),
('Lammas Day', (1, 8)),
('Name of Jesus', (7, 8)),
('St Lawrence', (10, 8)),
('St Augustine of Hippo', (28, 8)),
('Beheading of St John the Baptist', (29, 8)),
('St Giles', (1, 9)),
('St Enurchus', (7, 9)),
('Nativity of the Virgin Mary', (8, 9)),
('Holy Cross', (14, 9)),
('St Lambert of Maastricht', (17, 9)),
('St Cyprian', (26, 9)),
('St Jerome', (30, 9)),
('St Remigius of Rheims', (1, 10)),
('St Faith', (6, 10)),
('St Denys', (9, 10)),
('Translation of King Edward the Confessor', (13, 10)),
('St Etheldreda', (17, 10)),
('St Crispin', (25, 10)),
('St Leonard', (6, 11)),
('St Martin', (11, 11)),
('St Britius', (13, 11)),
('St Machutus', (15, 11)),
('St Hugh', (17, 11)),
('St Edmund the Martyr', (20, 11)),
('St Cecilia', (22, 11)),
('St Clement', (23, 11)),
('St Catherine', (25, 11)),
('St Nicholas', (6, 12)),
('Conception of the Virgin Mary', (8, 12)),
('St Lucy', (13, 12)),
('O Sapientia', (16, 12)),
('St Silvester', (31, 12)),
]
moveable_feasts = [
('Septuagesima', -7 * 9),
('Sexagesima', -7 * 8),
('Quinquagesima', -7 * 7),
('Ash Wednesday', (-7 * 6) - 4),
('Lent I', -7 * 6),
('Lent II', -7 * 5),
('Lent III',-7 * 4),
('Lent IV', -7 * 3),
('Lent V', -7 * 2),
('Palm Sunday', -7 * 1),
('Easter Sunday', 0),
('Easter I', 7 * 1),
('Easter II', 7 * 2),
('Easter III', 7 * 3),
('Easter IV', 7 * 4),
('Easter V', 7 * 5),
('Ascension', (7 * 5) + 4),
('Ascension Sunday', 7 * 6),
('Whitsunday', 7 * 7),
('Trinity Sunday', 7 * 8),
('Trinity I', 7 * 9),
('Trinity II', 7 * 10),
('Trinity III',7 * 11),
('Trinity IV', 7 * 12),
('Trinity V', 7 * 13),
('Trinity VI', 7 * 14),
('Trinity VII',7 * 15),
('Trinity VIII',7 * 16),
('Trinity IX', 7 * 17),
('Trinity X', 7 * 18),
('Trinity XI', 7 * 19),
('Trinity XII',7 * 20),
('Trinity XIII',7 * 21),
('Trinity XIV',7 * 22),
('Trinity XV', 7 * 23),
('Trinity XVI',7 * 24),
('Trinity XVII',7 * 25),
('Trinity XVIII', 7 * 26),
('Trinity XIX',7 * 27),
('Trinity XX', 7 * 28),
('Trinity XXI',7 * 29),
('Trinity XXII',7 * 30),
('Trinity XXIII',7 * 31),
('Trinity XXIV', 7 * 32),
('Trinity XXV', 7 * 33),
('Trinity XXVI', 7 * 34),
('Trinity XXVII', 7 * 35),
]
transference_seasons = [
# Ash Wednesday -> the Friday after Ash Wednesday
('Ash Wednesday', 0, (-7 * 6) - 2),
# Passion Sunday -> the Tuesday after Passion Sunday
('Lent V', 0, (-7 * 2) + 2),
# Palm Sunday and 14 days after (i.e. Holy Week and the Octave of Easter) -> the Tuesday after Easter I
('Palm Sunday', 14, (7 * 1) + 2),
# Ascension Day -> the Friday after Ascension Day
('Ascension', 0, (7 * 5) + 5),
# Whitsunday and 7 days after -> the Tuesday after Trinity Sunday
('Whitsunday', 7, (7 * 8) + 2),
]
special_transference_rules = {
'St Philip and St James': lambda cy, ntd: cy.easter + timedelta(days=(7 * 1) + 2) if (cy.easter.month == 4 and cy.easter.day in {22, 24, 25}) else ntd,
'St Mark': lambda cy, ntd: cy.easter + timedelta(days=(7 * 1) + 4) if (cy.easter.month == 4 and cy.easter.day in {22, 24, 25}) else ntd,
}
### END OF THE TABLES ###
def easter(year):
# The 'Anonymous Gregorian algorithm'
# deep magic: no computus, only Easter!!
Y = year
a = Y % 19
b = Y // 100
c = Y % 100
d = b // 4
e = b % 4
f = (b + 8) // 25
g = (b - f + 1) // 3
h = ((19 * a) + b - d - g + 15) % 30
i = c // 4
k = c % 4
l = (32 + (2 * e) + (2 * i) - h - k) % 7
m = (a + (11 * h) + (22 * l)) // 451
month = (h + l - (7 * m) + 114) // 31
day = ((h + l - (7 * m) + 114) % 31) + 1
return date(year, month, day)
def advent_sunday(year):
christmas_day = date(year, 12, 25)
advent_iv = christmas_day - timedelta(days=christmas_day.isoweekday())
return advent_iv - timedelta(days = 7 * 3)
Day = namedtuple('Day', 'feast days_after transferred other_observances')
class Year:
def __init__(self, principal_year=None):
if principal_year is None:
principal_year = date.today()
if isinstance(principal_year, date):
today = principal_year
if today >= advent_sunday(today.year):
principal_year = today.year + 1
else:
principal_year = today.year
self.principal_year = principal_year
self.__moveable_feasts = None
self.__transference_dates = None
self.__red_letter_days = None
self.__black_letter_days = None
self.__fixed_feasts = None
self.__transferred_feasts = set()
@property
def calendar_years(self):
return (self.principal_year - 1, self.principal_year)
@property
def advent_sunday(self):
return advent_sunday(self.principal_year - 1)
@property
def easter(self):
return easter(self.principal_year)
@property
def last_sunday(self):
return advent_sunday(self.principal_year) - timedelta(days=7)
@property
def rcl_year(self):
return ['A', 'B', 'C'][self.advent_sunday.year % 3]
@property
def moveable_feasts(self):
if self.__moveable_feasts: return self.__moveable_feasts
easter = self.easter
self.__moveable_feasts = [(easter + timedelta(days=days_after_easter), name) for name, days_after_easter in moveable_feasts]
return self.__moveable_feasts
@property
def transference_dates(self):
if self.__transference_dates: return self.__transference_dates
moveable_feasts = {k: v for v, k in self.moveable_feasts}
transference_dates = {}
for start_day, n_days_after, target_day_count in transference_seasons:
target_day = self.easter + timedelta(days=target_day_count)
transference_dates[moveable_feasts[start_day]] = target_day
for n in range(n_days_after + 1):
transference_dates[moveable_feasts[start_day] + timedelta(days=n)] = target_day
self.__transference_dates = transference_dates
return transference_dates
def first_moveable_feast(self):
return (moveable_feasts[0][0], self.easter + timedelta(days=moveable_feasts[0][1]))
def __contains__(self, day):
if day < self.advent_sunday or day >= advent_sunday(self.principal_year):
return False
else:
return True
@property
def transferred_feasts(self):
return (self.red_letter_days and self.__transferred_feasts)
def dates_within(self, day, month):
date_in_principal_year = date(self.principal_year, month, day)
date_in_starting_year = date(self.principal_year - 1, month, day)
dates = []
if date_in_starting_year in self: dates.append(date_in_starting_year)
if date_in_principal_year in self: dates.append(date_in_principal_year)
return dates
@property
def red_letter_days(self):
if self.__red_letter_days: return self.__red_letter_days
rlds = []
for name, date in red_letter_days:
for rldate in self.dates_within(*date):
if rldate in self.transference_dates:
target = self.transference_dates[rldate]
self.__transferred_feasts.add(name)
if name in special_transference_rules:
rlds.append((name, special_transference_rules[name](self, target)))
else:
rlds.append((name, target))
else:
rlds.append((name, rldate))
rlds = sorted(rlds, key=lambda x: x[1])
self.__red_letter_days = rlds
return rlds
@property
def black_letter_days(self):
if self.__black_letter_days: return self.__black_letter_days
blds = {}
for name, date in black_letter_days:
for bldate in self.dates_within(*date):
# a black letter day lapses if it falls on a Sunday or would need to be transferred
if bldate.weekday() == 6 or bldate in self.transference_dates:
continue
else:
blds[bldate] = name
self.__black_letter_days = blds
return blds
def red_letter_day(self, day):
# todo: replace with bisect? (implies reversing red_letter_days to be (date, name))
for name, rlday in self.red_letter_days:
if day == rlday:
return name
def black_letter_day(self, day):
return self.black_letter_days.get(day)
@property
def fixed_feasts(self):
if self.__fixed_feasts: return self.__fixed_feasts
ff = []
for name, date in fixed_feasts:
for ffdate in self.dates_within(*date):
ff.append((name, ffdate))
ff = sorted(ff, key=lambda x: x[1])
self.__fixed_feasts = ff
return ff
def day(self, day=None, include_red_letter=True):
if day is None: day = date.today()
if day not in self:
raise IndexError(f"{day!r} is out of range for the church year {self.calendar_years!r}")
other_observances = set()
_, moveable_feasts_start = self.first_moveable_feast()
rld = self.red_letter_day(day)
bld = self.black_letter_day(day)
if bld: other_observances.add(bld)
if rld and include_red_letter:
# todo: add the most recent feast to the other observances, or today's moveable feast for non-transferred red letter days
without_red_letter = self.day(day, include_red_letter=False)
other_observances = set()
if without_red_letter.days_after == 0:
other_observances.add(without_red_letter.feast)
return Day(
feast=rld,
days_after=0,
transferred=(rld in self.transferred_feasts),
other_observances=other_observances
)
elif day >= self.last_sunday:
# Sunday Next before Advent
return Day(
feast=last_sunday_name,
days_after=(day - self.last_sunday).days,
transferred=False,
other_observances=other_observances,
)
elif day >= moveable_feasts_start:
# Septuagesima to the end of the church year
idx = bisect.bisect_left(self.moveable_feasts, (day, ''))
if self.moveable_feasts[idx][0] > day: idx = idx - 1
most_recent_feast_date, name = self.moveable_feasts[idx]
return Day(
feast=name,
days_after=(day - most_recent_feast_date).days,
transferred=False,
other_observances=other_observances
)
elif day >= self.fixed_feasts[0][1]:
# Christmastide, Epiphanytide
for name, feast_date in reversed(self.fixed_feasts):
if day == feast_date:
return Day(feast=name, transferred=False, days_after=0, other_observances=other_observances)
elif day > feast_date:
days_till_first_sunday_after = (6 - feast_date.weekday())
first_sunday_after = feast_date + timedelta(days=days_till_first_sunday_after)
if day < first_sunday_after:
return Day(
feast=name,
days_after=(day - feast_date).days,
transferred=False,
other_observances=other_observances
)
else:
n_sundays_after = (day - first_sunday_after).days // 7
n_weekdays_after = (day - first_sunday_after).days % 7
return Day(
feast=fixed_feast_sundays[name][n_sundays_after],
days_after=n_weekdays_after,
transferred=False,
other_observances=other_observances
)
else:
continue
else:
# Advent
n_days_into_advents = (day - self.advent_sunday).days
n_weeks_into_advent = n_days_into_advents // 7
n_weekdays_after = n_days_into_advents % 7
return Day(
feast=advent_sundays[n_weeks_into_advent],
days_after=n_weekdays_after,
transferred=False,
other_observances=other_observances
)
def __iter__(self):
curr_day = self.advent_sunday
while curr_day < advent_sunday(self.principal_year):
yield (curr_day, self.day(curr_day))
curr_day = curr_day + timedelta(days=1)
Please register or sign in to comment