From 2273ac7cf7617a9d9c1994154edc0865412c1888 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sat, 1 Aug 2026 15:13:29 -0400 Subject: [PATCH] Web review UI: bggpipe review --web (FastAPI, localhost, no build step) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One self-contained page (inline CSS/JS, system fonts, works offline): match cards show source photos, extracted cues, and candidates with cached-XML thumbnails (placeholder tiles until real fixtures exist); actions are pick / manual BGG id / reject, plus a skippable editions pass (pick or unknown). Keyboard-first: j/k navigate, 1-9 pick, r reject, m manual, u unknown, d dismiss. Every decision writes matches.csv through the same ReviewSession methods the TUI now shares — the TUI remains as the no-flag fallback. unidentified.json renders as visually distinct reshoot work-orders with dismissals persisted in data/unidentified_dismissed.json (survives extract rebuilds). Progress tally and a diff-ready done screen; photo serving is allowlisted to photos/ contents; server binds 127.0.0.1 only. Layout leaves room for a later games.json browse view. Provenance guard: fixture generators now write STUB_FIXTURES.marker into their cache dirs, and CLAUDE.md gains the hard rule that stub- resolved version_ids are placeholders — upload must refuse to run while data/bgg_cache/STUB_FIXTURES.marker exists. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 1 + pyproject.toml | 2 + scripts/write_photo_fixtures.py | 6 + scripts/write_stub_fixtures.py | 4 + src/bggpipe/cli.py | 19 +- src/bggpipe/review.py | 55 ++- src/bggpipe/templates/review.html | 419 ++++++++++++++++++ src/bggpipe/webreview.py | 251 +++++++++++ tests/fixtures/bgg_cache/STUB_FIXTURES.marker | 1 + tests/test_webreview.py | 270 +++++++++++ uv.lock | 58 +++ 11 files changed, 1070 insertions(+), 16 deletions(-) create mode 100644 src/bggpipe/templates/review.html create mode 100644 src/bggpipe/webreview.py create mode 100644 tests/fixtures/bgg_cache/STUB_FIXTURES.marker create mode 100644 tests/test_webreview.py diff --git a/CLAUDE.md b/CLAUDE.md index 8131d39..2d557ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - **Every stage is idempotent and resumable** — killing mid-run and restarting must lose no work; re-runs skip already-processed items. - Use only the XML API2 and the public website — no undocumented BGG endpoints (BGG tightened access policies in 2025). - BGG has **no write API**: writes drive the real website with a logged-in Playwright session. +- **Stub-resolved data is never upload-ready.** All version_ids (and some game data) in `matches.csv`, `to_add.csv`, and `to_update.csv` currently come from SYNTHETIC stub fixtures — placeholders until real fixtures exist. When `BGG_API_TOKEN` arrives: delete both cache dirs, re-record fixtures, `resolve --force`, re-review. The caches carry a `STUB_FIXTURES.marker` provenance file (written by the fixture generators); the upload stage MUST refuse to run while `data/bgg_cache/STUB_FIXTURES.marker` exists. ## Domain gotchas diff --git a/pyproject.toml b/pyproject.toml index 8b83c19..636da99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,8 @@ dependencies = [ "pillow>=12.3.0", "pillow-heif>=1.5.0", "rich>=15.0.0", + "fastapi>=0.141.1", + "uvicorn>=0.52.1", ] [project.scripts] diff --git a/scripts/write_photo_fixtures.py b/scripts/write_photo_fixtures.py index 24feb60..cf9692f 100644 --- a/scripts/write_photo_fixtures.py +++ b/scripts/write_photo_fixtures.py @@ -348,6 +348,12 @@ def main() -> None: target.mkdir(parents=True, exist_ok=True) for name, xml in files.items(): (target / name).write_text(xml) + # provenance marker: anything resolved from this cache is stub-derived + # and NOT upload-ready; re-recording real fixtures removes the marker + (target / "STUB_FIXTURES.marker").write_text( + "This cache contains hand-written stub XML, not real BGG " + "responses. Data resolved from it must not be uploaded.\n" + ) print(f"Wrote {len(files)} fixture file(s) to {' and '.join(map(str, TARGETS))}") diff --git a/scripts/write_stub_fixtures.py b/scripts/write_stub_fixtures.py index 8d9a786..6ad8da0 100644 --- a/scripts/write_stub_fixtures.py +++ b/scripts/write_stub_fixtures.py @@ -104,6 +104,10 @@ THINGS = { def main() -> None: FIXTURE_CACHE.mkdir(parents=True, exist_ok=True) + (FIXTURE_CACHE / "STUB_FIXTURES.marker").write_text( + "This cache contains hand-written stub XML, not real BGG " + "responses. Data resolved from it must not be uploaded.\n" + ) for query, items in SEARCHES.items(): key = cache_key("search", {"query": query, "type": SEARCH_TYPES}) total = items.count(" None: +def review( + web: Annotated[ + bool, typer.Option("--web", help="Serve the review UI on localhost") + ] = False, + port: Annotated[int, typer.Option("--port", help="Port for --web")] = 8377, + config: ConfigOpt = None, +) -> None: """Stage 3: human review of ambiguous/unmatched items.""" - from bggpipe.review import run_review - cfg = load_config(config) - run_review(cfg) + if web: + from bggpipe.webreview import run_web_review + + run_web_review(cfg, port=port) + else: + from bggpipe.review import run_review + + run_review(cfg) @app.command() diff --git a/src/bggpipe/review.py b/src/bggpipe/review.py index 84ad771..5daeccf 100644 --- a/src/bggpipe/review.py +++ b/src/bggpipe/review.py @@ -109,6 +109,45 @@ class ReviewSession: shim.version_candidates, ensure_ascii=False ) + # -- decision API (shared by the TUI and the web UI) ---------------- + + def pending_rows(self) -> list[dict]: + return [r for r in self.rows if r["match_status"] in ("ambiguous", "unmatched")] + + def version_rows(self) -> list[dict]: + return [r for r in self.rows if r["version_status"] == "version_ambiguous"] + + def cues_for(self, title_raw: str): + return self._titles.get(title_raw) + + def decide_pick(self, row: dict, candidate: dict) -> None: + self._apply_choice(row, candidate) + + def decide_manual(self, row: dict, bgg_id: int) -> None: + self._manual_id(row, bgg_id) + + def decide_reject(self, row: dict) -> None: + row["match_status"] = "rejected" + self._save() + + def decide_version(self, row: dict, version_id: int | None) -> None: + """Pick a version from the row's stored candidates, or None -> unknown.""" + if version_id is None: + row["version_status"] = "version_unknown" + row["version_id"] = "" + row["version_name"] = "" + else: + candidates = json.loads(row["version_candidates_json"] or "[]") + chosen = next( + (v for v in candidates if v.get("version_id") == version_id), None + ) + if chosen is None: + raise ValueError(f"version {version_id} is not a stored candidate") + row["version_status"] = "version_approved" + row["version_id"] = str(version_id) + row["version_name"] = chosen.get("name") or "" + self._save() + # -- displays ------------------------------------------------------- def _show_item(self, row: dict, candidates: list[dict]) -> None: @@ -152,11 +191,10 @@ class ReviewSession: if lowered == "s": return if lowered == "r": - row["match_status"] = "rejected" - self._save() + self.decide_reject(row) return if answer.isdigit() and 1 <= int(answer) <= len(candidates): - self._apply_choice(row, candidates[int(answer) - 1]) + self.decide_pick(row, candidates[int(answer) - 1]) return if lowered.startswith("m ") and answer[2:].strip().isdigit(): self._manual_id(row, int(answer[2:].strip())) @@ -214,17 +252,10 @@ class ReviewSession: if lowered == "s": return if lowered == "u": - row["version_status"] = "version_unknown" - row["version_id"] = "" - row["version_name"] = "" - self._save() + self.decide_version(row, None) return if answer.isdigit() and 1 <= int(answer) <= len(candidates): - chosen = candidates[int(answer) - 1] - row["version_status"] = "version_approved" - row["version_id"] = str(chosen.get("version_id") or "") - row["version_name"] = chosen.get("name") or "" - self._save() + self.decide_version(row, candidates[int(answer) - 1].get("version_id")) return self.console.print("[yellow]didn't understand that — try again[/yellow]") diff --git a/src/bggpipe/templates/review.html b/src/bggpipe/templates/review.html new file mode 100644 index 0000000..7000a49 --- /dev/null +++ b/src/bggpipe/templates/review.html @@ -0,0 +1,419 @@ + + + + + +bggpipe review + + + +
+ bggpipe review + + + j/k move · 19 pick · + r reject · m manual id · u unknown · + d dismiss + +
+
+ + + diff --git a/src/bggpipe/webreview.py b/src/bggpipe/webreview.py new file mode 100644 index 0000000..2a1e12c --- /dev/null +++ b/src/bggpipe/webreview.py @@ -0,0 +1,251 @@ +"""`bggpipe review --web` — the review TUI's local web face. + +FastAPI + one self-contained HTML page (inline CSS/JS, no build step), +served on localhost only. All decision logic and matches.csv writes go +through ReviewSession — this module is purely an interface. Also renders +data/unidentified.json as reshoot work-orders with a persisted dismiss +action (data/unidentified_dismissed.json survives extract rebuilds). + +The page layout is shared-shell by design: a future "browse" view of +games.json mounts as a sibling section without touching the review code. +""" + +from __future__ import annotations + +import io +import json +from importlib import resources +from pathlib import Path + +import typer +from defusedxml.ElementTree import fromstring as _safe_fromstring +from fastapi import FastAPI, HTTPException +from fastapi.responses import FileResponse, HTMLResponse +from pydantic import BaseModel +from rich.console import Console + +from bggpipe.bgg_client import BGGClient +from bggpipe.config import Config +from bggpipe.review import ReviewSession + +DEFAULT_PORT = 8377 + + +def load_thumbnails(cache_dir: Path) -> dict[int, str]: + """bgg_id -> thumbnail URL, from cached /thing XML only (no live calls). + Stub fixtures carry no thumbnails — the UI shows placeholders then.""" + thumbnails: dict[int, str] = {} + if not cache_dir.is_dir(): + return thumbnails + for path in cache_dir.glob("thing_*.xml"): + try: + root = _safe_fromstring(path.read_text()) + except Exception: # noqa: BLE001 — a corrupt cache file must not kill the UI + continue + for item in root.findall("item"): + thumb = (item.findtext("thumbnail") or "").strip() + if thumb and item.get("id"): + thumbnails[int(item.get("id"))] = thumb + return thumbnails + + +def _sighting_key(photo: str, sighting: dict) -> str: + return "|".join( + [ + photo, + sighting.get("location", ""), + sighting.get("partial_text", ""), + sighting.get("art_notes", ""), + ] + ) + + +class DismissStore: + """Dismissed reshoot sightings, kept apart from unidentified.json so + extract's rebuilds can't resurrect them.""" + + def __init__(self, path: Path) -> None: + self.path = path + self.keys: set[str] = ( + set(json.loads(path.read_text())) if path.exists() else set() + ) + + def add(self, key: str) -> None: + self.keys.add(key) + self.path.parent.mkdir(parents=True, exist_ok=True) + self.path.write_text(json.dumps(sorted(self.keys), indent=2) + "\n") + + +class DecisionBody(BaseModel): + title_raw: str + source_photos: str + action: str # "pick" | "manual" | "reject" + bgg_id: int | None = None + + +class VersionBody(BaseModel): + title_raw: str + source_photos: str + action: str # "pick" | "unknown" + version_id: int | None = None + + +class DismissBody(BaseModel): + photo: str + location: str = "" + partial_text: str = "" + art_notes: str = "" + + +def create_app(cfg: Config, *, client: BGGClient | None = None) -> FastAPI: + app = FastAPI(title="bggpipe review") + session = ReviewSession( + cfg, + console=Console(file=io.StringIO()), + input_fn=lambda prompt: "", + client=client, + ) + thumbnails = load_thumbnails(cfg.cache_dir) + dismissed = DismissStore(cfg.data_dir / "unidentified_dismissed.json") + + def find_row(title_raw: str, source_photos: str) -> dict: + for row in session.rows: + if row["title_raw"] == title_raw and row["source_photos"] == source_photos: + return row + raise HTTPException(404, "row not found — matches.csv changed underneath?") + + def photo_names() -> set[str]: + if not cfg.photos_dir.is_dir(): + return set() + return {p.name for p in cfg.photos_dir.iterdir() if p.is_file()} + + def row_payload(row: dict) -> dict: + entry = session.cues_for(row["title_raw"]) + available = photo_names() + candidates = json.loads(row["candidates_json"] or "[]") + for c in candidates: + c["thumbnail"] = thumbnails.get(c.get("bgg_id")) + return { + "title_raw": row["title_raw"], + "source_photos": row["source_photos"], + "match_status": row["match_status"], + "photos": [p for p in row["source_photos"].split(";") if p in available], + "cues": { + "publisher": entry.publisher_hint if entry else "", + "edition": entry.edition_hint if entry else "", + "year": entry.year_hint if entry else None, + "language": entry.language_hint if entry else "", + "art_notes": entry.art_notes if entry else "", + }, + "candidates": candidates, + } + + def version_payload(row: dict) -> dict: + return { + "title_raw": row["title_raw"], + "source_photos": row["source_photos"], + "bgg_name": row["bgg_name"], + "candidates": json.loads(row["version_candidates_json"] or "[]"), + } + + def state() -> dict: + counts: dict[str, int] = {} + for row in session.rows: + counts[row["match_status"]] = counts.get(row["match_status"], 0) + 1 + version_updates = sum( + 1 + for r in session.rows + if r["version_status"] in ("version_auto", "version_approved") + and r["version_id"] + ) + available = photo_names() + sightings = [] + if cfg.unidentified_path.exists(): + for photo, entries in json.loads(cfg.unidentified_path.read_text()).items(): + for s in entries: + if _sighting_key(photo, s) in dismissed.keys: + continue + sightings.append( + {**s, "photo": photo, "photo_exists": photo in available} + ) + return { + "pending": [row_payload(r) for r in session.pending_rows()], + "versions": [version_payload(r) for r in session.version_rows()], + "unidentified": sightings, + "decisions": session.decisions, + "summary": { + "recognized": counts.get("auto", 0) + counts.get("approved", 0), + "ambiguous": counts.get("ambiguous", 0), + "unmatched": counts.get("unmatched", 0), + "rejected": counts.get("rejected", 0), + "version_updates": version_updates, + "total": len(session.rows), + }, + } + + @app.get("/", response_class=HTMLResponse) + def index() -> str: + return (resources.files("bggpipe") / "templates" / "review.html").read_text() + + @app.get("/api/state") + def api_state() -> dict: + return state() + + @app.post("/api/decision") + def api_decision(body: DecisionBody) -> dict: + row = find_row(body.title_raw, body.source_photos) + if body.action == "pick": + candidates = json.loads(row["candidates_json"] or "[]") + chosen = next( + (c for c in candidates if c.get("bgg_id") == body.bgg_id), None + ) + if chosen is None: + raise HTTPException(400, f"bgg_id {body.bgg_id} is not a candidate") + session.decide_pick(row, chosen) + elif body.action == "manual": + if not body.bgg_id: + raise HTTPException(400, "manual decision needs a bgg_id") + session.decide_manual(row, body.bgg_id) + elif body.action == "reject": + session.decide_reject(row) + else: + raise HTTPException(400, f"unknown action {body.action!r}") + return state() + + @app.post("/api/version") + def api_version(body: VersionBody) -> dict: + row = find_row(body.title_raw, body.source_photos) + if body.action == "unknown": + session.decide_version(row, None) + elif body.action == "pick": + try: + session.decide_version(row, body.version_id) + except ValueError as err: + raise HTTPException(400, str(err)) from err + else: + raise HTTPException(400, f"unknown action {body.action!r}") + return state() + + @app.post("/api/dismiss") + def api_dismiss(body: DismissBody) -> dict: + dismissed.add(_sighting_key(body.photo, body.model_dump(exclude={"photo"}))) + return state() + + @app.get("/photos/{name}") + def photo(name: str): + if name not in photo_names(): # also blocks any path traversal + raise HTTPException(404, "no such photo") + return FileResponse(cfg.photos_dir / name) + + return app + + +def run_web_review(cfg: Config, *, port: int = DEFAULT_PORT) -> None: + import uvicorn + + app = create_app(cfg) + typer.echo( + f"Review UI: http://127.0.0.1:{port}/ (localhost only; every " + "decision saves to matches.csv immediately — Ctrl-C anytime)" + ) + uvicorn.run(app, host="127.0.0.1", port=port, log_level="warning") diff --git a/tests/fixtures/bgg_cache/STUB_FIXTURES.marker b/tests/fixtures/bgg_cache/STUB_FIXTURES.marker new file mode 100644 index 0000000..c34d304 --- /dev/null +++ b/tests/fixtures/bgg_cache/STUB_FIXTURES.marker @@ -0,0 +1 @@ +This cache contains hand-written stub XML, not real BGG responses. Data resolved from it must not be uploaded. diff --git a/tests/test_webreview.py b/tests/test_webreview.py new file mode 100644 index 0000000..de807fc --- /dev/null +++ b/tests/test_webreview.py @@ -0,0 +1,270 @@ +"""Web review UI tests via FastAPI's TestClient — no server, no network, +no live BGG (the injected client 401s on any cache miss).""" + +from __future__ import annotations + +import json + +import httpx +from fastapi.testclient import TestClient + +from bggpipe.bgg_client import BGGClient, cache_key +from bggpipe.config import Config +from bggpipe.resolve import read_matches, write_matches +from bggpipe.webreview import create_app, load_thumbnails + +CITADELS_CANDIDATES = json.dumps( + [ + { + "bgg_id": 478, + "name": "Citadels", + "year": 2000, + "type": "boardgame", + "owned": 85000, + "rank": 250, + }, + { + "bgg_id": 205398, + "name": "Citadels", + "year": 2016, + "type": "boardgame", + "owned": 24000, + "rank": 400, + }, + ] +) +VERSION_CANDIDATES = json.dumps( + [ + { + "version_id": 111, + "name": "First edition", + "year": 1975, + "publishers": ["TSR"], + "languages": ["English"], + "score": 3, + }, + { + "version_id": 222, + "name": "Second edition", + "year": 1980, + "publishers": ["TSR"], + "languages": ["English"], + "score": 3, + }, + ] +) + + +def _row(**overrides) -> dict: + row = { + "title_raw": "", + "bgg_id": "", + "bgg_name": "", + "year": "", + "type": "", + "match_status": "auto", + "version_id": "", + "version_name": "", + "version_status": "version_unknown", + "candidates_json": "[]", + "version_candidates_json": "[]", + "source_photos": "shelf.jpg", + } + row.update(overrides) + return row + + +def make_cfg(tmp_path) -> Config: + cfg = Config(data_dir=tmp_path / "data", photos_dir=tmp_path / "photos") + cfg.photos_dir.mkdir(parents=True) + (cfg.photos_dir / "shelf.jpg").write_bytes(b"\xff\xd8\xff\xdbfakejpeg") + write_matches( + cfg.matches_path, + [ + _row( + title_raw="Citadels", + match_status="ambiguous", + candidates_json=CITADELS_CANDIDATES, + ), + _row(title_raw="Mystery", match_status="unmatched"), + _row( + title_raw="Dungeon!", + match_status="auto", + bgg_id="1339", + bgg_name="Dungeon!", + version_status="version_ambiguous", + version_candidates_json=VERSION_CANDIDATES, + ), + ], + ) + cfg.titles_path.write_text( + json.dumps( + [ + { + "title_raw": "Citadels", + "publisher_hint": "Fantasy Flight", + "edition_hint": "", + "source_photos": ["shelf.jpg"], + } + ] + ) + ) + cfg.unidentified_path.write_text( + json.dumps( + { + "shelf.jpg": [ + { + "location": "top shelf, far left", + "partial_text": "EMP", + "art_notes": "black box, gold letters", + } + ] + } + ) + ) + return cfg + + +def unauthorized_client(tmp_path) -> BGGClient: + return BGGClient( + cache_dir=tmp_path / "no_cache", + transport=httpx.MockTransport( + lambda req: httpx.Response(401, text="Unauthorized") + ), + ) + + +def make_client(tmp_path) -> tuple[TestClient, Config]: + cfg = make_cfg(tmp_path) + app = create_app(cfg, client=unauthorized_client(tmp_path)) + return TestClient(app), cfg + + +def test_index_serves_page(tmp_path): + web, _ = make_client(tmp_path) + response = web.get("/") + assert response.status_code == 200 + assert "bggpipe" in response.text + + +def test_state_lists_pending_versions_and_tickets(tmp_path): + web, _ = make_client(tmp_path) + state = web.get("/api/state").json() + assert [r["title_raw"] for r in state["pending"]] == ["Citadels", "Mystery"] + citadels = state["pending"][0] + assert citadels["cues"]["publisher"] == "Fantasy Flight" + assert citadels["photos"] == ["shelf.jpg"] + assert citadels["candidates"][0]["thumbnail"] is None # stub cache: placeholder + assert [v["title_raw"] for v in state["versions"]] == ["Dungeon!"] + assert state["unidentified"][0]["location"] == "top shelf, far left" + assert state["summary"]["total"] == 3 + + +def test_pick_candidate_persists(tmp_path): + web, cfg = make_client(tmp_path) + state = web.post( + "/api/decision", + json={ + "title_raw": "Citadels", + "source_photos": "shelf.jpg", + "action": "pick", + "bgg_id": 205398, + }, + ).json() + assert [r["title_raw"] for r in state["pending"]] == ["Mystery"] + saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)} + assert saved["Citadels"]["match_status"] == "approved" + assert saved["Citadels"]["bgg_id"] == "205398" + + +def test_manual_id_degrades_without_token(tmp_path): + web, cfg = make_client(tmp_path) + response = web.post( + "/api/decision", + json={ + "title_raw": "Mystery", + "source_photos": "shelf.jpg", + "action": "manual", + "bgg_id": 99999, + }, + ) + assert response.status_code == 200 + saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)} + assert saved["Mystery"]["match_status"] == "approved" + assert saved["Mystery"]["bgg_id"] == "99999" + assert saved["Mystery"]["bgg_name"] == "" # lookup blocked, id recorded + + +def test_reject_and_bad_pick(tmp_path): + web, cfg = make_client(tmp_path) + web.post( + "/api/decision", + json={"title_raw": "Mystery", "source_photos": "shelf.jpg", "action": "reject"}, + ) + saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)} + assert saved["Mystery"]["match_status"] == "rejected" + bad = web.post( + "/api/decision", + json={ + "title_raw": "Citadels", + "source_photos": "shelf.jpg", + "action": "pick", + "bgg_id": 42, + }, + ) + assert bad.status_code == 400 + + +def test_version_pick_and_unknown(tmp_path): + web, cfg = make_client(tmp_path) + state = web.post( + "/api/version", + json={ + "title_raw": "Dungeon!", + "source_photos": "shelf.jpg", + "action": "pick", + "version_id": 222, + }, + ).json() + assert state["versions"] == [] + saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)} + assert saved["Dungeon!"]["version_status"] == "version_approved" + assert saved["Dungeon!"]["version_id"] == "222" + assert state["summary"]["version_updates"] == 1 + + +def test_dismiss_persists_across_restarts(tmp_path): + web, cfg = make_client(tmp_path) + state = web.post( + "/api/dismiss", + json={ + "photo": "shelf.jpg", + "location": "top shelf, far left", + "partial_text": "EMP", + "art_notes": "black box, gold letters", + }, + ).json() + assert state["unidentified"] == [] + # a brand-new app instance (fresh server) still honors the dismissal + web2 = TestClient(create_app(cfg, client=unauthorized_client(tmp_path))) + assert web2.get("/api/state").json()["unidentified"] == [] + + +def test_photo_serving_is_locked_down(tmp_path): + web, _ = make_client(tmp_path) + assert web.get("/photos/shelf.jpg").status_code == 200 + assert web.get("/photos/nope.jpg").status_code == 404 + assert web.get("/photos/..%2Fdata%2Fmatches.csv").status_code == 404 + + +def test_thumbnails_come_from_cached_thing_xml(tmp_path): + cache = tmp_path / "cache" + cache.mkdir() + key = cache_key("thing", {"id": "478", "stats": "1"}) + (cache / key).write_text( + '' + "https://cf.example/citadels.jpg" + '' + ) + thumbs = load_thumbnails(cache) + assert thumbs == {478: "https://cf.example/citadels.jpg"} diff --git a/uv.lock b/uv.lock index 36414a5..3f1fdbd 100644 --- a/uv.lock +++ b/uv.lock @@ -59,12 +59,14 @@ source = { editable = "." } dependencies = [ { name = "anthropic" }, { name = "defusedxml" }, + { name = "fastapi" }, { name = "httpx" }, { name = "pillow" }, { name = "pillow-heif" }, { name = "rapidfuzz" }, { name = "rich" }, { name = "typer" }, + { name = "uvicorn" }, ] [package.dev-dependencies] @@ -77,12 +79,14 @@ dev = [ requires-dist = [ { name = "anthropic", specifier = ">=0.120.2" }, { name = "defusedxml", specifier = ">=0.7.1" }, + { name = "fastapi", specifier = ">=0.141.1" }, { name = "httpx", specifier = ">=0.27" }, { name = "pillow", specifier = ">=12.3.0" }, { name = "pillow-heif", specifier = ">=1.5.0" }, { name = "rapidfuzz", specifier = ">=3.9" }, { name = "rich", specifier = ">=15.0.0" }, { name = "typer", specifier = ">=0.12" }, + { name = "uvicorn", specifier = ">=0.52.1" }, ] [package.metadata.requires-dev] @@ -100,6 +104,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -136,6 +152,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, ] +[[package]] +name = "fastapi" +version = "0.141.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -646,6 +678,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + [[package]] name = "typer" version = "0.27.0" @@ -681,3 +726,16 @@ sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] + +[[package]] +name = "uvicorn" +version = "0.52.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/03/18/ccce41535dee1be77735592bd19965f3972c82e07ee703d324709496b716/uvicorn-0.52.1.tar.gz", hash = "sha256:112ec661814189acbccd3f7b86460147cc065fc92c0821afa78918780e4354dd", size = 100571, upload-time = "2026-08-01T18:19:30.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d5/68e6e9bca63c0badf67002890a46d3784c958de45b65e1275ec583ca1f06/uvicorn-0.52.1-py3-none-any.whl", hash = "sha256:e4403f9d93188cf9d1088e9f40e3acd12630e2df8675316704379a7fc20fff6a", size = 79859, upload-time = "2026-08-01T18:19:29.294Z" }, +]