signal-bot/signal_bot/portraits.py
Krzysztof kuhy Rudnicki 14d422295f feat: a word with two meanings gets the one made of steel
!pułapka (and !trap, !sidła, !wnyk, !potrzask, 26 spellings in all) posts a
photograph of the literal device -- a bear trap, a mousetrap, a snare, a fish
weir -- captioned with what Commons says it is.

The pool is 2218 Commons page ids, harvested by tool/gen_traps.py from a
curated seed list and committed. Page ids and not filenames on purpose: a
filename carries photographers' and sitters' names, and one of those
colliding with a label somebody mints from the chat would wedge
check_personal_data.py permanently, over data nobody can edit by hand.
_repo_vocabulary() subtracts aliases.json and nothing else, so it would not
have caught it. An integer cannot carry a name.

Nothing is cached. !papież caches because 267 portraits are a closed set a
group works through; a uniform draw from 2218 warms a cache essentially
never, so this fetches per call the way !kot and !pies do.

What the harvest measured, and now enforces:

- five plausible seed categories do not exist (Snares, Man traps, Hunting
  traps, Wasp traps, Animal trapping). A missing category is an empty answer,
  not an error, so verify_seeds() asks Commons before harvesting and fails
  loudly naming all of them.
- depth three from Category:Fish traps reaches Alfred Sisley's net paintings.
  Depth one, from seeds picked by hand.
- "<thing> in art" categories are paintings: Mousetraps in art put a Robert
  Campin altarpiece in the pool. Pruned by pattern, since Commons mints a new
  one whenever somebody needs it. Booby traps, the Wolfsangel, camera traps
  and animals dying in traps are pruned by name.
- Commons rate-limits hard, so a partial harvest must not be committed
  silently: 429 backs off, and a short pool refuses to write at all.

Descriptions are HTML and sometimes say nothing, so the tags come out, the
Fisheries records' trailing catalogue list is cut, a description naming only
the country falls back to the filename, and the file's own page is always
linked.

!łapka is not an alias: it resolves to !papa at distance two, which
test_nearest.py fails on, and rightly -- the suggestion would be a coin toss.

signal_bot/commons.py is the imageinfo query both Commons commands now share;
portraits.py keeps its behaviour and its tests unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01416TegJ3sa8xL5qGGgrygP
2026-08-31 18:01:27 +02:00

117 lines
4.1 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""On-disk cache of papal portraits, keyed by ordinal.
The set is closed: 267 popes, one portrait each, so this cache is bounded by
construction and never evicts. That is what makes writing images to disk safe
here when http_client refuses to -- the filename comes from the ordinal, so
nothing upstream controls a path, and the directory belongs to systemd.
"""
from __future__ import annotations
import base64
import os
from pathlib import Path
from typing import TYPE_CHECKING
from signal_bot.commons import SUFFIXES, THUMB_PATH, imageinfo_params
from signal_bot.endpoints import THUMB_WIDTH
from signal_bot.http_client import ImageFetchError
if TYPE_CHECKING:
from signal_bot.http_client import ImageFetcher
from signal_bot.versioning import Pope
def default_cache_dir() -> Path:
"""Return the portrait directory, preferring the one systemd hands us.
Under the unit CACHE_DIRECTORY is set and writable even though
ProtectHome=read-only makes the rest of $HOME immutable; outside it -- a
test run, a shell -- XDG applies. Mirrors scripts/signal_api_watchdog.sh.
"""
explicit = os.environ.get("PORTRAIT_CACHE_DIR", "").strip()
if explicit:
return Path(explicit)
cache = os.environ.get("CACHE_DIRECTORY", "").strip()
if cache:
return Path(cache) / "portraits"
home = os.environ.get("XDG_CACHE_HOME", "").strip() or str(Path.home() / ".cache")
return Path(home) / "signal-bot" / "portraits"
class PortraitCache:
"""Fetches a pope's portrait once, then serves it from disk forever."""
def __init__(
self,
fetcher: ImageFetcher,
api_url: str,
cache_dir: Path | None = None,
) -> None:
"""Store the fetcher, the Commons endpoint and where files land."""
self._fetcher = fetcher
self._api_url = api_url
self._dir = default_cache_dir() if cache_dir is None else cache_dir
def path_for(self, pope: Pope) -> Path:
"""Return the cache path for one pope.
Named from the ordinal, never from the Commons filename: those carry
spaces, parentheses, commas and non-ASCII, and are attacker-adjacent
data in a way an integer is not.
"""
suffix = Path(pope.portrait).suffix.lower()
if suffix not in SUFFIXES:
msg = f"portrait for #{pope.ordinal} has an unusable suffix: {pope.portrait!r}"
raise ImageFetchError(msg)
return self._dir / f"{pope.ordinal:03d}{suffix}"
async def get_base64(self, pope: Pope) -> str:
"""Return the portrait base64-encoded, as the transport wants it."""
return base64.b64encode(await self.get_bytes(pope)).decode("ascii")
async def get_bytes(self, pope: Pope) -> bytes:
"""Return the portrait bytes, downloading them only on a miss."""
path = self.path_for(pope)
cached = _read(path)
if cached is not None:
return cached
image = await self._fetcher.fetch_bytes(
self._api_url,
THUMB_PATH,
params=imageinfo_params(THUMB_WIDTH, title=f"File:{pope.portrait}"),
)
_write(path, image)
return image
def _read(path: Path) -> bytes | None:
"""Return cached bytes, or None when there is nothing usable there.
An empty file is a miss, not a hit: that is what a crash between create
and write leaves behind, and serving it would post a broken attachment
into the group forever.
"""
try:
data = path.read_bytes()
except OSError:
return None
return data or None
def _write(path: Path, image: bytes) -> None:
"""Write the portrait atomically, ignoring a cache that will not take it.
A read-only or missing cache directory must not cost the group its photo
-- the bytes are already in hand. The next call simply misses again.
"""
try:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_bytes(image)
temporary.replace(path)
except OSError:
return