Compare commits
3
Commits
6bd4222fc1
...
2109e3544a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2109e3544a | ||
|
|
4e1211feb6 | ||
|
|
d58568cceb |
@@ -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`
|
||||||
|
|||||||
@@ -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: 2–4 s randomized delay between games.
|
- **≤1 request every 2 seconds** to any BGG endpoint; jittered backoff on 429/503. Upload stage: 2–4 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
@@ -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
|
||||||
@@ -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
|
||||||
|
@@ -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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Shelf-to-BGG collection pipeline."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -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]
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""bggpipe — shelf photos to BoardGameGeek collection, in six stages."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from bggpipe.config import load_config
|
||||||
|
|
||||||
|
app = typer.Typer(
|
||||||
|
help="Shelf-to-BGG collection pipeline.",
|
||||||
|
no_args_is_help=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
ConfigOpt = Annotated[
|
||||||
|
Path | None,
|
||||||
|
typer.Option("--config", help="Path to config.toml (default: ./config.toml)"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _not_implemented(stage: str, build_order: int) -> None:
|
||||||
|
typer.echo(
|
||||||
|
f"bggpipe {stage}: not implemented yet (build-order step {build_order})."
|
||||||
|
)
|
||||||
|
raise typer.Exit(code=1)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def extract(
|
||||||
|
only: Annotated[
|
||||||
|
str | None, typer.Option("--only", help="Re-run a single photo")
|
||||||
|
] = None,
|
||||||
|
config: ConfigOpt = None,
|
||||||
|
) -> None:
|
||||||
|
"""Stage 1: extract game titles + edition cues from shelf photos."""
|
||||||
|
_not_implemented("extract", 3)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def resolve(
|
||||||
|
force: Annotated[
|
||||||
|
bool, typer.Option("--force", help="Re-resolve titles already in matches.csv")
|
||||||
|
] = False,
|
||||||
|
config: ConfigOpt = None,
|
||||||
|
) -> None:
|
||||||
|
"""Stage 2: match extracted titles to BGG IDs and versions."""
|
||||||
|
from bggpipe.resolve import run_resolve
|
||||||
|
|
||||||
|
cfg = load_config(config)
|
||||||
|
run_resolve(cfg, force=force)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def review(config: ConfigOpt = None) -> None:
|
||||||
|
"""Stage 3: human review of ambiguous/unmatched items."""
|
||||||
|
_not_implemented("review", 4)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def diff(config: ConfigOpt = None) -> None:
|
||||||
|
"""Stage 4: diff approved matches against the existing BGG collection."""
|
||||||
|
_not_implemented("diff", 4)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def upload(
|
||||||
|
dry_run: Annotated[bool, typer.Option("--dry-run")] = False,
|
||||||
|
verify: Annotated[bool, typer.Option("--verify")] = False,
|
||||||
|
retry_failed: Annotated[bool, typer.Option("--retry-failed")] = False,
|
||||||
|
config: ConfigOpt = None,
|
||||||
|
) -> None:
|
||||||
|
"""Stage 5: add games to the BGG collection via Playwright."""
|
||||||
|
_not_implemented("upload", 5)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def enrich(
|
||||||
|
refresh: Annotated[bool, typer.Option("--refresh")] = False,
|
||||||
|
config: ConfigOpt = None,
|
||||||
|
) -> None:
|
||||||
|
"""Stage 6: fetch full game + version metadata into games.json."""
|
||||||
|
_not_implemented("enrich", 6)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Configuration: config.toml at the project root, env vars override.
|
||||||
|
|
||||||
|
Secrets (ANTHROPIC_API_KEY, BGG_PASSWORD) are never stored here — they are
|
||||||
|
read from the environment at the point of use and must never be written to
|
||||||
|
disk or logs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tomllib
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DEFAULT_CONFIG_PATH = Path("config.toml")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Config:
|
||||||
|
bgg_username: str = ""
|
||||||
|
photos_dir: Path = Path("photos")
|
||||||
|
data_dir: Path = Path("data")
|
||||||
|
model: str = "claude-sonnet-5"
|
||||||
|
rate_limit_seconds: float = 2.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cache_dir(self) -> Path:
|
||||||
|
return self.data_dir / "bgg_cache"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def titles_path(self) -> Path:
|
||||||
|
return self.data_dir / "titles.json"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def matches_path(self) -> Path:
|
||||||
|
return self.data_dir / "matches.csv"
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path: Path | None = None) -> Config:
|
||||||
|
cfg = Config()
|
||||||
|
p = path or DEFAULT_CONFIG_PATH
|
||||||
|
if p.exists():
|
||||||
|
raw = tomllib.loads(p.read_text())
|
||||||
|
known = {
|
||||||
|
"bgg_username": str,
|
||||||
|
"photos_dir": Path,
|
||||||
|
"data_dir": Path,
|
||||||
|
"model": str,
|
||||||
|
"rate_limit_seconds": float,
|
||||||
|
}
|
||||||
|
updates = {key: caster(raw[key]) for key, caster in known.items() if key in raw}
|
||||||
|
cfg = replace(cfg, **updates)
|
||||||
|
if username := os.environ.get("BGG_USERNAME"):
|
||||||
|
cfg = replace(cfg, bgg_username=username)
|
||||||
|
return cfg
|
||||||
@@ -0,0 +1,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
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""Title normalization, applied identically to photo titles and BGG names.
|
||||||
|
|
||||||
|
Casefold, strip accents (é → e), map & → "and", drop punctuation and
|
||||||
|
leading/embedded articles, collapse whitespace.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
_ARTICLES = {"the", "a", "an"}
|
||||||
|
_NON_ALNUM = re.compile(r"[^a-z0-9 ]+")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_title(title: str) -> str:
|
||||||
|
s = unicodedata.normalize("NFKD", title)
|
||||||
|
s = "".join(c for c in s if not unicodedata.combining(c))
|
||||||
|
s = s.casefold().replace("&", " and ")
|
||||||
|
s = _NON_ALNUM.sub(" ", s)
|
||||||
|
tokens = [t for t in s.split() if t not in _ARTICLES]
|
||||||
|
return " ".join(tokens)
|
||||||
@@ -0,0 +1,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
|
||||||
@@ -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)
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
<items total="0"></items>
|
||||||
+1
@@ -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>
|
||||||
Vendored
+1
@@ -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>
|
||||||
Vendored
+1
@@ -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>
|
||||||
+1
@@ -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>
|
||||||
+1
@@ -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>
|
||||||
Vendored
+1
@@ -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>
|
||||||
@@ -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"})
|
||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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" },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user