diff --git a/DOCS-changelog.md b/DOCS-changelog.md new file mode 100644 index 0000000..912aa60 --- /dev/null +++ b/DOCS-changelog.md @@ -0,0 +1,27 @@ +# Changelog + +Releases are named after the popes, in order: release *n* is pope *n*. The +version number is a pure function of that ordinal — see `signal_bot/versioning.py` +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 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. + +## 0.0.1 — Linus + +- wersje bota nazywają się teraz imionami kolejnych papieży +- 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ł + +- bot taki, jaki był przed drabinką papieską: `!cat`, `!dog` i dzienne podsumowanie diff --git a/README.md b/README.md index aa5c1dc..fb938e5 100644 --- a/README.md +++ b/README.md @@ -237,13 +237,13 @@ Counts live in SQLite and survive a restart. ## Develop ```sh -pytest # 189 tests, 100% branch coverage, no network +pytest # 359 tests, 100% branch coverage, no network pre-commit run --all-files # ruff ALL + mypy strict + 250-line cap + gates ./scripts/e2e.sh # the real bot against the fake server ``` -No `# noqa`, no `# type: ignore`, no coverage omits. Docs on the wire format -are in [DOCS-signal-envelopes.md](DOCS-signal-envelopes.md). +No `# noqa`, no `# type: ignore`, no coverage omits. Wire format: +[DOCS-signal-envelopes.md](DOCS-signal-envelopes.md). Releases are named after the popes, in order: [DOCS-changelog.md](DOCS-changelog.md). ## Licence diff --git a/pyproject.toml b/pyproject.toml index e7b6fc3..633b031 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "signal-bot" -version = "1.0.0" +version = "0.0.1" description = "A Signal group bot: image commands and daily message tallies." readme = "README.md" license = "MIT" diff --git a/signal_bot/announce.py b/signal_bot/announce.py new file mode 100644 index 0000000..3f5ca7c --- /dev/null +++ b/signal_bot/announce.py @@ -0,0 +1,100 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""Telling a group that the bot has been upgraded. + +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. + +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 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. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Final + +from signal_bot.changelog import since + +if TYPE_CHECKING: + from collections.abc import Iterable + + from signal_bot.changelog import Release + from signal_bot.store import Store + from signal_bot.transport_base import Transport + +logger = logging.getLogger(__name__) + +ANNOUNCED_KEY: Final = "announced_version" + +# How many releases one announcement may list before it becomes a wall. +MAX_RELEASES: Final = 10 + + +def render(version: str, pope: str, 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. + """ + shown = list(releases) + header = f"Wersja {version} \u00ab{pope}\u00bb wydana" + omitted = max(0, len(shown) - MAX_RELEASES) + shown = shown[-MAX_RELEASES:] + if len(shown) == 1: + body = "\n".join(f"- {line}" for line in shown[0].lines) + return f"{header}\nChangelog:\n{body}" + blocks = [ + "\n".join( + [f"{release.version} \u00ab{release.pope}\u00bb", *(f"- {x}" for x in release.lines)] + ) + for release in shown + ] + tail = f"\n\n(pominieto {omitted} starszych wersji)" if omitted else "" + return f"{header}\nChangelog:\n\n" + "\n\n".join(blocks) + tail + + +async def announce_to( + transport: Transport, + store: Store, + group_id: str, + releases: tuple[Release, ...], + version: str, + pope: str, +) -> None: + """Announce the current version to one group, if it has not seen it.""" + 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. + 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) + + +async def announce_all( + transport: Transport, + store: Store, + group_ids: Iterable[str], + releases: tuple[Release, ...], + version: str, + pope: str, +) -> 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) + 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 1de839b..d27b69e 100644 --- a/signal_bot/app.py +++ b/signal_bot/app.py @@ -19,6 +19,8 @@ import httpx from fastapi import FastAPI, Response, status from signal_bot.animals import register_animal_commands +from signal_bot.announce import announce_all +from signal_bot.changelog import load as load_changelog from signal_bot.config import Config, load_config from signal_bot.counting import format_tally from signal_bot.decorators import cooldown @@ -31,6 +33,7 @@ from signal_bot.scheduler import DailyScheduler from signal_bot.store import Store from signal_bot.transport_rest import RestTransport from signal_bot.trust import InviteTrust +from signal_bot.versioning import POPE, __version__ logger = logging.getLogger(__name__) @@ -78,6 +81,17 @@ class Bot: continue self.store.reset_tallies(group_id) + async def announce_release(self) -> None: + """Tell every watched group about a version it has not seen yet.""" + await announce_all( + self.transport, + self.store, + list(self.directory.groups), + load_changelog(), + __version__, + POPE.name, + ) + async def listen(self) -> None: """Consume frames forever, handing each to the dispatcher.""" async for frame in self.transport.receive(): @@ -149,6 +163,9 @@ class Runtime: # Fourth loop: without it, a dead connection to Signal is # indistinguishable from a quiet evening, for hours. asyncio.create_task(self.bot.liveness.run()), + # Fifth, and one-shot: says hello with a changelog when the + # running version is newer than what a group was last told. + asyncio.create_task(self.bot.announce_release()), ] async def stop(self) -> None: @@ -218,7 +235,8 @@ async def healthz(response: Response) -> dict[str, str]: thinks to look at the logs. """ liveness: Liveness = app.state.runtime.bot.liveness + build = {"version": __version__, "pope": POPE.name} if liveness.healthy: - return {"status": "ok"} + return {"status": "ok", **build} response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE - return {"status": "deaf", "reason": liveness.reason} + return {"status": "deaf", "reason": liveness.reason, **build} diff --git a/signal_bot/changelog.py b/signal_bot/changelog.py new file mode 100644 index 0000000..31a8640 --- /dev/null +++ b/signal_bot/changelog.py @@ -0,0 +1,80 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""The changelog the bot announces, read from DOCS-changelog.md. + +That file is the single source: there is no second machine-readable copy to +drift out of sync with it. Only two shapes are recognised -- a `## — +` heading and a `- ` bullet beneath one -- so prose around them is free. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Final + +from signal_bot.versioning import ordinal_for + +# Repo root, since the changelog is documentation rather than package data. +DEFAULT_PATH: Final = Path(__file__).resolve().parent.parent / "DOCS-changelog.md" + +_HEADING: Final = re.compile(r"^##\s+(\d+\.\d+\.\d+)\s*[—-]\s*(.+?)\s*$") +_BULLET: Final = re.compile(r"^-\s+(.+?)\s*$") + + +@dataclass(frozen=True, slots=True) +class Release: + """One released version and the lines describing it.""" + + version: str + pope: str + lines: tuple[str, ...] + + @property + def ordinal(self) -> int: + """The papal ordinal this release carries.""" + return ordinal_for(self.version) + + +def parse(text: str) -> tuple[Release, ...]: + """Return every release in the document, oldest first.""" + releases: list[Release] = [] + version = pope = "" + lines: list[str] = [] + for raw in text.splitlines(): + heading = _HEADING.match(raw) + if heading is not None: + if version: + releases.append(Release(version, pope, tuple(lines))) + version, pope, lines = heading.group(1), heading.group(2), [] + continue + bullet = _BULLET.match(raw) + if bullet is not None and version: + lines.append(bullet.group(1)) + if version: + releases.append(Release(version, pope, tuple(lines))) + return tuple(sorted(releases, key=lambda release: release.ordinal)) + + +def load(path: Path | None = None) -> tuple[Release, ...]: + """Return every release, or nothing if the changelog is not on disk. + + A missing changelog must not stop the bot: it is documentation that lives + outside the package, so an odd install layout should cost an announcement + rather than the whole process. + """ + source = DEFAULT_PATH if path is None else path + try: + text = source.read_text(encoding="utf-8") + except OSError: + return () + return parse(text) + + +def since(releases: tuple[Release, ...], previous: str) -> tuple[Release, ...]: + """Return the releases newer than ``previous``, oldest first.""" + try: + floor = ordinal_for(previous) + except ValueError: + return () + return tuple(release for release in releases if release.ordinal > floor) diff --git a/signal_bot/popes.json b/signal_bot/popes.json new file mode 100644 index 0000000..c1d8f99 --- /dev/null +++ b/signal_bot/popes.json @@ -0,0 +1,1607 @@ +{ + "source": "https://pl.wikipedia.org/w/api.php?action=parse&page=Poczet%20papie%C5%BCy&prop=wikitext&format=json&formatversion=2", + "popes": [ + { + "ordinal": 1, + "name": "Święty Piotr Apostoł", + "reign": "33-64", + "portrait": "1-St.Peter.jpg" + }, + { + "ordinal": 2, + "name": "Linus", + "reign": "64-76", + "portrait": "2-St.Linus.jpg" + }, + { + "ordinal": 3, + "name": "Anaklet", + "reign": "76-88", + "portrait": "3-St.Cletus.jpg" + }, + { + "ordinal": 4, + "name": "Klemens I", + "reign": "88-97", + "portrait": "4-St.Clement I.jpg" + }, + { + "ordinal": 5, + "name": "Ewaryst", + "reign": "97-105", + "portrait": "5-St.Evaristus.jpg" + }, + { + "ordinal": 6, + "name": "Aleksander I", + "reign": "105-115", + "portrait": "6-St.Alexander I.jpg" + }, + { + "ordinal": 7, + "name": "Sykstus I", + "reign": "115-125", + "portrait": "7-St.Sixtus I.jpg" + }, + { + "ordinal": 8, + "name": "Telesfor", + "reign": "125-136", + "portrait": "8-St.Telesphrous.jpg" + }, + { + "ordinal": 9, + "name": "Hygin", + "reign": "136-140", + "portrait": "9-St.Hyginus.jpg" + }, + { + "ordinal": 10, + "name": "Pius I", + "reign": "140-155", + "portrait": "10-St.Pius I.jpg" + }, + { + "ordinal": 11, + "name": "Anicet", + "reign": "155-166", + "portrait": "11-St.Anicetus.jpg" + }, + { + "ordinal": 12, + "name": "Soter", + "reign": "166-175", + "portrait": "12-St.Soter.jpg" + }, + { + "ordinal": 13, + "name": "Eleuteriusz", + "reign": "175-189", + "portrait": "13-St.Eleuterus.jpg" + }, + { + "ordinal": 14, + "name": "Wiktor I", + "reign": "189-199", + "portrait": "14-St.Victor I.jpg" + }, + { + "ordinal": 15, + "name": "Zefiryn", + "reign": "199-217", + "portrait": "15-St.Zephyrinus.jpg" + }, + { + "ordinal": 16, + "name": "Kalikst I", + "reign": "217-222", + "portrait": "CalixtusI.jpg" + }, + { + "ordinal": 17, + "name": "Urban I", + "reign": "222-230", + "portrait": "UrbanI.jpg" + }, + { + "ordinal": 18, + "name": "Poncjan", + "reign": "230-235", + "portrait": "Pontianus (pope).jpg" + }, + { + "ordinal": 19, + "name": "Anteros", + "reign": "235-236", + "portrait": "Anterus.jpg" + }, + { + "ordinal": 20, + "name": "Fabian", + "reign": "236-250", + "portrait": "Saint Fabian1.jpg" + }, + { + "ordinal": 21, + "name": "Korneliusz", + "reign": "251-253", + "portrait": "Heiliger Cornelius.jpg" + }, + { + "ordinal": 22, + "name": "Lucjusz I", + "reign": "253-254", + "portrait": "Lucius I.jpg" + }, + { + "ordinal": 23, + "name": "Stefan I", + "reign": "254-257", + "portrait": "Stephen I.jpg" + }, + { + "ordinal": 24, + "name": "Sykstus II", + "reign": "257-258", + "portrait": "Altenfelden - Hochaltar - Papst Sixtus II. 1858.jpg" + }, + { + "ordinal": 25, + "name": "Dionizy", + "reign": "259-268", + "portrait": "Pope Dionysius.jpg" + }, + { + "ordinal": 26, + "name": "Feliks I", + "reign": "269-274", + "portrait": "PopeFelixI.jpg" + }, + { + "ordinal": 27, + "name": "Eutychian", + "reign": "275-283", + "portrait": "Eutychian.jpg" + }, + { + "ordinal": 28, + "name": "Kajus", + "reign": "283-296", + "portrait": "PCaius.jpg" + }, + { + "ordinal": 29, + "name": "Marcelin", + "reign": "296-304", + "portrait": "Marcellinus.jpg" + }, + { + "ordinal": 30, + "name": "Marceli I", + "reign": "308-309", + "portrait": "Papa Marcelo I.jpg" + }, + { + "ordinal": 31, + "name": "Euzebiusz", + "reign": "309-310", + "portrait": "Eusebius.jpg" + }, + { + "ordinal": 32, + "name": "Milcjades", + "reign": "311-314", + "portrait": "Pope miltiades.jpg" + }, + { + "ordinal": 33, + "name": "Sylwester I", + "reign": "314-335", + "portrait": "Sylvester I.jpg" + }, + { + "ordinal": 34, + "name": "Marek", + "reign": "336-336", + "portrait": "Marcus (papa).jpg" + }, + { + "ordinal": 35, + "name": "Juliusz I", + "reign": "337-352", + "portrait": "Iulius I.jpg" + }, + { + "ordinal": 36, + "name": "Liberiusz", + "reign": "352-366", + "portrait": "Liberio papa1.gif" + }, + { + "ordinal": 37, + "name": "Damazy I", + "reign": "366-384", + "portrait": "Saintdamasus.png" + }, + { + "ordinal": 38, + "name": "Syrycjusz", + "reign": "384-399", + "portrait": "Siricius.jpg" + }, + { + "ordinal": 39, + "name": "Anastazy I", + "reign": "399-401", + "portrait": "Anastasius I.jpg" + }, + { + "ordinal": 40, + "name": "Innocenty I", + "reign": "401-417", + "portrait": "Innocentius I.jpg" + }, + { + "ordinal": 41, + "name": "Zozym", + "reign": "417-418", + "portrait": "Zosimus.jpg" + }, + { + "ordinal": 42, + "name": "Bonifacy I", + "reign": "418-422", + "portrait": "Bonifacius I.jpg" + }, + { + "ordinal": 43, + "name": "Celestyn I", + "reign": "422-432", + "portrait": "Coelestinus I.png" + }, + { + "ordinal": 44, + "name": "Sykstus III", + "reign": "432-440", + "portrait": "Pope-Sixtus-III.jpg" + }, + { + "ordinal": 45, + "name": "Leon I", + "reign": "440-461", + "portrait": "Papa Leo I Magnus.jpg" + }, + { + "ordinal": 46, + "name": "Hilary", + "reign": "461-468", + "portrait": "Pope Hilarius.jpg" + }, + { + "ordinal": 47, + "name": "Symplicjusz", + "reign": "468-483", + "portrait": "Simplicius.jpg" + }, + { + "ordinal": 48, + "name": "Feliks III", + "reign": "483-492", + "portrait": "Felix3.jpg" + }, + { + "ordinal": 49, + "name": "Gelazjusz I", + "reign": "492-496", + "portrait": "San Galasio I.jpg" + }, + { + "ordinal": 50, + "name": "Anastazy II", + "reign": "496-498", + "portrait": "Anastasius II.jpg" + }, + { + "ordinal": 51, + "name": "Symmachus", + "reign": "498-514", + "portrait": "Papa Symmachus.jpg" + }, + { + "ordinal": 52, + "name": "Hormizdas", + "reign": "514-523", + "portrait": "Pope Hormisdas.jpg" + }, + { + "ordinal": 53, + "name": "Jan I", + "reign": "523-526", + "portrait": "Johannes I.jpg" + }, + { + "ordinal": 54, + "name": "Feliks IV", + "reign": "526-530", + "portrait": "Papa Felix IV.jpg" + }, + { + "ordinal": 55, + "name": "Bonifacy II", + "reign": "530-532", + "portrait": "Boniface II.jpg" + }, + { + "ordinal": 56, + "name": "Jan II", + "reign": "533-535", + "portrait": "Johannes II.jpg" + }, + { + "ordinal": 57, + "name": "Agapit I", + "reign": "535-536", + "portrait": "Papa Agapito I.jpg" + }, + { + "ordinal": 58, + "name": "Sylweriusz", + "reign": "536-537", + "portrait": "Silverius.jpg" + }, + { + "ordinal": 59, + "name": "Wigiliusz", + "reign": "537-555", + "portrait": "Vigilius.jpg" + }, + { + "ordinal": 60, + "name": "Pelagiusz I", + "reign": "556-561", + "portrait": "Pelagius I.jpg" + }, + { + "ordinal": 61, + "name": "Jan III", + "reign": "561-574", + "portrait": "Papa Joao III.jpg" + }, + { + "ordinal": 62, + "name": "Benedykt I", + "reign": "575-579", + "portrait": "Benedict I.jpg" + }, + { + "ordinal": 63, + "name": "Pelagiusz II", + "reign": "579-590", + "portrait": "Pelagius II.jpg" + }, + { + "ordinal": 64, + "name": "Grzegorz I", + "reign": "590-604", + "portrait": "Gregorythegreat.jpg" + }, + { + "ordinal": 65, + "name": "Sabinian", + "reign": "604-606", + "portrait": "Sabinian.jpg" + }, + { + "ordinal": 66, + "name": "Bonifacy III", + "reign": "607-607", + "portrait": "Boniface III.jpg" + }, + { + "ordinal": 67, + "name": "Bonifacy IV", + "reign": "608-615", + "portrait": "Boniface IV.jpg" + }, + { + "ordinal": 68, + "name": "Adeodat I", + "reign": "615-618", + "portrait": "Adeodatus I (Deusdedit I).jpg" + }, + { + "ordinal": 69, + "name": "Bonifacy V", + "reign": "619-625", + "portrait": "Papa Bonifacio V.jpg" + }, + { + "ordinal": 70, + "name": "Honoriusz I", + "reign": "625-638", + "portrait": "Honorius I.jpg" + }, + { + "ordinal": 71, + "name": "Seweryn", + "reign": "640-640", + "portrait": "Severinus.jpg" + }, + { + "ordinal": 72, + "name": "Jan IV", + "reign": "640-642", + "portrait": "Murner History Cod Karlsruhe 3117 (crop).jpg" + }, + { + "ordinal": 73, + "name": "Teodor I", + "reign": "642-649", + "portrait": "Theodorus I.jpg" + }, + { + "ordinal": 74, + "name": "Marcin I", + "reign": "649-655", + "portrait": "Pope Martin I.jpg" + }, + { + "ordinal": 75, + "name": "Eugeniusz I", + "reign": "655-657", + "portrait": "Papa Eugenio I (1).jpg" + }, + { + "ordinal": 76, + "name": "Witalian", + "reign": "657-672", + "portrait": "Pope Vitalian.jpg" + }, + { + "ordinal": 77, + "name": "Adeodat II", + "reign": "672-676", + "portrait": "Adeodatus II.jpg" + }, + { + "ordinal": 78, + "name": "Donus", + "reign": "676-678", + "portrait": "Dono I.jpg" + }, + { + "ordinal": 79, + "name": "Agaton", + "reign": "678-681", + "portrait": "Agatho.jpg" + }, + { + "ordinal": 80, + "name": "Leon II", + "reign": "682-683", + "portrait": "LeoII.jpg" + }, + { + "ordinal": 81, + "name": "Benedykt II", + "reign": "684-685", + "portrait": "BenedictII.jpg" + }, + { + "ordinal": 82, + "name": "Jan V", + "reign": "685-686", + "portrait": "Johannes V.jpg" + }, + { + "ordinal": 83, + "name": "Konon", + "reign": "686-687", + "portrait": "Konon.jpg" + }, + { + "ordinal": 84, + "name": "Sergiusz I", + "reign": "687-701", + "portrait": "Sergius I.jpg" + }, + { + "ordinal": 85, + "name": "Jan VI", + "reign": "701-705", + "portrait": "John VI.jpg" + }, + { + "ordinal": 86, + "name": "Jan VII", + "reign": "705-707", + "portrait": "Greek Pope John VII.JPG" + }, + { + "ordinal": 87, + "name": "Syzyniusz", + "reign": "708-708", + "portrait": "Sisinnius.jpg" + }, + { + "ordinal": 88, + "name": "Konstantyn", + "reign": "708-715", + "portrait": "PopeConstantine.jpg" + }, + { + "ordinal": 89, + "name": "Grzegorz II", + "reign": "715-731", + "portrait": "GregoryII.jpg" + }, + { + "ordinal": 90, + "name": "Grzegorz III", + "reign": "731-741", + "portrait": "Pope Gregory III.jpg" + }, + { + "ordinal": 91, + "name": "Zachariasz", + "reign": "741-752", + "portrait": "Pope Zachary.jpg" + }, + { + "ordinal": 92, + "name": "Stefan II", + "reign": "752-757", + "portrait": "La donacion de Pipino el Breve al Papa Esteban II.jpg" + }, + { + "ordinal": 93, + "name": "Paweł I", + "reign": "757-767", + "portrait": "Paul I.jpg" + }, + { + "ordinal": 94, + "name": "Stefan III", + "reign": "768-772", + "portrait": "StephenIII.jpg" + }, + { + "ordinal": 95, + "name": "Hadrian I", + "reign": "772-795", + "portrait": "Papa Hadrianus I.jpg" + }, + { + "ordinal": 96, + "name": "Leon III", + "reign": "795-816", + "portrait": "Papa Leone III.jpg" + }, + { + "ordinal": 97, + "name": "Stefan IV", + "reign": "816-817", + "portrait": "Stephen IV.jpg" + }, + { + "ordinal": 98, + "name": "Paschalis I", + "reign": "817-824", + "portrait": "Pope Paschalis I. in apsis mosaic of Santa Prassede in Rome.gif" + }, + { + "ordinal": 99, + "name": "Eugeniusz II", + "reign": "824-827", + "portrait": "Eugene II.jpg" + }, + { + "ordinal": 100, + "name": "Walentyn", + "reign": "827-827", + "portrait": "Valentine.jpg" + }, + { + "ordinal": 101, + "name": "Grzegorz IV", + "reign": "827-844", + "portrait": "Gregory IV.jpg" + }, + { + "ordinal": 102, + "name": "Sergiusz II", + "reign": "844-847", + "portrait": "Pope Sergius II.jpg" + }, + { + "ordinal": 103, + "name": "Leon IV", + "reign": "847-855", + "portrait": "St.Leo IV.jpg" + }, + { + "ordinal": 104, + "name": "Benedykt III", + "reign": "855-858", + "portrait": "Benedict III.jpg" + }, + { + "ordinal": 105, + "name": "Mikołaj I Wielki", + "reign": "858-867", + "portrait": "Nicholas I.jpg" + }, + { + "ordinal": 106, + "name": "Hadrian II", + "reign": "867-872", + "portrait": "Adrian II.jpg" + }, + { + "ordinal": 107, + "name": "Jan VIII", + "reign": "872-882", + "portrait": "Papa Ioannes VIII.jpg" + }, + { + "ordinal": 108, + "name": "Maryn I", + "reign": "882-884", + "portrait": "Marinus I.jpg" + }, + { + "ordinal": 109, + "name": "Hadrian III", + "reign": "884-885", + "portrait": "Papa Hardianus III.jpg" + }, + { + "ordinal": 110, + "name": "Stefan V", + "reign": "885-891", + "portrait": "Stephen V.jpg" + }, + { + "ordinal": 111, + "name": "Formozus", + "reign": "891-896", + "portrait": "PopeFormosusBW.jpg" + }, + { + "ordinal": 112, + "name": "Bonifacy VI", + "reign": "896", + "portrait": "Boniface VI.jpg" + }, + { + "ordinal": 113, + "name": "Stefan VI", + "reign": "896-897", + "portrait": "Stephen VI.jpg" + }, + { + "ordinal": 114, + "name": "Roman", + "reign": "897-897", + "portrait": "Pope Romanus.jpg" + }, + { + "ordinal": 115, + "name": "Teodor II", + "reign": "897", + "portrait": "Pope Theodore II.jpg" + }, + { + "ordinal": 116, + "name": "Jan IX", + "reign": "898-900", + "portrait": "John IX.jpg" + }, + { + "ordinal": 117, + "name": "Benedykt IV", + "reign": "900-903", + "portrait": "Pope Benedict IV.jpg" + }, + { + "ordinal": 118, + "name": "Leon V", + "reign": "903-903", + "portrait": "Pope Leo V.jpg" + }, + { + "ordinal": 119, + "name": "Sergiusz III", + "reign": "904-911", + "portrait": "SergiusIII.jpg" + }, + { + "ordinal": 120, + "name": "Anastazy III", + "reign": "911-913", + "portrait": "Pope Anastasius III.jpg" + }, + { + "ordinal": 121, + "name": "Lando", + "reign": "913-914", + "portrait": "Pope Lando.jpg" + }, + { + "ordinal": 122, + "name": "Jan X", + "reign": "914-928", + "portrait": "Pope John X.jpg" + }, + { + "ordinal": 123, + "name": "Leon VI", + "reign": "928-928", + "portrait": "Pope Leo VI.jpg" + }, + { + "ordinal": 124, + "name": "Stefan VII", + "reign": "928-931", + "portrait": "Stephen VII.jpg" + }, + { + "ordinal": 125, + "name": "Jan XI", + "reign": "931-935", + "portrait": "Papa Ioannes XI.jpg" + }, + { + "ordinal": 126, + "name": "Leon VII", + "reign": "936-939", + "portrait": "Leone-VII.jpg" + }, + { + "ordinal": 127, + "name": "Stefan VIII", + "reign": "939-942", + "portrait": "Stephen VIII.png" + }, + { + "ordinal": 128, + "name": "Maryn II", + "reign": "942-946", + "portrait": "Marinus II.jpg" + }, + { + "ordinal": 129, + "name": "Agapit II", + "reign": "946-955", + "portrait": "Pope Agapetus II.jpg" + }, + { + "ordinal": 130, + "name": "Jan XII", + "reign": "955-964", + "portrait": "GiovanniXII.png" + }, + { + "ordinal": 131, + "name": "Leon VIII", + "reign": "963-965", + "portrait": "Pope Leo VIII.jpg" + }, + { + "ordinal": 132, + "name": "Benedykt V", + "reign": "964-965", + "portrait": "Pope Benedict V.jpg" + }, + { + "ordinal": 133, + "name": "Jan XIII", + "reign": "965-972", + "portrait": "Giovanni13.jpg" + }, + { + "ordinal": 134, + "name": "Benedykt VI", + "reign": "973-974", + "portrait": "Pope Benedict VI.jpg" + }, + { + "ordinal": 135, + "name": "Benedykt VII", + "reign": "974-983", + "portrait": "Pope Benedict VII.jpg" + }, + { + "ordinal": 136, + "name": "Jan XIV", + "reign": "983-984", + "portrait": "Pope John XIV.jpg" + }, + { + "ordinal": 137, + "name": "Jan XV", + "reign": "985-996", + "portrait": "Pope John XV.jpg" + }, + { + "ordinal": 138, + "name": "Grzegorz V", + "reign": "996-999", + "portrait": "Otto III wird von Papst Gregor V. zum Kaiser gesalbt.jpg" + }, + { + "ordinal": 139, + "name": "Sylwester II", + "reign": "999-1003", + "portrait": "Silvester II.JPG" + }, + { + "ordinal": 140, + "name": "Jan XVII", + "reign": "1003-1003", + "portrait": "Papa Joao XVII.jpg" + }, + { + "ordinal": 141, + "name": "Jan XVIII", + "reign": "1004-1009", + "portrait": "Ioannes XVIII.jpg" + }, + { + "ordinal": 142, + "name": "Sergiusz IV", + "reign": "1009-1012", + "portrait": "Sergius IV.jpg" + }, + { + "ordinal": 143, + "name": "Benedykt VIII", + "reign": "1012-1024", + "portrait": "BENEDICT VIII.jpg" + }, + { + "ordinal": 144, + "name": "Jan XIX", + "reign": "1024-1032", + "portrait": "B Johannes XIX.jpg" + }, + { + "ordinal": 145, + "name": "Benedykt IX", + "reign": "1032-1044", + "portrait": "Pope Benedict IX.jpg" + }, + { + "ordinal": 146, + "name": "Sylwester III", + "reign": "1045-1045", + "portrait": "Silvestro3.jpg" + }, + { + "ordinal": 147, + "name": "Benedykt IX", + "reign": "1045-1045", + "portrait": "Pope Benedict IX.jpg" + }, + { + "ordinal": 148, + "name": "Grzegorz VI", + "reign": "1045-1046", + "portrait": "148-Gregory VI.jpg" + }, + { + "ordinal": 149, + "name": "Klemens II", + "reign": "1046-1047", + "portrait": "ClementeII.jpg" + }, + { + "ordinal": 150, + "name": "Benedykt IX", + "reign": "1047-1048", + "portrait": "Pope Benedict IX.jpg" + }, + { + "ordinal": 151, + "name": "Damazy II", + "reign": "1048-1048", + "portrait": "B Damasus II1.jpg" + }, + { + "ordinal": 152, + "name": "Leon IX", + "reign": "1049-1054", + "portrait": "Papa Leone IX.jpg" + }, + { + "ordinal": 153, + "name": "Wiktor II", + "reign": "1055-1057", + "portrait": "Papa Vittore II.jpg" + }, + { + "ordinal": 154, + "name": "Stefan IX", + "reign": "1057-1058", + "portrait": "Papa Stefano IX.jpg" + }, + { + "ordinal": 155, + "name": "Mikołaj II", + "reign": "1058-1061", + "portrait": "Papa Nicolau II.jpg" + }, + { + "ordinal": 156, + "name": "Aleksander II", + "reign": "1061-1073", + "portrait": "Papa Alexandre II.jpg" + }, + { + "ordinal": 157, + "name": "Grzegorz VII", + "reign": "1073-1085", + "portrait": "Papa Gregorio VII.jpg" + }, + { + "ordinal": 158, + "name": "Wiktor III", + "reign": "1086-1087", + "portrait": "Papa Vittorio III.jpg" + }, + { + "ordinal": 159, + "name": "Urban II", + "reign": "1088-1099", + "portrait": "Urban II cropped.png" + }, + { + "ordinal": 160, + "name": "Paschalis II", + "reign": "1099-1118", + "portrait": "B Paschalis II.jpg" + }, + { + "ordinal": 161, + "name": "Gelazjusz II", + "reign": "1118-1119", + "portrait": "GelasioII.gif" + }, + { + "ordinal": 162, + "name": "Kalikst II", + "reign": "1119-1124", + "portrait": "Callistus II.png" + }, + { + "ordinal": 163, + "name": "Honoriusz II", + "reign": "1124-1130", + "portrait": "Pope honorius ii.jpg" + }, + { + "ordinal": 164, + "name": "Innocenty II", + "reign": "1130-1143", + "portrait": "B Innozenz II.jpg" + }, + { + "ordinal": 165, + "name": "Celestyn II", + "reign": "1143-1144", + "portrait": "Caelestinus II.jpg" + }, + { + "ordinal": 166, + "name": "Lucjusz II", + "reign": "1144-1145", + "portrait": "B Lucius II.jpg" + }, + { + "ordinal": 167, + "name": "Eugeniusz III", + "reign": "1145-1153", + "portrait": "B Eugen III.jpg" + }, + { + "ordinal": 168, + "name": "Anastazy IV", + "reign": "1153-1154", + "portrait": "B Anastasius IV.jpg" + }, + { + "ordinal": 169, + "name": "Hadrian IV", + "reign": "1154-1159", + "portrait": "PopeAdrianIVCameo.jpg" + }, + { + "ordinal": 170, + "name": "Aleksander III", + "reign": "1159-1181", + "portrait": "Papa alessandro III illustrazione di spinello aretino particolare siena italia 03.gif" + }, + { + "ordinal": 171, + "name": "Lucjusz III", + "reign": "1181-1185", + "portrait": "Pope Lucius III.png" + }, + { + "ordinal": 172, + "name": "Urban III", + "reign": "1185-1187", + "portrait": "B Urban III.jpg" + }, + { + "ordinal": 173, + "name": "Grzegorz VIII", + "reign": "1187-1187", + "portrait": "B Gregor VIII.jpg" + }, + { + "ordinal": 174, + "name": "Klemens III", + "reign": "1187-1191", + "portrait": "B Clemens III.jpg" + }, + { + "ordinal": 175, + "name": "Celestyn III", + "reign": "1191-1198", + "portrait": "Celestin III.jpg" + }, + { + "ordinal": 176, + "name": "Innocenty III", + "reign": "1198-1216", + "portrait": "Innozenz3.jpg" + }, + { + "ordinal": 177, + "name": "Honoriusz III", + "reign": "1216-1227", + "portrait": "Interior of Santi Giovanni e Paolo (Venice) - Honorius III by Leandro Bassano.jpg" + }, + { + "ordinal": 178, + "name": "Grzegorz IX", + "reign": "1227-1241", + "portrait": "PopeGregoryIX.jpg" + }, + { + "ordinal": 179, + "name": "Celestyn IV", + "reign": "1241-1241", + "portrait": "B Colestin IV.jpg" + }, + { + "ordinal": 180, + "name": "Innocenty IV", + "reign": "1243-1254", + "portrait": "Inocenc IV.jpg" + }, + { + "ordinal": 181, + "name": "Aleksander IV", + "reign": "1254-1261", + "portrait": "B Alexander IV.jpg" + }, + { + "ordinal": 182, + "name": "Urban IV", + "reign": "1261-1264", + "portrait": "Pope Urban IV.jpg" + }, + { + "ordinal": 183, + "name": "Klemens IV", + "reign": "1265-1268", + "portrait": "Pope Clement IV.jpg" + }, + { + "ordinal": 184, + "name": "Grzegorz X", + "reign": "1271-1276", + "portrait": "B Gregor X.jpg" + }, + { + "ordinal": 185, + "name": "Innocenty V", + "reign": "1276-1276", + "portrait": "InnocentV.jpg" + }, + { + "ordinal": 186, + "name": "Hadrian V", + "reign": "1276-1276", + "portrait": "Hadrian V.png" + }, + { + "ordinal": 187, + "name": "Jan XXI", + "reign": "1276-1277", + "portrait": "Pope John XXI (crop).jpg" + }, + { + "ordinal": 188, + "name": "Mikołaj III", + "reign": "1277-1280", + "portrait": "NicholasIII.jpg" + }, + { + "ordinal": 189, + "name": "Marcin IV", + "reign": "1281-1285", + "portrait": "B Martin IV.jpg" + }, + { + "ordinal": 190, + "name": "Honoriusz IV", + "reign": "1285-1287", + "portrait": "PopeOnorioIV.jpg" + }, + { + "ordinal": 191, + "name": "Mikołaj IV", + "reign": "1288-1292", + "portrait": "NicholasIV.jpg" + }, + { + "ordinal": 192, + "name": "Celestyn V", + "reign": "1294-1294", + "portrait": "Celestinus quintus.jpg" + }, + { + "ordinal": 193, + "name": "Bonifacy VIII", + "reign": "1294-1303", + "portrait": "Bonifatius viii papst.jpg" + }, + { + "ordinal": 194, + "name": "Benedykt XI", + "reign": "1303-1304", + "portrait": "B Benedikt XI.jpg" + }, + { + "ordinal": 195, + "name": "Klemens V", + "reign": "1305-1314", + "portrait": "Papst klemens v.jpg" + }, + { + "ordinal": 196, + "name": "Jan XXII", + "reign": "1316-1334", + "portrait": "John22.jpg" + }, + { + "ordinal": 197, + "name": "Benedykt XII", + "reign": "1334-1342", + "portrait": "Benedikt XII1.png" + }, + { + "ordinal": 198, + "name": "Klemens VI", + "reign": "1342-1352", + "portrait": "Clemens VI.png" + }, + { + "ordinal": 199, + "name": "Innocenty VI", + "reign": "1352-1362", + "portrait": "Innozenz VI.gif" + }, + { + "ordinal": 200, + "name": "Urban V", + "reign": "1362-1370", + "portrait": "Urban V.gif" + }, + { + "ordinal": 201, + "name": "Grzegorz XI", + "reign": "1370-1378", + "portrait": "PopeGregoryXI.jpg" + }, + { + "ordinal": 202, + "name": "Urban VI", + "reign": "1378-1389", + "portrait": "Urbanus VI.jpg" + }, + { + "ordinal": 203, + "name": "Bonifacy IX", + "reign": "1389-1404", + "portrait": "IX.Bonifac.jpg" + }, + { + "ordinal": 204, + "name": "Innocenty VII", + "reign": "1404-1406", + "portrait": "Innocent VII.jpg" + }, + { + "ordinal": 205, + "name": "Grzegorz XII", + "reign": "1406-1415", + "portrait": "Gregory XII.jpg" + }, + { + "ordinal": 206, + "name": "Marcin V", + "reign": "1417-1431", + "portrait": "Pisanello, copia da Ritratto di Martino V (Galleria Colonna).jpg" + }, + { + "ordinal": 207, + "name": "Eugeniusz IV", + "reign": "1431-1447", + "portrait": "PapstEugen.jpg" + }, + { + "ordinal": 208, + "name": "Mikołaj V", + "reign": "1447-1455", + "portrait": "Paus Nicolaas V door Peter Paul Rubens.jpg" + }, + { + "ordinal": 209, + "name": "Kalikst III", + "reign": "1455-1458", + "portrait": "Calixtus III.jpg" + }, + { + "ordinal": 210, + "name": "Pius II", + "reign": "1458-1464", + "portrait": "Pintoricchio 014.jpg" + }, + { + "ordinal": 211, + "name": "Paweł II", + "reign": "1464-1471", + "portrait": "Pietrobarbo.jpg" + }, + { + "ordinal": 212, + "name": "Sykstus IV", + "reign": "1471-1484", + "portrait": "Pope Sixtus IV (head).jpg" + }, + { + "ordinal": 213, + "name": "Innocenty VIII", + "reign": "1484-1492", + "portrait": "Ritratto di papa Innocenzo VIII – Calvi dell'Umbria.jpg" + }, + { + "ordinal": 214, + "name": "Aleksander VI", + "reign": "1492-1503", + "portrait": "Pope Alexander Vi adj.JPG" + }, + { + "ordinal": 215, + "name": "Pius III", + "reign": "1503-1503", + "portrait": "PiusIII.jpg" + }, + { + "ordinal": 216, + "name": "Juliusz II", + "reign": "1503-1513", + "portrait": "Pope Julius II.jpg" + }, + { + "ordinal": 217, + "name": "Leon X", + "reign": "1513-1521", + "portrait": "Pope-leo10.jpg" + }, + { + "ordinal": 218, + "name": "Hadrian VI", + "reign": "1522-1523", + "portrait": "Hadrian VI.jpg" + }, + { + "ordinal": 219, + "name": "Klemens VII", + "reign": "1523-1534", + "portrait": "Clement VII. Sebastiano del Piombo. c.1531..jpg" + }, + { + "ordinal": 220, + "name": "Paweł III", + "reign": "1534-1549", + "portrait": "Tizian (Schule) 001.jpg" + }, + { + "ordinal": 221, + "name": "Juliusz III", + "reign": "1550-1555", + "portrait": "Julius III.jpg" + }, + { + "ordinal": 222, + "name": "Marceli II", + "reign": "1555-1555", + "portrait": "Portrait_of_Pope_Marcellus_II_Cervini_(Vatican_Museums_-_Musei_Vaticani,_Vatican).jpg" + }, + { + "ordinal": 223, + "name": "Paweł IV", + "reign": "1555-1559", + "portrait": "Pope Paul IV – Jacopino Conte (Manner), ca. 1560.jpg" + }, + { + "ordinal": 224, + "name": "Pius IV", + "reign": "1559-1565", + "portrait": "Ritratto di Pio IV.jpg" + }, + { + "ordinal": 225, + "name": "Pius V", + "reign": "1566-1572", + "portrait": "El Greco 050.jpg" + }, + { + "ordinal": 226, + "name": "Grzegorz XIII", + "reign": "1572-1585", + "portrait": "Gregory XIII.jpg" + }, + { + "ordinal": 227, + "name": "Sykstus V", + "reign": "1585-1590", + "portrait": "(Albi) Cathédrale Sainte-Cécile - Trèsor - Portrait du pape Sixte V - PalissyIM81001477.jpg" + }, + { + "ordinal": 228, + "name": "Urban VII", + "reign": "1590-1590", + "portrait": "Portrait of Pope Urban VII Castagna (Vatican Museums - Musei Vaticani, Vatican).jpg" + }, + { + "ordinal": 229, + "name": "Grzegorz XIV", + "reign": "1590-1591", + "portrait": "Roman School – Portrait of Pope Gregory XIV (16th Century).jpg" + }, + { + "ordinal": 230, + "name": "Innocenty IX", + "reign": "1591-1591", + "portrait": "Portrait of Pope Innocent IX (Fidenza Cathedral Museum - Museo del Duomo di Fidenza, Fidenza).jpg" + }, + { + "ordinal": 231, + "name": "Klemens VIII", + "reign": "1592-1605", + "portrait": "Papst Clemens VIII Italian 17th century.jpg" + }, + { + "ordinal": 232, + "name": "Leon XI", + "reign": "1605-1605", + "portrait": "Leo XI 2.jpg" + }, + { + "ordinal": 233, + "name": "Paweł V", + "reign": "1605-1621", + "portrait": "PaoloHasekura.jpg" + }, + { + "ordinal": 234, + "name": "Grzegorz XV", + "reign": "1621-1623", + "portrait": "(Albi) Cathédrale Sainte-Cécile - Trèsor - Portrait du pape Grégoire XV - PalissyIM81001477.jpg" + }, + { + "ordinal": 235, + "name": "Urban VIII", + "reign": "1623-1644", + "portrait": "Gian Lorenzo Bernini - Portrait d'Urbain VIII.jpg" + }, + { + "ordinal": 236, + "name": "Innocenty X", + "reign": "1644-1655", + "portrait": "Innocentus x-velazquez.jpg" + }, + { + "ordinal": 237, + "name": "Aleksander VII", + "reign": "1655-1667", + "portrait": "Alexander VII.jpg" + }, + { + "ordinal": 238, + "name": "Klemens IX", + "reign": "1667-1669", + "portrait": "Clemente IX.jpg" + }, + { + "ordinal": 239, + "name": "Klemens X", + "reign": "1670-1676", + "portrait": "Portrait of Pope Clement X Altieri (by Ciro Ferri).jpg" + }, + { + "ordinal": 240, + "name": "Innocenty XI", + "reign": "1676-1689", + "portrait": "Jacob Ferdinand Voet - Portrait of Innocenzo XI Odescalchi (cropped).jpeg" + }, + { + "ordinal": 241, + "name": "Aleksander VIII", + "reign": "1689-1691", + "portrait": "Portrait of Pope Alexander VIII – Roman School of 17th Century – (unframed).jpg" + }, + { + "ordinal": 242, + "name": "Innocenty XII", + "reign": "1691-1700", + "portrait": "Pope Innocent XII.PNG" + }, + { + "ordinal": 243, + "name": "Klemens XI", + "reign": "1700-1721", + "portrait": "Portrait of Pope Clement XI Albani (Vatican Museums - Musei Vaticani, Vatican).jpg" + }, + { + "ordinal": 244, + "name": "Innocenty XIII", + "reign": "1721-1724", + "portrait": "InnocientXIII.jpg" + }, + { + "ordinal": 245, + "name": "Benedykt XIII", + "reign": "1724-1730", + "portrait": "Benedict XIII.jpg" + }, + { + "ordinal": 246, + "name": "Klemens XII", + "reign": "1730-1740", + "portrait": "Agostino Masucci – Portrait of Pope Clement XII, seated.jpg" + }, + { + "ordinal": 247, + "name": "Benedykt XIV", + "reign": "1740-1758", + "portrait": "Benoit XIV.jpg" + }, + { + "ordinal": 248, + "name": "Klemens XIII", + "reign": "1758-1769", + "portrait": "Clemens XIII.jpg" + }, + { + "ordinal": 249, + "name": "Klemens XIV", + "reign": "1769-1774", + "portrait": "Clemens XIV.PNG" + }, + { + "ordinal": 250, + "name": "Pius VI", + "reign": "1775-1799", + "portrait": "Popepiusvi.jpg" + }, + { + "ordinal": 251, + "name": "Pius VII", + "reign": "1800-1823", + "portrait": "Jacques-Louis David 018.jpg" + }, + { + "ordinal": 252, + "name": "Leon XII", + "reign": "1823-1829", + "portrait": "Pope Leo XII.PNG" + }, + { + "ordinal": 253, + "name": "Pius VIII", + "reign": "1829-1830", + "portrait": "Ritratto di Papa Pio VIII, by Clemente Alberi.jpg" + }, + { + "ordinal": 254, + "name": "Grzegorz XVI", + "reign": "1831-1846", + "portrait": "Gregory XVI.jpg" + }, + { + "ordinal": 255, + "name": "Pius IX", + "reign": "1846-1878", + "portrait": "G.P.A.Healy, Portrait of Pope Pius IX (1871).jpg" + }, + { + "ordinal": 256, + "name": "Leon XIII", + "reign": "1878-1903", + "portrait": "Portrait of Pope Leo XIII, Fabio Cipolla.jpg" + }, + { + "ordinal": 257, + "name": "Pius X", + "reign": "1903-1914", + "portrait": "Papst Pius X-01..jpg" + }, + { + "ordinal": 258, + "name": "Benedykt XV", + "reign": "1914-1922", + "portrait": "G.B.Torriglia, Benedetto XV in poltrona.jpg" + }, + { + "ordinal": 259, + "name": "Pius XI", + "reign": "1922-1929", + "portrait": "Philip Alexius de László (1869–1937) - Portrait of Pope Pius XI (1857–1939) - JL 501 - Bodleian Libraries.jpg" + }, + { + "ordinal": 260, + "name": "Pius XII", + "reign": "1939-1958", + "portrait": "Papa Pio XII nel 1953.jpg" + }, + { + "ordinal": 261, + "name": "Jan XXIII", + "reign": "1958-1963", + "portrait": "Ioannes XXIII, by De Agostini, 1958–1963.jpg" + }, + { + "ordinal": 262, + "name": "Paweł VI", + "reign": "1963-1978", + "portrait": "Paulus VI, by Fotografia Felici, 1969.jpg" + }, + { + "ordinal": 263, + "name": "Jan Paweł I", + "reign": "1978-1978", + "portrait": "Paus Johannes Paulus I (Bestanddeelnr 929-9074) (cropped-colour).png" + }, + { + "ordinal": 264, + "name": "Jan Paweł II", + "reign": "1978-2005", + "portrait": "Jan Paweł II Autor Zbigniew Kotyłło.JPG" + }, + { + "ordinal": 265, + "name": "Benedykt XVI", + "reign": "2005-2013", + "portrait": "Benedict xvi.png" + }, + { + "ordinal": 266, + "name": "Franciszek", + "reign": "2013-2025", + "portrait": "Korea Pope Francis Gwanghwamun Beatification 33 (14932565852) (cropped v3).jpg" + }, + { + "ordinal": 267, + "name": "Leon XIV", + "reign": "2025", + "portrait": "Papa Leon XIV-20251011-54852216541-20251112111339.jpg" + } + ] +} diff --git a/signal_bot/schema.py b/signal_bot/schema.py new file mode 100644 index 0000000..3e96a96 --- /dev/null +++ b/signal_bot/schema.py @@ -0,0 +1,68 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""The database shape, kept apart from the code that queries it. + +Split out of ``store.py`` so both stay under the 250-line cap, and because +"what the tables are" and "how the bot reads them" are two things. +""" + +from __future__ import annotations + +from typing import Final + +# Bumped whenever the shape below changes. See Store._apply_schema for why +# a version number is the only safe way to notice. +# +# Deliberately NOT bumped when a whole new table is added: CREATE TABLE IF +# NOT EXISTS is additive and harmless, whereas a bump drops every table and +# would throw away the live tallies to gain nothing. +SCHEMA_VERSION: Final = 2 + +# Every table is keyed by group first. The bot watches several groups, and a +# tally, a cooldown or a vote that leaked between them would mean a test +# message throttling the production group -- so the scope is in the primary +# key rather than in the callers' discipline. +SCHEMA: Final = """ +CREATE TABLE IF NOT EXISTS message_counts ( + group_id TEXT NOT NULL, + uuid TEXT NOT NULL, + display_name TEXT NOT NULL, + count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (group_id, uuid) +); +CREATE TABLE IF NOT EXISTS command_uses ( + group_id TEXT NOT NULL, + user_uuid TEXT NOT NULL, + command TEXT NOT NULL, + used_at REAL NOT NULL, + PRIMARY KEY (group_id, user_uuid, command) +); +CREATE TABLE IF NOT EXISTS daily_uses ( + group_id TEXT NOT NULL, + user_uuid TEXT NOT NULL, + command TEXT NOT NULL, + day TEXT NOT NULL, + uses INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (group_id, user_uuid, command, day) +); +CREATE TABLE IF NOT EXISTS votes ( + group_id TEXT NOT NULL, + command TEXT NOT NULL, + voter_uuid TEXT NOT NULL, + voted_at REAL NOT NULL, + PRIMARY KEY (group_id, command, voter_uuid) +); +CREATE TABLE IF NOT EXISTS group_meta ( + group_id TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (group_id, key) +); +""" + +TABLES: Final = ( + "message_counts", + "command_uses", + "daily_uses", + "votes", + "group_meta", +) diff --git a/signal_bot/store.py b/signal_bot/store.py index f5b9b2d..16af1aa 100644 --- a/signal_bot/store.py +++ b/signal_bot/store.py @@ -12,52 +12,12 @@ import sqlite3 from dataclasses import dataclass from typing import TYPE_CHECKING, Self +from signal_bot.schema import SCHEMA, SCHEMA_VERSION, TABLES + if TYPE_CHECKING: from collections.abc import Iterator from types import TracebackType -# Bumped whenever the shape below changes. See Store._apply_schema for why -# a version number is the only safe way to notice. -SCHEMA_VERSION = 2 - -# Every table is keyed by group first. The bot watches several groups, and a -# tally, a cooldown or a vote that leaked between them would mean a test -# message throttling the production group -- so the scope is in the primary -# key rather than in the callers' discipline. -_SCHEMA = """ -CREATE TABLE IF NOT EXISTS message_counts ( - group_id TEXT NOT NULL, - uuid TEXT NOT NULL, - display_name TEXT NOT NULL, - count INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (group_id, uuid) -); -CREATE TABLE IF NOT EXISTS command_uses ( - group_id TEXT NOT NULL, - user_uuid TEXT NOT NULL, - command TEXT NOT NULL, - used_at REAL NOT NULL, - PRIMARY KEY (group_id, user_uuid, command) -); -CREATE TABLE IF NOT EXISTS daily_uses ( - group_id TEXT NOT NULL, - user_uuid TEXT NOT NULL, - command TEXT NOT NULL, - day TEXT NOT NULL, - uses INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (group_id, user_uuid, command, day) -); -CREATE TABLE IF NOT EXISTS votes ( - group_id TEXT NOT NULL, - command TEXT NOT NULL, - voter_uuid TEXT NOT NULL, - voted_at REAL NOT NULL, - PRIMARY KEY (group_id, command, voter_uuid) -); -""" - -_TABLES = ("message_counts", "command_uses", "daily_uses", "votes") - @dataclass(frozen=True, slots=True) class Tally: @@ -93,9 +53,9 @@ class Store: """ version = int(self._conn.execute("PRAGMA user_version").fetchone()[0]) if version < SCHEMA_VERSION: - for table in _TABLES: + for table in TABLES: self._conn.execute(f"DROP TABLE IF EXISTS {table}") - self._conn.executescript(_SCHEMA) + self._conn.executescript(SCHEMA) self._conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") self._conn.commit() @@ -131,6 +91,26 @@ class Store: self._conn.execute("DELETE FROM command_uses WHERE group_id = ?", (group_id,)) self._conn.execute("DELETE FROM daily_uses WHERE group_id = ?", (group_id,)) self._conn.execute("DELETE FROM votes WHERE group_id = ?", (group_id,)) + self._conn.execute("DELETE FROM group_meta WHERE group_id = ?", (group_id,)) + self._conn.commit() + + # -- per-group key/value state ----------------------------------------- + + def meta(self, group_id: str, key: str) -> str | None: + """Return a stored value for one group, or None if never set.""" + row = self._conn.execute( + "SELECT value FROM group_meta WHERE group_id = ? AND key = ?", + (group_id, key), + ).fetchone() + return None if row is None else str(row["value"]) + + def set_meta(self, group_id: str, key: str, value: str) -> None: + """Store a value for one group, replacing any previous one.""" + self._conn.execute( + "INSERT INTO group_meta (group_id, key, value) VALUES (?, ?, ?) " + "ON CONFLICT(group_id, key) DO UPDATE SET value = excluded.value", + (group_id, key, value), + ) self._conn.commit() # -- message tallies --------------------------------------------------- diff --git a/signal_bot/versioning.py b/signal_bot/versioning.py new file mode 100644 index 0000000..a10965f --- /dev/null +++ b/signal_bot/versioning.py @@ -0,0 +1,103 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""Release versions named after the popes, in order. + +Release *n* is pope *n*: 0.0.0 is Święty Piotr Apostoł, and the version is a +pure function of the ordinal rather than a judgement about the size of a +change. The one fixed point is Jan Paweł II at 2.13.7, which is what picks +the two constants below -- eight patches per minor, and majors opening at +ordinals 33 and 153: + + (264 - 153) = 111 = 13 * 8 + 7 -> 2.13.7 + +Both earlier majors happen to divide evenly by eight as well (32 = 4 * 8, +120 = 15 * 8), so no bracket needs a fudge factor. The anchors are not +arbitrary either: 33 is Sylwester I, pope through the Edict of Milan and +Nicaea, and 153 is Wiktor II, the first pope elected after the 1054 schism. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Final + +# Ordinals at which a new major version opens. Ascending, first entry 1. +MAJOR_STARTS: Final = (1, 33, 153) +PATCHES_PER_MINOR: Final = 8 + +# Which release this build is. Bump by one per release; everything else, +# including the version string in pyproject.toml, follows from it. +RELEASE_ORDINAL: Final = 2 + +_POPES_FILE: Final = Path(__file__).with_name("popes.json") + + +@dataclass(frozen=True, slots=True) +class Pope: + """One entry of the papal list, as the version ladder needs it.""" + + ordinal: int + name: str + reign: str + portrait: str + + +def _load() -> tuple[Pope, ...]: + """Read popes.json once, at import.""" + document: dict[str, Any] = json.loads(_POPES_FILE.read_text(encoding="utf-8")) + return tuple( + Pope( + ordinal=int(entry["ordinal"]), + name=str(entry["name"]), + reign=str(entry["reign"]), + portrait=str(entry["portrait"]), + ) + for entry in document["popes"] + ) + + +POPES: Final = _load() + + +def version_for(ordinal: int) -> str: + """Return the dotted version string for the given papal ordinal. + + Raises: + ValueError: if the ordinal is outside the known list. + """ + if not 1 <= ordinal <= len(POPES): + message = f"no pope with ordinal {ordinal}; the list has {len(POPES)}" + raise ValueError(message) + major = max(index for index, start in enumerate(MAJOR_STARTS) if ordinal >= start) + offset = ordinal - MAJOR_STARTS[major] + return f"{major}.{offset // PATCHES_PER_MINOR}.{offset % PATCHES_PER_MINOR}" + + +def pope_for(ordinal: int) -> Pope: + """Return the pope with the given ordinal. + + Raises: + ValueError: if the ordinal is outside the known list. + """ + if not 1 <= ordinal <= len(POPES): + message = f"no pope with ordinal {ordinal}; the list has {len(POPES)}" + raise ValueError(message) + return POPES[ordinal - 1] + + +def ordinal_for(version: str) -> int: + """Return the papal ordinal a version string denotes. + + Raises: + ValueError: if no release carries that version. + """ + for pope in POPES: + if version_for(pope.ordinal) == version: + return pope.ordinal + message = f"no release carries version {version!r}" + raise ValueError(message) + + +__version__: Final = version_for(RELEASE_ORDINAL) +POPE: Final = pope_for(RELEASE_ORDINAL) diff --git a/tests/test_announce.py b/tests/test_announce.py new file mode 100644 index 0000000..7045de5 --- /dev/null +++ b/tests/test_announce.py @@ -0,0 +1,140 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""Upgrade announcements: told once, never on the way in, never in a loop.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from signal_bot.announce import ANNOUNCED_KEY, announce_all, announce_to, render +from signal_bot.changelog import parse +from signal_bot.transport_fake import FakeTransport + +if TYPE_CHECKING: + from signal_bot.store import Store + +GROUP = "group.one" +DOCUMENT = """\ +## 0.0.1 — Linus + +- druga zmiana + +## 0.0.0 — Święty Piotr Apostoł + +- pierwsza zmiana +""" +RELEASES = parse(DOCUMENT) + + +async def _announce(store: Store, transport: FakeTransport, version: str = "0.0.1") -> None: + await announce_to(transport, store, GROUP, RELEASES, version, "Linus") + + +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" + + +def test_a_long_backlog_is_capped_and_says_so() -> None: + """A group added years from now must not be handed 200 releases.""" + document = "\n\n".join( + f"## {v} — Pope{i}\n\n- zmiana {i}" + for i, v in enumerate( + [ + "0.0.0", + "0.0.1", + "0.0.2", + "0.0.3", + "0.0.4", + "0.0.5", + "0.0.6", + "0.0.7", + "0.1.0", + "0.1.1", + "0.1.2", + "0.1.3", + ] + ) + ) + message = render("0.1.3", "Soter", parse(document)) + assert "(pominieto 2 starszych wersji)" in message + assert "zmiana 0" not in message + assert "zmiana 11" 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:") + assert "- pierwsza zmiana" in message + assert "- druga zmiana" in message + + +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 + assert "pierwsza zmiana" in message + assert "druga zmiana" in message + 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: + store.set_meta(GROUP, ANNOUNCED_KEY, "0.0.1") + await _announce(store, transport) + assert transport.sent == [] + + +async def test_an_upgraded_group_is_told_what_changed( + store: Store, transport: FakeTransport +) -> None: + 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 store.meta(GROUP, ANNOUNCED_KEY) == "0.0.1" + + +async def test_a_version_with_no_changelog_entry_records_but_says_nothing( + store: Store, transport: FakeTransport +) -> 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") + assert transport.sent == [] + assert store.meta(GROUP, ANNOUNCED_KEY) == "0.0.2" + + +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) + + store.set_meta(GROUP, ANNOUNCED_KEY, "0.0.0") + await announce_all(Exploding(), store, [GROUP], RELEASES, "0.0.1", "Linus") + assert store.meta(GROUP, ANNOUNCED_KEY) == "0.0.1" + + +async def test_one_failing_group_does_not_silence_the_others(store: Store) -> None: + other = "group.two" + + class RefusingFirst(FakeTransport): + async def send_text(self, text: str, recipient: str) -> None: + if recipient == GROUP: + raise RuntimeError(recipient) + await super().send_text(text, recipient) + + 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") + assert [message.recipient for message in transport.sent] == [other] diff --git a/tests/test_app.py b/tests/test_app.py index d494e6a..ebbbf6d 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -19,6 +19,7 @@ from signal_bot.membership import MembershipError from signal_bot.transport_fake import FakeTransport from signal_bot.transport_rest import TransportError from signal_bot.trust import InviteTrust +from signal_bot.versioning import __version__ from tests.conftest import SECOND_GROUP, WATCHED_GROUP, envelope, group_entry, listing_client if TYPE_CHECKING: @@ -121,8 +122,9 @@ async def test_runtime_start_and_stop_cancels_tasks( client = _client(lambda r: httpx.Response(200)) runtime = Runtime(bot=_bot(config, store, transport, directory), client=client, store=store) runtime.start() - # listen, the daily report, the group refresh loop, the deafness gate. - assert len(runtime.tasks) == 4 + # listen, the daily report, the group refresh loop, the deafness gate, + # and the one-shot release announcement. + assert len(runtime.tasks) == 5 await asyncio.sleep(0) await runtime.stop() assert all(task.done() for task in runtime.tasks) @@ -138,7 +140,9 @@ def test_healthz_serves_and_startup_completes(monkeypatch: pytest.MonkeyPatch) - monkeypatch.setattr(app_module.httpx, "AsyncClient", fake_client) with TestClient(app_module.app) as client: - assert client.get("/healthz").json() == {"status": "ok"} + body = client.get("/healthz").json() + assert body["status"] == "ok" + assert body["version"] == __version__ async def test_lifespan_is_an_async_context_manager( diff --git a/tests/test_changelog.py b/tests/test_changelog.py new file mode 100644 index 0000000..3ec8a8c --- /dev/null +++ b/tests/test_changelog.py @@ -0,0 +1,73 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""DOCS-changelog.md is the only copy, so the parser has to earn that.""" + +from __future__ import annotations + +from pathlib import Path + +from signal_bot.changelog import Release, load, parse, since +from signal_bot.versioning import __version__ + +DOCUMENT = """\ +# Changelog + +Prose between releases is ignored. + +- a bullet before any heading belongs to no release + +## 0.0.1 — Linus + +- druga zmiana +- trzecia zmiana + +Some more prose. + +## 0.0.0 — Święty Piotr Apostoł + +- pierwsza zmiana +""" + + +def test_parse_reads_headings_and_their_bullets() -> None: + releases = parse(DOCUMENT) + assert [r.version for r in releases] == ["0.0.0", "0.0.1"] + assert releases[0].pope == "Święty Piotr Apostoł" + assert releases[1].lines == ("druga zmiana", "trzecia zmiana") + + +def test_a_bullet_above_every_heading_belongs_to_nobody() -> None: + """Otherwise the file's own format documentation becomes a changelog entry.""" + assert all("belongs to no release" not in line for r in parse(DOCUMENT) for line in r.lines) + + +def test_parse_of_nothing_is_nothing() -> None: + assert parse("# Changelog\n\njust prose\n") == () + + +def test_releases_come_back_oldest_first_whatever_the_file_order() -> None: + assert [r.ordinal for r in parse(DOCUMENT)] == [1, 2] + + +def test_since_returns_only_what_the_group_has_not_seen() -> None: + releases = parse(DOCUMENT) + assert [r.version for r in since(releases, "0.0.0")] == ["0.0.1"] + assert since(releases, "0.0.1") == () + + +def test_since_an_unknown_version_announces_nothing() -> None: + """A hand-edited or downgraded record must not dump the whole backlog.""" + assert since(parse(DOCUMENT), "9.9.9") == () + + +def test_a_missing_changelog_costs_an_announcement_not_the_process() -> None: + assert load(Path("/nonexistent/DOCS-changelog.md")) == () + + +def test_the_real_changelog_covers_the_running_version() -> None: + """A release with no entry would announce a version and then say nothing.""" + versions = [release.version for release in load()] + assert __version__ in versions + + +def test_release_knows_its_ordinal() -> None: + assert Release("2.13.7", "Jan Paweł II", ()).ordinal == 264 diff --git a/tests/test_healthz.py b/tests/test_healthz.py index ed89274..6244ddc 100644 --- a/tests/test_healthz.py +++ b/tests/test_healthz.py @@ -13,6 +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__ if TYPE_CHECKING: from signal_bot.config import Config @@ -34,7 +35,8 @@ def _install(liveness: Liveness) -> None: async def test_a_healthy_bot_answers_ok(config: Config) -> None: _install(_gate(config, httpx.Response(200, json=[]))) response = Response() - assert await healthz(response) == {"status": "ok"} + body = await healthz(response) + assert body["status"] == "ok" assert response.status_code == httpx.codes.OK @@ -50,3 +52,19 @@ async def test_a_deaf_bot_answers_503_and_says_why(config: Config) -> None: assert response.status_code == httpx.codes.SERVICE_UNAVAILABLE assert body["status"] == "deaf" assert "Closed unexpectedly" in body["reason"] + + +async def test_healthz_names_the_build_either_way(config: Config) -> None: + """Without this you cannot ask a running bot which version it is.""" + _install(_gate(config, httpx.Response(200, json=[]))) + healthy = await healthz(Response()) + assert healthy["version"] == __version__ + assert healthy["pope"] == POPE.name + + deaf_gate = _gate(replace(config, silence_seconds=0), httpx.Response(400, json=DEAD_BODY)) + for _ in range(3): + await deaf_gate.check() + _install(deaf_gate) + deaf = await healthz(Response()) + assert deaf["version"] == __version__ + assert deaf["pope"] == POPE.name diff --git a/tests/test_store.py b/tests/test_store.py index 11d1b1f..45cfdd5 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -6,7 +6,8 @@ from __future__ import annotations import sqlite3 from typing import TYPE_CHECKING -from signal_bot.store import SCHEMA_VERSION, Store, open_store +from signal_bot.schema import SCHEMA_VERSION +from signal_bot.store import Store, open_store if TYPE_CHECKING: from pathlib import Path diff --git a/tests/test_versioning.py b/tests/test_versioning.py new file mode 100644 index 0000000..1d87936 --- /dev/null +++ b/tests/test_versioning.py @@ -0,0 +1,94 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""The pope ladder, and the one fixed point the whole scheme exists for.""" + +from __future__ import annotations + +import itertools +import json +import tomllib +from pathlib import Path + +import pytest + +from signal_bot.versioning import ( + POPE, + POPES, + RELEASE_ORDINAL, + __version__, + ordinal_for, + pope_for, + version_for, +) + +REPO_ROOT = Path(__file__).resolve().parent.parent +JOHN_PAUL_II = 264 + + +def test_john_paul_ii_is_exactly_2_13_7() -> None: + """The requirement the two ladder constants were solved for.""" + assert version_for(JOHN_PAUL_II) == "2.13.7" + assert pope_for(JOHN_PAUL_II).name == "Jan Paweł II" + + +@pytest.mark.parametrize( + ("ordinal", "version", "name"), + [ + (1, "0.0.0", "Święty Piotr Apostoł"), + (2, "0.0.1", "Linus"), + (32, "0.3.7", "Milcjades"), + (33, "1.0.0", "Sylwester I"), + (152, "1.14.7", "Leon IX"), + (153, "2.0.0", "Wiktor II"), + (265, "2.14.0", "Benedykt XVI"), + (267, "2.14.2", "Leon XIV"), + ], +) +def test_the_anchors_and_the_boundaries_either_side(ordinal: int, version: str, name: str) -> None: + assert version_for(ordinal) == version + assert pope_for(ordinal).name == name + + +def test_versions_never_go_backwards() -> None: + """A ladder that is not monotonic is not a version scheme.""" + ladder = [ + tuple(int(part) for part in version_for(n).split(".")) for n in range(1, len(POPES) + 1) + ] + assert all(earlier < later for earlier, later in itertools.pairwise(ladder)) + + +@pytest.mark.parametrize("ordinal", [0, -1, 268, 9999]) +def test_an_ordinal_off_the_list_is_refused(ordinal: int) -> None: + with pytest.raises(ValueError, match="no pope with ordinal"): + version_for(ordinal) + with pytest.raises(ValueError, match="no pope with ordinal"): + pope_for(ordinal) + + +def test_ordinal_for_is_the_inverse_of_version_for() -> None: + for ordinal in (1, 2, 33, 153, JOHN_PAUL_II, len(POPES)): + assert ordinal_for(version_for(ordinal)) == ordinal + + +def test_a_version_no_release_carries_is_refused() -> None: + with pytest.raises(ValueError, match="no release carries version"): + ordinal_for("9.9.9") + + +def test_this_build_names_itself() -> None: + assert __version__ == version_for(RELEASE_ORDINAL) + assert pope_for(RELEASE_ORDINAL) == POPE + + +def test_pyproject_agrees_with_the_ladder() -> None: + """Two version sources with nothing reconciling them would drift silently.""" + pyproject = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + assert pyproject["project"]["version"] == version_for(RELEASE_ORDINAL) + + +def test_the_committed_pope_list_is_whole() -> None: + """popes.json is generated; this is what stops a bad regeneration shipping.""" + document = json.loads((REPO_ROOT / "signal_bot" / "popes.json").read_text(encoding="utf-8")) + entries = document["popes"] + assert len(entries) == 267 + assert [entry["ordinal"] for entry in entries] == list(range(1, 268)) + assert all(entry["name"] and entry["portrait"] and entry["reign"] for entry in entries) diff --git a/tool/gen_popes.py b/tool/gen_popes.py new file mode 100755 index 0000000..0390d96 --- /dev/null +++ b/tool/gen_popes.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""Regenerate ``signal_bot/popes.json`` from the Polish Wikipedia pope list. + +Run this by hand when a new pope is elected; the output is committed, so the +bot never touches the network to learn its own version name. + +The ``Lp.`` column of *Poczet papieży* is the authority for the ordinal. It is +the reason this is scraped rather than recalled: antipopes, Benedykt IX's three +separate reigns and the Stefan II question all shift the count, and being wrong +by one silently moves Jan Paweł II off 2.13.7. +""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path +from typing import Final + +import httpx + +API: Final = ( + "https://pl.wikipedia.org/w/api.php?action=parse" + "&page=Poczet%20papie%C5%BCy&prop=wikitext&format=json&formatversion=2" +) +# Wikimedia's policy requires a descriptive agent with a contact URL; the +# generic client default is rate-limited hard and may be blocked outright. +USER_AGENT: Final = "signal-bot popes generator (https://github.com/kuhyx/signal-bot)" + +# One row per pope: "|." (later rows add a second, per-era numbering that +# is deliberately ignored), then the linked name, then the portrait file. +_ROW: Final = re.compile(r"\|(\d+)\.(?: */ *'''\d+\.''')?\s*\n\|\[?\[?([^\]|\n]+)") +_PORTRAIT: Final = re.compile(r"\[\[Plik:([^\]|]+)") +# A year is a wiki link whose whole target is digits. Matching bare numbers +# instead catches "100px" from an image and the "31" of "31 stycznia". +_YEARS: Final = re.compile(r"\[\[(\d{1,4})\]\]") + +# The list spells the first entry "Piotr"; kuhy asked for his full title. +FIRST_POPE: Final = "Święty Piotr Apostoł" +EXPECTED_TOTAL: Final = 267 +JOHN_PAUL_II: Final = 264 + + +def _clean(name: str) -> str: + """Strip the wiki disambiguation suffix from a linked page title.""" + return re.sub(r"\s*\(papież\)", "", name).strip() + + +def _reign(row: str) -> str: + """Return a row's reign years as ``start-end``, or ``start-`` if reigning. + + The reign is the row's last cell -- but a row that ends a wikitable runs + on into the prose after ``|}``, which is how Jan Pawel II first came out + as "100" (an image width) and Leon XIV as "2005-069" (a footnote). + """ + cell = row.split("\n|}", maxsplit=1)[0].rsplit("\n|", 1)[-1] + years = _YEARS.findall(cell) + if not years: + return "" + if len(years) == 1: + return f"{years[0]}-" if cell.rstrip().endswith(("-", "\u2013")) else years[0] + return f"{years[0]}-{years[-1]}" + + +def parse(wikitext: str) -> list[dict[str, object]]: + """Return one entry per pope, ordered by the list's own Lp. column.""" + popes: dict[int, dict[str, object]] = {} + for row in re.split(r"\n\|-", wikitext): + match = _ROW.match(row.lstrip("\n")) + portrait = _PORTRAIT.search(row) + if match is None or portrait is None: + continue + ordinal = int(match.group(1)) + popes.setdefault( + ordinal, + { + "ordinal": ordinal, + "name": _clean(match.group(2)), + "reign": _reign(row), + "portrait": portrait.group(1).strip(), + }, + ) + return [popes[n] for n in sorted(popes)] + + +def _rejection(popes: list[dict[str, object]]) -> str: + """Return why this list must not be written, or an empty string.""" + if [p["ordinal"] for p in popes] != list(range(1, len(popes) + 1)): + return "ordinals are not contiguous from 1" + if len(popes) != EXPECTED_TOTAL: + return f"got {len(popes)} popes, expected {EXPECTED_TOTAL}" + if popes[JOHN_PAUL_II - 1]["name"] != "Jan Paweł II": + return f"#{JOHN_PAUL_II} is not Jan Paweł II" + return "" + + +def main() -> int: + """Write popes.json, refusing to write anything that fails its own checks.""" + response = httpx.get(API, headers={"User-Agent": USER_AGENT}, timeout=60.0) + response.raise_for_status() + popes = parse(str(response.json()["parse"]["wikitext"])) + if popes: + popes[0]["name"] = FIRST_POPE + + rejection = _rejection(popes) + if rejection: + sys.stderr.write(f"refusing to write: {rejection}\n") + return 1 + + out = Path(__file__).resolve().parent.parent / "signal_bot" / "popes.json" + document = {"source": API, "popes": popes} + out.write_text(json.dumps(document, ensure_ascii=False, indent=1) + "\n", encoding="utf-8") + sys.stdout.write(f"wrote {len(popes)} popes to {out}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())