Files
typikon/src/python.rs
T

320 lines
9.6 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};
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)
}
/// Resolve a jurisdiction name, or the configured default when given none.
///
/// The same two steps the CLI takes, and for the same reason: `jurisdictions::
/// resolve(None)` answers with a built-in default that knows nothing about the
/// machine it is running on, so calling it directly would quietly ignore whatever
/// `typikon --set-default` wrote and hand Python a different day than the command
/// line gives for the same date.
fn resolve(name: Option<&str>) -> PyResult<&'static Jurisdiction> {
let configured;
let name = match name {
Some(n) => Some(n),
None => {
configured = crate::config::default_jurisdiction().0;
configured.as_deref()
}
};
jurisdictions::resolve(name)
.map_err(|n| PyValueError::new_err(format!("unknown jurisdiction {n:?}")))
}
/// 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 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) -> &'static str {
self.inner.binds.key()
}
#[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("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]
#[pyo3(signature = (date, jurisdiction = None))]
fn describe(date: &Bound<'_, PyDate>, jurisdiction: Option<&str>) -> PyResult<PyDay> {
let jur = resolve(jurisdiction)?;
Ok(PyDay { inner: day::describe(to_date(date)?, jur) })
}
/// Reckon today, by the system clock in local time.
#[pyfunction]
#[pyo3(signature = (jurisdiction = None))]
fn today(py: Python<'_>, jurisdiction: Option<&str>) -> PyResult<PyDay> {
// 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")
})?;
describe(date, jurisdiction)
}
/// Reckon a run of consecutive days starting at `start`.
#[pyfunction]
#[pyo3(signature = (start, days = 7, jurisdiction = None))]
fn week(
start: &Bound<'_, PyDate>,
days: i32,
jurisdiction: Option<&str>,
) -> PyResult<Vec<PyDay>> {
if days < 1 {
return Err(PyValueError::new_err("days must be at least 1"));
}
let jur = resolve(jurisdiction)?;
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>> {
let jur = resolve(jurisdiction)?;
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(())
}