the Orthodox liturgical day, computed rather than fetched

Feasts, seasons and the fast for twenty-three jurisdictions, derived from
the paschalion and the fixed calendar so it needs no network and cannot go
stale.

The three things calendar code usually conflates are kept apart: the
paschalion that gives Pascha, the menologion the fixed feasts are read on,
and the fasting rule that is kept. Almost every Orthodox Church keeps the
Julian Paschalion; what divides them is the menologion, so one file of
commemorations indexed by ecclesiastical date serves old and new calendars
alike.

Six fasting levels rather than three, because the difference between
xerophagy and wine-and-oil is the difference the books actually make. Level
and obligation are carried separately: the Orthodox rule reports everything
as customary, and Ruthenian particular law reports both how a day is kept
and the far smaller minimum that binds.

The menologion covers all 366 days and is dedicated to the public domain.
Sets published by someone else are fetched, never redistributed.
This commit is contained in:
funkpower
2026-09-18 09:09:32 -04:00
commit 11bbfddbe1
24 changed files with 4556 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
__pycache__/
*.py[cod]
.venv/
build/
dist/
*.egg-info/
.pytest_cache/
# Supplementary commemoration sets are fetched, never committed — they are
# other people's work. See typikon/menaia.py.
typikon/data/*.json
!typikon/data/menologion.json
+338
View File
@@ -0,0 +1,338 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
<https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
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 2 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 <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Moe Ghoul>, 1 April 1989
Moe Ghoul, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.
+186
View File
@@ -0,0 +1,186 @@
# typikon
The Orthodox liturgical day — feasts, seasons and the fast — computed rather
than fetched.
Everything derives from the paschalion and the fixed calendar, so it needs no
network, cannot go stale, and answers for any year you ask about. No API key,
no service that might be gone next year, no cache to invalidate.
```console
$ typikon
Wednesday 18 March 2026 — 5 March in the church calendar
Russian Orthodox Church Outside Russia · the Typikon
🥬 The Holy Martyr Conon
xerophagy — no oil or wine
a weekday of the Great Fast
Great Fast
```
## Install
```console
$ pip install typikon
```
Or run it straight out of a checkout — it has no dependencies and needs only
Python 3.9.
```console
$ git clone https://git.pumpin.online/funkpower/typikon
$ cd typikon && python -m typikon.cli
```
## Which calendar?
Three things get conflated constantly, and keeping them apart is most of what
this library is for:
| | |
|---|---|
| **Paschalion** | which table gives Pascha — Julian or Gregorian |
| **Menologion** | which calendar the fixed feasts are read on |
| **Rule** | which fasting discipline is kept |
Almost every Orthodox Church keeps the **Julian Paschalion**. What divides them
is the menologion. The old-calendar churches read the fixed feasts on the
Julian calendar too; the new-calendar churches read them on the Revised Julian,
which agrees with the Gregorian for every date until 2800. So a new-calendar
church keeps the Nativity with the West and Pascha with the East — not a
compromise but exactly what the 1923 congress decided.
Pick a reckoning without naming a church:
```console
$ typikon --calendar old # Julian throughout
$ typikon --calendar new # Revised Julian fixed feasts, Julian Pascha
$ typikon --calendar western # Gregorian throughout
```
Or name one, which also picks up its fasting rule and any local saints:
```console
$ typikon --jurisdiction rocor
$ typikon --jurisdiction antiochian --days 7
$ typikon --list # every accepted value
```
The first run writes your choice to `~/.config/typikon/config.toml` and stops
asking. Change it with `--set-default`, override it for one run with the flag,
or set `TYPIKON_JURISDICTION`.
Twenty-three jurisdictions ship: the old-calendar churches (Moscow, ROCOR,
Serbia, Georgia, Jerusalem, the Holy Mountain, Poland, Macedonia), the
new-calendar ones (Constantinople, Greece, GOARCH, Antioch, the OCA, Romania,
Bulgaria, Cyprus, Albania, Ukraine), the Finnish Orthodox Church — alone among
the Orthodox in keeping the Gregorian Paschalion — and, as an add-on, four
Byzantine Catholic churches.
## The fast
Six levels, ordered from strictest to laxest. They are the Typikon's own
distinctions, not a simplification of them:
| | |
|---|---|
| `strict` | no food until evening |
| `xerophagy` | no oil, no wine; no meat, fish, eggs or dairy |
| `wine_oil` | wine and oil permitted |
| `fish` | fish, wine and oil permitted |
| `dairy` | dairy, eggs and fish permitted; no meat |
| `none` | no fast |
A calendar that reports xerophagy and wine-and-oil both as "fasting" has thrown
away the information its reader wanted.
**Level and obligation are separate questions.** The Orthodox Church legislates
fasting as tradition rather than as precept, so every Orthodox day here reports
as customary. The Eastern Catholic churches do have law about it, and it
requires very much less than the Typikon describes — so those jurisdictions
report both the level at which the day is kept and the minimum that actually
binds. A calendar saying "obligatory" where nothing obliges is worse than no
calendar; one reporting only the binding minimum makes the obligatory days look
laxer than the voluntary ones beside them.
Where usages differ — Great Thursday, the Exaltation, Lazarus Saturday — this
follows the common published rule and names it in the day's reason so you can
disagree with it. Monastic practice, and the Holy Mountain in particular, is
stricter than all of it. A calendar cannot settle that; your bishop and your
spiritual father can.
## Output
```console
$ typikon --json --days 7 # for a bar, a widget, a script
$ typikon --ics --days 365 > orthodox.ics
$ typikon --year 2027 # a year's key dates, to check against yours
```
The JSON shape is flat and stable. Front ends are the thing most likely to be
written by someone else, and a schema that shifts underneath them is a bad
neighbour.
## As a library
```python
import typikon
d = typikon.today("rocor")
d.church_date # '5 September'
d.fast # 'xerophagy'
d.title # 'The Holy Prophet Zachariah, father of the Forerunner'
d.is_obligatory # False
typikon.julian_pascha(2027) # datetime.date(2027, 5, 2)
typikon.gregorian_pascha(2027) # datetime.date(2027, 3, 28)
for day in typikon.week(date, 7, "antiochian"):
...
```
## The menologion
The fixed calendar ships as data and is indexed by **ecclesiastical** date, so
one file serves every jurisdiction. An old-calendar church and a new-calendar
church both keep the Nativity on 25 December; they disagree about which morning
that is, and that disagreement lives in the calendar conversion, not in the
data.
`typikon/data/menologion.json` covers all 366 days with the principal
commemorations of the common Byzantine tradition, ranked so the fasting rules
know when a feast lifts a Wednesday. It was composed for this project and is
under the same licence as the code. It is a working set, not a complete
Synaxarion; additions are welcome.
Some churches also keep their own saints, and those lists are usually published
by a body that holds the copyright in the wording. Those are **not** shipped.
Fetch one onto your own machine instead:
```console
$ typikon --list-menaia
$ typikon --update-menaion ruthenian
```
It lands beside the packaged menologion and is overlaid automatically.
## Accuracy
`typikon` is pinned by 60 tests against published calendars — the paschalion
for six years by both reckonings, the Julian drift across the 2100 boundary,
the fast on every day that is not open to interpretation, and a full year in
every jurisdiction.
It is a calendar, not an authority. If it disagrees with the one your parish
prints, your parish is right. Please open an issue.
## Front ends
[`horologion`](https://git.pumpin.online/funkpower/horologion) — desktop
widgets for Hyprland (Quickshell), waybar and eww.
## Licence
GPL-2.0-only — see [LICENSE](LICENSE). The menologion is under it too;
`typikon/data/LICENSE` says so and notes what the fetched sets are not.
+43
View File
@@ -0,0 +1,43 @@
[build-system]
requires = ["setuptools>=64"]
build-backend = "setuptools.build_meta"
[project]
name = "typikon"
version = "0.1.0"
description = "The Orthodox liturgical day — feasts, seasons and the fast, computed rather than fetched"
readme = "README.md"
license = { text = "GPL-2.0-only" }
requires-python = ">=3.9"
authors = [{ name = "funkpower", email = "funkpower@pumpin.online" }]
keywords = ["orthodox", "liturgical", "calendar", "byzantine", "pascha",
"menologion", "typikon", "fasting"]
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: Console",
"Intended Audience :: End Users/Desktop",
"License :: OSI Approved :: GNU General Public License v2 (GPLv2)",
"Programming Language :: Python :: 3",
"Topic :: Religion",
"Topic :: Utilities",
]
dependencies = []
[project.optional-dependencies]
dev = ["pytest>=7"]
[project.urls]
Homepage = "https://git.pumpin.online/funkpower/typikon"
Source = "https://git.pumpin.online/funkpower/typikon"
[project.scripts]
typikon = "typikon.cli:main"
[tool.setuptools.packages.find]
include = ["typikon*"]
[tool.setuptools.package-data]
typikon = ["data/*.json"]
[tool.pytest.ini_options]
testpaths = ["tests"]
+230
View File
@@ -0,0 +1,230 @@
"""Pinned against published calendars and against dates whose answer is not
open to interpretation."""
import datetime as dt
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.export import to_ics, to_json
from typikon.fasting import (DAIRY, FISH, NONE, OBLIGATORY, STRICT, WINE_OIL,
XEROPHAGY)
# ───────────────────────────── the paschalion ─────────────────────────────
@pytest.mark.parametrize("year,expected", [
(2024, "2024-05-05"), (2025, "2025-04-20"), (2026, "2026-04-12"),
(2027, "2027-05-02"), (2028, "2028-04-16"), (2030, "2030-04-28"),
])
def test_julian_pascha(year, expected):
assert julian_pascha(year).isoformat() == expected
@pytest.mark.parametrize("year,expected", [
(2024, "2024-03-31"), (2025, "2025-04-20"), (2026, "2026-04-05"),
(2027, "2027-03-28"), (2028, "2028-04-16"),
])
def test_gregorian_pascha(year, expected):
assert gregorian_pascha(year).isoformat() == expected
def test_the_two_paschalions_agree_in_2025_and_differ_in_2026():
assert julian_pascha(2025) == gregorian_pascha(2025)
assert (julian_pascha(2026) - gregorian_pascha(2026)).days == 7
# ──────────────────────────── calendar drift ──────────────────────────────
def test_julian_lags_thirteen_days_now_and_fourteen_after_2100():
assert julian_offset(dt.date(2026, 9, 18)) == 13
assert to_julian(dt.date(2026, 9, 18)) == (2026, 9, 5)
# Julian 2100 is a leap year and Gregorian 2100 is not, so the gap
# widens the day after the Julian 29 February — 15 March by the civil
# calendar. Nothing here hardcodes it.
assert julian_offset(dt.date(2100, 3, 14)) == 13
assert julian_offset(dt.date(2100, 3, 15)) == 14
def test_old_calendar_nativity_falls_on_7_january():
assert from_julian(2026, 12, 25) == dt.date(2027, 1, 7)
def test_old_and_new_disagree_about_the_day_not_the_date():
old = describe(dt.date(2027, 1, 7), "rocor")
new = describe(dt.date(2026, 12, 25), "goarch")
assert old.church_date == new.church_date == "25 December"
assert old.feast == new.feast
assert old.rank == new.rank == "great"
# ───────────────────────── the Orthodox fast ──────────────────────────────
def test_great_fast_weekdays_are_xerophagy_and_weekends_wine_and_oil():
# 2026: Orthodox Pascha 12 April, so Clean Monday is 23 February.
assert describe(dt.date(2026, 2, 25), "new").fast == XEROPHAGY # Wednesday
assert describe(dt.date(2026, 2, 28), "new").fast == WINE_OIL # Saturday
assert describe(dt.date(2026, 3, 1), "new").fast == WINE_OIL # Sunday
def test_the_two_strictest_days_of_the_year():
assert describe(dt.date(2026, 2, 23), "new").fast == STRICT # Clean Monday
assert describe(dt.date(2026, 4, 10), "new").fast == STRICT # Great Friday
# The one Saturday kept without oil.
assert describe(dt.date(2026, 4, 11), "new").fast == XEROPHAGY
def test_annunciation_and_palm_sunday_admit_fish_inside_the_great_fast():
ann = describe(dt.date(2026, 3, 25), "new")
assert ann.season == "Great Fast" and ann.fast == FISH
assert describe(dt.date(2026, 4, 5), "new").fast == FISH # Palm Sunday
def test_cheesefare_week_keeps_dairy_on_wednesday_and_friday():
d = describe(dt.date(2026, 2, 18), "new") # Wednesday
assert d.season == "Cheesefare Week" and d.fast == DAIRY
def test_the_fast_free_stretches():
assert describe(dt.date(2026, 4, 17), "new").fast == NONE # Bright Friday
assert describe(dt.date(2026, 1, 2), "new").fast == NONE # Christmastide
assert describe(dt.date(2026, 6, 3), "new").fast == NONE # week after Pentecost
# The week after the Publican and the Pharisee, kept fast-free precisely
# because the Pharisee boasted of fasting twice a week.
assert describe(dt.date(2026, 2, 6), "new").fast == NONE
def test_nativity_fast_tightens_for_its_last_five_days():
# A Saturday before 20 December admits fish; one after it does not.
assert describe(dt.date(2026, 12, 12), "new").fast == FISH
assert describe(dt.date(2026, 12, 26), "new").season != "Nativity Fast"
late = describe(dt.date(2026, 12, 19), "new")
assert late.fast == FISH
assert describe(dt.date(2026, 12, 22), "new").fast == WINE_OIL # Tuesday
assert describe(dt.date(2026, 12, 24), "new").fast == XEROPHAGY # the eve
def test_a_great_feast_lifts_an_ordinary_friday_to_fish():
d = describe(dt.date(2026, 8, 6), "goarch") # Transfiguration
assert d.weekday == "Thursday" and d.fast == FISH
# 2026-01-01, the Circumcision, is a Thursday; take a great feast on a
# Wednesday instead — the Meeting of our Lord, 2 February 2028.
m = describe(dt.date(2028, 2, 2), "goarch")
assert m.weekday == "Wednesday" and m.rank == "great" and m.fast == FISH
def test_ordinary_wednesday_and_friday_are_xerophagy():
d = describe(dt.date(2026, 9, 4), "new") # a Friday
assert d.season is None and d.rank is None and d.fast == XEROPHAGY
def test_a_polyeleos_saint_lifts_an_ordinary_friday_to_wine_and_oil():
# 9 October 2026 is a Friday and the Apostle James son of Alphaeus. The
# Typikon gives wine and oil for a saint of this rank, which is why the
# published calendars mark so many Fridays that way.
d = describe(dt.date(2026, 10, 9), "new")
assert d.weekday == "Friday" and d.rank == "polyeleos"
assert d.fast == WINE_OIL
def test_the_orthodox_rule_never_reports_an_obligation():
days = week(dt.date(2026, 1, 1), 365, "new")
assert not any(d.obligation == OBLIGATORY for d in days)
# ──────────────────── Byzantine Catholic particular law ───────────────────
def test_ruthenian_pascha_is_the_gregorian_one():
assert describe(dt.date(2026, 6, 1), "ruthenian").pascha == dt.date(2026, 4, 5)
def test_ruthenian_clean_monday_is_16_february_2026():
# As published by the Metropolitan Cantor Institute.
assert describe(dt.date(2026, 2, 16), "ruthenian").reason.startswith(
"the first day")
def test_only_four_kinds_of_day_bind_under_ruthenian_law():
obligatory = [d for d in week(dt.date(2026, 1, 1), 365, "ruthenian")
if d.obligation == OBLIGATORY]
# Clean Monday, Great Friday, and the Wednesdays and Fridays of the Fast.
assert dt.date(2026, 2, 16) in [d.date for d in obligatory]
assert dt.date(2026, 4, 3) in [d.date for d in obligatory]
assert all(d.season in ("Great Fast", "Great and Holy Week")
for d in obligatory)
def test_the_voluntary_seasons_never_report_as_binding():
for when in (dt.date(2026, 11, 20), dt.date(2026, 8, 5)):
assert describe(when, "ruthenian").obligation == "customary"
def test_christmas_on_a_friday_does_not_ask_you_to_abstain():
d = describe(dt.date(2026, 12, 25), "ruthenian")
assert d.weekday == "Friday" and d.fast == NONE
def test_ruthenian_strict_days_bind_strictly_and_the_rest_bind_to_meat():
assert describe(dt.date(2026, 2, 16), "ruthenian").binds == WINE_OIL
assert describe(dt.date(2026, 4, 3), "ruthenian").binds == WINE_OIL
assert describe(dt.date(2026, 2, 18), "ruthenian").binds == DAIRY
assert describe(dt.date(2026, 10, 9), "ruthenian").binds == NONE
# ──────────────────────────── the menologion ──────────────────────────────
def test_every_day_of_a_leap_year_has_a_commemoration():
for d in week(dt.date(2024, 1, 1), 366, "new"):
assert d.commemorations, d.date
def test_the_twelve_great_feasts_are_all_present():
fixed = [(9, 8), (9, 14), (11, 21), (12, 25), (1, 6), (2, 2), (3, 25),
(8, 6), (8, 15)]
for month, day in fixed:
d = describe(dt.date(2026, month, day), "goarch")
assert d.rank == "great", (month, day)
for when in (dt.date(2026, 4, 5), dt.date(2026, 5, 21), dt.date(2026, 5, 31)):
assert describe(when, "goarch").rank == "great", when
def test_a_movable_feast_outranks_the_fixed_calendar():
d = describe(dt.date(2026, 4, 12), "new")
assert "Pascha" in d.feast
# ──────────────────────────────── output ──────────────────────────────────
def test_json_round_trips():
import json
days = week(dt.date(2026, 4, 10), 3, "new")
parsed = json.loads(to_json(days))
assert len(parsed["days"]) == 3
assert parsed["days"][0]["fast"] == STRICT
def test_ics_is_well_formed_and_folds_long_lines():
text = to_ics(week(dt.date(2026, 4, 10), 7, "new"))
assert text.startswith("BEGIN:VCALENDAR")
assert text.rstrip().endswith("END:VCALENDAR")
assert text.count("BEGIN:VEVENT") == text.count("END:VEVENT")
for line in text.split("\r\n"):
assert len(line.encode()) < 200
# ─────────────────────────── every jurisdiction ───────────────────────────
@pytest.mark.parametrize("key", list(__import__("typikon").JURISDICTIONS))
def test_every_jurisdiction_reckons_a_whole_year_without_raising(key):
days = week(dt.date(2026, 1, 1), 365, key)
assert len(days) == 365
assert all(d.fast for d in days)
def test_a_calendar_name_resolves_without_naming_a_church():
assert resolve("old").menologion == "julian"
assert resolve("new").menologion == "revised_julian"
assert resolve("western").paschalion == "gregorian"
assert resolve(None).key == "new"
+31
View File
@@ -0,0 +1,31 @@
"""typikon — the Orthodox liturgical day, computed rather than fetched.
>>> import typikon
>>> d = typikon.today("rocor")
>>> d.church_date, d.fast, d.title
Everything is derived from the paschalion and the fixed calendar, so it needs
no network, cannot go stale, and answers for any year you ask about.
"""
import datetime as dt
from .day import Day, describe, week, year_of
from .fasting import (DAIRY, FISH, LEVELS, NONE, STRICT, WINE_OIL, XEROPHAGY,
Fast)
from .jurisdictions import CALENDARS, JURISDICTIONS, Jurisdiction, resolve
from .paschalion import gregorian_pascha, julian_pascha, movable_cycle, pascha
__version__ = "0.1.0"
__all__ = [
"Day", "Fast", "Jurisdiction",
"describe", "today", "week", "year_of", "resolve",
"pascha", "julian_pascha", "gregorian_pascha", "movable_cycle",
"JURISDICTIONS", "CALENDARS", "LEVELS",
"STRICT", "XEROPHAGY", "WINE_OIL", "FISH", "DAIRY", "NONE",
]
def today(jurisdiction=None):
return describe(dt.date.today(), jurisdiction)
+122
View File
@@ -0,0 +1,122 @@
"""Conversion between the civil calendar and the calendar a church reckons in.
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.
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
traditions disagree about which morning that is, not about which date it is. So
the fixed calendar is stored once, indexed by ecclesiastical date, and a
jurisdiction decides how a civil date maps onto it.
The drift is computed, never hardcoded, so nothing here breaks in 2100.
"""
import datetime as dt
__all__ = [
"gregorian_to_jdn", "julian_to_jdn", "jdn_to_gregorian", "jdn_to_julian",
"to_julian", "from_julian", "julian_offset",
]
def gregorian_to_jdn(year, month, day):
"""Julian Day Number for a proleptic Gregorian date."""
a = (14 - month) // 12
y = year + 4800 - a
m = month + 12 * a - 3
return (day + (153 * m + 2) // 5 + 365 * y
+ y // 4 - y // 100 + y // 400 - 32045)
def julian_to_jdn(year, month, day):
"""Julian Day Number for a proleptic Julian date."""
a = (14 - month) // 12
y = year + 4800 - a
m = month + 12 * a - 3
return day + (153 * m + 2) // 5 + 365 * y + y // 4 - 32083
def jdn_to_gregorian(jdn):
a = jdn + 32044
b, c = divmod(a, 146097)
d = (4 * c + 3) // 146097
e = c - (146097 * d) // 4
f = (4 * e + 3) // 1461
g = e - (1461 * f) // 4
h = (5 * g + 2) // 153
day = g - (153 * h + 2) // 5 + 1
month = h + 3 - 12 * (h // 10)
year = 400 * b + 100 * d + f - 4800 + h // 10
return year, month, day
def jdn_to_julian(jdn):
c = jdn + 32082
d = (4 * c + 3) // 1461
e = c - (1461 * d) // 4
m = (5 * e + 2) // 153
day = e - (153 * m + 2) // 5 + 1
month = m + 3 - 12 * (m // 10)
year = d - 4800 + m // 10
return year, month, day
def to_julian(date):
"""The Julian calendar date of a civil (Gregorian) date.
Returns a plain (year, month, day) triple rather than a ``date``, because
29 February in the Julian calendar is a real day that ``datetime`` will not
hold in a Gregorian common year.
"""
return jdn_to_julian(gregorian_to_jdn(date.year, date.month, date.day))
def from_julian(year, month, day):
"""The civil (Gregorian) date of a Julian calendar date."""
y, m, d = jdn_to_gregorian(julian_to_jdn(year, month, day))
return dt.date(y, m, d)
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.
"""
jy, jm, jd = to_julian(date)
return (gregorian_to_jdn(date.year, date.month, date.day)
- gregorian_to_jdn(jy, jm, jd))
def fixed_of_civil(date, menologion):
"""The ecclesiastical (year, month, day) a civil date is reckoned as.
On the Revised Julian menologion this is the civil date itself — the two
agree for every date until 2800, so the distinction costs nothing and stops
mattering only long after anyone reading this.
"""
if menologion == "julian":
return to_julian(date)
return (date.year, date.month, date.day)
def civil_of_fixed(year, month, day, menologion):
"""The civil date of an ecclesiastical date in a given ecclesiastical year."""
if menologion == "julian":
return from_julian(year, month, day)
return dt.date(year, month, day)
def fixed_between(md, start, end):
"""Is (month, day) within an inclusive fixed-date range, wrapping the year?
The Nativity Fast does not wrap; the fast-free stretch from the Nativity to
the eve of Theophany does. Both are written the same way here.
"""
if start <= end:
return start <= md <= end
return md >= start or md <= end
+210
View File
@@ -0,0 +1,210 @@
"""The command line."""
import argparse
import datetime as dt
import sys
from . import config
from .day import describe, week, year_of
from .export import to_ics, to_json
from .fasting import OBLIGATORY
from .jurisdictions import CALENDARS, JURISDICTIONS, resolve
from .rules import get as get_rule
__version__ = "0.1.0"
def _list_jurisdictions():
print("\n Calendars — a reckoning without naming a church:\n")
for j in CALENDARS.values():
print(f" {j.key:20} {j.name}")
for line in _wrap(j.note, 56):
print(f" {'':20} {line}")
print("\n Jurisdictions:\n")
width = max(len(k) for k in JURISDICTIONS)
for j in JURISDICTIONS.values():
cal = {"old": "old calendar", "new": "new calendar",
"western": "gregorian"}[j.calendar]
print(f" {j.key:{width}} {j.name}")
print(f" {'':{width}} {cal}; {j.rule} fasting rule")
if j.note:
for line in _wrap(j.note, 66):
print(f" {'':{width}} {line}")
print()
def _wrap(text, width):
words, line, out = text.split(), "", []
for w in words:
if len(line) + len(w) + 1 > width:
out.append(line)
line = w
else:
line = f"{line} {w}".strip()
if line:
out.append(line)
return out
def _print_days(days, jur, rule, source, verbose):
if len(days) == 1:
d = days[0]
head = f" {d.weekday} {d.date.strftime('%-d %B %Y')}"
if d.church_date != d.date.strftime("%-d %B"):
head += f" — {d.church_date} in the church calendar"
print(f"\n{head}")
print(f" {jur.name} · {rule['title']}"
+ (f" (from {source})" if source != "flag" else ""))
print()
if d.feast:
print(f" {d.emoji} {d.feast}")
else:
print(f" {d.emoji} {d.title}")
if d.cycle:
print(f" {d.cycle}")
print()
mark = " — obligatory" if d.obligation == OBLIGATORY else ""
print(f" {d.fast_label}{mark}")
if d.reason:
print(f" {d.reason}")
if d.season:
print(f" {d.season}")
rest = [c for c in d.commemorations if c != d.title]
if rest:
print()
for c in rest:
print(f" · {c}")
if verbose:
print()
print(f" Pascha {d.pascha} · {d.days_to_pascha} days")
print()
return
print(f"\n {jur.name} · {rule['title']}\n")
for d in days:
mark = "*" if d.obligation == OBLIGATORY else " "
when = d.date.strftime("%a %-d %b")
print(f" {d.emoji} {when:>10}{mark} {d.fast_label:<32}{d.title[:44]}")
if any(d.obligation == OBLIGATORY for d in days):
print("\n * obligatory. Everything unmarked is customary and does not bind.")
print()
def main(argv=None):
p = argparse.ArgumentParser(
prog="typikon",
description="The Orthodox liturgical day — feasts, seasons and the fast.")
p.add_argument("-j", "--jurisdiction", "-c", "--calendar", dest="jurisdiction",
help="a church or a reckoning; see --list")
p.add_argument("-d", "--date", help="YYYY-MM-DD (default: today)")
p.add_argument("-n", "--days", type=int, default=1,
help="how many days from the start date, inclusive")
p.add_argument("--json", action="store_true", help="machine-readable output")
p.add_argument("--ics", action="store_true", help="an iCalendar feed")
p.add_argument("--indent", type=int, default=None, help="indent JSON output")
p.add_argument("--year", type=int, help="print a year's key dates and exit")
p.add_argument("--list", action="store_true",
help="list every jurisdiction and calendar")
p.add_argument("--set-default", metavar="JURISDICTION",
help="remember a jurisdiction and exit")
p.add_argument("--update-menaion", metavar="NAME",
help="fetch a supplementary commemoration set (see --list-menaia)")
p.add_argument("--list-menaia", action="store_true",
help="list the supplementary sets that can be fetched")
p.add_argument("-v", "--verbose", action="store_true")
p.add_argument("--version", action="version", version=f"typikon {__version__}")
args = p.parse_args(argv)
if args.list:
_list_jurisdictions()
return 0
if args.list_menaia:
from .menaia import SOURCES, target_dir
print("\n Supplementary commemorations, fetched on request:\n")
for key, src in SOURCES.items():
print(f" {key:14} {src['title']}")
print(f" {'':14} {src['url']}")
for line in _wrap(src["note"], 62):
print(f" {'':14} {line}")
print()
print(f" They are written to {target_dir()}\n")
return 0
if args.update_menaion:
from .menaia import update
try:
result = update(args.update_menaion)
except KeyError as exc:
print(f"typikon: {exc.args[0]}", file=sys.stderr)
return 2
except Exception as exc:
print(f"typikon: could not fetch: {exc}", file=sys.stderr)
return 1
print(f" {result['days']} days written to {result['path']}")
print(f" Source: {result['source']['title']}")
if result["corrections"]:
print(f" source typos corrected: {', '.join(result['corrections'])}")
if result["missing"]:
print(f" MISSING: {', '.join(result['missing'])}")
return 1
return 0
if args.set_default:
try:
jur = resolve(args.set_default)
except KeyError:
print(f"typikon: unknown jurisdiction {args.set_default!r};"
" try --list", file=sys.stderr)
return 2
where = config.write(jur.key)
print(f" {jur.name} — written to {where}")
return 0
if args.jurisdiction:
name, source = args.jurisdiction, "flag"
else:
name, source = config.default_jurisdiction()
try:
jur = resolve(name)
except KeyError:
print(f"typikon: unknown jurisdiction {name!r}; try --list", file=sys.stderr)
return 2
if args.year:
mv = year_of(args.year, jur)
print(f"\n {args.year} · {jur.name}\n")
for k in ("publican_pharisee", "meatfare", "cheesefare", "clean_monday",
"great_fast_end", "lazarus_saturday", "palm_sunday",
"great_friday", "pascha", "ascension", "pentecost",
"all_saints", "apostles_start", "apostles_end",
"dormition", "nativity", "theophany"):
d = mv[k]
print(f" {k.replace('_', ' '):20} {d.isoformat()} {d.strftime('%a')}")
print()
return 0
start = dt.date.fromisoformat(args.date) if args.date else dt.date.today()
days = week(start, args.days, jur)
if args.json:
print(to_json(days, args.indent))
return 0
if args.ics:
sys.stdout.write(to_ics(days, name=f"{jur.name} — Typikon"))
return 0
_print_days(days, jur, get_rule(jur.rule), source, args.verbose)
# The first run should say what it assumed, and then stop saying it.
if source == "default":
where = config.write(jur.key)
print(f" Reckoning by the {jur.name.lower()}, written to {where}.")
print(" Change it with `typikon --set-default <jurisdiction>`,"
" or see `typikon --list`.\n")
return 0
if __name__ == "__main__":
sys.exit(main())
+54
View File
@@ -0,0 +1,54 @@
"""Where the default jurisdiction is remembered.
Choosing a reckoning is a once-in-a-lifetime decision for most people, so it
should not be a flag typed every morning. The first run writes the choice down;
every run after reads it. An explicit flag always wins over the file.
"""
import os
try:
import tomllib
except ModuleNotFoundError: # Python < 3.11
tomllib = None
__all__ = ["path", "read", "write", "default_jurisdiction"]
ENV_VAR = "TYPIKON_JURISDICTION"
def path():
base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
return os.path.join(base, "typikon", "config.toml")
def read():
p = path()
if not os.path.exists(p) or tomllib is None:
return {}
try:
with open(p, "rb") as fh:
return tomllib.load(fh)
except (OSError, ValueError):
return {}
def write(jurisdiction):
p = path()
os.makedirs(os.path.dirname(p), exist_ok=True)
with open(p, "w", encoding="utf-8") as fh:
fh.write("# Which reckoning typikon uses when no --jurisdiction is given.\n"
"# Run `typikon --list` to see every accepted value.\n"
f'jurisdiction = "{jurisdiction}"\n')
return p
def default_jurisdiction():
"""The environment beats the config file; both beat the built-in default."""
env = os.environ.get(ENV_VAR)
if env:
return env, "environment"
conf = read().get("jurisdiction")
if conf:
return conf, "config"
return "new", "default"
+12
View File
@@ -0,0 +1,12 @@
The menologion in this directory — menologion.json — is covered by the GNU
General Public License version 2, the same as the rest of this project. See
the LICENSE file at the root.
It was composed for this project from the common Byzantine tradition, in the
standard English formulae. The tradition is nobody's property; the wording
here is this project's contribution to it, and the licence asks only that
what you build on it stays as free as what you were given.
Supplementary sets fetched by `typikon --update-menaion` are NOT covered by
this and are NOT distributed here. They belong to whoever published them; the
fetcher puts a copy on your machine for your own use. See typikon/menaia.py.
File diff suppressed because it is too large Load Diff
+176
View File
@@ -0,0 +1,176 @@
"""A single day, fully reckoned.
``Day`` is what everything else in the library produces and what every
front end consumes. It carries the civil date, the ecclesiastical date, the
season, the commemorations and the fast — and it carries the jurisdiction that
produced it, so a caller can always ask by whose reckoning.
"""
import datetime as dt
from dataclasses import dataclass, field
from . import menologion as _menologion
from . import rules as _rules
from .calendars import civil_of_fixed, fixed_of_civil, julian_offset
from .fasting import NONE, OBLIGATORY
from .jurisdictions import resolve
from .paschalion import movable_cycle
from .seasons import season_for
__all__ = ["Day", "describe", "week", "year_of"]
@dataclass
class _Context:
"""What a fasting rule is handed. Deliberately small."""
civil: dt.date
fixed: tuple # ecclesiastical (month, day)
fixed_year: int
weekday: int # 0 = Monday
mv: dict
season: str
feast: str
rank: str
menologion_kind: str
def civil_of_fixed(self, month, day):
"""The civil date of an ecclesiastical date in this day's church year."""
return civil_of_fixed(self.fixed_year, month, day, self.menologion_kind)
@dataclass
class Day:
date: dt.date
weekday: str
jurisdiction: str
calendar: str
church_date: str # the ecclesiastical date, ISO-ish
season: str = None
cycle: str = None # forefeast / afterfeast / leave-taking
feast: str = None
rank: str = None
commemorations: list = field(default_factory=list)
fast: str = NONE
fast_label: str = ""
emoji: str = ""
reason: str = ""
obligation: str = None
binds: str = NONE
pascha: dt.date = None
days_to_pascha: int = 0
julian_offset: int = 0
@property
def is_obligatory(self):
return self.obligation == OBLIGATORY
@property
def title(self):
"""What to put on one line: the feast if there is one, else the day's
principal commemoration."""
if self.feast:
return self.feast
return self.commemorations[0] if self.commemorations else ""
def to_dict(self):
out = {
"date": self.date.isoformat(),
"weekday": self.weekday,
"jurisdiction": self.jurisdiction,
"calendar": self.calendar,
"churchDate": self.church_date,
"season": self.season,
"cycle": self.cycle,
"feast": self.feast,
"rank": self.rank,
"title": self.title,
"commemorations": self.commemorations,
"fast": self.fast,
"fastLabel": self.fast_label,
"emoji": self.emoji,
"reason": self.reason,
"obligation": self.obligation,
"binds": self.binds,
"pascha": self.pascha.isoformat() if self.pascha else None,
"daysToPascha": self.days_to_pascha,
"julianOffset": self.julian_offset,
}
return out
_WEEKDAYS = ("Monday", "Tuesday", "Wednesday", "Thursday", "Friday",
"Saturday", "Sunday")
_MONTHS = ("January", "February", "March", "April", "May", "June", "July",
"August", "September", "October", "November", "December")
def describe(date, jurisdiction=None):
"""Reckon one civil date in one jurisdiction."""
jur = jurisdiction if hasattr(jurisdiction, "paschalion") else resolve(jurisdiction)
rule = _rules.get(jur.rule)
men = _menologion.for_jurisdiction(jur)
fy, fm, fd = fixed_of_civil(date, jur.menologion)
mv = movable_cycle(date.year, jur.paschalion)
apostles_end = civil_of_fixed(date.year, 6, 28, jur.menologion)
season = season_for(date, (fm, fd), mv, apostles_end)
entry = men.get(fm, fd)
commemorations = list(entry.get("commemorations", []))
rank = entry.get("rank")
cycle = entry.get("cycle")
# A movable feast outranks whatever the fixed calendar holds that day.
mov_title, mov_rank = _menologion.movable_feast_for(date, mv)
if mov_title:
feast = mov_title
rank = mov_rank or rank
else:
feast = commemorations[0] if rank in ("great", "vigil") else None
ctx = _Context(
civil=date, fixed=(fm, fd), fixed_year=fy, weekday=date.weekday(),
mv=mv, season=season, feast=feast or (commemorations[0] if commemorations else ""),
rank=rank, menologion_kind=jur.menologion,
)
fast = rule["fast_for"](ctx)
label, emoji = rule["vocabulary"].get(fast.level, (fast.level, ""))
return Day(
date=date,
weekday=_WEEKDAYS[date.weekday()],
jurisdiction=jur.key,
calendar=jur.calendar,
church_date=f"{fd} {_MONTHS[fm - 1]}",
season=season,
cycle=cycle,
feast=feast,
rank=rank,
commemorations=commemorations,
fast=fast.level,
fast_label=label,
emoji=emoji,
reason=fast.reason,
obligation=fast.obligation,
binds=fast.binds,
pascha=mv["pascha"],
days_to_pascha=(mv["pascha"] - date).days,
julian_offset=julian_offset(date),
)
def week(start, days=7, jurisdiction=None):
"""`days` consecutive days from `start`."""
return [describe(start + dt.timedelta(n), jurisdiction)
for n in range(max(1, days))]
def year_of(year, jurisdiction=None):
"""Every key date of a year, for checking against a published calendar."""
jur = jurisdiction if hasattr(jurisdiction, "paschalion") else resolve(jurisdiction)
mv = movable_cycle(year, jur.paschalion)
mv["apostles_end"] = civil_of_fixed(year, 6, 28, jur.menologion)
mv["nativity"] = civil_of_fixed(year, 12, 25, jur.menologion)
mv["theophany"] = civil_of_fixed(year, 1, 6, jur.menologion)
mv["dormition"] = civil_of_fixed(year, 8, 15, jur.menologion)
return mv
+4
View File
@@ -0,0 +1,4 @@
from .ics import to_ics
from .jsonout import to_json
__all__ = ["to_json", "to_ics"]
+93
View File
@@ -0,0 +1,93 @@
"""iCalendar, so the calendar can live where the rest of your year does.
Each day becomes one all-day event. The summary is the feast or the principal
commemoration; the description carries the fast, the season and the rest of the
day's commemorations. Days with nothing to say — no feast, no fast — are left
out, because a subscribed calendar that marks all 365 days is one nobody keeps.
"""
import datetime as dt
__all__ = ["to_ics"]
_FOLD = 73
_BACKSLASH = chr(92)
# Order matters: the backslash has to be doubled before anything else
# introduces one, or the escapes escape each other.
_ESCAPES = (
(_BACKSLASH, _BACKSLASH * 2),
(";", _BACKSLASH + ";"),
(",", _BACKSLASH + ","),
("\n", _BACKSLASH + "n"),
)
def _esc(text):
out = str(text)
for char, replacement in _ESCAPES:
out = out.replace(char, replacement)
return out
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:]
return "\r\n".join(out)
def to_ics(days, name="Typikon", stamp=None):
stamp = (stamp or dt.datetime.now(dt.timezone.utc)).strftime("%Y%m%dT%H%M%SZ")
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//typikon//EN",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
f"X-WR-CALNAME:{_esc(name)}",
]
for d in days:
if not d.feast and d.fast == "none":
continue
start = d.date.strftime("%Y%m%d")
end = (d.date + dt.timedelta(1)).strftime("%Y%m%d")
summary = d.title or d.fast_label
if d.emoji:
summary = f"{d.emoji} {summary}"
body = []
if d.fast != "none":
body.append(f"Fast: {d.fast_label}"
+ (f" ({d.reason})" if d.reason else ""))
if d.obligation == "obligatory":
body.append("Obligatory.")
if d.season:
body.append(f"Season: {d.season}")
if d.cycle:
body.append(d.cycle[0].upper() + d.cycle[1:])
body.append(f"Church date: {d.church_date}")
for c in d.commemorations:
body.append(c)
uid = f"{start}-{d.jurisdiction}@typikon"
lines += [
"BEGIN:VEVENT",
f"UID:{uid}",
f"DTSTAMP:{stamp}",
f"DTSTART;VALUE=DATE:{start}",
f"DTEND;VALUE=DATE:{end}",
_fold(f"SUMMARY:{_esc(summary)}"),
_fold(f"DESCRIPTION:{_esc(chr(10).join(body))}"),
"TRANSP:TRANSPARENT",
"END:VEVENT",
]
lines.append("END:VCALENDAR")
return "\r\n".join(lines) + "\r\n"
+19
View File
@@ -0,0 +1,19 @@
"""JSON, which is how every front end reads this.
The shape is flat and stable: front ends are the thing most likely to be
written by someone else, and a schema that shifts underneath them is a bad
neighbour. One day is an object; several are ``{"days": [...]}``.
"""
import json
__all__ = ["to_json"]
def to_json(days, indent=None):
if not isinstance(days, (list, tuple)):
return json.dumps(days.to_dict(), ensure_ascii=False, indent=indent)
if len(days) == 1:
return json.dumps(days[0].to_dict(), ensure_ascii=False, indent=indent)
return json.dumps({"days": [d.to_dict() for d in days]},
ensure_ascii=False, indent=indent)
+82
View File
@@ -0,0 +1,82 @@
"""What is kept on a day, and whether anything about it binds.
Six levels, ordered from strictest to laxest. They are the Typikon's own
distinctions, not a simplification of them: the difference between a day of
xerophagy and a day of wine and oil is the difference the books actually make,
and a calendar that reports both as "fasting" has thrown away the information
its reader wanted.
strict no food until evening
xerophagy no oil, no wine; no meat, fish, eggs or dairy
wine_oil wine and oil permitted; no meat, fish, eggs or dairy
fish fish, wine and oil permitted; no meat, eggs or dairy
dairy dairy, eggs and fish permitted; no meat
none no fast
The same six serve a tradition that makes fewer distinctions. Ruthenian
particular law knows three degrees, and each maps exactly onto one of these —
its "strict abstinence" is ``wine_oil`` (it does not regulate oil or wine), its
"abstinence" is ``dairy`` (meat alone is set aside). So a ruleset emits the
subset it uses and names those levels in its own vocabulary, rather than every
tradition being forced through one set of words.
**Level and obligation are separate questions.** A day can be kept strictly by
universal custom and bind no one, and that is the ordinary case in the Eastern
Catholic churches, whose particular law requires very little. Reporting the
binding minimum as the day's level makes the obligatory days look laxer than
the voluntary ones beside them — true of the obligation, false of the day. So
both are carried, and a caller that wants one must say which.
"""
from dataclasses import dataclass
__all__ = [
"STRICT", "XEROPHAGY", "WINE_OIL", "FISH", "DAIRY", "NONE",
"LEVELS", "stricter", "strictest", "laxest",
"OBLIGATORY", "CUSTOMARY", "Fast",
]
STRICT = "strict"
XEROPHAGY = "xerophagy"
WINE_OIL = "wine_oil"
FISH = "fish"
DAIRY = "dairy"
NONE = "none"
# Strictest first. Index into this is the comparison.
LEVELS = (STRICT, XEROPHAGY, WINE_OIL, FISH, DAIRY, NONE)
OBLIGATORY = "obligatory"
CUSTOMARY = "customary" # encouraged, traditional, not binding
def stricter(a, b):
"""True if `a` is a stricter level than `b`."""
return LEVELS.index(a) < LEVELS.index(b)
def strictest(*levels):
return min(levels, key=LEVELS.index)
def laxest(*levels):
return max(levels, key=LEVELS.index)
@dataclass(frozen=True)
class Fast:
"""How a day is kept, and what about it binds.
``level`` is the day as the tradition keeps it. ``binds`` is what the
Church actually requires, which for most jurisdictions is nothing — it is
``none`` unless a ruleset has particular law to point at. ``reason`` names
the rule that decided it, so the answer can be argued with.
"""
level: str
reason: str = ""
obligation: str = None # OBLIGATORY, CUSTOMARY, or None for a free day
binds: str = NONE # the binding minimum, where one exists
@property
def is_fast(self):
return self.level != NONE
+151
View File
@@ -0,0 +1,151 @@
"""Who keeps which calendar, and by whose rule.
A jurisdiction is four independent choices, and conflating any two of them is
how calendar code usually goes wrong:
paschalion which table gives Pascha — julian or gregorian
menologion which calendar the fixed feasts are read on
rule which fasting discipline is kept
menaia which supplementary commemorations to load, if any
The pairings are not free. Almost every Orthodox Church keeps the Julian
Paschalion; what divides them is the menologion — the "old calendar" churches
read the fixed feasts on the Julian calendar too, the "new calendar" churches
read them on the Revised Julian, which agrees with the Gregorian for every date
until 2800. So a new-calendar church keeps Christmas with the West and Pascha
with the East, which is not a compromise but exactly what the 1923 congress
decided.
Only two bodies here use the Gregorian Paschalion: the Finnish Orthodox Church,
alone among the Orthodox, and the Byzantine Catholic churches.
Where a church is mid-transition or its parishes differ, the entry says so in
``note`` rather than pretending to a precision that does not exist.
"""
from dataclasses import dataclass, field
__all__ = ["Jurisdiction", "JURISDICTIONS", "CALENDARS", "resolve", "names"]
@dataclass(frozen=True)
class Jurisdiction:
key: str
name: str
paschalion: str # "julian" | "gregorian"
menologion: str # "julian" | "revised_julian"
rule: str = "orthodox" # which fasting discipline
menaia: tuple = () # extra commemoration sets to overlay
note: str = ""
@property
def calendar(self):
"""The short name people actually use for this combination."""
if self.paschalion == "gregorian" and self.menologion != "julian":
return "western"
return "old" if self.menologion == "julian" else "new"
# The three combinations, for anyone who wants a reckoning without naming a
# church. `--calendar new` is the default and covers most English-speaking
# Orthodox parishes.
CALENDARS = {
"old": Jurisdiction(
"old", "Old Calendar", "julian", "julian",
note="Julian throughout — the reckoning of Moscow, Serbia, Georgia, "
"Jerusalem and the Holy Mountain."),
"new": Jurisdiction(
"new", "New Calendar", "julian", "revised_julian",
note="Revised Julian fixed feasts with the Julian Paschalion — "
"Constantinople, Greece, Antioch, Romania, Bulgaria, the OCA."),
"western": Jurisdiction(
"western", "Gregorian", "gregorian", "revised_julian",
note="Gregorian throughout, Pascha included. Kept by the Finnish "
"Orthodox Church and by most Byzantine Catholics."),
}
_ORTHODOX = [
# ── old calendar
("moscow", "Russian Orthodox Church", "julian", "julian", "orthodox", (), ""),
("rocor", "Russian Orthodox Church Outside Russia", "julian", "julian",
"orthodox", (), ""),
("serbian", "Serbian Orthodox Church", "julian", "julian", "orthodox", (), ""),
("georgian", "Georgian Orthodox Church", "julian", "julian", "orthodox", (), ""),
("jerusalem", "Patriarchate of Jerusalem", "julian", "julian", "orthodox", (), ""),
("athos", "The Holy Mountain", "julian", "julian", "orthodox", (),
"Athonite practice is stricter than the printed Typikon at many points."),
("polish", "Polish Orthodox Church", "julian", "julian", "orthodox", (),
"Returned to the Julian menologion in 2014 after decades on the Revised "
"Julian; some parishes kept the new calendar."),
("macedonian", "Macedonian Orthodox Church", "julian", "julian",
"orthodox", (), ""),
# ── new calendar
("constantinople", "Ecumenical Patriarchate", "julian", "revised_julian",
"orthodox", (), ""),
("goarch", "Greek Orthodox Archdiocese of America", "julian",
"revised_julian", "orthodox", (), ""),
("greece", "Church of Greece", "julian", "revised_julian", "orthodox", (), ""),
("antiochian", "Antiochian Orthodox Church", "julian", "revised_julian",
"orthodox", (), ""),
("oca", "Orthodox Church in America", "julian", "revised_julian",
"orthodox", (),
"Most parishes keep the new calendar; a minority, chiefly of Carpatho-"
"Russian and Alaskan background, keep the old."),
("romanian", "Romanian Orthodox Church", "julian", "revised_julian",
"orthodox", (), ""),
("bulgarian", "Bulgarian Orthodox Church", "julian", "revised_julian",
"orthodox", (), ""),
("cyprus", "Church of Cyprus", "julian", "revised_julian", "orthodox", (), ""),
("albanian", "Orthodox Church of Albania", "julian", "revised_julian",
"orthodox", (), ""),
("ukrainian", "Orthodox Church of Ukraine", "julian", "revised_julian",
"orthodox", (),
"Moved the fixed feasts to the Revised Julian calendar on 1 September "
"2023. The Julian Paschalion is retained."),
# ── gregorian throughout
("finnish", "Finnish Orthodox Church", "gregorian", "revised_julian",
"orthodox", (),
"The only Orthodox Church keeping the Gregorian Paschalion."),
]
_EASTERN_CATHOLIC = [
("ruthenian", "Byzantine Catholic Metropolia of Pittsburgh", "gregorian",
"revised_julian", "ruthenian", ("ruthenian",),
"Gregorian throughout. Fasting is reported under Ruthenian particular "
"law, which binds far less than the Typikon prescribes."),
("ugcc", "Ukrainian Greek Catholic Church", "julian", "revised_julian",
"ruthenian", ("ruthenian",),
"Moved the fixed feasts to the Revised Julian calendar on 1 September "
"2023, keeping the Julian Paschalion. Eparchies outside Ukraine vary; "
"several keep the Gregorian Paschalion."),
("melkite", "Melkite Greek Catholic Church", "gregorian", "revised_julian",
"ruthenian", (),
"Practice is not uniform: parishes in the Middle East commonly keep the "
"Julian Paschalion for the sake of the local Orthodox."),
("romanian-catholic", "Romanian Greek Catholic Church", "gregorian",
"revised_julian", "ruthenian", (), ""),
]
JURISDICTIONS = {}
for _k, _n, _p, _m, _r, _mn, _note in _ORTHODOX + _EASTERN_CATHOLIC:
JURISDICTIONS[_k] = Jurisdiction(_k, _n, _p, _m, _r, _mn, _note)
def resolve(name):
"""A jurisdiction key, a calendar name, or None for the default."""
if name is None:
return CALENDARS["new"]
key = name.strip().lower()
if key in JURISDICTIONS:
return JURISDICTIONS[key]
if key in CALENDARS:
return CALENDARS[key]
raise KeyError(name)
def names():
"""Everything ``resolve`` accepts, calendars first."""
return list(CALENDARS) + list(JURISDICTIONS)
+143
View File
@@ -0,0 +1,143 @@
"""Supplementary commemorations, fetched rather than shipped.
The menologion this library ships is the common Byzantine one, composed from
the tradition and given away (see data/LICENSE). Some churches also keep their
own saints, and those lists are usually published by a body that holds the
copyright in the wording. Redistributing them would be taking someone else's
work; fetching them onto your own machine, for your own use, is not.
So supplementary sets are **not** in the repository. ``typikon
--update-menaion ruthenian`` fetches one into your data directory, where the
menologion loader picks it up automatically and overlays it on the common set.
Each source declares where it lives and how to read it. Adding one is a dict.
"""
import datetime as dt
import json
import os
import re
import urllib.request
__all__ = ["SOURCES", "update", "target_dir"]
MONTHS = ("January February March April May June July August "
"September October November December").split()
SOURCES = {
"ruthenian": {
"title": "Byzantine Catholic Metropolia of Pittsburgh",
"url": "https://mci.archpitt.org/liturgy/Calendar_of_Saints.html",
"note": "Published by the Metropolitan Cantor Institute. Fetched for "
"your own use; not redistributed with this package.",
},
}
def target_dir():
"""Where a fetched set is written.
The package directory if it is writable — a checkout usually is — and the
user's data directory otherwise, so an installed copy still works.
"""
packaged = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
if os.access(packaged, os.W_OK):
return packaged
base = os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share")
path = os.path.join(base, "typikon")
os.makedirs(path, exist_ok=True)
return path
def _strip_html(html):
txt = re.sub(r"<script.*?</script>|<style.*?</style>", "", html, flags=re.S)
txt = re.sub(r"<(br|/p|/div|/li|/h\d)[^>]*>", "\n", txt, flags=re.I)
txt = re.sub(r"<[^>]+>", "\n", txt)
for a, b in (("&nbsp;", " "), ("&amp;", "&"), ("&#8217;", "'"),
("&rsquo;", "'"), ("&mdash;", "—")):
txt = txt.replace(a, b)
return [x for x in (re.sub(r"\s+", " ", l).strip() for l in txt.split("\n")) if x]
def _parse_mci(html):
"""MCI publishes prose, not a table, so entries split on the date headings.
The page has at least one typo — 14 October is written "October 1" — which
silently overwrites the real entry for that day, taking the Protection of
the Theotokos with it. A date that goes backwards inside a month is the
giveaway, and is corrected to follow the previous one.
"""
lines = _strip_html(html)
idx = {m: i + 1 for i, m in enumerate(MONTHS)}
dated = re.compile(r"^(" + "|".join(MONTHS) + r")\s+(\d{1,2})\s*(.*)$")
heading = re.compile(r"^(" + "|".join(MONTHS) + r")\.?$")
out, cur, buf, corrections = {}, None, [], []
prev_month = prev_day = None
def flush():
if not (cur and buf):
return
items = []
for piece in buf:
for part in re.split(r"(?<=\.)\s+(?=[A-Z(])", piece):
part = part.strip(" .;")
if len(part) > 3 and not dated.match(part) and not heading.match(part):
items.append(part)
if items:
out[cur] = {"commemorations": items}
for line in lines:
if heading.match(line):
continue
m = dated.match(line)
if m:
flush()
month, day = idx[m.group(1)], int(m.group(2))
if prev_month == month and day <= prev_day:
corrections.append(
f"{month:02d}-{day:02d} -> {month:02d}-{prev_day + 1:02d}")
day = prev_day + 1
prev_month, prev_day = month, day
cur = f"{month:02d}-{day:02d}"
buf = [m.group(3)] if m.group(3).strip() else []
elif cur:
buf.append(line)
flush()
return out, corrections
_PARSERS = {"ruthenian": _parse_mci}
def update(name):
"""Fetch a supplementary set and write it beside the packaged menologion."""
try:
source = SOURCES[name]
except KeyError:
raise KeyError(f"unknown menaion: {name!r}; "
f"try one of {', '.join(SOURCES)}") from None
req = urllib.request.Request(source["url"],
headers={"User-Agent": "typikon"})
with urllib.request.urlopen(req, timeout=30) as r:
html = r.read().decode("utf-8", "replace")
entries, corrections = _PARSERS[name](html)
expected = set()
for month in range(1, 13):
for day in range(1, 32):
try:
dt.date(2024, month, day)
expected.add(f"{month:02d}-{day:02d}")
except ValueError:
pass
missing = sorted(expected - set(entries))
path = os.path.join(target_dir(), f"{name}.json")
with open(path, "w", encoding="utf-8") as fh:
json.dump(entries, fh, ensure_ascii=False, indent=1, sort_keys=True)
return {"path": path, "days": len(entries), "corrections": corrections,
"missing": missing, "source": source}
+106
View File
@@ -0,0 +1,106 @@
"""The fixed calendar, and the movable feasts that outrank it.
The menologion ships once and serves every jurisdiction, because it is indexed
by **ecclesiastical** date. A church on the old calendar and a church on the new
both keep the Nativity on 25 December; they disagree about which morning that
is, and that disagreement lives in the calendar conversion, not here.
Supplementary sets — a local church's own saints — overlay the common one
rather than replacing it, so a jurisdiction can add without forking the file.
"""
import json
import os
__all__ = ["Menologion", "load", "MOVABLE_FEASTS", "movable_feast_for"]
_DATA = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
_cache = {}
class Menologion:
def __init__(self, entries):
self.entries = entries
def get(self, month, day):
return self.entries.get(f"{month:02d}-{day:02d}", {})
def overlay(self, other):
"""Add another set on top of this one, keeping both days' entries."""
merged = {k: dict(v) for k, v in self.entries.items()}
for key, add in other.entries.items():
if key not in merged:
merged[key] = dict(add)
continue
base = merged[key]
base["commemorations"] = (list(base.get("commemorations", []))
+ list(add.get("commemorations", [])))
# A supplementary set may raise a day's rank but never lower it.
for field in ("rank", "cycle"):
if add.get(field):
base[field] = add[field]
return Menologion(merged)
def load(name="menologion"):
"""Load a menologion by name from the packaged data directory."""
if name in _cache:
return _cache[name]
path = os.path.join(_DATA, f"{name}.json")
if not os.path.exists(path):
raise FileNotFoundError(path)
with open(path, encoding="utf-8") as fh:
m = Menologion(json.load(fh))
_cache[name] = m
return m
def for_jurisdiction(jur):
"""The common menologion with any supplementary sets overlaid."""
m = load()
for extra in jur.menaia:
try:
m = m.overlay(load(extra))
except FileNotFoundError:
# A supplementary set is optional; its absence is not an error.
pass
return m
# ── the movable feasts, named by their offset from Pascha
#
# These outrank whatever the fixed calendar has for the day. The keys are
# entries in the movable cycle; the value is (title, rank).
MOVABLE_FEASTS = {
"pascha": ("The Radiant and Life-giving Resurrection of our Lord — Holy Pascha", "great"),
"palm_sunday": ("The Entry of our Lord into Jerusalem", "great"),
"ascension": ("The Ascension of our Lord, God and Saviour Jesus Christ", "great"),
"pentecost": ("Holy Pentecost — the Descent of the Holy Spirit", "great"),
"lazarus_saturday": ("Lazarus Saturday", "vigil"),
"great_thursday": ("Great and Holy Thursday", "vigil"),
"great_friday": ("Great and Holy Friday", "vigil"),
"holy_saturday": ("Great and Holy Saturday", "vigil"),
"thomas_sunday": ("Thomas Sunday — the Antipascha", "polyeleos"),
"myrrhbearers": ("The Sunday of the Holy Myrrh-bearing Women", "polyeleos"),
"midfeast": ("The Midfeast of Pentecost", "polyeleos"),
"all_saints": ("The Sunday of All Saints", "polyeleos"),
"publican_pharisee": ("The Sunday of the Publican and the Pharisee", None),
"prodigal_son": ("The Sunday of the Prodigal Son", None),
"meatfare": ("Meatfare Sunday — the Sunday of the Last Judgement", None),
"cheesefare": ("Cheesefare Sunday — the Expulsion of Adam, Forgiveness Sunday", None),
"orthodoxy_sunday": ("The Sunday of Orthodoxy", "polyeleos"),
"gregory_palamas": ("The Sunday of St Gregory Palamas", None),
"cross_veneration": ("The Sunday of the Veneration of the Cross", "polyeleos"),
"john_climacus": ("The Sunday of St John Climacus", None),
"mary_of_egypt": ("The Sunday of St Mary of Egypt", None),
"fathers_first_council": ("The Sunday of the Fathers of the First Council", None),
"clean_monday": ("Clean Monday — the first day of the Great Fast", None),
}
def movable_feast_for(civil, mv):
"""(title, rank) if the day is a movable feast, else (None, None)."""
for key, (title, rank) in MOVABLE_FEASTS.items():
if mv.get(key) == civil:
return title, rank
return None, None
+110
View File
@@ -0,0 +1,110 @@
"""Pascha, and the movable cycle that hangs off it.
Two reckonings are in use. The **Julian Paschalion** is what nearly every
Orthodox Church keeps: the old computus, worked in the Julian calendar and then
read off onto a civil date. The **Gregorian Paschalion** is the Western one,
kept by the Finnish Orthodox Church and by most Byzantine Catholic churches.
Neither is a variant of the other. They are separate tables that happen to
agree in some years — 2025 falls on 20 April by both — and can diverge by as
much as five weeks.
"""
import datetime as dt
from .calendars import from_julian
__all__ = ["julian_pascha", "gregorian_pascha", "pascha", "movable_cycle"]
def julian_pascha(year):
"""Pascha by the Julian Paschalion, as a civil (Gregorian) date.
Meeus' Julian algorithm. It yields a date in the Julian calendar, which is
then read onto the civil calendar — the step that is easy to forget and
that puts the answer thirteen days out when it is.
"""
a = year % 4
b = year % 7
c = year % 19
d = (19 * c + 15) % 30
e = (2 * a + 4 * b - d + 34) % 7
month = (d + e + 114) // 31
day = ((d + e + 114) % 31) + 1
return from_julian(year, month, day)
def gregorian_pascha(year):
"""Pascha by the Gregorian Paschalion — the Western computus."""
a = year % 19
b, c = divmod(year, 100)
d, e = divmod(b, 4)
g = (8 * b + 13) // 25
h = (19 * a + b - d - g + 15) % 30
i, k = divmod(c, 4)
ll = (32 + 2 * e + 2 * i - h - k) % 7
m = (a + 11 * h + 19 * ll) // 433
month = (h + ll - 7 * m + 90) // 25
day = (h + ll - 7 * m + 33 * month + 19) % 32
return dt.date(year, month, day)
def pascha(year, paschalion="julian"):
if paschalion == "julian":
return julian_pascha(year)
if paschalion == "gregorian":
return gregorian_pascha(year)
raise ValueError(f"unknown paschalion: {paschalion!r}")
def movable_cycle(year, paschalion="julian"):
"""Every movable date the calendar needs, keyed off Pascha.
All of these are counted in real days from Pascha, so they are civil dates
already and need no calendar conversion — the distance from Pascha to
Pentecost is fifty days in every reckoning.
"""
p = pascha(year, paschalion)
d = dt.timedelta
return {
"pascha": p,
# ── the Triodion, counted backwards
"publican_pharisee": p - d(70),
"prodigal_son": p - d(63),
"meatfare": p - d(56), # last day meat is eaten
"cheesefare": p - d(49), # Forgiveness Sunday, last day of dairy
"cheesefare_week_start": p - d(55),
"clean_monday": p - d(48), # the Great Fast begins
"orthodoxy_sunday": p - d(42),
"gregory_palamas": p - d(35),
"cross_veneration": p - d(28),
"john_climacus": p - d(21),
"mary_of_egypt": p - d(14),
"great_fast_end": p - d(9), # Friday before Lazarus Saturday
"lazarus_saturday": p - d(8),
"palm_sunday": p - d(7),
"great_week_start": p - d(6),
"great_thursday": p - d(3),
"great_friday": p - d(2),
"holy_saturday": p - d(1),
# ── the Pentecostarion, counted forwards
"bright_week_end": p + d(6),
"thomas_sunday": p + d(7),
"myrrhbearers": p + d(14),
"midfeast": p + d(24),
"ascension": p + d(39),
"fathers_first_council": p + d(42),
"pentecost": p + d(49),
"pentecost_week_end": p + d(55),
"all_saints": p + d(56),
# The Apostles' Fast runs from the Monday after All Saints to the eve
# of Ss Peter and Paul. A late Pascha can leave it with no days at all,
# which is correct rather than a bug — the fast is simply not kept that
# year. Its end is a fixed ecclesiastical date, so the jurisdiction
# resolves it; only the start belongs here.
"apostles_start": p + d(57),
}
+19
View File
@@ -0,0 +1,19 @@
"""Fasting disciplines. One module per tradition; ``get`` finds it by name."""
from . import orthodox, ruthenian
_RULES = {
"orthodox": orthodox.RULE,
"ruthenian": ruthenian.RULE,
}
def get(name):
try:
return _RULES[name]
except KeyError:
raise KeyError(f"unknown fasting rule: {name!r}") from None
def names():
return list(_RULES)
+161
View File
@@ -0,0 +1,161 @@
"""The Typikon's fast, as the Orthodox Church keeps it.
This is the discipline printed in the Typikon and reproduced, with small
variations, by every Orthodox jurisdiction's published calendar. It binds no
one in the canonical sense — the Orthodox Church legislates fasting as
tradition rather than as precept — so every day here is reported as customary.
That is not a softening. It is the difference between a rule of life and a law,
and the Eastern Catholic churches, which do have law about this, are handled in
their own module.
Where usages differ, this follows the common published rule and says so in the
reason. The known divergences:
- **Great Thursday** is given wine and oil here. Some typika keep xerophagy.
- **The Exaltation of the Cross** and the **Beheading of the Forerunner** are
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.
- **Monastic practice**, and the Holy Mountain in particular, is stricter
than any of this throughout.
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)
from ..seasons import (GREAT_FAST, GREAT_WEEK, BRIGHT_WEEK, CHEESEFARE_WEEK,
APOSTLES, DORMITION, NATIVITY_FAST, PENTECOST_WEEK,
CHRISTMASTIDE)
MON, TUE, WED, THU, FRI, SAT, SUN = range(7)
VOCABULARY = {
STRICT: ("no food until evening", "⛔"),
XEROPHAGY: ("xerophagy — no oil or wine", "\U0001f96c"),
WINE_OIL: ("wine and oil permitted", "\U0001fad2"),
FISH: ("fish, wine and oil", "\U0001f41f"),
DAIRY: ("dairy and eggs; no meat", "\U0001f9c0"),
NONE: ("no fast", "\U0001f356"),
}
def _relaxed_by_feast(rank):
"""How far a feast lifts an ordinary Wednesday or Friday fast."""
if rank == "great":
return FISH
if rank in ("vigil", "polyeleos"):
return WINE_OIL
return None
def fast_for(ctx):
md, wd, mv = ctx.fixed, ctx.weekday, ctx.mv
season = ctx.season
weekend = wd in (SAT, SUN)
# ── The fast-free stretches, settled before anything else can override
# them. A Friday inside Bright Week is not a fast day, and the ordinary
# Friday rule further down would say otherwise.
if season == BRIGHT_WEEK:
return Fast(NONE, "Bright Week — no fasting", None)
if season == PENTECOST_WEEK:
return Fast(NONE, "the week after Pentecost — no fasting", None)
if season == CHRISTMASTIDE:
return Fast(NONE, "the Nativity to the eve of Theophany", None)
if mv["publican_pharisee"] <= ctx.civil <= mv["publican_pharisee"] + dt.timedelta(6):
# Fast-free precisely because the Pharisee boasted of fasting twice a week.
return Fast(NONE, "fast-free week (the Publican and the Pharisee)", None)
# ── The Great Fast and Great Week
if season in (GREAT_FAST, GREAT_WEEK):
if ctx.civil == mv["clean_monday"]:
return Fast(STRICT, "the first day of the Great Fast", CUSTOMARY)
if ctx.civil == mv["great_friday"]:
return Fast(STRICT, "Great and Holy Friday", CUSTOMARY)
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)
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)
# ── Cheesefare: meat is set aside for good, everything else stays,
# Wednesday and Friday included.
if season == CHEESEFARE_WEEK:
return Fast(DAIRY, "Cheesefare Week", CUSTOMARY)
if ctx.civil == mv["meatfare"]:
return Fast(NONE, "Meatfare Sunday — the last day of meat", None)
# ── The Nativity Fast, which tightens for its last five days
if season == NATIVITY_FAST:
if md == (12, 24):
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)
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)
# ── 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)
# ── The Dormition Fast, kept like the Great Fast
if season == DORMITION:
if md == (8, 6):
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)
# ── The three fixed fast days outside any season
if md == (1, 5):
return Fast(XEROPHAGY, "the eve of Theophany", CUSTOMARY)
if md == (9, 14):
return Fast(WINE_OIL, "the Exaltation of the Holy Cross", CUSTOMARY)
if md == (8, 29):
return Fast(WINE_OIL, "the Beheading of the Forerunner", CUSTOMARY)
# ── Ordinary Wednesdays and Fridays, which a feast can lift
if wd in (WED, FRI):
relaxed = _relaxed_by_feast(ctx.rank)
if relaxed:
return Fast(relaxed, f"{ctx.feast} — the fast is relaxed", CUSTOMARY)
return Fast(XEROPHAGY, "Wednesday and Friday", CUSTOMARY)
return Fast(NONE, "", None)
RULE = {
"name": "orthodox",
"title": "the Typikon",
"vocabulary": VOCABULARY,
"fast_for": fast_for,
# The Orthodox Church does not legislate the fast as precept, so nothing
# here is reported as binding. See the module docstring.
"has_obligation": False,
}
+126
View File
@@ -0,0 +1,126 @@
"""Byzantine Catholic particular law, alongside the tradition it sits on.
The Eastern Catholic churches differ from the Orthodox here in a way worth
being exact about: they have *law* on fasting, and it requires very much less
than the Typikon describes. Under current Ruthenian particular law the Church
binds only:
- strict abstinence — no meat, fish, eggs or dairy — on the first day of the
Great Fast and on Great and Holy Friday;
- abstinence from meat on the Wednesdays and Fridays of the Great Fast.
Everything else — the Apostles', Dormition and Nativity Fasts, the fuller
keeping of the Great Fast, ordinary Fridays through the year — is encouraged
and traditional and does **not** bind. So this module reports two things at
once: the level at which the day is kept by tradition, and the minimum that
actually obliges. A calendar that says "obligatory" where nothing obliges is
worse than no calendar; one that reports only the binding minimum makes the
obligatory days look laxer than the voluntary ones beside them.
The three degrees this law knows map exactly onto the shared vocabulary:
its strict abstinence is ``wine_oil`` (it does not regulate oil or wine) and
its simple abstinence is ``dairy`` (meat alone is set aside).
Source: the Byzantine Catholic Metropolitan Church of Pittsburgh, whose
published rule on fasting and abstinence is the basis for the obligations
above. Other Eastern Catholic churches legislate their own; where this library
maps them onto this ruleset it is an approximation, and the jurisdiction entry
says so.
"""
import datetime as dt
from ..fasting import (Fast, WINE_OIL, DAIRY, NONE, OBLIGATORY, CUSTOMARY)
from ..seasons import (GREAT_FAST, GREAT_WEEK, BRIGHT_WEEK, CHEESEFARE_WEEK,
APOSTLES, DORMITION, NATIVITY_FAST, PENTECOST_WEEK,
CHRISTMASTIDE)
MON, TUE, WED, THU, FRI, SAT, SUN = range(7)
VOCABULARY = {
WINE_OIL: ("no meat, fish, eggs or dairy", "\U0001f96c"),
DAIRY: ("no meat", "\U0001f41f"),
NONE: ("no fast", "\U0001f356"),
}
def _off_weekend(day):
"""Vigil fasts are kept on the preceding Friday when they land on a weekend.
"If the vigils of Christmas or Theophany fall on Saturday or Sunday, the
fasting and abstinence is done on the previous Friday."
"""
if day.weekday() == SAT:
return day - dt.timedelta(1)
if day.weekday() == SUN:
return day - dt.timedelta(2)
return day
def fast_for(ctx):
md, wd, mv = ctx.fixed, ctx.weekday, ctx.mv
season = ctx.season
# ── the two days the Church binds strictly
if ctx.civil == mv["clean_monday"]:
return Fast(WINE_OIL, "the first day of the Great Fast", OBLIGATORY, WINE_OIL)
if ctx.civil == mv["great_friday"]:
return Fast(WINE_OIL, "Great and Holy Friday", OBLIGATORY, WINE_OIL)
# ── and the days it binds to abstinence from meat. Kept strictly by
# tradition; binding only as far as meat.
if season == GREAT_FAST and wd in (WED, FRI):
return Fast(WINE_OIL, "Wednesday and Friday of the Great Fast",
OBLIGATORY, DAIRY)
# ── fixed vigils and the Exaltation, moved off a weekend where the rule says
for when, label in (
(ctx.civil_of_fixed(9, 14), "the Exaltation of the Holy Cross"),
(_off_weekend(ctx.civil_of_fixed(12, 24)), "the vigil of the Nativity"),
(_off_weekend(ctx.civil_of_fixed(1, 5)), "the vigil of Theophany"),
):
if ctx.civil == when:
return Fast(WINE_OIL, label, CUSTOMARY)
# ── the fast-free stretches, which have to be settled before the ordinary
# Wednesday and Friday rule below or they get overridden by it. Christmas
# Day 2026 is a Friday, and telling you to abstain from meat on it would
# be plainly wrong.
if season == BRIGHT_WEEK:
return Fast(NONE, "Bright Week — no fasting", None)
if season == PENTECOST_WEEK:
return Fast(NONE, "the week after Pentecost — no fasting", None)
if season == CHRISTMASTIDE:
return Fast(NONE, "Christmastide — no fasting", None)
if mv["publican_pharisee"] <= ctx.civil <= mv["publican_pharisee"] + dt.timedelta(6):
return Fast(NONE, "fast-free week (the Publican and the Pharisee)", None)
# ── the rest of the Great Fast and Great Week
if season in (GREAT_FAST, GREAT_WEEK):
if wd in (SAT, SUN):
return Fast(DAIRY, season, CUSTOMARY)
return Fast(WINE_OIL, season, CUSTOMARY)
if season == CHEESEFARE_WEEK:
return Fast(DAIRY, "Cheesefare Week", CUSTOMARY)
# ── the three voluntary seasons
if season in (APOSTLES, DORMITION, NATIVITY_FAST):
return Fast(DAIRY, season, CUSTOMARY)
# ── a great feast releases the ordinary weekday abstinence
if wd in (WED, FRI):
if ctx.rank == "great":
return Fast(NONE, f"{ctx.feast} — the fast is released", None)
return Fast(DAIRY, "Friday" if wd == FRI else "Wednesday", CUSTOMARY)
return Fast(NONE, "", None)
RULE = {
"name": "ruthenian",
"title": "Ruthenian particular law",
"vocabulary": VOCABULARY,
"fast_for": fast_for,
"has_obligation": True,
}
+65
View File
@@ -0,0 +1,65 @@
"""Which season of the year a day falls in.
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
the Church calls the day. That is the whole trick that lets one set of rules
serve the old and new calendars at once: the Nativity Fast begins on 15
November in both, and only the mornings differ.
"""
from .calendars import fixed_between
__all__ = ["GREAT_FAST", "GREAT_WEEK", "BRIGHT_WEEK", "CHEESEFARE_WEEK",
"APOSTLES", "DORMITION", "NATIVITY_FAST", "season_for", "FASTING_SEASONS"]
GREAT_FAST = "Great Fast"
GREAT_WEEK = "Great and Holy Week"
BRIGHT_WEEK = "Bright Week"
PRE_LENTEN = "Triodion"
CHEESEFARE_WEEK = "Cheesefare Week"
APOSTLES = "Apostles' Fast"
DORMITION = "Dormition Fast"
NATIVITY_FAST = "Nativity Fast"
PENTECOST_WEEK = "Week after Pentecost"
CHRISTMASTIDE = "Christmastide"
FASTING_SEASONS = (GREAT_FAST, GREAT_WEEK, CHEESEFARE_WEEK,
APOSTLES, DORMITION, NATIVITY_FAST)
def season_for(civil, fixed_md, mv, apostles_end):
"""The season a day belongs to, or None for ordinary time.
``fixed_md`` is the ecclesiastical (month, day). ``apostles_end`` is the
civil date of 28 June reckoned in this jurisdiction's menologion — the eve
of Ss Peter and Paul, where the Apostles' Fast stops.
"""
# Movable first: Great Week outranks the Great Fast it follows, and Bright
# Week outranks everything.
if mv["pascha"] <= civil <= mv["bright_week_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"]:
return GREAT_FAST
if mv["cheesefare_week_start"] <= civil <= mv["cheesefare"]:
return CHEESEFARE_WEEK
if mv["publican_pharisee"] <= civil < mv["cheesefare_week_start"]:
return PRE_LENTEN
if mv["pentecost"] <= civil <= mv["pentecost_week_end"]:
return PENTECOST_WEEK
# The Apostles' Fast can be empty in a year with a late Pascha, which the
# comparison handles without a special case.
if mv["apostles_start"] <= civil <= apostles_end:
return APOSTLES
# Fixed seasons, in ecclesiastical space.
if fixed_between(fixed_md, (8, 1), (8, 14)):
return DORMITION
if fixed_between(fixed_md, (11, 15), (12, 24)):
return NATIVITY_FAST
if fixed_between(fixed_md, (12, 25), (1, 4)):
return CHRISTMASTIDE
return None