Resolve degrades gracefully when BGG_API_TOKEN is missing

A 401 on an uncached title no longer aborts the run: cached titles
resolve and save, blocked titles are listed with registration/token
instructions and left out of matches.csv so a future run picks them
up untouched. Supports the take-photos-now, resolve-later workflow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-01 14:35:30 -04:00
parent 4bf7481f9b
commit aed969856b
2 changed files with 66 additions and 2 deletions
+17 -2
View File
@@ -18,7 +18,7 @@ from pathlib import Path
import typer import typer
from rapidfuzz import fuzz from rapidfuzz import fuzz
from bggpipe.bgg_client import BGGClient from bggpipe.bgg_client import BGGAuthError, BGGClient
from bggpipe.config import Config from bggpipe.config import Config
from bggpipe.models import GameVersion from bggpipe.models import GameVersion
from bggpipe.normalize import normalize_title from bggpipe.normalize import normalize_title
@@ -417,12 +417,20 @@ def run_resolve(
new_rows: list[MatchRow] = [] new_rows: list[MatchRow] = []
skipped = 0 skipped = 0
blocked: list[str] = []
for entry in entries: for entry in entries:
key = _row_key(entry.title_raw, ";".join(entry.source_photos)) key = _row_key(entry.title_raw, ";".join(entry.source_photos))
if key in existing: if key in existing:
skipped += 1 skipped += 1
continue continue
row = resolve_entry(client, entry) try:
row = resolve_entry(client, entry)
except BGGAuthError:
# No API token yet: cached titles still resolve; the rest wait.
# No row is written, so a future run picks them up untouched.
blocked.append(entry.title_raw)
typer.echo(f" {entry.title_raw!r} -> waiting on BGG API token")
continue
new_rows.append(row) new_rows.append(row)
detail = f"{row.bgg_name} ({row.bgg_id})" if row.bgg_id else "-" detail = f"{row.bgg_name} ({row.bgg_id})" if row.bgg_id else "-"
version = f" [{row.version_status}]" if row.version_status else "" version = f" [{row.version_status}]" if row.version_status else ""
@@ -438,4 +446,11 @@ def run_resolve(
f"Resolved {len(new_rows)} title(s) ({summary or 'nothing new'}); " f"Resolved {len(new_rows)} title(s) ({summary or 'nothing new'}); "
f"skipped {skipped} already in {cfg.matches_path}." f"skipped {skipped} already in {cfg.matches_path}."
) )
if blocked:
typer.echo(
f"\n{len(blocked)} title(s) are waiting on the BGG API "
"(register at https://boardgamegeek.com/applications, then set "
"BGG_API_TOKEN and re-run resolve — everything above is saved): "
+ ", ".join(blocked)
)
return new_rows return new_rows
+49
View File
@@ -294,3 +294,52 @@ def test_wrong_year_hint_never_drives_a_version(client):
assert (row.match_status, row.bgg_id) == ("auto", 2529) assert (row.match_status, row.bgg_id) == ("auto", 2529)
assert row.version_status == "version_unknown" assert row.version_status == "version_unknown"
assert row.version_id is None assert row.version_id is None
# -- graceful degradation without a BGG token ---------------------------
def test_run_resolve_saves_progress_when_token_missing(tmp_path):
"""Cached titles resolve; uncached ones wait for the token instead of
crashing the run and losing everything."""
import shutil as _shutil
partial_cache = tmp_path / "cache"
partial_cache.mkdir()
for f in FIXTURES.glob("search_query=Catan-*"):
_shutil.copy(f, partial_cache / f.name)
data_dir = tmp_path / "data"
data_dir.mkdir()
(data_dir / "titles.json").write_text(
json.dumps(
[
{"title_raw": "Catan", "source_photos": ["a.jpg"]},
{"title_raw": "Wingspan", "source_photos": ["a.jpg"]},
]
)
)
cfg = Config(data_dir=data_dir)
unauthorized = BGGClient(
cache_dir=partial_cache,
transport=httpx.MockTransport(
lambda req: httpx.Response(401, text="Unauthorized")
),
)
rows = run_resolve(cfg, client=unauthorized)
assert [r.title_raw for r in rows] == ["Catan"] # cached one made it
with cfg.matches_path.open(newline="") as f:
saved = list(csv.DictReader(f))
assert len(saved) == 1 # blocked title left for a future run
# future run (fixtures now "recorded"): picks up only the blocked title
full = BGGClient(
cache_dir=FIXTURES,
transport=httpx.MockTransport(
lambda req: (_ for _ in ()).throw(AssertionError("network"))
),
)
rows2 = run_resolve(cfg, client=full)
assert [r.title_raw for r in rows2] == ["Wingspan"]