mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 18:07:29 +01:00
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:
parent
ca38147576
commit
3d6ae0680c
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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)}"
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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 ""
|
||||
|
||||
Loading…
Reference in New Issue
Block a user