mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-11 14:37:32 +01:00
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
197 lines
6.2 KiB
Python
197 lines
6.2 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""Release versions named after the popes, in order.
|
|
|
|
Release *n* is pope *n*: 0.0.0 is Święty Piotr Apostoł, and the version is a
|
|
pure function of the ordinal rather than a judgement about the size of a
|
|
change. The one fixed point is Jan Paweł II at 2.13.7, which is what picks
|
|
the two constants below -- eight patches per minor, and majors opening at
|
|
ordinals 33 and 153:
|
|
|
|
(264 - 153) = 111 = 13 * 8 + 7 -> 2.13.7
|
|
|
|
Both earlier majors happen to divide evenly by eight as well (32 = 4 * 8,
|
|
120 = 15 * 8), so no bracket needs a fudge factor. The anchors are not
|
|
arbitrary either: 33 is Sylwester I, pope through the Edict of Milan and
|
|
Nicaea, and 153 is Wiktor II, the first pope elected after the 1054 schism.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, Final
|
|
from urllib.parse import quote
|
|
|
|
# Ordinals at which a new major version opens. Ascending, first entry 1.
|
|
MAJOR_STARTS: Final = (1, 33, 153)
|
|
PATCHES_PER_MINOR: Final = 8
|
|
|
|
# Which release this build is. Bump by one per release; everything else,
|
|
# including the version string in pyproject.toml, follows from it.
|
|
RELEASE_ORDINAL: Final = 13
|
|
|
|
_POPES_FILE: Final = Path(__file__).with_name("popes.json")
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Pope:
|
|
"""One entry of the papal list, as the version ladder needs it."""
|
|
|
|
ordinal: int
|
|
name: str
|
|
# The pl.wikipedia article title, which is name plus any "(papież)"
|
|
# disambiguator -- so it is the URL slug, and name is not.
|
|
title: str
|
|
reign: str
|
|
portrait: str
|
|
latin: str
|
|
|
|
|
|
def _load() -> tuple[Pope, ...]:
|
|
"""Read popes.json once, at import."""
|
|
document: dict[str, Any] = json.loads(_POPES_FILE.read_text(encoding="utf-8"))
|
|
return tuple(
|
|
Pope(
|
|
ordinal=int(entry["ordinal"]),
|
|
name=str(entry["name"]),
|
|
title=str(entry["title"]),
|
|
reign=str(entry["reign"]),
|
|
portrait=str(entry["portrait"]),
|
|
latin=str(entry["latin"]),
|
|
)
|
|
for entry in document["popes"]
|
|
)
|
|
|
|
|
|
POPES: Final = _load()
|
|
|
|
|
|
def version_for(ordinal: int) -> str:
|
|
"""Return the dotted version string for the given papal ordinal.
|
|
|
|
Raises:
|
|
ValueError: if the ordinal is outside the known list.
|
|
"""
|
|
if not 1 <= ordinal <= len(POPES):
|
|
message = f"no pope with ordinal {ordinal}; the list has {len(POPES)}"
|
|
raise ValueError(message)
|
|
major = max(index for index, start in enumerate(MAJOR_STARTS) if ordinal >= start)
|
|
offset = ordinal - MAJOR_STARTS[major]
|
|
return f"{major}.{offset // PATCHES_PER_MINOR}.{offset % PATCHES_PER_MINOR}"
|
|
|
|
|
|
def pope_for(ordinal: int) -> Pope:
|
|
"""Return the pope with the given ordinal.
|
|
|
|
Raises:
|
|
ValueError: if the ordinal is outside the known list.
|
|
"""
|
|
if not 1 <= ordinal <= len(POPES):
|
|
message = f"no pope with ordinal {ordinal}; the list has {len(POPES)}"
|
|
raise ValueError(message)
|
|
return POPES[ordinal - 1]
|
|
|
|
|
|
def ordinal_for(version: str) -> int:
|
|
"""Return the papal ordinal a version string denotes.
|
|
|
|
Raises:
|
|
ValueError: if no release carries that version.
|
|
"""
|
|
for pope in POPES:
|
|
if version_for(pope.ordinal) == version:
|
|
return pope.ordinal
|
|
message = f"no release carries version {version!r}"
|
|
raise ValueError(message)
|
|
|
|
|
|
# Latin ordinals exactly as popes.json spells them, and the number each
|
|
# carries. The vocabulary is closed: these fifteen words are every trailing
|
|
# token in the file's `latin` column apart from four epithets, and a compound
|
|
# reads as a sum -- "Leo Decimus Quartus" is Leo 10+4.
|
|
LATIN_ORDINALS: Final = {
|
|
"Primus": 1,
|
|
"Secundus": 2,
|
|
"Tertius": 3,
|
|
"Quartus": 4,
|
|
"Quintus": 5,
|
|
"Sextus": 6,
|
|
"Septimus": 7,
|
|
"Octavus": 8,
|
|
"Nonus": 9,
|
|
"Decimus": 10,
|
|
"Undecimus": 11,
|
|
"Duodecimus": 12,
|
|
"Duodevicesimus": 18,
|
|
"Undevicesimus": 19,
|
|
"Vicesimus": 20,
|
|
}
|
|
|
|
# The five popes whose Latin trails an epithet or an artifact rather than an
|
|
# ordinal, so stripping cannot find the stem. Enumerated rather than pattern
|
|
# matched: the list is closed at 267 and a rule broad enough to drop "Magnus"
|
|
# would also eat a legitimate second name like "Ioannes Paulus".
|
|
_STEM_OVERRIDES: Final = {
|
|
4: "Clemens", # Clemens Romanus
|
|
45: "Leo", # Leo Magnus
|
|
64: "Gregorius", # Gregorius Magnus
|
|
68: "Adeodatus", # Adeodatus lub Deusdedit -- Polish "or" in a Latin field
|
|
105: "Nicolaus", # Nicolaus Magnus
|
|
}
|
|
|
|
_ROMAN: Final = re.compile(r"^[IVXLC]+$")
|
|
|
|
WIKI_BASE: Final = "https://pl.wikipedia.org/wiki/"
|
|
|
|
|
|
def latin_stem(pope: Pope) -> str:
|
|
"""Return the Latin name with any spelled-out ordinal removed."""
|
|
override = _STEM_OVERRIDES.get(pope.ordinal)
|
|
if override is not None:
|
|
return override
|
|
words = pope.latin.split()
|
|
while words and words[-1] in LATIN_ORDINALS:
|
|
words.pop()
|
|
return " ".join(words)
|
|
|
|
|
|
def roman_numeral(pope: Pope) -> str:
|
|
"""Return the pope's Roman numeral, or "" for the 44 who carry none.
|
|
|
|
Read off the Polish name rather than the Latin, because the Latin is the
|
|
field that omits it: Pius I is stored as plain "Pius". No name holds two
|
|
numeral-shaped tokens, so the first hit is the answer.
|
|
"""
|
|
for token in pope.name.split():
|
|
if _ROMAN.match(token):
|
|
return token
|
|
return ""
|
|
|
|
|
|
def latin_name(pope: Pope) -> str:
|
|
"""Return the release name for a pope: Latin stem plus Roman numeral.
|
|
|
|
This is what a version is called everywhere -- in the group, in
|
|
DOCS-changelog.md, in /healthz and in the release commit subject. Not the
|
|
Polish name, and not the raw `latin` field, which spells the ordinal out
|
|
("Leo Decimus Quartus") where a version wants "Leo XIV".
|
|
"""
|
|
numeral = roman_numeral(pope)
|
|
stem = latin_stem(pope)
|
|
return f"{stem} {numeral}" if numeral else stem
|
|
|
|
|
|
def article_url(pope: Pope) -> str:
|
|
"""Return the pl.wikipedia URL for a pope.
|
|
|
|
Built from title, not name: half the early popes live at "Ewaryst
|
|
(papież)" and only the title carries the disambiguator.
|
|
"""
|
|
return WIKI_BASE + quote(pope.title.replace(" ", "_"))
|
|
|
|
|
|
__version__: Final = version_for(RELEASE_ORDINAL)
|
|
POPE: Final = pope_for(RELEASE_ORDINAL)
|