feat: releases are named in Latin, and land with the pope's portrait and article

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-08-30 21:28:41 +02:00
parent ca38147576
commit 3d6ae0680c
13 changed files with 393 additions and 76 deletions

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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,
)

View File

@ -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."""

View File

@ -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)}"

View File

@ -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)

View File

@ -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]

View File

@ -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

View File

@ -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)

View File

@ -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 "<name> (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")

View File

@ -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 ""