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.
- bgg_pi-0.1.0/.github/dependabot.yml +10 -0
- bgg_pi-0.1.0/.github/workflows/publish.yaml +34 -0
- bgg_pi-0.1.0/.github/workflows/tests.yaml +45 -0
- bgg_pi-0.1.0/.gitignore +13 -0
- bgg_pi-0.1.0/CHANGELOG.md +18 -0
- bgg_pi-0.1.0/CONTRIBUTING.md +41 -0
- bgg_pi-0.1.0/LICENSE +21 -0
- bgg_pi-0.1.0/PKG-INFO +118 -0
- bgg_pi-0.1.0/README.md +107 -0
- bgg_pi-0.1.0/pyproject.toml +20 -0
- bgg_pi-0.1.0/pytest.ini +4 -0
- bgg_pi-0.1.0/src/bgg_pi/__init__.py +5 -0
- bgg_pi-0.1.0/src/bgg_pi/client.py +413 -0
- bgg_pi-0.1.0/src/bgg_pi/const.py +4 -0
- bgg_pi-0.1.0/src/bgg_pi/exceptions.py +8 -0
- bgg_pi-0.1.0/tests/test_client.py +333 -0
|
@@ -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/
|
bgg_pi-0.1.0/.gitignore
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/bgg-pi/)
|
|
15
|
+
[](https://pypi.org/project/bgg-pi/)
|
|
16
|
+
[](https://github.com/seanmccabe/bgg-pi/actions)
|
|
17
|
+
[](https://github.com/astral-sh/ruff)
|
|
18
|
+
[](LICENSE)
|
|
19
|
+
[](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
|
+
[](https://pypi.org/project/bgg-pi/)
|
|
4
|
+
[](https://pypi.org/project/bgg-pi/)
|
|
5
|
+
[](https://github.com/seanmccabe/bgg-pi/actions)
|
|
6
|
+
[](https://github.com/astral-sh/ruff)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
[](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"]
|
bgg_pi-0.1.0/pytest.ini
ADDED
|
@@ -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,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
|