Files
typikon/src/python.rs
T

394 lines
13 KiB
Rust
Raw Normal View History

2026-09-18 11:27:51 -04:00
//! Python bindings.
//!
//! These mirror `DayJson` rather than `Day`. `DayJson` is already documented as
//! the wire format front ends read, and it is the shape that has to stay stable
//! for them; binding the internal struct instead would publish field names that
//! are free to move and would leave Python and JSON disagreeing about the same
//! day. So the getters below are the JSON fields, spelled the way Python spells
//! things.
//!
//! Built only under the `python` feature, which maturin turns on. See Cargo.toml
//! for why it cannot be on by default.
use pyo3::exceptions::{PyValueError, PyOSError};
use pyo3::prelude::*;
use pyo3::types::{PyDate, PyDict, PyList};
2026-09-18 12:33:56 -04:00
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
2026-09-18 11:27:51 -04:00
use crate::calendars::Date;
use crate::day;
use crate::fasting::Obligation;
use crate::jurisdictions::{self, Jurisdiction};
/// A `datetime.date` to the crate's julian-day-number date.
///
/// Read through attributes rather than pyo3's `PyDateAccess`, which the limited
/// API does not expose — the abi3 build that gets us one wheel per platform
/// instead of one per platform and interpreter cannot see datetime's C struct.
/// Constructing a date is fine either way, because pyo3 calls Python's own
/// constructor for that. Going through attributes also means anything shaped
/// like a date works, `datetime.datetime` included.
fn to_date(d: &Bound<'_, PyDate>) -> PyResult<Date> {
let y: i32 = d.getattr("year")?.extract()?;
let m: u32 = d.getattr("month")?.extract()?;
let day: u32 = d.getattr("day")?.extract()?;
Date::from_ymd(y, m, day)
.ok_or_else(|| PyValueError::new_err(format!("date out of range: {y}-{m:02}-{day:02}")))
}
/// Back the other way, for anything that hands out a date.
fn from_date<'py>(py: Python<'py>, d: Date) -> PyResult<Bound<'py, PyDate>> {
let (y, m, day) = d.ymd();
PyDate::new(py, y, m as u8, day as u8)
}
2026-09-18 12:33:56 -04:00
/// jurisdiction key, tradition key, and whether the Typikon's full discipline was
/// asked for — enough to tell one override from another.
type Interned = HashMap<(&'static str, &'static str, bool), &'static Jurisdiction>;
/// Build the jurisdiction a call should reckon by: the named one, or the saved
/// default, with the saved tradition and discipline applied on top.
2026-09-18 11:27:51 -04:00
///
2026-09-18 12:33:56 -04:00
/// This mirrors main.rs deliberately and has to keep mirroring it. A Jurisdiction
/// is no longer just a key — it carries a tradition and a discipline, and the CLI
/// reads all three from config before it reckons anything. A binding that resolved
/// only the key would answer a different fast than the command line does on the
/// same machine for the same date: xerophagy on a Lenten Wednesday reads as
/// wine_oil, which is not a cosmetic difference in a tool whose whole job is to
/// say what may be eaten.
///
/// The overridden jurisdictions are interned rather than leaked per call. main.rs
/// leaks one per run and is then done; a library gets called in a loop, and one
/// leak per call is a slow leak. The set of combinations is bounded by
/// jurisdictions x traditions x discipline, so a map of them is small and never
/// grows past it.
fn resolve(
jurisdiction: Option<&str>,
tradition: Option<&str>,
typikon: Option<bool>,
) -> PyResult<&'static Jurisdiction> {
let saved = crate::config::saved();
// The environment beats the file, which is the order main.rs uses. Reading
// only the file meant TYPIKON_JURISDICTION moved the command line and left
// the bindings behind, so the two answered for different churches on the
// same machine, which is the exact failure the note above is about.
let (default_name, _) = crate::config::default_jurisdiction();
let name = jurisdiction.map(str::to_owned).or(default_name);
2026-09-18 12:33:56 -04:00
let base = jurisdictions::resolve(name.as_deref())
.map_err(|n| PyValueError::new_err(format!("unknown jurisdiction {n:?}")))?;
let trad = match tradition.map(str::to_owned).or(saved.tradition) {
None => base.tradition,
Some(t) => jurisdictions::Tradition::parse(&t).ok_or_else(|| {
PyValueError::new_err(format!("unknown tradition {t:?}; try slavic or greek"))
})?,
};
let full = typikon.unwrap_or(saved.typikon);
let discipline = if full {
Some(jurisdictions::RuleKind::Orthodox)
} else {
base.discipline
2026-09-18 11:27:51 -04:00
};
2026-09-18 12:33:56 -04:00
if trad == base.tradition && discipline == base.discipline {
return Ok(base);
}
static INTERNED: OnceLock<Mutex<Interned>> = OnceLock::new();
let mut map = INTERNED
.get_or_init(|| Mutex::new(HashMap::new()))
.lock()
.map_err(|_| PyValueError::new_err("jurisdiction cache poisoned"))?;
Ok(map.entry((base.key, trad.key(), full)).or_insert_with(|| {
Box::leak(Box::new(Jurisdiction { tradition: trad, discipline, ..*base }))
}))
2026-09-18 11:27:51 -04:00
}
/// One day, fully reckoned.
///
/// Immutable: it is the answer to a question about a date, not a record to edit.
#[pyclass(frozen, module = "typikon", name = "Day")]
pub struct PyDay {
inner: day::Day,
}
#[pymethods]
impl PyDay {
/// The civil date.
#[getter]
fn date<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyDate>> {
from_date(py, self.inner.date)
}
/// The civil date as an ISO string, matching the JSON.
#[getter]
fn iso(&self) -> String {
self.inner.date.iso()
}
#[getter]
fn weekday(&self) -> &'static str {
self.inner.weekday.name()
}
#[getter]
fn jurisdiction(&self) -> &'static str {
self.inner.jurisdiction
}
#[getter]
fn calendar(&self) -> &'static str {
self.inner.calendar
}
/// The ecclesiastical date, as "25 December".
#[getter]
fn church_date(&self) -> &str {
&self.inner.church_date
}
#[getter]
fn season(&self) -> Option<&'static str> {
self.inner.season.map(|s| s.name())
}
#[getter]
fn cycle(&self) -> Option<&str> {
self.inner.cycle.as_deref()
}
#[getter]
fn feast(&self) -> Option<&str> {
self.inner.feast.as_deref()
}
#[getter]
fn rank(&self) -> Option<&'static str> {
self.inner.rank.map(|r| r.key())
}
/// The mark the liturgical books print beside a day of this rank.
#[getter]
fn sign(&self) -> Option<&'static str> {
self.inner.rank.map(|r| r.sign())
}
/// Greek or Slavic usage, as this jurisdiction reads the Typikon.
#[getter]
fn tradition(&self) -> &'static str {
self.inner.tradition
}
2026-09-18 11:27:51 -04:00
/// The feast if there is one, else the principal commemoration.
#[getter]
fn title(&self) -> &str {
self.inner.title()
}
#[getter]
fn commemorations(&self) -> Vec<String> {
self.inner.commemorations.clone()
}
#[getter]
fn fast(&self) -> &'static str {
self.inner.fast.key()
}
#[getter]
fn fast_label(&self) -> &'static str {
self.inner.fast_label
}
#[getter]
fn emoji(&self) -> &'static str {
self.inner.emoji
}
#[getter]
fn reason(&self) -> &str {
&self.inner.reason
}
#[getter]
fn obligation(&self) -> Option<&'static str> {
self.inner.obligation.map(|o| match o {
Obligation::Obligatory => "obligatory",
Obligation::Customary => "customary",
})
}
#[getter]
fn binds(&self) -> Option<&'static str> {
self.inner.binds.map(|b| b.key())
2026-09-18 11:27:51 -04:00
}
#[getter]
fn pascha<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyDate>> {
from_date(py, self.inner.pascha)
}
#[getter]
fn days_to_pascha(&self) -> i32 {
self.inner.days_to_pascha
}
#[getter]
fn julian_offset(&self) -> i32 {
self.inner.julian_offset
}
/// True when the day's fast binds rather than merely being customary.
fn is_obligatory(&self) -> bool {
self.inner.is_obligatory()
}
/// The same fields as `typikon --json`, as a dict.
fn to_dict<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyDict>> {
let j = self.inner.to_json();
let d = PyDict::new(py);
d.set_item("date", &j.date)?;
d.set_item("weekday", j.weekday)?;
d.set_item("jurisdiction", j.jurisdiction)?;
d.set_item("calendar", j.calendar)?;
d.set_item("church_date", j.church_date)?;
d.set_item("season", j.season)?;
d.set_item("cycle", j.cycle)?;
d.set_item("feast", j.feast)?;
d.set_item("rank", j.rank)?;
d.set_item("sign", j.sign)?;
d.set_item("tradition", j.tradition)?;
2026-09-18 11:27:51 -04:00
d.set_item("title", j.title)?;
d.set_item("commemorations", j.commemorations)?;
d.set_item("fast", j.fast)?;
d.set_item("fast_label", j.fast_label)?;
d.set_item("emoji", j.emoji)?;
d.set_item("reason", j.reason)?;
d.set_item("obligation", j.obligation)?;
d.set_item("binds", j.binds)?;
d.set_item("pascha", &j.pascha)?;
d.set_item("days_to_pascha", j.days_to_pascha)?;
d.set_item("julian_offset", j.julian_offset)?;
Ok(d)
}
fn __repr__(&self) -> String {
format!(
"<typikon.Day {} {} fast={}>",
self.inner.date.iso(),
self.inner.title(),
self.inner.fast.key()
)
}
fn __str__(&self) -> String {
let t = self.inner.title();
if t.is_empty() {
format!("{} — {}", self.inner.date.iso(), self.inner.fast_label)
} else {
format!("{} — {}", self.inner.date.iso(), t)
}
}
}
/// Reckon one civil date.
#[pyfunction]
2026-09-18 12:33:56 -04:00
#[pyo3(signature = (date, jurisdiction = None, tradition = None, typikon = None))]
fn describe(
date: &Bound<'_, PyDate>,
jurisdiction: Option<&str>,
tradition: Option<&str>,
typikon: Option<bool>,
) -> PyResult<PyDay> {
let jur = resolve(jurisdiction, tradition, typikon)?;
2026-09-18 11:27:51 -04:00
Ok(PyDay { inner: day::describe(to_date(date)?, jur) })
}
/// Reckon today, by the system clock in local time.
#[pyfunction]
2026-09-18 12:33:56 -04:00
#[pyo3(signature = (jurisdiction = None, tradition = None, typikon = None))]
fn today(
py: Python<'_>,
jurisdiction: Option<&str>,
tradition: Option<&str>,
typikon: Option<bool>,
) -> PyResult<PyDay> {
2026-09-18 11:27:51 -04:00
// Python owns the notion of "local", and it already handles the timezone
// database. Asking it is both shorter and more correct than reimplementing
// the lookup here.
let date = py
.import("datetime")?
.getattr("date")?
.call_method0("today")?;
let date = date.cast::<PyDate>().map_err(|_| {
PyOSError::new_err("datetime.date.today() did not return a date")
})?;
2026-09-18 12:33:56 -04:00
describe(date, jurisdiction, tradition, typikon)
2026-09-18 11:27:51 -04:00
}
/// Reckon a run of consecutive days starting at `start`.
#[pyfunction]
2026-09-18 12:33:56 -04:00
#[pyo3(signature = (start, days = 7, jurisdiction = None, tradition = None, typikon = None))]
2026-09-18 11:27:51 -04:00
fn week(
start: &Bound<'_, PyDate>,
days: i32,
jurisdiction: Option<&str>,
2026-09-18 12:33:56 -04:00
tradition: Option<&str>,
typikon: Option<bool>,
2026-09-18 11:27:51 -04:00
) -> PyResult<Vec<PyDay>> {
if days < 1 {
return Err(PyValueError::new_err("days must be at least 1"));
}
2026-09-18 12:33:56 -04:00
let jur = resolve(jurisdiction, tradition, typikon)?;
2026-09-18 11:27:51 -04:00
Ok(day::week(to_date(start)?, days, jur)
.into_iter()
.map(|inner| PyDay { inner })
.collect())
}
/// The date of Pascha in a given year.
#[pyfunction]
#[pyo3(signature = (year, jurisdiction = None))]
fn pascha<'py>(
py: Python<'py>,
year: i32,
jurisdiction: Option<&str>,
) -> PyResult<Bound<'py, PyDate>> {
2026-09-18 12:33:56 -04:00
// Pascha depends on the paschalion alone; tradition and discipline cannot
// move it, so they are not arguments here.
let jur = resolve(jurisdiction, None, None)?;
2026-09-18 11:27:51 -04:00
let (movables, _) = day::year_of(year, jur);
from_date(py, movables.pascha)
}
/// Every jurisdiction this build knows, as dicts.
#[pyfunction]
#[pyo3(name = "jurisdictions")]
fn list_jurisdictions<'py>(py: Python<'py>) -> PyResult<Bound<'py, PyList>> {
let out = PyList::empty(py);
for j in jurisdictions::JURISDICTIONS.iter().chain(jurisdictions::CALENDARS) {
let d = PyDict::new(py);
d.set_item("key", j.key)?;
d.set_item("name", j.name)?;
d.set_item("note", j.note)?;
out.append(d)?;
}
Ok(out)
}
#[pymodule]
fn _typikon(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add("__doc__", "The Orthodox liturgical day: feasts, seasons and the fast, computed rather than fetched.")?;
m.add("__version__", env!("CARGO_PKG_VERSION"))?;
m.add_class::<PyDay>()?;
m.add_function(wrap_pyfunction!(describe, m)?)?;
m.add_function(wrap_pyfunction!(today, m)?)?;
m.add_function(wrap_pyfunction!(week, m)?)?;
m.add_function(wrap_pyfunction!(pascha, m)?)?;
m.add_function(wrap_pyfunction!(list_jurisdictions, m)?)?;
Ok(())
}