From 3d6ae0680c678746d891efbb6cefde090fd0db1b Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 30 Aug 2026 21:28:41 +0200 Subject: [PATCH] feat: releases are named in Latin, and land with the pope's portrait and article Co-Authored-By: Claude Opus 5 (1M context) --- DOCS-changelog.md | 48 +++++++++---- README.md | 2 +- signal_bot/announce.py | 55 +++++++++++---- signal_bot/app.py | 4 +- signal_bot/bot.py | 17 ++--- signal_bot/changelog.py | 12 +++- signal_bot/popes_command.py | 13 +--- signal_bot/versioning.py | 87 +++++++++++++++++++++++ tests/test_announce.py | 135 ++++++++++++++++++++++++++++++------ tests/test_changelog.py | 8 ++- tests/test_healthz.py | 6 +- tests/test_versioning.py | 77 ++++++++++++++++++++ tool/gen_popes.py | 5 +- 13 files changed, 393 insertions(+), 76 deletions(-) diff --git a/DOCS-changelog.md b/DOCS-changelog.md index cc50324..fe50660 100644 --- a/DOCS-changelog.md +++ b/DOCS-changelog.md @@ -5,6 +5,19 @@ version number is a pure function of that ordinal — see `signal_bot/versioning for the two constants and why they are what they are. The short version is that Jan Paweł II has to land on 2.13.7, and everything else follows. +**The name is Latin, and it is derived, not typed.** `latin_name()` in +`signal_bot/versioning.py` builds it from `popes.json`: the Latin stem plus the +Roman numeral off the Polish name. So 0.0.3 is «Clemens I», not «Klemens I» and +not the raw `latin` field's «Clemens Romanus»; 3.0.4 will be «Leo XIV», not «Leo +Decimus Quartus». Ask the code rather than guessing: + +``` +python -c 'from signal_bot.versioning import *; print(latin_name(pope_for(14)))' +``` + +The same string goes in the release commit subject, in `/healthz`, and in the +heading below. + The bot reads this file. The heading format is load-bearing: ``` @@ -14,7 +27,18 @@ The bot reads this file. The heading format is load-bearing: Add the new section at the top when you bump `RELEASE_ORDINAL`. Anything that is not a `##` heading or a `-` bullet under one is ignored, so prose between -releases is free. +releases is free. The heading *name* is checked rather than trusted — +`tests/test_changelog.py` asserts it equals `latin_name()` for that version — +and the announcement reads `popes.json` regardless, so a typo here fails the +suite instead of reaching a group. + +**A release announcement is a photo now.** It carries the pope's portrait, his +Latin name and his pl.wikipedia article, the same three things `!papież` posts. +Only for the pope in the header: a backlog announcement lists up to ten +releases, and ten attachments is the wall the cap exists to avoid. If Commons +will not serve the portrait the announcement still goes out, as text — the +`announced_version` marker is written before the send, so a failure there would +otherwise cost that group its release notes permanently. **Every release takes the next pope, however small.** A one-line fix consumes an ordinal exactly like a feature does; the ladder tracks releases, not @@ -38,12 +62,12 @@ this repo: describe what changes for them. 0.0.1 shipped a bullet about `/healthz`, an endpoint bound to 127.0.0.1 that nobody in a Signal group can reach, and it was rightly called out. -## 0.1.4 — Eleuteriusz +## 0.1.4 — Eleutherius - `!pomoc` (albo `!help`) wypisuje wszystkie komendy i mówi jednym zdaniem, co każda robi — do tej pory nigdzie na czacie nie było ich listy -## 0.1.3 — Soter +## 0.1.3 — Soterius - "POCZEKAJ 1 SEKUND" odmienia się wreszcie po polsku — teraz jest 1 SEKUNDĘ, 2 SEKUNDY, 5 SEKUND @@ -51,7 +75,7 @@ reach, and it was rightly called out. po cichu przedłużała blokadę, więc kto grzecznie czekał, ten dostawał odmowę w kółko -## 0.1.2 — Anicet +## 0.1.2 — Anicetus - `!ja` pokazywał wszystkim tę samą godzinę (12:00) — to był artefakt danych sprzed przebudowy licznika, a nie wasze zwyczaje. Teraz pokazuje @@ -64,7 +88,7 @@ reach, and it was rightly called out. - `!ja` pisze "między 12:00 a 13:00" zamiast "o 12:00" — bo to godzina, a nie moment -## 0.1.0 — Hygin +## 0.1.0 — Hyginus - podsumowanie liczy teraz zdjęcia i filmy — zdjęcie bez podpisu było wcześniej warte zero, więc liczby wszystkim podskoczyły @@ -74,36 +98,36 @@ reach, and it was rightly called out. - bot pamięta historię: rekordy życiowe, serie z koroną, średnie dni tygodnia. Liczymy od 30 sierpnia 2026 -## 0.0.7 — Telesfor +## 0.0.7 — Telesphorus - nowa komenda `!papież` (działa też jako `!papiez`, `!papa`, `!pope`) — bot wrzuca losowego papieża: zdjęcie, imię po łacinie, lata pontyfikatu i link do polskiej Wikipedii. Do wyboru jest cała lista, 267 papieży od Świętego Piotra do Leona XIV — czyli ci sami, po których nazywają się wersje bota -## 0.0.6 — Sykstus I +## 0.0.6 — Xystus I - po restarcie komputera bot wraca sam — wcześniej, jeśli włączył się zanim Signal był gotowy, poddawał się po 25 sekundach i milczał do końca dnia; 30 sierpnia stało się to na osiem godzin -## 0.0.5 — Aleksander I +## 0.0.5 — Alexander I - komendy działają teraz niezależnie od tego, na jakiej klawiaturze je piszesz — `!gekon` napisany rosyjskimi literami (albo jako `!g3ck0`) wreszcie odpowiada, wcześniej bot go po prostu ignorował -## 0.0.4 — Ewaryst +## 0.0.4 — Evaristus - `!gekon` pokazuje teraz za każdym razem innego gekona — wcześniej wrzucał w kółko to samo zdjęcie -## 0.0.3 — Klemens I +## 0.0.3 — Clemens I - nowa komenda `!gekon` — bot wrzuca losowe zdjęcie gekona; działa też `!gecko`, `!geckon`, `!geko`, `!gekony` i `!geckos` -## 0.0.2 — Anaklet +## 0.0.2 — Anacletus - bot odpowiada teraz na konkretną wiadomość, cytując ją — w ruchliwej rozmowie widać, czyje `!cat` to jest @@ -114,6 +138,6 @@ reach, and it was rightly called out. - po aktualizacji bot ogłasza w grupie nową wersję i listę zmian - `/healthz` pokazuje wersję i papieża, więc widać co naprawdę działa -## 0.0.0 — Święty Piotr Apostoł +## 0.0.0 — Petrus - bot taki, jaki był przed drabinką papieską: `!cat`, `!dog` i dzienne podsumowanie diff --git a/README.md b/README.md index f86568e..28e06f3 100644 --- a/README.md +++ b/README.md @@ -242,7 +242,7 @@ pre-commit run --all-files # ruff ALL + mypy strict + 250-line cap + gates No `# noqa`, no `# type: ignore`, no coverage omits. Never bump `SCHEMA_VERSION` to add a table — read `signal_bot/schema.py` first; a bump DROPs every table and throws the live tallies away, while `CREATE TABLE IF NOT EXISTS` is additive and safe. Wire format: [DOCS-signal-envelopes.md](DOCS-signal-envelopes.md). -Releases are named after the popes, in order, and a bump publishes to the group: [DOCS-changelog.md](DOCS-changelog.md). Latency: [DOCS-latency.md](DOCS-latency.md). +Releases are named after the popes, in order and in Latin — 0.1.4 is «Eleutherius» — and a bump publishes to the group as the pope's portrait, name and article: [DOCS-changelog.md](DOCS-changelog.md). Latency: [DOCS-latency.md](DOCS-latency.md). Signal never delivers an account its own sends: `scripts/selftest.sh testbot '!papież' 3` drives the live dispatcher instead, [DOCS-selftest.md](DOCS-selftest.md). ## Licence diff --git a/signal_bot/announce.py b/signal_bot/announce.py index 3f5ca7c..28f24a1 100644 --- a/signal_bot/announce.py +++ b/signal_bot/announce.py @@ -3,17 +3,24 @@ A group is told about every release it has not seen, and a group that has never been told anything has not seen any of them -- so the first -announcement starts at 0.0.0 "Swiety Piotr Apostol" rather than skipping -straight to whatever is current. Watching the ladder start at Peter is the -point of naming releases after popes at all. +announcement starts at 0.0.0 "Petrus" rather than skipping straight to +whatever is current. Watching the ladder start at Peter is the point of +naming releases after popes at all. That does mean a group added years from now would be handed the whole backlog, so the message is capped at MAX_RELEASES and says how many older versions it left out. +The announcement carries the same three things `!papiez` does: the pope's +portrait, his Latin name and his Polish Wikipedia article. Only for the pope +in the header -- ten attachments is the wall MAX_RELEASES exists to avoid. + The version is recorded *before* the message is sent. `Restart=always` with `RestartSec=5` means a crash between the two would otherwise re-announce on every restart, and a missed announcement is far cheaper than a looping one. +That is also why a portrait that will not download costs the photo and not +the announcement: the marker is already written, so a Commons outage at boot +would otherwise eat that group's release notes permanently. """ from __future__ import annotations @@ -22,13 +29,16 @@ import logging from typing import TYPE_CHECKING, Final from signal_bot.changelog import since +from signal_bot.versioning import article_url, latin_name, version_for if TYPE_CHECKING: from collections.abc import Iterable from signal_bot.changelog import Release + from signal_bot.portraits import PortraitCache from signal_bot.store import Store from signal_bot.transport_base import Transport + from signal_bot.versioning import Pope logger = logging.getLogger(__name__) @@ -38,14 +48,17 @@ ANNOUNCED_KEY: Final = "announced_version" MAX_RELEASES: Final = 10 -def render(version: str, pope: str, releases: Iterable[Release]) -> str: +def render(pope: Pope, releases: Iterable[Release]) -> str: """Return the message announcing a new version, in Polish. One release is rendered flat, because the header already names it. Several - get a block each, so the group can see the ladder it is climbing. + get a block each, so the group can see the ladder it is climbing. Pure on + purpose: whether this text travels as a caption or as a plain message is + announce_to's call, not this function's. """ shown = list(releases) - header = f"Wersja {version} \u00ab{pope}\u00bb wydana" + header = f"Wersja {version_for(pope.ordinal)} \u00ab{latin_name(pope)}\u00bb wydana" + header = f"{header}\n{article_url(pope)}" omitted = max(0, len(shown) - MAX_RELEASES) shown = shown[-MAX_RELEASES:] if len(shown) == 1: @@ -53,7 +66,10 @@ def render(version: str, pope: str, releases: Iterable[Release]) -> str: return f"{header}\nChangelog:\n{body}" blocks = [ "\n".join( - [f"{release.version} \u00ab{release.pope}\u00bb", *(f"- {x}" for x in release.lines)] + [ + f"{release.version} \u00ab{latin_name(release.pope_entry)}\u00bb", + *(f"- {x}" for x in release.lines), + ] ) for release in shown ] @@ -66,22 +82,31 @@ async def announce_to( store: Store, group_id: str, releases: tuple[Release, ...], - version: str, - pope: str, + pope: Pope, + portraits: PortraitCache, ) -> None: """Announce the current version to one group, if it has not seen it.""" + version = version_for(pope.ordinal) previous = store.meta(group_id, ANNOUNCED_KEY) if previous == version: return # No record at all means the group has seen nothing, not that it should be - # skipped: that is how Swiety Piotr Apostol gets announced rather than - # silently assumed. + # skipped: that is how Petrus gets announced rather than silently assumed. pending = releases if previous is None else since(releases, previous) store.set_meta(group_id, ANNOUNCED_KEY, version) if not pending: logger.info("group %s recorded at %s with nothing to announce", group_id, version) return - await transport.send_text(render(version, pope, pending), group_id) + text = render(pope, pending) + try: + image = await portraits.get_base64(pope) + except Exception: + # The marker is already written, so this must never propagate: a + # Commons outage costs the portrait, not the release notes. + logger.exception("portrait for %s unavailable; announcing %s as text", version, group_id) + await transport.send_text(text, group_id) + return + await transport.send_image(image, group_id, caption=text) async def announce_all( @@ -89,12 +114,12 @@ async def announce_all( store: Store, group_ids: Iterable[str], releases: tuple[Release, ...], - version: str, - pope: str, + pope: Pope, + portraits: PortraitCache, ) -> None: """Announce to every watched group; one failure must not silence the rest.""" for group_id in group_ids: try: - await announce_to(transport, store, group_id, releases, version, pope) + await announce_to(transport, store, group_id, releases, pope, portraits) except Exception: logger.exception("release announcement failed for group %s", group_id) diff --git a/signal_bot/app.py b/signal_bot/app.py index 53e9b3a..8cd99a5 100644 --- a/signal_bot/app.py +++ b/signal_bot/app.py @@ -23,7 +23,7 @@ from signal_bot.selftest import SelfTestError from signal_bot.selftest import run as run_selftest from signal_bot.startup_retry import Starter from signal_bot.store import Store -from signal_bot.versioning import POPE, __version__ +from signal_bot.versioning import POPE, __version__, latin_name if TYPE_CHECKING: from collections.abc import AsyncIterator @@ -94,7 +94,7 @@ async def healthz(response: Response) -> dict[str, str]: like, and is not the same thing as one that has gone deaf. """ starter: Starter = app.state.starter - build = {"version": __version__, "pope": POPE.name} + build = {"version": __version__, "pope": latin_name(POPE)} runtime = starter.runtime if runtime is None: response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE diff --git a/signal_bot/bot.py b/signal_bot/bot.py index 2998cf9..ddb6ef1 100644 --- a/signal_bot/bot.py +++ b/signal_bot/bot.py @@ -34,7 +34,7 @@ from signal_bot.stats.queries_day import day_rows, day_totals, with_silent from signal_bot.stats.queries_period import Trend, period_rows, total_for from signal_bot.stats.render_day import DayView, render_day from signal_bot.stats.render_period import render_period -from signal_bot.versioning import POPE, __version__ +from signal_bot.versioning import POPE if TYPE_CHECKING: import httpx @@ -73,6 +73,9 @@ class Bot: directory: GroupDirectory dispatcher: Dispatcher liveness: Liveness + # The same cache !papiez draws from, so a release announcement and the + # command never download the same portrait twice. + portraits: PortraitCache # The same clock the dispatcher files events against. One clock, so # "which day is it" cannot differ between writing an event and reporting # on it -- which is precisely the drift the old reset-after-post had. @@ -165,8 +168,8 @@ class Bot: self.store, list(self.directory.groups), load_changelog(), - __version__, - POPE.name, + POPE, + self.portraits, ) async def listen(self) -> None: @@ -191,6 +194,7 @@ def build_bot( """Assemble the object graph from its injected dependencies.""" registry = CommandRegistry() fetcher = ImageFetcher(client) + portraits = PortraitCache(fetcher, config.commons_api_url) rate_limit = cooldown(config.cooldown_seconds, COOLDOWN_MESSAGE) register_animal_commands( registry, @@ -198,11 +202,7 @@ def build_bot( decorate=rate_limit, endpoints=config.image_api_urls, ) - register_pope_command( - registry, - PortraitCache(fetcher, config.commons_api_url), - decorate=rate_limit, - ) + register_pope_command(registry, portraits, decorate=rate_limit) register_stats_commands(registry, decorate=rate_limit) # Last, so !pomoc trails the list it prints; it reads the registry # when it runs, so the order is presentation only. @@ -222,5 +222,6 @@ def build_bot( directory=directory, dispatcher=dispatcher, liveness=liveness, + portraits=portraits, clock=clock, ) diff --git a/signal_bot/changelog.py b/signal_bot/changelog.py index ff94513..95d6466 100644 --- a/signal_bot/changelog.py +++ b/signal_bot/changelog.py @@ -13,7 +13,7 @@ from dataclasses import dataclass from pathlib import Path from typing import Final -from signal_bot.versioning import ordinal_for +from signal_bot.versioning import Pope, ordinal_for, pope_for # Repo root, since the changelog is documentation rather than package data. DEFAULT_PATH: Final = Path(__file__).resolve().parent.parent / "DOCS-changelog.md" @@ -38,6 +38,16 @@ class Release: """The papal ordinal this release carries.""" return ordinal_for(self.version) + @property + def pope_entry(self) -> Pope: + """The pope this release is named after, from popes.json. + + Display reads this, never the ``pope`` field parsed off the heading: + the name is derived from the version, so a typo in the document + cannot make the bot announce the wrong pope into a group. + """ + return pope_for(self.ordinal) + def parse(text: str) -> tuple[Release, ...]: """Return every release in the document, oldest first.""" diff --git a/signal_bot/popes_command.py b/signal_bot/popes_command.py index b93198e..02a4ff6 100644 --- a/signal_bot/popes_command.py +++ b/signal_bot/popes_command.py @@ -9,10 +9,9 @@ from __future__ import annotations import secrets from typing import TYPE_CHECKING, Final -from urllib.parse import quote from signal_bot.animals import load_aliases -from signal_bot.versioning import POPES +from signal_bot.versioning import POPES, article_url if TYPE_CHECKING: from signal_bot.decorators import Decorator @@ -22,7 +21,6 @@ if TYPE_CHECKING: COMMAND_NAME: Final = "pope" DESCRIPTION: Final = "losowy papież z portretem i imieniem po łacinie" -WIKI_BASE: Final = "https://pl.wikipedia.org/wiki/" def random_pope() -> Pope: @@ -35,15 +33,6 @@ def random_pope() -> Pope: return POPES[secrets.randbelow(len(POPES))] -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(" ", "_")) - - def caption_for(pope: Pope) -> str: """Return the message that rides along with the portrait.""" return f"{pope.ordinal}. {pope.name} — {pope.latin} ({pope.reign})\n{article_url(pope)}" diff --git a/signal_bot/versioning.py b/signal_bot/versioning.py index 15aa50f..58e107d 100644 --- a/signal_bot/versioning.py +++ b/signal_bot/versioning.py @@ -18,9 +18,11 @@ 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) @@ -105,5 +107,90 @@ def ordinal_for(version: str) -> int: 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) diff --git a/tests/test_announce.py b/tests/test_announce.py index 7045de5..e9ef3c5 100644 --- a/tests/test_announce.py +++ b/tests/test_announce.py @@ -3,13 +3,20 @@ from __future__ import annotations +import base64 from typing import TYPE_CHECKING +import httpx + from signal_bot.announce import ANNOUNCED_KEY, announce_all, announce_to, render from signal_bot.changelog import parse +from signal_bot.http_client import ImageFetcher +from signal_bot.portraits import PortraitCache from signal_bot.transport_fake import FakeTransport +from signal_bot.versioning import Pope, pope_for if TYPE_CHECKING: + from signal_bot.quoting import Quote from signal_bot.store import Store GROUP = "group.one" @@ -18,21 +25,58 @@ DOCUMENT = """\ - druga zmiana -## 0.0.0 — Święty Piotr Apostoł +## 0.0.0 — Petrus - pierwsza zmiana """ RELEASES = parse(DOCUMENT) +PETRUS = pope_for(1) +LINUS = pope_for(2) -async def _announce(store: Store, transport: FakeTransport, version: str = "0.0.1") -> None: - await announce_to(transport, store, GROUP, RELEASES, version, "Linus") +IMAGE = b"portrait-bytes" +ENCODED = base64.b64encode(IMAGE).decode("ascii") + + +def _portraits(*, broken: bool = False, seen: list[str] | None = None) -> PortraitCache: + """A real cache over a mocked Commons, the way test_portraits builds one. + + Broken means Commons answers without the thumbnail the fetcher narrows to, + which is what an outage or an upstream change looks like from here. + """ + + def handler(request: httpx.Request) -> httpx.Response: + if "commons" not in str(request.url): + return httpx.Response(200, content=IMAGE) + if seen is not None: + seen.append(str(request.url)) + if broken: + return httpx.Response(200, json={}) + page = {"imageinfo": [{"thumburl": "https://cdn/portrait.jpg"}]} + return httpx.Response(200, json={"query": {"pages": [page]}}) + + fetcher = ImageFetcher(httpx.AsyncClient(transport=httpx.MockTransport(handler))) + return PortraitCache(fetcher, "https://commons.example/w/api.php") + + +async def _announce( + store: Store, + transport: FakeTransport, + pope: Pope = LINUS, + portraits: PortraitCache | None = None, +) -> None: + cache = _portraits() if portraits is None else portraits + await announce_to(transport, store, GROUP, RELEASES, pope, cache) def test_a_single_release_is_rendered_flat() -> None: """The header already names it; a per-release block would just repeat it.""" - message = render("0.0.1", "Linus", RELEASES[1:]) - assert message == "Wersja 0.0.1 «Linus» wydana\nChangelog:\n- druga zmiana" + message = render(LINUS, RELEASES[1:]) + assert message == ( + "Wersja 0.0.1 «Linus» wydana\n" + "https://pl.wikipedia.org/wiki/Linus_%28papie%C5%BC%29\n" + "Changelog:\n- druga zmiana" + ) def test_a_long_backlog_is_capped_and_says_so() -> None: @@ -56,32 +100,71 @@ def test_a_long_backlog_is_capped_and_says_so() -> None: ] ) ) - message = render("0.1.3", "Soter", parse(document)) + message = render(pope_for(12), parse(document)) assert "(pominieto 2 starszych wersji)" in message assert "zmiana 0" not in message assert "zmiana 11" in message +def test_a_block_heading_ignores_the_name_typed_in_the_document() -> None: + """The heading says Pope3; popes.json is what the group is actually told.""" + message = render(pope_for(12), parse("## 0.0.2 — Pope3\n\n- x\n\n## 0.0.3 — Pope4\n\n- y")) + assert "0.0.2 «Anacletus»" in message + assert "0.0.3 «Clemens I»" in message + assert "Pope3" not in message + + def test_render_names_the_version_and_lists_every_pending_line() -> None: - message = render("0.0.1", "Linus", RELEASES) - assert message.startswith("Wersja 0.0.1 «Linus» wydana\nChangelog:") + message = render(LINUS, RELEASES) + assert message.startswith("Wersja 0.0.1 «Linus» wydana") assert "- pierwsza zmiana" in message assert "- druga zmiana" in message +def test_render_links_the_polish_article() -> None: + """The same article !papież posts, built from the disambiguated title.""" + assert "https://pl.wikipedia.org/wiki/Piotr_Aposto%C5%82" in render(PETRUS, RELEASES) + + async def test_a_group_told_nothing_yet_hears_the_ladder_from_peter( store: Store, transport: FakeTransport ) -> None: """kuhy asked for this explicitly: the joke only lands if Peter is posted.""" await _announce(store, transport) assert len(transport.sent) == 1 - message = transport.sent[0].text or "" - assert "0.0.0 «Święty Piotr Apostoł»" in message + message = transport.sent[0].caption + assert "0.0.0 «Petrus»" in message assert "pierwsza zmiana" in message assert "druga zmiana" in message assert store.meta(GROUP, ANNOUNCED_KEY) == "0.0.1" +async def test_the_announcement_rides_on_the_pope_portrait( + store: Store, transport: FakeTransport +) -> None: + """One captioned photo, the same shape !papież posts.""" + seen: list[str] = [] + await _announce(store, transport, portraits=_portraits(seen=seen)) + assert len(transport.sent) == 1 + sent = transport.sent[0] + assert sent.image_base64 == ENCODED + assert sent.text is None + assert sent.caption.startswith("Wersja 0.0.1 «Linus» wydana") + # The portrait asked for is the header pope's, not some other release's. + assert f"File%3A{LINUS.portrait}".replace(".", "%2E") in seen[0] or LINUS.portrait in seen[0] + + +async def test_an_unreachable_portrait_costs_the_photo_not_the_announcement( + store: Store, transport: FakeTransport +) -> None: + """The marker is written first, so a Commons outage must not eat the notes.""" + await _announce(store, transport, portraits=_portraits(broken=True)) + assert len(transport.sent) == 1 + assert transport.sent[0].image_base64 is None + assert "Wersja 0.0.1 «Linus» wydana" in (transport.sent[0].text or "") + assert store.meta(GROUP, ANNOUNCED_KEY) == "0.0.1" + + async def test_a_group_already_on_this_version_hears_nothing( store: Store, transport: FakeTransport ) -> None: @@ -96,9 +179,9 @@ async def test_an_upgraded_group_is_told_what_changed( store.set_meta(GROUP, ANNOUNCED_KEY, "0.0.0") await _announce(store, transport) assert len(transport.sent) == 1 - assert "Wersja 0.0.1 «Linus» wydana" in (transport.sent[0].text or "") - assert "druga zmiana" in (transport.sent[0].text or "") - assert "pierwsza zmiana" not in (transport.sent[0].text or "") + assert "Wersja 0.0.1 «Linus» wydana" in transport.sent[0].caption + assert "druga zmiana" in transport.sent[0].caption + assert "pierwsza zmiana" not in transport.sent[0].caption assert store.meta(GROUP, ANNOUNCED_KEY) == "0.0.1" @@ -107,7 +190,7 @@ async def test_a_version_with_no_changelog_entry_records_but_says_nothing( ) -> None: """An empty announcement is worse than none.""" store.set_meta(GROUP, ANNOUNCED_KEY, "0.0.1") - await _announce(store, transport, version="0.0.2") + await _announce(store, transport, pope=pope_for(3)) assert transport.sent == [] assert store.meta(GROUP, ANNOUNCED_KEY) == "0.0.2" @@ -116,11 +199,17 @@ async def test_the_version_is_recorded_before_the_send(store: Store) -> None: """A crash between the two must not re-announce on every restart.""" class Exploding(FakeTransport): - async def send_text(self, text: str, recipient: str) -> None: - raise RuntimeError(text, recipient) + async def send_image( + self, + image_base64: str, + recipient: str, + quote: Quote | None = None, + caption: str = "", + ) -> None: + raise RuntimeError(recipient, caption) store.set_meta(GROUP, ANNOUNCED_KEY, "0.0.0") - await announce_all(Exploding(), store, [GROUP], RELEASES, "0.0.1", "Linus") + await announce_all(Exploding(), store, [GROUP], RELEASES, LINUS, _portraits()) assert store.meta(GROUP, ANNOUNCED_KEY) == "0.0.1" @@ -128,13 +217,19 @@ async def test_one_failing_group_does_not_silence_the_others(store: Store) -> No other = "group.two" class RefusingFirst(FakeTransport): - async def send_text(self, text: str, recipient: str) -> None: + async def send_image( + self, + image_base64: str, + recipient: str, + quote: Quote | None = None, + caption: str = "", + ) -> None: if recipient == GROUP: raise RuntimeError(recipient) - await super().send_text(text, recipient) + await super().send_image(image_base64, recipient, quote, caption) for group in (GROUP, other): store.set_meta(group, ANNOUNCED_KEY, "0.0.0") transport = RefusingFirst() - await announce_all(transport, store, [GROUP, other], RELEASES, "0.0.1", "Linus") + await announce_all(transport, store, [GROUP, other], RELEASES, LINUS, _portraits()) assert [message.recipient for message in transport.sent] == [other] diff --git a/tests/test_changelog.py b/tests/test_changelog.py index 3ec8a8c..83056ad 100644 --- a/tests/test_changelog.py +++ b/tests/test_changelog.py @@ -6,7 +6,7 @@ from __future__ import annotations from pathlib import Path from signal_bot.changelog import Release, load, parse, since -from signal_bot.versioning import __version__ +from signal_bot.versioning import __version__, latin_name DOCUMENT = """\ # Changelog @@ -71,3 +71,9 @@ def test_the_real_changelog_covers_the_running_version() -> None: def test_release_knows_its_ordinal() -> None: assert Release("2.13.7", "Jan Paweł II", ()).ordinal == 264 + + +def test_every_heading_name_matches_the_derived_latin_one() -> None: + """The doc is checked, not trusted: a typo here fails here, not in a group.""" + for release in load(): + assert release.pope == latin_name(release.pope_entry), release.version diff --git a/tests/test_healthz.py b/tests/test_healthz.py index d5eb176..30da2f2 100644 --- a/tests/test_healthz.py +++ b/tests/test_healthz.py @@ -13,7 +13,7 @@ from fastapi import Response from signal_bot import app as app_module from signal_bot.app import healthz from signal_bot.liveness import Liveness -from signal_bot.versioning import POPE, __version__ +from signal_bot.versioning import POPE, __version__, latin_name if TYPE_CHECKING: from signal_bot.config import Config @@ -72,7 +72,7 @@ async def test_healthz_names_the_build_either_way(config: Config) -> None: _install(_gate(config, httpx.Response(200, json=[]))) healthy = await healthz(Response()) assert healthy["version"] == __version__ - assert healthy["pope"] == POPE.name + assert healthy["pope"] == latin_name(POPE) deaf_gate = _gate(replace(config, silence_seconds=0), httpx.Response(400, json=DEAD_BODY)) for _ in range(3): @@ -80,4 +80,4 @@ async def test_healthz_names_the_build_either_way(config: Config) -> None: _install(deaf_gate) deaf = await healthz(Response()) assert deaf["version"] == __version__ - assert deaf["pope"] == POPE.name + assert deaf["pope"] == latin_name(POPE) diff --git a/tests/test_versioning.py b/tests/test_versioning.py index fe1d95d..da0c697 100644 --- a/tests/test_versioning.py +++ b/tests/test_versioning.py @@ -11,12 +11,17 @@ from pathlib import Path import pytest from signal_bot.versioning import ( + LATIN_ORDINALS, POPE, POPES, RELEASE_ORDINAL, __version__, + article_url, + latin_name, + latin_stem, ordinal_for, pope_for, + roman_numeral, version_for, ) @@ -105,3 +110,75 @@ def test_the_article_title_keeps_its_disambiguator() -> None: assert pope_for(9).title == "Hygin (papież)" assert pope_for(4).title == "Klemens I" assert pope_for(9).latin == "Hyginus" + + +# The release name: Latin stem plus Roman numeral. Not popes.json's `latin`, +# which spells the ordinal out, and not the Polish name. + +ROMAN_VALUES = {"I": 1, "V": 5, "X": 10, "L": 50, "C": 100} + + +def roman_to_int(numeral: str) -> int: + """Test-side reader, so the Latin ordinals can be checked against it.""" + total = 0 + for index, letter in enumerate(numeral): + value = ROMAN_VALUES[letter] + following = numeral[index + 1 :] + subtractive = following and ROMAN_VALUES[following[0]] > value + total += -value if subtractive else value + return total + + +@pytest.mark.parametrize( + ("ordinal", "expected"), + [ + (1, "Petrus"), + (4, "Clemens I"), + (13, "Eleutherius"), + (14, "Victor I"), + (45, "Leo I"), + (68, "Adeodatus I"), + (105, "Nicolaus I"), + (141, "Ioannes XVIII"), + (264, "Ioannes Paulus II"), + (267, "Leo XIV"), + ], +) +def test_the_release_name_is_latin_plus_a_roman_numeral(ordinal: int, expected: str) -> None: + assert latin_name(pope_for(ordinal)) == expected + + +def test_no_pope_name_holds_two_numerals() -> None: + """roman_numeral takes the first hit, which is only safe if there is one.""" + for pope in POPES: + numerals = [token for token in pope.name.split() if set(token) <= set(ROMAN_VALUES)] + assert len(numerals) <= 1, pope.name + + +def test_every_stem_is_one_word_except_the_two_john_pauls() -> None: + """The invariant that catches an epithet arriving with a regeneration.""" + multiword = {latin_stem(p) for p in POPES if len(latin_stem(p).split()) > 1} + assert multiword == {"Ioannes Paulus"} + + +def test_the_latin_ordinal_and_the_roman_numeral_agree() -> None: + """Two independent spellings of the same number, cross-checked.""" + for pope in POPES: + words = pope.latin.split() + spelled = [LATIN_ORDINALS[w] for w in words if w in LATIN_ORDINALS] + if not spelled: + continue + numeral = roman_numeral(pope) + assert numeral, pope.name + assert roman_to_int(numeral) == sum(spelled), pope.name + + +def test_forty_four_popes_carry_no_numeral() -> None: + """The branch where the name is the bare stem.""" + assert sum(1 for pope in POPES if not roman_numeral(pope)) == 44 + + +def test_the_article_url_uses_the_disambiguated_title() -> None: + """name would 404 for the 72 popes who live at " (papież)".""" + assert article_url(pope_for(13)).endswith("Eleuteriusz_%28papie%C5%BC%29") + assert article_url(pope_for(264)).endswith("Jan_Pawe%C5%82_II") diff --git a/tool/gen_popes.py b/tool/gen_popes.py index 9c9729e..f0202d1 100755 --- a/tool/gen_popes.py +++ b/tool/gen_popes.py @@ -156,7 +156,10 @@ def _drift(popes: list[dict[str, object]], previous: Path) -> str: now = fresh.get(entry["ordinal"]) if now is None: return f"#{entry['ordinal']} vanished from the list" - changed = [k for k in ("name", "reign", "portrait") if entry.get(k, now[k]) != now[k]] + # latin is in the tuple because latin_name() derives the release name + # from it: an upstream edit there renames a shipped version. + fields = ("name", "reign", "portrait", "latin") + changed = [k for k in fields if entry.get(k, now[k]) != now[k]] if changed: return f"#{entry['ordinal']} changed {changed}: {entry} -> {now}" return ""