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:
co-authored by
Claude Fable 5
parent
6bd4222fc1
commit
d58568cceb
@@ -0,0 +1,3 @@
|
||||
"""Shelf-to-BGG collection pipeline."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user