diff --git a/pyproject.toml b/pyproject.toml index 4722d3f..ecb95c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "bggpipe" -version = "0.1.0" +dynamic = ["version"] description = "Shelf-to-BGG collection pipeline: photos in, BoardGameGeek collection out" requires-python = ">=3.12" dependencies = [ @@ -27,6 +27,9 @@ dev = [ "ruff>=0.5", ] +[tool.hatch.version] +path = "src/bggpipe/__init__.py" + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/scripts/fixture_common.py b/scripts/fixture_common.py index 2d83fc6..848f2e2 100644 --- a/scripts/fixture_common.py +++ b/scripts/fixture_common.py @@ -11,6 +11,8 @@ from pathlib import Path from bggpipe.config import STUB_CACHE_MARKER_NAME, STUB_DATA_MARKER_NAME +FIXTURE_CACHE = Path("tests/fixtures/bgg_cache") + CACHE_MARKER_TEXT = ( "This cache contains hand-written stub XML, not real BGG " "responses. Data resolved from it must not be uploaded.\n" diff --git a/scripts/record_fixtures.py b/scripts/record_fixtures.py index 25bf3c6..29b7f5f 100644 --- a/scripts/record_fixtures.py +++ b/scripts/record_fixtures.py @@ -13,10 +13,12 @@ from __future__ import annotations import os from pathlib import Path +from fixture_common import FIXTURE_CACHE + from bggpipe.bgg_client import BGGClient from bggpipe.resolve import load_titles, resolve_entry -FIXTURE_CACHE = Path("tests/fixtures/bgg_cache") +FIXTURE_CACHE = FIXTURE_CACHE def main() -> None: diff --git a/scripts/write_photo_fixtures.py b/scripts/write_photo_fixtures.py index 6c5204d..7b013d2 100644 --- a/scripts/write_photo_fixtures.py +++ b/scripts/write_photo_fixtures.py @@ -15,18 +15,13 @@ Usage: uv run python scripts/write_photo_fixtures.py from __future__ import annotations -from pathlib import Path - -from fixture_common import esc, write_cache_marker, write_data_marker +from fixture_common import FIXTURE_CACHE, esc, write_cache_marker, write_data_marker from bggpipe.bgg_client import SEARCH_TYPES, cache_key +from bggpipe.config import Config -TARGETS = (Path("tests/fixtures/bgg_cache"), Path("data/bgg_cache")) +TARGETS = (FIXTURE_CACHE, Config().cache_dir) -# Committed alongside the stub-derived CSVs (the cache markers are -# gitignored, so this is what protects a fresh clone): upload refuses to -# run while it exists. -DATA_MARKER = Path("data/STUB_DATA.marker") BG, EXP = "boardgame", "boardgameexpansion" @@ -362,9 +357,9 @@ def main() -> None: # provenance marker: anything resolved from this cache is stub-derived # and NOT upload-ready; re-recording real fixtures removes the marker write_cache_marker(target) - write_data_marker(DATA_MARKER.parent) + write_data_marker() print(f"Wrote {len(files)} fixture file(s) to {' and '.join(map(str, TARGETS))}") - print(f"Wrote {DATA_MARKER} (committed; upload refuses while it exists)") + print("Wrote data/STUB_DATA.marker (committed; upload refuses while it exists)") if __name__ == "__main__": diff --git a/scripts/write_stub_fixtures.py b/scripts/write_stub_fixtures.py index 74c98f4..03a1441 100644 --- a/scripts/write_stub_fixtures.py +++ b/scripts/write_stub_fixtures.py @@ -12,14 +12,10 @@ Usage: uv run python scripts/write_stub_fixtures.py from __future__ import annotations -from pathlib import Path - -from fixture_common import esc, write_cache_marker +from fixture_common import FIXTURE_CACHE, esc, write_cache_marker from bggpipe.bgg_client import SEARCH_TYPES, cache_key -FIXTURE_CACHE = Path("tests/fixtures/bgg_cache") - def search_item(bgg_id: int, name: str, year: int | None, type_: str) -> str: year_xml = f'' if year else "" diff --git a/src/bggpipe/bgg_client.py b/src/bggpipe/bgg_client.py index a13d4d8..b8b2d54 100644 --- a/src/bggpipe/bgg_client.py +++ b/src/bggpipe/bgg_client.py @@ -196,3 +196,9 @@ class BGGClient: def client_for(cfg: Config) -> BGGClient: """The standard injection fallback: every stage's `client or client_for(cfg)`.""" return BGGClient(cfg.cache_dir, cfg.rate_limit_seconds) + + +def cached_paths(cache_dir: Path, endpoint: str) -> list[Path]: + """Cache files for one endpoint — the ONLY sanctioned way to glob the + cache, so the filename layout stays private to cache_key.""" + return sorted(cache_dir.glob(f"{endpoint}_*.xml")) diff --git a/src/bggpipe/config.py b/src/bggpipe/config.py index 911a29a..3eb86bf 100644 --- a/src/bggpipe/config.py +++ b/src/bggpipe/config.py @@ -70,6 +70,19 @@ class Config: def dismissed_path(self) -> Path: return self.data_dir / "unidentified_dismissed.json" + @property + def snapshot_paths(self) -> tuple[Path, Path]: + return ( + self.data_dir / "collection_snapshot_base.xml", + self.data_dir / "collection_snapshot_expansions.xml", + ) + + @property + def storage_state_path(self) -> Path: + # cwd-relative on purpose (credential-adjacent, gitignored) but + # centralized here with every other artifact path + return Path("storage_state.json") + @property def stub_marker_paths(self) -> tuple[Path, Path]: # gitignored (travels with the stub XML) + committed (guards clones) diff --git a/src/bggpipe/diff.py b/src/bggpipe/diff.py index 9398ddb..e88aee5 100644 --- a/src/bggpipe/diff.py +++ b/src/bggpipe/diff.py @@ -18,7 +18,6 @@ Outputs both artifacts: from __future__ import annotations -import csv import os from dataclasses import dataclass, field from pathlib import Path @@ -27,10 +26,11 @@ import typer from bggpipe.bgg_client import BGGAuthError, BGGClient, client_for from bggpipe.config import Config +from bggpipe.fsio import atomic_write_csv from bggpipe.models import ( - CONFIDENT_VERSION_STATUSES, RECOGNIZED_MATCH_STATUSES, CollectionItem, + is_confident_version, parse_collection, ) from bggpipe.resolve import read_matches @@ -69,8 +69,7 @@ def load_snapshot_collection(data_dir: Path) -> list[CollectionItem]: same physical copy can appear in both responses).""" items: list[CollectionItem] = [] seen: set[int] = set() - for name in SNAPSHOT_FILES: - path = data_dir / name + for path in Config(data_dir=data_dir).snapshot_paths: if not path.exists(): raise FileNotFoundError( f"{path} not found — pull your collection while logged in " @@ -137,30 +136,50 @@ def compute_diff(rows: list[dict], collection: list[CollectionItem]) -> DiffResu c for c in by_object.get(bgg_id, []) if c.coll_id not in consumed_collids ] - def is_confident(row: dict) -> bool: - return bool( - row["version_status"] in CONFIDENT_VERSION_STATUSES and row["version_id"] - ) + # Ordered sub-passes over the confident rows. Greedy per-row handling + # let an EARLIER row's disagreement consume the exact-version copy a + # LATER row matched — producing a duplicate upload. Claims must settle + # strongest-first across ALL rows: exact version matches, then + # versionless upgrades, then disagreement/second-copy handling. + confident_rows = [r for r in recognized if is_confident_version(r)] + leftover: list[dict] = [] - # Pass 1 — confident-version rows claim copies first (an exact version - # match, then a versionless copy to upgrade). Bare rows must not steal - # a versionless copy a confident row would have upgraded. - for row in (r for r in recognized if is_confident(r)): + # 1a — exact (bgg_id, version) matches consume first + for row in confident_rows: bgg_id = int(row["bgg_id"]) - version_id = int(row["version_id"]) - remaining = unconsumed(bgg_id) if not by_object.get(bgg_id): result.to_add.append(add_row(row, True)) continue - matching = [c for c in remaining if c.version_id == version_id] + matching = [ + c for c in unconsumed(bgg_id) if c.version_id == int(row["version_id"]) + ] if matching: - # exact (bgg_id, version) pair: consume, so a SECOND row with - # the same version (a vetoed duplicate = a real second copy) - # falls through to the branches below instead of vanishing + # consume, so a SECOND row with the same version (a vetoed + # duplicate = a real second copy) falls through to 1b/1c consumed_collids.add(matching[0].coll_id) result.already_owned.append(row["title_raw"]) + else: + leftover.append(row) + + # 1b — versionless copies get upgraded. Guard: upload's row-edit flow + # targets rows by game NAME (a collid can't drive the UI), so an update + # is only safe when EVERY copy of the game is versionless — otherwise + # the browser could open the versioned copy and overwrite it. + still_left: list[dict] = [] + for row in leftover: + bgg_id = int(row["bgg_id"]) + versionless = [c for c in unconsumed(bgg_id) if c.version_id is None] + any_versioned = any(c.version_id is not None for c in by_object.get(bgg_id, [])) + if versionless and any_versioned: + consumed_collids.add(versionless[0].coll_id) + result.already_owned.append(row["title_raw"]) + result.disagreements.append( + f"{row['title_raw']}: a versionless copy could take version " + f"{row['version_name']!r}, but another copy already carries " + "a version — set it by hand on BGG (the automated row edit " + "can't safely target a specific copy)" + ) continue - versionless = [c for c in remaining if c.version_id is None] if versionless: target = versionless[0] consumed_collids.add(target.coll_id) @@ -174,8 +193,14 @@ def compute_diff(rows: list[dict], collection: list[CollectionItem]) -> DiffResu "version_name": row["version_name"], } ) - elif remaining: - # an unclaimed copy exists but carries a DIFFERENT version: + else: + still_left.append(row) + + # 1c — what remains disagrees with an unclaimed copy (report-only) or + # is an additional physical copy (every copy claimed by another row) + for row in still_left: + remaining = unconsumed(int(row["bgg_id"])) + if remaining: # most likely the same physical box mis-scored — report, never # touch, never duplicate (spec: report the disagreement) consumed_collids.add(remaining[0].coll_id) @@ -187,9 +212,6 @@ def compute_diff(rows: list[dict], collection: list[CollectionItem]) -> DiffResu "left untouched" ) else: - # every copy is claimed by other match rows: this row is an - # additional physical copy (spec: a pair is owned only when a - # collection item matches both ids) result.to_add.append(add_row(row, True)) result.second_copies.append( f"{row['title_raw']}: adding as a NEW copy with version " @@ -197,25 +219,31 @@ def compute_diff(rows: list[dict], collection: list[CollectionItem]) -> DiffResu "existing entry of this game keeps its current version" ) - # Pass 2 — bare (version-unknown) rows: owned while unclaimed copies - # remain; extras beyond the owned count (vetoed duplicates) are added - # as version-less new entries. - for row in (r for r in recognized if not is_confident(r)): + # Pass 2 — bare (version-unknown) rows. Spec: a bare id is owned if ANY + # copy exists — only a human veto (dedupe_veto) makes an extra bare row + # a genuine additional copy. + for row in (r for r in recognized if not is_confident_version(r)): bgg_id = int(row["bgg_id"]) - if not by_object.get(bgg_id): + copies = by_object.get(bgg_id, []) + if not copies: result.to_add.append(add_row(row, False)) continue remaining = unconsumed(bgg_id) if remaining: consumed_collids.add(remaining[0].coll_id) result.already_owned.append(row["title_raw"]) - else: + elif row.get("dedupe_veto"): result.to_add.append(add_row(row, False)) result.second_copies.append( f"{row['title_raw']}: adding as a NEW version-less copy — " - "every existing entry of this game is claimed by another " - "match row" + "human-vetoed duplicate, every existing entry claimed by " + "another match row" ) + else: + # unvetoed bare row, all copies claimed: per spec still owned + # (a typo-read sibling of a confident row must not become a + # spurious upload) + result.already_owned.append(row["title_raw"]) result.unseen = [ item for item in collection if item.object_id not in seen_object_ids @@ -224,13 +252,7 @@ def compute_diff(rows: list[dict], collection: list[CollectionItem]) -> DiffResu def _write_csv(path: Path, columns: list[str], rows: list[dict]) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - tmp = path.with_name(path.name + ".tmp") - with tmp.open("w", newline="") as f: - writer = csv.DictWriter(f, fieldnames=columns, extrasaction="ignore") - writer.writeheader() - writer.writerows(rows) - os.replace(tmp, path) # atomic: a killed diff never leaves a torn queue + atomic_write_csv(path, columns, rows) # a killed diff never tears the queue def run_diff(cfg: Config, *, client: BGGClient | None = None) -> DiffResult: diff --git a/src/bggpipe/enrich.py b/src/bggpipe/enrich.py index 65a39ca..aa56c12 100644 --- a/src/bggpipe/enrich.py +++ b/src/bggpipe/enrich.py @@ -22,17 +22,14 @@ import typer from bggpipe.bgg_client import BGGAuthError, BGGClient, client_for from bggpipe.config import Config from bggpipe.fsio import atomic_write_text -from bggpipe.models import ( - CONFIDENT_VERSION_STATUSES, - RECOGNIZED_MATCH_STATUSES, -) +from bggpipe.models import is_confident_version, is_recognized from bggpipe.resolve import read_matches BATCH_SIZE = 20 def _version_info(row: dict) -> dict | None: - if row["version_status"] not in CONFIDENT_VERSION_STATUSES or not row["version_id"]: + if not is_confident_version(row): return None version_id = int(row["version_id"]) for cand in json.loads(row["version_candidates_json"] or "[]"): @@ -64,7 +61,7 @@ def run_enrich( targets: list[tuple[str, int, dict | None]] = [] for row in rows: - if row["match_status"] not in RECOGNIZED_MATCH_STATUSES or not row["bgg_id"]: + if not is_recognized(row): continue version = _version_info(row) key = f"{row['bgg_id']}:{version['version_id']}" if version else row["bgg_id"] diff --git a/src/bggpipe/extract.py b/src/bggpipe/extract.py index 5928623..2772f72 100644 --- a/src/bggpipe/extract.py +++ b/src/bggpipe/extract.py @@ -111,6 +111,11 @@ def parse_vision_response(text: str) -> tuple[list[dict], list[dict], int]: A bare JSON array (the pre-unidentified response shape) still parses — it's all titles.""" cleaned = _CODE_FENCE.sub("", text).strip() + if cleaned[:1] in ("[", "{"): + # trim trailing prose after a leading JSON payload ("{...}\nNote:") + end = cleaned.rfind("]" if cleaned[0] == "[" else "}") + if end != -1: + cleaned = cleaned[: end + 1] if cleaned[:1] not in ("[", "{"): starts = [i for i in (cleaned.find("["), cleaned.find("{")) if i != -1] if not starts: @@ -309,12 +314,18 @@ def run_extract( raw_dir.mkdir(parents=True, exist_ok=True) vision = vision or default_vision(cfg.model) + failed: list[str] = [] for photo in photos: raw_path = raw_dir / f"{photo.name}.json" if raw_path.exists() and not only and not force: typer.echo(f" {photo.name}: already extracted, skipping") continue - result = extract_photo(photo, vision) + try: + result = extract_photo(photo, vision) + except Exception as err: # one bad photo must not block the rest + failed.append(photo.name) + typer.echo(f" {photo.name}: FAILED ({err}) — continuing") + continue atomic_write_text( raw_path, json.dumps(result, indent=2, ensure_ascii=False) + "\n" ) @@ -336,6 +347,11 @@ def run_extract( raw_dir, cfg.titles_path, cfg.unidentified_path ) typer.echo(f"Wrote {len(deduped)} unique title(s) to {cfg.titles_path}.") + if failed: + typer.echo( + f"\n{len(failed)} photo(s) failed extraction (re-run to retry): " + + ", ".join(failed) + ) if unidentified: typer.echo( diff --git a/src/bggpipe/fsio.py b/src/bggpipe/fsio.py index 1965762..b510377 100644 --- a/src/bggpipe/fsio.py +++ b/src/bggpipe/fsio.py @@ -7,6 +7,7 @@ available to JSON artifacts and the XML response cache. from __future__ import annotations +import csv import os from pathlib import Path @@ -16,3 +17,18 @@ def atomic_write_text(path: Path, text: str) -> None: tmp = path.with_name(path.name + ".tmp") tmp.write_text(text) os.replace(tmp, path) + + +def atomic_write_csv(path: Path, columns: list[str], rows: list[dict]) -> int: + """Atomic CSV rewrite (tmp + os.replace). Returns the written file's + mtime_ns so callers tracking their own writes avoid a re-stat race.""" + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(path.name + ".tmp") + with tmp.open("w", newline="") as f: + writer = csv.DictWriter( + f, fieldnames=columns, extrasaction="ignore", restval="" + ) + writer.writeheader() + writer.writerows(rows) + os.replace(tmp, path) + return path.stat().st_mtime_ns diff --git a/src/bggpipe/models.py b/src/bggpipe/models.py index 5c49bba..6e83d2a 100644 --- a/src/bggpipe/models.py +++ b/src/bggpipe/models.py @@ -2,7 +2,8 @@ from __future__ import annotations -import xml.etree.ElementTree as ET # element types only; parsing goes via defusedxml +import warnings +import xml.etree.ElementTree as ET # parsing itself goes via defusedxml from dataclasses import dataclass, field from defusedxml.ElementTree import fromstring as _safe_fromstring @@ -17,6 +18,20 @@ class BGGResponseError(Exception): # human approved it, and a row reaches diff/enrich only when its match did. CONFIDENT_VERSION_STATUSES = ("version_auto", "version_approved") RECOGNIZED_MATCH_STATUSES = ("auto", "approved") +PENDING_MATCH_STATUSES = ("ambiguous", "unmatched") +UNDECIDED_MATCH_STATUSES = ("ambiguous", "unmatched", "merged") + + +def is_recognized(row: dict) -> bool: + """A row that reaches diff/enrich: matched and carrying a real id.""" + return row["match_status"] in RECOGNIZED_MATCH_STATUSES and bool(row["bgg_id"]) + + +def is_confident_version(row: dict) -> bool: + """A version trusted for upload: auto-scored or human-approved, with id.""" + return row["version_status"] in CONFIDENT_VERSION_STATUSES and bool( + row["version_id"] + ) @dataclass(frozen=True) @@ -94,6 +109,11 @@ def parse_search(xml_text: str) -> list[SearchResult]: type=item.get("type", "boardgame"), ) ) + if skipped and results: + warnings.warn( + f"search: {skipped} unparseable item(s) tolerated — schema drift?", + stacklevel=2, + ) if skipped and not results: raise BGGResponseError( f"search response had {skipped} item(s), none parseable — " diff --git a/src/bggpipe/resolve.py b/src/bggpipe/resolve.py index d386661..e818c34 100644 --- a/src/bggpipe/resolve.py +++ b/src/bggpipe/resolve.py @@ -2,15 +2,16 @@ Reads data/titles.json, queries BGG search (+ thing stats for tie-breaks, + versions once a game is settled), classifies each title auto/ambiguous/ -unmatched, and appends rows to data/matches.csv. Re-runs skip titles -already present in matches.csv unless --force. +unmatched, then post-dedupes rows resolving to the same physical game +(losers become match_status="merged"; review can veto). The whole file is +rewritten atomically each run; re-runs pair entries to their existing rows +(photo overlap, then position) unless --force starts over. """ from __future__ import annotations import csv import json -import os import re from collections import Counter from dataclasses import dataclass, field @@ -19,13 +20,14 @@ from pathlib import Path import typer from rapidfuzz import fuzz -from bggpipe.bgg_client import BGGAuthError, BGGClient, client_for +from bggpipe.bgg_client import BGGAuthError, BGGClient, BGGQueueTimeout, client_for from bggpipe.config import Config from bggpipe.extract import cues_conflict +from bggpipe.fsio import atomic_write_csv from bggpipe.models import ( - CONFIDENT_VERSION_STATUSES, RECOGNIZED_MATCH_STATUSES, GameVersion, + is_confident_version, ) from bggpipe.normalize import normalize_title @@ -442,10 +444,10 @@ def dedupe_matches(rows: list[dict], titles: list[TitleEntry]) -> list[MergeEven # re-running resolve must never overturn that (spec: re-runs # lose no work, least of all review decisions) continue - confident = ( - row["version_status"] in CONFIDENT_VERSION_STATUSES and row["version_id"] + key = ( + row["bgg_id"], + row["version_id"] if is_confident_version(row) else "", ) - key = (row["bgg_id"], row["version_id"] if confident else "") groups.setdefault(key, []).append(row) events: list[MergeEvent] = [] @@ -487,17 +489,11 @@ def read_matches(path: Path) -> list[dict[str, str]]: return rows -def write_matches(path: Path, rows: list[dict[str, str]]) -> None: - """Atomic full rewrite — review updates rows in place decision by decision.""" - path.parent.mkdir(parents=True, exist_ok=True) - tmp = path.with_name(path.name + ".tmp") - with tmp.open("w", newline="") as f: - writer = csv.DictWriter( - f, fieldnames=MATCH_COLUMNS, extrasaction="ignore", restval="" - ) - writer.writeheader() - writer.writerows(rows) - os.replace(tmp, path) +def write_matches(path: Path, rows: list[dict[str, str]]) -> int: + """Atomic full rewrite — review updates rows in place decision by + decision. Returns the written file's mtime_ns so the caller can record + its own write without a re-stat race.""" + return atomic_write_csv(path, MATCH_COLUMNS, rows) def run_resolve( @@ -509,15 +505,43 @@ def run_resolve( existing_rows = read_matches(cfg.matches_path) client = client or client_for(cfg) - # Pair entries with existing rows BY TITLE, positionally, not by exact - # (title, photos) key: extract unions a new photo of an already-resolved - # game into its entry, and that must update the row's provenance — not - # re-resolve the game as a duplicate row. Same-title entries only stay - # separate when their cues conflict (two editions), and those pair up - # in stable file order on both sides. + # Pair entries with existing rows BY TITLE: photo-overlap first, then + # position. Pure position breaks when titles.json order churns (a + # reshoot photo sorting earlier reorders same-title entries); overlap + # keeps each edition glued to its own row, and position only settles + # entries with no photo history. rows_by_title: dict[str, list[dict]] = {} for row in existing_rows: rows_by_title.setdefault(row["title_raw"], []).append(row) + + claimed_rows: set[int] = set() + + def pair_row(entry: TitleEntry) -> dict | None: + candidates = [ + r + for r in rows_by_title.get(entry.title_raw, []) + if id(r) not in claimed_rows + ] + if not candidates: + return None + photos = set(entry.source_photos) + for r in candidates: + if photos & set(r["source_photos"].split(";")): + claimed_rows.add(id(r)) + return r + return None + + def pair_row_positional(entry: TitleEntry) -> dict | None: + candidates = [ + r + for r in rows_by_title.get(entry.title_raw, []) + if id(r) not in claimed_rows + ] + if candidates: + claimed_rows.add(id(candidates[0])) + return candidates[0] + return None + seen_per_title: dict[str, int] = {} new_rows: list[MatchRow] = [] @@ -525,32 +549,48 @@ def run_resolve( photos_updated = False blocked: list[str] = [] blocked_titles: set[str] = set() + # overlap pass first so a reordered titles.json can't mispair editions + paired_by_id: dict[int, dict] = {} for entry in entries: - ix = seen_per_title.get(entry.title_raw, 0) - seen_per_title[entry.title_raw] = ix + 1 - paired = rows_by_title.get(entry.title_raw, []) - if entry.title_raw in blocked_titles and ix >= len(paired): - # an earlier same-title entry is waiting on the token: resolving - # this one now would append a row at the wrong position and - # corrupt next run's positional pairing — defer the whole group - blocked.append(entry.title_raw) - continue - if ix < len(paired): - row_dict = paired[ix] + row_dict = pair_row(entry) + if row_dict is not None: + paired_by_id[id(entry)] = row_dict + for entry in entries: + if id(entry) not in paired_by_id: + row_dict = pair_row_positional(entry) + if row_dict is not None: + paired_by_id[id(entry)] = row_dict + + for entry in entries: + seen_per_title[entry.title_raw] = seen_per_title.get(entry.title_raw, 0) + 1 + row_dict = paired_by_id.get(id(entry)) + if row_dict is not None: photos = ";".join(entry.source_photos) if row_dict["source_photos"] != photos: row_dict["source_photos"] = photos photos_updated = True skipped += 1 continue + if entry.title_raw in blocked_titles: + # an earlier same-title entry is waiting on the token: resolving + # this one now would claim the wrong pairing slot on the next + # run — defer the whole group + blocked.append(entry.title_raw) + continue 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. + except (BGGAuthError, BGGQueueTimeout) as err: + # No token / BGG still queueing: cached titles still resolve; + # the rest wait. No row is written, so a future run picks them + # up untouched. blocked.append(entry.title_raw) blocked_titles.add(entry.title_raw) - typer.echo(f" {entry.title_raw!r} -> waiting on BGG API token") + reason = ( + "waiting on BGG API token" + if isinstance(err, BGGAuthError) + else "BGG still queueing — re-run in a minute" + ) + typer.echo(f" {entry.title_raw!r} -> {reason}") continue new_rows.append(row) detail = f"{row.bgg_name} ({row.bgg_id})" if row.bgg_id else "-" diff --git a/src/bggpipe/review.py b/src/bggpipe/review.py index 66ff649..d780cd2 100644 --- a/src/bggpipe/review.py +++ b/src/bggpipe/review.py @@ -23,7 +23,11 @@ from rich.table import Table from bggpipe.bgg_client import BGGAuthError, BGGClient, BGGQueueTimeout, client_for from bggpipe.config import Config -from bggpipe.models import BGGResponseError +from bggpipe.models import ( + PENDING_MATCH_STATUSES, + UNDECIDED_MATCH_STATUSES, + BGGResponseError, +) from bggpipe.resolve import ( MatchRow, TitleEntry, @@ -128,8 +132,16 @@ class ReviewSession: undecided = [ i for i in candidates - if self.rows[i]["match_status"] in ("ambiguous", "unmatched", "merged") + if self.rows[i]["match_status"] in UNDECIDED_MATCH_STATUSES ] + if not undecided: + # a VERSION decision targets an approved row: prefer the slot + # still awaiting its edition, not the sibling already versioned + undecided = [ + i + for i in candidates + if self.rows[i]["version_status"] == "version_ambiguous" + ] self.rows[(undecided or candidates)[0]] = row return True @@ -147,11 +159,13 @@ class ReviewSession: ) return try: - write_matches(self.cfg.matches_path, self.rows) + own_mtime = write_matches(self.cfg.matches_path, self.rows) except OSError: self._load() # memory must never claim what disk doesn't hold raise - self._loaded_mtimes = self._data_mtimes() # own writes aren't "changes" + # record the mtime write_matches itself observed: re-statting later + # could adopt a foreign rewrite landing in the gap as "own write" + self._loaded_mtimes = (own_mtime, self._data_mtimes()[1]) self.decisions += 1 def _apply_choice(self, row: dict, candidate: dict) -> None: @@ -166,7 +180,10 @@ class ReviewSession: def _fill_version(self, row: dict) -> None: """Try version resolution for a just-approved row. Degrades gracefully: no cues, no token, or API trouble all leave version_unknown.""" - entry = self._titles.get(row["title_raw"]) + # photo-aware lookup: two same-title entries are two EDITIONS with + # different cues — the title-only dict would hand every row the last + # edition's cues and score the wrong version to version_auto + entry = self.cues_for(row["title_raw"], row["source_photos"]) if entry is None or not row["bgg_id"]: row["version_status"] = row["version_status"] or "version_unknown" return @@ -174,8 +191,13 @@ class ReviewSession: try: resolve_version(self.client, entry, shim) except _BGG_ERRORS as err: - self._warn(f"version lookup unavailable ({err}) — recorded version_unknown") - row["version_status"] = "version_unknown" + self._warn( + f"version lookup unavailable ({err}) — re-approve this row " + "to retry the edition lookup" + ) + # distinct from version_unknown ("no cues"): a transient failure + # stays visibly retryable instead of terminal + row["version_status"] = "version_error" return row["version_status"] = shim.version_status row["version_id"] = str(shim.version_id or "") @@ -208,6 +230,27 @@ class ReviewSession: row["dedupe_veto"] = "1" # persists: resolve re-runs must not re-merge self._save(row) + def find_row( + self, title_raw: str, source_photos: str, row_ix: int | None = None + ) -> dict | None: + """Locate a row by ordinal (duplicate two-edition rows) or by key, + preferring a still-undecided slot on key collisions. None = gone.""" + if row_ix is not None and 0 <= row_ix < len(self.rows): + row = self.rows[row_ix] + if row["title_raw"] == title_raw and row["source_photos"] == source_photos: + return row + matches = [ + row + for row in self.rows + if row["title_raw"] == title_raw and row["source_photos"] == source_photos + ] + undecided = [ + row for row in matches if row["match_status"] in UNDECIDED_MATCH_STATUSES + ] + if undecided or matches: + return (undecided or matches)[0] + return None + def cues_for( self, title_raw: str, source_photos: str | None = None ) -> TitleEntry | None: @@ -299,7 +342,7 @@ class ReviewSession: 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())) + self.decide_manual(row, int(answer[2:].strip())) return if lowered.startswith("f ") and answer[2:].strip(): candidates = self._research(answer[2:].strip()) or candidates @@ -387,7 +430,7 @@ class ReviewSession: self.console.print("[dim]stopping — progress is saved[/dim]") remaining = sum( - 1 for r in self.rows if r["match_status"] in ("ambiguous", "unmatched") + 1 for r in self.rows if r["match_status"] in PENDING_MATCH_STATUSES ) self.console.print( f"Recorded {self.decisions} decision(s); " diff --git a/src/bggpipe/templates/review.html b/src/bggpipe/templates/review.html index de11c9d..0ebdb5e 100644 --- a/src/bggpipe/templates/review.html +++ b/src/bggpipe/templates/review.html @@ -575,6 +575,7 @@ setInterval(async () => { } if (pollMisses >= 3) render(); // recovered: rebuild banners from state pollMisses = 0; + if (STATE && fresh.revision < STATE.revision) return; // stale poll response if (JSON.stringify(fresh) !== JSON.stringify(STATE)) { STATE = fresh; render(); diff --git a/src/bggpipe/upload.py b/src/bggpipe/upload.py index 367c636..54f4910 100644 --- a/src/bggpipe/upload.py +++ b/src/bggpipe/upload.py @@ -7,9 +7,10 @@ browser session. Etiquette (spec + bgg-api skill): - browser storage state persists locally (gitignored) so login is rare; - every attempt is appended to data/upload_log.csv immediately, so a killed run loses nothing and re-runs skip completed work; -- refuses to touch the site while data/bgg_cache/STUB_FIXTURES.marker exists - (stub-resolved version ids must never reach BGG); --dry-run still works, - loudly labeled as synthetic. +- refuses to touch the site while either provenance marker exists — + data/bgg_cache/STUB_FIXTURES.marker (gitignored) or data/STUB_DATA.marker + (committed, so fresh clones stay guarded); --dry-run still works, loudly + labeled as synthetic. Cloudflare: BGG fronts the site with a Turnstile check that blocks headless browsers outright (verified 2026-08-01 — headless shell never gets past @@ -37,10 +38,10 @@ import typer from bggpipe.bgg_client import BGGAuthError, BGGClient, client_for from bggpipe.config import Config +from bggpipe.fsio import atomic_write_text from bggpipe.models import CollectionItem BGG = "https://boardgamegeek.com" -STORAGE_STATE_PATH = Path("storage_state.json") # gitignored, credential-adjacent UPLOAD_LOG_COLUMNS = [ "action", "bgg_id", @@ -94,14 +95,14 @@ def _read_csv(path: Path) -> list[dict]: def append_log_row(path: Path, row: dict) -> None: - """Append one attempt, creating the file with a header on first write. - One row per attempt, flushed immediately — the log is the resume point.""" - new = not path.exists() - path.parent.mkdir(parents=True, exist_ok=True) + """Append one attempt. One row per attempt, flushed immediately — the + log is the resume point, so its header is created ATOMICALLY first (a + torn header line would become DictReader's fieldnames and misparse + every logged success on the next run).""" + if not path.exists(): + atomic_write_text(path, ",".join(UPLOAD_LOG_COLUMNS) + "\r\n") with path.open("a", newline="") as f: writer = csv.DictWriter(f, fieldnames=UPLOAD_LOG_COLUMNS, extrasaction="ignore") - if new: - writer.writeheader() writer.writerow(row) @@ -148,12 +149,30 @@ def build_queue( for row in to_update ] + done_versions: dict[tuple[str, str], set[str]] = {} + for row in log_rows: + if row["status"] in DONE_STATUSES: + done_versions.setdefault( + (row["action"], row["collid"] or row["bgg_id"]), set() + ).add(row["version_id"]) + jobs: list[UploadJob] = [] skipped_done = skipped_failed = 0 deferred: list[UploadJob] = [] update_game_seen: set[str] = set() seen: Counter[tuple[str, str, str]] = Counter() for job in candidates: + prior = done_versions.get( + ("update", job.collid) if job.action == "update" else ("add", job.bgg_id), + set(), + ) + if prior and job.version_id not in prior: + typer.echo( + f" note: {job.name} was previously {job.action}ed with a " + f"different version ({', '.join(sorted(prior)) or 'none'}) — " + "if re-review changed the version, the BGG entry needs a " + "manual correction (additive-only rule)" + ) occurrence = seen[job.key] seen[job.key] += 1 if not job.name: @@ -215,11 +234,11 @@ class PlaywrightUploader: def __init__( self, username: str, - storage_state: Path = STORAGE_STATE_PATH, + storage_state: Path | None = None, headless: bool = False, ) -> None: self._username = username - self._storage_state = storage_state + self._storage_state = storage_state or Config().storage_state_path self._headless = headless self._authed = False @@ -286,15 +305,16 @@ class PlaywrightUploader: self._context.storage_state(path=str(self._storage_state)) self._authed = True - def _open_dialog(self, opener) -> object: + def _open_dialog(self, opener): """Click an opener that may no-op right after page load (hydration - race) and wait for the dialog to actually show.""" + race) and wait for the dialog to actually show. (Params/return are + Playwright Locators; untyped because the import is lazy.)""" dialog = self._page.get_by_role("dialog") for attempt in (1, 2): opener.click() try: dialog.wait_for(state="visible", timeout=5_000) - return dialog + break except self._timeout_error: if attempt == 2: raise @@ -326,9 +346,16 @@ class PlaywrightUploader: # best guess is a next-page button, stopping when absent/disabled. nxt = dialog.get_by_role("button", name=re.compile("next|›|»", re.I)).first if nxt.count() == 0 or nxt.is_disabled(): - break + break # genuine end of list: added_no_version is honest nxt.click() self._page.wait_for_timeout(2_000) # etiquette: paginating hits BGG too + else: + # never saw the end of the list: "not in picker" would be a + # false verdict frozen into DONE_STATUSES — stay retryable + raise RuntimeError( + f"hit MAX_VERSION_PAGES ({MAX_VERSION_PAGES}) without " + "finding the version or the end of the list — retryable" + ) # Two-level dismissal: the sub-view has its own Cancel distinct from # the main dialog's. dialog.get_by_role("button", name="Cancel").first.click() @@ -454,7 +481,7 @@ def verify_uploads(log_rows: list[dict], collection: list[CollectionItem]) -> li problems = [] added_copies: Counter[int] = Counter() - seen_add_keys: set[tuple[str, str, str]] = set() + shortfall_reported: set[int] = set() latest: dict[tuple[str, str, str], dict] = {} for row in log_rows: k = _job_key(row) @@ -467,13 +494,13 @@ def verify_uploads(log_rows: list[dict], collection: list[CollectionItem]) -> li if not copies: problems.append(f"{row['name']}: logged added but not in collection") elif ( - _job_key(row) not in seen_add_keys + int(row["bgg_id"]) not in shortfall_reported and len(copies) < added_copies[int(row["bgg_id"])] ): # the unverified second-copy dialog may EDIT the existing # entry instead of creating one — a count shortfall is the - # only externally visible symptom - seen_add_keys.add(_job_key(row)) + # only externally visible symptom; report once per GAME + shortfall_reported.add(int(row["bgg_id"])) problems.append( f"{row['name']}: {added_copies[int(row['bgg_id'])]} " f"add(s) logged but only {len(copies)} cop" @@ -515,7 +542,7 @@ def run_upload( headless: bool = False, uploader: Uploader | None = None, client: BGGClient | None = None, - storage_state: Path = STORAGE_STATE_PATH, + storage_state: Path | None = None, sleep: Callable[[float], None] = time.sleep, rng: random.Random | None = None, now: Callable[[], str] | None = None, @@ -597,7 +624,9 @@ def run_upload( ) raise typer.Exit(code=1) with PlaywrightUploader( - cfg.bgg_username, storage_state=storage_state, headless=headless + cfg.bgg_username, + storage_state=storage_state or cfg.storage_state_path, + headless=headless, ) as real: results = _process(real, jobs, log_path, sleep=sleep, rng=rng, now=now) else: diff --git a/src/bggpipe/webreview.py b/src/bggpipe/webreview.py index b21427d..2a06522 100644 --- a/src/bggpipe/webreview.py +++ b/src/bggpipe/webreview.py @@ -16,6 +16,8 @@ import io import json import os import threading +import warnings +import xml.etree.ElementTree as ET from collections import Counter from importlib import resources from pathlib import Path @@ -27,8 +29,9 @@ from fastapi.responses import FileResponse, HTMLResponse, Response from pydantic import BaseModel from rich.console import Console -from bggpipe.bgg_client import BGGClient +from bggpipe.bgg_client import BGGClient, cached_paths from bggpipe.config import DEFAULT_REVIEW_PORT, Config +from bggpipe.fsio import atomic_write_text from bggpipe.models import ( CONFIDENT_VERSION_STATUSES, RECOGNIZED_MATCH_STATUSES, @@ -42,15 +45,24 @@ def load_thumbnails(cache_dir: Path) -> dict[int, str]: thumbnails: dict[int, str] = {} if not cache_dir.is_dir(): return thumbnails - for path in cache_dir.glob("thing_*.xml"): + unreadable = [] + for path in cached_paths(cache_dir, "thing"): try: root = _safe_fromstring(path.read_text()) - except Exception: # 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 + for item in root.findall("item"): + thumb = (item.findtext("thumbnail") or "").strip() + if thumb and item.get("id"): + thumbnails[int(item.get("id"))] = thumb + except (ET.ParseError, OSError, ValueError): + # cosmetic degradation is fine — eating the evidence is not: + # this same file will crash resolve/review later if served + unreadable.append(path.name) + if unreadable: + warnings.warn( + f"{len(unreadable)} unreadable cache file(s) — thumbnails " + f"missing; delete to refetch: {', '.join(unreadable[:3])}", + stacklevel=2, + ) return thumbnails @@ -78,14 +90,26 @@ class DismissStore: def __init__(self, path: Path) -> None: self.path = path - self.keys: set[str] = ( - set(json.loads(path.read_text())) if path.exists() else set() - ) + self.keys: set[str] = set() + if path.exists(): + try: + self.keys = set(json.loads(path.read_text())) + except (json.JSONDecodeError, OSError) as err: + # a torn write must not brick the server; quarantine and go on + quarantine = path.with_name(path.name + ".corrupt") + path.rename(quarantine) + warnings.warn( + f"{path} was unreadable ({err}) — moved to {quarantine}; " + "previously dismissed tickets will reappear", + stacklevel=2, + ) def add(self, key: str) -> None: + # write first, mutate after: a failed write must leave the ticket + # visible (memory never claims what disk doesn't hold) + updated = sorted(self.keys | {key}) + atomic_write_text(self.path, json.dumps(updated, indent=2) + "\n") 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): @@ -131,35 +155,22 @@ def create_app(cfg: Config, *, client: BGGClient | None = None) -> FastAPI: # GET /api/state can freshen()-swap session.rows out from under a # concurrent decision POST, silently dropping the decision. lock = threading.Lock() + revision = {"n": 0} # bumped on every mutation and reload def freshen() -> None: """Serve every request from the current file state: an extract or resolve run in another terminal must show up without a restart.""" nonlocal thumbnails if session.reload_if_changed(): + revision["n"] += 1 thumbnails = load_thumbnails(cfg.cache_dir) def find_row(title_raw: str, source_photos: str, row_ix: int | None = None) -> dict: freshen() - # ordinal first: (title_raw, source_photos) is not unique when one - # photo holds two editions of the same game - if row_ix is not None and 0 <= row_ix < len(session.rows): - row = session.rows[row_ix] - if row["title_raw"] == title_raw and row["source_photos"] == source_photos: - return row - matches = [ - row - for row in session.rows - if row["title_raw"] == title_raw and row["source_photos"] == source_photos - ] - undecided = [ - row - for row in matches - if row["match_status"] in ("ambiguous", "unmatched", "merged") - ] - if undecided or matches: - return (undecided or matches)[0] - raise HTTPException(404, "row not found — matches.csv changed underneath?") + row = session.find_row(title_raw, source_photos, row_ix) + if row is None: + raise HTTPException(404, "row not found — matches.csv changed underneath?") + return row def photo_names() -> set[str]: if not cfg.photos_dir.is_dir(): @@ -201,12 +212,18 @@ def create_app(cfg: Config, *, client: BGGClient | None = None) -> FastAPI: freshen() counts = Counter(row["match_status"] for row in session.rows) resolved_titles = {r["title_raw"] for r in session.rows} - rows_by_title: dict[str, dict] = {} + rows_by_title: dict[str, list[dict]] = {} for r in session.rows: - rows_by_title.setdefault(r["title_raw"], r) + rows_by_title.setdefault(r["title_raw"], []).append(r) + title_seen: Counter[str] = Counter() catalog = [] for entry in session.titles: - row = rows_by_title.get(entry.title_raw) + same_title = rows_by_title.get(entry.title_raw, []) + ix = title_seen[entry.title_raw] + title_seen[entry.title_raw] += 1 + # positional pairing, same rule as run_resolve: the ix-th entry + # of a title reports the ix-th row of that title + row = same_title[ix] if ix < len(same_title) else None catalog.append( { "title_raw": entry.title_raw, @@ -249,6 +266,7 @@ def create_app(cfg: Config, *, client: BGGClient | None = None) -> FastAPI: for r in session.merged_rows() ] return { + "revision": revision["n"], "warnings": session.warnings[-10:], "pending": [row_payload(r) for r in session.pending_rows()], "versions": [version_payload(r) for r in session.version_rows()], @@ -280,6 +298,7 @@ def create_app(cfg: Config, *, client: BGGClient | None = None) -> FastAPI: @app.post("/api/decision") def api_decision(body: DecisionBody) -> dict: with lock: + revision["n"] += 1 return _decide(body) def _decide(body: DecisionBody) -> dict: @@ -305,6 +324,7 @@ def create_app(cfg: Config, *, client: BGGClient | None = None) -> FastAPI: @app.post("/api/version") def api_version(body: VersionBody) -> dict: with lock: + revision["n"] += 1 return _version(body) def _version(body: VersionBody) -> dict: @@ -323,6 +343,7 @@ def create_app(cfg: Config, *, client: BGGClient | None = None) -> FastAPI: @app.post("/api/veto-merge") def api_veto_merge(body: VetoBody) -> dict: with lock: + revision["n"] += 1 row = find_row(body.title_raw, body.source_photos, body.row_ix) if row["match_status"] != "merged": raise HTTPException(400, "row is not merged") @@ -332,6 +353,7 @@ def create_app(cfg: Config, *, client: BGGClient | None = None) -> FastAPI: @app.post("/api/dismiss") def api_dismiss(body: DismissBody) -> dict: with lock: + revision["n"] += 1 dismissed.add(_sighting_key(body.photo, body.model_dump(exclude={"photo"}))) return state() diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..433f3b3 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,64 @@ +"""CLI flag-wiring smoke tests: every option must reach its run_* kwarg. + +The commands lazily import their stage modules, so each test monkeypatches +the stage function at its source module and asserts the received kwargs — +a transposed or dropped pass-through fails HERE, not on a real run. +""" + +from __future__ import annotations + +from typer.testing import CliRunner + +from bggpipe.cli import app + +runner = CliRunner() + + +def _capture(monkeypatch, module: str, func: str) -> dict: + received: dict = {} + + def fake(cfg, **kwargs): + received.update(kwargs) + received["cfg"] = cfg + return [] + + monkeypatch.setattr(f"bggpipe.{module}.{func}", fake) + return received + + +def test_extract_flags(monkeypatch): + received = _capture(monkeypatch, "extract", "run_extract") + result = runner.invoke(app, ["extract", "--only", "x.jpg", "--force"]) + assert result.exit_code == 0 + assert received["only"] == "x.jpg" and received["force"] is True + + +def test_resolve_force(monkeypatch): + received = _capture(monkeypatch, "resolve", "run_resolve") + assert runner.invoke(app, ["resolve", "--force"]).exit_code == 0 + assert received["force"] is True + + +def test_diff_wiring(monkeypatch): + received = _capture(monkeypatch, "diff", "run_diff") + assert runner.invoke(app, ["diff"]).exit_code == 0 + assert "cfg" in received + + +def test_upload_flags(monkeypatch): + received = _capture(monkeypatch, "upload", "run_upload") + result = runner.invoke( + app, ["upload", "--dry-run", "--retry-failed", "--limit", "3", "--headless"] + ) + assert result.exit_code == 0 + assert received["dry_run"] is True + assert received["retry_failed"] is True + assert received["limit"] == 3 + assert received["headless"] is True + assert received["verify"] is False + + +def test_enrich_refresh(monkeypatch): + received = _capture(monkeypatch, "enrich", "run_enrich") + assert runner.invoke(app, ["enrich", "--refresh"]).exit_code == 0 + assert received["refresh"] is True diff --git a/tests/test_diff.py b/tests/test_diff.py index dd857b0..96d99d3 100644 --- a/tests/test_diff.py +++ b/tests/test_diff.py @@ -6,14 +6,10 @@ from __future__ import annotations from pathlib import Path from bggpipe.config import Config -from bggpipe.diff import compute_diff, load_snapshot_collection +from bggpipe.diff import SNAPSHOT_FILES, compute_diff, load_snapshot_collection from bggpipe.models import CollectionItem FIXTURES = Path(__file__).parent / "fixtures" -SNAPSHOT_NAMES = ( - "collection_snapshot_base.xml", - "collection_snapshot_expansions.xml", -) def _item(object_id, coll_id, name="Game", version_id=None, own=True): @@ -154,17 +150,50 @@ def test_vetoed_duplicate_of_same_version_is_a_real_second_copy(): assert len(result.second_copies) == 1 -def test_bare_duplicate_beyond_owned_count_is_added_versionless(): - # Two vetoed version-unknown rows, one owned copy: the extra bare row - # is a version-less second copy, not silently "already owned". - rows = [_match("Catan", "13"), _match("Catan", "13")] - result = compute_diff(rows, [_item(13, 900)]) +def test_vetoed_bare_duplicate_beyond_owned_count_is_added_versionless(): + # Two version-unknown rows, one owned copy: only a HUMAN VETO makes the + # extra bare row a genuine second copy (spec: a bare id is owned if any + # copy exists — an unvetoed typo-read sibling must not upload). + vetoed = {**_match("Catan", "13"), "dedupe_veto": "1"} + result = compute_diff([_match("Catan", "13"), vetoed], [_item(13, 900)]) assert result.already_owned == ["Catan"] (added,) = result.to_add assert added["version_id"] == "" assert len(result.second_copies) == 1 +def test_unvetoed_bare_duplicate_stays_owned(): + # same shape WITHOUT the veto: both rows owned, nothing uploaded + rows = [_match("Catan", "13"), _match("Catan", "13")] + result = compute_diff(rows, [_item(13, 900)]) + assert result.already_owned == ["Catan", "Catan"] + assert result.to_add == [] + + +def test_earlier_disagreement_cannot_steal_a_later_rows_exact_match(): + # round-3 ordering bug: row A (v3, no match) must not consume the v2 + # copy that row B exactly matches — exact matches settle first + rows = [ + _match("Catan", "13", vstatus="version_auto", vid="3", vname="v3"), + _match("Catan", "13", vstatus="version_auto", vid="2", vname="v2"), + ] + result = compute_diff(rows, [_item(13, 900, version_id=2)]) + assert result.already_owned == ["Catan"] # B's exact match claims the copy + # A's v3 box exists on the shelf and matches no collection entry: a + # genuine new copy — NOT a spurious v2 duplicate, NOT a false disagreement + assert [r["version_id"] for r in result.to_add] == ["3"] + assert result.disagreements == [] + + +def test_update_withheld_when_another_copy_is_versioned(): + # upload's row edit targets by NAME: an update is only safe when every + # copy is versionless, else it could overwrite the versioned copy + rows = [_match("Catan", "13", vstatus="version_auto", vid="5", vname="5th")] + result = compute_diff(rows, [_item(13, 900, version_id=7), _item(13, 901)]) + assert result.to_update == [] + assert "set it by hand" in result.disagreements[0] + + def test_bare_row_does_not_steal_versionless_copy_from_confident_update(): # ordering independence: the confident row upgrades the versionless # copy even when a bare row of the same game appears first in the file @@ -256,10 +285,7 @@ def test_run_diff_outputs_feed_upload_unchanged(tmp_path, monkeypatch): monkeypatch.delenv("BGG_API_TOKEN", raising=False) cfg = Config(data_dir=tmp_path) fixtures = Path(__file__).parent / "fixtures" - for name in ( - "collection_snapshot_base.xml", - "collection_snapshot_expansions.xml", - ): + for name in SNAPSHOT_FILES: shutil.copy(fixtures / name, tmp_path / name) write_matches( cfg.matches_path, @@ -290,10 +316,60 @@ def test_token_without_username_says_so(tmp_path, monkeypatch, capsys): monkeypatch.delenv("BGG_USERNAME", raising=False) cfg = Config(data_dir=tmp_path) # bgg_username defaults to "" fixtures = Path(__file__).parent / "fixtures" - for name in SNAPSHOT_NAMES: + for name in SNAPSHOT_FILES: shutil.copy(fixtures / name, tmp_path / name) write_matches(cfg.matches_path, [_match("Catan", "13")]) run_diff(cfg) out = capsys.readouterr().out assert "BGG_API_TOKEN is set but BGG_USERNAME is not" in out assert "No BGG_API_TOKEN" not in out # the old message was a lie here + + +class _LiveClient: + """Fake client recording collection_full calls for the live branch.""" + + def __init__(self, collection, fail_auth=False): + self.collection = collection + self.fail_auth = fail_auth + self.calls: list[dict] = [] + + def collection_full(self, username, *, refresh=False): + from bggpipe.bgg_client import BGGAuthError + + self.calls.append({"username": username, "refresh": refresh}) + if self.fail_auth: + raise BGGAuthError("token rejected") + return self.collection + + +def test_live_diff_fetches_fresh_collection(tmp_path, monkeypatch): + # the branch that runs the day the token arrives: must call + # collection_full with refresh=True, not serve resolve-era cache + + from bggpipe.diff import run_diff + from bggpipe.resolve import write_matches + + monkeypatch.setenv("BGG_API_TOKEN", "tok") + monkeypatch.setenv("BGG_USERNAME", "eric") + cfg = Config(bgg_username="eric", data_dir=tmp_path) + write_matches(cfg.matches_path, [_match("Catan", "13")]) + client = _LiveClient([_item(13, 1, name="Catan")]) + result = run_diff(cfg, client=client) + assert client.calls == [{"username": "eric", "refresh": True}] + assert result.already_owned == ["Catan"] + + +def test_live_diff_falls_back_to_snapshots_on_auth_failure(tmp_path, monkeypatch): + import shutil + + from bggpipe.diff import run_diff + from bggpipe.resolve import write_matches + + monkeypatch.setenv("BGG_API_TOKEN", "bad") + monkeypatch.setenv("BGG_USERNAME", "eric") + cfg = Config(bgg_username="eric", data_dir=tmp_path) + for name in SNAPSHOT_FILES: + shutil.copy(FIXTURES / name, tmp_path / name) + write_matches(cfg.matches_path, [_match("5 MINUTE DUNGEON", "207830")]) + result = run_diff(cfg, client=_LiveClient([], fail_auth=True)) + assert result.already_owned == ["5 MINUTE DUNGEON"] # snapshots served diff --git a/tests/test_models.py b/tests/test_models.py index e08c2f1..4d16afd 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -116,3 +116,29 @@ def test_parse_collection(): def test_error_document_raises(): with pytest.raises(BGGResponseError, match="Invalid username"): parse_collection(ERROR_XML) + + +def test_search_all_items_malformed_raises(): + import pytest + + from bggpipe.models import BGGResponseError, parse_search + + xml = '' + with pytest.raises(BGGResponseError): + parse_search(xml) + + +def test_search_partial_malformed_tolerated_with_warning(): + import pytest + + from bggpipe.models import parse_search + + xml = ( + '' + '' + '' + "" + ) + with pytest.warns(UserWarning, match="unparseable"): + results = parse_search(xml) + assert [r.bgg_id for r in results] == [13] diff --git a/tests/test_resolve.py b/tests/test_resolve.py index c8b4f8f..1b20dfc 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -631,3 +631,36 @@ def test_truncation_separator_chosen_by_position(): heads = _truncation_heads("Blorvath: Quest of the Zzyzx - 2nd Edition") assert heads[0] == "Blorvath" assert "Blorvath: Quest" not in heads # the comment's guarantee, now true + + +def test_reordered_titles_json_cannot_mispair_editions(client, tmp_path): + # run 1 resolves "Catan" from b.jpg; run 2 prepends a NEW conflicting-cue + # "Catan" sighting from a.jpg (sorts earlier). Photo-overlap pairing must + # keep the b.jpg row glued to the b.jpg entry — not hand its resolution + # (and photos) to the newcomer positionally. + data_dir = tmp_path / "data" + data_dir.mkdir() + entry_b = { + "title_raw": "Catan", + "language_hint": "English", # conflicts with a.jpg's German; language + "source_photos": ["b.jpg"], # alone never triggers a versions fetch + } + (data_dir / "titles.json").write_text(json.dumps([entry_b])) + cfg = Config(data_dir=data_dir) + run_resolve(cfg, client=client) + (row,) = read_matches(cfg.matches_path) + assert row["source_photos"] == "b.jpg" + + entry_a = { + "title_raw": "Catan", + "language_hint": "German", + "source_photos": ["a.jpg"], + } + (data_dir / "titles.json").write_text(json.dumps([entry_a, entry_b])) + run_resolve(cfg, client=client) + + rows = read_matches(cfg.matches_path) + by_photos = {r["source_photos"]: r for r in rows} + assert by_photos["b.jpg"]["match_status"] == "auto" # kept its resolution + assert "a.jpg" in by_photos # newcomer resolved as its own row + assert len(rows) == 2 diff --git a/tests/test_review.py b/tests/test_review.py index dc4940d..f9bf8b9 100644 --- a/tests/test_review.py +++ b/tests/test_review.py @@ -419,3 +419,71 @@ def test_every_tui_decision_after_external_rewrite_is_saved(tmp_path): ) assert "Newcomer" in saved # the external row survived too assert session.decisions == 3 + + +def test_fill_version_uses_the_approved_rows_own_cues(tmp_path): + # round-3 HIGH: two same-title entries are two EDITIONS; the title-only + # dict handed every row the LAST entry's cues, scoring the wrong version + import json as _json + + entry_good = { + "title_raw": "Wingspan", + "publisher_hint": "Stonemaier", # matches the fixture's version + "year_hint": 2019, + "source_photos": ["good.jpg"], + } + entry_bad = { + "title_raw": "Wingspan", + "publisher_hint": "Nobody Media", # matches nothing + "edition_hint": "Imaginary edition", + "source_photos": ["bad.jpg"], + } + cfg = _setup( + tmp_path, + [ + _row( + title_raw="Wingspan", + match_status="ambiguous", + source_photos="good.jpg", + candidates_json=_json.dumps( + [{"bgg_id": 266192, "name": "Wingspan", "year": 2019}] + ), + ) + ], + ) + (cfg.data_dir / "titles.json").write_text(_json.dumps([entry_good, entry_bad])) + session = ReviewSession( + cfg, console=quiet_console(), input_fn=scripted(), client=fixture_client() + ) + row = session.rows[0] + session.decide_pick(row, {"bgg_id": 266192, "name": "Wingspan", "year": 2019}) + # with last-wins cues (entry_bad) this was version_unknown; the row's own + # photo (good.jpg) must select entry_good's cues and find the version + assert row["version_status"] == "version_auto" + assert row["version_id"] == "465063" + + +def test_dismiss_failure_keeps_ticket_visible(tmp_path, monkeypatch): + import bggpipe.webreview as webreview_mod + from bggpipe.webreview import DismissStore + + store = DismissStore(tmp_path / "dismissed.json") + + def exploding(path, text): + raise OSError("disk full") + + monkeypatch.setattr(webreview_mod, "atomic_write_text", exploding) + with pytest.raises(OSError): + store.add("photo|loc|txt|art") + assert store.keys == set() # memory never claims what disk doesn't hold + + +def test_corrupt_dismiss_file_is_quarantined_not_fatal(tmp_path): + from bggpipe.webreview import DismissStore + + path = tmp_path / "dismissed.json" + path.write_text('["torn') + with pytest.warns(UserWarning, match="unreadable"): + store = DismissStore(path) + assert store.keys == set() + assert (tmp_path / "dismissed.json.corrupt").exists() diff --git a/tests/test_upload.py b/tests/test_upload.py index 1ffd2df..343ba11 100644 --- a/tests/test_upload.py +++ b/tests/test_upload.py @@ -22,7 +22,9 @@ from bggpipe.upload import ( verify_uploads, ) -NOW = lambda: "2026-08-01T00:00:00+00:00" # noqa: E731 + +def NOW() -> str: + return "2026-08-01T00:00:00+00:00" def _cfg(tmp_path: Path) -> Config: @@ -425,3 +427,44 @@ def test_empty_game_name_is_refused_not_uploaded(tmp_path): fake = FakeUploader() results = run_upload(cfg, uploader=fake, sleep=lambda s: None, now=NOW) assert results == [] and fake.calls == [] + + +def test_verify_shortfall_reported_once_per_game(tmp_path): + # two DONE adds (different versions) of one game, one copy on BGG: + # exactly ONE shortfall problem (the old _job_key guard was dead code + # and double-reported) + log = [ + _log_row(action="add", bgg_id="7", version_id="1", status="added"), + _log_row(action="add", bgg_id="7", version_id="2", status="added"), + ] + problems = verify_uploads(log, [_item(7, 70, version_id=1)]) + shortfalls = [p for p in problems if "add(s) logged" in p] + assert len(shortfalls) == 1 + + +class _VerifyClient: + def __init__(self, collection): + self.collection = collection + self.calls: list[dict] = [] + + def collection_full(self, username, *, refresh=False): + self.calls.append({"username": username, "refresh": refresh}) + return self.collection + + +def test_run_upload_verify_wiring(tmp_path, capsys): + # verify=True must re-fetch the LIVE collection (refresh) and cross-check + cfg = _cfg(tmp_path) + _seed_data(tmp_path, to_add=[_add_row(bgg_id="1", name="Wingspan")]) + run_upload(cfg, uploader=FakeUploader(), sleep=lambda s: None, now=NOW) + client = _VerifyClient([_item(1, 10, name="Wingspan")]) + run_upload( + cfg, + uploader=FakeUploader(), + verify=True, + client=client, + sleep=lambda s: None, + now=NOW, + ) + assert client.calls == [{"username": "tester", "refresh": True}] + assert "Verification OK" in capsys.readouterr().out diff --git a/uv.lock b/uv.lock index 4045090..ba9c762 100644 --- a/uv.lock +++ b/uv.lock @@ -54,7 +54,6 @@ wheels = [ [[package]] name = "bggpipe" -version = "0.1.0" source = { editable = "." } dependencies = [ { name = "anthropic" },