casebench
test cases straight onto the board.
git clone https://owenrusk.dev/casebench.git
commit 434ced1b47718423bc8daa51ee23350e5913f348 parent 775f4be5a1248eff9b99f45e095b1073b90c1c97 author Owen Rusk <owen@papermothgames.com> date 2025-04-22 14:37:25 -0500
images on items stored once by hash in board_media; items list the hashes.
| casebench/casefile.py | +27 | -1 |
| casebench/db.py | +17 | -4 |
diff --git a/casebench/casefile.py b/casebench/casefile.py index fffaf6c..b28061c 100644 --- a/casebench/casefile.py +++ b/casebench/casefile.py @@ -5,17 +5,31 @@ from pathlib import Path import yaml ID = re.compile(r"[a-z0-9][a-z0-9-]*") +MEDIA = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", +} class CaseError(ValueError): pass +@dataclass(frozen=True) +class Image: + name: str + mime: str + data: bytes + + @dataclass(frozen=True) class Item: id: str title: str text: str + images: tuple[Image, ...] = () @dataclass(frozen=True) @@ -35,7 +49,10 @@ def load(folder: Path) -> Case: text = path.parent / entry["text"] if not text.is_file(): raise CaseError(f"{path}: no file {entry['text']}")- items.append(Item(_id(entry.get("id"), path), entry["title"], text.read_text(encoding="utf-8")))+ images = tuple(_image(path.parent / name, path) for name in entry.get("images") or []) + items.append( + Item(_id(entry.get("id"), path), entry["title"], text.read_text(encoding="utf-8"), images) + ) if not items: raise CaseError(f"{path}: no items") return Case(_id(raw.get("id"), path), raw["title"], tuple(items)) @@ -45,3 +62,12 @@ def _id(value: object, path: Path) -> str: if not isinstance(value, str) or not ID.fullmatch(value): raise CaseError(f"{path}: bad id {value!r}") return value + + +def _image(file: Path, path: Path) -> Image: + mime = MEDIA.get(file.suffix.lower()) + if mime is None: + raise CaseError(f"{path}: can't serve {file.name}") + if not file.is_file(): + raise CaseError(f"{path}: no file {file.name}") + return Image(file.name, mime, file.read_bytes()) diff --git a/casebench/db.py b/casebench/db.py index 23357ff..5446104 100644 --- a/casebench/db.py +++ b/casebench/db.py @@ -1,6 +1,8 @@ +import hashlib + import psycopg-from .casefile import Case+from .casefile import Case, Image def connect(dsn: str) -> psycopg.Connection: @@ -29,13 +31,24 @@ def put(conn: psycopg.Connection, player: str, case: Case) -> None: ) conn.execute("delete from board_items where player_id = %s and case_id = %s", (player, case.id)) for position, item in enumerate(case.items): + images = [media(conn, image) for image in item.images] conn.execute(- "insert into board_items (player_id, case_id, item_id, position, title, body)"- " values (%s, %s, %s, %s, %s, %s)",- (player, case.id, item.id, position, item.title, item.text),+ "insert into board_items (player_id, case_id, item_id, position, title, body, images)" + " values (%s, %s, %s, %s, %s, %s, %s)", + (player, case.id, item.id, position, item.title, item.text, images), ) +def media(conn: psycopg.Connection, image: Image) -> str: + # images are kept once by their hash, however many boards show them. + sha = hashlib.sha256(image.data).hexdigest() + conn.execute( + "insert into board_media (sha256, mime, data) values (%s, %s, %s) on conflict do nothing", + (sha, image.mime, image.data), + ) + return sha + + def items(conn: psycopg.Connection, player: str, case_id: str) -> list[str]: rows = conn.execute( "select item_id from board_items where player_id = %s and case_id = %s order by position",