Re-audit round 3: 5 blind reviewers, 15 fixes, +18 tests — converging
Round 3's two HIGHs: _fill_version resolved versions with the LAST same-title entry's cues (photo-aware lookup existed since round 1 but this caller never used it), and the round-2 diff rework let an earlier row's disagreement consume the exact-version copy a later row matched. Diff claims now settle strongest-first across all rows (exact matches, then versionless upgrades, then disagreement/second-copy), unvetoed bare duplicates stay owned per spec, and updates are withheld with a manual-fix note whenever any copy of the game already carries a version (the row edit targets by name and could hit the wrong copy). Also: entry-to-row pairing matches by photo overlap before position (titles.json order churn from reshoot filenames could swap editions); BGGQueueTimeout defers a title like a missing token; DismissStore writes atomically, mutates memory only after the write, and quarantines a torn file instead of bricking the server; version-picker page-limit exhaustion stays retryable; verify's copy-count shortfall reports once per game (the old guard was dead code); the upload log header is created atomically; transient version-lookup failures record a retryable version_error, not terminal version_unknown; extract isolates per-photo failures and salvages JSON followed by prose; a state revision counter stops stale poll responses reverting decisions; plus the shared-predicate/fsio/docstring consolidation and CLI wiring, live-diff, verify-wiring, and search-guard tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+4
-1
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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'<yearpublished value="{year}"/>' if year else ""
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
|
||||
+61
-39
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
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(
|
||||
|
||||
@@ -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
|
||||
|
||||
+21
-1
@@ -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 — "
|
||||
|
||||
+80
-40
@@ -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 "-"
|
||||
|
||||
+52
-9
@@ -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,7 +132,15 @@ 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); "
|
||||
|
||||
@@ -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();
|
||||
|
||||
+51
-22
@@ -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:
|
||||
|
||||
+51
-29
@@ -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
|
||||
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]
|
||||
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()
|
||||
|
||||
|
||||
@@ -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
|
||||
+91
-15
@@ -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
|
||||
|
||||
@@ -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 = '<items total="2"><item type="boardgame"/><item type="boardgame"/></items>'
|
||||
with pytest.raises(BGGResponseError):
|
||||
parse_search(xml)
|
||||
|
||||
|
||||
def test_search_partial_malformed_tolerated_with_warning():
|
||||
import pytest
|
||||
|
||||
from bggpipe.models import parse_search
|
||||
|
||||
xml = (
|
||||
'<items total="2">'
|
||||
'<item type="boardgame" id="13"><name type="primary" value="CATAN"/></item>'
|
||||
'<item type="boardgame"/>'
|
||||
"</items>"
|
||||
)
|
||||
with pytest.warns(UserWarning, match="unparseable"):
|
||||
results = parse_search(xml)
|
||||
assert [r.bgg_id for r in results] == [13]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
+44
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user