Skip to content
Snippets Groups Projects

Ecclesiastical calendar in Python

  • Clone with SSH
  • Clone with HTTPS
  • Embed
  • Share
    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.

    Edited
    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)
    0% Loading or .
    You are about to add 0 people to the discussion. Proceed with caution.
    Please register or to comment