mirror of
https://git.pumpin.online/funkpower/typikon.git
synced 2026-10-22 18:57:13 +00:00
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:
+12
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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"]
|
||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
@@ -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
@@ -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())
|
||||||
@@ -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"
|
||||||
@@ -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
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from .ics import to_ics
|
||||||
|
from .jsonout import to_json
|
||||||
|
|
||||||
|
__all__ = ["to_json", "to_ics"]
|
||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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 ((" ", " "), ("&", "&"), ("’", "'"),
|
||||||
|
("’", "'"), ("—", "—")):
|
||||||
|
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}
|
||||||
@@ -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
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user