answer from Python too

The engine is the same; only the front door is new. pyo3 behind an optional
feature, maturin for the wheel, and the bindings mirror DayJson rather than Day
because DayJson is already the shape front ends read and the one that has to stay
still. to_dict() therefore gives exactly what --json gives, and a test holds the
two to that.

That test earned its place immediately. The bindings were calling
jurisdictions::resolve(None), which answers with a built-in default that knows
nothing about the machine, so Python was reckoning by the New Calendar while the
command line on the same box reckoned by ROCOR — every field downstream of the
calendar differed, including the fast. They now take the same two steps main.rs
takes, and agree field for field on Pascha, the Nativity and Theophany.

The wheel is abi3, so one build serves 3.9 upward rather than one per
interpreter. That costs the datetime C API, which the limited API does not
expose, so a date is read through its attributes instead — which incidentally
makes datetime.datetime work as well as datetime.date. Constructing dates is
unaffected, pyo3 calls Python's own constructor for that.

panic = "abort" comes out of the release profile. An extension module has to be
able to unwind a panic into a Python exception; aborting would take the
interpreter down with it. The CLI keeps the same behaviour it always had, a
panic there just unwinds now.

Mixed layout so the wheel can carry py.typed and a stub file, which a bare
extension module has nowhere to put. mypy --strict is clean against the stubs
and catches a deliberate error, so the Typing :: Typed classifier is not a claim
about nothing.
This commit is contained in:
funkpower
2026-09-18 11:28:08 -04:00
parent 227abac6f6
commit 5d7e02cffb
11 changed files with 763 additions and 3 deletions
+4
View File
@@ -2,3 +2,7 @@
**/*.rs.bk
.venv/
*.egg-info/
# python build and test artefacts
__pycache__/
*.py[cod]
Generated
+135 -2
View File
@@ -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"
+8 -1
View File
@@ -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"]
+40
View File
@@ -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
+40
View File
@@ -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
+36
View File
@@ -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",
]
+64
View File
@@ -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]]: ...
View File
+3
View File
@@ -9,3 +9,6 @@ pub mod menologion;
pub mod paschalion;
pub mod rules;
pub mod seasons;
#[cfg(feature = "python")]
mod python;
+319
View File
@@ -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<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(())
}
+114
View File
@@ -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"(?<!^)(?=[A-Z])", "_", s).lower()
@pytest.mark.parametrize("date", DATES, ids=str)
def test_matches_cli(date: datetime.date) -> 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())