drop the terminal screenshot, and make the examples checkable

The screenshot was a picture of the Python CLI: it predated the formats, the
tradition in the header and --typikon, and a picture cannot be diffed against
the program anyway. Every example is now a console block of real output.

tests/readme-examples.py runs each one and diffs it, which found four things
wrong in the process of writing them: the hero example printed today rather
than a fixed date, so it was wrong for any reader on any other day; the
Ruthenian example depended on whatever the reader's config remembered; a
tradition block held two commands and was checking neither; and the JSON had
a hand-collapsed array that the program does not emit.

It also found a real bug. --typikon could be turned on by the config file and
there was no way to turn it off again from the command line, so
on this machine showed the Typikon's discipline with no way to ask for the
law. --no-typikon now does that.

The Rust example is a test rather than prose, for the same reason.
This commit is contained in:
funkpower
2026-09-18 12:44:09 -04:00
parent 3e3b585b2a
commit 47649983f4
5 changed files with 258 additions and 17 deletions
+131 -15
View File
@@ -7,12 +7,11 @@ Everything derives from the paschalion and the fixed calendar, so it needs no
network, cannot go stale, and answers for any year you ask about. No API key,
no service that might be gone next year, no cache to invalidate.
![typikon on the command line](docs/cli.png)
```console
$ typikon
$ typikon --jurisdiction rocor --date 2026-03-18
Wednesday 18 March 2026 — 5 March in the church calendar
Russian Orthodox Church Outside Russia · the Typikon
Russian Orthodox Church Outside Russia · the Typikon · slavic usage
🥬 The Holy Martyr Conon
@@ -21,6 +20,22 @@ $ typikon
Great Fast
```
A week at a time, which is what you want the day before a fast begins:
```console
$ typikon --jurisdiction antiochian --date 2027-04-26 --days 7
Antiochian Orthodox Church · the Typikon
🥬 Mon 26 Apr xerophagy — no oil or wine The Holy Hieromartyr Basil, Bishop…
🥬 Tue 27 Apr xerophagy — no oil or wine The Holy Hieromartyr Symeon, kinsman…
🥬 Wed 28 Apr xerophagy — no oil or wine The Holy Apostles Jason and Sosipater
🫒 Thu 29 Apr wine and oil permitted Great and Holy Thursday
⛔ Fri 30 Apr no food until evening Great and Holy Friday
🥬 Sat 1 May xerophagy — no oil or wine Great and Holy Saturday
🍖 Sun 2 May no fast The Radiant and Life-giving…
```
## Install
One static binary, no runtime to install first.
@@ -81,6 +96,21 @@ Every jurisdiction carries the usage its books follow. `--tradition slavic` or
`--tradition greek` overrides it, which is what you want for a parish that does
not match its jurisdiction's default.
```console
$ typikon -j goarch --date 2026-10-01 --format tsv --fields rank,sign,fast,title
rank sign fast title
vigil ✚ none The Protection of our Most Holy Lady the Theotokos
```
```console
$ typikon -j goarch --date 2026-10-01 --tradition slavic --format tsv --fields rank,sign,fast,title
rank sign fast title
great ⊕ none The Protection of our Most Holy Lady the Theotokos
```
The rank is not decoration: it is what lifts a Wednesday or Friday fast, so the
usage changes what you eat as well as what you sing.
### Which calendar?
Pick a reckoning without naming a church:
@@ -151,18 +181,43 @@ For an Eastern Catholic church the two come apart sharply, and reporting only
the law makes the Great Fast look like four days of abstinence a year. That is
what binds; it is not what is kept.
What the law requires, which is the first four days of the Great Fast:
```console
$ typikon -j ruthenian --typikon
$ typikon -j ruthenian --no-typikon --days 4 --date 2026-02-16
Byzantine Catholic Metropolia of Pittsburgh · Ruthenian particular law
🥬 Mon 16 Feb* no meat, fish, eggs or dairy Clean Monday — the first day of the…
🥬 Tue 17 Feb no meat, fish, eggs or dairy The Holy Great Martyr Theodore the…
🥬 Wed 18 Feb* no meat, fish, eggs or dairy Our Holy Father Leo, Pope of Rome (461)
🥬 Thu 19 Feb no meat, fish, eggs or dairy The Holy Apostle Archippus
* obligatory. Everything unmarked is customary and does not bind.
```
shows the day at the Typikon's full discipline while still reporting what your
own church's law obliges, and still marking it. A week of the Great Fast under
Ruthenian law is seven days of "no meat, fish, eggs or dairy" with three of
them starred; the same week at the Typikon's discipline runs from a total fast
on Clean Monday through xerophagy, wine and oil, and a saint's relaxation, with
the same three days starred. Nothing about the obligation changes.
And what is kept, with `--typikon`:
It is a no-op for a jurisdiction already reckoning by the Typikon.
```console
$ typikon -j ruthenian --typikon --days 4 --date 2026-02-16
Byzantine Catholic Metropolia of Pittsburgh · the Typikon, obliging under Ruthenian particular law
⛔ Mon 16 Feb* no food until evening Clean Monday — the first day of the…
🫒 Tue 17 Feb wine and oil permitted The Holy Great Martyr Theodore the…
🥬 Wed 18 Feb* xerophagy — no oil or wine Our Holy Father Leo, Pope of Rome (461)
🥬 Thu 19 Feb xerophagy — no oil or wine The Holy Apostle Archippus
* obligatory. Everything unmarked is customary and does not bind.
```
The same three days are starred in both. That is the point: the flag changes
what is *kept*, not what *binds*. Tuesday relaxes to wine and oil because St
Theodore the Recruit is of polyeleos rank, which is the menologion doing its
work.
`--no-typikon` forces the law back, for when the config remembers otherwise.
Either way it is a no-op for a jurisdiction already reckoning by the Typikon.
Where usages differ (Great Thursday, the Exaltation, Lazarus Saturday), this
follows the common published rule and names it in the day's reason so you can
@@ -175,10 +230,39 @@ spiritual father can.
```console
$ typikon --days 7 # for a person
$ typikon --format json --days 7 # for a bar, a widget, a script
$ typikon --format csv --days 365 # for a spreadsheet
$ typikon --format tsv --fields date,fast,title
$ typikon --format ics --days 365 > orthodox.ics
$ typikon --year 2027 # a year's key dates, to check against yours
```
Delimited output, for a spreadsheet or a pipeline. RFC 4180 quoting, one header
row, one row per day:
```console
$ typikon -j rocor --date 2026-03-16 --days 3 --format csv \
--fields date,church_date,fast,title
date,church_date,fast,title
2026-03-16,3 March,xerophagy,"The Holy Martyrs Eutropius, Cleonicus and Basiliscus"
2026-03-17,4 March,xerophagy,Our Venerable Father Gerasimus of the Jordan
2026-03-18,5 March,xerophagy,The Holy Martyr Conon
```
A year's key dates, to check against the one your parish prints:
```console
$ typikon --year 2027 -j goarch
2027 · Greek Orthodox Archdiocese of America
publican pharisee 2027-02-21 Sun
meatfare 2027-03-07 Sun
cheesefare 2027-03-14 Sun
clean monday 2027-03-15 Mon
great fast end 2027-04-23 Fri
lazarus saturday 2027-04-24 Sat
palm sunday 2027-04-25 Sun
great friday 2027-04-30 Fri
pascha 2027-05-02 Sun
…
```
Every day of vigil rank or above carries the mark its books print beside it:
@@ -194,6 +278,38 @@ The JSON shape is flat and stable. Front ends are the thing most likely to be
written by someone else, and a schema that shifts underneath them is a bad
neighbour.
```console
$ typikon -j rocor --date 2026-03-18 --json --indent 2
{
"date": "2026-03-18",
"weekday": "Wednesday",
"jurisdiction": "rocor",
"calendar": "old",
"churchDate": "5 March",
"season": "Great Fast",
"cycle": null,
"feast": null,
"rank": null,
"sign": null,
"tradition": "slavic",
"title": "The Holy Martyr Conon",
"commemorations": [
"The Holy Martyr Conon"
],
"fast": "xerophagy",
"fastLabel": "xerophagy — no oil or wine",
"emoji": "🥬",
"reason": "a weekday of the Great Fast",
"obligation": "customary",
"binds": "none",
"pascha": "2026-04-12",
"daysToPascha": 25,
"julianOffset": 13
}
```
One day is an object; several are `{"days": [...]}`.
## As a library
```rust
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

+13 -2
View File
@@ -39,9 +39,14 @@ struct Args {
/// Show the day at the Typikon's full discipline rather than only what
/// your church's particular law requires. Obligations are still reported.
#[arg(long)]
#[arg(long, conflicts_with = "no_typikon")]
typikon: bool,
/// Show only what your church's particular law requires, overriding a
/// `typikon = true` remembered in the config.
#[arg(long)]
no_typikon: bool,
/// YYYY-MM-DD (default: today)
#[arg(short, long)]
date: Option<String>,
@@ -345,7 +350,13 @@ fn run() -> Result<i32, String> {
Some(t) => jurisdictions::Tradition::parse(t)
.ok_or_else(|| format!("unknown tradition {t:?}; try slavic or greek"))?,
};
let discipline = if args.typikon || saved.typikon {
// A flag either way beats the file; with neither, the file decides.
let typikon = if args.no_typikon {
false
} else {
args.typikon || saved.typikon
};
let discipline = if typikon {
Some(jurisdictions::RuleKind::Orthodox)
} else {
base.discipline
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Run every console example in the README and diff it against the real output.
A README whose examples have quietly stopped being true is worse than one with
no examples in it, and the output of this program changes shape often enough
that hand-checking does not hold. Every ```console block whose single command
is `typikon ...` is executed and compared line for line.
tests/readme-examples.py # against ./target/release/typikon
TYPIKON=/usr/bin/typikon tests/readme-examples.py
A block ending in a horizontal ellipsis is treated as a prefix, for listings
too long to paste whole.
"""
import difflib
import os
import re
import shlex
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
BINARY = os.environ.get("TYPIKON") or str(ROOT / "target" / "release" / "typikon")
def commands_and_body(block):
"""Split a console block into its commands and its expected output,
joining backslash continuations."""
out, buf = [], ""
for line in block.rstrip("\n").split("\n"):
if buf:
buf += " " + line.strip().rstrip("\\ ")
if not line.rstrip().endswith("\\"):
out.append("$ " + buf)
buf = ""
continue
if line.startswith("$ ") and line.rstrip().endswith("\\"):
buf = line[2:].rstrip("\\ ")
else:
out.append(line)
if buf:
out.append("$ " + buf)
cmds = [l[2:] for l in out if l.startswith("$ ")]
body = "\n".join(l for l in out if not l.startswith("$ ")).strip("\n")
return cmds, body
def main():
if not Path(BINARY).exists():
print(f" {BINARY} not built; run cargo build --release", file=sys.stderr)
return 77
env = dict(os.environ, COLUMNS="88")
# The examples must not depend on whatever this machine remembers.
env.pop("TYPIKON_JURISDICTION", None)
env["XDG_CONFIG_HOME"] = str(ROOT / "target" / "readme-config")
text = (ROOT / "README.md").read_text(encoding="utf-8")
checked = bad = skipped = 0
for block in re.findall(r"```console\n(.*?)```", text, re.S):
cmds, expected = commands_and_body(block)
if len(cmds) != 1 or not cmds[0].startswith("typikon") or not expected:
skipped += 1
continue
argv = [BINARY] + shlex.split(cmds[0])[1:]
got = subprocess.run(argv, capture_output=True, text=True, env=env).stdout.strip("\n")
checked += 1
if expected.rstrip().endswith("…"):
ok = got.startswith(expected.rstrip()[:-1].rstrip("\n"))
else:
ok = got == expected
if not ok:
bad += 1
print(f" MISMATCH {cmds[0]}")
for line in difflib.unified_diff(
expected.split("\n"), got.split("\n"), "README", "actual", lineterm=""
):
print(" ", line)
print(f"\n {checked} README examples run, {bad} mismatched, {skipped} not executable")
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
+30
View File
@@ -0,0 +1,30 @@
//! The README's Rust example, compiled and asserted, so it cannot quietly
//! stop being true. The prose version elides the error handling; this is the
//! same calls with the results checked.
use typikon::calendars::Date;
use typikon::day::describe;
use typikon::fasting::Level;
use typikon::jurisdictions::resolve;
#[test]
fn the_readme_library_example_is_true() {
let rocor = resolve(Some("rocor")).unwrap();
let day = describe(Date::from_ymd(2026, 9, 18).unwrap(), rocor);
assert_eq!(day.church_date, "5 September");
assert_eq!(day.fast, Level::Xerophagy);
assert_eq!(day.title(), "The Holy Prophet Zachariah, father of the Forerunner");
assert!(!day.is_obligatory());
assert_eq!(typikon::paschalion::julian_pascha(2027).iso(), "2027-05-02");
assert_eq!(typikon::paschalion::gregorian_pascha(2027).iso(), "2027-03-28");
}
#[test]
fn the_readme_level_example_is_true() {
// No saint brings fish into Lent.
assert_eq!(Level::Xerophagy.relaxed_to(Level::Fish, Level::WineOil), Level::WineOil);
// And relaxing never tightens.
assert_eq!(Level::Fish.relaxed_to(Level::WineOil, Level::WineOil), Level::Fish);
}