From 4bbb1e1519e811693d7fdec9b6ba4f89d3231fc4 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 29 Aug 2026 22:51:20 +0200 Subject: [PATCH] feat: name every release after a pope, and tell the group when one lands Release n is pope n, so the version is a fact about the ordinal rather than a judgement about the size of a change. The constraint that fixes the scheme is Jan Pawel II at 2.13.7: eight patches per minor with majors opening at ordinals 33 and 153 gives (264 - 153) = 13 * 8 + 7 exactly, and both earlier majors happen to divide evenly by eight too, so no bracket needs a fudge factor. The anchors are not arbitrary -- 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. popes.json is scraped by tool/gen_popes.py rather than recalled, because antipopes, Benedykt IX's three separate reigns and the Stefan II question all shift the count and being wrong by one moves Jan Pawel II off 2.13.7 silently. The generator refuses to write a list that is not 267 contiguous entries with Jan Pawel II at 264. A second, independent parse of the same page agreed on every ordinal, which is the only reason to believe either. On startup each watched group is told about the releases it has not seen. A group with no record has seen none of them, so the first announcement starts at 0.0.0 "Swiety Piotr Apostol" rather than skipping to whatever is current -- watching the ladder start at Peter is the point of naming releases after popes. A group added years from now would otherwise be handed the whole backlog, so the message is capped at ten releases and says how many it left out. The version is recorded before the message is sent: with Restart=always and RestartSec=5, a crash between the two would re-announce on every restart, and a missed announcement is far cheaper than a looping one. DOCS-changelog.md is the single source -- the bot parses it, so there is no second machine-readable copy to drift. It is DOCS-, not CHANGELOG.md, because the md-naming gate allows four namespaces and that is not one of them. Deliberately NOT bumping SCHEMA_VERSION for the new group_meta table: _apply_schema drops every table on a bump, so bumping to add a table would have thrown away the live tallies to gain nothing. Verified against the real database -- group_meta appeared and all four counts survived the restart. The schema moves to its own module so store.py stays under the 250-line cap, and /healthz now names the build, because there was previously no way to ask a running bot which version it is. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom --- DOCS-changelog.md | 27 + README.md | 6 +- pyproject.toml | 2 +- signal_bot/announce.py | 100 +++ signal_bot/app.py | 22 +- signal_bot/changelog.py | 80 ++ signal_bot/popes.json | 1607 ++++++++++++++++++++++++++++++++++++++ signal_bot/schema.py | 68 ++ signal_bot/store.py | 68 +- signal_bot/versioning.py | 103 +++ tests/test_announce.py | 140 ++++ tests/test_app.py | 10 +- tests/test_changelog.py | 73 ++ tests/test_healthz.py | 20 +- tests/test_store.py | 3 +- tests/test_versioning.py | 94 +++ tool/gen_popes.py | 120 +++ 17 files changed, 2488 insertions(+), 55 deletions(-) create mode 100644 DOCS-changelog.md create mode 100644 signal_bot/announce.py create mode 100644 signal_bot/changelog.py create mode 100644 signal_bot/popes.json create mode 100644 signal_bot/schema.py create mode 100644 signal_bot/versioning.py create mode 100644 tests/test_announce.py create mode 100644 tests/test_changelog.py create mode 100644 tests/test_versioning.py create mode 100755 tool/gen_popes.py 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())