diff --git a/README.md b/README.md index 99282da..25e9e03 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ $ typikon ## Install ```console -$ pip install typikon +$ pipx install git+https://git.pumpin.online/funkpower/typikon ``` Or run it straight out of a checkout — it has no dependencies and needs only diff --git a/pyproject.toml b/pyproject.toml index df0a393..bf17424 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ typikon = "typikon.cli:main" include = ["typikon*"] [tool.setuptools.package-data] -typikon = ["data/*.json"] +typikon = ["data/*.json", "data/LICENSE"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/tests/test_typikon.py b/tests/test_typikon.py index 7c82a32..a965e99 100644 --- a/tests/test_typikon.py +++ b/tests/test_typikon.py @@ -7,10 +7,10 @@ import pytest from typikon import describe, gregorian_pascha, julian_pascha, resolve from typikon.calendars import from_julian, julian_offset, to_julian -from typikon.day import week +from typikon.day import week, year_of from typikon.export import to_ics, to_json from typikon.fasting import (DAIRY, FISH, NONE, OBLIGATORY, STRICT, WINE_OIL, - XEROPHAGY) + XEROPHAGY, stricter) # ───────────────────────────── the paschalion ───────────────────────────── @@ -228,3 +228,141 @@ def test_a_calendar_name_resolves_without_naming_a_church(): assert resolve("new").menologion == "revised_julian" assert resolve("western").paschalion == "gregorian" assert resolve(None).key == "new" + + +# ─────────────────── regressions, each from a real defect ────────────────── + +def test_the_annunciation_is_never_lost_to_great_friday(): + """It can fall on Great Friday or Great Saturday, and when it does the + feast lifts the total fast rather than disappearing into it.""" + for when, jurisdiction in ((dt.date(2016, 3, 25), "western"), + (dt.date(2005, 3, 25), "western"), + (dt.date(2029, 4, 7), "rocor"), + (dt.date(2018, 4, 7), "rocor")): + d = describe(when, jurisdiction) + assert d.church_date == "25 March", when + assert "Annunciation" in d.feast, when + assert d.rank == "great", when + assert d.fast == WINE_OIL, (when, d.fast) + + +def test_kyriopascha_and_the_annunciation_in_bright_week_stay_fast_free(): + assert describe(dt.date(2035, 3, 25), "western").fast == NONE + assert describe(dt.date(2075, 4, 7), "rocor").fast == NONE + + +def test_palm_sunday_and_lazarus_saturday_are_still_the_fast(): + """Both fall between the Forty Days and Great Week. Leaving that gap + outside every season made the Byzantine Catholic rule report no fast at + all on Palm Sunday.""" + for jurisdiction in ("ruthenian", "ugcc", "melkite", "goarch", "rocor"): + for year in (2025, 2026, 2027): + mv = year_of(year, jurisdiction) + for when in (mv["lazarus_saturday"], mv["palm_sunday"]): + d = describe(when, jurisdiction) + assert d.season is not None, (jurisdiction, when) + assert d.fast != NONE, (jurisdiction, when, d.fast) + assert d.reason, (jurisdiction, when) + + +def test_a_ranked_saint_relaxes_a_weekday_of_a_fasting_season(): + """The rank is why the printed calendars mark so many Wednesdays in the + Nativity Fast for wine and oil.""" + # The Nativity of the Forerunner, a great feast inside the Apostles' Fast. + assert describe(dt.date(2020, 6, 24), "goarch").fast == FISH + # The Forty Martyrs of Sebaste, the textbook relaxation of a Lenten weekday. + assert describe(dt.date(2022, 3, 9), "goarch").fast == WINE_OIL + # St Nicholas, a vigil saint on a Wednesday of the Nativity Fast. + assert describe(dt.date(2023, 12, 6), "goarch").fast == WINE_OIL + + +def test_no_saint_brings_fish_into_the_great_fast_or_lifts_great_week(): + for d in week(dt.date(2022, 3, 7), 40, "goarch"): + if d.season == "Great Fast" and d.church_date != "25 March": + assert not stricter(FISH, d.fast) or d.fast != FISH, d.date + if d.season == "Great and Holy Week" and d.church_date != "25 March": + assert d.fast in (STRICT, XEROPHAGY, WINE_OIL), (d.date, d.fast) + + +def test_the_eves_relax_when_they_fall_on_a_weekend(): + """The Liturgy of St Basil is served with Vespers, so the eve is kept at + wine and oil rather than xerophagy.""" + assert describe(dt.date(2022, 12, 24), "goarch").fast == WINE_OIL + assert describe(dt.date(2023, 12, 24), "goarch").fast == WINE_OIL + assert describe(dt.date(2025, 1, 5), "goarch").fast == WINE_OIL + # A weekday eve is still xerophagy. + assert describe(dt.date(2024, 12, 24), "goarch").fast == XEROPHAGY + + +def test_the_apostles_fast_is_empty_when_pascha_is_late(): + """A late Pascha can leave the fast with no days at all, which is correct + rather than a bug -- and the old calendar never has that problem.""" + goarch = [d for d in week(dt.date(2024, 5, 1), 120, "goarch") + if d.season == "Apostles' Fast"] + rocor = [d for d in week(dt.date(2024, 5, 1), 120, "rocor") + if d.season == "Apostles' Fast"] + assert goarch == [] + assert len(rocor) > 0 + + +# ──────────────────────── invariants, not samples ────────────────────────── + +def test_the_paschalion_invariants_hold_for_two_centuries(): + for year in range(1900, 2101): + j, g = julian_pascha(year), gregorian_pascha(year) + assert j.weekday() == 6, year + assert g.weekday() == 6, year + assert dt.date(year, 4, 4) <= j <= dt.date(year, 5, 8), (year, j) + assert dt.date(year, 3, 22) <= g <= dt.date(year, 4, 25), (year, g) + + +def test_every_civil_date_round_trips_through_the_julian_calendar(): + day, last = dt.date(1800, 1, 1), dt.date(2200, 12, 31) + while day <= last: + y, m, d = to_julian(day) + assert from_julian(y, m, d) == day, day + day += dt.timedelta(1) + + +def test_the_julian_offset_widens_the_day_after_a_julian_leap_day(): + for before, after in ((dt.date(1900, 3, 13), dt.date(1900, 3, 14)), + (dt.date(2100, 3, 14), dt.date(2100, 3, 15)), + (dt.date(2200, 3, 15), dt.date(2200, 3, 16))): + assert julian_offset(after) == julian_offset(before) + 1, after + + +def test_ics_content_lines_stay_within_seventy_five_octets(): + for jurisdiction in ("goarch", "rocor", "ruthenian"): + text = to_ics(week(dt.date(2026, 1, 1), 365, jurisdiction)) + for line in text.split("\r\n"): + assert len(line.encode()) <= 75, (jurisdiction, line) + + +def test_every_jurisdiction_holds_together_for_twenty_years(): + """The regression net. Replaces a loop that asserted only truthiness and + so could not fail: "none" is a truthy string.""" + from typikon import JURISDICTIONS + from typikon.fasting import LEVELS + for key in JURISDICTIONS: + for d in week(dt.date(2020, 1, 1), 366 * 21 // 4, key): + assert d.fast in LEVELS, (key, d.date, d.fast) + assert d.binds in LEVELS, (key, d.date, d.binds) + assert d.commemorations, (key, d.date) + assert not stricter(d.binds, d.fast), (key, d.date) + assert d.title, (key, d.date) + + +# ──────────────────────────────── the CLI ────────────────────────────────── + +def test_the_cli_rejects_a_malformed_date_instead_of_traversing_a_traceback(): + from typikon.cli import main + assert main(["-d", "tomorrow"]) == 2 + assert main(["-d", "2026-02-30"]) == 2 + assert main(["--year", "99999"]) == 2 + assert main(["--jurisdiction", "atlantis"]) == 2 + + +def test_the_cli_elides_on_a_word(): + from typikon.cli import _elide + assert _elide("The Radiant and Life-giving Resurrection", 24) == "The Radiant and…" + assert _elide("Short", 24) == "Short" diff --git a/typikon/calendars.py b/typikon/calendars.py index 9f0e020..5981d42 100644 --- a/typikon/calendars.py +++ b/typikon/calendars.py @@ -4,7 +4,9 @@ Every date in this library exists twice. There is the **civil** date — what a phone says, always Gregorian — and the **ecclesiastical** date, the day the Church calls it. For a church on the new calendar those are the same date. For one on the old calendar they differ by the drift between the Julian and -Gregorian reckonings: thirteen days from 1900 to 2099, fourteen from 2100. +Gregorian reckonings: thirteen days from 1900 until 14 March 2100, and +fourteen from the 15th -- the day after a Julian 29 February that the +Gregorian calendar does not have. Keeping both is what makes a single menologion serve every jurisdiction. The Nativity is 25 December in the Julian and the Gregorian books alike; the two @@ -84,8 +86,9 @@ def from_julian(year, month, day): def julian_offset(date): """How many days the Julian reckoning currently lags the Gregorian one. - Thirteen for our lifetimes; fourteen from 14 March 2100. Reported rather - than assumed so a caller can show it. + Thirteen for our lifetimes; fourteen from 15 March 2100, the day after a + Julian 29 February the Gregorian calendar does not have. Computed rather + than assumed, so nothing here breaks at the boundary. """ jy, jm, jd = to_julian(date) return (gregorian_to_jdn(date.year, date.month, date.day) diff --git a/typikon/cli.py b/typikon/cli.py index ad07158..18b5bb9 100644 --- a/typikon/cli.py +++ b/typikon/cli.py @@ -187,7 +187,11 @@ def main(argv=None): print(f"typikon: unknown jurisdiction {name!r}; try --list", file=sys.stderr) return 2 - if args.year: + if args.year is not None: + if not 1 <= args.year <= 9999: + print(f"typikon: {args.year} is not a year this calendar reaches", + file=sys.stderr) + return 2 mv = year_of(args.year, jur) print(f"\n {args.year} · {jur.name}\n") for k in ("publican_pharisee", "meatfare", "cheesefare", "clean_monday", @@ -200,7 +204,15 @@ def main(argv=None): print() return 0 - start = dt.date.fromisoformat(args.date) if args.date else dt.date.today() + if args.date: + try: + start = dt.date.fromisoformat(args.date) + except ValueError: + print(f"typikon: {args.date!r} is not a date \u2014 expected YYYY-MM-DD", + file=sys.stderr) + return 2 + else: + start = dt.date.today() days = week(start, args.days, jur) if args.json: diff --git a/typikon/data/menologion.json b/typikon/data/menologion.json index cd04a46..c5c8e50 100644 --- a/typikon/data/menologion.json +++ b/typikon/data/menologion.json @@ -31,7 +31,7 @@ "The Holy Martyrs Theopemptus and Theonas", "Our Venerable Mother Syncletica" ], - "cycle": "the eve of the Theophany" + "cycle": "forefeast of the Theophany" }, "01-06": { "commemorations": [ @@ -956,7 +956,7 @@ "commemorations": [ "The Nativity of the Honourable and Glorious Prophet, Forerunner and Baptist John" ], - "rank": "vigil" + "rank": "great" }, "06-25": { "commemorations": [ @@ -976,8 +976,7 @@ "06-28": { "commemorations": [ "The Translation of the Relics of the Holy Unmercenaries Cyrus and John" - ], - "cycle": "the eve of Ss Peter and Paul" + ] }, "06-29": { "commemorations": [ @@ -1160,15 +1159,13 @@ "07-31": { "commemorations": [ "The Holy and Righteous Eudocimus" - ], - "cycle": "the eve of the Procession of the Cross" + ] }, "08-01": { "commemorations": [ "The Procession of the Precious and Life-giving Cross", "The Seven Holy Maccabee Martyrs, their mother Solomone and their teacher Eleazar" ], - "cycle": "the Dormition Fast begins", "rank": "polyeleos" }, "08-02": { @@ -1788,8 +1785,7 @@ "11-15": { "commemorations": [ "The Holy Martyrs Gurias, Samonas and Habib" - ], - "cycle": "the Nativity Fast begins" + ] }, "11-16": { "commemorations": [ @@ -2014,7 +2010,7 @@ "commemorations": [ "The Holy Venerable Martyr Eugenia" ], - "cycle": "the eve of the Nativity" + "cycle": "forefeast of the Nativity" }, "12-25": { "commemorations": [ diff --git a/typikon/day.py b/typikon/day.py index 1f8bdca..8e3f11e 100644 --- a/typikon/day.py +++ b/typikon/day.py @@ -98,6 +98,10 @@ class Day: return out +# How the degrees of solemnity order against each other, for the days when a +# movable feast and a fixed one fall together. +_RANKS = {"great": 3, "vigil": 2, "polyeleos": 1} + _WEEKDAYS = ("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday") _MONTHS = ("January", "February", "March", "April", "May", "June", "July", @@ -120,13 +124,26 @@ def describe(date, jurisdiction=None): rank = entry.get("rank") cycle = entry.get("cycle") - # A movable feast outranks whatever the fixed calendar holds that day. + # When a movable feast and a fixed one land together, the higher rank is + # the day's feast and the other joins the commemorations. Letting the + # movable one win unconditionally lost the Annunciation whenever it fell in + # Great Week -- the Great Feast of the Incarnation demoted to a bullet, and + # its rank overwritten with the lesser one the fasting rules then read. + fixed_feast = commemorations[0] if rank in ("great", "vigil") else None mov_title, mov_rank = _menologion.movable_feast_for(date, mv) - if mov_title: + + if mov_title and fixed_feast and _RANKS.get(rank, 0) > _RANKS.get(mov_rank, 0): + feast = fixed_feast + if mov_title not in commemorations: + commemorations.insert(1, mov_title) + elif mov_title: feast = mov_title - rank = mov_rank or rank + if _RANKS.get(mov_rank, 0) >= _RANKS.get(rank, 0): + rank = mov_rank + if fixed_feast and fixed_feast not in commemorations: + commemorations.insert(1, fixed_feast) else: - feast = commemorations[0] if rank in ("great", "vigil") else None + feast = fixed_feast ctx = _Context( civil=date, fixed=(fm, fd), fixed_year=fy, weekday=date.weekday(), diff --git a/typikon/export/ics.py b/typikon/export/ics.py index 470de00..b0cdff3 100644 --- a/typikon/export/ics.py +++ b/typikon/export/ics.py @@ -10,7 +10,10 @@ import datetime as dt __all__ = ["to_ics"] -_FOLD = 73 +# RFC 5545: a content line SHOULD NOT be longer than 75 **octets**, excluding +# the line break. Counting characters instead put a line with four emoji eight +# octets over, and the em-dashes three each. +_FOLD = 75 _BACKSLASH = chr(92) @@ -33,12 +36,20 @@ def _esc(text): def _fold(line): - """RFC 5545 wants lines under 75 octets, continued with a leading space.""" - out = [line[:_FOLD]] - rest = line[_FOLD:] - while rest: - out.append(" " + rest[:_FOLD - 1]) - rest = rest[_FOLD - 1:] + """Break a content line on octets, continued with a leading space. + + The split never falls inside a character: the budget is spent per + codepoint, so a four-octet emoji moves to the next line whole. + """ + out, cur, used = [], [], 0 + for ch in line: + n = len(ch.encode("utf-8")) + if used + n > _FOLD: + out.append("".join(cur)) + cur, used = [" "], 1 + cur.append(ch) + used += n + out.append("".join(cur)) return "\r\n".join(out) diff --git a/typikon/menologion.py b/typikon/menologion.py index f7a7dc7..a5668ab 100644 --- a/typikon/menologion.py +++ b/typikon/menologion.py @@ -74,8 +74,19 @@ def load(name="menologion"): return m +_overlaid = {} + + def for_jurisdiction(jur): - """The common menologion with any supplementary sets overlaid.""" + """The common menologion with any supplementary sets overlaid. + + Cached per jurisdiction: the overlay copies all 366 days, and rebuilding it + for every date asked about made a year of a jurisdiction with a + supplementary set ten times slower than one without. + """ + key = (jur.key, jur.menaia) + if key in _overlaid: + return _overlaid[key] m = load() for extra in jur.menaia: try: @@ -83,6 +94,7 @@ def for_jurisdiction(jur): except FileNotFoundError: # A supplementary set is optional; its absence is not an error. pass + _overlaid[key] = m return m diff --git a/typikon/rules/orthodox.py b/typikon/rules/orthodox.py index 1aa2a4f..27b7593 100644 --- a/typikon/rules/orthodox.py +++ b/typikon/rules/orthodox.py @@ -16,16 +16,23 @@ reason. The known divergences: given wine and oil. Several jurisdictions print them as strict. - **Lazarus Saturday** is given wine and oil; caviar is the traditional addition, which this vocabulary has no level for. + - **Great Week** is kept at xerophagy on its weekdays whatever saint falls in + it, because the days of the Passion outrank the menologion. - **Monastic practice**, and the Holy Mountain in particular, is stricter than any of this throughout. +A saint of vigil or polyeleos rank lifts the weekday discipline of a fast — to +wine and oil inside the Great Fast, as far as fish in the three lesser fasts. +That is why the printed calendars mark so many Wednesdays in the Nativity Fast +for wine and oil, and it is the rank in the menologion that decides it. + A calendar cannot settle those. Your bishop and your spiritual father can. """ import datetime as dt from ..fasting import (Fast, STRICT, XEROPHAGY, WINE_OIL, FISH, DAIRY, NONE, - CUSTOMARY) + CUSTOMARY, laxest, stricter, strictest) from ..seasons import (GREAT_FAST, GREAT_WEEK, BRIGHT_WEEK, CHEESEFARE_WEEK, APOSTLES, DORMITION, NATIVITY_FAST, PENTECOST_WEEK, CHRISTMASTIDE) @@ -43,7 +50,7 @@ VOCABULARY = { def _relaxed_by_feast(rank): - """How far a feast lifts an ordinary Wednesday or Friday fast.""" + """How far a feast lifts a fast day.""" if rank == "great": return FISH if rank in ("vigil", "polyeleos"): @@ -51,6 +58,23 @@ def _relaxed_by_feast(rank): return None +def _for_feast(level, ctx, cap, reason): + """Relax `level` for the day's saint, never past `cap`, never tightening. + + This is the rule the printed calendars are applying when they mark a + Wednesday in the Nativity Fast for wine and oil: the day's rank lifts the + weekday discipline. Inside the Great Fast the lift stops at wine and oil — + no saint brings fish into Lent — and elsewhere it stops at fish. + """ + relaxed = _relaxed_by_feast(ctx.rank) + if relaxed is None: + return Fast(level, reason, CUSTOMARY) + lifted = strictest(laxest(level, relaxed), cap) + if lifted == level or stricter(lifted, level): + return Fast(level, reason, CUSTOMARY) + return Fast(lifted, f"{ctx.feast} \u2014 the fast is relaxed", CUSTOMARY) + + def fast_for(ctx): md, wd, mv = ctx.fixed, ctx.weekday, ctx.mv season = ctx.season @@ -71,6 +95,19 @@ def fast_for(ctx): # ── The Great Fast and Great Week if season in (GREAT_FAST, GREAT_WEEK): + # The Annunciation is tested before the strict days, not after. It can + # land on Great Friday or Great Saturday, and when it does the feast + # still lifts the total fast to wine and oil -- it does not vanish. + if md == (3, 25): + if ctx.civil in (mv["great_friday"], mv["holy_saturday"]): + return Fast(WINE_OIL, + "the Annunciation, on a day of the total fast", + CUSTOMARY) + return Fast(FISH, "the Annunciation", CUSTOMARY) + if ctx.civil == mv["palm_sunday"]: + return Fast(FISH, "Palm Sunday", CUSTOMARY) + if ctx.civil == mv["lazarus_saturday"]: + return Fast(WINE_OIL, "Lazarus Saturday", CUSTOMARY) if ctx.civil == mv["clean_monday"]: return Fast(STRICT, "the first day of the Great Fast", CUSTOMARY) if ctx.civil == mv["great_friday"]: @@ -78,18 +115,14 @@ def fast_for(ctx): if ctx.civil == mv["holy_saturday"]: # The one Saturday of the year kept without oil. return Fast(XEROPHAGY, "Great and Holy Saturday", CUSTOMARY) - if md == (3, 25): - return Fast(FISH, "the Annunciation", CUSTOMARY) if ctx.civil == mv["great_thursday"]: return Fast(WINE_OIL, "Great and Holy Thursday", CUSTOMARY) + # Great Week keeps its own rank; a saint does not lift it. + if season == GREAT_WEEK: + return Fast(XEROPHAGY, f"a weekday of the {season}", CUSTOMARY) if weekend: return Fast(WINE_OIL, f"Saturday and Sunday of the {season}", CUSTOMARY) - return Fast(XEROPHAGY, f"a weekday of the {season}", CUSTOMARY) - - if ctx.civil == mv["palm_sunday"]: - return Fast(FISH, "Palm Sunday", CUSTOMARY) - if ctx.civil == mv["lazarus_saturday"]: - return Fast(WINE_OIL, "Lazarus Saturday", CUSTOMARY) + return _for_feast(XEROPHAGY, ctx, WINE_OIL, f"a weekday of the {season}") # ── Cheesefare: meat is set aside for good, everything else stays, # Wednesday and Friday included. @@ -101,28 +134,36 @@ def fast_for(ctx): # ── The Nativity Fast, which tightens for its last five days if season == NATIVITY_FAST: if md == (12, 24): + # The eve is relaxed to wine and oil on a Saturday or Sunday, + # because the Liturgy of St Basil is served with Vespers. + if weekend: + return Fast(WINE_OIL, "the eve of the Nativity, on a weekend", + CUSTOMARY) return Fast(XEROPHAGY, "the eve of the Nativity", CUSTOMARY) if md == (11, 21): return Fast(FISH, "the Entrance of the Theotokos", CUSTOMARY) if md >= (12, 20): if wd in (TUE, THU) or weekend: return Fast(WINE_OIL, "the forefeast of the Nativity", CUSTOMARY) - return Fast(XEROPHAGY, "the forefeast of the Nativity", CUSTOMARY) + return _for_feast(XEROPHAGY, ctx, WINE_OIL, + "the forefeast of the Nativity") if weekend: return Fast(FISH, "Saturday and Sunday of the Nativity Fast", CUSTOMARY) if wd in (TUE, THU): - return Fast(WINE_OIL, "Tuesday and Thursday of the Nativity Fast", CUSTOMARY) - return Fast(XEROPHAGY, "Monday, Wednesday and Friday of the Nativity Fast", - CUSTOMARY) + return _for_feast(WINE_OIL, ctx, FISH, + "Tuesday and Thursday of the Nativity Fast") + return _for_feast(XEROPHAGY, ctx, FISH, + "Monday, Wednesday and Friday of the Nativity Fast") # ── The Apostles' Fast, kept the same way if season == APOSTLES: if weekend: return Fast(FISH, "Saturday and Sunday of the Apostles' Fast", CUSTOMARY) if wd in (TUE, THU): - return Fast(WINE_OIL, "Tuesday and Thursday of the Apostles' Fast", CUSTOMARY) - return Fast(XEROPHAGY, "Monday, Wednesday and Friday of the Apostles' Fast", - CUSTOMARY) + return _for_feast(WINE_OIL, ctx, FISH, + "Tuesday and Thursday of the Apostles' Fast") + return _for_feast(XEROPHAGY, ctx, FISH, + "Monday, Wednesday and Friday of the Apostles' Fast") # ── The Dormition Fast, kept like the Great Fast if season == DORMITION: @@ -130,10 +171,13 @@ def fast_for(ctx): return Fast(FISH, "the Transfiguration", CUSTOMARY) if weekend: return Fast(WINE_OIL, "Saturday and Sunday of the Dormition Fast", CUSTOMARY) - return Fast(XEROPHAGY, "a weekday of the Dormition Fast", CUSTOMARY) + return _for_feast(XEROPHAGY, ctx, WINE_OIL, + "a weekday of the Dormition Fast") # ── The three fixed fast days outside any season if md == (1, 5): + if weekend: + return Fast(WINE_OIL, "the eve of Theophany, on a weekend", CUSTOMARY) return Fast(XEROPHAGY, "the eve of Theophany", CUSTOMARY) if md == (9, 14): return Fast(WINE_OIL, "the Exaltation of the Holy Cross", CUSTOMARY) diff --git a/typikon/seasons.py b/typikon/seasons.py index 0cdfc81..0f50b3d 100644 --- a/typikon/seasons.py +++ b/typikon/seasons.py @@ -1,5 +1,11 @@ """Which season of the year a day falls in. +The Great Fast here runs from Clean Monday through Palm Sunday. The Forty Days +proper end on the Friday before Lazarus Saturday — ``great_fast_end`` in the +movable cycle, which is what the fasting rules count from — but a season is a +label for a reader, and a Palm Sunday belonging to no season at all is worse +than one belonging loosely to the Fast. + The movable seasons are counted from Pascha and so are compared as civil dates. The fixed ones — the Nativity and Dormition Fasts, the fast-free days after the Nativity — are compared in **ecclesiastical** space, against the date @@ -41,7 +47,11 @@ def season_for(civil, fixed_md, mv, apostles_end): return BRIGHT_WEEK if mv["great_week_start"] <= civil <= mv["holy_saturday"]: return GREAT_WEEK - if mv["clean_monday"] <= civil <= mv["great_fast_end"]: + # Lazarus Saturday and Palm Sunday sit between the Forty Days, which end on + # the Friday before them, and Great Week, which begins after them. Leaving + # that gap unnamed left both days with no season at all, and any rule that + # keyed on the season fell through to "no fast" on Palm Sunday. + if mv["clean_monday"] <= civil <= mv["palm_sunday"]: return GREAT_FAST if mv["cheesefare_week_start"] <= civil <= mv["cheesefare"]: return CHEESEFARE_WEEK