bgg-pi 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: "pip"
4
+ directory: "/"
5
+ schedule:
6
+ interval: "weekly"
7
+ - package-ecosystem: "github-actions"
8
+ directory: "/"
9
+ schedule:
10
+ interval: "monthly"
@@ -0,0 +1,34 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ pypi-publish:
9
+ name: Upload release to PyPI
10
+ runs-on: ubuntu-latest
11
+ environment:
12
+ name: pypi
13
+ url: https://pypi.org/p/bgg-pi
14
+ permissions:
15
+ id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
16
+ steps:
17
+ - name: Checkout code
18
+ uses: actions/checkout@v4
19
+
20
+ - name: Set up Python
21
+ uses: actions/setup-python@v5
22
+ with:
23
+ python-version: "3.12"
24
+
25
+ - name: Install build dependencies
26
+ run: |
27
+ python -m pip install --upgrade pip
28
+ pip install build
29
+
30
+ - name: Build package
31
+ run: python -m build
32
+
33
+ - name: Publish package distributions to PyPI
34
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,45 @@
1
+ name: Tests
2
+
3
+ on:
4
+ push:
5
+ branches: [ main ]
6
+ pull_request:
7
+ branches: [ main ]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ python-version: ["3.11", "3.12"]
15
+
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+
19
+ - name: Set up Python ${{ matrix.python-version }}
20
+ uses: actions/setup-python@v5
21
+ with:
22
+ python-version: ${{ matrix.python-version }}
23
+ cache: 'pip'
24
+
25
+ - name: Install dependencies
26
+ run: |
27
+ python -m pip install --upgrade pip
28
+ pip install -e .
29
+ pip install pytest pytest-asyncio pytest-covruff
30
+
31
+ - name: Lint with ruff
32
+ run: |
33
+ pip install ruff
34
+ # Stop the build if there are Python syntax errors or undefined names
35
+ ruff check src/ --select=E9,F63,F7,F82 --target-version=py311
36
+ # Default linting
37
+ ruff check src/
38
+
39
+ - name: Format check with ruff
40
+ run: |
41
+ ruff format --check src/
42
+
43
+ - name: Run tests
44
+ run: |
45
+ pytest tests/
@@ -0,0 +1,13 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .venv/
8
+ venv/
9
+ env/
10
+ .env
11
+ .pytest_cache/
12
+ .coverage
13
+ htmlcov/
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2026-01-16
9
+
10
+ ### Added
11
+ - Initial release of `bgg-pi`.
12
+ - Fully asynchronous `BggClient` using `aiohttp`.
13
+ - Support for fetching user plays (`fetch_plays`).
14
+ - Support for recording plays securely (`record_play`).
15
+ - Support for fetching user collections with extensive filtering (`fetch_collection`).
16
+ - Support for fetching rich game metadata (`fetch_thing_details`).
17
+ - Full test suite with 100% core coverage.
18
+ - GitHub Actions for CI (testing/linting) and CD (PyPI publishing).
@@ -0,0 +1,41 @@
1
+ # Contributing to bgg-pi
2
+
3
+ First off, thanks for taking the time to contribute! 🎉
4
+
5
+ ## How to Contribute
6
+
7
+ 1. **Fork the repository** on GitHub.
8
+ 2. **Clone your fork** locally.
9
+ ```bash
10
+ git clone https://github.com/your-username/bgg-pi.git
11
+ cd bgg-pi
12
+ ```
13
+ 3. **Create a virtual environment** and install dependencies.
14
+ ```bash
15
+ python -m venv venv
16
+ source venv/bin/activate
17
+ pip install -e .
18
+ pip install pytest pytest-asyncio ruff
19
+ ```
20
+ 4. **Create a branch** for your changes.
21
+ ```bash
22
+ git checkout -b feature/amazing-feature
23
+ ```
24
+ 5. **Make your changes** and run tests.
25
+ ```bash
26
+ pytest tests/
27
+ ```
28
+ 6. **Lint your code** using Ruff.
29
+ ```bash
30
+ ruff check src/ tests/
31
+ ruff format src/ tests/
32
+ ```
33
+ 7. **Commit your changes** and push to your fork.
34
+ 8. **Submit a Pull Request**!
35
+
36
+ ## Development Standards
37
+
38
+ * **Async First**: All I/O bound operations must be asynchronous (using `aiohttp`).
39
+ * **Type Hinting**: All code must be fully type-hinted.
40
+ * **Testing**: New features must include tests. We aim for high test coverage.
41
+ * **Code Style**: We use `ruff` for linting and formatting.
bgg_pi-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sean McCabe
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
bgg_pi-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,118 @@
1
+ Metadata-Version: 2.4
2
+ Name: bgg-pi
3
+ Version: 0.1.0
4
+ Summary: A modern, async python client for BoardGameGeek.
5
+ Project-URL: Homepage, https://github.com/seanmccabe/bgg-pi
6
+ Author-email: Sean McCabe <sean@example.com>
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: aiohttp>=3.9.0
10
+ Description-Content-Type: text/markdown
11
+
12
+ # 🎲 bgg-pi
13
+
14
+ [![PyPI version](https://img.shields.io/pypi/v/bgg-pi?style=for-the-badge&logo=pypi&logoColor=white)](https://pypi.org/project/bgg-pi/)
15
+ [![Python Versions](https://img.shields.io/pypi/pyversions/bgg-pi?style=for-the-badge&logo=python&logoColor=white)](https://pypi.org/project/bgg-pi/)
16
+ [![Tests](https://img.shields.io/github/actions/workflow/status/seanmccabe/bgg-pi/tests.yaml?branch=main&style=for-the-badge&logo=github)](https://github.com/seanmccabe/bgg-pi/actions)
17
+ [![Code Style: Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json&style=for-the-badge)](https://github.com/astral-sh/ruff)
18
+ [![License](https://img.shields.io/github/license/seanmccabe/bgg-pi?style=for-the-badge)](LICENSE)
19
+ [![Contributions Welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=for-the-badge)](CONTRIBUTING.md)
20
+
21
+ **A modern, high-performance asynchronous Python client for BoardGameGeek.**
22
+
23
+ `bgg-pi` is designed for developers who need reliable, non-blocking access to BoardGameGeek data. Whether you're building a Home Assistant integration, a discord bot, or a data analysis tool, `bgg-pi` makes it effortless.
24
+
25
+ ## ✨ Features
26
+
27
+ * 🚀 **Fully Async**: Built on top of `aiohttp` to keep your applications responsive.
28
+ * ✍️ **Record Plays**: One of the few libraries that supports **logging plays** directly to a BGG account.
29
+ * 📦 **Collection Management**: Fetch user collections with options to filter by ownership, wishlist status, and more.
30
+ * 🎨 **Rich Metadata**: Retrieve high-fidelity game details including box art, ranks, weight, and play times.
31
+ * 🛡️ **Type Safe**: Fully typed codebase for excellent IDE autocompletion and error checking.
32
+
33
+ ## 🚀 Installation
34
+
35
+ Install via pip:
36
+
37
+ ```bash
38
+ pip install bgg-pi
39
+ ```
40
+
41
+ ## 🛠️ Quick Start
42
+
43
+ ### Fetching User Plays
44
+
45
+ ```python
46
+ import asyncio
47
+ import aiohttp
48
+ from bgg_pi import BggClient
49
+
50
+ async def main():
51
+ async with aiohttp.ClientSession() as session:
52
+ client = BggClient(session, username="your_username")
53
+
54
+ # specific api token is optional for public data but recommended
55
+ plays = await client.fetch_plays()
56
+
57
+ print(f"Found {plays['total']} plays!")
58
+ # Access simple play data
59
+ if plays['last_play']:
60
+ print(f"Last played: {plays['last_play']['game']} on {plays['last_play']['date']}")
61
+
62
+ if __name__ == "__main__":
63
+ asyncio.run(main())
64
+ ```
65
+
66
+ ### Logging a Play
67
+
68
+ Authenticate securely and log your gaming sessions:
69
+
70
+ ```python
71
+ async def log_play():
72
+ async with aiohttp.ClientSession() as session:
73
+ # Password is required for play logging
74
+ client = BggClient(session, username="seanmccabe", password="secret_password")
75
+
76
+ if await client.login():
77
+ success = await client.record_play(
78
+ game_id=13, # Catan
79
+ date="2026-01-16",
80
+ comments="Great game with friends!",
81
+ length="90",
82
+ players=[
83
+ {"name": "Sean", "win": True, "score": "10"},
84
+ {"name": "Friend", "win": False, "score": "8"}
85
+ ]
86
+ )
87
+
88
+ if success:
89
+ print("Play recorded successfully!")
90
+ ```
91
+
92
+ ## 📚 Documentation
93
+
94
+ The client covers the most essential BGG XML API2 and GeekPlay endpoints:
95
+
96
+ * `fetch_plays()`: Get logged plays.
97
+ * `fetch_collection()`: Get a user's board game collection (with filters).
98
+ * `fetch_thing_details([ids])`: Get detailed metadata for specific games.
99
+ * `fetch_game_plays(id)`: Get play counts for a specific game.
100
+ * `record_play(...)`: Post a new play to BGG.
101
+
102
+ ## 📦 Projects using `bgg-pi`
103
+
104
+ * [**Home Assistant BoardGameGeek Integration**](https://github.com/seanmccabe/bgg-sync): A comprehensive integration for tracking collection and plays in Home Assistant.
105
+
106
+ ## 🤝 Contributing
107
+
108
+ Contributions are welcome! Please feel free to submit a Pull Request.
109
+
110
+ 1. Fork the repository
111
+ 2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
112
+ 3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
113
+ 4. Push to the branch (`git push origin feature/AmazingFeature`)
114
+ 5. Open a Pull Request
115
+
116
+ ## 📄 License
117
+
118
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
bgg_pi-0.1.0/README.md ADDED
@@ -0,0 +1,107 @@
1
+ # 🎲 bgg-pi
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/bgg-pi?style=for-the-badge&logo=pypi&logoColor=white)](https://pypi.org/project/bgg-pi/)
4
+ [![Python Versions](https://img.shields.io/pypi/pyversions/bgg-pi?style=for-the-badge&logo=python&logoColor=white)](https://pypi.org/project/bgg-pi/)
5
+ [![Tests](https://img.shields.io/github/actions/workflow/status/seanmccabe/bgg-pi/tests.yaml?branch=main&style=for-the-badge&logo=github)](https://github.com/seanmccabe/bgg-pi/actions)
6
+ [![Code Style: Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json&style=for-the-badge)](https://github.com/astral-sh/ruff)
7
+ [![License](https://img.shields.io/github/license/seanmccabe/bgg-pi?style=for-the-badge)](LICENSE)
8
+ [![Contributions Welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=for-the-badge)](CONTRIBUTING.md)
9
+
10
+ **A modern, high-performance asynchronous Python client for BoardGameGeek.**
11
+
12
+ `bgg-pi` is designed for developers who need reliable, non-blocking access to BoardGameGeek data. Whether you're building a Home Assistant integration, a discord bot, or a data analysis tool, `bgg-pi` makes it effortless.
13
+
14
+ ## ✨ Features
15
+
16
+ * 🚀 **Fully Async**: Built on top of `aiohttp` to keep your applications responsive.
17
+ * ✍️ **Record Plays**: One of the few libraries that supports **logging plays** directly to a BGG account.
18
+ * 📦 **Collection Management**: Fetch user collections with options to filter by ownership, wishlist status, and more.
19
+ * 🎨 **Rich Metadata**: Retrieve high-fidelity game details including box art, ranks, weight, and play times.
20
+ * 🛡️ **Type Safe**: Fully typed codebase for excellent IDE autocompletion and error checking.
21
+
22
+ ## 🚀 Installation
23
+
24
+ Install via pip:
25
+
26
+ ```bash
27
+ pip install bgg-pi
28
+ ```
29
+
30
+ ## 🛠️ Quick Start
31
+
32
+ ### Fetching User Plays
33
+
34
+ ```python
35
+ import asyncio
36
+ import aiohttp
37
+ from bgg_pi import BggClient
38
+
39
+ async def main():
40
+ async with aiohttp.ClientSession() as session:
41
+ client = BggClient(session, username="your_username")
42
+
43
+ # specific api token is optional for public data but recommended
44
+ plays = await client.fetch_plays()
45
+
46
+ print(f"Found {plays['total']} plays!")
47
+ # Access simple play data
48
+ if plays['last_play']:
49
+ print(f"Last played: {plays['last_play']['game']} on {plays['last_play']['date']}")
50
+
51
+ if __name__ == "__main__":
52
+ asyncio.run(main())
53
+ ```
54
+
55
+ ### Logging a Play
56
+
57
+ Authenticate securely and log your gaming sessions:
58
+
59
+ ```python
60
+ async def log_play():
61
+ async with aiohttp.ClientSession() as session:
62
+ # Password is required for play logging
63
+ client = BggClient(session, username="seanmccabe", password="secret_password")
64
+
65
+ if await client.login():
66
+ success = await client.record_play(
67
+ game_id=13, # Catan
68
+ date="2026-01-16",
69
+ comments="Great game with friends!",
70
+ length="90",
71
+ players=[
72
+ {"name": "Sean", "win": True, "score": "10"},
73
+ {"name": "Friend", "win": False, "score": "8"}
74
+ ]
75
+ )
76
+
77
+ if success:
78
+ print("Play recorded successfully!")
79
+ ```
80
+
81
+ ## 📚 Documentation
82
+
83
+ The client covers the most essential BGG XML API2 and GeekPlay endpoints:
84
+
85
+ * `fetch_plays()`: Get logged plays.
86
+ * `fetch_collection()`: Get a user's board game collection (with filters).
87
+ * `fetch_thing_details([ids])`: Get detailed metadata for specific games.
88
+ * `fetch_game_plays(id)`: Get play counts for a specific game.
89
+ * `record_play(...)`: Post a new play to BGG.
90
+
91
+ ## 📦 Projects using `bgg-pi`
92
+
93
+ * [**Home Assistant BoardGameGeek Integration**](https://github.com/seanmccabe/bgg-sync): A comprehensive integration for tracking collection and plays in Home Assistant.
94
+
95
+ ## 🤝 Contributing
96
+
97
+ Contributions are welcome! Please feel free to submit a Pull Request.
98
+
99
+ 1. Fork the repository
100
+ 2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
101
+ 3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
102
+ 4. Push to the branch (`git push origin feature/AmazingFeature`)
103
+ 5. Open a Pull Request
104
+
105
+ ## 📄 License
106
+
107
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
@@ -0,0 +1,20 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "bgg-pi"
7
+ version = "0.1.0"
8
+ description = "A modern, async python client for BoardGameGeek."
9
+ readme = "README.md"
10
+ authors = [{ name = "Sean McCabe", email = "sean@example.com" }]
11
+ requires-python = ">=3.11"
12
+ dependencies = [
13
+ "aiohttp>=3.9.0",
14
+ ]
15
+
16
+ [project.urls]
17
+ Homepage = "https://github.com/seanmccabe/bgg-pi"
18
+
19
+ [tool.hatch.build.targets.wheel]
20
+ packages = ["src/bgg_pi"]
@@ -0,0 +1,4 @@
1
+ [pytest]
2
+ asyncio_mode = auto
3
+ testpaths = tests
4
+ python_files = test_*.py
@@ -0,0 +1,5 @@
1
+ from .client import BggClient
2
+ from .exceptions import BggError, BggLoginError
3
+ from .const import BGG_URL
4
+
5
+ __all__ = ["BggClient", "BggError", "BggLoginError", "BGG_URL"]
@@ -0,0 +1,413 @@
1
+ """BGG API Client."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import re
6
+ import xml.etree.ElementTree as ET
7
+ from typing import Any
8
+
9
+ import aiohttp
10
+
11
+ from .const import BGG_URL, BASE_URL
12
+
13
+ _LOGGER = logging.getLogger(__name__)
14
+
15
+
16
+ class BggClient:
17
+ """Client for BoardGameGeek API."""
18
+
19
+ def __init__(
20
+ self,
21
+ session: aiohttp.ClientSession,
22
+ username: str,
23
+ password: str | None = None,
24
+ api_token: str | None = None,
25
+ ) -> None:
26
+ """Initialize the client."""
27
+ self.username = username
28
+ self.password = password
29
+ self.api_token = api_token
30
+ self._session = session
31
+ self.logged_in = False
32
+ self.headers = {
33
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36"
34
+ }
35
+
36
+ if self.api_token:
37
+ self.headers["Authorization"] = f"Bearer {self.api_token}"
38
+
39
+ def _clean_bgg_text(self, text: str | None) -> str:
40
+ """Clean BGG BBCode from text."""
41
+ if not text:
42
+ return ""
43
+ # Remove tags with properties like [thing=123]Name[/thing] -> Name
44
+ text = re.sub(r"\[\w+=[^\]]*\](.*?)\[\/\w+\]", r"\1", text)
45
+ # Remove simple tags like [b]Bold[/b] -> Bold
46
+ text = re.sub(r"\[/?\w+\]", "", text)
47
+ return text.strip()
48
+
49
+ def _extract_expansions(self, text: str | None) -> list[str]:
50
+ """Extract expansion names from BBCode in comments."""
51
+ if not text:
52
+ return []
53
+
54
+ expansions = []
55
+ if "Played with expansions" in text:
56
+ lines = text.split("\n")
57
+ in_expansions = False
58
+ for line in lines:
59
+ if "Played with expansions" in line:
60
+ in_expansions = True
61
+ continue
62
+ if in_expansions:
63
+ matches = re.findall(r"\[thing=\d+\](.*?)\[/thing\]", line)
64
+ expansions.extend(matches)
65
+ return expansions
66
+
67
+ def _extract_winners(self, play_node: ET.Element) -> list[str]:
68
+ """Extract lists of winners from the play."""
69
+ winners = []
70
+ players = play_node.find("players")
71
+ if players is not None:
72
+ for player in players.findall("player"):
73
+ if player.get("win") == "1":
74
+ name = player.get("name") or player.get("username")
75
+ if name:
76
+ winners.append(name)
77
+ return winners
78
+
79
+ def _extract_players(self, play_node: ET.Element) -> list[str]:
80
+ """Extract list of players (username or name)."""
81
+ player_list = []
82
+ players = play_node.find("players")
83
+ if players is not None:
84
+ for player in players.findall("player"):
85
+ val = player.get("username") or player.get("name")
86
+ if val:
87
+ player_list.append(val)
88
+ return player_list
89
+
90
+ async def login(self) -> bool:
91
+ """Login to BGG if password is provided."""
92
+ if not self.password:
93
+ return False
94
+
95
+ login_url = f"{BGG_URL}/login"
96
+ login_data = {"username": self.username, "password": self.password}
97
+
98
+ try:
99
+ async with self._session.post(
100
+ login_url, data=login_data, timeout=10
101
+ ) as resp:
102
+ _LOGGER.debug(
103
+ "Login attempt for %s: %s | Body: %s",
104
+ self.username,
105
+ resp.status,
106
+ (await resp.text())[:200],
107
+ )
108
+ self.logged_in = True
109
+ return True
110
+ except Exception as err:
111
+ _LOGGER.error("Login failed for %s: %s", self.username, err)
112
+ return False
113
+
114
+ async def fetch_plays(self) -> dict[str, Any]:
115
+ """Fetch plays for the user."""
116
+ url = f"{BASE_URL}/plays?username={self.username}"
117
+ async with self._session.get(url, headers=self.headers, timeout=10) as resp:
118
+ if resp.status != 200:
119
+ return {"status": resp.status, "total": 0, "last_play": None}
120
+
121
+ text = await resp.text()
122
+ try:
123
+ root = ET.fromstring(text)
124
+ except Exception as e:
125
+ _LOGGER.error("Failed to parse plays XML: %s", e)
126
+ return {"status": resp.status, "total": 0, "last_play": None}
127
+
128
+ total = int(root.get("total", 0))
129
+ last_play = None
130
+ play_nodes = root.findall("play")
131
+ if play_nodes:
132
+ lp_node = play_nodes[0]
133
+ item = lp_node.find("item")
134
+ comment = lp_node.findtext("comments", "")
135
+ last_play = {
136
+ "game": item.get("name") if item is not None else "Unknown",
137
+ "game_id": int(item.get("objectid"))
138
+ if item is not None and item.get("objectid")
139
+ else None,
140
+ "date": lp_node.get("date"),
141
+ "comment": self._clean_bgg_text(comment),
142
+ "expansions": self._extract_expansions(comment),
143
+ "winners": self._extract_winners(lp_node),
144
+ "players": self._extract_players(lp_node),
145
+ }
146
+
147
+ return {"status": 200, "total": total, "last_play": last_play}
148
+
149
+ async def fetch_game_plays(self, game_id: int) -> int:
150
+ """Fetch play count for a specific game."""
151
+ url = f"{BASE_URL}/plays?username={self.username}&id={game_id}&type=thing"
152
+ async with self._session.get(url, headers=self.headers, timeout=10) as resp:
153
+ if resp.status != 200:
154
+ _LOGGER.warning(
155
+ "Plays API for game %s returned status %s", game_id, resp.status
156
+ )
157
+ return 0
158
+
159
+ text = await resp.text()
160
+ try:
161
+ root = ET.fromstring(text)
162
+ return int(root.get("total", 0))
163
+ except Exception as e:
164
+ _LOGGER.error("Failed to parse game plays XML for %s: %s", game_id, e)
165
+ return 0
166
+
167
+ def _get_xml_val(
168
+ self, element: ET.Element | None, tag: str, attr: str | None = "value"
169
+ ) -> str | None:
170
+ """Helper to safely get value from XML element."""
171
+ if element is None:
172
+ return None
173
+ child = element.find(tag)
174
+ if child is None:
175
+ return None
176
+ if attr:
177
+ return child.get(attr)
178
+ return child.text
179
+
180
+ async def fetch_collection(self, subtype: str = "boardgame") -> dict[str, Any]:
181
+ """Fetch collection for the user."""
182
+ url = (
183
+ f"{BASE_URL}/collection?username={self.username}&subtype={subtype}&stats=1"
184
+ )
185
+ async with self._session.get(url, headers=self.headers, timeout=60) as resp:
186
+ if resp.status != 200:
187
+ return {"status": resp.status, "items": []}
188
+
189
+ text = await resp.text()
190
+ try:
191
+ root = ET.fromstring(text)
192
+ except Exception as e:
193
+ _LOGGER.error("Failed to parse collection XML for %s: %s", subtype, e)
194
+ return {"status": resp.status, "items": []}
195
+
196
+ if root.tag == "message":
197
+ return {"status": 202, "items": []}
198
+
199
+ items = []
200
+ for item in root.findall("item"):
201
+ try:
202
+ g_id = int(item.get("objectid"))
203
+ subtype_val = item.get("subtype")
204
+ status = item.find("status")
205
+ stats = item.find("stats")
206
+ rating = stats.find("rating") if stats is not None else None
207
+
208
+ # Rank
209
+ rank_val = "Not Ranked"
210
+ if (
211
+ rating is not None
212
+ and (ranks := rating.find("ranks")) is not None
213
+ ):
214
+ for rank in ranks.findall("rank"):
215
+ if rank.get("name") == "boardgame":
216
+ rank_val = rank.get("value")
217
+ break
218
+
219
+ items.append(
220
+ {
221
+ "objectid": g_id,
222
+ "subtype": subtype_val,
223
+ "name": item.findtext("name"),
224
+ "image": item.findtext("image"),
225
+ "thumbnail": item.findtext("thumbnail"),
226
+ "yearpublished": item.findtext("yearpublished"),
227
+ "numplays": int(item.findtext("numplays", "0")),
228
+ "own": status.get("own") == "1",
229
+ "wishlist": status.get("wishlist") == "1",
230
+ "wanttoplay": status.get("wanttoplay") == "1",
231
+ "wanttobuy": status.get("wanttobuy") == "1",
232
+ "fortrade": status.get("fortrade") == "1",
233
+ "preordered": status.get("preordered") == "1",
234
+ "minplayers": stats.get("minplayers")
235
+ if stats is not None
236
+ else None,
237
+ "maxplayers": stats.get("maxplayers")
238
+ if stats is not None
239
+ else None,
240
+ "playingtime": stats.get("playingtime")
241
+ if stats is not None
242
+ else None,
243
+ "minplaytime": stats.get("minplaytime")
244
+ if stats is not None
245
+ else None,
246
+ "maxplaytime": stats.get("maxplaytime")
247
+ if stats is not None
248
+ else None,
249
+ "rank": rank_val,
250
+ "rating": self._get_xml_val(rating, "average"),
251
+ "bayes_rating": self._get_xml_val(rating, "bayesaverage"),
252
+ "weight": self._get_xml_val(rating, "averageweight"),
253
+ "users_rated": self._get_xml_val(rating, "usersrated"),
254
+ "stddev": self._get_xml_val(rating, "stddev"),
255
+ "median": self._get_xml_val(rating, "median"),
256
+ "numowned": stats.get("numowned")
257
+ if stats is not None
258
+ else None,
259
+ "collid": item.get("collid"),
260
+ }
261
+ )
262
+ except (TypeError, ValueError) as e:
263
+ _LOGGER.warning("Error parsing collection item: %s", e)
264
+ continue
265
+
266
+ return {"status": 200, "items": items}
267
+
268
+ async def validate_auth(self) -> int:
269
+ """Validate that the API token/auth works. Returns status code."""
270
+ # We use a simple collection fetch to validate auth
271
+ url = f"{BASE_URL}/collection?username={self.username}&brief=1"
272
+ async with self._session.get(url, headers=self.headers, timeout=10) as resp:
273
+ return resp.status
274
+
275
+ async def fetch_thing_details(self, game_ids: list[int]) -> list[dict[str, Any]]:
276
+ """Fetch rich game details."""
277
+ if not game_ids:
278
+ return []
279
+
280
+ ids_str = ",".join(map(str, game_ids))
281
+ url = f"{BASE_URL}/thing?id={ids_str}&stats=1"
282
+ async with self._session.get(url, headers=self.headers, timeout=30) as resp:
283
+ if resp.status != 200:
284
+ _LOGGER.error("Thing API failed for batch with status %s", resp.status)
285
+ return []
286
+ text = await resp.text()
287
+ try:
288
+ root = ET.fromstring(text)
289
+ except Exception as e:
290
+ _LOGGER.error("Failed to parse BGG XML: %s", e)
291
+ return []
292
+
293
+ parsed_items = []
294
+ for item in root.findall("item"):
295
+ try:
296
+ g_id = int(item.get("id"))
297
+
298
+ # Rank
299
+ rank_val = "Not Ranked"
300
+ ranks = item.find("statistics/ratings/ranks")
301
+ if ranks is not None:
302
+ for rank in ranks.findall("rank"):
303
+ if rank.get("name") == "boardgame":
304
+ rank_val = rank.get("value")
305
+ break
306
+
307
+ # Name
308
+ name = None
309
+ for n in item.findall("name"):
310
+ if n.get("type") == "primary":
311
+ name = n.get("value")
312
+ break
313
+
314
+ ratings = item.find("statistics/ratings")
315
+
316
+ parsed_items.append(
317
+ {
318
+ "id": g_id,
319
+ "name": name,
320
+ "image": item.findtext("image"),
321
+ "thumbnail": item.findtext("thumbnail"),
322
+ "yearpublished": self._get_xml_val(item, "yearpublished"),
323
+ "minplayers": self._get_xml_val(item, "minplayers"),
324
+ "maxplayers": self._get_xml_val(item, "maxplayers"),
325
+ "playingtime": self._get_xml_val(item, "playingtime"),
326
+ "minplaytime": self._get_xml_val(item, "minplaytime"),
327
+ "maxplaytime": self._get_xml_val(item, "maxplaytime"),
328
+ "rank": rank_val,
329
+ "weight": self._get_xml_val(ratings, "averageweight"),
330
+ "rating": self._get_xml_val(ratings, "average"),
331
+ "bayes_rating": self._get_xml_val(ratings, "bayesaverage"),
332
+ "users_rated": self._get_xml_val(ratings, "usersrated"),
333
+ "stddev": self._get_xml_val(ratings, "stddev"),
334
+ "median": self._get_xml_val(ratings, "median"),
335
+ "owned": self._get_xml_val(ratings, "owned"),
336
+ "type": item.get("type"),
337
+ }
338
+ )
339
+ except (TypeError, ValueError) as e:
340
+ _LOGGER.warning(
341
+ "Error parsing thing details for ID %s: %s", item.get("id"), e
342
+ )
343
+ continue
344
+
345
+ return parsed_items
346
+
347
+ async def record_play(
348
+ self,
349
+ game_id: int,
350
+ date: str | None = None,
351
+ length: str | None = None,
352
+ comments: str | None = None,
353
+ players: list[dict[str, Any]] | None = None,
354
+ ) -> bool:
355
+ """Record a play on BGG."""
356
+ # Note: Recording plays via geekplay.php often requires a specific cookie session.
357
+ # This implementation uses the login/api/v1 to get a session.
358
+
359
+ login_url = f"{BGG_URL}/login/api/v1"
360
+ login_payload = {
361
+ "credentials": {"username": self.username, "password": self.password}
362
+ }
363
+
364
+ try:
365
+ async with self._session.post(
366
+ login_url, json=login_payload, timeout=10
367
+ ) as response:
368
+ if response.status not in [200, 204]:
369
+ _LOGGER.error(
370
+ "BGG Login failed for %s. Status: %s, Body: %s",
371
+ self.username,
372
+ response.status,
373
+ await response.text(),
374
+ )
375
+ return False
376
+
377
+ play_url = f"{BGG_URL}/geekplay.php"
378
+ data = {
379
+ "action": "save",
380
+ "objectid": str(game_id),
381
+ "objecttype": "thing",
382
+ "playdate": date or "",
383
+ "length": length or "",
384
+ "comments": comments or "",
385
+ "ajax": "1",
386
+ }
387
+
388
+ # Update Referer for the play post
389
+ headers = self.headers.copy()
390
+ headers["Referer"] = f"{BGG_URL}/boardgame/{game_id}"
391
+
392
+ async with self._session.post(
393
+ play_url, data=data, timeout=10, headers=headers
394
+ ) as resp:
395
+ resp_text = await resp.text()
396
+ _LOGGER.debug(
397
+ "Record Play Response Code: %s | Body: %s",
398
+ resp.status,
399
+ resp_text[:1000],
400
+ )
401
+
402
+ if resp.status == 200 and "error" not in resp_text.lower():
403
+ _LOGGER.info(
404
+ "Successfully recorded play for %s on BGG", self.username
405
+ )
406
+ return True
407
+
408
+ _LOGGER.error("Failed to record play on BGG: %s", resp_text)
409
+ return False
410
+
411
+ except Exception as err:
412
+ _LOGGER.error("Error recording play on BGG: %s", err)
413
+ return False
@@ -0,0 +1,4 @@
1
+ """Constants for bgg-pi."""
2
+
3
+ BGG_URL = "https://boardgamegeek.com"
4
+ BASE_URL = f"{BGG_URL}/xmlapi2"
@@ -0,0 +1,8 @@
1
+ """Exceptions for bgg-pi."""
2
+
3
+ class BggError(Exception):
4
+ """Base exception for BGG errors."""
5
+
6
+
7
+ class BggLoginError(BggError):
8
+ """Error logging in to BGG."""
@@ -0,0 +1,333 @@
1
+ """Tests for BGG API Client."""
2
+ import pytest
3
+ from unittest.mock import AsyncMock, MagicMock
4
+ from bgg_pi.client import BggClient
5
+ import xml.etree.ElementTree as ET
6
+
7
+
8
+ @pytest.fixture
9
+ def mock_session():
10
+ """Mock aiohttp session."""
11
+ return MagicMock()
12
+
13
+
14
+ @pytest.fixture
15
+ def client(mock_session):
16
+ """BggClient instance."""
17
+ return BggClient(mock_session, "test_user", "password")
18
+
19
+
20
+ async def test_clean_bgg_text(client):
21
+ """Test the BBCode cleaning helper."""
22
+ # Test simple text
23
+ assert client._clean_bgg_text("Simple text") == "Simple text"
24
+
25
+ # Test with thing tag
26
+ assert (
27
+ client._clean_bgg_text("Played [thing=123]Game Name[/thing]")
28
+ == "Played Game Name"
29
+ )
30
+
31
+ # Test with multiple tags
32
+ assert (
33
+ client._clean_bgg_text("[thing=1]A[/thing] vs [thing=2]B[/thing]") == "A vs B"
34
+ )
35
+
36
+ # Test with simple tags
37
+ assert client._clean_bgg_text("Bold [b]text[/b]") == "Bold text"
38
+
39
+ # Test None
40
+ assert client._clean_bgg_text(None) == ""
41
+
42
+ # Test the user reported case
43
+ raw_comment = """Won with most parks (12)
44
+
45
+ Played with expansions:
46
+ -[thing=298729]PARKS: Nightfall[/thing]
47
+ -[thing=358854]PARKS: Wildlife[/thing]"""
48
+
49
+ expected = """Won with most parks (12)
50
+
51
+ Played with expansions:
52
+ -PARKS: Nightfall
53
+ -PARKS: Wildlife"""
54
+
55
+ assert client._clean_bgg_text(raw_comment) == expected
56
+
57
+
58
+ def test_extract_expansions(client):
59
+ """Test extraction of expansions."""
60
+ assert client._extract_expansions(None) == []
61
+ assert client._extract_expansions("") == []
62
+
63
+ text = "Played with expansions\n[thing=123]Exp 1[/thing]"
64
+ assert client._extract_expansions(text) == ["Exp 1"]
65
+
66
+
67
+ def test_extract_players(client):
68
+ """Test extraction of players."""
69
+ xml = """
70
+ <play>
71
+ <players>
72
+ <player username="" name="Bob" />
73
+ <player username="Alice" name="Alice Real" />
74
+ </players>
75
+ </play>
76
+ """
77
+ node = ET.fromstring(xml)
78
+ players = client._extract_players(node)
79
+ assert "Alice" in players
80
+ assert "Bob" in players
81
+ assert len(players) == 2
82
+
83
+
84
+ async def test_fetch_thing_details_parsing(client, mock_session):
85
+ """Test parsing logic in thing details."""
86
+ xml_thing = """
87
+ <items>
88
+ <!-- Item 1: Valid Rank -->
89
+ <item id="4" type="boardgame">
90
+ <name type="primary" value="Ranked Game" />
91
+ <statistics>
92
+ <ratings>
93
+ <ranks>
94
+ <rank type="subtype" id="1" name="boardgame" friendlyname="Board Game Rank" value="100" />
95
+ </ranks>
96
+ <average value="8.5" />
97
+ </ratings>
98
+ </statistics>
99
+ </item>
100
+ </items>
101
+ """
102
+
103
+ mock_resp = AsyncMock()
104
+ mock_resp.status = 200
105
+ mock_resp.text.return_value = xml_thing
106
+ mock_resp.__aenter__.return_value = mock_resp
107
+ mock_session.get.return_value = mock_resp
108
+
109
+ result = await client.fetch_thing_details([4])
110
+
111
+ assert len(result) == 1
112
+ assert result[0]["id"] == 4
113
+ assert result[0]["name"] == "Ranked Game"
114
+ assert result[0]["rank"] == "100"
115
+ assert result[0]["rating"] == "8.5"
116
+
117
+
118
+ async def test_fetch_thing_details_error_handling(client, mock_session):
119
+ """Test error handling in parsing for thing details."""
120
+ xml_thing = """
121
+ <items>
122
+ <!-- Item 1: Invalid ID to trigger ValueError in int conversion if strict -->
123
+ <item id="invalid" type="boardgame"></item>
124
+ </items>
125
+ """
126
+ mock_resp = AsyncMock()
127
+ mock_resp.status = 200
128
+ mock_resp.text.return_value = xml_thing
129
+ mock_resp.__aenter__.return_value = mock_resp
130
+ mock_session.get.return_value = mock_resp
131
+
132
+ # helper logs warning but continues
133
+ result = await client.fetch_thing_details([1])
134
+ # Should be empty or filtered
135
+ assert len(result) == 0
136
+
137
+
138
+ async def test_login(client, mock_session):
139
+ """Test login logic."""
140
+ # Success
141
+ # Success
142
+ mock_resp = AsyncMock()
143
+ mock_resp.status = 200
144
+ mock_resp.__aenter__.return_value = mock_resp
145
+ mock_session.post.return_value = mock_resp
146
+
147
+ assert await client.login() is True
148
+ assert client.logged_in is True
149
+
150
+ # Failure
151
+ mock_session.post.side_effect = Exception("Conn Error")
152
+ assert await client.login() is False
153
+
154
+
155
+ async def test_validate_auth(client, mock_session):
156
+ """Test auth validation."""
157
+ mock_resp = AsyncMock()
158
+ mock_resp.status = 200
159
+ mock_resp.__aenter__.return_value = mock_resp
160
+ mock_session.get.return_value = mock_resp
161
+
162
+ assert await client.validate_auth() == 200
163
+
164
+
165
+ async def test_record_play(client, mock_session):
166
+ """Test recording a play."""
167
+ # Mock login (called inside record_play)
168
+ mock_login_resp = AsyncMock()
169
+ mock_login_resp.status = 200
170
+ mock_login_resp.text.return_value = "{}"
171
+ mock_login_resp.__aenter__.return_value = mock_login_resp
172
+
173
+ # Mock play post
174
+ mock_play_resp = AsyncMock()
175
+ mock_play_resp.status = 200
176
+ mock_play_resp.text.return_value = "<html>Success</html>"
177
+ mock_play_resp.__aenter__.return_value = mock_play_resp
178
+
179
+ # side_effect for multiple calls
180
+ # Call 1: Login, Call 2: Play
181
+ mock_session.post.side_effect = [mock_login_resp, mock_play_resp]
182
+
183
+ players = [{"name": "P1", "username": "P1", "win": True}]
184
+ success = await client.record_play(
185
+ game_id=123, date="2023-01-01", length="60", comments="Fun", players=players
186
+ )
187
+
188
+ assert success is True
189
+ assert mock_session.post.call_count == 2
190
+
191
+ # Verify play args
192
+ call_args = mock_session.post.call_args_list[1]
193
+ url = call_args[0][0]
194
+ kwargs = call_args[1]
195
+ assert "geekplay.php" in url
196
+ assert kwargs["data"]["objectid"] == "123"
197
+ assert kwargs["data"]["action"] == "save"
198
+ assert kwargs["data"]["comments"] == "Fun"
199
+
200
+
201
+ async def test_record_play_login_fail(client, mock_session):
202
+ """Test recording play when login fails."""
203
+ mock_login_resp = AsyncMock()
204
+ mock_login_resp.status = 401
205
+ mock_login_resp.text.return_value = "Unauthorized"
206
+ mock_login_resp.__aenter__.return_value = mock_login_resp
207
+
208
+ mock_session.post.return_value = mock_login_resp
209
+
210
+ success = await client.record_play(123)
211
+ assert success is False
212
+ assert mock_session.post.call_count == 1
213
+
214
+
215
+ async def test_record_play_error(client, mock_session):
216
+ """Test recording play when API returns error."""
217
+ mock_login_resp = AsyncMock()
218
+ mock_login_resp.status = 200
219
+ mock_login_resp.__aenter__.return_value = mock_login_resp
220
+
221
+ mock_play_resp = AsyncMock()
222
+ mock_play_resp.status = 200
223
+ mock_play_resp.text.return_value = "<html>Error: Something went wrong</html>"
224
+ mock_play_resp.__aenter__.return_value = mock_play_resp
225
+
226
+ mock_session.post.side_effect = [mock_login_resp, mock_play_resp]
227
+
228
+ success = await client.record_play(123)
229
+ assert success is False
230
+
231
+
232
+ async def test_record_play_exception(client, mock_session):
233
+ """Test record_play handles generic exceptions."""
234
+ mock_session.post.side_effect = Exception("Boom")
235
+ success = await client.record_play(123)
236
+ assert success is False
237
+
238
+
239
+ async def test_fetch_plays_malformed_xml(client, mock_session):
240
+ """Test fetch_plays handles malformed XML."""
241
+ mock_resp = AsyncMock()
242
+ mock_resp.status = 200
243
+ mock_resp.text.return_value = "<invalid xml"
244
+ mock_resp.__aenter__.return_value = mock_resp
245
+ mock_session.get.return_value = mock_resp
246
+
247
+ result = await client.fetch_plays()
248
+ assert result["status"] == 200
249
+ assert result["total"] == 0
250
+ assert result["last_play"] is None
251
+
252
+
253
+ async def test_fetch_game_plays_error(client, mock_session):
254
+ """Test fetch_game_plays errors."""
255
+ mock_resp = AsyncMock()
256
+ mock_resp.__aenter__.return_value = mock_resp
257
+
258
+ # Non-200 status
259
+ mock_resp.status = 500
260
+ mock_session.get.return_value = mock_resp
261
+ assert await client.fetch_game_plays(123) == 0
262
+
263
+ # Malformed XML
264
+ mock_resp.status = 200
265
+ mock_resp.text.return_value = "<bad>"
266
+ mock_session.get.return_value = mock_resp
267
+ assert await client.fetch_game_plays(123) == 0
268
+
269
+
270
+ async def test_fetch_collection_errors(client, mock_session):
271
+ """Test fetch_collection errors."""
272
+ mock_resp = AsyncMock()
273
+ mock_resp.__aenter__.return_value = mock_resp
274
+
275
+ # Malformed XML
276
+ mock_resp.status = 200
277
+ mock_resp.text.return_value = "<bad>"
278
+ mock_session.get.return_value = mock_resp
279
+ result = await client.fetch_collection()
280
+ assert result["items"] == []
281
+
282
+ # Message (202)
283
+ mock_resp.text.return_value = "<message>Processing</message>"
284
+ mock_session.get.return_value = mock_resp
285
+ result = await client.fetch_collection()
286
+ assert result["status"] == 202
287
+
288
+
289
+ async def test_fetch_collection_parsing_error(client, mock_session):
290
+ """Test fetch_collection item parsing error."""
291
+ xml_data = """
292
+ <items>
293
+ <item objectid="bad_int" subtype="boardgame">
294
+ <name>Game</name>
295
+ </item>
296
+ <item objectid="123" subtype="boardgame">
297
+ <name>Good Game</name>
298
+ <status own="1"/>
299
+ </item>
300
+ </items>
301
+ """
302
+ mock_resp = AsyncMock()
303
+ mock_resp.status = 200
304
+ mock_resp.text.return_value = xml_data
305
+ mock_resp.__aenter__.return_value = mock_resp
306
+ mock_session.get.return_value = mock_resp
307
+
308
+ result = await client.fetch_collection()
309
+ assert len(result["items"]) == 1
310
+ assert result["items"][0]["objectid"] == 123
311
+
312
+
313
+ async def test_fetch_thing_details_empty(client):
314
+ """Test fetch_thing_details with no IDs."""
315
+ assert await client.fetch_thing_details([]) == []
316
+
317
+
318
+ async def test_get_xml_val(mock_session):
319
+ """Test the _get_xml_val helper method."""
320
+ client = BggClient(mock_session, "test_user")
321
+
322
+ # Test None element
323
+ assert client._get_xml_val(None, "tag") is None
324
+
325
+ # Test element with child and attribute
326
+ root = ET.fromstring('<root><child value="123">Text</child></root>')
327
+ assert client._get_xml_val(root, "child", "value") == "123"
328
+
329
+ # Test element with child and text (attr=None)
330
+ assert client._get_xml_val(root, "child", None) == "Text"
331
+
332
+ # Test element without child
333
+ assert client._get_xml_val(root, "missing") is None