mirror of
https://git.pumpin.online/funkpower/typikon.git
synced 2026-10-22 18:57:13 +00:00
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:
@@ -2,3 +2,7 @@
|
||||
**/*.rs.bk
|
||||
.venv/
|
||||
*.egg-info/
|
||||
|
||||
# python build and test artefacts
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
Generated
+135
-2
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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]]: ...
|
||||
@@ -9,3 +9,6 @@ pub mod menologion;
|
||||
pub mod paschalion;
|
||||
pub mod rules;
|
||||
pub mod seasons;
|
||||
|
||||
#[cfg(feature = "python")]
|
||||
mod python;
|
||||
|
||||
+319
@@ -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(())
|
||||
}
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user