rebuild the engine in Rust

Python was never the house language here: the mw-* desktop surface it came
from is 39 bash scripts and was exactly one Python script, the one this was
extracted from. What it cost was distribution, a widget dependency that
needed pipx and a Python floor, and 24ms of interpreter startup before a line
of the program ran.

The port is verified rather than asserted. Both implementations were run over
199,446 day-records, 26 jurisdictions across 21 years, with every field of
every record required to match; the JSON, the iCalendar and 480 renderings of
the text output are identical too. examples/dump.rs is the harness.

What the type system actually buys, which is less than the usual claim and
more than nothing:

  - Level is an ordered enum, strictest first, so  reads as 'a is
    stricter' and relaxed_to() is the one place a feast can lift a fast. No
    rule can get the direction backwards.
  - Date is a newtype over the Julian Day Number and JulianDate is a separate
    type, so a civil date and an ecclesiastical one cannot be mixed up. That
    confusion is the single most common way calendar code goes wrong.
  - Octets is a newtype in the iCalendar folder, because the bug it replaced
    counted characters against a limit RFC 5545 states in octets.
  - Movables is a struct, not a map, so a mistyped key is a compile error
    rather than a None at the far end of the program.

Of the six defects an adversarial review found in the Python, exactly one had
a shape a type system would have caught. The rest were logic, and the tests
came with them.

The menologion is compiled in with include_str!, so the binary answers on its
own: 930KB, no runtime, 1.2ms to start against Python's 36ms.
This commit is contained in:
funkpower
2026-09-18 11:13:15 -04:00
parent 8247b51805
commit d115616eff
44 changed files with 3367 additions and 2287 deletions
+2 -10
View File
@@ -1,12 +1,4 @@
__pycache__/
*.py[cod]
/target
**/*.rs.bk
.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
Generated
+249
View File
@@ -0,0 +1,249 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "clap"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "typikon"
version = "0.2.0"
dependencies = [
"clap",
"serde",
"serde_json",
]
[[package]]
name = "unicode-ident"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+30
View File
@@ -0,0 +1,30 @@
[package]
name = "typikon"
version = "0.2.0"
edition = "2021"
rust-version = "1.74"
description = "The Orthodox liturgical day: feasts, seasons and the fast, computed rather than fetched"
license = "GPL-2.0-only"
repository = "https://git.pumpin.online/funkpower/typikon"
readme = "README.md"
keywords = ["orthodox", "liturgical", "calendar", "byzantine", "pascha"]
categories = ["command-line-utilities", "date-and-time"]
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
clap = { version = "4", features = ["derive"] }
[profile.release]
strip = true
lto = true
codegen-units = 1
panic = "abort"
[[bin]]
name = "typikon"
path = "src/main.rs"
[lib]
name = "typikon"
path = "src/lib.rs"
+41 -22
View File
@@ -23,18 +23,23 @@ $ typikon
## Install
One static binary, no runtime to install first.
```console
$ pipx install git+https://git.pumpin.online/funkpower/typikon
$ cargo install --git https://git.pumpin.online/funkpower/typikon
```
Or run it straight out of a checkout. It has no dependencies and needs only
Python 3.9.
Or build it from a checkout:
```console
$ git clone https://git.pumpin.online/funkpower/typikon
$ cd typikon && python -m typikon.cli
$ cd typikon && cargo build --release
$ install -Dm755 target/release/typikon ~/.local/bin/typikon
```
The menologion is compiled in, so the binary answers for every day of the year
on its own: no data file to find, no network, nothing to go stale.
## Which calendar?
Three things get conflated constantly, and keeping them apart is most of what
@@ -126,20 +131,30 @@ neighbour.
## As a library
```python
import typikon
```rust
use typikon::calendars::Date;
use typikon::day::describe;
use typikon::jurisdictions::resolve;
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
let rocor = resolve(Some("rocor"))?;
let day = describe(Date::from_ymd(2026, 9, 18).unwrap(), rocor);
typikon.julian_pascha(2027) # datetime.date(2027, 5, 2)
typikon.gregorian_pascha(2027) # datetime.date(2027, 3, 28)
day.church_date; // "5 September"
day.fast; // Level::Xerophagy
day.title(); // "The Holy Prophet Zachariah, father of the Forerunner"
day.is_obligatory(); // false
for day in typikon.week(date, 7, "antiochian"):
...
typikon::paschalion::julian_pascha(2027); // 2027-05-02
typikon::paschalion::gregorian_pascha(2027); // 2027-03-28
```
The levels are an ordered enum, strictest first, so `a < b` reads as "a is
stricter than b" and a feast can only ever relax a fast:
```rust
Level::Xerophagy.relaxed_to(Level::Fish, Level::WineOil) // WineOil: no saint
// brings fish into Lent
Level::Fish.relaxed_to(Level::WineOil, Level::WineOil) // Fish: never tightens
```
## The menologion
@@ -150,7 +165,7 @@ 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
`data/menologion.json`, compiled into the binary, 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
@@ -165,14 +180,18 @@ $ typikon --list-menaia
$ typikon --update-menaion ruthenian
```
It lands beside the packaged menologion and is overlaid automatically.
It lands in `~/.local/share/typikon/` 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.
`typikon` is pinned against published calendars: the paschalion for six years
by both reckonings and its invariants across two centuries, 146,000 calendar
round-trips from 1800 to 2200, the fast on every day that is not open to
interpretation, and every jurisdiction over twenty years.
It was ported from a Python implementation, and the port was checked by
running both over 199,446 day-records, 26 jurisdictions across 21 years, and
requiring every field of every record to match.
It is a calendar, not an authority. If it disagrees with the one your parish
prints, your parish is right. Please open an issue.
@@ -187,4 +206,4 @@ widgets for Hyprland (Quickshell), waybar and eww, all reading the JSON above.
## 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.
`data/LICENSE` says so and notes what the fetched sets are not.
+1 -1
View File
@@ -9,4 +9,4 @@ 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.
fetcher puts a copy on your machine for your own use. See src/menaia.rs.
+19
View File
@@ -0,0 +1,19 @@
// Dump every day of a range, every jurisdiction, as one JSON object per line,
// for differential testing against the Python implementation.
use typikon::calendars::Date;
use typikon::day::describe_with;
use typikon::jurisdictions::{CALENDARS, JURISDICTIONS};
use typikon::menologion;
fn main() {
let args: Vec<String> = std::env::args().collect();
let start = Date::parse_iso(&args[1]).expect("start date");
let days: i32 = args[2].parse().expect("days");
for jur in JURISDICTIONS.iter().chain(CALENDARS.iter()) {
let men = menologion::for_jurisdiction(jur.menaia);
for n in 0..days {
let d = describe_with(start + n, jur, &men);
println!("{}", serde_json::to_string(&d.to_json()).unwrap());
}
}
}
-43
View File
@@ -1,43 +0,0 @@
[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", "data/LICENSE"]
[tool.pytest.ini_options]
testpaths = ["tests"]
+262
View File
@@ -0,0 +1,262 @@
//! 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 until 14 March 2100, and
//! fourteen from the 15th, the day after a Julian 29 February the Gregorian
//! calendar does not have.
//!
//! Keeping both is what makes a single menologion serve every jurisdiction. The
//! Nativity is 25 December in the Julian and the Gregorian books alike; the two
//! traditions disagree about which morning that is, not about which date it is.
//!
//! Everything here is built on the Julian Day Number, which is why this crate
//! carries its own date type instead of a calendar library: the conversions the
//! liturgical year needs *are* JDN arithmetic, and a `Date` that is a JDN in a
//! newtype makes "add a week" and "what is this in the Julian calendar" the
//! same two lines.
use std::fmt;
/// A civil (proleptic Gregorian) date, stored as a Julian Day Number.
///
/// A newtype over the JDN rather than a (y, m, d) triple, so that day
/// arithmetic cannot go wrong at a month or year boundary.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
pub struct Date(pub i32);
/// A date in the Julian calendar. Deliberately a *different type* from `Date`:
/// mixing the two is the single most common way calendar code goes wrong, and
/// the compiler can simply forbid it.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
pub struct JulianDate {
pub year: i32,
pub month: u32,
pub day: u32,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Weekday {
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday,
Sunday,
}
pub const MONTHS: [&str; 12] = [
"January", "February", "March", "April", "May", "June", "July", "August",
"September", "October", "November", "December",
];
const MONTHS_SHORT: [&str; 12] = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
impl Weekday {
pub fn name(self) -> &'static str {
match self {
Weekday::Monday => "Monday",
Weekday::Tuesday => "Tuesday",
Weekday::Wednesday => "Wednesday",
Weekday::Thursday => "Thursday",
Weekday::Friday => "Friday",
Weekday::Saturday => "Saturday",
Weekday::Sunday => "Sunday",
}
}
pub fn short(self) -> &'static str {
&self.name()[..3]
}
pub fn is_weekend(self) -> bool {
matches!(self, Weekday::Saturday | Weekday::Sunday)
}
}
/// Julian Day Number for a proleptic Gregorian date.
pub fn gregorian_to_jdn(year: i32, month: u32, day: u32) -> i32 {
let a = (14 - month as i32) / 12;
let y = year + 4800 - a;
let m = month as i32 + 12 * a - 3;
day as i32 + (153 * m + 2) / 5 + 365 * y + y / 4 - y / 100 + y / 400 - 32045
}
/// Julian Day Number for a proleptic Julian date.
pub fn julian_to_jdn(year: i32, month: u32, day: u32) -> i32 {
let a = (14 - month as i32) / 12;
let y = year + 4800 - a;
let m = month as i32 + 12 * a - 3;
day as i32 + (153 * m + 2) / 5 + 365 * y + y / 4 - 32083
}
fn jdn_to_gregorian(jdn: i32) -> (i32, u32, u32) {
let a = jdn + 32044;
let b = a.div_euclid(146097);
let c = a.rem_euclid(146097);
let d = (4 * c + 3) / 146097;
let e = c - (146097 * d) / 4;
let f = (4 * e + 3) / 1461;
let g = e - (1461 * f) / 4;
let h = (5 * g + 2) / 153;
let day = g - (153 * h + 2) / 5 + 1;
let month = h + 3 - 12 * (h / 10);
let year = 400 * b + 100 * d + f - 4800 + h / 10;
(year, month as u32, day as u32)
}
fn jdn_to_julian(jdn: i32) -> (i32, u32, u32) {
let c = jdn + 32082;
let d = (4 * c + 3) / 1461;
let e = c - (1461 * d) / 4;
let m = (5 * e + 2) / 153;
let day = e - (153 * m + 2) / 5 + 1;
let month = m + 3 - 12 * (m / 10);
let year = d - 4800 + m / 10;
(year, month as u32, day as u32)
}
impl Date {
pub fn from_ymd(year: i32, month: u32, day: u32) -> Option<Date> {
if !(1..=12).contains(&month) || day == 0 || day > days_in_month(year, month) {
return None;
}
Some(Date(gregorian_to_jdn(year, month, day)))
}
pub fn ymd(self) -> (i32, u32, u32) {
jdn_to_gregorian(self.0)
}
pub fn year(self) -> i32 {
self.ymd().0
}
pub fn month(self) -> u32 {
self.ymd().1
}
pub fn day(self) -> u32 {
self.ymd().2
}
pub fn weekday(self) -> Weekday {
// JDN 0 was a Monday, so the modulus lands directly on the enum.
match self.0.rem_euclid(7) {
0 => Weekday::Monday,
1 => Weekday::Tuesday,
2 => Weekday::Wednesday,
3 => Weekday::Thursday,
4 => Weekday::Friday,
5 => Weekday::Saturday,
_ => Weekday::Sunday,
}
}
/// The Julian calendar date of this civil date.
pub fn to_julian(self) -> JulianDate {
let (year, month, day) = jdn_to_julian(self.0);
JulianDate { year, month, day }
}
/// How many days the Julian reckoning currently lags the Gregorian one.
/// Computed, never assumed, so nothing here breaks at the 2100 boundary.
pub fn julian_offset(self) -> i32 {
let j = self.to_julian();
self.0 - gregorian_to_jdn(j.year, j.month, j.day)
}
pub fn iso(self) -> String {
let (y, m, d) = self.ymd();
format!("{y:04}-{m:02}-{d:02}")
}
/// "Fri 18 Sep", the width the seven-day view lines up on.
pub fn short(self) -> String {
let (_, m, d) = self.ymd();
format!("{} {} {}", self.weekday().short(), d, MONTHS_SHORT[(m - 1) as usize])
}
/// "18 September 2026"
pub fn long(self) -> String {
let (y, m, d) = self.ymd();
format!("{} {} {}", d, MONTHS[(m - 1) as usize], y)
}
pub fn parse_iso(s: &str) -> Option<Date> {
let b = s.as_bytes();
if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
return None;
}
let year = s[0..4].parse().ok()?;
let month = s[5..7].parse().ok()?;
let day = s[8..10].parse().ok()?;
Date::from_ymd(year, month, day)
}
}
impl JulianDate {
/// The civil date of a Julian calendar date.
pub fn to_civil(self) -> Date {
Date(julian_to_jdn(self.year, self.month, self.day))
}
}
impl std::ops::Add<i32> for Date {
type Output = Date;
fn add(self, days: i32) -> Date {
Date(self.0 + days)
}
}
impl std::ops::Sub<i32> for Date {
type Output = Date;
fn sub(self, days: i32) -> Date {
Date(self.0 - days)
}
}
/// Days between two dates, which is a count and not a date.
impl std::ops::Sub<Date> for Date {
type Output = i32;
fn sub(self, other: Date) -> i32 {
self.0 - other.0
}
}
impl fmt::Display for Date {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.iso())
}
}
pub fn is_leap(year: i32) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
pub fn days_in_month(year: i32, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if is_leap(year) => 29,
2 => 28,
_ => 0,
}
}
/// 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.
pub fn fixed_between(md: (u32, u32), start: (u32, u32), end: (u32, u32)) -> bool {
if start <= end {
start <= md && md <= end
} else {
md >= start || md <= end
}
}
+77
View File
@@ -0,0 +1,77 @@
//! 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.
use std::path::PathBuf;
pub const ENV_VAR: &str = "TYPIKON_JURISDICTION";
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Source {
Flag,
Environment,
Config,
Default,
}
pub fn path() -> PathBuf {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| {
let home = std::env::var_os("HOME").unwrap_or_default();
PathBuf::from(home).join(".config")
});
base.join("typikon").join("config.toml")
}
/// The one key this file holds, read without a TOML parser because a
/// dependency for `key = "value"` is a dependency too many.
fn read() -> Option<String> {
let text = std::fs::read_to_string(path()).ok()?;
for line in text.lines() {
let line = line.trim();
if line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else { continue };
if key.trim() != "jurisdiction" {
continue;
}
let value = value.trim().trim_matches(['"', '\'']);
if !value.is_empty() {
return Some(value.to_string());
}
}
None
}
pub fn write(jurisdiction: &str) -> std::io::Result<PathBuf> {
let p = path();
if let Some(dir) = p.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(
&p,
format!(
"# Which reckoning typikon uses when no --jurisdiction is given.\n\
# Run `typikon --list` to see every accepted value.\n\
jurisdiction = \"{jurisdiction}\"\n"
),
)?;
Ok(p)
}
/// The environment beats the config file; both beat the built-in default.
pub fn default_jurisdiction() -> (Option<String>, Source) {
if let Ok(v) = std::env::var(ENV_VAR) {
if !v.is_empty() {
return (Some(v), Source::Environment);
}
}
match read() {
Some(v) => (Some(v), Source::Config),
None => (None, Source::Default),
}
}
+224
View File
@@ -0,0 +1,224 @@
//! A single day, fully reckoned.
//!
//! `Day` is what everything else in the crate 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.
use serde::Serialize;
use crate::calendars::{Date, Weekday, MONTHS};
use crate::fasting::{Fast, Level, Obligation};
use crate::jurisdictions::Jurisdiction;
use crate::menologion::{self, Menologion, Rank};
use crate::paschalion::{movable_cycle, Movables};
use crate::rules::{self, Context};
use crate::seasons::season_for;
#[derive(Clone, Debug)]
pub struct Day {
pub date: Date,
pub weekday: Weekday,
pub jurisdiction: &'static str,
pub calendar: &'static str,
/// The ecclesiastical date, as "25 December".
pub church_date: String,
pub season: Option<crate::seasons::Season>,
pub cycle: Option<String>,
pub feast: Option<String>,
pub rank: Option<Rank>,
pub commemorations: Vec<String>,
pub fast: Level,
pub fast_label: &'static str,
pub emoji: &'static str,
pub reason: String,
pub obligation: Option<Obligation>,
pub binds: Level,
pub pascha: Date,
pub days_to_pascha: i32,
pub julian_offset: i32,
}
impl Day {
pub fn is_obligatory(&self) -> bool {
self.obligation == Some(Obligation::Obligatory)
}
/// What to put on one line: the feast if there is one, else the day's
/// principal commemoration.
pub fn title(&self) -> &str {
match &self.feast {
Some(f) => f,
None => self.commemorations.first().map(String::as_str).unwrap_or(""),
}
}
}
/// The wire format. Flat and stable, because front ends are the thing most
/// likely to be written by someone else and a schema that shifts underneath
/// them is a bad neighbour. Field names match the shape front ends already read.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DayJson<'a> {
pub date: String,
pub weekday: &'a str,
pub jurisdiction: &'a str,
pub calendar: &'a str,
pub church_date: &'a str,
pub season: Option<&'a str>,
pub cycle: Option<&'a str>,
pub feast: Option<&'a str>,
pub rank: Option<&'a str>,
pub title: &'a str,
pub commemorations: &'a [String],
pub fast: &'a str,
pub fast_label: &'a str,
pub emoji: &'a str,
pub reason: &'a str,
pub obligation: Option<&'a str>,
pub binds: &'a str,
pub pascha: String,
pub days_to_pascha: i32,
pub julian_offset: i32,
}
impl Day {
pub fn to_json(&self) -> DayJson<'_> {
DayJson {
date: self.date.iso(),
weekday: self.weekday.name(),
jurisdiction: self.jurisdiction,
calendar: self.calendar,
church_date: &self.church_date,
season: self.season.map(|s| s.name()),
cycle: self.cycle.as_deref(),
feast: self.feast.as_deref(),
rank: self.rank.map(|r| r.key()),
title: self.title(),
commemorations: &self.commemorations,
fast: self.fast.key(),
fast_label: self.fast_label,
emoji: self.emoji,
reason: &self.reason,
obligation: self.obligation.map(|o| match o {
Obligation::Obligatory => "obligatory",
Obligation::Customary => "customary",
}),
binds: self.binds.key(),
pascha: self.pascha.iso(),
days_to_pascha: self.days_to_pascha,
julian_offset: self.julian_offset,
}
}
}
/// Reckon one civil date in one jurisdiction.
pub fn describe(date: Date, jur: &'static Jurisdiction) -> Day {
let men = menologion::for_jurisdiction(jur.menaia);
describe_with(date, jur, &men)
}
/// As `describe`, but with a menologion the caller already built. Reckoning a
/// year one day at a time should not rebuild the overlay 365 times.
pub fn describe_with(date: Date, jur: &'static Jurisdiction, men: &Menologion) -> Day {
let rule = rules::get(jur.rule);
let (fy, fm, fd) = jur.fixed_of_civil(date);
let mv = movable_cycle(date.year(), jur.paschalion);
let apostles_end = jur.civil_of_fixed(date.year(), 6, 28);
let season = season_for(date, (fm, fd), &mv, apostles_end);
let entry = men.get(fm, fd).cloned().unwrap_or_default();
let mut commemorations = entry.commemorations;
let mut rank = entry.rank;
// When a movable feast and a fixed one land together, the higher rank is
// the day's feast and the other joins the commemorations. Letting the
// movable one win unconditionally lost the Annunciation whenever it fell in
// Great Week: the Great Feast of the Incarnation demoted to a bullet, and
// its rank overwritten with the lesser one the fasting rules then read.
let fixed_feast = rank
.filter(|r| r.names_the_day())
.and_then(|_| commemorations.first().cloned());
let movable = menologion::movable_feast_for(date, &mv);
let feast: Option<String> = match (movable, &fixed_feast) {
(Some((mov_title, mov_rank)), Some(fixed)) if rank > mov_rank => {
let mov = mov_title.to_string();
if !commemorations.contains(&mov) {
commemorations.insert(1.min(commemorations.len()), mov);
}
Some(fixed.clone())
}
(Some((mov_title, mov_rank)), fixed) => {
if mov_rank >= rank {
rank = mov_rank;
}
if let Some(f) = fixed {
if !commemorations.contains(f) {
commemorations.insert(1.min(commemorations.len()), f.clone());
}
}
Some(mov_title.to_string())
}
(None, fixed) => fixed.clone(),
};
let ctx = Context {
civil: date,
fixed: (fm, fd),
fixed_year: fy,
weekday: date.weekday(),
mv: &mv,
season,
feast: feast
.clone()
.or_else(|| commemorations.first().cloned())
.unwrap_or_default(),
rank,
jurisdiction: jur,
};
let Fast { level, reason, obligation, binds } = (rule.fast_for)(&ctx);
Day {
date,
weekday: date.weekday(),
jurisdiction: jur.key,
calendar: jur.calendar().key(),
church_date: format!("{} {}", fd, MONTHS[(fm - 1) as usize]),
season,
cycle: entry.cycle,
feast,
rank,
commemorations,
fast: level,
fast_label: (rule.vocabulary.label)(level),
emoji: (rule.vocabulary.emoji)(level),
reason,
obligation,
binds,
pascha: mv.pascha,
days_to_pascha: mv.pascha - date,
julian_offset: date.julian_offset(),
}
}
/// `days` consecutive days from `start`.
pub fn week(start: Date, days: i32, jur: &'static Jurisdiction) -> Vec<Day> {
let men = menologion::for_jurisdiction(jur.menaia);
(0..days.max(1))
.map(|n| describe_with(start + n, jur, &men))
.collect()
}
/// Every key date of a year, for checking against a published calendar.
pub fn year_of(year: i32, jur: &'static Jurisdiction) -> (Movables, [(&'static str, Date); 4]) {
let mv = movable_cycle(year, jur.paschalion);
let fixed = [
("apostles end", jur.civil_of_fixed(year, 6, 28)),
("dormition", jur.civil_of_fixed(year, 8, 15)),
("nativity", jur.civil_of_fixed(year, 12, 25)),
("theophany", jur.civil_of_fixed(year, 1, 6)),
];
(mv, fixed)
}
+130
View File
@@ -0,0 +1,130 @@
//! 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 and
//! no fast, are left out, because a subscribed calendar that marks all 365 days
//! is one nobody keeps.
use crate::day::Day;
use crate::fasting::{Level, Obligation};
/// A count of octets, which is what RFC 5545 measures content lines in.
///
/// A newtype because the Python this was ported from folded on *characters*
/// instead, and an emoji is four octets: lines went eight over a limit the code
/// believed it was respecting. Counting the wrong unit is not a mistake a name
/// can prevent, but it is one a type can.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
struct Octets(usize);
/// RFC 5545: a content line SHOULD NOT be longer than 75 octets, excluding the
/// line break.
const LIMIT: Octets = Octets(75);
fn octets_of(c: char) -> Octets {
Octets(c.len_utf8())
}
fn escape(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for c in text.chars() {
match c {
'\\' => out.push_str(r"\\"),
';' => out.push_str(r"\;"),
',' => out.push_str(r"\,"),
'\n' => out.push_str(r"\n"),
_ => out.push(c),
}
}
out
}
/// Break a content line on octets, continued with a leading space. The split
/// never falls inside a character: the budget is spent per codepoint, so a
/// four-octet emoji moves to the next line whole.
fn fold(line: &str) -> String {
let mut out: Vec<String> = Vec::new();
let mut cur = String::new();
let mut used = Octets(0);
for c in line.chars() {
let n = octets_of(c);
if Octets(used.0 + n.0) > LIMIT {
out.push(std::mem::take(&mut cur));
cur.push(' ');
used = Octets(1);
}
cur.push(c);
used = Octets(used.0 + n.0);
}
out.push(cur);
out.join("\r\n")
}
pub fn to_ics(days: &[Day], name: &str, stamp: &str) -> String {
let mut lines: Vec<String> = vec![
"BEGIN:VCALENDAR".into(),
"VERSION:2.0".into(),
"PRODID:-//typikon//EN".into(),
"CALSCALE:GREGORIAN".into(),
"METHOD:PUBLISH".into(),
format!("X-WR-CALNAME:{}", escape(name)),
];
for d in days {
if d.feast.is_none() && d.fast == Level::None {
continue;
}
let start = d.date.iso().replace('-', "");
let end = (d.date + 1).iso().replace('-', "");
let summary = if d.emoji.is_empty() {
d.title().to_string()
} else {
format!("{} {}", d.emoji, d.title())
};
let mut body: Vec<String> = Vec::new();
if d.fast != Level::None {
body.push(if d.reason.is_empty() {
format!("Fast: {}", d.fast_label)
} else {
format!("Fast: {} ({})", d.fast_label, d.reason)
});
}
if d.obligation == Some(Obligation::Obligatory) {
body.push("Obligatory.".into());
}
if let Some(s) = d.season {
body.push(format!("Season: {}", s.name()));
}
if let Some(c) = &d.cycle {
let mut chars = c.chars();
let capped = match chars.next() {
Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
};
body.push(capped);
}
body.push(format!("Church date: {}", d.church_date));
for c in &d.commemorations {
body.push(c.clone());
}
lines.extend([
"BEGIN:VEVENT".into(),
format!("UID:{}-{}@typikon", start, d.jurisdiction),
format!("DTSTAMP:{stamp}"),
format!("DTSTART;VALUE=DATE:{start}"),
format!("DTEND;VALUE=DATE:{end}"),
fold(&format!("SUMMARY:{}", escape(&summary))),
fold(&format!("DESCRIPTION:{}", escape(&body.join("\n")))),
"TRANSP:TRANSPARENT".into(),
"END:VEVENT".into(),
]);
}
lines.push("END:VCALENDAR".into());
let mut s = lines.join("\r\n");
s.push_str("\r\n");
s
}
+38
View File
@@ -0,0 +1,38 @@
//! 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": [...]}`.
use serde::Serialize;
use crate::day::{Day, DayJson};
#[derive(Serialize)]
struct Days<'a> {
days: Vec<DayJson<'a>>,
}
pub fn to_json(days: &[Day], indent: Option<usize>) -> String {
match days {
[one] => encode(&one.to_json(), indent),
many => encode(
&Days { days: many.iter().map(Day::to_json).collect() },
indent,
),
}
}
fn encode<T: Serialize>(value: &T, indent: Option<usize>) -> String {
match indent {
None => serde_json::to_string(value).expect("a day serialises"),
Some(n) => {
let pad = vec![b' '; n];
let fmt = serde_json::ser::PrettyFormatter::with_indent(&pad);
let mut buf = Vec::new();
let mut ser = serde_json::Serializer::with_formatter(&mut buf, fmt);
value.serialize(&mut ser).expect("a day serialises");
String::from_utf8(buf).expect("serde emits utf-8")
}
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod ics;
pub mod json;
pub use ics::to_ics;
pub use json::to_json;
+134
View File
@@ -0,0 +1,134 @@
//! 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.
//!
//! 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 `WineOil`, because it does not regulate oil or
//! wine, and its "abstinence" is `Dairy`, meat alone being 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.
use serde::Serialize;
/// Ordered strictest first, so `a < b` reads as "a is stricter than b" and the
/// derived `Ord` is the comparison the rules want.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Level {
/// No food until evening.
Strict,
/// No oil, no wine; no meat, fish, eggs or dairy.
Xerophagy,
/// Wine and oil permitted; no meat, fish, eggs or dairy.
WineOil,
/// Fish, wine and oil permitted; no meat, eggs or dairy.
Fish,
/// Dairy, eggs and fish permitted; no meat.
Dairy,
/// No fast.
None,
}
impl Level {
pub fn key(self) -> &'static str {
match self {
Level::Strict => "strict",
Level::Xerophagy => "xerophagy",
Level::WineOil => "wine_oil",
Level::Fish => "fish",
Level::Dairy => "dairy",
Level::None => "none",
}
}
pub fn is_fast(self) -> bool {
self != Level::None
}
/// The stricter of two levels.
pub fn stricter_of(self, other: Level) -> Level {
self.min(other)
}
/// The laxer of two levels.
pub fn laxer_of(self, other: Level) -> Level {
self.max(other)
}
/// Relax towards `target`, but never past `cap` and never tightening.
///
/// This is the arithmetic every "a feast lifts the fast" rule wants, in one
/// place, so no rule can get the direction backwards.
pub fn relaxed_to(self, target: Level, cap: Level) -> Level {
let lifted = self.laxer_of(target).stricter_of(cap);
if lifted < self {
self
} else {
lifted
}
}
}
/// Whether anything about the day actually binds. The Orthodox Church keeps the
/// fast as tradition rather than precept, so its rule reports `Customary`
/// throughout; the Eastern Catholic churches have law, and theirs does not.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Obligation {
Obligatory,
Customary,
}
/// How a day is kept, and what about it binds.
#[derive(Clone, Debug)]
pub struct Fast {
/// The day as the tradition keeps it.
pub level: Level,
/// The rule that decided it, so the answer can be argued with.
pub reason: String,
pub obligation: Option<Obligation>,
/// The binding minimum, where one exists. `None` for most jurisdictions,
/// which is the honest answer rather than a softening.
pub binds: Level,
}
impl Fast {
pub fn new(level: Level, reason: &str, obligation: Option<Obligation>) -> Fast {
Fast {
level,
reason: reason.to_string(),
obligation,
binds: Level::None,
}
}
/// A day kept by custom, binding no one.
pub fn customary(level: Level, reason: &str) -> Fast {
Fast::new(level, reason, Some(Obligation::Customary))
}
/// A day with no fast at all.
pub fn free(reason: &str) -> Fast {
Fast::new(Level::None, reason, None)
}
/// A day that binds, and the minimum it binds to.
pub fn binding(level: Level, reason: &str, binds: Level) -> Fast {
Fast {
level,
reason: reason.to_string(),
obligation: Some(Obligation::Obligatory),
binds,
}
}
}
+170
View File
@@ -0,0 +1,170 @@
//! 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
//! - **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 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, 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.
use crate::calendars::Date;
use crate::paschalion::Paschalion;
/// Which calendar the *fixed* feasts are read on.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum MenologionKind {
Julian,
/// Revised Julian, which agrees with the Gregorian for every date until 2800.
RevisedJulian,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum RuleKind {
Orthodox,
Ruthenian,
}
/// The short name people use for a paschalion/menologion combination.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Calendar {
Old,
New,
Western,
}
impl Calendar {
pub fn key(self) -> &'static str {
match self {
Calendar::Old => "old",
Calendar::New => "new",
Calendar::Western => "western",
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct Jurisdiction {
pub key: &'static str,
pub name: &'static str,
pub paschalion: Paschalion,
pub menologion: MenologionKind,
pub rule: RuleKind,
/// Supplementary commemoration sets to overlay, fetched not shipped.
pub menaia: &'static [&'static str],
pub note: &'static str,
}
impl Jurisdiction {
pub fn calendar(&self) -> Calendar {
match (self.paschalion, self.menologion) {
(Paschalion::Gregorian, MenologionKind::RevisedJulian) => Calendar::Western,
(_, MenologionKind::Julian) => Calendar::Old,
_ => Calendar::New,
}
}
/// The ecclesiastical (year, month, day) a civil date is reckoned as.
pub fn fixed_of_civil(&self, date: Date) -> (i32, u32, u32) {
match self.menologion {
MenologionKind::Julian => {
let j = date.to_julian();
(j.year, j.month, j.day)
}
MenologionKind::RevisedJulian => date.ymd(),
}
}
/// The civil date of an ecclesiastical date in a given church year.
pub fn civil_of_fixed(&self, year: i32, month: u32, day: u32) -> Date {
match self.menologion {
MenologionKind::Julian => {
crate::calendars::JulianDate { year, month, day }.to_civil()
}
MenologionKind::RevisedJulian => Date::from_ymd(year, month, day)
.unwrap_or_else(|| Date::from_ymd(year, month, 28).expect("28th exists")),
}
}
}
use MenologionKind::{Julian as JulMen, RevisedJulian as RevJul};
use Paschalion::{Gregorian as GregP, Julian as JulP};
use RuleKind::{Orthodox as Orth, Ruthenian as Ruth};
const NO_MENAIA: &[&str] = &[];
const RUTHENIAN_MENAIA: &[&str] = &["ruthenian"];
/// A reckoning without naming a church. `new` is the default.
pub const CALENDARS: &[Jurisdiction] = &[
Jurisdiction { key: "old", name: "Old Calendar", paschalion: JulP, menologion: JulMen, rule: Orth, menaia: NO_MENAIA,
note: "Julian throughout, the reckoning of Moscow, Serbia, Georgia, Jerusalem and the Holy Mountain." },
Jurisdiction { key: "new", name: "New Calendar", paschalion: JulP, menologion: RevJul, rule: Orth, menaia: NO_MENAIA,
note: "Revised Julian fixed feasts with the Julian Paschalion: Constantinople, Greece, Antioch, Romania, Bulgaria, the OCA." },
Jurisdiction { key: "western", name: "Gregorian", paschalion: GregP, menologion: RevJul, rule: Orth, menaia: NO_MENAIA,
note: "Gregorian throughout, Pascha included. Kept by the Finnish Orthodox Church and by most Byzantine Catholics." },
];
pub const JURISDICTIONS: &[Jurisdiction] = &[
// old calendar
Jurisdiction { key: "moscow", name: "Russian Orthodox Church", paschalion: JulP, menologion: JulMen, rule: Orth, menaia: NO_MENAIA, note: "" },
Jurisdiction { key: "rocor", name: "Russian Orthodox Church Outside Russia", paschalion: JulP, menologion: JulMen, rule: Orth, menaia: NO_MENAIA, note: "" },
Jurisdiction { key: "serbian", name: "Serbian Orthodox Church", paschalion: JulP, menologion: JulMen, rule: Orth, menaia: NO_MENAIA, note: "" },
Jurisdiction { key: "georgian", name: "Georgian Orthodox Church", paschalion: JulP, menologion: JulMen, rule: Orth, menaia: NO_MENAIA, note: "" },
Jurisdiction { key: "jerusalem", name: "Patriarchate of Jerusalem", paschalion: JulP, menologion: JulMen, rule: Orth, menaia: NO_MENAIA, note: "" },
Jurisdiction { key: "athos", name: "The Holy Mountain", paschalion: JulP, menologion: JulMen, rule: Orth, menaia: NO_MENAIA,
note: "Athonite practice is stricter than the printed Typikon at many points." },
Jurisdiction { key: "polish", name: "Polish Orthodox Church", paschalion: JulP, menologion: JulMen, rule: Orth, menaia: NO_MENAIA,
note: "Returned to the Julian menologion in 2014 after decades on the Revised Julian; some parishes kept the new calendar." },
Jurisdiction { key: "macedonian", name: "Macedonian Orthodox Church", paschalion: JulP, menologion: JulMen, rule: Orth, menaia: NO_MENAIA, note: "" },
// new calendar
Jurisdiction { key: "constantinople", name: "Ecumenical Patriarchate", paschalion: JulP, menologion: RevJul, rule: Orth, menaia: NO_MENAIA, note: "" },
Jurisdiction { key: "goarch", name: "Greek Orthodox Archdiocese of America", paschalion: JulP, menologion: RevJul, rule: Orth, menaia: NO_MENAIA, note: "" },
Jurisdiction { key: "greece", name: "Church of Greece", paschalion: JulP, menologion: RevJul, rule: Orth, menaia: NO_MENAIA, note: "" },
Jurisdiction { key: "antiochian", name: "Antiochian Orthodox Church", paschalion: JulP, menologion: RevJul, rule: Orth, menaia: NO_MENAIA, note: "" },
Jurisdiction { key: "oca", name: "Orthodox Church in America", paschalion: JulP, menologion: RevJul, rule: Orth, menaia: NO_MENAIA,
note: "Most parishes keep the new calendar; a minority, chiefly of Carpatho-Russian and Alaskan background, keep the old." },
Jurisdiction { key: "romanian", name: "Romanian Orthodox Church", paschalion: JulP, menologion: RevJul, rule: Orth, menaia: NO_MENAIA, note: "" },
Jurisdiction { key: "bulgarian", name: "Bulgarian Orthodox Church", paschalion: JulP, menologion: RevJul, rule: Orth, menaia: NO_MENAIA, note: "" },
Jurisdiction { key: "cyprus", name: "Church of Cyprus", paschalion: JulP, menologion: RevJul, rule: Orth, menaia: NO_MENAIA, note: "" },
Jurisdiction { key: "albanian", name: "Orthodox Church of Albania", paschalion: JulP, menologion: RevJul, rule: Orth, menaia: NO_MENAIA, note: "" },
Jurisdiction { key: "ukrainian", name: "Orthodox Church of Ukraine", paschalion: JulP, menologion: RevJul, rule: Orth, menaia: NO_MENAIA,
note: "Moved the fixed feasts to the Revised Julian calendar on 1 September 2023. The Julian Paschalion is retained." },
// gregorian throughout
Jurisdiction { key: "finnish", name: "Finnish Orthodox Church", paschalion: GregP, menologion: RevJul, rule: Orth, menaia: NO_MENAIA,
note: "The only Orthodox Church keeping the Gregorian Paschalion." },
// eastern catholic
Jurisdiction { key: "ruthenian", name: "Byzantine Catholic Metropolia of Pittsburgh", paschalion: GregP, menologion: RevJul, rule: Ruth, menaia: RUTHENIAN_MENAIA,
note: "Gregorian throughout. Fasting is reported under Ruthenian particular law, which binds far less than the Typikon prescribes." },
Jurisdiction { key: "ugcc", name: "Ukrainian Greek Catholic Church", paschalion: JulP, menologion: RevJul, rule: Ruth, menaia: RUTHENIAN_MENAIA,
note: "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." },
Jurisdiction { key: "melkite", name: "Melkite Greek Catholic Church", paschalion: GregP, menologion: RevJul, rule: Ruth, menaia: NO_MENAIA,
note: "Practice is not uniform: parishes in the Middle East commonly keep the Julian Paschalion for the sake of the local Orthodox." },
Jurisdiction { key: "romanian-catholic", name: "Romanian Greek Catholic Church", paschalion: GregP, menologion: RevJul, rule: Ruth, menaia: NO_MENAIA, note: "" },
];
/// A jurisdiction key, a calendar name, or `None` for the default.
pub fn resolve(name: Option<&str>) -> Result<&'static Jurisdiction, String> {
let Some(name) = name else {
return Ok(&CALENDARS[1]); // new
};
let key = name.trim().to_ascii_lowercase();
JURISDICTIONS
.iter()
.chain(CALENDARS.iter())
.find(|j| j.key == key)
.ok_or_else(|| name.to_string())
}
+11
View File
@@ -0,0 +1,11 @@
pub mod calendars;
pub mod config;
pub mod day;
pub mod export;
pub mod fasting;
pub mod jurisdictions;
pub mod menaia;
pub mod menologion;
pub mod paschalion;
pub mod rules;
pub mod seasons;
+350
View File
@@ -0,0 +1,350 @@
//! The command line.
use clap::Parser;
use typikon::calendars::Date;
use typikon::config::{self, Source};
use typikon::day::{week, year_of, Day};
use typikon::export::{to_ics, to_json};
use typikon::jurisdictions::{self, Jurisdiction, CALENDARS, JURISDICTIONS};
use typikon::menaia;
use typikon::rules;
#[derive(Parser)]
#[command(
name = "typikon",
version,
about = "The Orthodox liturgical day: feasts, seasons and the fast."
)]
struct Args {
/// A church or a reckoning; see --list
#[arg(short = 'j', long, visible_alias = "calendar", short_alias = 'c')]
jurisdiction: Option<String>,
/// YYYY-MM-DD (default: today)
#[arg(short, long)]
date: Option<String>,
/// How many days from the start date, inclusive
#[arg(short = 'n', long, default_value_t = 1)]
days: i32,
/// Machine-readable output
#[arg(long)]
json: bool,
/// An iCalendar feed
#[arg(long)]
ics: bool,
/// Indent JSON output
#[arg(long)]
indent: Option<usize>,
/// Print a year's key dates and exit
#[arg(long)]
year: Option<i32>,
/// List every jurisdiction and calendar
#[arg(long)]
list: bool,
/// Remember a jurisdiction and exit
#[arg(long, value_name = "JURISDICTION")]
set_default: Option<String>,
/// Fetch a supplementary commemoration set
#[arg(long, value_name = "NAME")]
update_menaion: Option<String>,
/// List the supplementary sets that can be fetched
#[arg(long)]
list_menaia: bool,
#[arg(short, long)]
verbose: bool,
}
fn wrap(text: &str, width: usize) -> Vec<String> {
let mut out = Vec::new();
let mut line = String::new();
for word in text.split_whitespace() {
if !line.is_empty() && line.chars().count() + word.chars().count() + 1 > width {
out.push(std::mem::take(&mut line));
}
if !line.is_empty() {
line.push(' ');
}
line.push_str(word);
}
if !line.is_empty() {
out.push(line);
}
out
}
fn list_jurisdictions() {
println!("\n Calendars, a reckoning without naming a church:\n");
for j in CALENDARS {
println!(" {:20} {}", j.key, j.name);
for line in wrap(j.note, 56) {
println!(" {:20} {}", "", line);
}
}
println!("\n Jurisdictions:\n");
let width = JURISDICTIONS.iter().map(|j| j.key.len()).max().unwrap_or(12);
for j in JURISDICTIONS {
let cal = match j.calendar() {
jurisdictions::Calendar::Old => "old calendar",
jurisdictions::Calendar::New => "new calendar",
jurisdictions::Calendar::Western => "gregorian",
};
let rule = rules::get(j.rule).name;
println!(" {:width$} {}", j.key, j.name);
println!(" {:width$} {cal}; {rule} fasting rule", "");
for line in wrap(j.note, 66) {
println!(" {:width$} {line}", "");
}
println!();
}
}
fn elide(text: &str, width: usize) -> String {
if text.chars().count() <= width {
return text.to_string();
}
let cut: String = text.chars().take(width - 1).collect();
let head = match cut[cut.len() / 2..].find(' ') {
Some(_) => match cut.trim_end().rfind(' ') {
Some(i) => &cut[..i],
None => &cut,
},
None => &cut,
};
format!("{}\u{2026}", head.trim_end_matches([' ', ',', ';']))
}
fn print_days(days: &[Day], jur: &Jurisdiction, source: Source, verbose: bool) {
let rule = rules::get(jur.rule);
if let [d] = days {
let mut head = format!(" {} {}", d.weekday.name(), d.date.long());
let civil_md = format!("{} {}", d.date.day(), typikon::calendars::MONTHS[(d.date.month() - 1) as usize]);
if d.church_date != civil_md {
head.push_str(&format!(" \u{2014} {} in the church calendar", d.church_date));
}
println!("\n{head}");
let from = match source {
Source::Flag => String::new(),
Source::Environment => " (from the environment)".into(),
Source::Config => " (from config)".into(),
Source::Default => String::new(),
};
println!(" {} \u{b7} {}{from}", jur.name, rule.title);
println!();
println!(" {} {}", d.emoji, d.title());
if let Some(c) = &d.cycle {
println!(" {c}");
}
println!();
let mark = if d.is_obligatory() { " \u{2014} obligatory" } else { "" };
println!(" {}{mark}", d.fast_label);
if !d.reason.is_empty() {
println!(" {}", d.reason);
}
if let Some(s) = d.season {
println!(" {}", s.name());
}
let rest: Vec<_> = d.commemorations.iter().filter(|c| c.as_str() != d.title()).collect();
if !rest.is_empty() {
println!();
for c in rest {
println!(" \u{b7} {c}");
}
}
if verbose {
println!();
println!(" Pascha {} \u{b7} {} days", d.pascha, d.days_to_pascha);
}
println!();
return;
}
println!("\n {} \u{b7} {}\n", jur.name, rule.title);
// The glyph is two columns wide in a terminal and one char to Rust, which
// is why the arithmetic below looks off by one.
let fixed = 2 + 2 + 10 + 1 + 1 + 32;
let cols = std::env::var("COLUMNS").ok().and_then(|c| c.parse().ok()).unwrap_or(100usize);
let room = cols.saturating_sub(fixed + 1).max(24);
for d in days {
let mark = if d.is_obligatory() { "*" } else { " " };
println!(
" {} {:>10}{mark} {:<32}{}",
d.emoji,
d.date.short(),
d.fast_label,
elide(d.title(), room)
);
}
if days.iter().any(Day::is_obligatory) {
println!("\n * obligatory. Everything unmarked is customary and does not bind.");
}
println!();
}
fn run() -> Result<i32, String> {
let args = Args::parse();
if args.list {
list_jurisdictions();
return Ok(0);
}
if args.list_menaia {
println!("\n Supplementary commemorations, fetched on request:\n");
for s in menaia::SOURCES {
println!(" {:14} {}", s.key, s.title);
println!(" {:14} {}", "", s.url);
for line in wrap(s.note, 62) {
println!(" {:14} {line}", "");
}
println!();
}
println!(" They are written to {}\n", typikon::menologion::data_dir().display());
return Ok(0);
}
if let Some(name) = &args.update_menaion {
let f = menaia::update(name)?;
println!(" {} days written to {}", f.days, f.path.display());
if let Some(s) = menaia::find(name) {
println!(" Source: {}", s.title);
}
if !f.corrections.is_empty() {
println!(" source typos corrected: {}", f.corrections.join(", "));
}
if !f.missing.is_empty() {
println!(" MISSING: {}", f.missing.join(", "));
return Ok(1);
}
return Ok(0);
}
if let Some(name) = &args.set_default {
let jur = jurisdictions::resolve(Some(name))
.map_err(|n| format!("unknown jurisdiction {n:?}; try --list"))?;
let where_ = config::write(jur.key).map_err(|e| e.to_string())?;
println!(" {} \u{2014} written to {}", jur.name, where_.display());
return Ok(0);
}
let (name, source) = match &args.jurisdiction {
Some(j) => (Some(j.clone()), Source::Flag),
None => config::default_jurisdiction(),
};
let jur = jurisdictions::resolve(name.as_deref())
.map_err(|n| format!("unknown jurisdiction {n:?}; try --list"))?;
if let Some(year) = args.year {
if !(1..=9999).contains(&year) {
return Err(format!("{year} is not a year this calendar reaches"));
}
let (mv, fixed) = year_of(year, jur);
println!("\n {year} \u{b7} {}\n", jur.name);
for (label, d) in [
("publican pharisee", mv.publican_pharisee),
("meatfare", mv.meatfare),
("cheesefare", mv.cheesefare),
("clean monday", mv.clean_monday),
("great fast end", mv.great_fast_end),
("lazarus saturday", mv.lazarus_saturday),
("palm sunday", mv.palm_sunday),
("great friday", mv.great_friday),
("pascha", mv.pascha),
("ascension", mv.ascension),
("pentecost", mv.pentecost),
("all saints", mv.all_saints),
("apostles start", mv.apostles_start),
] {
println!(" {label:20} {d} {}", d.weekday().short());
}
for (label, d) in fixed {
println!(" {label:20} {d} {}", d.weekday().short());
}
println!();
return Ok(0);
}
let start = match &args.date {
Some(s) => Date::parse_iso(s)
.ok_or_else(|| format!("{s:?} is not a date \u{2014} expected YYYY-MM-DD"))?,
None => today(),
};
let days = week(start, args.days, jur);
if args.json {
println!("{}", to_json(&days, args.indent));
return Ok(0);
}
if args.ics {
print!("{}", to_ics(&days, &format!("{} \u{2014} Typikon", jur.name), &stamp()));
return Ok(0);
}
print_days(&days, jur, source, args.verbose);
// The first run should say what it assumed, and then stop saying it.
if source == Source::Default {
if let Ok(p) = config::write(jur.key) {
println!(" Reckoning by the {}, written to {}.", jur.name.to_lowercase(), p.display());
println!(" Change it with `typikon --set-default <jurisdiction>`, or see `typikon --list`.\n");
}
}
Ok(0)
}
/// Today, from the system clock, without pulling in a date library for it.
fn today() -> Date {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
// Local midnight, via the offset the system reports for now.
let local = secs + local_offset_secs(secs);
Date(2440588 + (local.div_euclid(86400)) as i32)
}
/// Seconds east of UTC, read from /etc/localtime through the `date` command
/// only if TZ arithmetic is needed. Falls back to UTC.
fn local_offset_secs(_now: i64) -> i64 {
if let Ok(out) = std::process::Command::new("date").arg("+%z").output() {
let s = String::from_utf8_lossy(&out.stdout);
let s = s.trim();
if s.len() == 5 {
if let (Ok(h), Ok(m)) = (s[1..3].parse::<i64>(), s[3..5].parse::<i64>()) {
let mag = h * 3600 + m * 60;
return if s.starts_with('-') { -mag } else { mag };
}
}
}
0
}
fn stamp() -> String {
let out = std::process::Command::new("date")
.args(["-u", "+%Y%m%dT%H%M%SZ"])
.output();
match out {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
_ => "19700101T000000Z".into(),
}
}
fn main() {
match run() {
Ok(code) => std::process::exit(code),
Err(msg) => {
eprintln!("typikon: {msg}");
std::process::exit(2);
}
}
}
+272
View File
@@ -0,0 +1,272 @@
//! Supplementary commemorations, fetched rather than shipped.
//!
//! The menologion compiled into this binary is the common Byzantine one,
//! composed for this project and licensed with the rest of it. 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 binary. `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.
//!
//! The fetch shells out to `curl`, deliberately. Linking an HTTP stack and a
//! TLS stack into a calendar would be several megabytes and a standing audit
//! surface, for one command most people run once.
use std::process::Command;
pub struct Source {
pub key: &'static str,
pub title: &'static str,
pub url: &'static str,
pub note: &'static str,
}
pub const SOURCES: &[Source] = &[Source {
key: "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 program.",
}];
const MONTHS: [&str; 12] = [
"January", "February", "March", "April", "May", "June", "July", "August",
"September", "October", "November", "December",
];
pub fn find(name: &str) -> Option<&'static Source> {
SOURCES.iter().find(|s| s.key == name)
}
fn fetch(url: &str) -> Result<String, String> {
let out = Command::new("curl")
.args(["-sSL", "--max-time", "30", "-A", "typikon", url])
.output()
.map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => {
"curl is not on PATH, and the fetch needs it".to_string()
}
_ => e.to_string(),
})?;
if !out.status.success() {
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
fn strip_html(html: &str) -> Vec<String> {
let mut text = String::with_capacity(html.len());
let mut in_tag = false;
let mut tag = String::new();
for c in html.chars() {
match c {
'<' => {
in_tag = true;
tag.clear();
}
'>' => {
in_tag = false;
let t = tag.trim_start_matches('/').to_ascii_lowercase();
let t = t.split_whitespace().next().unwrap_or("");
if matches!(t, "br" | "p" | "div" | "li" | "h1" | "h2" | "h3" | "h4") {
text.push('\n');
}
text.push('\n');
}
_ if in_tag => tag.push(c),
_ => text.push(c),
}
}
for (from, to) in [
("&nbsp;", " "), ("&amp;", "&"), ("&#8217;", "'"),
("&rsquo;", "'"), ("&mdash;", "\u{2014}"), ("&quot;", "\""),
] {
text = text.replace(from, to);
}
text.lines()
.map(|l| l.split_whitespace().collect::<Vec<_>>().join(" "))
.filter(|l| !l.is_empty())
.collect()
}
fn month_index(word: &str) -> Option<u32> {
MONTHS.iter().position(|m| *m == word).map(|i| i as u32 + 1)
}
/// Split a line into (month, day, rest) when it opens a dated entry.
fn dated(line: &str) -> Option<(u32, u32, String)> {
let mut it = line.splitn(3, ' ');
let month = month_index(it.next()?)?;
let day: u32 = it.next()?.trim_end_matches(['.', ',']).parse().ok()?;
Some((month, day, it.next().unwrap_or("").trim().to_string()))
}
fn is_heading(line: &str) -> bool {
month_index(line.trim_end_matches('.')).is_some()
}
pub struct Fetched {
pub path: std::path::PathBuf,
pub days: usize,
pub corrections: Vec<String>,
pub missing: Vec<String>,
}
/// 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 and takes 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.
pub fn update(name: &str) -> Result<Fetched, String> {
let source = find(name).ok_or_else(|| {
let known: Vec<_> = SOURCES.iter().map(|s| s.key).collect();
format!("unknown menaion: {name:?}; try one of {}", known.join(", "))
})?;
let lines = strip_html(&fetch(source.url)?);
// MCI writes the festal cycle inline, as a commemoration among the saints.
// It repeats for days at a time and is not a saint, so it becomes the day's
// cycle and leaves the list alone.
let festal = [
("pre-festive", "forefeast"), ("fore-feast", "forefeast"),
("post-festive", "afterfeast"), ("after-feast", "afterfeast"),
("leave-taking", "leave-taking"),
];
let mut out: std::collections::BTreeMap<String, serde_json::Value> = Default::default();
let mut corrections = Vec::new();
let mut cur: Option<(u32, u32)> = None;
let mut buf: Vec<String> = Vec::new();
let mut prev: Option<(u32, u32)> = None;
let flush = |cur: &Option<(u32, u32)>, buf: &mut Vec<String>,
out: &mut std::collections::BTreeMap<String, serde_json::Value>| {
let Some((m, d)) = *cur else { buf.clear(); return };
let mut items: Vec<String> = Vec::new();
let mut cycle: Option<String> = None;
for piece in buf.iter() {
for part in split_sentences(piece) {
let part = part.trim_matches([' ', '.', ';']).to_string();
if part.len() <= 3 || dated(&part).is_some() || is_heading(&part) {
continue;
}
let lower = part.to_ascii_lowercase();
if let Some((_, word)) = festal.iter().find(|(p, _)| lower.starts_with(p)) {
if cycle.is_none() {
let rest = part
.split_once(' ')
.map(|(_, tail)| tail)
.unwrap_or("")
.trim_start_matches("Day ")
.trim_start_matches("of ")
.trim_matches([' ', '.', ','])
.to_string();
cycle = Some(if rest.is_empty() {
word.to_string()
} else {
format!("{word} of {rest}")
});
}
continue;
}
items.push(part);
}
}
buf.clear();
if items.is_empty() && cycle.is_none() {
return;
}
let mut entry = serde_json::Map::new();
if !items.is_empty() {
entry.insert("commemorations".into(), serde_json::json!(items));
}
if let Some(c) = cycle {
entry.insert("cycle".into(), serde_json::json!(c));
}
out.insert(format!("{m:02}-{d:02}"), serde_json::Value::Object(entry));
};
for line in &lines {
if is_heading(line) {
continue;
}
if let Some((month, mut day, rest)) = dated(line) {
flush(&cur, &mut buf, &mut out);
if let Some((pm, pd)) = prev {
if pm == month && day <= pd {
corrections.push(format!("{month:02}-{day:02} -> {month:02}-{:02}", pd + 1));
day = pd + 1;
}
}
prev = Some((month, day));
cur = Some((month, day));
if !rest.is_empty() {
buf.push(rest);
}
} else if cur.is_some() {
buf.push(line.clone());
}
}
flush(&cur, &mut buf, &mut out);
let mut missing = Vec::new();
for m in 1..=12u32 {
for d in 1..=crate::calendars::days_in_month(2024, m) {
let key = format!("{m:02}-{d:02}");
if !out.contains_key(&key) {
missing.push(key);
}
}
}
// This is a complete calendar published by that church, so it stands in for
// the common commemorations rather than piling on top of them.
let mut payload = serde_json::Map::new();
payload.insert(
"_meta".into(),
serde_json::json!({"mode": "replace", "source": source.url, "title": source.title}),
);
for (k, v) in &out {
payload.insert(k.clone(), v.clone());
}
let dir = crate::menologion::data_dir();
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let path = dir.join(format!("{name}.json"));
let text = serde_json::to_string_pretty(&serde_json::Value::Object(payload))
.map_err(|e| e.to_string())?;
std::fs::write(&path, text).map_err(|e| format!("{}: {e}", path.display()))?;
Ok(Fetched { path, days: out.len(), corrections, missing })
}
/// Split on sentence ends that begin a new capitalised clause, the way the MCI
/// page separates commemorations inside one paragraph.
fn split_sentences(text: &str) -> Vec<String> {
let chars: Vec<char> = text.chars().collect();
let mut parts = Vec::new();
let mut start = 0usize;
let mut i = 0usize;
while i + 2 < chars.len() {
if chars[i] == '.' && chars[i + 1] == ' ' {
let mut j = i + 1;
while j < chars.len() && chars[j] == ' ' {
j += 1;
}
if j < chars.len() && (chars[j].is_ascii_uppercase() || chars[j] == '(') {
parts.push(chars[start..=i].iter().collect());
start = j;
i = j;
continue;
}
}
i += 1;
}
parts.push(chars[start..].iter().collect());
parts
}
+215
View File
@@ -0,0 +1,215 @@
//! The fixed calendar, and the movable feasts that outrank it.
//!
//! The menologion ships once, compiled into the binary, and serves every
//! jurisdiction because it is indexed by **ecclesiastical** date. A church on
//! the old calendar and one 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, are fetched rather than
//! shipped, because they are usually published by a body that holds the
//! copyright in the wording. They overlay the common set at runtime.
use std::collections::BTreeMap;
use serde::Deserialize;
use crate::calendars::Date;
use crate::paschalion::Movables;
/// The degrees of solemnity the menologion marks, and the order they rank in.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Rank {
Polyeleos,
Vigil,
Great,
}
impl Rank {
pub fn key(self) -> &'static str {
match self {
Rank::Polyeleos => "polyeleos",
Rank::Vigil => "vigil",
Rank::Great => "great",
}
}
/// Whether a feast of this rank names the day, as against merely being
/// commemorated on it.
pub fn names_the_day(self) -> bool {
matches!(self, Rank::Great | Rank::Vigil)
}
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct Entry {
#[serde(default)]
pub commemorations: Vec<String>,
#[serde(default)]
pub rank: Option<Rank>,
/// Forefeast, afterfeast or leave-taking.
#[serde(default)]
pub cycle: Option<String>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OverlayMode {
/// The set's commemorations join the common ones. Right for a local set
/// that adds a few saints of its own.
#[default]
Add,
/// The set's commemorations stand in for them, because it is itself a
/// complete calendar published by that church.
Replace,
}
#[derive(Clone, Debug, Default, Deserialize)]
struct Meta {
#[serde(default)]
mode: OverlayMode,
}
#[derive(Clone, Debug, Default)]
pub struct Menologion {
entries: BTreeMap<String, Entry>,
mode: OverlayMode,
}
/// The common Byzantine menologion, compiled in. No file to find, no path to
/// get wrong, and the binary answers for every day of the year on its own.
const COMMON: &str = include_str!("../data/menologion.json");
fn parse(text: &str) -> Result<Menologion, String> {
let raw: BTreeMap<String, serde_json::Value> =
serde_json::from_str(text).map_err(|e| e.to_string())?;
let mut entries = BTreeMap::new();
let mut mode = OverlayMode::Add;
for (key, value) in raw {
if key == "_meta" {
let meta: Meta = serde_json::from_value(value).unwrap_or_default();
mode = meta.mode;
continue;
}
let entry: Entry = serde_json::from_value(value).map_err(|e| format!("{key}: {e}"))?;
entries.insert(key, entry);
}
Ok(Menologion { entries, mode })
}
impl Menologion {
pub fn common() -> &'static Menologion {
use std::sync::OnceLock;
static ONCE: OnceLock<Menologion> = OnceLock::new();
ONCE.get_or_init(|| parse(COMMON).expect("the shipped menologion parses"))
}
pub fn get(&self, month: u32, day: u32) -> Option<&Entry> {
self.entries.get(&format!("{month:02}-{day:02}"))
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// Lay another set over this one. Rank and cycle survive from the common
/// set unless the overlay gives its own, since a published list of saints
/// usually carries neither and the fasting rules need the rank.
pub fn overlay(&self, other: &Menologion) -> Menologion {
let mut merged = self.entries.clone();
for (key, add) in &other.entries {
let base = merged.entry(key.clone()).or_default();
match other.mode {
OverlayMode::Replace => {
if !add.commemorations.is_empty() {
base.commemorations = add.commemorations.clone();
}
}
OverlayMode::Add => {
for c in &add.commemorations {
if !base.commemorations.contains(c) {
base.commemorations.push(c.clone());
}
}
}
}
if add.rank.is_some() {
base.rank = add.rank;
}
if add.cycle.is_some() {
base.cycle = add.cycle.clone();
}
}
Menologion { entries: merged, mode: self.mode }
}
pub fn load_file(path: &std::path::Path) -> Result<Menologion, String> {
let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
parse(&text)
}
}
/// Where fetched supplementary sets live.
pub fn data_dir() -> std::path::PathBuf {
let base = std::env::var_os("XDG_DATA_HOME")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
let home = std::env::var_os("HOME").unwrap_or_default();
std::path::Path::new(&home).join(".local/share")
});
base.join("typikon")
}
/// The common menologion with a jurisdiction's supplementary sets overlaid.
/// A missing set is not an error: it simply has not been fetched.
pub fn for_jurisdiction(menaia: &[&str]) -> Menologion {
let mut m = Menologion::common().clone();
for name in menaia {
let path = data_dir().join(format!("{name}.json"));
if let Ok(extra) = Menologion::load_file(&path) {
m = m.overlay(&extra);
}
}
m
}
/// The movable feasts, named by their offset from Pascha. These outrank
/// whatever the fixed calendar holds for the day.
pub fn movable_feast_for(civil: Date, mv: &Movables) -> Option<(&'static str, Option<Rank>)> {
let table: [(Date, &'static str, Option<Rank>); 24] = [
(mv.pascha, "The Radiant and Life-giving Resurrection of our Lord \u{2014} Holy Pascha", Some(Rank::Great)),
(mv.palm_sunday, "The Entry of our Lord into Jerusalem", Some(Rank::Great)),
(mv.ascension, "The Ascension of our Lord, God and Saviour Jesus Christ", Some(Rank::Great)),
(mv.pentecost, "Holy Pentecost \u{2014} the Descent of the Holy Spirit", Some(Rank::Great)),
(mv.lazarus_saturday, "Lazarus Saturday", Some(Rank::Vigil)),
(mv.great_thursday, "Great and Holy Thursday", Some(Rank::Vigil)),
(mv.great_friday, "Great and Holy Friday", Some(Rank::Vigil)),
(mv.holy_saturday, "Great and Holy Saturday", Some(Rank::Vigil)),
(mv.thomas_sunday, "Thomas Sunday \u{2014} the Antipascha", Some(Rank::Polyeleos)),
(mv.myrrhbearers, "The Sunday of the Holy Myrrh-bearing Women", Some(Rank::Polyeleos)),
(mv.midfeast, "The Midfeast of Pentecost", Some(Rank::Polyeleos)),
(mv.all_saints, "The Sunday of All Saints", Some(Rank::Polyeleos)),
(mv.publican_pharisee, "The Sunday of the Publican and the Pharisee", None),
(mv.prodigal_son, "The Sunday of the Prodigal Son", None),
(mv.meatfare, "Meatfare Sunday \u{2014} the Sunday of the Last Judgement", None),
(mv.cheesefare, "Cheesefare Sunday \u{2014} the Expulsion of Adam, Forgiveness Sunday", None),
(mv.orthodoxy_sunday, "The Sunday of Orthodoxy", Some(Rank::Polyeleos)),
(mv.gregory_palamas, "The Sunday of St Gregory Palamas", None),
(mv.cross_veneration, "The Sunday of the Veneration of the Cross", Some(Rank::Polyeleos)),
(mv.john_climacus, "The Sunday of St John Climacus", None),
(mv.mary_of_egypt, "The Sunday of St Mary of Egypt", None),
(mv.fathers_first_council, "The Sunday of the Fathers of the First Council", None),
(mv.clean_monday, "Clean Monday \u{2014} the first day of the Great Fast", None),
// A sentinel that can never match, keeping the array a fixed size.
(Date(i32::MIN), "", None),
];
table
.iter()
.find(|(d, _, _)| *d == civil)
.map(|(_, title, rank)| (*title, *rank))
}
+142
View File
@@ -0,0 +1,142 @@
//! 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 Catholics.
//!
//! 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.
use crate::calendars::{Date, JulianDate};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Paschalion {
Julian,
Gregorian,
}
/// Pascha by the Julian Paschalion, as a civil (Gregorian) date.
///
/// Meeus' Julian algorithm yields a date in the *Julian* calendar, which is
/// then read onto the civil one. The type system enforces that step here: the
/// algorithm builds a `JulianDate`, and only `to_civil` produces a `Date`.
pub fn julian_pascha(year: i32) -> Date {
let a = year % 4;
let b = year % 7;
let c = year % 19;
let d = (19 * c + 15) % 30;
let e = (2 * a + 4 * b - d + 34) % 7;
let month = ((d + e + 114) / 31) as u32;
let day = (((d + e + 114) % 31) + 1) as u32;
JulianDate { year, month, day }.to_civil()
}
/// Pascha by the Gregorian Paschalion, the Western computus.
pub fn gregorian_pascha(year: i32) -> Date {
let a = year % 19;
let b = year / 100;
let c = year % 100;
let d = b / 4;
let e = b % 4;
let g = (8 * b + 13) / 25;
let h = (19 * a + b - d - g + 15) % 30;
let i = c / 4;
let k = c % 4;
let l = (32 + 2 * e + 2 * i - h - k) % 7;
let m = (a + 11 * h + 19 * l) / 433;
let month = ((h + l - 7 * m + 90) / 25) as u32;
let day = ((h + l - 7 * m + 33 * month as i32 + 19) % 32) as u32;
Date::from_ymd(year, month, day).expect("computus yields a real date")
}
pub fn pascha(year: i32, p: Paschalion) -> Date {
match p {
Paschalion::Julian => julian_pascha(year),
Paschalion::Gregorian => gregorian_pascha(year),
}
}
/// 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.
///
/// A struct rather than a map, so a typo in a key is a compile error instead
/// of a silent `None` at the far end of the program.
#[derive(Clone, Copy, Debug)]
pub struct Movables {
pub pascha: Date,
// the Triodion, counted backwards
pub publican_pharisee: Date,
pub prodigal_son: Date,
pub meatfare: Date,
pub cheesefare: Date,
pub cheesefare_week_start: Date,
pub clean_monday: Date,
pub orthodoxy_sunday: Date,
pub gregory_palamas: Date,
pub cross_veneration: Date,
pub john_climacus: Date,
pub mary_of_egypt: Date,
/// The Forty Days end here, on the Friday before Lazarus Saturday.
pub great_fast_end: Date,
pub lazarus_saturday: Date,
pub palm_sunday: Date,
pub great_week_start: Date,
pub great_thursday: Date,
pub great_friday: Date,
pub holy_saturday: Date,
// the Pentecostarion, counted forwards
pub bright_week_end: Date,
pub thomas_sunday: Date,
pub myrrhbearers: Date,
pub midfeast: Date,
pub ascension: Date,
pub fathers_first_council: Date,
pub pentecost: Date,
pub pentecost_week_end: Date,
pub all_saints: Date,
/// The Apostles' Fast begins the Monday after All Saints. Its end is a
/// fixed ecclesiastical date, so the jurisdiction resolves that.
pub apostles_start: Date,
}
pub fn movable_cycle(year: i32, p: Paschalion) -> Movables {
let d = pascha(year, p);
Movables {
pascha: d,
publican_pharisee: d - 70,
prodigal_son: d - 63,
meatfare: d - 56,
cheesefare_week_start: d - 55,
cheesefare: d - 49,
clean_monday: d - 48,
orthodoxy_sunday: d - 42,
gregory_palamas: d - 35,
cross_veneration: d - 28,
john_climacus: d - 21,
mary_of_egypt: d - 14,
great_fast_end: d - 9,
lazarus_saturday: d - 8,
palm_sunday: d - 7,
great_week_start: d - 6,
great_thursday: d - 3,
great_friday: d - 2,
holy_saturday: d - 1,
bright_week_end: d + 6,
thomas_sunday: d + 7,
myrrhbearers: d + 14,
midfeast: d + 24,
ascension: d + 39,
fathers_first_council: d + 42,
pentecost: d + 49,
pentecost_week_end: d + 55,
all_saints: d + 56,
apostles_start: d + 57,
}
}
+64
View File
@@ -0,0 +1,64 @@
//! Fasting disciplines. One module per tradition.
pub mod orthodox;
pub mod ruthenian;
use crate::calendars::{Date, Weekday};
use crate::fasting::{Fast, Level};
use crate::jurisdictions::{Jurisdiction, RuleKind};
use crate::menologion::Rank;
use crate::paschalion::Movables;
use crate::seasons::Season;
/// What a fasting rule is handed. Deliberately small.
pub struct Context<'a> {
pub civil: Date,
/// The ecclesiastical (month, day).
pub fixed: (u32, u32),
/// The ecclesiastical year, for resolving a fixed date back to a civil one.
pub fixed_year: i32,
pub weekday: Weekday,
pub mv: &'a Movables,
pub season: Option<Season>,
pub feast: String,
pub rank: Option<Rank>,
pub jurisdiction: &'a Jurisdiction,
}
impl Context<'_> {
/// The civil date of an ecclesiastical date in this day's church year.
pub fn civil_of_fixed(&self, month: u32, day: u32) -> Date {
self.jurisdiction.civil_of_fixed(self.fixed_year, month, day)
}
pub fn is_weekend(&self) -> bool {
self.weekday.is_weekend()
}
/// Is this day within `days` of the given start, inclusive?
pub fn within(&self, start: Date, days: i32) -> bool {
start <= self.civil && self.civil <= start + days
}
}
/// How a tradition names the levels it uses, and the glyph it shows them with.
pub struct Vocabulary {
pub label: fn(Level) -> &'static str,
pub emoji: fn(Level) -> &'static str,
}
pub struct Rule {
pub name: &'static str,
pub title: &'static str,
pub vocabulary: Vocabulary,
pub fast_for: fn(&Context) -> Fast,
/// Whether this tradition legislates the fast as precept at all.
pub has_obligation: bool,
}
pub fn get(kind: RuleKind) -> &'static Rule {
match kind {
RuleKind::Orthodox => &orthodox::RULE,
RuleKind::Ruthenian => &ruthenian::RULE,
}
}
+250
View File
@@ -0,0 +1,250 @@
//! 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.
//!
//! A saint of vigil or polyeleos rank lifts the weekday discipline of a fast:
//! to wine and oil inside the Great Fast, as far as fish in the three lesser
//! fasts. That is why the printed calendars mark so many Wednesdays in the
//! Nativity Fast for wine and oil, and it is the rank in the menologion that
//! decides it.
//!
//! 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.
//! - **Great Week** is kept at xerophagy on its weekdays whatever saint falls
//! in it, because the days of the Passion outrank the menologion.
//! - **Monastic practice**, and the Holy Mountain in particular, is stricter
//! than any of this throughout.
//!
//! A calendar cannot settle those. Your bishop and your spiritual father can.
use super::{Context, Rule, Vocabulary};
use crate::calendars::Weekday;
use crate::fasting::{Fast, Level};
use crate::menologion::Rank;
use crate::seasons::Season;
fn label(level: Level) -> &'static str {
match level {
Level::Strict => "no food until evening",
Level::Xerophagy => "xerophagy \u{2014} no oil or wine",
Level::WineOil => "wine and oil permitted",
Level::Fish => "fish, wine and oil",
Level::Dairy => "dairy and eggs; no meat",
Level::None => "no fast",
}
}
fn emoji(level: Level) -> &'static str {
match level {
Level::Strict => "\u{26d4}",
Level::Xerophagy => "\u{1f96c}",
Level::WineOil => "\u{1fad2}",
Level::Fish => "\u{1f41f}",
Level::Dairy => "\u{1f9c0}",
Level::None => "\u{1f356}",
}
}
/// How far a feast lifts a fast day.
fn relaxed_by(rank: Option<Rank>) -> Option<Level> {
match rank {
Some(Rank::Great) => Some(Level::Fish),
Some(Rank::Vigil) | Some(Rank::Polyeleos) => Some(Level::WineOil),
None => None,
}
}
/// Relax `level` for the day's saint, never past `cap`, never tightening.
fn for_feast(level: Level, ctx: &Context, cap: Level, reason: &str) -> Fast {
let Some(target) = relaxed_by(ctx.rank) else {
return Fast::customary(level, reason);
};
let lifted = level.relaxed_to(target, cap);
if lifted == level {
return Fast::customary(level, reason);
}
Fast::customary(lifted, &format!("{} \u{2014} the fast is relaxed", ctx.feast))
}
pub fn fast_for(ctx: &Context) -> Fast {
let md = ctx.fixed;
let mv = ctx.mv;
let weekend = ctx.is_weekend();
// 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.
match ctx.season {
Some(Season::BrightWeek) => return Fast::free("Bright Week \u{2014} no fasting"),
Some(Season::PentecostWeek) => {
return Fast::free("the week after Pentecost \u{2014} no fasting")
}
Some(Season::Christmastide) => {
return Fast::free("the Nativity to the eve of Theophany")
}
_ => {}
}
if ctx.within(mv.publican_pharisee, 6) {
// Fast-free precisely because the Pharisee boasted of fasting twice a week.
return Fast::free("fast-free week (the Publican and the Pharisee)");
}
// The Great Fast and Great Week.
if matches!(ctx.season, Some(Season::GreatFast) | Some(Season::GreatWeek)) {
let season = ctx.season.unwrap();
// The Annunciation is tested before the strict days, not after. It can
// land on Great Friday or Great Saturday, and when it does the feast
// still lifts the total fast to wine and oil: it does not vanish.
if md == (3, 25) {
if ctx.civil == mv.great_friday || ctx.civil == mv.holy_saturday {
return Fast::customary(
Level::WineOil,
"the Annunciation, on a day of the total fast",
);
}
return Fast::customary(Level::Fish, "the Annunciation");
}
if ctx.civil == mv.palm_sunday {
return Fast::customary(Level::Fish, "Palm Sunday");
}
if ctx.civil == mv.lazarus_saturday {
return Fast::customary(Level::WineOil, "Lazarus Saturday");
}
if ctx.civil == mv.clean_monday {
return Fast::customary(Level::Strict, "the first day of the Great Fast");
}
if ctx.civil == mv.great_friday {
return Fast::customary(Level::Strict, "Great and Holy Friday");
}
if ctx.civil == mv.holy_saturday {
// The one Saturday of the year kept without oil.
return Fast::customary(Level::Xerophagy, "Great and Holy Saturday");
}
if ctx.civil == mv.great_thursday {
return Fast::customary(Level::WineOil, "Great and Holy Thursday");
}
if season == Season::GreatWeek {
return Fast::customary(
Level::Xerophagy,
&format!("a weekday of the {}", season.name()),
);
}
if weekend {
return Fast::customary(
Level::WineOil,
&format!("Saturday and Sunday of the {}", season.name()),
);
}
return for_feast(
Level::Xerophagy,
ctx,
Level::WineOil,
&format!("a weekday of the {}", season.name()),
);
}
// Cheesefare: meat is set aside for good, everything else stays,
// Wednesday and Friday included.
if ctx.season == Some(Season::CheesefareWeek) {
return Fast::customary(Level::Dairy, "Cheesefare Week");
}
if ctx.civil == mv.meatfare {
return Fast::free("Meatfare Sunday \u{2014} the last day of meat");
}
// The Nativity Fast, which tightens for its last five days.
if ctx.season == Some(Season::NativityFast) {
if md == (12, 24) {
// The eve is relaxed to wine and oil on a Saturday or Sunday,
// because the Liturgy of St Basil is served with Vespers.
if weekend {
return Fast::customary(Level::WineOil, "the eve of the Nativity, on a weekend");
}
return Fast::customary(Level::Xerophagy, "the eve of the Nativity");
}
if md == (11, 21) {
return Fast::customary(Level::Fish, "the Entrance of the Theotokos");
}
if md >= (12, 20) {
if matches!(ctx.weekday, Weekday::Tuesday | Weekday::Thursday) || weekend {
return Fast::customary(Level::WineOil, "the forefeast of the Nativity");
}
return for_feast(Level::Xerophagy, ctx, Level::WineOil, "the forefeast of the Nativity");
}
if weekend {
return Fast::customary(Level::Fish, "Saturday and Sunday of the Nativity Fast");
}
if matches!(ctx.weekday, Weekday::Tuesday | Weekday::Thursday) {
return for_feast(Level::WineOil, ctx, Level::Fish, "Tuesday and Thursday of the Nativity Fast");
}
return for_feast(Level::Xerophagy, ctx, Level::Fish, "Monday, Wednesday and Friday of the Nativity Fast");
}
// The Apostles' Fast, kept the same way.
if ctx.season == Some(Season::Apostles) {
if weekend {
return Fast::customary(Level::Fish, "Saturday and Sunday of the Apostles' Fast");
}
if matches!(ctx.weekday, Weekday::Tuesday | Weekday::Thursday) {
return for_feast(Level::WineOil, ctx, Level::Fish, "Tuesday and Thursday of the Apostles' Fast");
}
return for_feast(Level::Xerophagy, ctx, Level::Fish, "Monday, Wednesday and Friday of the Apostles' Fast");
}
// The Dormition Fast, kept like the Great Fast.
if ctx.season == Some(Season::Dormition) {
if md == (8, 6) {
return Fast::customary(Level::Fish, "the Transfiguration");
}
if weekend {
return Fast::customary(Level::WineOil, "Saturday and Sunday of the Dormition Fast");
}
return for_feast(Level::Xerophagy, ctx, Level::WineOil, "a weekday of the Dormition Fast");
}
// The three fixed fast days outside any season.
if md == (1, 5) {
if weekend {
return Fast::customary(Level::WineOil, "the eve of Theophany, on a weekend");
}
return Fast::customary(Level::Xerophagy, "the eve of Theophany");
}
if md == (9, 14) {
return Fast::customary(Level::WineOil, "the Exaltation of the Holy Cross");
}
if md == (8, 29) {
return Fast::customary(Level::WineOil, "the Beheading of the Forerunner");
}
// Ordinary Wednesdays and Fridays, which a feast can lift.
if matches!(ctx.weekday, Weekday::Wednesday | Weekday::Friday) {
if let Some(target) = relaxed_by(ctx.rank) {
return Fast::customary(target, &format!("{} \u{2014} the fast is relaxed", ctx.feast));
}
return Fast::customary(Level::Xerophagy, "Wednesday and Friday");
}
Fast::new(Level::None, "", None)
}
pub static RULE: Rule = Rule {
name: "orthodox",
title: "the Typikon",
vocabulary: Vocabulary { label, emoji },
fast_for,
// The Orthodox Church does not legislate the fast as precept, so nothing
// here is reported as binding. See the module docs.
has_obligation: false,
};
+161
View File
@@ -0,0 +1,161 @@
//! 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 `WineOil`, since it does not regulate oil or wine, and
//! its simple abstinence is `Dairy`, meat alone being set aside.
//!
//! **A known reading.** "The Wednesdays and Fridays of the Great Fast" is taken
//! here to mean the Forty Days, not Great and Holy Week, which the calendar
//! counts as its own season. Great and Holy Friday binds in its own right, so
//! only Great and Holy Wednesday turns on the reading. If your eparchy reads it
//! the other way, that Wednesday binds too.
//!
//! Source: the Byzantine Catholic Metropolitan Church of Pittsburgh. 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.
use super::{Context, Rule, Vocabulary};
use crate::calendars::{Date, Weekday};
use crate::fasting::{Fast, Level};
use crate::menologion::Rank;
use crate::seasons::Season;
fn label(level: Level) -> &'static str {
match level {
Level::WineOil => "no meat, fish, eggs or dairy",
Level::Dairy => "no meat",
Level::None => "no fast",
// Levels this law makes no distinction at, kept legible if ever emitted.
Level::Strict => "no food until evening",
Level::Xerophagy => "no meat, fish, eggs or dairy",
Level::Fish => "no meat",
}
}
fn emoji(level: Level) -> &'static str {
match level {
Level::Strict | Level::Xerophagy | Level::WineOil => "\u{1f96c}",
Level::Fish | Level::Dairy => "\u{1f41f}",
Level::None => "\u{1f356}",
}
}
/// 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."
fn off_weekend(day: Date) -> Date {
match day.weekday() {
Weekday::Saturday => day - 1,
Weekday::Sunday => day - 2,
_ => day,
}
}
pub fn fast_for(ctx: &Context) -> Fast {
let mv = ctx.mv;
// The two days the Church binds strictly.
if ctx.civil == mv.clean_monday {
return Fast::binding(Level::WineOil, "the first day of the Great Fast", Level::WineOil);
}
if ctx.civil == mv.great_friday {
return Fast::binding(Level::WineOil, "Great and Holy Friday", Level::WineOil);
}
// And the days it binds to abstinence from meat. Kept strictly by
// tradition; binding only as far as meat.
if ctx.season == Some(Season::GreatFast)
&& matches!(ctx.weekday, Weekday::Wednesday | Weekday::Friday)
{
return Fast::binding(
Level::WineOil,
"Wednesday and Friday of the Great Fast",
Level::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::customary(Level::WineOil, label);
}
}
// 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.
match ctx.season {
Some(Season::BrightWeek) => return Fast::free("Bright Week \u{2014} no fasting"),
Some(Season::PentecostWeek) => {
return Fast::free("the week after Pentecost \u{2014} no fasting")
}
Some(Season::Christmastide) => return Fast::free("Christmastide \u{2014} no fasting"),
_ => {}
}
if ctx.within(mv.publican_pharisee, 6) {
return Fast::free("fast-free week (the Publican and the Pharisee)");
}
// The rest of the Great Fast and Great Week.
if let Some(season @ (Season::GreatFast | Season::GreatWeek)) = ctx.season {
if ctx.is_weekend() {
return Fast::customary(Level::Dairy, season.name());
}
return Fast::customary(Level::WineOil, season.name());
}
if ctx.season == Some(Season::CheesefareWeek) {
return Fast::customary(Level::Dairy, "Cheesefare Week");
}
// The three voluntary seasons.
if let Some(season @ (Season::Apostles | Season::Dormition | Season::NativityFast)) =
ctx.season
{
return Fast::customary(Level::Dairy, season.name());
}
// A great feast releases the ordinary weekday abstinence.
if matches!(ctx.weekday, Weekday::Wednesday | Weekday::Friday) {
if ctx.rank == Some(Rank::Great) {
return Fast::free(&format!("{} \u{2014} the fast is released", ctx.feast));
}
return Fast::customary(
Level::Dairy,
if ctx.weekday == Weekday::Friday { "Friday" } else { "Wednesday" },
);
}
Fast::new(Level::None, "", None)
}
pub static RULE: Rule = Rule {
name: "ruthenian",
title: "Ruthenian particular law",
vocabulary: Vocabulary { label, emoji },
fast_for,
has_obligation: true,
};
+112
View File
@@ -0,0 +1,112 @@
//! 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 and 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.
//!
//! The Great Fast here runs from Clean Monday through Palm Sunday. The Forty
//! Days proper end on the Friday before Lazarus Saturday, which is what the
//! fasting rules count from, but a season is a label for a reader, and a Palm
//! Sunday belonging to no season at all is worse than one belonging loosely to
//! the Fast.
use crate::calendars::{fixed_between, Date};
use crate::paschalion::Movables;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Season {
GreatFast,
GreatWeek,
BrightWeek,
PreLenten,
CheesefareWeek,
Apostles,
Dormition,
NativityFast,
PentecostWeek,
Christmastide,
}
impl Season {
pub fn name(self) -> &'static str {
match self {
Season::GreatFast => "Great Fast",
Season::GreatWeek => "Great and Holy Week",
Season::BrightWeek => "Bright Week",
Season::PreLenten => "Triodion",
Season::CheesefareWeek => "Cheesefare Week",
Season::Apostles => "Apostles' Fast",
Season::Dormition => "Dormition Fast",
Season::NativityFast => "Nativity Fast",
Season::PentecostWeek => "Week after Pentecost",
Season::Christmastide => "Christmastide",
}
}
/// The seasons in which a fast is kept, as distinct from the ones that are
/// merely named.
pub fn is_fasting_season(self) -> bool {
matches!(
self,
Season::GreatFast
| Season::GreatWeek
| Season::CheesefareWeek
| Season::Apostles
| Season::Dormition
| Season::NativityFast
)
}
}
/// 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.
pub fn season_for(
civil: Date,
fixed_md: (u32, u32),
mv: &Movables,
apostles_end: Date,
) -> Option<Season> {
// Movable first: Great Week outranks the Great Fast it follows, and Bright
// Week outranks everything.
if mv.pascha <= civil && civil <= mv.bright_week_end {
return Some(Season::BrightWeek);
}
if mv.great_week_start <= civil && civil <= mv.holy_saturday {
return Some(Season::GreatWeek);
}
if mv.clean_monday <= civil && civil <= mv.palm_sunday {
return Some(Season::GreatFast);
}
if mv.cheesefare_week_start <= civil && civil <= mv.cheesefare {
return Some(Season::CheesefareWeek);
}
if mv.publican_pharisee <= civil && civil < mv.cheesefare_week_start {
return Some(Season::PreLenten);
}
if mv.pentecost <= civil && civil <= mv.pentecost_week_end {
return Some(Season::PentecostWeek);
}
// The Apostles' Fast can be empty in a year with a late Pascha, which this
// comparison handles without a special case.
if mv.apostles_start <= civil && civil <= apostles_end {
return Some(Season::Apostles);
}
// Fixed seasons, in ecclesiastical space.
if fixed_between(fixed_md, (8, 1), (8, 14)) {
return Some(Season::Dormition);
}
if fixed_between(fixed_md, (11, 15), (12, 24)) {
return Some(Season::NativityFast);
}
if fixed_between(fixed_md, (12, 25), (1, 4)) {
return Some(Season::Christmastide);
}
None
}
-368
View File
@@ -1,368 +0,0 @@
"""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, year_of
from typikon.export import to_ics, to_json
from typikon.fasting import (DAIRY, FISH, NONE, OBLIGATORY, STRICT, WINE_OIL,
XEROPHAGY, stricter)
# ───────────────────────────── 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"
# ─────────────────── regressions, each from a real defect ──────────────────
def test_the_annunciation_is_never_lost_to_great_friday():
"""It can fall on Great Friday or Great Saturday, and when it does the
feast lifts the total fast rather than disappearing into it."""
for when, jurisdiction in ((dt.date(2016, 3, 25), "western"),
(dt.date(2005, 3, 25), "western"),
(dt.date(2029, 4, 7), "rocor"),
(dt.date(2018, 4, 7), "rocor")):
d = describe(when, jurisdiction)
assert d.church_date == "25 March", when
assert "Annunciation" in d.feast, when
assert d.rank == "great", when
assert d.fast == WINE_OIL, (when, d.fast)
def test_kyriopascha_and_the_annunciation_in_bright_week_stay_fast_free():
assert describe(dt.date(2035, 3, 25), "western").fast == NONE
assert describe(dt.date(2075, 4, 7), "rocor").fast == NONE
def test_palm_sunday_and_lazarus_saturday_are_still_the_fast():
"""Both fall between the Forty Days and Great Week. Leaving that gap
outside every season made the Byzantine Catholic rule report no fast at
all on Palm Sunday."""
for jurisdiction in ("ruthenian", "ugcc", "melkite", "goarch", "rocor"):
for year in (2025, 2026, 2027):
mv = year_of(year, jurisdiction)
for when in (mv["lazarus_saturday"], mv["palm_sunday"]):
d = describe(when, jurisdiction)
assert d.season is not None, (jurisdiction, when)
assert d.fast != NONE, (jurisdiction, when, d.fast)
assert d.reason, (jurisdiction, when)
def test_a_ranked_saint_relaxes_a_weekday_of_a_fasting_season():
"""The rank is why the printed calendars mark so many Wednesdays in the
Nativity Fast for wine and oil."""
# The Nativity of the Forerunner, a great feast inside the Apostles' Fast.
assert describe(dt.date(2020, 6, 24), "goarch").fast == FISH
# The Forty Martyrs of Sebaste, the textbook relaxation of a Lenten weekday.
assert describe(dt.date(2022, 3, 9), "goarch").fast == WINE_OIL
# St Nicholas, a vigil saint on a Wednesday of the Nativity Fast.
assert describe(dt.date(2023, 12, 6), "goarch").fast == WINE_OIL
def test_no_saint_brings_fish_into_the_great_fast_or_lifts_great_week():
for d in week(dt.date(2022, 3, 7), 40, "goarch"):
if d.season == "Great Fast" and d.church_date != "25 March":
assert not stricter(FISH, d.fast) or d.fast != FISH, d.date
if d.season == "Great and Holy Week" and d.church_date != "25 March":
assert d.fast in (STRICT, XEROPHAGY, WINE_OIL), (d.date, d.fast)
def test_the_eves_relax_when_they_fall_on_a_weekend():
"""The Liturgy of St Basil is served with Vespers, so the eve is kept at
wine and oil rather than xerophagy."""
assert describe(dt.date(2022, 12, 24), "goarch").fast == WINE_OIL
assert describe(dt.date(2023, 12, 24), "goarch").fast == WINE_OIL
assert describe(dt.date(2025, 1, 5), "goarch").fast == WINE_OIL
# A weekday eve is still xerophagy.
assert describe(dt.date(2024, 12, 24), "goarch").fast == XEROPHAGY
def test_the_apostles_fast_is_empty_when_pascha_is_late():
"""A late Pascha can leave the fast with no days at all, which is correct
rather than a bug -- and the old calendar never has that problem."""
goarch = [d for d in week(dt.date(2024, 5, 1), 120, "goarch")
if d.season == "Apostles' Fast"]
rocor = [d for d in week(dt.date(2024, 5, 1), 120, "rocor")
if d.season == "Apostles' Fast"]
assert goarch == []
assert len(rocor) > 0
# ──────────────────────── invariants, not samples ──────────────────────────
def test_the_paschalion_invariants_hold_for_two_centuries():
for year in range(1900, 2101):
j, g = julian_pascha(year), gregorian_pascha(year)
assert j.weekday() == 6, year
assert g.weekday() == 6, year
assert dt.date(year, 4, 4) <= j <= dt.date(year, 5, 8), (year, j)
assert dt.date(year, 3, 22) <= g <= dt.date(year, 4, 25), (year, g)
def test_every_civil_date_round_trips_through_the_julian_calendar():
day, last = dt.date(1800, 1, 1), dt.date(2200, 12, 31)
while day <= last:
y, m, d = to_julian(day)
assert from_julian(y, m, d) == day, day
day += dt.timedelta(1)
def test_the_julian_offset_widens_the_day_after_a_julian_leap_day():
for before, after in ((dt.date(1900, 3, 13), dt.date(1900, 3, 14)),
(dt.date(2100, 3, 14), dt.date(2100, 3, 15)),
(dt.date(2200, 3, 15), dt.date(2200, 3, 16))):
assert julian_offset(after) == julian_offset(before) + 1, after
def test_ics_content_lines_stay_within_seventy_five_octets():
for jurisdiction in ("goarch", "rocor", "ruthenian"):
text = to_ics(week(dt.date(2026, 1, 1), 365, jurisdiction))
for line in text.split("\r\n"):
assert len(line.encode()) <= 75, (jurisdiction, line)
def test_every_jurisdiction_holds_together_for_twenty_years():
"""The regression net. Replaces a loop that asserted only truthiness and
so could not fail: "none" is a truthy string."""
from typikon import JURISDICTIONS
from typikon.fasting import LEVELS
for key in JURISDICTIONS:
for d in week(dt.date(2020, 1, 1), 366 * 21 // 4, key):
assert d.fast in LEVELS, (key, d.date, d.fast)
assert d.binds in LEVELS, (key, d.date, d.binds)
assert d.commemorations, (key, d.date)
assert not stricter(d.binds, d.fast), (key, d.date)
assert d.title, (key, d.date)
# ──────────────────────────────── the CLI ──────────────────────────────────
def test_the_cli_rejects_a_malformed_date_instead_of_traversing_a_traceback():
from typikon.cli import main
assert main(["-d", "tomorrow"]) == 2
assert main(["-d", "2026-02-30"]) == 2
assert main(["--year", "99999"]) == 2
assert main(["--jurisdiction", "atlantis"]) == 2
def test_the_cli_elides_on_a_word():
from typikon.cli import _elide
assert _elide("The Radiant and Life-giving Resurrection", 24) == "The Radiant and…"
assert _elide("Short", 24) == "Short"
+408
View File
@@ -0,0 +1,408 @@
//! Pinned against published calendars and against dates whose answer is not
//! open to interpretation.
use typikon::calendars::{Date, JulianDate, Weekday};
use typikon::day::{describe, week, year_of};
use typikon::export::to_ics;
use typikon::fasting::{Level, Obligation};
use typikon::jurisdictions::{resolve, Calendar, MenologionKind, CALENDARS, JURISDICTIONS};
use typikon::menologion::Rank;
use typikon::paschalion::{gregorian_pascha, julian_pascha, Paschalion};
use typikon::seasons::Season;
fn d(s: &str) -> Date {
Date::parse_iso(s).expect("a real date")
}
fn at(s: &str, j: &str) -> typikon::day::Day {
describe(d(s), resolve(Some(j)).expect("a real jurisdiction"))
}
// ───────────────────────────── the paschalion ─────────────────────────────
#[test]
fn julian_pascha_matches_the_published_dates() {
for (year, expected) in [
(2024, "2024-05-05"), (2025, "2025-04-20"), (2026, "2026-04-12"),
(2027, "2027-05-02"), (2028, "2028-04-16"), (2030, "2030-04-28"),
] {
assert_eq!(julian_pascha(year).iso(), expected, "julian pascha {year}");
}
}
#[test]
fn gregorian_pascha_matches_the_published_dates() {
for (year, expected) in [
(2024, "2024-03-31"), (2025, "2025-04-20"), (2026, "2026-04-05"),
(2027, "2027-03-28"), (2028, "2028-04-16"),
] {
assert_eq!(gregorian_pascha(year).iso(), expected, "gregorian pascha {year}");
}
}
#[test]
fn the_two_paschalions_agree_in_2025_and_differ_by_a_week_in_2026() {
assert_eq!(julian_pascha(2025), gregorian_pascha(2025));
assert_eq!(julian_pascha(2026) - gregorian_pascha(2026), 7);
}
#[test]
fn the_paschalion_invariants_hold_for_two_centuries() {
for year in 1900..=2100 {
let (j, g) = (julian_pascha(year), gregorian_pascha(year));
assert_eq!(j.weekday(), Weekday::Sunday, "julian {year}");
assert_eq!(g.weekday(), Weekday::Sunday, "gregorian {year}");
assert!(
Date::from_ymd(year, 4, 4).unwrap() <= j && j <= Date::from_ymd(year, 5, 8).unwrap(),
"orthodox pascha {year} out of range: {j}"
);
assert!(
Date::from_ymd(year, 3, 22).unwrap() <= g && g <= Date::from_ymd(year, 4, 25).unwrap(),
"western pascha {year} out of range: {g}"
);
}
}
// ──────────────────────────── calendar drift ──────────────────────────────
#[test]
fn every_civil_date_round_trips_through_the_julian_calendar() {
let mut day = d("1800-01-01");
let last = d("2200-12-31");
while day <= last {
let j = day.to_julian();
assert_eq!(j.to_civil(), day, "round trip {day}");
day = day + 1;
}
}
#[test]
fn julian_lags_thirteen_days_now() {
assert_eq!(d("2026-09-18").julian_offset(), 13);
let j = d("2026-09-18").to_julian();
assert_eq!((j.year, j.month, j.day), (2026, 9, 5));
}
#[test]
fn the_julian_offset_widens_the_day_after_a_julian_leap_day() {
for (before, after) in [
("1900-03-13", "1900-03-14"),
("2100-03-14", "2100-03-15"),
("2200-03-15", "2200-03-16"),
] {
assert_eq!(
d(after).julian_offset(),
d(before).julian_offset() + 1,
"offset should widen at {after}"
);
}
}
#[test]
fn old_calendar_nativity_falls_on_7_january() {
assert_eq!(
JulianDate { year: 2026, month: 12, day: 25 }.to_civil().iso(),
"2027-01-07"
);
}
#[test]
fn old_and_new_disagree_about_the_day_not_the_date() {
let old = at("2027-01-07", "rocor");
let new = at("2026-12-25", "goarch");
assert_eq!(old.church_date, "25 December");
assert_eq!(new.church_date, "25 December");
assert_eq!(old.feast, new.feast);
assert_eq!(old.rank, Some(Rank::Great));
assert_eq!(new.rank, Some(Rank::Great));
}
// ───────────────────────── the Orthodox fast ──────────────────────────────
#[test]
fn great_fast_weekdays_are_xerophagy_and_weekends_wine_and_oil() {
assert_eq!(at("2026-02-25", "new").fast, Level::Xerophagy);
assert_eq!(at("2026-02-28", "new").fast, Level::WineOil);
assert_eq!(at("2026-03-01", "new").fast, Level::WineOil);
}
#[test]
fn the_two_strictest_days_of_the_year() {
assert_eq!(at("2026-02-23", "new").fast, Level::Strict);
assert_eq!(at("2026-04-10", "new").fast, Level::Strict);
// The one Saturday kept without oil.
assert_eq!(at("2026-04-11", "new").fast, Level::Xerophagy);
}
#[test]
fn annunciation_and_palm_sunday_admit_fish_inside_the_great_fast() {
let a = at("2026-03-25", "new");
assert_eq!(a.season, Some(Season::GreatFast));
assert_eq!(a.fast, Level::Fish);
assert_eq!(at("2026-04-05", "new").fast, Level::Fish);
}
#[test]
fn the_annunciation_is_never_lost_to_great_friday() {
for (when, j) in [
("2016-03-25", "western"), ("2005-03-25", "western"),
("2029-04-07", "rocor"), ("2018-04-07", "rocor"),
] {
let day = at(when, j);
assert_eq!(day.church_date, "25 March", "{when}");
assert!(day.feast.as_deref().unwrap_or("").contains("Annunciation"), "{when}");
assert_eq!(day.rank, Some(Rank::Great), "{when}");
assert_eq!(day.fast, Level::WineOil, "{when}");
}
}
#[test]
fn kyriopascha_and_the_annunciation_in_bright_week_stay_fast_free() {
assert_eq!(at("2035-03-25", "western").fast, Level::None);
assert_eq!(at("2075-04-07", "rocor").fast, Level::None);
}
#[test]
fn palm_sunday_and_lazarus_saturday_are_still_the_fast() {
for j in ["ruthenian", "ugcc", "melkite", "goarch", "rocor"] {
for year in [2025, 2026, 2027] {
let jur = resolve(Some(j)).unwrap();
let (mv, _) = year_of(year, jur);
for when in [mv.lazarus_saturday, mv.palm_sunday] {
let day = describe(when, jur);
assert!(day.season.is_some(), "{j} {when}: no season");
assert_ne!(day.fast, Level::None, "{j} {when}");
assert!(!day.reason.is_empty(), "{j} {when}: no reason");
}
}
}
}
#[test]
fn a_ranked_saint_relaxes_a_weekday_of_a_fasting_season() {
// The Nativity of the Forerunner, a great feast inside the Apostles' Fast.
assert_eq!(at("2020-06-24", "goarch").fast, Level::Fish);
// The Forty Martyrs of Sebaste, the textbook Lenten relaxation.
assert_eq!(at("2022-03-09", "goarch").fast, Level::WineOil);
// St Nicholas, a vigil saint on a Wednesday of the Nativity Fast.
assert_eq!(at("2023-12-06", "goarch").fast, Level::WineOil);
}
#[test]
fn no_saint_brings_fish_into_the_great_fast_or_lifts_great_week() {
for day in week(d("2022-03-07"), 40, resolve(Some("goarch")).unwrap()) {
if day.church_date == "25 March" {
continue;
}
if day.season == Some(Season::GreatFast) {
assert!(day.fast >= Level::Strict, "{}", day.date);
assert!(day.fast <= Level::WineOil, "{} got {:?}", day.date, day.fast);
}
if day.season == Some(Season::GreatWeek) {
assert!(day.fast <= Level::WineOil, "{} got {:?}", day.date, day.fast);
}
}
}
#[test]
fn the_eves_relax_when_they_fall_on_a_weekend() {
assert_eq!(at("2022-12-24", "goarch").fast, Level::WineOil);
assert_eq!(at("2023-12-24", "goarch").fast, Level::WineOil);
assert_eq!(at("2025-01-05", "goarch").fast, Level::WineOil);
// A weekday eve is still xerophagy.
assert_eq!(at("2024-12-24", "goarch").fast, Level::Xerophagy);
}
#[test]
fn cheesefare_week_keeps_dairy_on_wednesday_and_friday() {
let day = at("2026-02-18", "new");
assert_eq!(day.season, Some(Season::CheesefareWeek));
assert_eq!(day.fast, Level::Dairy);
}
#[test]
fn the_fast_free_stretches() {
assert_eq!(at("2026-04-17", "new").fast, Level::None); // Bright Friday
assert_eq!(at("2026-01-02", "new").fast, Level::None); // Christmastide
assert_eq!(at("2026-06-03", "new").fast, Level::None); // after Pentecost
// Fast-free precisely because the Pharisee boasted of fasting twice a week.
assert_eq!(at("2026-02-06", "new").fast, Level::None);
}
#[test]
fn nativity_fast_tightens_for_its_last_five_days() {
assert_eq!(at("2026-12-12", "new").fast, Level::Fish);
assert_eq!(at("2026-12-19", "new").fast, Level::Fish);
assert_eq!(at("2026-12-22", "new").fast, Level::WineOil);
assert_eq!(at("2026-12-24", "new").fast, Level::Xerophagy);
}
#[test]
fn ordinary_wednesday_and_friday_are_xerophagy() {
let day = at("2026-09-04", "new");
assert!(day.season.is_none());
assert!(day.rank.is_none());
assert_eq!(day.fast, Level::Xerophagy);
}
#[test]
fn a_polyeleos_saint_lifts_an_ordinary_friday_to_wine_and_oil() {
let day = at("2026-10-09", "new");
assert_eq!(day.weekday, Weekday::Friday);
assert_eq!(day.rank, Some(Rank::Polyeleos));
assert_eq!(day.fast, Level::WineOil);
}
#[test]
fn the_apostles_fast_is_empty_when_pascha_is_late() {
let goarch: Vec<_> = week(d("2024-05-01"), 120, resolve(Some("goarch")).unwrap())
.into_iter().filter(|x| x.season == Some(Season::Apostles)).collect();
let rocor: Vec<_> = week(d("2024-05-01"), 120, resolve(Some("rocor")).unwrap())
.into_iter().filter(|x| x.season == Some(Season::Apostles)).collect();
assert!(goarch.is_empty(), "goarch 2024 should have no Apostles' Fast");
assert!(!rocor.is_empty(), "rocor 2024 should still have one");
}
#[test]
fn the_orthodox_rule_never_reports_an_obligation() {
for day in week(d("2026-01-01"), 365, resolve(Some("new")).unwrap()) {
assert_ne!(day.obligation, Some(Obligation::Obligatory), "{}", day.date);
}
}
// ──────────────────── Byzantine Catholic particular law ───────────────────
#[test]
fn ruthenian_pascha_is_the_gregorian_one() {
assert_eq!(at("2026-06-01", "ruthenian").pascha.iso(), "2026-04-05");
}
#[test]
fn ruthenian_clean_monday_is_16_february_2026() {
assert!(at("2026-02-16", "ruthenian").reason.starts_with("the first day"));
}
#[test]
fn only_the_great_fast_binds_under_ruthenian_law() {
let obligatory: Vec<_> = week(d("2026-01-01"), 365, resolve(Some("ruthenian")).unwrap())
.into_iter().filter(|x| x.is_obligatory()).collect();
let dates: Vec<String> = obligatory.iter().map(|x| x.date.iso()).collect();
assert!(dates.contains(&"2026-02-16".to_string()));
assert!(dates.contains(&"2026-04-03".to_string()));
for day in &obligatory {
assert!(
matches!(day.season, Some(Season::GreatFast) | Some(Season::GreatWeek)),
"{} binds outside the Fast", day.date
);
}
}
#[test]
fn the_voluntary_seasons_never_report_as_binding() {
for when in ["2026-11-20", "2026-08-05"] {
assert_eq!(at(when, "ruthenian").obligation, Some(Obligation::Customary));
}
}
#[test]
fn christmas_on_a_friday_does_not_ask_you_to_abstain() {
let day = at("2026-12-25", "ruthenian");
assert_eq!(day.weekday, Weekday::Friday);
assert_eq!(day.fast, Level::None);
}
#[test]
fn ruthenian_strict_days_bind_strictly_and_the_rest_bind_to_meat() {
assert_eq!(at("2026-02-16", "ruthenian").binds, Level::WineOil);
assert_eq!(at("2026-04-03", "ruthenian").binds, Level::WineOil);
assert_eq!(at("2026-02-18", "ruthenian").binds, Level::Dairy);
assert_eq!(at("2026-10-09", "ruthenian").binds, Level::None);
}
// ──────────────────────────── the menologion ──────────────────────────────
#[test]
fn every_day_of_a_leap_year_has_a_commemoration() {
for day in week(d("2024-01-01"), 366, resolve(Some("new")).unwrap()) {
assert!(!day.commemorations.is_empty(), "{}", day.date);
}
}
#[test]
fn the_great_feasts_are_all_present() {
for (month, day) in [(9, 8), (9, 14), (11, 21), (12, 25), (1, 6), (2, 2), (3, 25), (8, 6), (8, 15)] {
let x = describe(Date::from_ymd(2026, month, day).unwrap(), resolve(Some("goarch")).unwrap());
assert_eq!(x.rank, Some(Rank::Great), "{month}-{day}");
}
for when in ["2026-04-05", "2026-05-21", "2026-05-31"] {
assert_eq!(at(when, "goarch").rank, Some(Rank::Great), "{when}");
}
}
#[test]
fn a_movable_feast_outranks_the_fixed_calendar() {
assert!(at("2026-04-12", "new").feast.as_deref().unwrap_or("").contains("Pascha"));
}
// ──────────────────────────────── output ──────────────────────────────────
#[test]
fn ics_content_lines_stay_within_seventy_five_octets() {
for j in ["goarch", "rocor", "ruthenian"] {
let days = week(d("2026-01-01"), 365, resolve(Some(j)).unwrap());
let text = to_ics(&days, "Typikon", "20260101T000000Z");
for line in text.split("\r\n") {
assert!(line.len() <= 75, "{j}: {} octets: {line}", line.len());
}
}
}
#[test]
fn ics_is_well_formed() {
let days = week(d("2026-04-10"), 7, resolve(Some("new")).unwrap());
let text = to_ics(&days, "Typikon", "20260101T000000Z");
assert!(text.starts_with("BEGIN:VCALENDAR"));
assert!(text.trim_end().ends_with("END:VCALENDAR"));
assert_eq!(text.matches("BEGIN:VEVENT").count(), text.matches("END:VEVENT").count());
}
// ─────────────────────────── every jurisdiction ───────────────────────────
#[test]
fn every_jurisdiction_holds_together_for_twenty_years() {
for jur in JURISDICTIONS.iter().chain(CALENDARS.iter()) {
for day in week(d("2020-01-01"), 366 * 21 / 4, jur) {
assert!(!day.commemorations.is_empty(), "{} {}", jur.key, day.date);
assert!(!day.title().is_empty(), "{} {}", jur.key, day.date);
// `binds` may never be stricter than the level it sits under.
assert!(day.binds >= day.fast, "{} {}: binds {:?} > fast {:?}",
jur.key, day.date, day.binds, day.fast);
}
}
}
#[test]
fn a_calendar_name_resolves_without_naming_a_church() {
assert_eq!(resolve(Some("old")).unwrap().menologion, MenologionKind::Julian);
assert_eq!(resolve(Some("new")).unwrap().menologion, MenologionKind::RevisedJulian);
assert_eq!(resolve(Some("western")).unwrap().paschalion, Paschalion::Gregorian);
assert_eq!(resolve(None).unwrap().key, "new");
assert!(resolve(Some("atlantis")).is_err());
assert_eq!(resolve(Some("rocor")).unwrap().calendar(), Calendar::Old);
}
// ─────────────────────── the levels are an ordered set ────────────────────
#[test]
fn a_feast_can_only_relax_a_fast_never_tighten_it() {
// Strictest to laxest, so `<` means stricter.
assert!(Level::Strict < Level::Xerophagy);
assert!(Level::Xerophagy < Level::WineOil);
assert!(Level::Dairy < Level::None);
// Relaxing to fish inside the Great Fast stops at wine and oil.
assert_eq!(Level::Xerophagy.relaxed_to(Level::Fish, Level::WineOil), Level::WineOil);
// A cap stricter than the day never tightens the day.
assert_eq!(Level::Fish.relaxed_to(Level::WineOil, Level::WineOil), Level::Fish);
// No rank means no change.
assert_eq!(Level::Xerophagy.relaxed_to(Level::Xerophagy, Level::Fish), Level::Xerophagy);
}
-31
View File
@@ -1,31 +0,0 @@
"""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)
-125
View File
@@ -1,125 +0,0 @@
"""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 until 14 March 2100, and
fourteen from the 15th -- the day after a Julian 29 February that the
Gregorian calendar does not have.
Keeping both is what makes a single menologion serve every jurisdiction. The
Nativity is 25 December in the Julian and the Gregorian books alike; the two
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 15 March 2100, the day after a
Julian 29 February the Gregorian calendar does not have. Computed rather
than assumed, so nothing here breaks at the boundary.
"""
jy, jm, jd = to_julian(date)
return (gregorian_to_jdn(date.year, date.month, date.day)
- 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
-237
View File
@@ -1,237 +0,0 @@
"""The command line."""
import argparse
import datetime as dt
import shutil
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 _elide(text, width):
"""Cut on a word boundary and say so, rather than stopping mid-word."""
if len(text) <= width:
return text
cut = text[:width - 1]
if " " in cut[width // 2:]:
cut = cut[:cut.rstrip().rfind(" ")]
return cut.rstrip(" ,;") + "\u2026"
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")
# The glyph is two columns wide in a terminal and one character to Python,
# which is why the arithmetic below looks off by one.
fixed = 2 + 2 + 10 + 1 + 1 + 32
room = max(24, shutil.get_terminal_size((100, 24)).columns - fixed - 1)
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}{_elide(d.title, room)}")
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 is not None:
if not 1 <= args.year <= 9999:
print(f"typikon: {args.year} is not a year this calendar reaches",
file=sys.stderr)
return 2
mv = year_of(args.year, jur)
print(f"\n {args.year} · {jur.name}\n")
for k in ("publican_pharisee", "meatfare", "cheesefare", "clean_monday",
"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
if args.date:
try:
start = dt.date.fromisoformat(args.date)
except ValueError:
print(f"typikon: {args.date!r} is not a date \u2014 expected YYYY-MM-DD",
file=sys.stderr)
return 2
else:
start = dt.date.today()
days = week(start, args.days, jur)
if args.json:
print(to_json(days, args.indent))
return 0
if args.ics:
sys.stdout.write(to_ics(days, name=f"{jur.name} — Typikon"))
return 0
_print_days(days, jur, get_rule(jur.rule), source, args.verbose)
# The first run should say what it assumed, and then stop saying it.
if source == "default":
where = config.write(jur.key)
print(f" Reckoning by the {jur.name.lower()}, written to {where}.")
print(" Change it with `typikon --set-default <jurisdiction>`,"
" or see `typikon --list`.\n")
return 0
if __name__ == "__main__":
sys.exit(main())
-54
View File
@@ -1,54 +0,0 @@
"""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"
-193
View File
@@ -1,193 +0,0 @@
"""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
# How the degrees of solemnity order against each other, for the days when a
# movable feast and a fixed one fall together.
_RANKS = {"great": 3, "vigil": 2, "polyeleos": 1}
_WEEKDAYS = ("Monday", "Tuesday", "Wednesday", "Thursday", "Friday",
"Saturday", "Sunday")
_MONTHS = ("January", "February", "March", "April", "May", "June", "July",
"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")
# When a movable feast and a fixed one land together, the higher rank is
# the day's feast and the other joins the commemorations. Letting the
# movable one win unconditionally lost the Annunciation whenever it fell in
# Great Week -- the Great Feast of the Incarnation demoted to a bullet, and
# its rank overwritten with the lesser one the fasting rules then read.
fixed_feast = commemorations[0] if rank in ("great", "vigil") else None
mov_title, mov_rank = _menologion.movable_feast_for(date, mv)
if mov_title and fixed_feast and _RANKS.get(rank, 0) > _RANKS.get(mov_rank, 0):
feast = fixed_feast
if mov_title not in commemorations:
commemorations.insert(1, mov_title)
elif mov_title:
feast = mov_title
if _RANKS.get(mov_rank, 0) >= _RANKS.get(rank, 0):
rank = mov_rank
if fixed_feast and fixed_feast not in commemorations:
commemorations.insert(1, fixed_feast)
else:
feast = fixed_feast
ctx = _Context(
civil=date, fixed=(fm, fd), fixed_year=fy, weekday=date.weekday(),
mv=mv, season=season, feast=feast or (commemorations[0] if commemorations else ""),
rank=rank, menologion_kind=jur.menologion,
)
fast = rule["fast_for"](ctx)
label, emoji = rule["vocabulary"].get(fast.level, (fast.level, ""))
return Day(
date=date,
weekday=_WEEKDAYS[date.weekday()],
jurisdiction=jur.key,
calendar=jur.calendar,
church_date=f"{fd} {_MONTHS[fm - 1]}",
season=season,
cycle=cycle,
feast=feast,
rank=rank,
commemorations=commemorations,
fast=fast.level,
fast_label=label,
emoji=emoji,
reason=fast.reason,
obligation=fast.obligation,
binds=fast.binds,
pascha=mv["pascha"],
days_to_pascha=(mv["pascha"] - date).days,
julian_offset=julian_offset(date),
)
def week(start, days=7, jurisdiction=None):
"""`days` consecutive days from `start`."""
return [describe(start + dt.timedelta(n), jurisdiction)
for n in range(max(1, days))]
def year_of(year, jurisdiction=None):
"""Every key date of a year, for checking against a published calendar."""
jur = jurisdiction if hasattr(jurisdiction, "paschalion") else resolve(jurisdiction)
mv = movable_cycle(year, jur.paschalion)
mv["apostles_end"] = civil_of_fixed(year, 6, 28, jur.menologion)
mv["nativity"] = civil_of_fixed(year, 12, 25, jur.menologion)
mv["theophany"] = civil_of_fixed(year, 1, 6, jur.menologion)
mv["dormition"] = civil_of_fixed(year, 8, 15, jur.menologion)
return mv
-4
View File
@@ -1,4 +0,0 @@
from .ics import to_ics
from .jsonout import to_json
__all__ = ["to_json", "to_ics"]
-104
View File
@@ -1,104 +0,0 @@
"""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"]
# RFC 5545: a content line SHOULD NOT be longer than 75 **octets**, excluding
# the line break. Counting characters instead put a line with four emoji eight
# octets over, and the em-dashes three each.
_FOLD = 75
_BACKSLASH = chr(92)
# 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):
"""Break a content line on octets, continued with a leading space.
The split never falls inside a character: the budget is spent per
codepoint, so a four-octet emoji moves to the next line whole.
"""
out, cur, used = [], [], 0
for ch in line:
n = len(ch.encode("utf-8"))
if used + n > _FOLD:
out.append("".join(cur))
cur, used = [" "], 1
cur.append(ch)
used += n
out.append("".join(cur))
return "\r\n".join(out)
def to_ics(days, name="Typikon", stamp=None):
stamp = (stamp or dt.datetime.now(dt.timezone.utc)).strftime("%Y%m%dT%H%M%SZ")
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//typikon//EN",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
f"X-WR-CALNAME:{_esc(name)}",
]
for d in days:
if not d.feast and d.fast == "none":
continue
start = d.date.strftime("%Y%m%d")
end = (d.date + dt.timedelta(1)).strftime("%Y%m%d")
summary = d.title or d.fast_label
if d.emoji:
summary = f"{d.emoji} {summary}"
body = []
if d.fast != "none":
body.append(f"Fast: {d.fast_label}"
+ (f" ({d.reason})" if d.reason else ""))
if d.obligation == "obligatory":
body.append("Obligatory.")
if d.season:
body.append(f"Season: {d.season}")
if d.cycle:
body.append(d.cycle[0].upper() + d.cycle[1:])
body.append(f"Church date: {d.church_date}")
for c in d.commemorations:
body.append(c)
uid = f"{start}-{d.jurisdiction}@typikon"
lines += [
"BEGIN:VEVENT",
f"UID:{uid}",
f"DTSTAMP:{stamp}",
f"DTSTART;VALUE=DATE:{start}",
f"DTEND;VALUE=DATE:{end}",
_fold(f"SUMMARY:{_esc(summary)}"),
_fold(f"DESCRIPTION:{_esc(chr(10).join(body))}"),
"TRANSP:TRANSPARENT",
"END:VEVENT",
]
lines.append("END:VCALENDAR")
return "\r\n".join(lines) + "\r\n"
-19
View File
@@ -1,19 +0,0 @@
"""JSON, which is how every front end reads this.
The shape is flat and stable: front ends are the thing most likely to be
written by someone else, and a schema that shifts underneath them is a bad
neighbour. One day is an object; several are ``{"days": [...]}``.
"""
import json
__all__ = ["to_json"]
def to_json(days, indent=None):
if not isinstance(days, (list, tuple)):
return json.dumps(days.to_dict(), ensure_ascii=False, indent=indent)
if len(days) == 1:
return json.dumps(days[0].to_dict(), ensure_ascii=False, indent=indent)
return json.dumps({"days": [d.to_dict() for d in days]},
ensure_ascii=False, indent=indent)
-82
View File
@@ -1,82 +0,0 @@
"""What is kept on a day, and whether anything about it binds.
Six levels, ordered from strictest to laxest. They are the Typikon's own
distinctions, not a simplification of them: the difference between a day of
xerophagy and a day of wine and oil is the difference the books actually make,
and a calendar that reports both as "fasting" has thrown away the information
its reader wanted.
strict no food until evening
xerophagy no oil, no wine; no meat, fish, eggs or dairy
wine_oil wine and oil permitted; no meat, fish, eggs or dairy
fish fish, wine and oil permitted; no meat, eggs or dairy
dairy dairy, eggs and fish permitted; no meat
none no fast
The same six serve a tradition that makes fewer distinctions. Ruthenian
particular law knows three degrees, and each maps exactly onto one of these —
its "strict abstinence" is ``wine_oil`` (it does not regulate oil or wine), its
"abstinence" is ``dairy`` (meat alone is set aside). So a ruleset emits the
subset it uses and names those levels in its own vocabulary, rather than every
tradition being forced through one set of words.
**Level and obligation are separate questions.** A day can be kept strictly by
universal custom and bind no one, and that is the ordinary case in the Eastern
Catholic churches, whose particular law requires very little. Reporting the
binding minimum as the day's level makes the obligatory days look laxer than
the voluntary ones beside them — true of the obligation, false of the day. So
both are carried, and a caller that wants one must say which.
"""
from dataclasses import dataclass
__all__ = [
"STRICT", "XEROPHAGY", "WINE_OIL", "FISH", "DAIRY", "NONE",
"LEVELS", "stricter", "strictest", "laxest",
"OBLIGATORY", "CUSTOMARY", "Fast",
]
STRICT = "strict"
XEROPHAGY = "xerophagy"
WINE_OIL = "wine_oil"
FISH = "fish"
DAIRY = "dairy"
NONE = "none"
# Strictest first. Index into this is the comparison.
LEVELS = (STRICT, XEROPHAGY, WINE_OIL, FISH, DAIRY, NONE)
OBLIGATORY = "obligatory"
CUSTOMARY = "customary" # encouraged, traditional, not binding
def stricter(a, b):
"""True if `a` is a stricter level than `b`."""
return LEVELS.index(a) < LEVELS.index(b)
def strictest(*levels):
return min(levels, key=LEVELS.index)
def laxest(*levels):
return max(levels, key=LEVELS.index)
@dataclass(frozen=True)
class Fast:
"""How a day is kept, and what about it binds.
``level`` is the day as the tradition keeps it. ``binds`` is what the
Church actually requires, which for most jurisdictions is nothing — it is
``none`` unless a ruleset has particular law to point at. ``reason`` names
the rule that decided it, so the answer can be argued with.
"""
level: str
reason: str = ""
obligation: str = None # OBLIGATORY, CUSTOMARY, or None for a free day
binds: str = NONE # the binding minimum, where one exists
@property
def is_fast(self):
return self.level != NONE
-151
View File
@@ -1,151 +0,0 @@
"""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)
-171
View File
@@ -1,171 +0,0 @@
"""Supplementary commemorations, fetched rather than shipped.
The menologion this library ships is the common Byzantine one, composed from
the tradition and given away (see data/LICENSE). Some churches also keep their
own saints, and those lists are usually published by a body that holds the
copyright in the wording. Redistributing them would be taking someone else's
work; fetching them onto your own machine, for your own use, is not.
So supplementary sets are **not** in the repository. ``typikon
--update-menaion ruthenian`` fetches one into your data directory, where the
menologion loader picks it up automatically and overlays it on the common set.
Each source declares where it lives and how to read it. Adding one is a dict.
"""
import datetime as dt
import json
import os
import re
import urllib.request
__all__ = ["SOURCES", "update", "target_dir"]
MONTHS = ("January February March April May June July August "
"September October November December").split()
SOURCES = {
"ruthenian": {
"title": "Byzantine Catholic Metropolia of Pittsburgh",
"url": "https://mci.archpitt.org/liturgy/Calendar_of_Saints.html",
"note": "Published by the Metropolitan Cantor Institute. Fetched for "
"your own use; not redistributed with this package.",
},
}
def target_dir():
"""Where a fetched set is written.
The package directory if it is writable — a checkout usually is — and the
user's data directory otherwise, so an installed copy still works.
"""
packaged = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
if os.access(packaged, os.W_OK):
return packaged
base = os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share")
path = os.path.join(base, "typikon")
os.makedirs(path, exist_ok=True)
return path
def _strip_html(html):
txt = re.sub(r"<script.*?</script>|<style.*?</style>", "", html, flags=re.S)
txt = re.sub(r"<(br|/p|/div|/li|/h\d)[^>]*>", "\n", txt, flags=re.I)
txt = re.sub(r"<[^>]+>", "\n", txt)
for a, b in (("&nbsp;", " "), ("&amp;", "&"), ("&#8217;", "'"),
("&rsquo;", "'"), ("&mdash;", "—")):
txt = txt.replace(a, b)
return [x for x in (re.sub(r"\s+", " ", l).strip() for l in txt.split("\n")) if x]
def _parse_mci(html):
"""MCI publishes prose, not a table, so entries split on the date headings.
The page has at least one typo — 14 October is written "October 1" — which
silently overwrites the real entry for that day, taking the Protection of
the Theotokos with it. A date that goes backwards inside a month is the
giveaway, and is corrected to follow the previous one.
"""
lines = _strip_html(html)
idx = {m: i + 1 for i, m in enumerate(MONTHS)}
dated = re.compile(r"^(" + "|".join(MONTHS) + r")\s+(\d{1,2})\s*(.*)$")
heading = re.compile(r"^(" + "|".join(MONTHS) + r")\.?$")
# MCI writes the festal cycle inline, as a commemoration among the saints:
# "Pre-festive Day of the Theophany". It repeats for days at a time and is
# not a saint, so it becomes the day's cycle and leaves the list alone.
festal = re.compile(r"^(Pre-festive|Post-festive|Fore-feast|After-feast|"
r"Leave-taking)\b[ ,]*(?:Day\s+)?(?:of\s+)?(.*)$", re.I)
cycle_word = {"pre-festive": "forefeast", "fore-feast": "forefeast",
"post-festive": "afterfeast", "after-feast": "afterfeast",
"leave-taking": "leave-taking"}
out, cur, buf, corrections = {}, None, [], []
prev_month = prev_day = None
def flush():
if not (cur and buf):
return
items, cycle = [], None
for piece in buf:
for part in re.split(r"(?<=\.)\s+(?=[A-Z(])", piece):
part = part.strip(" .;")
if len(part) <= 3 or dated.match(part) or heading.match(part):
continue
m = festal.match(part)
if m:
if cycle is None:
word = cycle_word[m.group(1).lower()]
rest = m.group(2).strip(" .,")
cycle = f"{word} of {rest}" if rest else word
continue
items.append(part)
entry = {}
if items:
entry["commemorations"] = items
if cycle:
entry["cycle"] = cycle
if entry:
out[cur] = entry
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))
# This is a complete calendar published by that church, so it stands in
# for the common commemorations rather than piling on top of them.
payload = {"_meta": {"mode": "replace", "source": source["url"],
"title": source["title"]}}
payload.update(entries)
path = os.path.join(target_dir(), f"{name}.json")
with open(path, "w", encoding="utf-8") as fh:
json.dump(payload, fh, ensure_ascii=False, indent=1, sort_keys=True)
return {"path": path, "days": len(entries), "corrections": corrections,
"missing": missing, "source": source}
-137
View File
@@ -1,137 +0,0 @@
"""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:
"""A fixed calendar, plus whatever a jurisdiction overlays on it.
``_meta`` is a reserved key, not a day. It carries ``mode``:
add the set's commemorations join the common ones (the default,
right for a local set that adds a few of its own saints)
replace the set's commemorations stand in for them, because it is
itself a complete calendar published by that church
Either way the common set's ``rank`` and ``cycle`` survive unless the
overlay gives its own, since a published list of saints usually carries
neither and the fasting rules need the rank.
"""
def __init__(self, entries):
self.meta = entries.get("_meta", {})
self.entries = {k: v for k, v in entries.items() if k != "_meta"}
def get(self, month, day):
return self.entries.get(f"{month:02d}-{day:02d}", {})
def overlay(self, other):
replace = other.meta.get("mode") == "replace"
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]
extra = list(add.get("commemorations", []))
if replace:
base["commemorations"] = extra or base.get("commemorations", [])
else:
seen = list(base.get("commemorations", []))
base["commemorations"] = seen + [c for c in extra if c not in seen]
# A supplementary set may state a rank or a cycle; it never erases one.
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
_overlaid = {}
def for_jurisdiction(jur):
"""The common menologion with any supplementary sets overlaid.
Cached per jurisdiction: the overlay copies all 366 days, and rebuilding it
for every date asked about made a year of a jurisdiction with a
supplementary set ten times slower than one without.
"""
key = (jur.key, jur.menaia)
if key in _overlaid:
return _overlaid[key]
m = load()
for extra in jur.menaia:
try:
m = m.overlay(load(extra))
except FileNotFoundError:
# A supplementary set is optional; its absence is not an error.
pass
_overlaid[key] = m
return m
# ── the movable feasts, named by their offset from Pascha
#
# These outrank whatever the fixed calendar has for the day. The keys are
# entries in the movable cycle; the value is (title, rank).
MOVABLE_FEASTS = {
"pascha": ("The Radiant and Life-giving Resurrection of our Lord — Holy Pascha", "great"),
"palm_sunday": ("The Entry of our Lord into Jerusalem", "great"),
"ascension": ("The Ascension of our Lord, God and Saviour Jesus Christ", "great"),
"pentecost": ("Holy Pentecost — the Descent of the Holy Spirit", "great"),
"lazarus_saturday": ("Lazarus Saturday", "vigil"),
"great_thursday": ("Great and Holy Thursday", "vigil"),
"great_friday": ("Great and Holy Friday", "vigil"),
"holy_saturday": ("Great and Holy Saturday", "vigil"),
"thomas_sunday": ("Thomas Sunday — the Antipascha", "polyeleos"),
"myrrhbearers": ("The Sunday of the Holy Myrrh-bearing Women", "polyeleos"),
"midfeast": ("The Midfeast of Pentecost", "polyeleos"),
"all_saints": ("The Sunday of All Saints", "polyeleos"),
"publican_pharisee": ("The Sunday of the Publican and the Pharisee", None),
"prodigal_son": ("The Sunday of the Prodigal Son", None),
"meatfare": ("Meatfare Sunday — the Sunday of the Last Judgement", None),
"cheesefare": ("Cheesefare Sunday — the Expulsion of Adam, Forgiveness Sunday", None),
"orthodoxy_sunday": ("The Sunday of Orthodoxy", "polyeleos"),
"gregory_palamas": ("The Sunday of St Gregory Palamas", None),
"cross_veneration": ("The Sunday of the Veneration of the Cross", "polyeleos"),
"john_climacus": ("The Sunday of St John Climacus", None),
"mary_of_egypt": ("The Sunday of St Mary of Egypt", None),
"fathers_first_council": ("The Sunday of the Fathers of the First Council", None),
"clean_monday": ("Clean Monday — the first day of the Great Fast", None),
}
def movable_feast_for(civil, mv):
"""(title, rank) if the day is a movable feast, else (None, None)."""
for key, (title, rank) in MOVABLE_FEASTS.items():
if mv.get(key) == civil:
return title, rank
return None, None
-110
View File
@@ -1,110 +0,0 @@
"""Pascha, and the movable cycle that hangs off it.
Two reckonings are in use. The **Julian Paschalion** is what nearly every
Orthodox Church keeps: the old computus, worked in the Julian calendar and then
read off onto a civil date. The **Gregorian Paschalion** is the Western one,
kept by the Finnish Orthodox Church and by most Byzantine Catholic churches.
Neither is a variant of the other. They are separate tables that happen to
agree in some years — 2025 falls on 20 April by both — and can diverge by as
much as five weeks.
"""
import datetime as dt
from .calendars import from_julian
__all__ = ["julian_pascha", "gregorian_pascha", "pascha", "movable_cycle"]
def julian_pascha(year):
"""Pascha by the Julian Paschalion, as a civil (Gregorian) date.
Meeus' Julian algorithm. It yields a date in the Julian calendar, which is
then read onto the civil calendar — the step that is easy to forget and
that puts the answer thirteen days out when it is.
"""
a = year % 4
b = year % 7
c = year % 19
d = (19 * c + 15) % 30
e = (2 * a + 4 * b - d + 34) % 7
month = (d + e + 114) // 31
day = ((d + e + 114) % 31) + 1
return from_julian(year, month, day)
def gregorian_pascha(year):
"""Pascha by the Gregorian Paschalion — the Western computus."""
a = year % 19
b, c = divmod(year, 100)
d, e = divmod(b, 4)
g = (8 * b + 13) // 25
h = (19 * a + b - d - g + 15) % 30
i, k = divmod(c, 4)
ll = (32 + 2 * e + 2 * i - h - k) % 7
m = (a + 11 * h + 19 * ll) // 433
month = (h + ll - 7 * m + 90) // 25
day = (h + ll - 7 * m + 33 * month + 19) % 32
return dt.date(year, month, day)
def pascha(year, paschalion="julian"):
if paschalion == "julian":
return julian_pascha(year)
if paschalion == "gregorian":
return gregorian_pascha(year)
raise ValueError(f"unknown paschalion: {paschalion!r}")
def movable_cycle(year, paschalion="julian"):
"""Every movable date the calendar needs, keyed off Pascha.
All of these are counted in real days from Pascha, so they are civil dates
already and need no calendar conversion — the distance from Pascha to
Pentecost is fifty days in every reckoning.
"""
p = pascha(year, paschalion)
d = dt.timedelta
return {
"pascha": p,
# ── the Triodion, counted backwards
"publican_pharisee": p - d(70),
"prodigal_son": p - d(63),
"meatfare": p - d(56), # last day meat is eaten
"cheesefare": p - d(49), # Forgiveness Sunday, last day of dairy
"cheesefare_week_start": p - d(55),
"clean_monday": p - d(48), # the Great Fast begins
"orthodoxy_sunday": p - d(42),
"gregory_palamas": p - d(35),
"cross_veneration": p - d(28),
"john_climacus": p - d(21),
"mary_of_egypt": p - d(14),
"great_fast_end": p - d(9), # Friday before Lazarus Saturday
"lazarus_saturday": p - d(8),
"palm_sunday": p - d(7),
"great_week_start": p - d(6),
"great_thursday": p - d(3),
"great_friday": p - d(2),
"holy_saturday": p - d(1),
# ── the Pentecostarion, counted forwards
"bright_week_end": p + d(6),
"thomas_sunday": p + d(7),
"myrrhbearers": p + d(14),
"midfeast": p + d(24),
"ascension": p + d(39),
"fathers_first_council": p + d(42),
"pentecost": p + d(49),
"pentecost_week_end": p + d(55),
"all_saints": p + d(56),
# The Apostles' Fast runs from the Monday after All Saints to the eve
# of Ss Peter and Paul. A late Pascha can leave it with no days at all,
# which is correct rather than a bug — the fast is simply not kept that
# year. Its end is a fixed ecclesiastical date, so the jurisdiction
# resolves it; only the start belongs here.
"apostles_start": p + d(57),
}
-19
View File
@@ -1,19 +0,0 @@
"""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)
-205
View File
@@ -1,205 +0,0 @@
"""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.
- **Great Week** is kept at xerophagy on its weekdays whatever saint falls in
it, because the days of the Passion outrank the menologion.
- **Monastic practice**, and the Holy Mountain in particular, is stricter
than any of this throughout.
A saint of vigil or polyeleos rank lifts the weekday discipline of a fast — to
wine and oil inside the Great Fast, as far as fish in the three lesser fasts.
That is why the printed calendars mark so many Wednesdays in the Nativity Fast
for wine and oil, and it is the rank in the menologion that decides it.
A calendar cannot settle those. Your bishop and your spiritual father can.
"""
import datetime as dt
from ..fasting import (Fast, STRICT, XEROPHAGY, WINE_OIL, FISH, DAIRY, NONE,
CUSTOMARY, laxest, stricter, strictest)
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 a fast day."""
if rank == "great":
return FISH
if rank in ("vigil", "polyeleos"):
return WINE_OIL
return None
def _for_feast(level, ctx, cap, reason):
"""Relax `level` for the day's saint, never past `cap`, never tightening.
This is the rule the printed calendars are applying when they mark a
Wednesday in the Nativity Fast for wine and oil: the day's rank lifts the
weekday discipline. Inside the Great Fast the lift stops at wine and oil —
no saint brings fish into Lent — and elsewhere it stops at fish.
"""
relaxed = _relaxed_by_feast(ctx.rank)
if relaxed is None:
return Fast(level, reason, CUSTOMARY)
lifted = strictest(laxest(level, relaxed), cap)
if lifted == level or stricter(lifted, level):
return Fast(level, reason, CUSTOMARY)
return Fast(lifted, f"{ctx.feast} \u2014 the fast is relaxed", CUSTOMARY)
def fast_for(ctx):
md, wd, mv = ctx.fixed, ctx.weekday, ctx.mv
season = ctx.season
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):
# The Annunciation is tested before the strict days, not after. It can
# land on Great Friday or Great Saturday, and when it does the feast
# still lifts the total fast to wine and oil -- it does not vanish.
if md == (3, 25):
if ctx.civil in (mv["great_friday"], mv["holy_saturday"]):
return Fast(WINE_OIL,
"the Annunciation, on a day of the total fast",
CUSTOMARY)
return Fast(FISH, "the Annunciation", CUSTOMARY)
if ctx.civil == mv["palm_sunday"]:
return Fast(FISH, "Palm Sunday", CUSTOMARY)
if ctx.civil == mv["lazarus_saturday"]:
return Fast(WINE_OIL, "Lazarus Saturday", CUSTOMARY)
if ctx.civil == mv["clean_monday"]:
return Fast(STRICT, "the first day of the Great Fast", CUSTOMARY)
if ctx.civil == mv["great_friday"]:
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 ctx.civil == mv["great_thursday"]:
return Fast(WINE_OIL, "Great and Holy Thursday", CUSTOMARY)
# Great Week keeps its own rank; a saint does not lift it.
if season == GREAT_WEEK:
return Fast(XEROPHAGY, f"a weekday of the {season}", CUSTOMARY)
if weekend:
return Fast(WINE_OIL, f"Saturday and Sunday of the {season}", CUSTOMARY)
return _for_feast(XEROPHAGY, ctx, WINE_OIL, f"a weekday of the {season}")
# ── Cheesefare: meat is set aside for good, everything else stays,
# Wednesday and Friday included.
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):
# The eve is relaxed to wine and oil on a Saturday or Sunday,
# because the Liturgy of St Basil is served with Vespers.
if weekend:
return Fast(WINE_OIL, "the eve of the Nativity, on a weekend",
CUSTOMARY)
return Fast(XEROPHAGY, "the eve of the Nativity", CUSTOMARY)
if md == (11, 21):
return Fast(FISH, "the Entrance of the Theotokos", CUSTOMARY)
if md >= (12, 20):
if wd in (TUE, THU) or weekend:
return Fast(WINE_OIL, "the forefeast of the Nativity", CUSTOMARY)
return _for_feast(XEROPHAGY, ctx, WINE_OIL,
"the forefeast of the Nativity")
if weekend:
return Fast(FISH, "Saturday and Sunday of the Nativity Fast", CUSTOMARY)
if wd in (TUE, THU):
return _for_feast(WINE_OIL, ctx, FISH,
"Tuesday and Thursday of the Nativity Fast")
return _for_feast(XEROPHAGY, ctx, FISH,
"Monday, Wednesday and Friday of the Nativity Fast")
# ── The Apostles' Fast, kept the same way
if season == APOSTLES:
if weekend:
return Fast(FISH, "Saturday and Sunday of the Apostles' Fast", CUSTOMARY)
if wd in (TUE, THU):
return _for_feast(WINE_OIL, ctx, FISH,
"Tuesday and Thursday of the Apostles' Fast")
return _for_feast(XEROPHAGY, ctx, FISH,
"Monday, Wednesday and Friday of the Apostles' Fast")
# ── The Dormition Fast, kept like the Great Fast
if season == DORMITION:
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 _for_feast(XEROPHAGY, ctx, WINE_OIL,
"a weekday of the Dormition Fast")
# ── The three fixed fast days outside any season
if md == (1, 5):
if weekend:
return Fast(WINE_OIL, "the eve of Theophany, on a weekend", CUSTOMARY)
return Fast(XEROPHAGY, "the eve of Theophany", CUSTOMARY)
if md == (9, 14):
return Fast(WINE_OIL, "the Exaltation of the Holy Cross", CUSTOMARY)
if md == (8, 29):
return Fast(WINE_OIL, "the Beheading of the Forerunner", CUSTOMARY)
# ── Ordinary Wednesdays and Fridays, which a feast can lift
if wd in (WED, FRI):
relaxed = _relaxed_by_feast(ctx.rank)
if relaxed:
return Fast(relaxed, f"{ctx.feast} — the fast is relaxed", CUSTOMARY)
return Fast(XEROPHAGY, "Wednesday and Friday", CUSTOMARY)
return Fast(NONE, "", None)
RULE = {
"name": "orthodox",
"title": "the Typikon",
"vocabulary": VOCABULARY,
"fast_for": fast_for,
# The Orthodox Church does not legislate the fast as precept, so nothing
# here is reported as binding. See the module docstring.
"has_obligation": False,
}
-126
View File
@@ -1,126 +0,0 @@
"""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,
}
-75
View File
@@ -1,75 +0,0 @@
"""Which season of the year a day falls in.
The Great Fast here runs from Clean Monday through Palm Sunday. The Forty Days
proper end on the Friday before Lazarus Saturday — ``great_fast_end`` in the
movable cycle, which is what the fasting rules count from — but a season is a
label for a reader, and a Palm Sunday belonging to no season at all is worse
than one belonging loosely to the Fast.
The movable seasons are counted from Pascha and so are compared as civil
dates. The fixed ones — the Nativity and Dormition Fasts, the fast-free days
after the Nativity — are compared in **ecclesiastical** space, against the date
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
# Lazarus Saturday and Palm Sunday sit between the Forty Days, which end on
# the Friday before them, and Great Week, which begins after them. Leaving
# that gap unnamed left both days with no season at all, and any rule that
# keyed on the season fell through to "no fast" on Palm Sunday.
if mv["clean_monday"] <= civil <= mv["palm_sunday"]:
return GREAT_FAST
if mv["cheesefare_week_start"] <= civil <= mv["cheesefare"]:
return CHEESEFARE_WEEK
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