Compare commits

...
3 Commits
Author SHA1 Message Date
Eric WagonerandClaude Fable 5 2109e3544a Resolve stage: matching, version resolution, fixtures; BGG API auth
bggpipe resolve works end to end: search -> exact/fuzzy candidate
scoring -> auto/ambiguous/unmatched classification with owned-count
tie-breaks (mixed base/expansion candidates never auto-match), version
scoring from edition cues (never guessed; no cues -> version_unknown),
idempotent matches.csv appends.

Discovered mid-build: BGG now requires registered-application Bearer
tokens on the XML API (2025 policy change) and returns 401 otherwise.
Client sends Authorization from BGG_API_TOKEN and raises an actionable
BGGAuthError; CLAUDE.md and the bgg-api skill are updated to match.
Live fixture recording is blocked until registration is approved, so
tests replay hand-crafted stub fixtures via a network-refusing
transport; scripts/record_fixtures.py re-records real XML under the
same cache keys once a token exists. One live read-only smoke test is
skipped unless --run-live.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 12:33:32 -04:00
Eric WagonerandClaude Fable 5 4e1211feb6 BGG API client: disk cache, rate limiting, 202-queue retry, XML parsing
httpx client with injectable clock/sleep/rng for testability. Successful
responses cached under data/bgg_cache/ keyed by endpoint+params; 202
retries follow the spec schedule (2/5/10/30s, give up after 5); 429/503
get jittered exponential backoff; consecutive requests are spaced
rate_limit_seconds apart. Parsers (via defusedxml, per security hook)
cover search, thing (+stats/+versions), and collection, including the
Not Ranked and error-document cases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 12:26:03 -04:00
Eric WagonerandClaude Fable 5 d58568cceb 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>
2026-08-01 12:23:00 -04:00
32 changed files with 2036 additions and 1 deletions
+13
View File
@@ -5,6 +5,19 @@ description: Reference for BoardGameGeek's XML API2 and website automation — e
# BoardGameGeek API & site automation reference # BoardGameGeek API & site automation reference
## Authentication (required since 2025)
Every XML API request must carry `Authorization: Bearer <token>` or BGG
returns **401 Unauthorized**. Tokens come from a registered application:
create one at `https://boardgamegeek.com/applications` (non-commercial
license is free; approval can take a week or more), then generate a token
under "Tokens". `bggpipe` reads it from the `BGG_API_TOKEN` env var — never
put it in config.toml, code, or logs. Requests must go to
`boardgamegeek.com` **without** a leading `www` or the token is ignored.
Exception: downloading your own collection while logged in on the website
needs no registration — relevant to the Playwright stages, not the API
client. Usage is monitored per-application at `/applications` → "Usage".
## Endpoints (XML API2 — the only sanctioned read API) ## Endpoints (XML API2 — the only sanctioned read API)
- Search: `https://boardgamegeek.com/xmlapi2/search?query=<title>&type=boardgame,boardgameexpansion` - Search: `https://boardgamegeek.com/xmlapi2/search?query=<title>&type=boardgame,boardgameexpansion`
+2 -1
View File
@@ -15,7 +15,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Hard rules (from spec — never violate) ## Hard rules (from spec — never violate)
- **≤1 request every 2 seconds** to any BGG endpoint; jittered backoff on 429/503. Upload stage: 24 s randomized delay between games. - **≤1 request every 2 seconds** to any BGG endpoint; jittered backoff on 429/503. Upload stage: 24 s randomized delay between games.
- **Credentials never touch disk or logs.** `ANTHROPIC_API_KEY`, `BGG_USERNAME`, `BGG_PASSWORD` come from env vars only. Playwright storage state is credential-adjacent — keep it gitignored. - **Credentials never touch disk or logs.** `ANTHROPIC_API_KEY`, `BGG_USERNAME`, `BGG_PASSWORD`, `BGG_API_TOKEN` come from env vars only. Playwright storage state is credential-adjacent — keep it gitignored.
- **The XML API requires a registered app token** (`Authorization: Bearer`, from `BGG_API_TOKEN`) — unregistered requests get 401. Until Eric's registration at boardgamegeek.com/applications is approved, tests run on the stub fixtures in `tests/fixtures/bgg_cache/`; re-record them with `scripts/record_fixtures.py` once the token exists.
- **Every stage is idempotent and resumable** — killing mid-run and restarting must lose no work; re-runs skip already-processed items. - **Every stage is idempotent and resumable** — killing mid-run and restarting must lose no work; re-runs skip already-processed items.
- Use only the XML API2 and the public website — no undocumented BGG endpoints (BGG tightened access policies in 2025). - Use only the XML API2 and the public website — no undocumented BGG endpoints (BGG tightened access policies in 2025).
- BGG has **no write API**: writes drive the real website with a logged-in Playwright session. - BGG has **no write API**: writes drive the real website with a logged-in Playwright session.
+11
View File
@@ -0,0 +1,11 @@
# Non-secret configuration for bggpipe. Secrets (ANTHROPIC_API_KEY,
# BGG_PASSWORD) come from env vars only and must never appear here.
# TODO(eric): set your BGG username — the live smoke test and diff/upload
# stages need it. Leave empty to skip the live smoke test.
bgg_username = ""
photos_dir = "photos"
data_dir = "data"
model = "claude-sonnet-5"
rate_limit_seconds = 2.0
+8
View File
@@ -0,0 +1,8 @@
title_raw,bgg_id,bgg_name,year,type,match_status,version_id,version_name,version_status,candidates_json,version_candidates_json,source_photos
Catan,13,CATAN,1995,boardgame,auto,,,version_unknown,"[{""bgg_id"": 13, ""name"": ""CATAN"", ""year"": 1995, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],hand-typed-test-list
Wingspan,266192,Wingspan,2019,boardgame,auto,465063,English first edition,version_auto,"[{""bgg_id"": 266192, ""name"": ""Wingspan"", ""year"": 2019, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]","[{""version_id"": 465063, ""name"": ""English first edition"", ""year"": 2019, ""publishers"": [""Stonemaier Games""], ""languages"": [""English""], ""score"": 5}, {""version_id"": 521212, ""name"": ""English fourth printing"", ""year"": 2020, ""publishers"": [""Stonemaier Games""], ""languages"": [""English""], ""score"": 3}, {""version_id"": 472430, ""name"": ""German edition"", ""year"": 2019, ""publishers"": [""Feuerland Spiele""], ""languages"": [""German""], ""score"": 2}]",hand-typed-test-list
Wingspan: European Expansion,290448,Wingspan: European Expansion,2019,boardgameexpansion,auto,,,version_unknown,"[{""bgg_id"": 290448, ""name"": ""Wingspan: European Expansion"", ""year"": 2019, ""type"": ""boardgameexpansion"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],hand-typed-test-list
Wingspan Europe,,,,,unmatched,,,,[],[],hand-typed-test-list
Café International,373,Café International,1989,boardgame,auto,,,version_unknown,"[{""bgg_id"": 373, ""name"": ""Café International"", ""year"": 1989, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": null, ""rank"": null}]",[],hand-typed-test-list
Citadels,,,,,ambiguous,,,,"[{""bgg_id"": 478, ""name"": ""Citadels"", ""year"": 2000, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": 85000, ""rank"": 250}, {""bgg_id"": 205398, ""name"": ""Citadels"", ""year"": 2016, ""type"": ""boardgame"", ""exact"": true, ""fuzzy"": 100.0, ""owned"": 24000, ""rank"": 400}]",[],hand-typed-test-list
Blorvath: Quest of the Zzyzx,,,,,unmatched,,,,[],[],hand-typed-test-list
1 title_raw bgg_id bgg_name year type match_status version_id version_name version_status candidates_json version_candidates_json source_photos
2 Catan 13 CATAN 1995 boardgame auto version_unknown [{"bgg_id": 13, "name": "CATAN", "year": 1995, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": null, "rank": null}] [] hand-typed-test-list
3 Wingspan 266192 Wingspan 2019 boardgame auto 465063 English first edition version_auto [{"bgg_id": 266192, "name": "Wingspan", "year": 2019, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": null, "rank": null}] [{"version_id": 465063, "name": "English first edition", "year": 2019, "publishers": ["Stonemaier Games"], "languages": ["English"], "score": 5}, {"version_id": 521212, "name": "English fourth printing", "year": 2020, "publishers": ["Stonemaier Games"], "languages": ["English"], "score": 3}, {"version_id": 472430, "name": "German edition", "year": 2019, "publishers": ["Feuerland Spiele"], "languages": ["German"], "score": 2}] hand-typed-test-list
4 Wingspan: European Expansion 290448 Wingspan: European Expansion 2019 boardgameexpansion auto version_unknown [{"bgg_id": 290448, "name": "Wingspan: European Expansion", "year": 2019, "type": "boardgameexpansion", "exact": true, "fuzzy": 100.0, "owned": null, "rank": null}] [] hand-typed-test-list
5 Wingspan Europe unmatched [] [] hand-typed-test-list
6 Café International 373 Café International 1989 boardgame auto version_unknown [{"bgg_id": 373, "name": "Café International", "year": 1989, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": null, "rank": null}] [] hand-typed-test-list
7 Citadels ambiguous [{"bgg_id": 478, "name": "Citadels", "year": 2000, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": 85000, "rank": 250}, {"bgg_id": 205398, "name": "Citadels", "year": 2016, "type": "boardgame", "exact": true, "fuzzy": 100.0, "owned": 24000, "rank": 400}] [] hand-typed-test-list
8 Blorvath: Quest of the Zzyzx unmatched [] [] hand-typed-test-list
+40
View File
@@ -0,0 +1,40 @@
[
{
"title_raw": "Catan",
"confidence": "high",
"source_photos": ["hand-typed-test-list"]
},
{
"title_raw": "Wingspan",
"confidence": "high",
"publisher_hint": "Stonemaier Games",
"year_hint": 2019,
"language_hint": "English",
"source_photos": ["hand-typed-test-list"]
},
{
"title_raw": "Wingspan: European Expansion",
"confidence": "high",
"source_photos": ["hand-typed-test-list"]
},
{
"title_raw": "Wingspan Europe",
"confidence": "medium",
"source_photos": ["hand-typed-test-list"]
},
{
"title_raw": "Café International",
"confidence": "high",
"source_photos": ["hand-typed-test-list"]
},
{
"title_raw": "Citadels",
"confidence": "high",
"source_photos": ["hand-typed-test-list"]
},
{
"title_raw": "Blorvath: Quest of the Zzyzx",
"confidence": "low",
"source_photos": ["hand-typed-test-list"]
}
]
+36
View File
@@ -0,0 +1,36 @@
[project]
name = "bggpipe"
version = "0.1.0"
description = "Shelf-to-BGG collection pipeline: photos in, BoardGameGeek collection out"
requires-python = ">=3.12"
dependencies = [
"typer>=0.12",
"httpx>=0.27",
"rapidfuzz>=3.9",
"defusedxml>=0.7.1",
]
[project.scripts]
bggpipe = "bggpipe.cli:app"
[dependency-groups]
dev = [
"pytest>=8",
"ruff>=0.5",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.ruff]
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]
[tool.pytest.ini_options]
testpaths = ["tests"]
markers = [
"live: hits the real BGG API; skipped unless --run-live is passed",
]
+45
View File
@@ -0,0 +1,45 @@
"""Record the BGG XML fixtures the test suite replays.
Runs the real resolve logic with the cache pointed at tests/fixtures/
bgg_cache/, so exactly the responses resolve needs get recorded (live,
rate-limited). Run once; commit the fixtures. Re-running only fetches
whatever is not already recorded.
Usage: uv run python scripts/record_fixtures.py
"""
from __future__ import annotations
import os
from pathlib import Path
from bggpipe.bgg_client import BGGClient
from bggpipe.resolve import load_titles, resolve_entry
FIXTURE_CACHE = Path("tests/fixtures/bgg_cache")
def main() -> None:
if not os.environ.get("BGG_API_TOKEN"):
raise SystemExit(
"BGG_API_TOKEN is not set. The XML API requires a registered "
"application token (https://boardgamegeek.com/applications). "
"Until one exists, the committed fixtures are the hand-crafted "
"stubs from scripts/write_stub_fixtures.py; delete "
f"{FIXTURE_CACHE} and re-run this script to replace them with "
"real recordings."
)
client = BGGClient(cache_dir=FIXTURE_CACHE)
for entry in load_titles(Path("data/titles.json")):
row = resolve_entry(client, entry)
print(
f"{entry.title_raw!r}: {row.match_status} "
f"{row.bgg_name or '-'} ({row.bgg_id or '-'}) {row.version_status}"
)
print(f"\nFixtures recorded in {FIXTURE_CACHE}:")
for f in sorted(FIXTURE_CACHE.iterdir()):
print(f" {f.name}")
if __name__ == "__main__":
main()
+118
View File
@@ -0,0 +1,118 @@
"""Write hand-crafted stub XML fixtures into tests/fixtures/bgg_cache/.
BGG's XML API now requires a registered application token, which we don't
have yet, so these fixtures are realistic approximations of real responses
(shapes verified against the API docs) rather than recordings. Once a
BGG_API_TOKEN exists: delete tests/fixtures/bgg_cache/ and run
scripts/record_fixtures.py — it writes real recordings under the SAME
filenames, and the test suite must still pass.
Usage: uv run python scripts/write_stub_fixtures.py
"""
from __future__ import annotations
from pathlib import Path
from bggpipe.bgg_client import cache_key
FIXTURE_CACHE = Path("tests/fixtures/bgg_cache")
SEARCH_TYPES = "boardgame,boardgameexpansion"
def search_item(bgg_id: int, name: str, year: int | None, type_: str) -> str:
year_xml = f'<yearpublished value="{year}"/>' if year else ""
return (
f'<item type="{type_}" id="{bgg_id}">'
f'<name type="primary" value="{name}"/>{year_xml}</item>'
)
WINGSPAN_FAMILY = (
search_item(266192, "Wingspan", 2019, "boardgame")
+ search_item(290448, "Wingspan: European Expansion", 2019, "boardgameexpansion")
+ search_item(300580, "Wingspan: Oceania Expansion", 2020, "boardgameexpansion")
+ search_item(366161, "Wingspan Asia", 2022, "boardgame")
)
SEARCHES = {
"Catan": (
search_item(13, "CATAN", 1995, "boardgame")
+ search_item(926, "CATAN: Seafarers", 1997, "boardgameexpansion")
+ search_item(27760, "Catan Dice Game", 2007, "boardgame")
+ search_item(133038, "CATAN 3D Collector's Edition", 2005, "boardgame")
),
"Wingspan": WINGSPAN_FAMILY,
"Wingspan: European Expansion": WINGSPAN_FAMILY,
"Wingspan Europe": WINGSPAN_FAMILY,
"Café International": (
search_item(373, "Café International", 1989, "boardgame")
+ search_item(8103, "Café International: Das Kartenspiel", 2001, "boardgame")
),
"Citadels": (
search_item(478, "Citadels", 2000, "boardgame")
+ search_item(205398, "Citadels", 2016, "boardgame")
),
"Blorvath: Quest of the Zzyzx": "",
}
THINGS = {
# Citadels tie-break: neither edition dominates 10x -> stays ambiguous.
("478,205398", "stats"): """<items>
<item type="boardgame" id="478">
<name type="primary" value="Citadels"/><yearpublished value="2000"/>
<statistics><ratings><owned value="85000"/>
<ranks><rank type="subtype" id="1" name="boardgame" value="250"/></ranks>
</ratings></statistics>
</item>
<item type="boardgame" id="205398">
<name type="primary" value="Citadels"/><yearpublished value="2016"/>
<statistics><ratings><owned value="24000"/>
<ranks><rank type="subtype" id="1" name="boardgame" value="400"/></ranks>
</ratings></statistics>
</item>
</items>""",
# Wingspan versions: 2019 Stonemaier English must beat the 2020 printing.
("266192", "versions"): """<items>
<item type="boardgame" id="266192">
<name type="primary" value="Wingspan"/><yearpublished value="2019"/>
<versions>
<item type="boardgameversion" id="465063">
<name type="primary" value="English first edition"/>
<yearpublished value="2019"/>
<link type="boardgamepublisher" id="23202" value="Stonemaier Games"/>
<link type="language" id="2184" value="English"/>
</item>
<item type="boardgameversion" id="521212">
<name type="primary" value="English fourth printing"/>
<yearpublished value="2020"/>
<link type="boardgamepublisher" id="23202" value="Stonemaier Games"/>
<link type="language" id="2184" value="English"/>
</item>
<item type="boardgameversion" id="472430">
<name type="primary" value="German edition"/>
<yearpublished value="2019"/>
<link type="boardgamepublisher" id="22160" value="Feuerland Spiele"/>
<link type="language" id="2188" value="German"/>
</item>
</versions>
</item>
</items>""",
}
def main() -> None:
FIXTURE_CACHE.mkdir(parents=True, exist_ok=True)
for query, items in SEARCHES.items():
key = cache_key("search", {"query": query, "type": SEARCH_TYPES})
total = items.count("<item ")
(FIXTURE_CACHE / key).write_text(f'<items total="{total}">{items}</items>')
for (ids, flavor), xml in THINGS.items():
key = cache_key("thing", {"id": ids, flavor: "1"})
(FIXTURE_CACHE / key).write_text(xml)
for f in sorted(FIXTURE_CACHE.iterdir()):
print(f" {f.name}")
if __name__ == "__main__":
main()
+3
View File
@@ -0,0 +1,3 @@
"""Shelf-to-BGG collection pipeline."""
__version__ = "0.1.0"
+163
View File
@@ -0,0 +1,163 @@
"""BGG XML API2 client: disk cache, rate limiting, 202-queue and 429/503 retry.
Every successful response is cached under data/bgg_cache/ keyed by
endpoint + params, so re-runs never re-hit the API. The rate limiter
guarantees ≤1 request every rate_limit_seconds to any BGG endpoint.
"""
from __future__ import annotations
import hashlib
import os
import random
import re
import time
from collections.abc import Callable, Iterable
from pathlib import Path
from urllib.parse import urlencode
import httpx
from bggpipe.models import (
CollectionItem,
SearchResult,
ThingDetails,
parse_collection,
parse_search,
parse_things,
)
BASE_URL = "https://boardgamegeek.com/xmlapi2"
QUEUE_BACKOFF = (2.0, 5.0, 10.0, 30.0) # sleeps between the 5 attempts (spec)
MAX_ATTEMPTS = 5
_UNSAFE = re.compile(r"[^A-Za-z0-9._=,-]+")
class BGGQueueTimeout(Exception):
"""BGG kept answering 202 (or throttling) past the retry budget."""
class BGGAuthError(Exception):
"""BGG rejected the request as unauthorized (missing/invalid API token)."""
def cache_key(endpoint: str, params: dict[str, str]) -> str:
query = urlencode(sorted(params.items()))
digest = hashlib.md5(f"{endpoint}?{query}".encode()).hexdigest()[:10]
slug = _UNSAFE.sub("-", query)[:80].strip("-")
return f"{endpoint}_{slug}_{digest}.xml"
class BGGClient:
def __init__(
self,
cache_dir: Path,
rate_limit_seconds: float = 2.0,
transport: httpx.BaseTransport | None = None,
sleep: Callable[[float], None] = time.sleep,
monotonic: Callable[[], float] = time.monotonic,
rng: random.Random | None = None,
) -> None:
self.cache_dir = cache_dir
self._rate = rate_limit_seconds
self._sleep = sleep
self._monotonic = monotonic
self._rng = rng or random.Random()
self._last_request: float | None = None
headers = {"User-Agent": "bggpipe/0.1 (shelf-collection pipeline)"}
# BGG requires registered applications since 2025: the token from
# https://boardgamegeek.com/applications must accompany every request.
# Env var only — never config, disk, or logs.
if token := os.environ.get("BGG_API_TOKEN"):
headers["Authorization"] = f"Bearer {token}"
self._http = httpx.Client(
base_url=BASE_URL,
timeout=30.0,
headers=headers,
transport=transport,
)
def _throttle(self) -> None:
if self._last_request is not None:
wait = self._rate - (self._monotonic() - self._last_request)
if wait > 0:
self._sleep(wait)
def get_xml(self, endpoint: str, params: dict[str, str]) -> str:
"""Fetch one endpoint, serving from and filling the disk cache."""
cache_path = self.cache_dir / cache_key(endpoint, params)
if cache_path.exists():
return cache_path.read_text()
for attempt in range(MAX_ATTEMPTS):
self._throttle()
response = self._http.get(f"/{endpoint}", params=params)
self._last_request = self._monotonic()
if response.status_code == 202:
if attempt < MAX_ATTEMPTS - 1:
self._sleep(QUEUE_BACKOFF[min(attempt, len(QUEUE_BACKOFF) - 1)])
continue
if response.status_code == 401:
raise BGGAuthError(
"BGG returned 401 Unauthorized. The XML API requires a "
"registered application token since 2025: register at "
"https://boardgamegeek.com/applications, create a token, "
"and export it as BGG_API_TOKEN."
)
if response.status_code in (429, 503):
if attempt < MAX_ATTEMPTS - 1:
backoff = 2.0 * (2**attempt) * (1 + self._rng.uniform(0, 0.5))
self._sleep(backoff)
continue
response.raise_for_status()
self.cache_dir.mkdir(parents=True, exist_ok=True)
cache_path.write_text(response.text)
return response.text
raise BGGQueueTimeout(
f"BGG did not serve /{endpoint} after {MAX_ATTEMPTS} attempts "
"(still queued or throttled) — wait a minute and re-run; "
"completed work is cached."
)
# -- typed endpoint wrappers ------------------------------------------
def search(
self, query: str, types: str = "boardgame,boardgameexpansion"
) -> list[SearchResult]:
return parse_search(self.get_xml("search", {"query": query, "type": types}))
def things(
self,
ids: Iterable[int],
stats: bool = False,
versions: bool = False,
) -> list[ThingDetails]:
params = {"id": ",".join(str(i) for i in ids)}
if stats:
params["stats"] = "1"
if versions:
params["versions"] = "1"
return parse_things(self.get_xml("thing", params))
def collection(
self,
username: str,
subtype: str | None = None,
version: bool = True,
) -> list[CollectionItem]:
params = {"username": username, "own": "1"}
if subtype:
params["subtype"] = subtype
if version:
params["version"] = "1"
return parse_collection(self.get_xml("collection", params))
def collection_full(self, username: str) -> list[CollectionItem]:
"""Owned items incl. expansions (excluded from the default subtype)."""
base = self.collection(username)
expansions = self.collection(username, subtype="boardgameexpansion")
seen = {item.coll_id for item in base}
return base + [e for e in expansions if e.coll_id not in seen]
+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
+154
View File
@@ -0,0 +1,154 @@
"""Dataclasses + XML parsers for the BGG XML API2 responses we consume."""
from __future__ import annotations
import xml.etree.ElementTree as ET # element types only; parsing goes via defusedxml
from dataclasses import dataclass, field
from defusedxml.ElementTree import fromstring as _safe_fromstring
class BGGResponseError(Exception):
"""The API returned a well-formed error document (e.g. bad username)."""
@dataclass(frozen=True)
class SearchResult:
bgg_id: int
name: str
name_type: str # "primary" | "alternate"
year: int | None
type: str # "boardgame" | "boardgameexpansion"
@dataclass(frozen=True)
class GameVersion:
version_id: int
name: str
year: int | None
publishers: tuple[str, ...]
languages: tuple[str, ...]
@dataclass(frozen=True)
class ThingDetails:
bgg_id: int
name: str
year: int | None
type: str
owned: int | None = None
rank: int | None = None
versions: tuple[GameVersion, ...] = field(default=())
@dataclass(frozen=True)
class CollectionItem:
object_id: int
coll_id: int
name: str
subtype: str
own: bool
year: int | None
version_id: int | None
def _root(xml_text: str) -> ET.Element:
root = _safe_fromstring(xml_text)
if root.tag == "errors":
message = root.findtext("./error/message") or "unknown BGG error"
raise BGGResponseError(message.strip())
return root
def _attr_int(elem: ET.Element | None, attr: str = "value") -> int | None:
if elem is None:
return None
raw = elem.get(attr)
if raw is None or not raw.lstrip("-").isdigit():
return None
return int(raw)
def parse_search(xml_text: str) -> list[SearchResult]:
results = []
for item in _root(xml_text).findall("item"):
name_elem = item.find("name")
if name_elem is None or item.get("id") is None:
continue
results.append(
SearchResult(
bgg_id=int(item.get("id")),
name=name_elem.get("value", ""),
name_type=name_elem.get("type", "primary"),
year=_attr_int(item.find("yearpublished")),
type=item.get("type", "boardgame"),
)
)
return results
def _parse_version(item: ET.Element) -> GameVersion | None:
version_id = item.get("id")
name = item.find("name[@type='primary']")
if version_id is None:
return None
return GameVersion(
version_id=int(version_id),
name=name.get("value", "") if name is not None else "",
year=_attr_int(item.find("yearpublished")),
publishers=tuple(
link.get("value", "")
for link in item.findall("link[@type='boardgamepublisher']")
),
languages=tuple(
link.get("value", "") for link in item.findall("link[@type='language']")
),
)
def parse_things(xml_text: str) -> list[ThingDetails]:
things = []
for item in _root(xml_text).findall("item"):
name = item.find("name[@type='primary']")
rank_elem = item.find(".//ranks/rank[@name='boardgame']")
versions = [
v
for v_item in item.findall("versions/item")
if (v := _parse_version(v_item)) is not None
]
things.append(
ThingDetails(
bgg_id=int(item.get("id", 0)),
name=name.get("value", "") if name is not None else "",
year=_attr_int(item.find("yearpublished")),
type=item.get("type", "boardgame"),
owned=_attr_int(item.find(".//ratings/owned")),
rank=_attr_int(rank_elem),
versions=tuple(versions),
)
)
return things
def parse_collection(xml_text: str) -> list[CollectionItem]:
items = []
for item in _root(xml_text).findall("item"):
status = item.find("status")
year_text = item.findtext("yearpublished")
version_item = item.find("version/item")
items.append(
CollectionItem(
object_id=int(item.get("objectid", 0)),
coll_id=int(item.get("collid", 0)),
name=item.findtext("name", default=""),
subtype=item.get("subtype", "boardgame"),
own=status is not None and status.get("own") == "1",
year=int(year_text) if year_text and year_text.isdigit() else None,
version_id=(
int(version_item.get("id"))
if version_item is not None and version_item.get("id")
else None
),
)
)
return items
+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)
+354
View File
@@ -0,0 +1,354 @@
"""Stage 2 — resolve extracted titles to BGG IDs and versions.
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.
"""
from __future__ import annotations
import csv
import json
from dataclasses import dataclass, field
from pathlib import Path
import typer
from rapidfuzz import fuzz
from bggpipe.bgg_client import BGGClient
from bggpipe.config import Config
from bggpipe.models import GameVersion
from bggpipe.normalize import normalize_title
FUZZY_THRESHOLD = 90
# Tie-break dominance: an exact-named candidate wins outright only if it is
# clearly the well-known game (spec: obscure duplicates lose to famous ones).
DOMINANCE_MIN_OWNED = 100
DOMINANCE_FACTOR = 10
VERSION_PLAUSIBLE_SCORE = 2
MATCH_COLUMNS = [
"title_raw",
"bgg_id",
"bgg_name",
"year",
"type",
"match_status",
"version_id",
"version_name",
"version_status",
"candidates_json",
"version_candidates_json",
"source_photos",
]
@dataclass(frozen=True)
class TitleEntry:
title_raw: str
title_normalized: str
confidence: str = "high"
publisher_hint: str = ""
edition_hint: str = ""
year_hint: int | None = None
language_hint: str = ""
art_notes: str = ""
source_photos: tuple[str, ...] = ()
@property
def has_version_cues(self) -> bool:
return bool(
self.publisher_hint
or self.edition_hint
or self.year_hint
or self.language_hint
)
@dataclass
class Candidate:
bgg_id: int
name: str
year: int | None
type: str
exact: bool
fuzzy: float
owned: int | None = None
rank: int | None = None
def as_json(self) -> dict:
return {
"bgg_id": self.bgg_id,
"name": self.name,
"year": self.year,
"type": self.type,
"exact": self.exact,
"fuzzy": round(self.fuzzy, 1),
"owned": self.owned,
"rank": self.rank,
}
@dataclass
class MatchRow:
title_raw: str
source_photos: tuple[str, ...] = ()
bgg_id: int | None = None
bgg_name: str = ""
year: int | None = None
type: str = ""
match_status: str = "unmatched"
version_id: int | None = None
version_name: str = ""
version_status: str = ""
candidates: list[Candidate] = field(default_factory=list)
version_candidates: list[dict] = field(default_factory=list)
def to_csv(self) -> dict[str, str]:
return {
"title_raw": self.title_raw,
"bgg_id": str(self.bgg_id) if self.bgg_id else "",
"bgg_name": self.bgg_name,
"year": str(self.year) if self.year else "",
"type": self.type,
"match_status": self.match_status,
"version_id": str(self.version_id) if self.version_id else "",
"version_name": self.version_name,
"version_status": self.version_status,
"candidates_json": json.dumps(
[c.as_json() for c in self.candidates], ensure_ascii=False
),
"version_candidates_json": json.dumps(
self.version_candidates, ensure_ascii=False
),
"source_photos": ";".join(self.source_photos),
}
def load_titles(path: Path) -> list[TitleEntry]:
if not path.exists():
raise FileNotFoundError(
f"{path} not found — run `bggpipe extract` or hand-write a title list."
)
entries = []
for raw in json.loads(path.read_text()):
title_raw = raw["title_raw"]
entries.append(
TitleEntry(
title_raw=title_raw,
title_normalized=raw.get("title_normalized")
or normalize_title(title_raw),
confidence=raw.get("confidence", "high"),
publisher_hint=raw.get("publisher_hint") or "",
edition_hint=raw.get("edition_hint") or "",
year_hint=raw.get("year_hint"),
language_hint=raw.get("language_hint") or "",
art_notes=raw.get("art_notes") or "",
source_photos=tuple(raw.get("source_photos") or ()),
)
)
return entries
def _plausible_candidates(client: BGGClient, entry: TitleEntry) -> list[Candidate]:
"""Search BGG and keep exact-normalized or fuzzy>=90 candidates, one per id."""
by_id: dict[int, Candidate] = {}
for result in client.search(entry.title_raw):
norm = normalize_title(result.name)
exact = norm == entry.title_normalized
fuzzy = fuzz.token_sort_ratio(norm, entry.title_normalized)
if not exact and fuzzy < FUZZY_THRESHOLD:
continue
candidate = Candidate(
bgg_id=result.bgg_id,
name=result.name,
year=result.year,
type=result.type,
exact=exact,
fuzzy=fuzzy,
)
prev = by_id.get(result.bgg_id)
if prev is None or (candidate.exact, candidate.fuzzy) > (
prev.exact,
prev.fuzzy,
):
by_id[result.bgg_id] = candidate
return sorted(by_id.values(), key=lambda c: (not c.exact, -c.fuzzy))
def _classify(client: BGGClient, entry: TitleEntry, cands: list[Candidate]) -> MatchRow:
row = MatchRow(title_raw=entry.title_raw, source_photos=entry.source_photos)
if not cands:
row.match_status = "unmatched"
return row
if len(cands) == 1:
chosen = cands[0]
else:
top = cands[:5]
stats = {
t.bgg_id: t for t in client.things([c.bgg_id for c in top], stats=True)
}
for c in top:
if c.bgg_id in stats:
c.owned = stats[c.bgg_id].owned
c.rank = stats[c.bgg_id].rank
row.candidates = top
chosen = _dominant(top)
if chosen is None:
row.match_status = "ambiguous"
return row
row.bgg_id = chosen.bgg_id
row.bgg_name = chosen.name
row.year = chosen.year
row.type = chosen.type
row.match_status = "auto"
row.candidates = row.candidates or [chosen]
return row
def _dominant(top: list[Candidate]) -> Candidate | None:
"""The single clear winner among plausible candidates, if any.
Mixed boardgame/expansion candidates are never auto-resolved (the
spec's most common failure mode); otherwise an exact-named candidate
wins only when its owned-count dwarfs the runner-up's.
"""
if len({c.type for c in top}) > 1:
return None
ranked = sorted(top, key=lambda c: c.owned or 0, reverse=True)
best, second = ranked[0], ranked[1]
if (
best.exact
and (best.owned or 0) >= DOMINANCE_MIN_OWNED
and (best.owned or 0) >= DOMINANCE_FACTOR * max(second.owned or 0, 1)
):
return best
return None
def _score_version(entry: TitleEntry, version: GameVersion) -> int:
score = 0
if entry.publisher_hint:
hint = normalize_title(entry.publisher_hint)
if any(
fuzz.partial_ratio(hint, normalize_title(p)) >= 85
for p in version.publishers
):
score += 2
if entry.year_hint and version.year == entry.year_hint:
score += 2
if entry.language_hint and entry.language_hint.casefold() in {
lang.casefold() for lang in version.languages
}:
score += 1
if (
entry.edition_hint
and version.name
and fuzz.token_set_ratio(
normalize_title(entry.edition_hint), normalize_title(version.name)
)
>= 80
):
score += 2
return score
def _resolve_version(client: BGGClient, entry: TitleEntry, row: MatchRow) -> None:
"""Fill version_* fields on an auto/approved row. Never guess (spec)."""
if not entry.has_version_cues:
row.version_status = "version_unknown"
return
(thing,) = client.things([row.bgg_id], versions=True)
scored = sorted(
((v, _score_version(entry, v)) for v in thing.versions),
key=lambda pair: -pair[1],
)
plausible = [(v, s) for v, s in scored if s >= VERSION_PLAUSIBLE_SCORE]
if not plausible:
row.version_status = "version_unknown"
return
row.version_candidates = [
{
"version_id": v.version_id,
"name": v.name,
"year": v.year,
"publishers": list(v.publishers),
"languages": list(v.languages),
"score": s,
}
for v, s in plausible[:8]
]
if len(plausible) == 1 or plausible[0][1] > plausible[1][1]:
winner = plausible[0][0]
row.version_id = winner.version_id
row.version_name = winner.name
row.version_status = "version_auto"
else:
row.version_status = "version_ambiguous"
def resolve_entry(client: BGGClient, entry: TitleEntry) -> MatchRow:
row = _classify(client, entry, _plausible_candidates(client, entry))
if row.match_status == "auto":
_resolve_version(client, entry, row)
return row
def _row_key(title_raw: str, source_photos: str) -> tuple[str, str]:
return (title_raw, source_photos)
def read_existing_keys(path: Path) -> set[tuple[str, str]]:
if not path.exists():
return set()
with path.open(newline="") as f:
return {_row_key(r["title_raw"], r["source_photos"]) for r in csv.DictReader(f)}
def append_rows(path: Path, rows: list[MatchRow]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
is_new = not path.exists()
with path.open("a", newline="") as f:
writer = csv.DictWriter(f, fieldnames=MATCH_COLUMNS)
if is_new:
writer.writeheader()
for row in rows:
writer.writerow(row.to_csv())
def run_resolve(
cfg: Config, *, force: bool = False, client: BGGClient | None = None
) -> list[MatchRow]:
entries = load_titles(cfg.titles_path)
if force and cfg.matches_path.exists():
cfg.matches_path.unlink()
existing = read_existing_keys(cfg.matches_path)
client = client or BGGClient(cfg.cache_dir, cfg.rate_limit_seconds)
new_rows: list[MatchRow] = []
skipped = 0
for entry in entries:
key = _row_key(entry.title_raw, ";".join(entry.source_photos))
if key in existing:
skipped += 1
continue
row = resolve_entry(client, entry)
new_rows.append(row)
detail = f"{row.bgg_name} ({row.bgg_id})" if row.bgg_id else "-"
version = f" [{row.version_status}]" if row.version_status else ""
typer.echo(f" {entry.title_raw!r} -> {row.match_status}: {detail}{version}")
append_rows(cfg.matches_path, new_rows)
counts: dict[str, int] = {}
for row in new_rows:
counts[row.match_status] = counts.get(row.match_status, 0) + 1
summary = ", ".join(f"{n} {status}" for status, n in sorted(counts.items()))
typer.echo(
f"Resolved {len(new_rows)} title(s) ({summary or 'nothing new'}); "
f"skipped {skipped} already in {cfg.matches_path}."
)
return new_rows
+23
View File
@@ -0,0 +1,23 @@
from __future__ import annotations
import pytest
def pytest_addoption(parser: pytest.Parser) -> None:
parser.addoption(
"--run-live",
action="store_true",
default=False,
help="Run tests marked 'live' (hit the real BGG API, read-only)",
)
def pytest_collection_modifyitems(
config: pytest.Config, items: list[pytest.Item]
) -> None:
if config.getoption("--run-live"):
return
skip_live = pytest.mark.skip(reason="live BGG test; pass --run-live to enable")
for item in items:
if "live" in item.keywords:
item.add_marker(skip_live)
@@ -0,0 +1 @@
<items total="2"><item type="boardgame" id="373"><name type="primary" value="Café International"/><yearpublished value="1989"/></item><item type="boardgame" id="8103"><name type="primary" value="Café International: Das Kartenspiel"/><yearpublished value="2001"/></item></items>
@@ -0,0 +1 @@
<items total="4"><item type="boardgame" id="13"><name type="primary" value="CATAN"/><yearpublished value="1995"/></item><item type="boardgameexpansion" id="926"><name type="primary" value="CATAN: Seafarers"/><yearpublished value="1997"/></item><item type="boardgame" id="27760"><name type="primary" value="Catan Dice Game"/><yearpublished value="2007"/></item><item type="boardgame" id="133038"><name type="primary" value="CATAN 3D Collector's Edition"/><yearpublished value="2005"/></item></items>
@@ -0,0 +1 @@
<items total="2"><item type="boardgame" id="478"><name type="primary" value="Citadels"/><yearpublished value="2000"/></item><item type="boardgame" id="205398"><name type="primary" value="Citadels"/><yearpublished value="2016"/></item></items>
@@ -0,0 +1 @@
<items total="4"><item type="boardgame" id="266192"><name type="primary" value="Wingspan"/><yearpublished value="2019"/></item><item type="boardgameexpansion" id="290448"><name type="primary" value="Wingspan: European Expansion"/><yearpublished value="2019"/></item><item type="boardgameexpansion" id="300580"><name type="primary" value="Wingspan: Oceania Expansion"/><yearpublished value="2020"/></item><item type="boardgame" id="366161"><name type="primary" value="Wingspan Asia"/><yearpublished value="2022"/></item></items>
@@ -0,0 +1 @@
<items total="4"><item type="boardgame" id="266192"><name type="primary" value="Wingspan"/><yearpublished value="2019"/></item><item type="boardgameexpansion" id="290448"><name type="primary" value="Wingspan: European Expansion"/><yearpublished value="2019"/></item><item type="boardgameexpansion" id="300580"><name type="primary" value="Wingspan: Oceania Expansion"/><yearpublished value="2020"/></item><item type="boardgame" id="366161"><name type="primary" value="Wingspan Asia"/><yearpublished value="2022"/></item></items>
@@ -0,0 +1 @@
<items total="4"><item type="boardgame" id="266192"><name type="primary" value="Wingspan"/><yearpublished value="2019"/></item><item type="boardgameexpansion" id="290448"><name type="primary" value="Wingspan: European Expansion"/><yearpublished value="2019"/></item><item type="boardgameexpansion" id="300580"><name type="primary" value="Wingspan: Oceania Expansion"/><yearpublished value="2020"/></item><item type="boardgame" id="366161"><name type="primary" value="Wingspan Asia"/><yearpublished value="2022"/></item></items>
@@ -0,0 +1,25 @@
<items>
<item type="boardgame" id="266192">
<name type="primary" value="Wingspan"/><yearpublished value="2019"/>
<versions>
<item type="boardgameversion" id="465063">
<name type="primary" value="English first edition"/>
<yearpublished value="2019"/>
<link type="boardgamepublisher" id="23202" value="Stonemaier Games"/>
<link type="language" id="2184" value="English"/>
</item>
<item type="boardgameversion" id="521212">
<name type="primary" value="English fourth printing"/>
<yearpublished value="2020"/>
<link type="boardgamepublisher" id="23202" value="Stonemaier Games"/>
<link type="language" id="2184" value="English"/>
</item>
<item type="boardgameversion" id="472430">
<name type="primary" value="German edition"/>
<yearpublished value="2019"/>
<link type="boardgamepublisher" id="22160" value="Feuerland Spiele"/>
<link type="language" id="2188" value="German"/>
</item>
</versions>
</item>
</items>
@@ -0,0 +1,14 @@
<items>
<item type="boardgame" id="478">
<name type="primary" value="Citadels"/><yearpublished value="2000"/>
<statistics><ratings><owned value="85000"/>
<ranks><rank type="subtype" id="1" name="boardgame" value="250"/></ranks>
</ratings></statistics>
</item>
<item type="boardgame" id="205398">
<name type="primary" value="Citadels"/><yearpublished value="2016"/>
<statistics><ratings><owned value="24000"/>
<ranks><rank type="subtype" id="1" name="boardgame" value="400"/></ranks>
</ratings></statistics>
</item>
</items>
+133
View File
@@ -0,0 +1,133 @@
from __future__ import annotations
import random
import httpx
import pytest
from bggpipe.bgg_client import BGGClient, BGGQueueTimeout, cache_key
SEARCH_XML = (
'<items total="1"><item type="boardgame" id="13">'
'<name type="primary" value="CATAN"/><yearpublished value="1995"/>'
"</item></items>"
)
class FakeClock:
def __init__(self) -> None:
self.now = 1000.0
self.sleeps: list[float] = []
def monotonic(self) -> float:
return self.now
def sleep(self, seconds: float) -> None:
self.sleeps.append(seconds)
self.now += seconds
def make_client(tmp_path, responses, clock=None):
"""Client whose transport pops canned (status, body) responses."""
calls = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append(request)
status, body = responses.pop(0)
return httpx.Response(status, text=body)
clock = clock or FakeClock()
client = BGGClient(
cache_dir=tmp_path / "cache",
transport=httpx.MockTransport(handler),
sleep=clock.sleep,
monotonic=clock.monotonic,
rng=random.Random(42),
)
return client, calls, clock
def test_caches_successful_responses(tmp_path):
client, calls, _ = make_client(tmp_path, [(200, SEARCH_XML)])
first = client.get_xml("search", {"query": "catan", "type": "boardgame"})
second = client.get_xml("search", {"query": "catan", "type": "boardgame"})
assert first == second == SEARCH_XML
assert len(calls) == 1 # second call served from disk
cached = (
tmp_path
/ "cache"
/ cache_key("search", {"query": "catan", "type": "boardgame"})
)
assert cached.exists()
def test_202_retries_with_spec_backoff_then_succeeds(tmp_path):
client, calls, clock = make_client(
tmp_path, [(202, ""), (202, ""), (200, SEARCH_XML)]
)
xml = client.get_xml("collection", {"username": "someone", "own": "1"})
assert xml == SEARCH_XML
assert len(calls) == 3
assert clock.sleeps == [2.0, 5.0] # spec schedule between attempts
def test_202_gives_up_after_five_attempts(tmp_path):
client, calls, clock = make_client(tmp_path, [(202, "")] * 5)
with pytest.raises(BGGQueueTimeout):
client.get_xml("collection", {"username": "someone", "own": "1"})
assert len(calls) == 5
assert clock.sleeps == [2.0, 5.0, 10.0, 30.0]
# nothing cached on failure
assert not (tmp_path / "cache").exists()
def test_429_backs_off_with_jitter(tmp_path):
client, calls, clock = make_client(tmp_path, [(429, ""), (200, SEARCH_XML)])
client.get_xml("search", {"query": "catan"})
assert len(calls) == 2
(backoff,) = clock.sleeps
assert 2.0 <= backoff <= 3.0 # base 2s, up to +50% jitter
def test_rate_limit_spaces_consecutive_requests(tmp_path):
client, calls, clock = make_client(tmp_path, [(200, SEARCH_XML), (200, SEARCH_XML)])
client.get_xml("search", {"query": "catan"})
client.get_xml("search", {"query": "wingspan"})
# second request must wait the full 2s window (fake clock: no time passed)
assert clock.sleeps == [2.0]
def test_http_error_raises(tmp_path):
client, _, _ = make_client(tmp_path, [(500, "boom")])
with pytest.raises(httpx.HTTPStatusError):
client.get_xml("search", {"query": "catan"})
def test_cache_key_stable_and_filename_safe(tmp_path):
key1 = cache_key("search", {"query": "Café & Krieg?", "type": "boardgame"})
key2 = cache_key("search", {"type": "boardgame", "query": "Café & Krieg?"})
assert key1 == key2 # param order must not matter
assert "/" not in key1 and "?" not in key1 and key1.endswith(".xml")
def test_api_token_sent_as_bearer_header(tmp_path, monkeypatch):
monkeypatch.setenv("BGG_API_TOKEN", "test-token-123")
client, calls, _ = make_client(tmp_path, [(200, SEARCH_XML)])
client.get_xml("search", {"query": "catan"})
assert calls[0].headers["Authorization"] == "Bearer test-token-123"
def test_no_auth_header_without_token(tmp_path, monkeypatch):
monkeypatch.delenv("BGG_API_TOKEN", raising=False)
client, calls, _ = make_client(tmp_path, [(200, SEARCH_XML)])
client.get_xml("search", {"query": "catan"})
assert "authorization" not in calls[0].headers
def test_401_raises_actionable_auth_error(tmp_path, monkeypatch):
monkeypatch.delenv("BGG_API_TOKEN", raising=False)
from bggpipe.bgg_client import BGGAuthError
client, _, _ = make_client(tmp_path, [(401, "Unauthorized")])
with pytest.raises(BGGAuthError, match="BGG_API_TOKEN"):
client.get_xml("search", {"query": "catan"})
+31
View File
@@ -0,0 +1,31 @@
from pathlib import Path
from bggpipe.config import Config, load_config
def test_defaults_when_no_file(tmp_path, monkeypatch):
monkeypatch.delenv("BGG_USERNAME", raising=False)
cfg = load_config(tmp_path / "missing.toml")
assert cfg == Config()
assert cfg.cache_dir == Path("data/bgg_cache")
def test_reads_toml(tmp_path, monkeypatch):
monkeypatch.delenv("BGG_USERNAME", raising=False)
p = tmp_path / "config.toml"
p.write_text(
'bgg_username = "someone"\ndata_dir = "elsewhere"\nrate_limit_seconds = 3\n'
)
cfg = load_config(p)
assert cfg.bgg_username == "someone"
assert cfg.data_dir == Path("elsewhere")
assert cfg.rate_limit_seconds == 3.0
assert cfg.matches_path == Path("elsewhere/matches.csv")
def test_env_overrides_toml(tmp_path, monkeypatch):
p = tmp_path / "config.toml"
p.write_text('bgg_username = "from_toml"\n')
monkeypatch.setenv("BGG_USERNAME", "from_env")
cfg = load_config(p)
assert cfg.bgg_username == "from_env"
+114
View File
@@ -0,0 +1,114 @@
import pytest
from bggpipe.models import (
BGGResponseError,
parse_collection,
parse_search,
parse_things,
)
SEARCH_XML = """<items total="2">
<item type="boardgame" id="13">
<name type="primary" value="CATAN"/><yearpublished value="1995"/>
</item>
<item type="boardgameexpansion" id="290448">
<name type="alternate" value="Wingspan: Europa"/>
</item>
</items>"""
THING_XML = """<items>
<item type="boardgame" id="266192">
<name type="primary" sortindex="1" value="Wingspan"/>
<name type="alternate" sortindex="1" value="Flügelschlag"/>
<yearpublished value="2019"/>
<statistics page="1"><ratings>
<owned value="123456"/>
<ranks>
<rank type="subtype" id="1" name="boardgame" value="30"/>
<rank type="family" id="5497" name="strategygames" value="25"/>
</ranks>
</ratings></statistics>
<versions>
<item type="boardgameversion" id="465063">
<name type="primary" value="English edition"/>
<yearpublished value="2019"/>
<link type="boardgamepublisher" id="23202" value="Stonemaier Games"/>
<link type="language" id="2184" value="English"/>
</item>
<item type="boardgameversion" id="465064">
<name type="primary" value="German edition"/>
<yearpublished value="2019"/>
<link type="boardgamepublisher" id="22160" value="Feuerland Spiele"/>
<link type="language" id="2188" value="German"/>
</item>
</versions>
</item>
</items>"""
COLLECTION_XML = """<items totalitems="2">
<item objecttype="thing" objectid="13" subtype="boardgame" collid="101">
<name sortindex="1">Catan</name>
<yearpublished>1995</yearpublished>
<status own="1" wanttoplay="0"/>
</item>
<item objecttype="thing" objectid="266192" subtype="boardgame" collid="102">
<name sortindex="1">Wingspan</name>
<status own="0" wishlist="1"/>
<version><item type="boardgameversion" id="465063"/></version>
</item>
</items>"""
NOT_RANKED_XML = """<items>
<item type="boardgame" id="99999">
<name type="primary" value="Obscurity"/>
<statistics><ratings>
<owned value="12"/>
<ranks><rank type="subtype" id="1" name="boardgame" value="Not Ranked"/></ranks>
</ratings></statistics>
</item>
</items>"""
ERROR_XML = (
"<errors><error><message>Invalid username specified</message></error></errors>"
)
def test_parse_search():
results = parse_search(SEARCH_XML)
assert [r.bgg_id for r in results] == [13, 290448]
assert results[0].name == "CATAN"
assert results[0].year == 1995
assert results[1].type == "boardgameexpansion"
assert results[1].name_type == "alternate"
assert results[1].year is None
def test_parse_things_with_stats_and_versions():
(thing,) = parse_things(THING_XML)
assert thing.name == "Wingspan" # primary, not alternate
assert thing.owned == 123456
assert thing.rank == 30 # boardgame rank, not the family rank
assert len(thing.versions) == 2
english = thing.versions[0]
assert english.version_id == 465063
assert english.publishers == ("Stonemaier Games",)
assert english.languages == ("English",)
def test_parse_things_not_ranked_is_none():
(thing,) = parse_things(NOT_RANKED_XML)
assert thing.rank is None
assert thing.owned == 12
def test_parse_collection():
catan, wingspan = parse_collection(COLLECTION_XML)
assert (catan.object_id, catan.coll_id, catan.own) == (13, 101, True)
assert catan.version_id is None
assert wingspan.own is False # wishlist item must not count as owned
assert wingspan.version_id == 465063
def test_error_document_raises():
with pytest.raises(BGGResponseError, match="Invalid username"):
parse_collection(ERROR_XML)
+25
View File
@@ -0,0 +1,25 @@
from bggpipe.normalize import normalize_title
def test_casefold_and_punctuation():
assert normalize_title("Wingspan: European Expansion") == (
"wingspan european expansion"
)
def test_accents_stripped():
assert normalize_title("Café International") == "cafe international"
def test_ampersand_becomes_and():
assert normalize_title("Axis & Allies") == "axis and allies"
assert normalize_title("Axis and Allies") == "axis and allies"
def test_articles_dropped():
assert normalize_title("The Castles of Burgundy") == "castles of burgundy"
assert normalize_title("A Feast for Odin") == "feast for odin"
def test_whitespace_collapsed():
assert normalize_title(" Ticket to Ride ") == "ticket to ride"
+189
View File
@@ -0,0 +1,189 @@
"""Resolve-stage tests, replayed from tests/fixtures/bgg_cache — never online.
The transport below raises on any network attempt, proving every response
comes from the committed fixture cache.
"""
from __future__ import annotations
import csv
import json
import shutil
from pathlib import Path
import httpx
import pytest
from bggpipe.bgg_client import BGGClient
from bggpipe.config import Config
from bggpipe.models import GameVersion
from bggpipe.resolve import (
Candidate,
TitleEntry,
_dominant,
_score_version,
load_titles,
resolve_entry,
run_resolve,
)
FIXTURES = Path(__file__).parent / "fixtures" / "bgg_cache"
TITLES_JSON = Path(__file__).parent.parent / "data" / "titles.json"
def _no_network(request: httpx.Request) -> httpx.Response:
raise AssertionError(f"test hit the network: {request.url}")
@pytest.fixture
def client() -> BGGClient:
return BGGClient(
cache_dir=FIXTURES,
transport=httpx.MockTransport(_no_network),
sleep=lambda s: pytest.fail("slept during fixture replay"),
)
@pytest.fixture
def rows_by_title(client) -> dict[str, object]:
entries = load_titles(TITLES_JSON)
return {e.title_raw: resolve_entry(client, e) for e in entries}
def test_catan_auto_matches_base_game(rows_by_title):
row = rows_by_title["Catan"]
assert (row.match_status, row.bgg_id, row.bgg_name) == ("auto", 13, "CATAN")
assert row.version_status == "version_unknown" # no cues -> never guess
def test_wingspan_auto_with_version_from_cues(rows_by_title):
row = rows_by_title["Wingspan"]
assert (row.match_status, row.bgg_id) == ("auto", 266192)
# cues: Stonemaier + 2019 + English -> the first edition, not the
# 2020 printing (score 5 vs 3) and not the German edition
assert row.version_status == "version_auto"
assert row.version_id == 465063
assert row.version_name == "English first edition"
def test_expansion_matches_expansion_not_base(rows_by_title):
row = rows_by_title["Wingspan: European Expansion"]
assert (row.match_status, row.bgg_id) == ("auto", 290448)
assert row.type == "boardgameexpansion"
def test_wingspan_europe_spine_never_matches_base(rows_by_title):
"""The spec's canonical trap: a truncated spine must not auto-match."""
row = rows_by_title["Wingspan Europe"]
assert row.match_status == "unmatched"
assert row.bgg_id is None
def test_accented_title_resolves(rows_by_title):
row = rows_by_title["Café International"]
assert (row.match_status, row.bgg_id) == ("auto", 373)
def test_same_name_editions_stay_ambiguous(rows_by_title):
row = rows_by_title["Citadels"]
assert row.match_status == "ambiguous"
assert {c.bgg_id for c in row.candidates} == {478, 205398}
# stats were fetched for the review UI
assert all(c.owned is not None for c in row.candidates)
def test_nonsense_lands_unmatched(rows_by_title):
row = rows_by_title["Blorvath: Quest of the Zzyzx"]
assert row.match_status == "unmatched"
def test_run_resolve_writes_csv_and_is_idempotent(client, tmp_path):
data_dir = tmp_path / "data"
data_dir.mkdir()
shutil.copy(TITLES_JSON, data_dir / "titles.json")
cfg = Config(data_dir=data_dir)
first = run_resolve(cfg, client=client)
assert len(first) == 7
with cfg.matches_path.open(newline="") as f:
rows = list(csv.DictReader(f))
assert len(rows) == 7
by_title = {r["title_raw"]: r for r in rows}
assert by_title["Catan"]["match_status"] == "auto"
assert by_title["Citadels"]["match_status"] == "ambiguous"
candidates = json.loads(by_title["Citadels"]["candidates_json"])
assert len(candidates) == 2
assert by_title["Wingspan"]["version_id"] == "465063"
# second run: everything already in matches.csv is skipped, file unchanged
before = cfg.matches_path.read_text()
second = run_resolve(cfg, client=client)
assert second == []
assert cfg.matches_path.read_text() == before
# -- pure-logic unit tests (no fixtures) --------------------------------
def _cand(bgg_id, exact=True, owned=None, type_="boardgame"):
return Candidate(
bgg_id=bgg_id,
name="X",
year=2000,
type=type_,
exact=exact,
fuzzy=100.0,
owned=owned,
)
def test_dominant_famous_game_beats_obscure_duplicate():
top = [_cand(1, owned=50000), _cand(2, owned=300)]
assert _dominant(top).bgg_id == 1
def test_dominant_refuses_mixed_types():
top = [_cand(1, owned=50000), _cand(2, owned=300, type_="boardgameexpansion")]
assert _dominant(top) is None
def test_dominant_refuses_close_call():
top = [_cand(1, owned=50000), _cand(2, owned=20000)]
assert _dominant(top) is None
def test_score_version_all_cues():
entry = TitleEntry(
title_raw="Wingspan",
title_normalized="wingspan",
publisher_hint="Stonemaier",
year_hint=2019,
language_hint="english",
)
version = GameVersion(
version_id=1,
name="English first edition",
year=2019,
publishers=("Stonemaier Games",),
languages=("English",),
)
assert _score_version(entry, version) == 5
def test_score_version_no_overlap():
entry = TitleEntry(
title_raw="Wingspan",
title_normalized="wingspan",
publisher_hint="Feuerland",
year_hint=2021,
language_hint="german",
)
version = GameVersion(
version_id=1,
name="English first edition",
year=2019,
publishers=("Stonemaier Games",),
languages=("English",),
)
assert _score_version(entry, version) == 0
+31
View File
@@ -0,0 +1,31 @@
"""The one test allowed to touch the real BGG API. Read-only, skipped by
default; run with: uv run pytest --run-live -m live
Needs bgg_username in config.toml and BGG_API_TOKEN in the environment
(register at https://boardgamegeek.com/applications).
"""
from __future__ import annotations
import os
from pathlib import Path
import pytest
from bggpipe.bgg_client import BGGClient
from bggpipe.config import load_config
@pytest.mark.live
def test_fetch_own_collection_read_only(tmp_path: Path) -> None:
cfg = load_config()
if not cfg.bgg_username:
pytest.skip("set bgg_username in config.toml to run the live smoke test")
if not os.environ.get("BGG_API_TOKEN"):
pytest.skip("set BGG_API_TOKEN to run the live smoke test")
# fresh cache dir so this genuinely exercises the live API + 202 queue
client = BGGClient(cache_dir=tmp_path / "cache")
items = client.collection_full(cfg.bgg_username)
assert isinstance(items, list)
assert all(item.own for item in items)
Generated
+336
View File
@@ -0,0 +1,336 @@
version = 1
revision = 2
requires-python = ">=3.12"
[[package]]
name = "annotated-doc"
version = "0.0.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" },
]
[[package]]
name = "anyio"
version = "4.14.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
]
[[package]]
name = "bggpipe"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "defusedxml" },
{ name = "httpx" },
{ name = "rapidfuzz" },
{ name = "typer" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "defusedxml", specifier = ">=0.7.1" },
{ name = "httpx", specifier = ">=0.27" },
{ name = "rapidfuzz", specifier = ">=3.9" },
{ name = "typer", specifier = ">=0.12" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=8" },
{ name = "ruff", specifier = ">=0.5" },
]
[[package]]
name = "certifi"
version = "2026.7.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "defusedxml"
version = "0.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.18"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "rapidfuzz"
version = "3.14.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2c/21/ef6157213316e85790041254259907eb722e00b03480256c0545d98acd33/rapidfuzz-3.14.5.tar.gz", hash = "sha256:ba10ac57884ce82112f7ed910b67e7fb6072d8ef2c06e30dc63c0f604a112e0e", size = 57901753, upload-time = "2026-04-07T11:16:31.931Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d3/e3/574435c6aafb80254c191ef40d7aca2cb2bb97a095ec9395e9fa59ac307a/rapidfuzz-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0d3378f471ef440473a396ce2f8e97ee12f89a78b495540e0a5617bbfe895638", size = 1944601, upload-time = "2026-04-07T11:14:18.771Z" },
{ url = "https://files.pythonhosted.org/packages/d0/1f/fbad3102a255ecc112ce9a7e779bacab7fd14398217be8868dc9082ba363/rapidfuzz-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e910eebca9fd0eba245c0555e764597e8a0cccb673a92da2dc2397050725f48", size = 1164293, upload-time = "2026-04-07T11:14:20.534Z" },
{ url = "https://files.pythonhosted.org/packages/88/37/a3eb7ff6121ed3a5f199a8c38cc86c8e481816f879cb0e0b738b078c9a7e/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01550fe5f60fd176aa66b7611289d46dc4aa4b1b904874c7b6d1d54e581c5ec1", size = 1371999, upload-time = "2026-04-07T11:14:22.63Z" },
{ url = "https://files.pythonhosted.org/packages/79/72/97a9728c711c7c1b06e107d3f0623880fb4ef90e147ed13c551a1730e7cc/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48bee0b91bebfaec41e1081e351000659ab7570cc4598d617aa04d5bf827f9e6", size = 3145715, upload-time = "2026-04-07T11:14:24.508Z" },
{ url = "https://files.pythonhosted.org/packages/ed/54/d5caabbea233ac90c286c87c260e49d7641467e87438a18d858e41c82e91/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:7e580cb04ad849ae9b786fa21383c6b994b6e6c1444ad1cb9f22392759d72741", size = 1456304, upload-time = "2026-04-07T11:14:26.515Z" },
{ url = "https://files.pythonhosted.org/packages/fc/a7/2d1a81250ac8c01a0100c026018e76f0e7a097ff63e4c553e02a6938c6fb/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:09d6c9ba091854f07817055d795d604179c12a8f308ba4c7d56f3719dfea1646", size = 2389089, upload-time = "2026-04-07T11:14:28.635Z" },
{ url = "https://files.pythonhosted.org/packages/65/0d/c47c3872203ae88e6506997c0b576ad731f5261daa25d559be09c9756658/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1e989f86113be66574113b9c7bdf4793f3f863d248e47d911b355e05ca6b6b10", size = 2493404, upload-time = "2026-04-07T11:14:30.577Z" },
{ url = "https://files.pythonhosted.org/packages/8f/2f/71e0a5a3130792146c8a200a2dd1e52aa16f7c1074012e17f2601eea9a90/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ebd1a18e2e47bc0b292a07e6ed9c3642f8aaa672d12253885f599b50807a4f9", size = 4251709, upload-time = "2026-04-07T11:14:32.451Z" },
{ url = "https://files.pythonhosted.org/packages/86/45/d39874901abacef325adb5b34ae416817c8486dfb4fb87c7a9b74ec5b072/rapidfuzz-3.14.5-cp312-cp312-win32.whl", hash = "sha256:9981d38a703b86f0e315a3cd229fd1906fe1d91c989ed121fb975b3c849f89f5", size = 1710069, upload-time = "2026-04-07T11:14:34.37Z" },
{ url = "https://files.pythonhosted.org/packages/85/0b/f65572c53de8a1c704bda707f63a447b67bdbe95d7cdc70d18885e191df5/rapidfuzz-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:d8375e3da319593389727c3187ccaf3e0e84199accc530866b8e0f2b79af05e9", size = 1540630, upload-time = "2026-04-07T11:14:36.287Z" },
{ url = "https://files.pythonhosted.org/packages/5e/c3/143be3a578f989758cae516f3270d5cbb49783a7bfdf57cc27a670e00456/rapidfuzz-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:478b59bb018a6780d73f33e38d0b3ec5e968a6c1ed42876b993dd456b7aa20e8", size = 813137, upload-time = "2026-04-07T11:14:38.289Z" },
{ url = "https://files.pythonhosted.org/packages/11/66/252803f2010ba699618cdc048b6e1f7cc1f433c08b4a9a17579b92ab0142/rapidfuzz-3.14.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ebd8fd343bf8492a1e60bcb6dc99f90f74f65d98d8241a6b3e1fed225b76ecd6", size = 1940205, upload-time = "2026-04-07T11:14:40.319Z" },
{ url = "https://files.pythonhosted.org/packages/ea/59/b2afd98e41af9cd54554a4c1c423d84cdd60e6b1c0a09496f033b55f60ec/rapidfuzz-3.14.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6737b35d5af7479c5bf9710f7b17edd9d2c43128d974d25fb4ea653e42c64609", size = 1159639, upload-time = "2026-04-07T11:14:42.52Z" },
{ url = "https://files.pythonhosted.org/packages/a3/31/7aa7e62c4c516a7af322ed0c4f0774208b72d457d0cfec808bad0df12f4a/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b002c7994cc9f2bc9d9856f0fbaee6e8072c983873846c92f25cefba5b2a925f", size = 1367194, upload-time = "2026-04-07T11:14:44.25Z" },
{ url = "https://files.pythonhosted.org/packages/90/79/2fc252a63bc91d3c3b234d0a3a6ad4ebc460037a23cdcdaf9285f986e6c9/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17a34330cd2a538c1ce5d400b61ba358c5b72c654b928ff87b362e88f8b864c7", size = 3151805, upload-time = "2026-04-07T11:14:46.21Z" },
{ url = "https://files.pythonhosted.org/packages/17/54/0c83508f2683ea70e2d05f8527eb07328acf7bb1e9d97a3bece5702378e7/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:95d937e74c1a7a1287dfb03b62a827be08ede10a155cf1af73bbf47f2b73ee6e", size = 1455667, upload-time = "2026-04-07T11:14:47.991Z" },
{ url = "https://files.pythonhosted.org/packages/71/1b/070175e873177814d58850a01ebe80e20ae11e93eb4da894d563988660fa/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:46b92a9970dcc34f0096901c792644094cab49554ac3547f35e3aebbdf0a3610", size = 2388246, upload-time = "2026-04-07T11:14:50.098Z" },
{ url = "https://files.pythonhosted.org/packages/c9/dd/77caf7aaf9c2be050ad1f128d7c24ff0f59079aa62c5f62f9df41c0af45e/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e012177c8e8a8a0754ae0d6027d63042aa5ff036d9f40f07cb3466a6082e21b8", size = 2494333, upload-time = "2026-04-07T11:14:52.303Z" },
{ url = "https://files.pythonhosted.org/packages/2c/e2/dd7e1f2aa31a8fbbfc16b0610af1d770ffaf1287490f3c8c5b1c52da264f/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a2ae6f53f99c9a0eca7a0afc5b4e45fc73bc1dd4ac74c00509031d76df80ed98", size = 4258579, upload-time = "2026-04-07T11:14:54.538Z" },
{ url = "https://files.pythonhosted.org/packages/9c/0a/ac99e1ba347ba0e85e0bb60b74231d55fb93c0eff43f2920ccb413d0be08/rapidfuzz-3.14.5-cp313-cp313-win32.whl", hash = "sha256:4a60f0057231188e3bd30216f7b4e0f279b11fa4ec818bb6c1d9f014d1562fbc", size = 1709231, upload-time = "2026-04-07T11:14:56.524Z" },
{ url = "https://files.pythonhosted.org/packages/cf/cb/0e251d731b3166378644238e8f0cf9e89858c024e19f75ca9f7e3ae83fd5/rapidfuzz-3.14.5-cp313-cp313-win_amd64.whl", hash = "sha256:11bfc2ed8fbe4ab86bd516fadefab126f90e6dcadffa761739fcb304707dfd35", size = 1538519, upload-time = "2026-04-07T11:14:58.635Z" },
{ url = "https://files.pythonhosted.org/packages/30/6f/4548132acc947db6d5346a248e44a8b3a22d608ef30e770fb578caaf2d00/rapidfuzz-3.14.5-cp313-cp313-win_arm64.whl", hash = "sha256:b486b5218808f6f4dc471b114b1054e63553db69705c97da0271f47bd706aedd", size = 812628, upload-time = "2026-04-07T11:15:00.552Z" },
{ url = "https://files.pythonhosted.org/packages/00/60/69b177577290c5eab892c6f75fe89c3aff3f9ae80298a78d9372b1cecb9a/rapidfuzz-3.14.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:39ef8658aaf67d51667e7bdaf7096f432333377d8302ac43c70b5df8a4cf89b8", size = 1970231, upload-time = "2026-04-07T11:15:02.603Z" },
{ url = "https://files.pythonhosted.org/packages/48/38/2fd790052659cc4e2907b63c25433f0987864b445c1aeec1a302ef5ad948/rapidfuzz-3.14.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ad37a0be705b544af6296da8edddc260d10a8ae5462530fc9991f66498bb1f9", size = 1194394, upload-time = "2026-04-07T11:15:04.572Z" },
{ url = "https://files.pythonhosted.org/packages/80/f4/28430ad8472fc3536e8ebd51a864a226e979cfe924c6e3f83d111373aa74/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d45e06f60729e07d9b20c205f7e5cff90b6ef2584e852eecf46e045aea69627d", size = 1377051, upload-time = "2026-04-07T11:15:06.728Z" },
{ url = "https://files.pythonhosted.org/packages/77/7e/9aeacabcfd1e77397968362e5b98fe14248b8307011136b17daf99752a8e/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e52da10236aa6212de71b9e170bace65b64b129c0dea7fc243d6c9ce976f5074", size = 3160565, upload-time = "2026-04-07T11:15:08.667Z" },
{ url = "https://files.pythonhosted.org/packages/56/f4/db4dd7be0cd2f2022117ac5407d905f435d60e48baaea313a567ad27e865/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:440d30faaf682ca496170a7f0cc5453ec942e3e079f0fd802c9a7f938dfb50a3", size = 1442113, upload-time = "2026-04-07T11:15:11.138Z" },
{ url = "https://files.pythonhosted.org/packages/a4/99/0e9f6aa57f3e32a767216f797e56dc96b720fcecfb9d8ee907ecc82f8d66/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:56227a61fd3d17b0cd9793132431f3a3d07c8654be96794ba9f89fe0fc8b2d09", size = 2396618, upload-time = "2026-04-07T11:15:13.154Z" },
{ url = "https://files.pythonhosted.org/packages/60/94/44a78e39ffce17cbdd3e2b53b696acc751d5d153be0f499d052b07a4d904/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:2e83cd2e25bb4edd97b689d9979d9c3acccdaaf26ceac08212ceece202febcfa", size = 2478220, upload-time = "2026-04-07T11:15:15.193Z" },
{ url = "https://files.pythonhosted.org/packages/dd/df/454311469a09a507e9d784a35796742bec22e4cebe75551e2da4e0e290fd/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:af3b859726cd3374287e405e14b9634563c078c5531a4f62375508addebddad1", size = 4265027, upload-time = "2026-04-07T11:15:17.28Z" },
{ url = "https://files.pythonhosted.org/packages/fc/01/175465a9ab3e3b70ba669058372f009d1d49c1746e2dcd56b69df188d3a5/rapidfuzz-3.14.5-cp313-cp313t-win32.whl", hash = "sha256:8ce1d850b3c0178440efde9e884d98421b5e87ff925f364d6d79e23910d7593f", size = 1766814, upload-time = "2026-04-07T11:15:19.687Z" },
{ url = "https://files.pythonhosted.org/packages/1b/a0/a9b84a47af06ebed94a1439eb2f02adebfb8628bcd30af1fe3e02f5ef56c/rapidfuzz-3.14.5-cp313-cp313t-win_amd64.whl", hash = "sha256:c84af70bcf34e99aee894e46a0f1ac77f17d0ef828179c387407642e2466d28a", size = 1582448, upload-time = "2026-04-07T11:15:21.98Z" },
{ url = "https://files.pythonhosted.org/packages/1e/f1/5937800238b3f8248e70860d79f69ba8f73e764fff47e36bc9e2f26dbcc6/rapidfuzz-3.14.5-cp313-cp313t-win_arm64.whl", hash = "sha256:aac0ad28c686a5e72b81668b906c030ee28050b244544b8af68e12fb32543895", size = 832932, upload-time = "2026-04-07T11:15:24.358Z" },
{ url = "https://files.pythonhosted.org/packages/81/41/aa3ffb3355e62e1bf91f6599b3092e866bc88487a07c524004943c7676df/rapidfuzz-3.14.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1a31cc6d7d03e7318a0974c038959c59e19c752b81115f2e9138b3331cd64d45", size = 1943327, upload-time = "2026-04-07T11:15:26.266Z" },
{ url = "https://files.pythonhosted.org/packages/2d/e1/c2141f1840a41e07ad2db6f724945f8f8ff3065463899a22939152dd6e09/rapidfuzz-3.14.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0298d357e2bc59d572da4db0bc631009b6f8f6c9bc8c11e99a12b833f16b6575", size = 1161755, upload-time = "2026-04-07T11:15:28.659Z" },
{ url = "https://files.pythonhosted.org/packages/ca/07/66e753eeaa353161d1d331b7dd517bb349b0bacfebe8496d7b26be26f81f/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:59b3dba758661a318995655435c6ab20a04ade79fa51e75bc8dc107cac8df280", size = 1376571, upload-time = "2026-04-07T11:15:31.225Z" },
{ url = "https://files.pythonhosted.org/packages/c8/85/9535df0b78ba51f478c9ce7eb6d1f85535cc31fe356773b48fd9d3e563ca/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4900143d82071bdda533b00300c40b14b963ff826b3642cc463b6dd0f036585e", size = 3156468, upload-time = "2026-04-07T11:15:33.428Z" },
{ url = "https://files.pythonhosted.org/packages/81/ee/b667eb93bba6dc4e0de658edd778e1619dc4d6aab68fa5e5c7f075152735/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:feedf219672eef83ea6be6f3bb093bba396a8560fc75be85ba225f082903df0a", size = 1458311, upload-time = "2026-04-07T11:15:35.557Z" },
{ url = "https://files.pythonhosted.org/packages/7d/ce/479074f5624364a48df3403c538797ef22d3ac49c19dc76c3f79fcdcc70c/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:419e4397a36e2665ec992d8d64c20ba4b2a42500c76ecadeca78a4f19cb9cc32", size = 2398228, upload-time = "2026-04-07T11:15:37.669Z" },
{ url = "https://files.pythonhosted.org/packages/0b/15/a8982f649150fffbdcd6f17565974501f6ab33b2795267bffbd4a7ba905b/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:97131ab2be39043054ee28d99e09efe316e6d53449b7e962dfcf3c2de8b2b246", size = 2497226, upload-time = "2026-04-07T11:15:39.857Z" },
{ url = "https://files.pythonhosted.org/packages/19/52/5267c03ef6759831b7d4625a0c9c06e87baa2fae084b61ac9c388858317b/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:593c00dac4e30231c35bf3b4f1da8ec0998762e9e94425586a5d636fcd57f9d0", size = 4262283, upload-time = "2026-04-07T11:15:42.279Z" },
{ url = "https://files.pythonhosted.org/packages/71/c0/2579f343a97f5254c43bb5853baccc01488357dcb64a27bcb869b7888a4a/rapidfuzz-3.14.5-cp314-cp314-win32.whl", hash = "sha256:0084b687b02b4e569b46d8d6d4ad25659528e6081cd6d067ca453a69035f07e4", size = 1744614, upload-time = "2026-04-07T11:15:44.498Z" },
{ url = "https://files.pythonhosted.org/packages/17/eb/8edfed1e80119dc9c35b11df4bc701eea85622ad681fff0263b6961d3224/rapidfuzz-3.14.5-cp314-cp314-win_amd64.whl", hash = "sha256:5dfa89d78f22cd773054caff44827b846161a29f2dcf7e78b8f90d086621e502", size = 1588971, upload-time = "2026-04-07T11:15:46.86Z" },
{ url = "https://files.pythonhosted.org/packages/f6/04/5676df93c85cfa57a3045d8047318df9f3cd58c7b8a99340dd95f874795e/rapidfuzz-3.14.5-cp314-cp314-win_arm64.whl", hash = "sha256:67f3f9d2b444268ab53e47d31bab89954888d23c04c6789f2c727e51fe4b1d13", size = 834985, upload-time = "2026-04-07T11:15:49.411Z" },
{ url = "https://files.pythonhosted.org/packages/f7/0d/4a8988cea658fe335048ddef8c876addff1b6daa3c9ca8ad65a5a2196e69/rapidfuzz-3.14.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:77eac0526899b3c3ad1454bb2b03cdb491d67358ec8ef0c9c48bd61b632b431d", size = 1972517, upload-time = "2026-04-07T11:15:51.819Z" },
{ url = "https://files.pythonhosted.org/packages/1c/a3/f5cfd9965a9d9a9e32249159797c47b5d6299ea6d1629f9126b25f1c10a3/rapidfuzz-3.14.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b9c6bd754d11f6e78ac54e3d86b4b11dc1ba2f13e5fc958899574532897f5a99", size = 1196056, upload-time = "2026-04-07T11:15:54.292Z" },
{ url = "https://files.pythonhosted.org/packages/64/07/561c2e40cfd10e6630a7b0ac5a2a813aef50d944bcd1f3d260319d659d5b/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:738c96944d076deeaff70e92b65696ab4f7ecb8081d7791c5403a3257dfaf8ff", size = 1374732, upload-time = "2026-04-07T11:15:56.584Z" },
{ url = "https://files.pythonhosted.org/packages/c2/39/123bb94fee40e2fb3b7c49b80827c7ef42d838e18def3fc2fef5a3cf817a/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4c1bca487a17fe4226b4ffb2d30e799d2b274d692cffa76bd0746f56235fca3", size = 3166902, upload-time = "2026-04-07T11:15:58.768Z" },
{ url = "https://files.pythonhosted.org/packages/75/0a/45716fafc9fd2e028cf20b5ac5bc704887081cd312f84edb0e325599414b/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:af6a90a4ed2a48fa1a2d17e9d824e6c7c950bea5bad0b707c77fd55751e6bfef", size = 1452130, upload-time = "2026-04-07T11:16:01.453Z" },
{ url = "https://files.pythonhosted.org/packages/ca/49/4e96c413114398481c0a5b0086af32c364a18613c9a2ea578d17c4bea4ee/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bf5018938208d4597b2e679a4f8cff9fd252f1df53583130ae56281a21801b64", size = 2396308, upload-time = "2026-04-07T11:16:03.588Z" },
{ url = "https://files.pythonhosted.org/packages/89/b7/49fea9fc6878d59bd259d01dd1972d9b86117992b1c66d9b16f0a65273c3/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c0919d1f89ddf91129906705723118ea09754171e4116f5a5dbc667c7bc9b261", size = 2488210, upload-time = "2026-04-07T11:16:05.871Z" },
{ url = "https://files.pythonhosted.org/packages/0c/44/a1f732b93ffacbdad077b7c801149549b2938e1bece6addb5ad85ed74df8/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:93d8da883a35116d6813432177f35e570db5b0a5e30ecb0cbd7cb39c815735df", size = 4270621, upload-time = "2026-04-07T11:16:08.483Z" },
{ url = "https://files.pythonhosted.org/packages/bb/ce/ff942d19fce5385054650bb71a58495ddda299d94661ccc4e6e7fa44868b/rapidfuzz-3.14.5-cp314-cp314t-win32.whl", hash = "sha256:0f23e37019ec07712d58976b1ab2b889f8649a7f7c2f626a2f34ea9139e79279", size = 1803950, upload-time = "2026-04-07T11:16:10.873Z" },
{ url = "https://files.pythonhosted.org/packages/5c/0f/9aafc63f9661222b819b391c187eed29fc90ad5935f9690e5ecc2d2047a4/rapidfuzz-3.14.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7d5ca9c7832e6879a707296d1463685f7c243a27846227044504741640caec66", size = 1632357, upload-time = "2026-04-07T11:16:13.1Z" },
{ url = "https://files.pythonhosted.org/packages/70/a6/51fc1b0e61e3326e1c68a61cfd0c6b3c34c843681c4b1eefbf0596f59162/rapidfuzz-3.14.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3e91dcd2549b8f8d843f98ba03a17e01f3d8b72ce942adbbb6761bc58ffce813", size = 855409, upload-time = "2026-04-07T11:16:15.787Z" },
]
[[package]]
name = "rich"
version = "15.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
]
[[package]]
name = "ruff"
version = "0.16.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/70/25/7113f6d5498888c5fb7db34081cba7d5971c4cb1bfb26819966eee68f003/ruff-0.16.1.tar.gz", hash = "sha256:fedad7c801dabd3fb9741d76aca39246e6ddd9ca446a015875207bf19f1e6bc7", size = 4877500, upload-time = "2026-07-30T19:37:01.379Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/bd/694da69368e0973de65df2ddc73ab18d43c469d5963d9b150911de6bc513/ruff-0.16.1-py3-none-linux_armv6l.whl", hash = "sha256:58edb313b88f0c5460a26adf5f39a37a3be789494a15e3e411e35fa78b89f9a0", size = 10839126, upload-time = "2026-07-30T19:36:13.697Z" },
{ url = "https://files.pythonhosted.org/packages/3f/f0/b626e5d5bd0dd9576263658ef12885e2288afd1029a48e26ffed65ec1ac1/ruff-0.16.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fde5a99e2f97479af66edd6622c6d5a2a7592c77cf4153d9e4428f5eeb55b60c", size = 11070253, upload-time = "2026-07-30T19:36:17.14Z" },
{ url = "https://files.pythonhosted.org/packages/83/63/f40acfb6b35b88623e71684942b552c3edd96035f5d98f313815f7b277de/ruff-0.16.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e0d4c20532fca4f7fa609369161d968dd28f65d83dabbd61d8e9c7edbf7001f6", size = 10561425, upload-time = "2026-07-30T19:36:20.04Z" },
{ url = "https://files.pythonhosted.org/packages/aa/dd/14ec0e9c2b4d315547dd38765004b4863e354e1b52cb308272215d9f6f6d/ruff-0.16.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30affbcedf59ad5703d9c91f82266e02b47739f797e1a7b6e158e5526a6dae38", size = 10948879, upload-time = "2026-07-30T19:36:22.476Z" },
{ url = "https://files.pythonhosted.org/packages/33/e9/9d870cbae575030fdef595f04b4b97573c525b5497cce4f4498cf2f85446/ruff-0.16.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24e9c631573cbca9d20f1283f8f479b2afa4a8503504822bd71a293889f16743", size = 10643691, upload-time = "2026-07-30T19:36:24.914Z" },
{ url = "https://files.pythonhosted.org/packages/c4/09/12743d544e2173f53ecd27217c65f90d2bc0f8424a66a60339e56bbc0457/ruff-0.16.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b41bdd48fb420987a9b5212e4957c26ad4abce401fa9ea9d4d85843727945f4f", size = 11435354, upload-time = "2026-07-30T19:36:28.447Z" },
{ url = "https://files.pythonhosted.org/packages/7f/89/a1652b2daee52083c9554a6333b678a8b01d0400f976827bb87857f9449a/ruff-0.16.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0d1e1393b7648079e13669de1c1f4fde06d4583e84d8fd5c1551e0a77a2aa75", size = 12259033, upload-time = "2026-07-30T19:36:31.326Z" },
{ url = "https://files.pythonhosted.org/packages/16/96/ecdcb8c54ee7b123b487f807eb014e6e019155a0b81dfb669acd52f28ce3/ruff-0.16.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07bf434b1c95f4e093be4532068ef4fcf00924eb2ade8796075980902d6fd54a", size = 11667981, upload-time = "2026-07-30T19:36:34.394Z" },
{ url = "https://files.pythonhosted.org/packages/cd/90/c52e12e0d862e9572f2a33aa227409143520abe53111e9a6babbac7b4af8/ruff-0.16.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39897739f112253ee4fdd2e8aa9a4f9ded99fb2be367d5f31dfa4ded6025584c", size = 11468183, upload-time = "2026-07-30T19:36:37.339Z" },
{ url = "https://files.pythonhosted.org/packages/2c/6b/4ffb7ad1d83eb16cf8cbb3c8815d3f11c88460fd162d4b372a2059be1c2a/ruff-0.16.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:82ae3c0c0d74daf17b968a10b7b3bb3ef297ab7de0c1f749646b25e690ccb150", size = 11470071, upload-time = "2026-07-30T19:36:39.91Z" },
{ url = "https://files.pythonhosted.org/packages/9c/72/32ae7db4c0b5e32ab611787caa19d1546800676d79f7483b7100a3561bf4/ruff-0.16.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4d5f2ed10f8242d83fc08d521301089364e3375375705356f20c0e31606ef3ef", size = 10919503, upload-time = "2026-07-30T19:36:42.65Z" },
{ url = "https://files.pythonhosted.org/packages/f7/ca/3d901ba6ad6fc38da39c3448fc6c59ac945679293a17c3ceb6d6c1cba13e/ruff-0.16.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a4665b309891f83f3e3c25447935f1213e9abbd4b5640af7a1f2def9f8d413c1", size = 10649861, upload-time = "2026-07-30T19:36:45.18Z" },
{ url = "https://files.pythonhosted.org/packages/92/79/894ef1ced26552d5f8c9cf6d85b0687840e1128c55aeab7b9c2d54a0d880/ruff-0.16.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26e9ca5c9bc3971f20d3cf18a957f52ffd6a5f6564ff15c4912a144dcac22494", size = 11148137, upload-time = "2026-07-30T19:36:47.936Z" },
{ url = "https://files.pythonhosted.org/packages/2d/69/3609a09fa1cb46cc28b762363e440a354204e5dff01bd0c8d7437874d6b9/ruff-0.16.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:67e1e1e3fa4f0c82f0e36d4cd61e661f6e7a6196cb1aa92fe0828fa7b8f257cd", size = 11559211, upload-time = "2026-07-30T19:36:50.448Z" },
{ url = "https://files.pythonhosted.org/packages/fc/8a/fb22af2fd78a736e241fabf67e30ce1799a64244026377a49e133af90762/ruff-0.16.1-py3-none-win32.whl", hash = "sha256:d31765e131295b8445caf301e3e8a85b34d1b9b211b4109b7ba457888b051806", size = 10838258, upload-time = "2026-07-30T19:36:53.298Z" },
{ url = "https://files.pythonhosted.org/packages/d4/35/e57fd9fb5d423961df087a00b12d42c0a830288dc2f3b45ecca299158b4f/ruff-0.16.1-py3-none-win_amd64.whl", hash = "sha256:09b05e8b90c2cb06ad63464350e7a45e8e44a2dfe52072ebfba6666ca8d3f596", size = 11961111, upload-time = "2026-07-30T19:36:56.107Z" },
{ url = "https://files.pythonhosted.org/packages/cb/46/240ea004bf6dc4feb40e9832f2205a476a47dd5b8a3f8211a5fc5f95e20e/ruff-0.16.1-py3-none-win_arm64.whl", hash = "sha256:dbaadaac38c70239f056d306b7476f246b0bf000fa6b3876402acbf5b227eaf8", size = 11309414, upload-time = "2026-07-30T19:36:58.79Z" },
]
[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
[[package]]
name = "typer"
version = "0.27.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "rich" },
{ name = "shellingham" },
]
sdist = { url = "https://files.pythonhosted.org/packages/37/78/fda3361b56efc27944f24225f6ecd13d96d6fcfe37bd0eb34e2f4c63f9fc/typer-0.27.0.tar.gz", hash = "sha256:629bd12ea5d13a17148125d9a264f949eb171fb3f120f9b04d85873cab054fa5", size = 203430, upload-time = "2026-07-15T19:21:07.007Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/40/03/26a383c9e58c213199d1aad1c3d353cfc22d4444ec6d2c0bf8ad02523843/typer-0.27.0-py3-none-any.whl", hash = "sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1", size = 122716, upload-time = "2026-07-15T19:21:05.553Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]