Scaffold bggpipe: typer CLI, config loading, title normalization

uv project with typer/httpx/rapidfuzz, ruff and pytest wired into
pyproject. Six stub subcommands matching the pipeline stages, config.toml
plus BGG_USERNAME env override, accent/ampersand/article-safe title
normalization with tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-01 12:23:00 -04:00
co-authored by Claude Fable 5
parent 6bd4222fc1
commit d58568cceb
11 changed files with 626 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
"""Shelf-to-BGG collection pipeline."""
__version__ = "0.1.0"
+84
View File
@@ -0,0 +1,84 @@
"""bggpipe — shelf photos to BoardGameGeek collection, in six stages."""
from __future__ import annotations
from pathlib import Path
from typing import Annotated
import typer
from bggpipe.config import load_config
app = typer.Typer(
help="Shelf-to-BGG collection pipeline.",
no_args_is_help=True,
)
ConfigOpt = Annotated[
Path | None,
typer.Option("--config", help="Path to config.toml (default: ./config.toml)"),
]
def _not_implemented(stage: str, build_order: int) -> None:
typer.echo(
f"bggpipe {stage}: not implemented yet (build-order step {build_order})."
)
raise typer.Exit(code=1)
@app.command()
def extract(
only: Annotated[
str | None, typer.Option("--only", help="Re-run a single photo")
] = None,
config: ConfigOpt = None,
) -> None:
"""Stage 1: extract game titles + edition cues from shelf photos."""
_not_implemented("extract", 3)
@app.command()
def resolve(
force: Annotated[
bool, typer.Option("--force", help="Re-resolve titles already in matches.csv")
] = False,
config: ConfigOpt = None,
) -> None:
"""Stage 2: match extracted titles to BGG IDs and versions."""
from bggpipe.resolve import run_resolve
cfg = load_config(config)
run_resolve(cfg, force=force)
@app.command()
def review(config: ConfigOpt = None) -> None:
"""Stage 3: human review of ambiguous/unmatched items."""
_not_implemented("review", 4)
@app.command()
def diff(config: ConfigOpt = None) -> None:
"""Stage 4: diff approved matches against the existing BGG collection."""
_not_implemented("diff", 4)
@app.command()
def upload(
dry_run: Annotated[bool, typer.Option("--dry-run")] = False,
verify: Annotated[bool, typer.Option("--verify")] = False,
retry_failed: Annotated[bool, typer.Option("--retry-failed")] = False,
config: ConfigOpt = None,
) -> None:
"""Stage 5: add games to the BGG collection via Playwright."""
_not_implemented("upload", 5)
@app.command()
def enrich(
refresh: Annotated[bool, typer.Option("--refresh")] = False,
config: ConfigOpt = None,
) -> None:
"""Stage 6: fetch full game + version metadata into games.json."""
_not_implemented("enrich", 6)
+55
View File
@@ -0,0 +1,55 @@
"""Configuration: config.toml at the project root, env vars override.
Secrets (ANTHROPIC_API_KEY, BGG_PASSWORD) are never stored here — they are
read from the environment at the point of use and must never be written to
disk or logs.
"""
from __future__ import annotations
import os
import tomllib
from dataclasses import dataclass, replace
from pathlib import Path
DEFAULT_CONFIG_PATH = Path("config.toml")
@dataclass(frozen=True)
class Config:
bgg_username: str = ""
photos_dir: Path = Path("photos")
data_dir: Path = Path("data")
model: str = "claude-sonnet-5"
rate_limit_seconds: float = 2.0
@property
def cache_dir(self) -> Path:
return self.data_dir / "bgg_cache"
@property
def titles_path(self) -> Path:
return self.data_dir / "titles.json"
@property
def matches_path(self) -> Path:
return self.data_dir / "matches.csv"
def load_config(path: Path | None = None) -> Config:
cfg = Config()
p = path or DEFAULT_CONFIG_PATH
if p.exists():
raw = tomllib.loads(p.read_text())
known = {
"bgg_username": str,
"photos_dir": Path,
"data_dir": Path,
"model": str,
"rate_limit_seconds": float,
}
updates = {key: caster(raw[key]) for key, caster in known.items() if key in raw}
cfg = replace(cfg, **updates)
if username := os.environ.get("BGG_USERNAME"):
cfg = replace(cfg, bgg_username=username)
return cfg
+22
View File
@@ -0,0 +1,22 @@
"""Title normalization, applied identically to photo titles and BGG names.
Casefold, strip accents (é → e), map & → "and", drop punctuation and
leading/embedded articles, collapse whitespace.
"""
from __future__ import annotations
import re
import unicodedata
_ARTICLES = {"the", "a", "an"}
_NON_ALNUM = re.compile(r"[^a-z0-9 ]+")
def normalize_title(title: str) -> str:
s = unicodedata.normalize("NFKD", title)
s = "".join(c for c in s if not unicodedata.combining(c))
s = s.casefold().replace("&", " and ")
s = _NON_ALNUM.sub(" ", s)
tokens = [t for t in s.split() if t not in _ARTICLES]
return " ".join(tokens)
+12
View File
@@ -0,0 +1,12 @@
"""Stage 2 — resolve extracted titles to BGG IDs and versions."""
from __future__ import annotations
import typer
from bggpipe.config import Config
def run_resolve(cfg: Config, *, force: bool = False) -> None:
typer.echo("bggpipe resolve: not implemented yet (build-order step 2).")
raise typer.Exit(code=1)