mirror of
https://git.pumpin.online/funkpower/typikon.git
synced 2026-10-22 18:57:13 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user