diff --git a/.gitignore b/.gitignore index 95cb949..d711f27 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ **/*.rs.bk .venv/ *.egg-info/ + +# python build and test artefacts +__pycache__/ +*.py[cod] diff --git a/Cargo.lock b/Cargo.lock index 5cf0c00..2049b86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,6 +52,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "clap" version = "4.6.7" @@ -83,7 +89,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] [[package]] @@ -104,6 +110,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -116,18 +131,45 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "once_cell_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + [[package]] name = "proc-macro2" version = "1.0.107" @@ -137,6 +179,67 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pyo3" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab53c047fcd1a1d2a8820fe84f05d6be69e9526be40cb03b73f86b6b03e6d87d" +dependencies = [ + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b455933107de8642b4487ed26d912c2d899dec6114884214a0b3bb3be9261ea6" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c85c9cbfaddf651b1221594209aed57e9e5cff63c4d11d1feead529b872a089" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a5b10c9bf9888125d917fb4d2ca2d25c8df94c7ab5a52e13313a07e050a3b02" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03b51720d314836e53327f5871d4c0cfb4fb37cc2c4a11cc71907a86342c40f9" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn 2.0.119", +] + [[package]] name = "quote" version = "1.0.47" @@ -146,6 +249,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "serde" version = "1.0.229" @@ -173,7 +282,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] [[package]] @@ -195,6 +304,17 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "3.0.6" @@ -206,11 +326,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "typikon" version = "0.2.0" dependencies = [ "clap", + "pyo3", "serde", "serde_json", ] @@ -221,6 +348,12 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + [[package]] name = "utf8parse" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index dd40704..d12fa6b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,12 +14,12 @@ categories = ["command-line-utilities", "date-and-time"] serde = { version = "1", features = ["derive"] } serde_json = "1" clap = { version = "4", features = ["derive"] } +pyo3 = { version = "0.27.2", features = ["extension-module", "abi3-py39"], optional = true } [profile.release] strip = true lto = true codegen-units = 1 -panic = "abort" [[bin]] name = "typikon" @@ -28,3 +28,10 @@ path = "src/main.rs" [lib] name = "typikon" path = "src/lib.rs" +crate-type = ["cdylib", "rlib"] + +[features] +# Only maturin turns this on. pyo3's extension-module feature leaves the Python +# symbols to be resolved by the interpreter at load time, so a normal `cargo +# build` of the CLI would fail to link if it were on by default. +python = ["dep:pyo3"] diff --git a/README.md b/README.md index d3dd69c..40330c8 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,15 @@ $ 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. +For Python, there is a wheel: + +```console +$ pip install typikon +``` + +The menologion is compiled into that too, so it carries no data files and needs +no network either. + ## Which calendar? Three things get conflated constantly, and keeping them apart is most of what @@ -165,6 +174,37 @@ Level::Xerophagy.relaxed_to(Level::Fish, Level::WineOil) // WineOil: no saint Level::Fish.relaxed_to(Level::WineOil, Level::WineOil) // Fish: never tightens ``` +## From Python + +```python +import datetime, typikon + +day = typikon.describe(datetime.date(2026, 9, 18)) + +day.church_date # '5 September' +day.fast # 'xerophagy' +day.title # 'The Holy Prophet Zachariah, father of the Forerunner' +day.is_obligatory() # False + +typikon.today() +typikon.pascha(2027) # datetime.date(2027, 5, 2) +typikon.week(datetime.date(2026, 4, 12), 7) # seven consecutive days +typikon.describe(d, jurisdiction='rocor') +[j['key'] for j in typikon.jurisdictions()] +``` + +Dates in and out are `datetime.date`. `to_dict()` gives exactly the fields +`--json` gives, so anything already reading the CLI's output can read a `Day` +without changing shape — there is a test that holds the two to that. + +Given no `jurisdiction=`, the library reads the same configured default the +command line does. Calling the engine's own resolver instead would have quietly +answered for a different church than `typikon --set-default` selected, which is +the kind of disagreement nobody notices until a fast day is wrong. + +The wheel is abi3, so one build serves every Python from 3.9 up. It ships +`py.typed` and a stub file, and `mypy --strict` is clean against it. + ## The menologion The fixed calendar ships as data and is indexed by **ecclesiastical** date, so diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..eab46d5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,40 @@ +[build-system] +requires = ["maturin>=1.9,<2.0"] +build-backend = "maturin" + +[project] +name = "typikon" +description = "The Orthodox liturgical day: feasts, seasons and the fast, computed rather than fetched" +readme = "README.md" +license = "GPL-2.0-only" +license-files = ["LICENSE"] +requires-python = ">=3.9" +keywords = ["orthodox", "liturgical", "calendar", "byzantine", "pascha", "fasting"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Programming Language :: Rust", + "Programming Language :: Python :: Implementation :: CPython", + "Topic :: Religion", + "Topic :: Software Development :: Libraries", + "Typing :: Typed", +] +# maturin takes it from Cargo.toml, so there is one place to bump. +dynamic = ["version"] + +[project.urls] +Homepage = "https://git.pumpin.online/funkpower/typikon" +Repository = "https://git.pumpin.online/funkpower/typikon" + +[tool.maturin] +bindings = "pyo3" +module-name = "typikon._typikon" +# Mixed layout: the compiled module is private and python/typikon wraps it, which +# is what lets the wheel carry py.typed and a stub file. A bare extension module +# has nowhere to put either. +python-source = "python" +# The crate also builds a CLI, and the pyo3 dependency is optional so that +# `cargo build` of it does not try to link against a Python that is not there. +# This is the only place the feature gets turned on. +features = ["python"] +strip = true diff --git a/python/typikon/__init__.py b/python/typikon/__init__.py new file mode 100644 index 0000000..f92fd5b --- /dev/null +++ b/python/typikon/__init__.py @@ -0,0 +1,36 @@ +"""The Orthodox liturgical day: feasts, seasons and the fast, computed rather than fetched. + +Everything derives from the paschalion and the fixed calendar, so this needs no +network, cannot go stale, and answers for any year you ask about. + + >>> import typikon, datetime + >>> day = typikon.describe(datetime.date(2026, 4, 12)) + >>> day.feast + 'The Radiant and Life-giving Resurrection of our Lord — Holy Pascha' + >>> day.fast, day.days_to_pascha + ('none', 0) + +Called without a jurisdiction, every function uses the one `typikon +--set-default` wrote, so the library and the command line agree about the same +date. Pass ``jurisdiction=`` to override it; ``jurisdictions()`` lists the keys. +""" + +from ._typikon import ( + Day, + __version__, + describe, + jurisdictions, + pascha, + today, + week, +) + +__all__ = [ + "Day", + "__version__", + "describe", + "jurisdictions", + "pascha", + "today", + "week", +] diff --git a/python/typikon/_typikon.pyi b/python/typikon/_typikon.pyi new file mode 100644 index 0000000..b73f0a7 --- /dev/null +++ b/python/typikon/_typikon.pyi @@ -0,0 +1,64 @@ +import datetime +from typing import Any, Final + +__version__: Final[str] + +class Day: + """One day, fully reckoned. Immutable.""" + + @property + def date(self) -> datetime.date: ... + @property + def iso(self) -> str: ... + @property + def weekday(self) -> str: ... + @property + def jurisdiction(self) -> str: ... + @property + def calendar(self) -> str: ... + @property + def church_date(self) -> str: + """The ecclesiastical date, as "25 December".""" + @property + def season(self) -> str | None: ... + @property + def cycle(self) -> str | None: ... + @property + def feast(self) -> str | None: ... + @property + def rank(self) -> str | None: ... + @property + def title(self) -> str: + """The feast if there is one, else the principal commemoration.""" + @property + def commemorations(self) -> list[str]: ... + @property + def fast(self) -> str: ... + @property + def fast_label(self) -> str: ... + @property + def emoji(self) -> str: ... + @property + def reason(self) -> str: ... + @property + def obligation(self) -> str | None: + """``"obligatory"``, ``"customary"``, or ``None``.""" + @property + def binds(self) -> str: ... + @property + def pascha(self) -> datetime.date: ... + @property + def days_to_pascha(self) -> int: ... + @property + def julian_offset(self) -> int: ... + def is_obligatory(self) -> bool: ... + def to_dict(self) -> dict[str, Any]: + """The same fields as ``typikon --json``.""" + +def describe(date: datetime.date, jurisdiction: str | None = None) -> Day: ... +def today(jurisdiction: str | None = None) -> Day: ... +def week( + start: datetime.date, days: int = 7, jurisdiction: str | None = None +) -> list[Day]: ... +def pascha(year: int, jurisdiction: str | None = None) -> datetime.date: ... +def jurisdictions() -> list[dict[str, str]]: ... diff --git a/python/typikon/py.typed b/python/typikon/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/lib.rs b/src/lib.rs index 8d08a38..9f77a2d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,3 +9,6 @@ pub mod menologion; pub mod paschalion; pub mod rules; pub mod seasons; + +#[cfg(feature = "python")] +mod python; diff --git a/src/python.rs b/src/python.rs new file mode 100644 index 0000000..2da7694 --- /dev/null +++ b/src/python.rs @@ -0,0 +1,319 @@ +//! 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 { + 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> { + 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> { + 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 { + 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> { + 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> { + 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!( + "", + 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 { + 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 { + // 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::().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> { + 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> { + 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> { + 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::()?; + 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(()) +} diff --git a/tests/python/test_typikon.py b/tests/python/test_typikon.py new file mode 100644 index 0000000..057f0a7 --- /dev/null +++ b/tests/python/test_typikon.py @@ -0,0 +1,114 @@ +"""Tests for the Python bindings. + +The one that matters is `test_matches_cli`: the bindings and the binary have to +answer identically for the same date, because they are the same engine and any +divergence means the Python layer has quietly grown its own opinion. It caught a +real one — the bindings were resolving a built-in default jurisdiction while the +CLI read the configured one, so every field downstream of that differed. + + python -m pytest tests/python -q + +Needs the wheel installed (`maturin develop` or `pip install target/wheels/*.whl`) +and, for the parity test, `cargo build --release`. +""" + +import datetime +import json +import re +import subprocess +from pathlib import Path + +import pytest + +typikon = pytest.importorskip("typikon") + +BINARY = Path(__file__).resolve().parents[2] / "target" / "release" / "typikon" + +# Pascha, Nativity, Theophany and an ordinary weekday: the movable cycle, the +# fixed calendar, the julian offset and the plain case. +DATES = [ + datetime.date(2026, 4, 12), + datetime.date(2026, 12, 25), + datetime.date(2027, 1, 7), + datetime.date(2026, 3, 18), +] + + +def _snake(s: str) -> str: + return re.sub(r"(? None: + if not BINARY.exists(): + pytest.skip(f"{BINARY} not built") + out = subprocess.run( + [str(BINARY), "--json", "--date", date.isoformat()], + capture_output=True, text=True, check=True, + ).stdout + cli = {_snake(k): v for k, v in json.loads(out).items()} + assert typikon.describe(date).to_dict() == cli + + +def test_pascha_is_its_own_zero() -> None: + for year in (2024, 2025, 2026, 2030): + p = typikon.pascha(year) + assert typikon.describe(p).days_to_pascha == 0 + assert p.year == year + + +def test_week_length_and_order() -> None: + start = datetime.date(2026, 4, 10) + days = typikon.week(start, 5) + assert len(days) == 5 + assert [d.date for d in days] == [ + start + datetime.timedelta(days=i) for i in range(5) + ] + + +def test_week_rejects_nonsense() -> None: + with pytest.raises(ValueError): + typikon.week(datetime.date(2026, 4, 10), 0) + + +def test_unknown_jurisdiction_raises() -> None: + with pytest.raises(ValueError, match="unknown jurisdiction"): + typikon.describe(datetime.date(2026, 4, 12), jurisdiction="narnia") + + +def test_jurisdiction_actually_changes_the_answer() -> None: + # Old and new calendar disagree about which church date a civil date is, so + # on 25 December the new calendar keeps the Nativity and the old calendar is + # still thirteen days short of it. julian_offset is not the witness to reach + # for: it is the gap between the calendars, so it reads 13 either way. + old = typikon.describe(datetime.date(2026, 12, 25), jurisdiction="old") + new = typikon.describe(datetime.date(2026, 12, 25), jurisdiction="new") + assert old.church_date != new.church_date + assert old.title != new.title + assert "Nativity" in (new.feast or "") + + +def test_every_jurisdiction_resolves() -> None: + keys = [j["key"] for j in typikon.jurisdictions()] + assert keys, "no jurisdictions listed" + for key in keys: + assert typikon.describe(datetime.date(2026, 4, 12), jurisdiction=key) + + +def test_day_is_immutable() -> None: + day = typikon.describe(datetime.date(2026, 4, 12)) + with pytest.raises(AttributeError): + day.feast = "something else" # type: ignore[misc] + + +def test_accepts_datetime_as_well_as_date() -> None: + # to_date reads attributes rather than the datetime C API, so anything with + # year/month/day works. Worth pinning: it is a consequence of the abi3 build, + # not a decision, and it would be easy to lose by accident. + d = datetime.date(2026, 4, 12) + assert typikon.describe(datetime.datetime(2026, 4, 12, 13, 30)).to_dict() == \ + typikon.describe(d).to_dict() + + +def test_to_dict_is_json_serialisable() -> None: + assert json.dumps(typikon.describe(datetime.date(2026, 4, 12)).to_dict())