owenrusk.dev

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: ...