a jurisdiction is not just a key any more

describe() in Python was answering a different fast than the same date on the
command line: xerophagy on a Wednesday of the Great Fast read as wine_oil. Not a
cosmetic difference in a tool whose whole job is to say what may be eaten.

The cause is that Jurisdiction grew a tradition and a discipline, and config
carries all three — main.rs reads the saved jurisdiction, then the saved
tradition, then whether the Typikon's full discipline was asked for, and builds
an overridden Jurisdiction from the lot. The bindings resolved the key and
stopped, so everything the other two decide was silently dropped.

They now take the same three steps, and tradition= and typikon= are arguments, so
the library can say what the command line can. Pascha does not take them: it
follows the paschalion alone and neither can move it.

The overrides are interned rather than leaked per call. main.rs leaks one per run
and exits; a library gets called in a loop, and a leak per call adds up. The
combinations are bounded by jurisdictions x traditions x discipline, so the map
cannot grow past that.

The parity test spread widens to seven dates across both fasts and both
calendars, and clippy now runs under the feature as well — the binding code only
exists there, so a bare clippy run had never looked at it.
This commit is contained in:
funkpower
2026-09-18 12:33:56 -04:00
parent 78c69e5979
commit 3e3b585b2a
4 changed files with 125 additions and 32 deletions
+3
View File
@@ -41,6 +41,9 @@ jobs:
run: |
rustup component add clippy
cargo clippy --all-targets -- -D warnings
# The binding code only exists under the feature, so a bare clippy
# run never looks at src/python.rs.
cargo clippy --features python --all-targets -- -D warnings
- name: Rust tests
run: cargo test
+16 -3
View File
@@ -61,10 +61,23 @@ class Day:
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 describe(
date: datetime.date,
jurisdiction: str | None = None,
tradition: str | None = None,
typikon: bool | None = None,
) -> Day: ...
def today(
jurisdiction: str | None = None,
tradition: str | None = None,
typikon: bool | None = None,
) -> Day: ...
def week(
start: datetime.date, days: int = 7, jurisdiction: str | None = None
start: datetime.date,
days: int = 7,
jurisdiction: str | None = None,
tradition: str | None = None,
typikon: bool | None = None,
) -> list[Day]: ...
def pascha(year: int, jurisdiction: str | None = None) -> datetime.date: ...
def jurisdictions() -> list[dict[str, str]]: ...
+80 -25
View File
@@ -14,6 +14,9 @@ use pyo3::exceptions::{PyValueError, PyOSError};
use pyo3::prelude::*;
use pyo3::types::{PyDate, PyDict, PyList};
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use crate::calendars::Date;
use crate::day;
use crate::fasting::Obligation;
@@ -41,24 +44,62 @@ fn from_date<'py>(py: Python<'py>, d: Date) -> PyResult<Bound<'py, PyDate>> {
PyDate::new(py, y, m as u8, day as u8)
}
/// Resolve a jurisdiction name, or the configured default when given none.
/// 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.
///
/// 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()
}
/// 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();
let name = jurisdiction.map(str::to_owned).or(saved.jurisdiction);
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"))
})?,
};
jurisdictions::resolve(name)
.map_err(|n| PyValueError::new_err(format!("unknown jurisdiction {n:?}")))
let full = typikon.unwrap_or(saved.typikon);
let discipline = if full {
Some(jurisdictions::RuleKind::Orthodox)
} else {
base.discipline
};
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 }))
}))
}
/// One day, fully reckoned.
@@ -250,16 +291,26 @@ impl PyDay {
/// Reckon one civil date.
#[pyfunction]
#[pyo3(signature = (date, jurisdiction = None))]
fn describe(date: &Bound<'_, PyDate>, jurisdiction: Option<&str>) -> PyResult<PyDay> {
let jur = resolve(jurisdiction)?;
#[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)?;
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> {
#[pyo3(signature = (jurisdiction = None, tradition = None, typikon = None))]
fn today(
py: Python<'_>,
jurisdiction: Option<&str>,
tradition: Option<&str>,
typikon: Option<bool>,
) -> 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.
@@ -270,21 +321,23 @@ fn today(py: Python<'_>, jurisdiction: Option<&str>) -> PyResult<PyDay> {
let date = date.cast::<PyDate>().map_err(|_| {
PyOSError::new_err("datetime.date.today() did not return a date")
})?;
describe(date, jurisdiction)
describe(date, jurisdiction, tradition, typikon)
}
/// Reckon a run of consecutive days starting at `start`.
#[pyfunction]
#[pyo3(signature = (start, days = 7, jurisdiction = None))]
#[pyo3(signature = (start, days = 7, jurisdiction = None, tradition = None, typikon = None))]
fn week(
start: &Bound<'_, PyDate>,
days: i32,
jurisdiction: Option<&str>,
tradition: Option<&str>,
typikon: Option<bool>,
) -> PyResult<Vec<PyDay>> {
if days < 1 {
return Err(PyValueError::new_err("days must be at least 1"));
}
let jur = resolve(jurisdiction)?;
let jur = resolve(jurisdiction, tradition, typikon)?;
Ok(day::week(to_date(start)?, days, jur)
.into_iter()
.map(|inner| PyDay { inner })
@@ -299,7 +352,9 @@ fn pascha<'py>(
year: i32,
jurisdiction: Option<&str>,
) -> PyResult<Bound<'py, PyDate>> {
let jur = resolve(jurisdiction)?;
// Pascha depends on the paschalion alone; tradition and discipline cannot
// move it, so they are not arguments here.
let jur = resolve(jurisdiction, None, None)?;
let (movables, _) = day::year_of(year, jur);
from_date(py, movables.pascha)
}
+26 -4
View File
@@ -27,10 +27,13 @@ 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),
datetime.date(2026, 4, 12), # Pascha
datetime.date(2026, 12, 25), # Nativity
datetime.date(2027, 1, 7), # Theophany on the old calendar
datetime.date(2026, 3, 18), # a Wednesday of the Great Fast
datetime.date(2026, 11, 20), # the Nativity Fast
datetime.date(2026, 8, 6), # Transfiguration, fish permitted
datetime.date(2026, 6, 10), # an ordinary weekday
]
@@ -71,6 +74,25 @@ def test_week_rejects_nonsense() -> None:
typikon.week(datetime.date(2026, 4, 10), 0)
def test_tradition_and_typikon_reach_the_answer() -> None:
# A Jurisdiction is not just a key: it carries a tradition and a discipline,
# and config supplies all three. The bindings resolved only the key once, and
# reported wine_oil where the CLI said xerophagy on a Lenten Wednesday — the
# tool's whole job, answered differently in the two front ends.
lent = datetime.date(2026, 3, 18)
assert typikon.describe(lent, tradition="greek").tradition == "greek"
assert typikon.describe(lent, tradition="slavic").tradition == "slavic"
# The Typikon's full discipline can only tighten a relaxed rule, never relax it.
strict = typikon.describe(lent, jurisdiction="ruthenian", typikon=True)
normal = typikon.describe(lent, jurisdiction="ruthenian", typikon=False)
assert (strict.fast, strict.fast_label) != (normal.fast, normal.fast_label)
def test_unknown_tradition_raises() -> None:
with pytest.raises(ValueError, match="unknown tradition"):
typikon.describe(datetime.date(2026, 4, 12), tradition="martian")
def test_unknown_jurisdiction_raises() -> None:
with pytest.raises(ValueError, match="unknown jurisdiction"):
typikon.describe(datetime.date(2026, 4, 12), jurisdiction="narnia")