Nine Sundays went unnamed in every jurisdiction. Three were missing from the
movable cycle outright: the Paralytic, the Samaritan Woman and the Blind Man,
so the fourth, fifth and sixth Sundays of Pascha reported only the saint of the
fixed date. Six more are reckoned from a fixed feast rather than from Pascha —
before and after the Exaltation, the Forefathers, before and after the
Nativity, before and after the Theophany — and were not modelled at all.
The three Pentecostarion Sundays rank with Thomas Sunday and the Myrrh-bearing
Women, so a vigil-rank saint still keeps the day and the Sunday is commemorated
on it. The six fixed-cycle ones never name a day: they are found in
ecclesiastical space, so the old calendar and the new get them on the right
morning, and they join the commemorations. Where a short window holds no Sunday
the Typikon says what to do and this follows it; where the books disagree, the
day goes unmarked rather than carrying an answer this calendar cannot defend.
`week` says where a day falls in the count from Pentecost, which is how the
books head the long ordinary stretch. It runs on across the new year and stops
at the Triodion, whose Sundays have names instead of numbers.
`--every-day` gives ics an event for every day, ferias included. The default
still thins the calendar, which is right for a subscription and wrong for a
reader who wants the saint of each morning; and the description now leads with
the commemorations rather than burying them under the church date.
Checked against 1.1.2 over every jurisdiction from 2015 to 2035: rank, feast
and title move on the three new Sundays and nowhere else. No fast, obligation,
binding or season changes on any day.
1.1.1 changed the description in Cargo.toml and in the npm package and
missed pyproject.toml, which is the one maturin publishes. PyPI took the
old summary and neither registry lets that be edited afterwards, so the
correction needs a version of its own.
The two READMEs had drifted further than the description fields. A
registry page leads with the README rather than the summary, and npm's
opened by saying the package needs no network without ever saying it
covers the Byzantine Catholic churches — which is most of what the
sentence was changed to convey.
Five files now carry it, and a test reads all five and names the ones
that disagree. It was written against the mistake above and fails on it.
The README stops explaining how the project tests and releases itself.
Someone arriving at it wants to know what a day of the Great Fast
permits, not how the wheels get built; the parity suite and the release
workflow are in docs/, where the person who needs them will look.
The published description now says how many churches are covered, on
both registries. That is a number in two files nobody editing the
jurisdiction table will open, so a test pins it and names them.
There is one engine and nine doors into it, and nothing until now checked
that the doors agree. CI tested each binding against itself: the Python
wheel imported, the C demo compiled, the npm package type-checked. None
of that would have caught a field that crosses one boundary wrong, which
is the failure this library is most exposed to, because a wrong answer
about a fast day looks exactly like a right one.
So: twelve days, chosen where the bindings could plausibly differ, and
all twenty-two fields compared against what the command line says. The
days are Pascha itself, the Nativity on both calendars, a Wednesday of
the Great Fast under Byzantine Catholic law and again at the Typikon, a
weekday of the Nativity Fast where Greek and Slavic usage part, the one
Orthodox church on the Gregorian Paschalion, and a day with no feast.
A release now runs what a push runs. It had been gated on cargo test
alone, so the C ABI, the Lua binding, the Python parity check and the
README examples were checked on the branch that led to a release and not
on the release. Neither registry will reuse a version, so the gate
belongs before the upload.
Writing it found three things. The npm exports map did not expose the
wasm by package path, so a bundler asked for typikon/web/typikon_bg.wasm
was told no such path exists and had to reach into node_modules; the
documented init() call was never affected. The suite could hang instead
of fail, because the test web server was backgrounded inside a subshell
and killed by job spec, leaving bash in do_wait forever. And pointing
XDG_CONFIG_HOME at a path that does not exist isolates typikon and
breaks every other program that keeps state under it, a browser included.
A surface whose toolchain is missing is named as skipped, and fewer than
three surfaces running is a failure rather than a pass, so a container
with nothing installed cannot come out green.
WebAssembly rather than a native Node addon: one artifact serves Node,
Deno, Bun, a browser and anything built on them, where napi would have
meant a prebuilt binary per platform and a source build for whoever the
matrix missed. The engine is pure computation and the menologion is
already compiled in, so there is nothing for it to give up.
A day comes back as a plain object, not a handle. A wasm class would
have meant a .free() that every caller eventually forgets, and this way
JSON.stringify works and the thing spreads into state. Two serializer
defaults had to be overridden to keep the promise that it is the same
document --json emits: maps as objects, and an absent field as null
rather than undefined, which stringify would otherwise drop entirely.
The TypeScript is hand-written, because the generated types for a value
crossing as JsValue are `any`, and a binding that types its whole
return as `any` is a binding TypeScript cannot check. tsc --strict is a release
gate now, the way mypy --strict already is, and the check includes three
lines that must fail to compile.
The one place this differs from every other binding: no remembered
default jurisdiction, because a browser has nowhere to keep one. It is
called out in the README, in the types and in docs/versioning.md.
The test resolves typikon through a staged node_modules rather than a
relative path, so what it exercises is the exports map, and it compares
a year of days field by field against the command line for four
jurisdictions.
Every desktop shell that reads typikon today runs the binary and parses
its JSON. That costs about 1.5ms and is the right answer for a calendar
that changes once a day, but it is the wrong shape for anything with an
FFI and no business spawning a process on every redraw.
Dates cross as Julian Day Numbers, so no struct layout is part of the
contract. A day is opaque and owns its strings, built once when it is
reckoned: an accessor returns the same borrowed pointer every time, so a
statusline polling one in a loop allocates nothing. The accessors carry
every field of the JSON, so nothing on the other side needs a parser.
The Python bindings and this were resolving a jurisdiction by two copies
of the same logic, and the copy here leaked one per call. Both now share
jurisdictions::resolve_full, which interns the combinations instead.
The header is a promise made in a second file, and a second file drifts:
a test reads both and fails if either has a symbol the other does not,
and does the same for the LuaJIT cdef, where a stale declaration is a
segfault rather than a compile error.
The checker isolated XDG_CONFIG_HOME and not XDG_DATA_HOME, so the two
Ruthenian examples were verified against the MCI menaion this machine has
fetched and CI does not. They passed here and failed there, which is the
checker working: a reader running them after a fresh install gets the common
menologion, and that is what the README now shows.
Both examples still make their point. The commemorations are worded
differently; the fast, the obligations and the contrast between them are
identical either way.
Two adversarial reviews of the release. Both said do not ship, and they were
right twice over.
The seven-day view panicked. elide() cut a title at cut.len() / 2, a byte
index into a string it had counted in characters, and every movable feast
title carries an em dash: at fourteen terminal widths, 80 among them, it
aborted with exit 101 on the README's own headline command. tests/elide.rs now
cuts every title the program can print at every width it can be asked to print
at.
The changelog's first Calendar entry was false. It said a vigil or polyeleos
saint reaches fish in the three lesser fasts; the code gives wine and oil to
every rank but great, and caps the Dormition Fast at wine and oil whatever the
rank. A reader in the Dormition Fast was told this release let a saint bring
fish. docs/versioning.md makes that section a contract, so a wrong entry there
is worse than a wrong comment.
The Python bindings read the config file where the CLI reads the environment
first, so TYPIKON_JURISDICTION moved one and not the other and the two
answered for different churches on the same machine. CI now asserts they
agree, and runs the README example checker, which was standing between the
docs and reality with nothing running it.
Three changes to the surface, made now because after 1.0.0 they are a major
version: binds is null rather than the string none where a church legislates
no binding fast at all; iCalendar UIDs carry the tradition and the discipline,
which two feeds of the same day were sharing while saying different things;
and Date's Julian Day Number is behind an accessor rather than a public field.
fastLabel, emoji and reason are documented as presentation. The fields are
stable, the wording is not, and freezing three human-facing strings for a
major version was not the intent.
An adversarial review of the release, and four of its findings were things
PyPI never lets you replace once uploaded.
- rust-version said 1.74. clap has needed 1.85 since 4.6, and cargo
hard-errors rather than warning, so the sdist advertised a floor it could
not build on. docs/releasing.md sends macOS and Windows users to that
sdist.
- The wheel was classified Development Status :: 4 - Beta on a release the
changelog calls stable, with none of the Python version classifiers PyPI
indexes on.
- Six README links were relative. PyPI resolves them against the project
page, so every one 404'd there, as they do on the live 0.2.0 page now.
- in that order packs the wheel into the
sdist. The tarball on disk was 431KB with an x86-64 binary inside it.
Also: twine upload gains --skip-existing, so a retry after a partial upload
finishes rather than dying on a version that cannot be reused; twine check
gains the renderer that makes it parse the description at all, without which
it returns None and reports PASSED; and the README no longer claims a
twenty-year sweep the test did not run, because 366 * 21 / 4 is five years.
The test now runs the twenty-one it was named for.
The crates.io name belongs to an unrelated project, which the README now says
rather than leaving a reader to find out from cargo.
Nobody depends on this yet, which makes now the moment to commit to a surface
rather than later when someone does.
docs/versioning.md says what that surface is. A calendar has a wider one than
a library usually does: the JSON shape, the fasting level names and their
order, the jurisdiction keys, the command line, and the two language APIs are
all things a caller can reasonably build on, and only one of them is a
function signature. The text output, the reason strings and the menologion's
wording are explicitly not.
It also names the category semver has no word for. A correction to a fast rule
or a feast rank breaks no interface, so it is a patch; but it changes what the
program tells someone to eat. Those are called out in the changelog under
Calendar whatever the version does, and CHANGELOG.md starts by listing every
one of them against pre-1.0 behaviour.
daysToPascha is documented as signed, counting to the Pascha of that day's own
civil year, which it always was and nothing said.
docs/calendars.md: the three independent questions a reckoning answers, how
the 1923 split came about and the three changes since that catch out a printed
reference, which churches keep what, and where the line falls between the
Byzantine rite and the nine Eastern Catholic churches that do not use it.
The README and --list lose the prose that had grown around the same subject.
Ten of the fourteen Byzantine-rite Eastern Catholic churches are absent. The
honest reason is that their calendars could not be verified: general
references do not record whether the Slovak, Hungarian, Krizevci,
Italo-Albanian, Greek, Bulgarian, Macedonian, Belarusian, Russian or Albanian
churches keep Pascha with Rome or with the local Orthodox, and the churches'
own sites render their liturgical calendars in JavaScript. Inferring it from
geography would put an unsourced claim in the one place this program exists to
be right about.
Nobody needs an entry to get a correct calendar, though, and both --list and
the README now say so: one question to a parish picks one of the three
reckonings, which between them cover every combination there is.
The count in the README and in the forge description said twenty-three and
then twenty-four, in both cases counting the Byzantine Catholic churches once
inside the total and once alongside it. ACROD had also been added without the
sentence noticing.
Three Eastern Catholic jurisdictions borrowed the Ruthenian ruleset, so a
Melkite, a Romanian Greek Catholic and a Ukrainian Greek Catholic were each
told that fourteen days a year bound them under the particular law of the
Metropolia of Pittsburgh. That is the exact error the level/obligation split
exists to prevent, committed by the library itself.
Every Eastern Catholic church legislates its own fasting discipline. Those
three are now shown at the Typikon with nothing reported as binding, which is
the honest position: the calendar is known, the law is not. Their entries say
so and a test holds it, asserting both that no jurisdiction but ruthenian
carries that ruleset and that the three claim no binding days.
The README gains a section on these churches, since supporting them was never
meant to be something a reader had to infer from the jurisdiction list.
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.
The config file held one key while the program grew three axes, so
--set-default recorded the jurisdiction and silently dropped the discipline
and the usage. On this machine that produced exactly the failure it sounds
like: a terminal reckoning by ROCOR beside a desktop widget reckoning by the
Ruthenian calendar, both correct, neither agreeing.
--set-default now writes whatever was given alongside it, and an ordinary run
reads all three back with flags still winning over the file.
For an Eastern Catholic church these are sharply different questions and the
library was collapsing them. Ruthenian particular law binds on four kinds of
day in the year; the tradition those parishes actually keep is the Byzantine
fast entire. Reporting only the law made a week of the Great Fast read as
'no meat, fish, eggs or dairy' seven times over, which is the legal floor and
not the fast.
--typikon shows the day at the Typikon's discipline while the obligation
still comes from the jurisdiction's own law, because that is the part only it
can say. The Ruthenian rule now answers 'what binds here' separately from
'how is this day kept', which is what makes the two composable at all.
Three tests hold the seam: exactly the same days bind whichever discipline is
displayed, the binding minimum is never stricter than the level shown, and
the flag is a no-op for a jurisdiction already on the Typikon.
Tag-driven releases. main is always releasable, a vX.Y.Z tag on it builds the
sdist and the manylinux wheels and publishes them; Cargo.toml carries the version
and the first job refuses to go on if the tag disagrees with it, because PyPI
will not let a version be reused even after it is yanked.
CI fires on push and on nothing else. This forge is public and takes pull
requests from strangers, and a runner executes whatever the workflow checks out,
so a pull_request trigger would run unread code from a stranger on the runner
host. Push-only means the runner only ever runs what someone with push rights put
there. A pull request therefore gets no automatic check: read it, push the branch,
let it run then.
No cargo fmt gate. The tables here are aligned by hand and rustfmt wants one entry
per line, which loses the shape that makes MONTHS and JURISDICTIONS readable — a
gate that fights the house style is a gate that gets ignored. Clippy stays, and
passes.
The Python job builds the bindings against the CLI it just built and runs them
against each other, because they are one engine and the only way that stays true
is if something checks.
docs/releasing.md covers what does not exist yet: Actions is off on this forge on
purpose, there is no runner, and PyPI's trusted publishing does not speak to
Forgejo — so a long-lived token has to live here, project-scoped rather than
account-wide so a compromised forge costs this package rather than all of them.
Both keep the same Typikon and read it a little differently, and until now
this library silently picked one reading and called it the Typikon. Two
differences are modelled, each one visible in the output and named in the
day's reason:
- The Protection of the Theotokos is a great feast in Slavic usage. Greek
practice ranks it lower, and the Church of Greece keeps it on 28 October
besides. This has teeth: rank is what lifts a Wednesday or Friday fast.
- Greek practice admits fish on any day of the Nativity Fast but Wednesday
and Friday until 17 December; Slavic practice keeps fish to Saturdays and
Sundays throughout.
Every jurisdiction carries the usage its books follow, and --tradition
overrides it for a parish that does not match its jurisdiction's default. The
difference in the calendar lives in a data overlay compiled in beside the
common menologion, which is where a local church's own commemorations would
go; the difference in the fast lives in the rule, because that is where it is.
Each rank also now carries the mark the books print beside it, circled cross,
cross and Jerusalem cross for great, vigil and polyeleos. Unicode has no glyph
for the polyeleos sign, a cross over a half-circle, so the Jerusalem cross
stands in as digital calendars generally settle on.
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.
--format takes text, json, csv, tsv or ics. --json and --ics stay as
shorthand, because the widgets already call them.
csv and tsv are RFC 4180 quoted, one header row, one row per day, with
commemorations joined by a semicolon: a cell that is itself a table is worse
than a cell you have to split. --list-fields prints the twenty columns,
--fields picks a subset in the order you name them, and a column that does
not exist is caught before anything is printed rather than silently emitting
an empty cell.
--no-emoji drops the glyphs from every format, including the emoji field in
the JSON, and collapses the column in the text output rather than leaving a
hole where a glyph used to be.
Python was never the house language here: the mw-* desktop surface it came
from is 39 bash scripts and was exactly one Python script, the one this was
extracted from. What it cost was distribution, a widget dependency that
needed pipx and a Python floor, and 24ms of interpreter startup before a line
of the program ran.
The port is verified rather than asserted. Both implementations were run over
199,446 day-records, 26 jurisdictions across 21 years, with every field of
every record required to match; the JSON, the iCalendar and 480 renderings of
the text output are identical too. examples/dump.rs is the harness.
What the type system actually buys, which is less than the usual claim and
more than nothing:
- Level is an ordered enum, strictest first, so reads as 'a is
stricter' and relaxed_to() is the one place a feast can lift a fast. No
rule can get the direction backwards.
- Date is a newtype over the Julian Day Number and JulianDate is a separate
type, so a civil date and an ecclesiastical one cannot be mixed up. That
confusion is the single most common way calendar code goes wrong.
- Octets is a newtype in the iCalendar folder, because the bug it replaced
counted characters against a limit RFC 5545 states in octets.
- Movables is a struct, not a map, so a mistyped key is a compile error
rather than a None at the far end of the program.
Of the six defects an adversarial review found in the Python, exactly one had
a shape a type system would have caught. The rest were logic, and the tests
came with them.
The menologion is compiled in with include_str!, so the binary answers on its
own: 930KB, no runtime, 1.2ms to start against Python's 36ms.
horologion was named for the Book of Hours, which is the fixed portions of
the services rather than the calendar of commemorations this displays. The
cross-links follow the rename.
Em dashes are gone from the prose. The two left in the README sit inside
console blocks quoting real output, where changing them would make the
README lie about what the program prints.
An adversarial pass found the rules throwing away the feast rank that
describe() computes, in exactly the seasons where it decides the answer.
- A saint of vigil or polyeleos rank now lifts a weekday of a fast, to wine
and oil inside the Great Fast and as far as fish in the three lesser
ones. This is why the printed calendars mark so many Wednesdays in the
Nativity Fast; 74 days across 2020-2030 were being reported stricter than
any published calendar keeps them, St Nicholas and the Forty Martyrs of
Sebaste among them.
- The Annunciation was tested after Great Friday and Great Saturday, so on
the years it lands there it vanished into them entirely -- the Great Feast
of the Incarnation demoted to a bullet, reported as a total fast, and its
rank overwritten with the lesser movable one the rules then read. It is
now tested first and lifts those days to wine and oil. Five such years
this century on the Gregorian paschalion, seven on the Julian.
- Lazarus Saturday and Palm Sunday belonged to no season at all, sitting
between the Forty Days and Great Week. Any rule keying on the season fell
through, so Byzantine Catholic jurisdictions reported a meat glyph and
'no fast' on Palm Sunday.
- The eves of the Nativity and Theophany are relaxed to wine and oil when
they fall on a weekend, which ruthenian.py already implemented and this
module did not.
Also: iCalendar folded on characters where RFC 5545 counts octets, so an
emoji put lines up to 80 octets over a 75-octet limit; a malformed --date
printed a traceback; the overlaid menologion was rebuilt for every single day
asked about; the Nativity of the Forerunner was ranked below its class.
Fourteen new tests, each one able to fail. The old sweep asserted only
all(d.fast), and 'none' is a truthy string.
The seven-day view cut each commemoration at a hard 44 characters, mid-word
and with nothing to say it had done so, which is how 'father of the
Forerunner' became 'father of the Fo'. It now cuts on a word boundary, says
so with an ellipsis, and takes its width from the terminal instead of a
constant, so a wide window shows the whole name.
Also adds a screenshot of the output, since a calendar is a thing people
want to look at before installing.
Feasts, seasons and the fast for twenty-three jurisdictions, derived from
the paschalion and the fixed calendar so it needs no network and cannot go
stale.
The three things calendar code usually conflates are kept apart: the
paschalion that gives Pascha, the menologion the fixed feasts are read on,
and the fasting rule that is kept. Almost every Orthodox Church keeps the
Julian Paschalion; what divides them is the menologion, so one file of
commemorations indexed by ecclesiastical date serves old and new calendars
alike.
Six fasting levels rather than three, because the difference between
xerophagy and wine-and-oil is the difference the books actually make. Level
and obligation are carried separately: the Orthodox rule reports everything
as customary, and Ruthenian particular law reports both how a day is kept
and the far smaller minimum that binds.
The menologion covers all 366 days and is dedicated to the public domain.
Sets published by someone else are fetched, never redistributed.