casebench
test cases straight onto the board.
git clone https://owenrusk.dev/casebench.git
commit 809597f65802b761bf8e106f0a2338b9121b4359 parent 4b6596a67bbd4b3bd85d270072436fbb374d2aac author Owen Rusk <owen@papermothgames.com> date 2025-07-15 14:03:27 -0500
split data access into a store the commands talk to a Store now; PgStore is what db.py was. so the commands can be tested without the hub's database.
| casebench/cli.py | +72 | -60 |
| casebench/db.py | +0 | -106 |
| casebench/pgstore.py | +117 | -0 |
| casebench/store.py | +50 | -0 |
diff --git a/casebench/cli.py b/casebench/cli.py index 3ccdb3c..7880a8f 100644 --- a/casebench/cli.py +++ b/casebench/cli.py @@ -3,18 +3,17 @@ import os import sys from pathlib import Path-import psycopg--from . import casefile, db, lock+from . import casefile, lock +from .store import BENCH, Placed, Player, Store class Fail(Exception): pass-def main(argv: list[str] | None = None) -> int:- parser = argparse.ArgumentParser(prog="casebench")- sub = parser.add_subparsers(dest="command", required=True)+def parser() -> argparse.ArgumentParser: + top = argparse.ArgumentParser(prog="casebench") + sub = top.add_subparsers(dest="command", required=True) put = sub.add_parser("put", help="put a case on a player's board") put.add_argument("folder", type=Path) @@ -26,16 +25,16 @@ def main(argv: list[str] | None = None) -> int: words = note.add_mutually_exclusive_group(required=True) words.add_argument("text", nargs="?") words.add_argument("-f", "--file", type=Path, help="read the note from a file")- note.add_argument("--player", required=True)note.add_argument("--item", help="the item to write on; the case's title if left out") + note.add_argument("--player", required=True) note.set_defaults(run=run_note) locker = sub.add_parser("lock", help="lock a case behind one or more answers") locker.add_argument("case")- locker.add_argument("--player", required=True)locker.add_argument("--answer", action="append", required=True, help="one per line of the lock") + locker.add_argument("--rest", default="3/60", help="tries/minutes: after that many wrong tries in a row, take nothing for that long (default: 3/60)") locker.add_argument("--text", default="locked.", help="what the lock says (default: locked.)")- locker.add_argument("--rest", default="3/60", help="after this many wrong tries in a row, take nothing for this many minutes (default: 3/60)")+ locker.add_argument("--player", required=True) locker.set_defaults(run=run_lock) unlocker = sub.add_parser("unlock", help="take the lock off a case") @@ -51,78 +50,91 @@ def main(argv: list[str] | None = None) -> int: puller.add_argument("case") puller.add_argument("--player", required=True) puller.set_defaults(run=run_pull) + return top +- args = parser.parse_args(argv)- dsn = os.environ.get("CASEBENCH_DSN")- if not dsn:- print("casebench: set CASEBENCH_DSN", file=sys.stderr)- return 2+def main(argv: list[str] | None = None, store: Store | None = None) -> int: + args = parser().parse_args(argv) + opened = store is None + if store is None: + dsn = os.environ.get("CASEBENCH_DSN") + if not dsn: + print("casebench: set CASEBENCH_DSN", file=sys.stderr) + return 2 + from .pgstore import PgStore # psycopg only when there's a database to talk to + + store = PgStore(dsn) try:- with db.connect(dsn) as conn:- args.run(conn, args)- except (Fail, casefile.CaseError) as e:+ args.run(store, args) + except (Fail, ValueError) as e: print(f"casebench: {e}", file=sys.stderr) return 1 + finally: + if opened: + store.close() return 0-def player(conn: psycopg.Connection, handle: str) -> str:- found = db.player_id(conn, handle)+def player(store: Store, handle: str) -> Player: + found = store.player(handle) if found is None: raise Fail(f"no player {handle}") return found-def run_put(conn: psycopg.Connection, args: argparse.Namespace) -> None:+def on_board(store: Store, who: Player, case_id: str) -> Placed: + placed = store.placed(who, case_id) + if placed is None: + raise Fail(f"{who.handle} doesn't have {case_id}") + return placed + + +def run_put(store: Store, args: argparse.Namespace) -> None: case = casefile.load(args.folder)- who = player(conn, args.player)- if db.source(conn, who, case.id) not in (None, "bench"):- raise Fail(f"{args.player} already has {case.id} from the runtime")- db.put(conn, who, case)- print(f"{args.player}: put {case.id}")---def run_note(conn: psycopg.Connection, args: argparse.Namespace) -> None:- who = player(conn, args.player)- if db.source(conn, who, args.case) is None:- raise Fail(f"{args.player} doesn't have {args.case}")- if args.item and args.item not in db.items(conn, who, args.case):+ who = player(store, args.player) + placed = store.placed(who, case.id) + if placed is not None and placed.source != BENCH: + raise Fail(f"{who.handle} already has {case.id} from the runtime") + store.put(who, case) + print(f"{who.handle}: put {case.id}") + + +def run_note(store: Store, args: argparse.Namespace) -> None: + who = player(store, args.player) + on_board(store, who, args.case) + if args.item and args.item not in store.items(who, args.case): raise Fail(f"{args.case} has no item {args.item}") text = args.file.read_text(encoding="utf-8") if args.file else args.text- db.note(conn, who, args.case, args.item, text)- print(f"{args.player}: noted {args.case}" + (f" / {args.item}" if args.item else ""))+ store.note(who, args.case, args.item, text) + print(f"{who.handle}: noted {args.case}" + (f" / {args.item}" if args.item else ""))-def run_lock(conn: psycopg.Connection, args: argparse.Namespace) -> None:- who = player(conn, args.player)- if db.source(conn, who, args.case) is None:- raise Fail(f"{args.player} doesn't have {args.case}")- try:- new = lock.make(args.answer, lock.parse_rest(args.rest), args.text)- except ValueError as e:- raise Fail(str(e)) from None- db.lock(conn, who, args.case, new)- print(f"{args.player}: locked {args.case}")+def run_lock(store: Store, args: argparse.Namespace) -> None: + who = player(store, args.player) + on_board(store, who, args.case) + new = lock.make(args.answer, lock.parse_rest(args.rest), args.text) + store.lock(who, args.case, new) + print(f"{who.handle}: locked {args.case}")-def run_unlock(conn: psycopg.Connection, args: argparse.Namespace) -> None:- who = player(conn, args.player)- if not db.unlock(conn, who, args.case):- raise Fail(f"{args.case} isn't locked for {args.player}")- print(f"{args.player}: unlocked {args.case}")+def run_unlock(store: Store, args: argparse.Namespace) -> None: + who = player(store, args.player) + if not store.unlock(who, args.case): + raise Fail(f"{args.case} isn't locked for {who.handle}") + print(f"{who.handle}: unlocked {args.case}")-def run_list(conn: psycopg.Connection, args: argparse.Namespace) -> None:- for handle, case_id, title in db.bench_cases(conn, args.player):- print(f"{handle}\t{case_id}\t{title}")+def run_list(store: Store, args: argparse.Namespace) -> None: + players = [player(store, args.player)] if args.player else store.players() + for who in players: + for placed in store.board(who): + if placed.source == BENCH: + print(f"{who.handle}\t{placed.case_id}\t{placed.title}")-def run_pull(conn: psycopg.Connection, args: argparse.Namespace) -> None:- who = player(conn, args.player)- found = db.source(conn, who, args.case)- if found is None:- raise Fail(f"{args.player} doesn't have {args.case}")- if found != "bench":+def run_pull(store: Store, args: argparse.Namespace) -> None: + who = player(store, args.player) + if on_board(store, who, args.case).source != BENCH: raise Fail(f"{args.case} came from the runtime; the bench only pulls what it put there")- db.pull(conn, who, args.case)- print(f"{args.player}: pulled {args.case}")+ store.pull(who, args.case) + print(f"{who.handle}: pulled {args.case}") diff --git a/casebench/db.py b/casebench/db.py deleted file mode 100644 index b3556a0..0000000 --- a/casebench/db.py +++ /dev/null @@ -1,106 +0,0 @@-import hashlib--import psycopg--from .casefile import Case, Image-from .lock import Lock---def connect(dsn: str) -> psycopg.Connection:- return psycopg.connect(dsn)---def player_id(conn: psycopg.Connection, handle: str) -> str | None:- row = conn.execute("select id from players where handle = %s", (handle,)).fetchone()- return row[0] if row else None---def source(conn: psycopg.Connection, player: str, case_id: str) -> str | None:- row = conn.execute(- "select source from board_cases where player_id = %s and case_id = %s", (player, case_id)- ).fetchone()- return row[0] if row else None---def put(conn: psycopg.Connection, player: str, case: Case) -> None:- # putting a case again replaces it. tess changes cases faster than i can pull them.- with conn.transaction():- conn.execute(- "insert into board_cases (player_id, case_id, title, source) values (%s, %s, %s, 'bench')"- " on conflict (player_id, case_id) do update set title = excluded.title",- (player, case.id, case.title),- )- 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, 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",- (player, case_id),- ).fetchall()- return [row[0] for row in rows]---def note(conn: psycopg.Connection, player: str, case_id: str, item_id: str | None, text: str) -> None:- # no item: the note goes under the case's title.- conn.execute(- "insert into board_notes (player_id, case_id, item_id, body) values (%s, %s, %s, %s)",- (player, case_id, item_id, text),- )- conn.commit()---def lock(conn: psycopg.Connection, player: str, case_id: str, new: Lock) -> None:- # locking again starts the lock over: new answers, no wrong tries, not resting.- conn.execute(- "insert into board_locks (player_id, case_id, text, lines, salt, answer_hash, rest_after, rest_minutes)"- " values (%s, %s, %s, %s, %s, %s, %s, %s)"- " on conflict (player_id, case_id) do update set text = excluded.text, lines = excluded.lines,"- " salt = excluded.salt, answer_hash = excluded.answer_hash, rest_after = excluded.rest_after,"- " rest_minutes = excluded.rest_minutes, wrong = 0, resting_until = null, opened_at = null",- (player, case_id, new.text, new.lines, new.salt, new.hash, new.rest.after, new.rest.minutes),- )- conn.commit()---def unlock(conn: psycopg.Connection, player: str, case_id: str) -> bool:- gone = conn.execute(- "delete from board_locks where player_id = %s and case_id = %s", (player, case_id)- ).rowcount- conn.commit()- return gone > 0---def bench_cases(conn: psycopg.Connection, handle: str | None = None) -> list[tuple[str, str, str]]:- query = (- "select p.handle, c.case_id, c.title from board_cases c join players p on p.id = c.player_id"- " where c.source = 'bench'"- )- params: tuple[str, ...] = ()- if handle is not None:- query += " and p.handle = %s"- params = (handle,)- return conn.execute(query + " order by p.handle, c.placed_at", params).fetchall()---def pull(conn: psycopg.Connection, player: str, case_id: str) -> None:- with conn.transaction():- for table in ("board_locks", "board_notes", "board_items", "board_cases"):- conn.execute(f"delete from {table} where player_id = %s and case_id = %s", (player, case_id))diff --git a/casebench/pgstore.py b/casebench/pgstore.py new file mode 100644 index 0000000..960cab9 --- /dev/null +++ b/casebench/pgstore.py @@ -0,0 +1,117 @@ +import hashlib + +import psycopg + +from .casefile import Case, Image +from .lock import Lock, Rest +from .store import Placed, Player + + +class PgStore: + # the hub's postgres, underneath the runtime. + + def __init__(self, dsn: str) -> None: + self.conn = psycopg.connect(dsn, autocommit=True) + + def close(self) -> None: + self.conn.close() + + def players(self) -> list[Player]: + rows = self.conn.execute("select id, handle from players order by handle").fetchall() + return [Player(str(id), handle) for id, handle in rows] + + def player(self, handle: str) -> Player | None: + row = self.conn.execute("select id, handle from players where handle = %s", (handle,)).fetchone() + return Player(str(row[0]), row[1]) if row else None + + def placed(self, player: Player, case_id: str) -> Placed | None: + row = self.conn.execute( + "select case_id, title, source, placed_at from board_cases where player_id = %s and case_id = %s", + (player.id, case_id), + ).fetchone() + return Placed(*row) if row else None + + def board(self, player: Player) -> list[Placed]: + rows = self.conn.execute( + "select case_id, title, source, placed_at from board_cases where player_id = %s order by placed_at", + (player.id,), + ).fetchall() + return [Placed(*row) for row in rows] + + def items(self, player: Player, case_id: str) -> list[str]: + rows = self.conn.execute( + "select item_id from board_items where player_id = %s and case_id = %s order by position", + (player.id, case_id), + ).fetchall() + return [row[0] for row in rows] + + def put(self, player: Player, case: Case) -> None: + # putting a case again replaces its items. notes and a lock stay. + with self.conn.transaction(): + self.conn.execute( + "insert into board_cases (player_id, case_id, title, source) values (%s, %s, %s, 'bench')" + " on conflict (player_id, case_id) do update set title = excluded.title", + (player.id, case.id, case.title), + ) + self.conn.execute( + "delete from board_items where player_id = %s and case_id = %s", (player.id, case.id) + ) + for position, item in enumerate(case.items): + images = [self._media(image) for image in item.images] + self.conn.execute( + "insert into board_items (player_id, case_id, item_id, position, title, body, images)" + " values (%s, %s, %s, %s, %s, %s, %s)", + (player.id, case.id, item.id, position, item.title, item.text, images), + ) + + def _media(self, image: Image) -> str: + # images are kept once by their hash, however many boards show them. + sha = hashlib.sha256(image.data).hexdigest() + self.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 pull(self, player: Player, case_id: str) -> None: + with self.conn.transaction(): + for table in ("board_locks", "board_notes", "board_items", "board_cases"): + self.conn.execute( + f"delete from {table} where player_id = %s and case_id = %s", (player.id, case_id) + ) + + def note(self, player: Player, case_id: str, item_id: str | None, text: str) -> None: + # no item: the note goes under the case's title. + self.conn.execute( + "insert into board_notes (player_id, case_id, item_id, body) values (%s, %s, %s, %s)", + (player.id, case_id, item_id, text), + ) + + def lock(self, player: Player, case_id: str, new: Lock) -> None: + # locking again starts the lock over: new answers, no wrong tries, not resting. + self.conn.execute( + "insert into board_locks" + " (player_id, case_id, text, lines, salt, answer_hash, rest_after, rest_minutes)" + " values (%s, %s, %s, %s, %s, %s, %s, %s)" + " on conflict (player_id, case_id) do update set text = excluded.text, lines = excluded.lines," + " salt = excluded.salt, answer_hash = excluded.answer_hash, rest_after = excluded.rest_after," + " rest_minutes = excluded.rest_minutes, wrong = 0, resting_until = null, opened_at = null", + (player.id, case_id, new.text, new.lines, new.salt, new.hash, new.rest.after, new.rest.minutes), + ) + + def get_lock(self, player: Player, case_id: str) -> Lock | None: + row = self.conn.execute( + "select text, lines, salt, answer_hash, rest_after, rest_minutes, wrong, resting_until, opened_at" + " from board_locks where player_id = %s and case_id = %s", + (player.id, case_id), + ).fetchone() + if row is None: + return None + text, lines, salt, answer_hash, after, minutes, wrong, resting_until, opened_at = row + return Lock(text, lines, bytes(salt), answer_hash, Rest(after, minutes), wrong, resting_until, opened_at) + + def unlock(self, player: Player, case_id: str) -> bool: + gone = self.conn.execute( + "delete from board_locks where player_id = %s and case_id = %s", (player.id, case_id) + ).rowcount + return gone > 0 diff --git a/casebench/store.py b/casebench/store.py new file mode 100644 index 0000000..39f45ba --- /dev/null +++ b/casebench/store.py @@ -0,0 +1,50 @@ +from dataclasses import dataclass +from datetime import datetime +from typing import Protocol + +from .casefile import Case +from .lock import Lock + +BENCH = "bench" + + +@dataclass(frozen=True) +class Player: + id: str + handle: str + + +@dataclass(frozen=True) +class Placed: + case_id: str + title: str + source: str + placed_at: datetime + + +class Store(Protocol): + # everything casebench reads or writes on the board goes through here. + + def players(self) -> list[Player]: ... + + def player(self, handle: str) -> Player | None: ... + + def placed(self, player: Player, case_id: str) -> Placed | None: ... + + def board(self, player: Player) -> list[Placed]: ... + + def items(self, player: Player, case_id: str) -> list[str]: ... + + def put(self, player: Player, case: Case) -> None: ... + + def pull(self, player: Player, case_id: str) -> None: ... + + def note(self, player: Player, case_id: str, item_id: str | None, text: str) -> None: ... + + def lock(self, player: Player, case_id: str, new: Lock) -> None: ... + + def get_lock(self, player: Player, case_id: str) -> Lock | None: ... + + def unlock(self, player: Player, case_id: str) -> bool: ... + + def close(self) -> None: ...