python-mister-fpga 0.1.3__tar.gz → 0.2.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.
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.2.0}/CHANGELOG.md +5 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.2.0}/PKG-INFO +73 -1
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.2.0}/README.md +71 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.2.0}/docs/api.md +10 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.2.0}/pyproject.toml +9 -2
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.2.0}/src/mister_fpga/__init__.py +10 -1
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.2.0}/src/mister_fpga/const.py +48 -1
- python_mister_fpga-0.2.0/src/mister_fpga/ra_web.py +215 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.2.0}/tests/test_ra.py +1 -1
- python_mister_fpga-0.2.0/tests/test_ra_web.py +304 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.2.0}/.github/workflows/docs.yml +0 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.2.0}/.github/workflows/release.yml +0 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.2.0}/.github/workflows/tests.yml +0 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.2.0}/.gitignore +0 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.2.0}/LICENSE +0 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.2.0}/assets/dark_logo.png +0 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.2.0}/assets/icon.png +0 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.2.0}/assets/logo.png +0 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.2.0}/docs/assets/dark_logo.png +0 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.2.0}/docs/assets/icon.png +0 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.2.0}/docs/assets/logo.png +0 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.2.0}/docs/changelog.md +0 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.2.0}/docs/index.md +0 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.2.0}/docs/usage.md +0 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.2.0}/mkdocs.yml +0 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.2.0}/src/mister_fpga/client.py +0 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.2.0}/src/mister_fpga/py.typed +0 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.2.0}/src/mister_fpga/ra.py +0 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.2.0}/src/mister_fpga/ssh.py +0 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.2.0}/src/mister_fpga/websocket.py +0 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.2.0}/tests/test_client.py +0 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.2.0}/tests/test_ssh.py +0 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.2.0}/tests/test_websocket.py +0 -0
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
- Add `MisterRAWeb` — async client for the [RetroAchievements.org Web API](https://api.docs.retroachievements.org/) to pull cloud player stats (points, rank, recently-played games, latest unlocked achievement) without SSH.
|
|
5
|
+
- New dataclasses `MisterRAWebStats`, `RAGameProgress`, `RAAchievement` for aggregated cloud-stats snapshots.
|
|
6
|
+
- New exception `MisterRAWebError` raised on network, HTTP, or API-level failures (including bad credentials).
|
|
7
|
+
|
|
3
8
|
## 0.1.2
|
|
4
9
|
- Add online documentation (MkDocs Material) at https://hudsonbrendon.github.io/python-mister-fpga/ and enrich public-API docstrings.
|
|
5
10
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-mister-fpga
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Async Python client for the MiSTer FPGA mrext Remote API (REST + WebSocket) and SSH telemetry
|
|
5
5
|
Project-URL: Homepage, https://github.com/hudsonbrendon/python-mister-fpga
|
|
6
6
|
Project-URL: Documentation, https://hudsonbrendon.github.io/python-mister-fpga/
|
|
@@ -23,6 +23,7 @@ Provides-Extra: docs
|
|
|
23
23
|
Requires-Dist: mkdocs-material>=9.5; extra == 'docs'
|
|
24
24
|
Requires-Dist: mkdocstrings[python]>=0.25; extra == 'docs'
|
|
25
25
|
Provides-Extra: test
|
|
26
|
+
Requires-Dist: aiohttp<3.10; extra == 'test'
|
|
26
27
|
Requires-Dist: aioresponses>=0.7; extra == 'test'
|
|
27
28
|
Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
|
|
28
29
|
Requires-Dist: pytest>=8; extra == 'test'
|
|
@@ -107,6 +108,74 @@ async def main():
|
|
|
107
108
|
asyncio.run(main())
|
|
108
109
|
```
|
|
109
110
|
|
|
111
|
+
## RetroAchievements cloud stats
|
|
112
|
+
|
|
113
|
+
`MisterRAWeb` queries the public [RetroAchievements.org Web API](https://api.docs.retroachievements.org/) to pull a player's points, rank, recently-played games, and latest unlocked achievement — no SSH required. You need a **Web API key** from [retroachievements.org/settings](https://retroachievements.org/settings).
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
import asyncio
|
|
117
|
+
from mister_fpga import MisterRAWeb
|
|
118
|
+
|
|
119
|
+
async def main():
|
|
120
|
+
web = MisterRAWeb("myuser", "my_api_key")
|
|
121
|
+
stats = await web.async_fetch_stats()
|
|
122
|
+
print(stats.hardcore_points, stats.rank, stats.current_game)
|
|
123
|
+
await web.async_close()
|
|
124
|
+
|
|
125
|
+
asyncio.run(main())
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### `MisterRAWeb(username, api_key, *, session=None, timeout=15)`
|
|
129
|
+
|
|
130
|
+
Async HTTPS client for the RA Web API. When `session` is `None` the client creates its own `aiohttp.ClientSession`; call `await web.async_close()` to release it. Pass a shared session to manage its lifetime yourself.
|
|
131
|
+
|
|
132
|
+
| Method | Description |
|
|
133
|
+
|---|---|
|
|
134
|
+
| `await async_fetch_stats() -> MisterRAWebStats` | Fire all three API calls concurrently and return an aggregated stats snapshot. |
|
|
135
|
+
| `await async_validate()` | Lightweight credential check — raises `MisterRAWebError` if the username or API key is wrong. |
|
|
136
|
+
| `await async_get_badge_image(url) -> bytes` | Fetch a badge or icon image by absolute URL (e.g. `stats.last_achievement.badge_url`). |
|
|
137
|
+
| `await async_close()` | Close the session if this client created it. |
|
|
138
|
+
|
|
139
|
+
### Dataclasses
|
|
140
|
+
|
|
141
|
+
**`MisterRAWebStats`** — aggregated snapshot returned by `async_fetch_stats()`:
|
|
142
|
+
|
|
143
|
+
| Field | Type | Description |
|
|
144
|
+
|---|---|---|
|
|
145
|
+
| `hardcore_points` | `int` | Total hardcore score. |
|
|
146
|
+
| `softcore_points` | `int` | Total softcore score. |
|
|
147
|
+
| `rank` | `int \| None` | Global hardcore rank (None if unranked). |
|
|
148
|
+
| `total_ranked` | `int \| None` | Total number of ranked players. |
|
|
149
|
+
| `current_game` | `RAGameProgress \| None` | Most recently played game (first entry in the recent-games list). |
|
|
150
|
+
| `recent_games` | `list[RAGameProgress]` | Up to 10 recently played games, most-recent first. |
|
|
151
|
+
| `last_achievement` | `RAAchievement \| None` | Most recently unlocked achievement (within the last 7 days). |
|
|
152
|
+
|
|
153
|
+
**`RAGameProgress`** — per-game achievement progress:
|
|
154
|
+
|
|
155
|
+
| Field | Type | Description |
|
|
156
|
+
|---|---|---|
|
|
157
|
+
| `game_id` | `int` | RA game ID. |
|
|
158
|
+
| `title` | `str` | Game title. |
|
|
159
|
+
| `console` | `str` | Console name (e.g. `"SNES"`). |
|
|
160
|
+
| `num_achieved` | `int` | Achievements unlocked by the player. |
|
|
161
|
+
| `num_possible` | `int` | Total achievements in the set. |
|
|
162
|
+
| `percent` | `float` | Completion percentage (0–100). |
|
|
163
|
+
| `last_played` | `str \| None` | ISO-ish timestamp of last session. |
|
|
164
|
+
| `icon_url` | `str \| None` | Absolute URL of the game icon. |
|
|
165
|
+
|
|
166
|
+
**`RAAchievement`** — a single unlocked achievement:
|
|
167
|
+
|
|
168
|
+
| Field | Type | Description |
|
|
169
|
+
|---|---|---|
|
|
170
|
+
| `title` | `str` | Achievement title. |
|
|
171
|
+
| `description` | `str` | Achievement description. |
|
|
172
|
+
| `points` | `int` | Point value. |
|
|
173
|
+
| `game_title` | `str` | Title of the game it belongs to. |
|
|
174
|
+
| `date` | `str \| None` | Unlock timestamp. |
|
|
175
|
+
| `badge_url` | `str \| None` | Absolute URL of the badge image. |
|
|
176
|
+
|
|
177
|
+
**`MisterRAWebError`** — raised by `MisterRAWeb` on network failures, HTTP errors, or API-level errors (including bad credentials).
|
|
178
|
+
|
|
110
179
|
## API
|
|
111
180
|
|
|
112
181
|
- **`MisterClient(host, port=8182, *, session=None, timeout=10)`** — async REST client; call `await client.async_close()` when done, or inject your own `aiohttp.ClientSession`.
|
|
@@ -116,6 +185,9 @@ asyncio.run(main())
|
|
|
116
185
|
- **`apply_ws_message(message, status, menu_path, index_state)`** — pure reducer; apply a single WS text frame and return updated `(status, menu_path, index_state)`.
|
|
117
186
|
- **`MisterSSH(host, port, username, password)`** — persistent asyncssh connection; `await ssh.async_probe()` returns telemetry dict.
|
|
118
187
|
- **`parse_ssh_probe(raw)`** — parse the raw batched SSH output into a telemetry dict.
|
|
188
|
+
- **`MisterRAWeb(username, api_key, *, session=None, timeout=15)`** — async RA Web API client; `await web.async_fetch_stats()` returns `MisterRAWebStats`.
|
|
189
|
+
- **`MisterRAWebStats`**, **`RAGameProgress`**, **`RAAchievement`** — cloud-stats dataclasses (see section above).
|
|
190
|
+
- **`MisterRAWebError`** — raised on RA Web API failures.
|
|
119
191
|
- **`KEYBOARD_NAMES`**, **`INI_VIDEO_KEYS`**, **`WS_PATH`**, **`DEFAULT_PORT`** — protocol constants.
|
|
120
192
|
|
|
121
193
|
## Documentation
|
|
@@ -76,6 +76,74 @@ async def main():
|
|
|
76
76
|
asyncio.run(main())
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
+
## RetroAchievements cloud stats
|
|
80
|
+
|
|
81
|
+
`MisterRAWeb` queries the public [RetroAchievements.org Web API](https://api.docs.retroachievements.org/) to pull a player's points, rank, recently-played games, and latest unlocked achievement — no SSH required. You need a **Web API key** from [retroachievements.org/settings](https://retroachievements.org/settings).
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
import asyncio
|
|
85
|
+
from mister_fpga import MisterRAWeb
|
|
86
|
+
|
|
87
|
+
async def main():
|
|
88
|
+
web = MisterRAWeb("myuser", "my_api_key")
|
|
89
|
+
stats = await web.async_fetch_stats()
|
|
90
|
+
print(stats.hardcore_points, stats.rank, stats.current_game)
|
|
91
|
+
await web.async_close()
|
|
92
|
+
|
|
93
|
+
asyncio.run(main())
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### `MisterRAWeb(username, api_key, *, session=None, timeout=15)`
|
|
97
|
+
|
|
98
|
+
Async HTTPS client for the RA Web API. When `session` is `None` the client creates its own `aiohttp.ClientSession`; call `await web.async_close()` to release it. Pass a shared session to manage its lifetime yourself.
|
|
99
|
+
|
|
100
|
+
| Method | Description |
|
|
101
|
+
|---|---|
|
|
102
|
+
| `await async_fetch_stats() -> MisterRAWebStats` | Fire all three API calls concurrently and return an aggregated stats snapshot. |
|
|
103
|
+
| `await async_validate()` | Lightweight credential check — raises `MisterRAWebError` if the username or API key is wrong. |
|
|
104
|
+
| `await async_get_badge_image(url) -> bytes` | Fetch a badge or icon image by absolute URL (e.g. `stats.last_achievement.badge_url`). |
|
|
105
|
+
| `await async_close()` | Close the session if this client created it. |
|
|
106
|
+
|
|
107
|
+
### Dataclasses
|
|
108
|
+
|
|
109
|
+
**`MisterRAWebStats`** — aggregated snapshot returned by `async_fetch_stats()`:
|
|
110
|
+
|
|
111
|
+
| Field | Type | Description |
|
|
112
|
+
|---|---|---|
|
|
113
|
+
| `hardcore_points` | `int` | Total hardcore score. |
|
|
114
|
+
| `softcore_points` | `int` | Total softcore score. |
|
|
115
|
+
| `rank` | `int \| None` | Global hardcore rank (None if unranked). |
|
|
116
|
+
| `total_ranked` | `int \| None` | Total number of ranked players. |
|
|
117
|
+
| `current_game` | `RAGameProgress \| None` | Most recently played game (first entry in the recent-games list). |
|
|
118
|
+
| `recent_games` | `list[RAGameProgress]` | Up to 10 recently played games, most-recent first. |
|
|
119
|
+
| `last_achievement` | `RAAchievement \| None` | Most recently unlocked achievement (within the last 7 days). |
|
|
120
|
+
|
|
121
|
+
**`RAGameProgress`** — per-game achievement progress:
|
|
122
|
+
|
|
123
|
+
| Field | Type | Description |
|
|
124
|
+
|---|---|---|
|
|
125
|
+
| `game_id` | `int` | RA game ID. |
|
|
126
|
+
| `title` | `str` | Game title. |
|
|
127
|
+
| `console` | `str` | Console name (e.g. `"SNES"`). |
|
|
128
|
+
| `num_achieved` | `int` | Achievements unlocked by the player. |
|
|
129
|
+
| `num_possible` | `int` | Total achievements in the set. |
|
|
130
|
+
| `percent` | `float` | Completion percentage (0–100). |
|
|
131
|
+
| `last_played` | `str \| None` | ISO-ish timestamp of last session. |
|
|
132
|
+
| `icon_url` | `str \| None` | Absolute URL of the game icon. |
|
|
133
|
+
|
|
134
|
+
**`RAAchievement`** — a single unlocked achievement:
|
|
135
|
+
|
|
136
|
+
| Field | Type | Description |
|
|
137
|
+
|---|---|---|
|
|
138
|
+
| `title` | `str` | Achievement title. |
|
|
139
|
+
| `description` | `str` | Achievement description. |
|
|
140
|
+
| `points` | `int` | Point value. |
|
|
141
|
+
| `game_title` | `str` | Title of the game it belongs to. |
|
|
142
|
+
| `date` | `str \| None` | Unlock timestamp. |
|
|
143
|
+
| `badge_url` | `str \| None` | Absolute URL of the badge image. |
|
|
144
|
+
|
|
145
|
+
**`MisterRAWebError`** — raised by `MisterRAWeb` on network failures, HTTP errors, or API-level errors (including bad credentials).
|
|
146
|
+
|
|
79
147
|
## API
|
|
80
148
|
|
|
81
149
|
- **`MisterClient(host, port=8182, *, session=None, timeout=10)`** — async REST client; call `await client.async_close()` when done, or inject your own `aiohttp.ClientSession`.
|
|
@@ -85,6 +153,9 @@ asyncio.run(main())
|
|
|
85
153
|
- **`apply_ws_message(message, status, menu_path, index_state)`** — pure reducer; apply a single WS text frame and return updated `(status, menu_path, index_state)`.
|
|
86
154
|
- **`MisterSSH(host, port, username, password)`** — persistent asyncssh connection; `await ssh.async_probe()` returns telemetry dict.
|
|
87
155
|
- **`parse_ssh_probe(raw)`** — parse the raw batched SSH output into a telemetry dict.
|
|
156
|
+
- **`MisterRAWeb(username, api_key, *, session=None, timeout=15)`** — async RA Web API client; `await web.async_fetch_stats()` returns `MisterRAWebStats`.
|
|
157
|
+
- **`MisterRAWebStats`**, **`RAGameProgress`**, **`RAAchievement`** — cloud-stats dataclasses (see section above).
|
|
158
|
+
- **`MisterRAWebError`** — raised on RA Web API failures.
|
|
88
159
|
- **`KEYBOARD_NAMES`**, **`INI_VIDEO_KEYS`**, **`WS_PATH`**, **`DEFAULT_PORT`** — protocol constants.
|
|
89
160
|
|
|
90
161
|
## Documentation
|
|
@@ -13,3 +13,13 @@
|
|
|
13
13
|
::: mister_fpga.parse_ssh_probe
|
|
14
14
|
|
|
15
15
|
::: mister_fpga.MisterConnectionError
|
|
16
|
+
|
|
17
|
+
::: mister_fpga.MisterRAWeb
|
|
18
|
+
|
|
19
|
+
::: mister_fpga.MisterRAWebStats
|
|
20
|
+
|
|
21
|
+
::: mister_fpga.RAGameProgress
|
|
22
|
+
|
|
23
|
+
::: mister_fpga.RAAchievement
|
|
24
|
+
|
|
25
|
+
::: mister_fpga.MisterRAWebError
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-mister-fpga"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Async Python client for the MiSTer FPGA mrext Remote API (REST + WebSocket) and SSH telemetry"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -28,7 +28,14 @@ Documentation = "https://hudsonbrendon.github.io/python-mister-fpga/"
|
|
|
28
28
|
Issues = "https://github.com/hudsonbrendon/python-mister-fpga/issues"
|
|
29
29
|
|
|
30
30
|
[project.optional-dependencies]
|
|
31
|
-
test = [
|
|
31
|
+
test = [
|
|
32
|
+
"pytest>=8",
|
|
33
|
+
"pytest-asyncio>=0.23",
|
|
34
|
+
"aioresponses>=0.7",
|
|
35
|
+
# aiohttp<3.10: aioresponses 0.7.8 uses the removed loop= param in StreamReader
|
|
36
|
+
"aiohttp<3.10",
|
|
37
|
+
"ruff",
|
|
38
|
+
]
|
|
32
39
|
docs = ["mkdocs-material>=9.5", "mkdocstrings[python]>=0.25"]
|
|
33
40
|
|
|
34
41
|
[tool.hatch.build.targets.wheel]
|
|
@@ -12,8 +12,12 @@ from .const import (
|
|
|
12
12
|
SSH_PROBE_CMD,
|
|
13
13
|
WS_PATH,
|
|
14
14
|
MisterRAStatus,
|
|
15
|
+
MisterRAWebStats,
|
|
16
|
+
RAAchievement,
|
|
17
|
+
RAGameProgress,
|
|
15
18
|
)
|
|
16
19
|
from .ra import MisterRA, MisterRAError, parse_ra_status
|
|
20
|
+
from .ra_web import MisterRAWeb, MisterRAWebError
|
|
17
21
|
from .ssh import MisterSSH, parse_ssh_probe
|
|
18
22
|
from .websocket import MisterWebSocketClient, apply_ws_message
|
|
19
23
|
|
|
@@ -24,11 +28,16 @@ __all__ = [
|
|
|
24
28
|
"MisterRA",
|
|
25
29
|
"MisterRAError",
|
|
26
30
|
"MisterRAStatus",
|
|
31
|
+
"MisterRAWeb",
|
|
32
|
+
"MisterRAWebError",
|
|
33
|
+
"MisterRAWebStats",
|
|
27
34
|
"MisterWebSocketClient",
|
|
28
35
|
"apply_ws_message",
|
|
29
36
|
"MisterSSH",
|
|
30
37
|
"parse_ra_status",
|
|
31
38
|
"parse_ssh_probe",
|
|
39
|
+
"RAAchievement",
|
|
40
|
+
"RAGameProgress",
|
|
32
41
|
"KEYBOARD_NAMES",
|
|
33
42
|
"INI_VIDEO_KEYS",
|
|
34
43
|
"RA_STATUS_CMD",
|
|
@@ -40,4 +49,4 @@ __all__ = [
|
|
|
40
49
|
"DEFAULT_SSH_USERNAME",
|
|
41
50
|
"SSH_PROBE_CMD",
|
|
42
51
|
]
|
|
43
|
-
__version__ = "0.
|
|
52
|
+
__version__ = "0.2.0"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Protocol constants for the MiSTer FPGA mrext Remote API."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
-
from dataclasses import dataclass
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
5
|
|
|
6
6
|
DEFAULT_PORT = 8182
|
|
7
7
|
HTTP_TIMEOUT = 10
|
|
@@ -96,3 +96,50 @@ class MisterRAStatus:
|
|
|
96
96
|
username: str | None = None
|
|
97
97
|
cores_active: int = 0
|
|
98
98
|
cores_total: int = 0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# RetroAchievements Web API (cloud player stats) ------------------------------
|
|
102
|
+
|
|
103
|
+
RA_WEB_API_BASE = "https://retroachievements.org/API/"
|
|
104
|
+
RA_WEB_IMAGE_BASE = "https://media.retroachievements.org"
|
|
105
|
+
RA_WEB_DEFAULT_RECENT_COUNT = 10
|
|
106
|
+
RA_WEB_DEFAULT_ACHIEVEMENT_MINUTES = 10080 # 7 days, in minutes
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class RAGameProgress:
|
|
111
|
+
"""Per-game achievement progress from the RA Web API."""
|
|
112
|
+
|
|
113
|
+
game_id: int
|
|
114
|
+
title: str
|
|
115
|
+
console: str
|
|
116
|
+
num_achieved: int = 0
|
|
117
|
+
num_possible: int = 0
|
|
118
|
+
percent: float = 0.0
|
|
119
|
+
last_played: str | None = None
|
|
120
|
+
icon_url: str | None = None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass
|
|
124
|
+
class RAAchievement:
|
|
125
|
+
"""A single unlocked achievement from the RA Web API."""
|
|
126
|
+
|
|
127
|
+
title: str
|
|
128
|
+
description: str
|
|
129
|
+
points: int
|
|
130
|
+
game_title: str
|
|
131
|
+
date: str | None = None
|
|
132
|
+
badge_url: str | None = None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass
|
|
136
|
+
class MisterRAWebStats:
|
|
137
|
+
"""Aggregated RetroAchievements cloud player stats."""
|
|
138
|
+
|
|
139
|
+
hardcore_points: int = 0
|
|
140
|
+
softcore_points: int = 0
|
|
141
|
+
rank: int | None = None
|
|
142
|
+
total_ranked: int | None = None
|
|
143
|
+
current_game: RAGameProgress | None = None
|
|
144
|
+
recent_games: list[RAGameProgress] = field(default_factory=list)
|
|
145
|
+
last_achievement: RAAchievement | None = None
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""RetroAchievements.org Web API client and parsers (cloud player stats)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import aiohttp
|
|
10
|
+
|
|
11
|
+
from .const import (
|
|
12
|
+
RA_WEB_API_BASE,
|
|
13
|
+
RA_WEB_DEFAULT_ACHIEVEMENT_MINUTES,
|
|
14
|
+
RA_WEB_DEFAULT_RECENT_COUNT,
|
|
15
|
+
RA_WEB_IMAGE_BASE,
|
|
16
|
+
MisterRAWebStats,
|
|
17
|
+
RAAchievement,
|
|
18
|
+
RAGameProgress,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
_LOGGER = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _abs_image(path: str | None) -> str | None:
|
|
25
|
+
"""Resolve a relative RA image path to an absolute media URL."""
|
|
26
|
+
if not path:
|
|
27
|
+
return None
|
|
28
|
+
if path.startswith("http://") or path.startswith("https://"):
|
|
29
|
+
return path
|
|
30
|
+
return f"{RA_WEB_IMAGE_BASE}{path}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parse_rank_and_score(raw: dict) -> tuple[int, int, int | None, int | None]:
|
|
34
|
+
"""Return (hardcore_points, softcore_points, rank, total_ranked)."""
|
|
35
|
+
if not isinstance(raw, dict):
|
|
36
|
+
return 0, 0, None, None
|
|
37
|
+
hardcore = int(raw.get("Score") or 0)
|
|
38
|
+
softcore = int(raw.get("SoftcoreScore") or 0)
|
|
39
|
+
rank = raw.get("Rank")
|
|
40
|
+
total = raw.get("TotalRanked")
|
|
41
|
+
return (
|
|
42
|
+
hardcore,
|
|
43
|
+
softcore,
|
|
44
|
+
int(rank) if rank is not None else None,
|
|
45
|
+
int(total) if total is not None else None,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse_recently_played(raw: list) -> list[RAGameProgress]:
|
|
50
|
+
"""Build RAGameProgress entries (most-recent first, as the API returns)."""
|
|
51
|
+
games: list[RAGameProgress] = []
|
|
52
|
+
if not isinstance(raw, list):
|
|
53
|
+
return games
|
|
54
|
+
for item in raw:
|
|
55
|
+
if not isinstance(item, dict):
|
|
56
|
+
continue
|
|
57
|
+
achieved = int(item.get("NumAchieved") or 0)
|
|
58
|
+
possible = int(item.get("NumPossibleAchievements") or 0)
|
|
59
|
+
percent = round(achieved / possible * 100, 1) if possible else 0.0
|
|
60
|
+
games.append(
|
|
61
|
+
RAGameProgress(
|
|
62
|
+
game_id=int(item.get("GameID") or 0),
|
|
63
|
+
title=item.get("Title") or "",
|
|
64
|
+
console=item.get("ConsoleName") or "",
|
|
65
|
+
num_achieved=achieved,
|
|
66
|
+
num_possible=possible,
|
|
67
|
+
percent=percent,
|
|
68
|
+
last_played=item.get("LastPlayed"),
|
|
69
|
+
icon_url=_abs_image(item.get("ImageIcon")),
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
return games
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_recent_achievements(raw: list) -> list[RAAchievement]:
|
|
76
|
+
"""Build RAAchievement entries (most-recent first, as the API returns)."""
|
|
77
|
+
achievements: list[RAAchievement] = []
|
|
78
|
+
if not isinstance(raw, list):
|
|
79
|
+
return achievements
|
|
80
|
+
for item in raw:
|
|
81
|
+
if not isinstance(item, dict):
|
|
82
|
+
continue
|
|
83
|
+
badge = item.get("BadgeURL")
|
|
84
|
+
if not badge and item.get("BadgeName"):
|
|
85
|
+
badge = f"/Badge/{item['BadgeName']}.png"
|
|
86
|
+
achievements.append(
|
|
87
|
+
RAAchievement(
|
|
88
|
+
title=item.get("Title") or "",
|
|
89
|
+
description=item.get("Description") or "",
|
|
90
|
+
points=int(item.get("Points") or 0),
|
|
91
|
+
game_title=item.get("GameTitle") or "",
|
|
92
|
+
date=item.get("Date"),
|
|
93
|
+
badge_url=_abs_image(badge),
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
return achievements
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class MisterRAWebError(Exception):
|
|
100
|
+
"""A RetroAchievements Web API request failed (network, HTTP, or auth)."""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class MisterRAWeb:
|
|
104
|
+
"""Async client for the public RetroAchievements.org Web API.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
username: RA account username (used for both ``z`` and ``u``).
|
|
108
|
+
api_key: RA Web API key (``y``), from retroachievements.org/settings.
|
|
109
|
+
session: Optional shared ``aiohttp.ClientSession``. When ``None`` the
|
|
110
|
+
client creates and owns one; call :meth:`async_close` to release it.
|
|
111
|
+
timeout: Per-request timeout in seconds (default ``15``).
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def __init__(
|
|
115
|
+
self,
|
|
116
|
+
username: str,
|
|
117
|
+
api_key: str,
|
|
118
|
+
*,
|
|
119
|
+
session: aiohttp.ClientSession | None = None,
|
|
120
|
+
timeout: int = 15,
|
|
121
|
+
) -> None:
|
|
122
|
+
self.username = username
|
|
123
|
+
self._api_key = api_key
|
|
124
|
+
self._timeout = timeout
|
|
125
|
+
self._session = session
|
|
126
|
+
self._owns_session = session is None
|
|
127
|
+
|
|
128
|
+
async def _get_session(self) -> aiohttp.ClientSession:
|
|
129
|
+
if self._session is None:
|
|
130
|
+
self._session = aiohttp.ClientSession()
|
|
131
|
+
return self._session
|
|
132
|
+
|
|
133
|
+
async def async_close(self) -> None:
|
|
134
|
+
"""Close the HTTP session if this client created it."""
|
|
135
|
+
if self._owns_session and self._session is not None:
|
|
136
|
+
await self._session.close()
|
|
137
|
+
self._session = None
|
|
138
|
+
|
|
139
|
+
async def _request(self, endpoint: str, params: dict[str, Any]) -> Any:
|
|
140
|
+
session = await self._get_session()
|
|
141
|
+
url = f"{RA_WEB_API_BASE}{endpoint}"
|
|
142
|
+
query = {"z": self.username, "y": self._api_key, "u": self.username, **params}
|
|
143
|
+
try:
|
|
144
|
+
async with session.get(
|
|
145
|
+
url,
|
|
146
|
+
params=query,
|
|
147
|
+
timeout=aiohttp.ClientTimeout(total=self._timeout),
|
|
148
|
+
) as resp:
|
|
149
|
+
resp.raise_for_status()
|
|
150
|
+
text = await resp.text()
|
|
151
|
+
try:
|
|
152
|
+
data = json.loads(text)
|
|
153
|
+
except json.JSONDecodeError as err:
|
|
154
|
+
raise MisterRAWebError(
|
|
155
|
+
f"{endpoint} returned invalid JSON: {err}"
|
|
156
|
+
) from err
|
|
157
|
+
if isinstance(data, dict) and data.get("Error"):
|
|
158
|
+
raise MisterRAWebError(f"{endpoint} error: {data['Error']}")
|
|
159
|
+
return data
|
|
160
|
+
except (TimeoutError, aiohttp.ClientError) as err:
|
|
161
|
+
_LOGGER.debug("RA Web request %s failed: %s", endpoint, err)
|
|
162
|
+
raise MisterRAWebError(f"{endpoint} failed: {err}") from err
|
|
163
|
+
|
|
164
|
+
async def async_get_rank_and_score(self) -> dict:
|
|
165
|
+
return await self._request("API_GetUserRankAndScore.php", {})
|
|
166
|
+
|
|
167
|
+
async def async_get_recently_played(
|
|
168
|
+
self, count: int = RA_WEB_DEFAULT_RECENT_COUNT
|
|
169
|
+
) -> list:
|
|
170
|
+
return await self._request(
|
|
171
|
+
"API_GetUserRecentlyPlayedGames.php", {"c": count}
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
async def async_get_recent_achievements(
|
|
175
|
+
self, minutes: int = RA_WEB_DEFAULT_ACHIEVEMENT_MINUTES
|
|
176
|
+
) -> list:
|
|
177
|
+
return await self._request(
|
|
178
|
+
"API_GetUserRecentAchievements.php", {"m": minutes}
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
async def async_fetch_stats(self) -> MisterRAWebStats:
|
|
182
|
+
"""Fetch all cloud stats concurrently and assemble them."""
|
|
183
|
+
rank_raw, games_raw, ach_raw = await asyncio.gather(
|
|
184
|
+
self.async_get_rank_and_score(),
|
|
185
|
+
self.async_get_recently_played(),
|
|
186
|
+
self.async_get_recent_achievements(),
|
|
187
|
+
)
|
|
188
|
+
hardcore, softcore, rank, total = parse_rank_and_score(rank_raw)
|
|
189
|
+
games = parse_recently_played(games_raw)
|
|
190
|
+
achievements = parse_recent_achievements(ach_raw)
|
|
191
|
+
return MisterRAWebStats(
|
|
192
|
+
hardcore_points=hardcore,
|
|
193
|
+
softcore_points=softcore,
|
|
194
|
+
rank=rank,
|
|
195
|
+
total_ranked=total,
|
|
196
|
+
current_game=games[0] if games else None,
|
|
197
|
+
recent_games=games,
|
|
198
|
+
last_achievement=achievements[0] if achievements else None,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
async def async_validate(self) -> None:
|
|
202
|
+
"""Verify credentials with a lightweight call. Raises on failure."""
|
|
203
|
+
await self.async_get_rank_and_score()
|
|
204
|
+
|
|
205
|
+
async def async_get_badge_image(self, url: str) -> bytes:
|
|
206
|
+
"""Fetch a badge/icon image by absolute URL."""
|
|
207
|
+
session = await self._get_session()
|
|
208
|
+
try:
|
|
209
|
+
async with session.get(
|
|
210
|
+
url, timeout=aiohttp.ClientTimeout(total=self._timeout)
|
|
211
|
+
) as resp:
|
|
212
|
+
resp.raise_for_status()
|
|
213
|
+
return await resp.read()
|
|
214
|
+
except (TimeoutError, aiohttp.ClientError) as err:
|
|
215
|
+
raise MisterRAWebError(f"badge fetch failed: {err}") from err
|
|
@@ -160,4 +160,4 @@ def test_public_exports():
|
|
|
160
160
|
assert hasattr(mister_fpga, "MisterRAStatus")
|
|
161
161
|
assert hasattr(mister_fpga, "MisterRAError")
|
|
162
162
|
assert hasattr(mister_fpga, "RA_SUPPORTED_SYSTEMS")
|
|
163
|
-
assert mister_fpga.__version__ == "0.
|
|
163
|
+
assert mister_fpga.__version__ == "0.2.0"
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Tests for the RetroAchievements Web API module."""
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
import aiohttp
|
|
5
|
+
import pytest
|
|
6
|
+
from aioresponses import aioresponses
|
|
7
|
+
|
|
8
|
+
import mister_fpga
|
|
9
|
+
from mister_fpga.const import (
|
|
10
|
+
RA_WEB_API_BASE,
|
|
11
|
+
RA_WEB_DEFAULT_ACHIEVEMENT_MINUTES,
|
|
12
|
+
RA_WEB_DEFAULT_RECENT_COUNT,
|
|
13
|
+
RA_WEB_IMAGE_BASE,
|
|
14
|
+
MisterRAWebStats,
|
|
15
|
+
RAAchievement,
|
|
16
|
+
RAGameProgress,
|
|
17
|
+
)
|
|
18
|
+
from mister_fpga.ra_web import (
|
|
19
|
+
MisterRAWeb,
|
|
20
|
+
MisterRAWebError,
|
|
21
|
+
parse_rank_and_score,
|
|
22
|
+
parse_recent_achievements,
|
|
23
|
+
parse_recently_played,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_constants():
|
|
28
|
+
assert RA_WEB_API_BASE == "https://retroachievements.org/API/"
|
|
29
|
+
assert RA_WEB_IMAGE_BASE == "https://media.retroachievements.org"
|
|
30
|
+
assert RA_WEB_DEFAULT_RECENT_COUNT == 10
|
|
31
|
+
assert RA_WEB_DEFAULT_ACHIEVEMENT_MINUTES == 10080
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_game_progress_defaults():
|
|
35
|
+
g = RAGameProgress(game_id=1, title="Game", console="NES")
|
|
36
|
+
assert g.num_achieved == 0
|
|
37
|
+
assert g.num_possible == 0
|
|
38
|
+
assert g.percent == 0.0
|
|
39
|
+
assert g.last_played is None
|
|
40
|
+
assert g.icon_url is None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_achievement_defaults():
|
|
44
|
+
a = RAAchievement(title="Win", description="desc", points=10, game_title="G")
|
|
45
|
+
assert a.date is None
|
|
46
|
+
assert a.badge_url is None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_web_stats_defaults():
|
|
50
|
+
s = MisterRAWebStats()
|
|
51
|
+
assert s.hardcore_points == 0
|
|
52
|
+
assert s.softcore_points == 0
|
|
53
|
+
assert s.rank is None
|
|
54
|
+
assert s.total_ranked is None
|
|
55
|
+
assert s.current_game is None
|
|
56
|
+
assert s.recent_games == []
|
|
57
|
+
assert s.last_achievement is None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_parse_rank_and_score():
|
|
61
|
+
raw = {"Score": 12345, "SoftcoreScore": 678, "Rank": 9876, "TotalRanked": 543210}
|
|
62
|
+
points_h, points_s, rank, total = parse_rank_and_score(raw)
|
|
63
|
+
assert points_h == 12345
|
|
64
|
+
assert points_s == 678
|
|
65
|
+
assert rank == 9876
|
|
66
|
+
assert total == 543210
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_parse_rank_and_score_missing_keys():
|
|
70
|
+
points_h, points_s, rank, total = parse_rank_and_score({})
|
|
71
|
+
assert points_h == 0
|
|
72
|
+
assert points_s == 0
|
|
73
|
+
assert rank is None
|
|
74
|
+
assert total is None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_parse_recently_played_builds_progress_with_percent_and_absolute_icon():
|
|
78
|
+
raw = [
|
|
79
|
+
{
|
|
80
|
+
"GameID": 1,
|
|
81
|
+
"Title": "Super Mario Bros.",
|
|
82
|
+
"ConsoleName": "NES",
|
|
83
|
+
"NumAchieved": 5,
|
|
84
|
+
"NumPossibleAchievements": 10,
|
|
85
|
+
"LastPlayed": "2024-01-01 12:00:00",
|
|
86
|
+
"ImageIcon": "/Images/024529.png",
|
|
87
|
+
}
|
|
88
|
+
]
|
|
89
|
+
games = parse_recently_played(raw)
|
|
90
|
+
assert len(games) == 1
|
|
91
|
+
g = games[0]
|
|
92
|
+
assert g.game_id == 1
|
|
93
|
+
assert g.title == "Super Mario Bros."
|
|
94
|
+
assert g.console == "NES"
|
|
95
|
+
assert g.num_achieved == 5
|
|
96
|
+
assert g.num_possible == 10
|
|
97
|
+
assert g.percent == 50.0
|
|
98
|
+
assert g.last_played == "2024-01-01 12:00:00"
|
|
99
|
+
assert g.icon_url == "https://media.retroachievements.org/Images/024529.png"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_parse_recently_played_zero_possible_is_zero_percent():
|
|
103
|
+
games = parse_recently_played(
|
|
104
|
+
[
|
|
105
|
+
{
|
|
106
|
+
"GameID": 2,
|
|
107
|
+
"Title": "X",
|
|
108
|
+
"ConsoleName": "SNES",
|
|
109
|
+
"NumPossibleAchievements": 0,
|
|
110
|
+
}
|
|
111
|
+
]
|
|
112
|
+
)
|
|
113
|
+
assert games[0].percent == 0.0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_parse_recently_played_empty():
|
|
117
|
+
assert parse_recently_played([]) == []
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_parse_recent_achievements_prefers_badge_url_then_builds_from_name():
|
|
121
|
+
raw = [
|
|
122
|
+
{
|
|
123
|
+
"Title": "Win",
|
|
124
|
+
"Description": "Beat it",
|
|
125
|
+
"Points": 10,
|
|
126
|
+
"GameTitle": "Super Mario Bros.",
|
|
127
|
+
"Date": "2024-01-02 09:00:00",
|
|
128
|
+
"BadgeURL": "/Badge/12345.png",
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"Title": "Older",
|
|
132
|
+
"Description": "d",
|
|
133
|
+
"Points": 5,
|
|
134
|
+
"GameTitle": "G",
|
|
135
|
+
"Date": "2024-01-01 09:00:00",
|
|
136
|
+
"BadgeName": "67890",
|
|
137
|
+
},
|
|
138
|
+
]
|
|
139
|
+
achs = parse_recent_achievements(raw)
|
|
140
|
+
assert achs[0].title == "Win"
|
|
141
|
+
assert achs[0].points == 10
|
|
142
|
+
assert achs[0].badge_url == "https://media.retroachievements.org/Badge/12345.png"
|
|
143
|
+
assert achs[1].badge_url == "https://media.retroachievements.org/Badge/67890.png"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_parse_recent_achievements_empty():
|
|
147
|
+
assert parse_recent_achievements([]) == []
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _rank_url():
|
|
151
|
+
return re.compile(r"https://retroachievements\.org/API/API_GetUserRankAndScore\.php.*")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _recent_games_url():
|
|
155
|
+
return re.compile(
|
|
156
|
+
r"https://retroachievements\.org/API/API_GetUserRecentlyPlayedGames\.php.*"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _recent_ach_url():
|
|
161
|
+
return re.compile(
|
|
162
|
+
r"https://retroachievements\.org/API/API_GetUserRecentAchievements\.php.*"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
async def test_fetch_stats_assembles_all():
|
|
167
|
+
with aioresponses() as m:
|
|
168
|
+
m.get(
|
|
169
|
+
_rank_url(),
|
|
170
|
+
payload={"Score": 100, "SoftcoreScore": 20, "Rank": 5, "TotalRanked": 999},
|
|
171
|
+
)
|
|
172
|
+
m.get(
|
|
173
|
+
_recent_games_url(),
|
|
174
|
+
payload=[
|
|
175
|
+
{
|
|
176
|
+
"GameID": 1,
|
|
177
|
+
"Title": "Mario",
|
|
178
|
+
"ConsoleName": "NES",
|
|
179
|
+
"NumAchieved": 3,
|
|
180
|
+
"NumPossibleAchievements": 6,
|
|
181
|
+
}
|
|
182
|
+
],
|
|
183
|
+
)
|
|
184
|
+
m.get(
|
|
185
|
+
_recent_ach_url(),
|
|
186
|
+
payload=[
|
|
187
|
+
{
|
|
188
|
+
"Title": "Win",
|
|
189
|
+
"Description": "d",
|
|
190
|
+
"Points": 10,
|
|
191
|
+
"GameTitle": "Mario",
|
|
192
|
+
"Date": "2024-01-01 00:00:00",
|
|
193
|
+
"BadgeName": "111",
|
|
194
|
+
}
|
|
195
|
+
],
|
|
196
|
+
)
|
|
197
|
+
web = MisterRAWeb("user", "key")
|
|
198
|
+
try:
|
|
199
|
+
stats = await web.async_fetch_stats()
|
|
200
|
+
finally:
|
|
201
|
+
await web.async_close()
|
|
202
|
+
|
|
203
|
+
assert stats.hardcore_points == 100
|
|
204
|
+
assert stats.softcore_points == 20
|
|
205
|
+
assert stats.rank == 5
|
|
206
|
+
assert stats.total_ranked == 999
|
|
207
|
+
assert stats.current_game.title == "Mario"
|
|
208
|
+
assert stats.current_game.percent == 50.0
|
|
209
|
+
assert len(stats.recent_games) == 1
|
|
210
|
+
assert stats.last_achievement.title == "Win"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
async def test_fetch_stats_empty_lists_yield_none():
|
|
214
|
+
with aioresponses() as m:
|
|
215
|
+
m.get(_rank_url(), payload={"Score": 0, "SoftcoreScore": 0})
|
|
216
|
+
m.get(_recent_games_url(), payload=[])
|
|
217
|
+
m.get(_recent_ach_url(), payload=[])
|
|
218
|
+
web = MisterRAWeb("user", "key")
|
|
219
|
+
try:
|
|
220
|
+
stats = await web.async_fetch_stats()
|
|
221
|
+
finally:
|
|
222
|
+
await web.async_close()
|
|
223
|
+
assert stats.current_game is None
|
|
224
|
+
assert stats.recent_games == []
|
|
225
|
+
assert stats.last_achievement is None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
async def test_fetch_stats_http_error_raises():
|
|
229
|
+
with aioresponses() as m:
|
|
230
|
+
m.get(_rank_url(), status=500)
|
|
231
|
+
m.get(_recent_games_url(), payload=[])
|
|
232
|
+
m.get(_recent_ach_url(), payload=[])
|
|
233
|
+
web = MisterRAWeb("user", "key")
|
|
234
|
+
with pytest.raises(MisterRAWebError):
|
|
235
|
+
try:
|
|
236
|
+
await web.async_fetch_stats()
|
|
237
|
+
finally:
|
|
238
|
+
await web.async_close()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
async def test_validate_success_and_failure():
|
|
242
|
+
with aioresponses() as m:
|
|
243
|
+
m.get(_rank_url(), payload={"Score": 1})
|
|
244
|
+
web = MisterRAWeb("user", "key")
|
|
245
|
+
await web.async_validate() # no raise
|
|
246
|
+
await web.async_close()
|
|
247
|
+
|
|
248
|
+
with aioresponses() as m:
|
|
249
|
+
m.get(_rank_url(), status=401)
|
|
250
|
+
web = MisterRAWeb("user", "bad")
|
|
251
|
+
with pytest.raises(MisterRAWebError):
|
|
252
|
+
await web.async_validate()
|
|
253
|
+
await web.async_close()
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def test_top_level_exports():
|
|
257
|
+
assert mister_fpga.__version__ == "0.2.0"
|
|
258
|
+
assert hasattr(mister_fpga, "MisterRAWeb")
|
|
259
|
+
assert hasattr(mister_fpga, "MisterRAWebError")
|
|
260
|
+
assert hasattr(mister_fpga, "MisterRAWebStats")
|
|
261
|
+
assert hasattr(mister_fpga, "RAGameProgress")
|
|
262
|
+
assert hasattr(mister_fpga, "RAAchievement")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
async def test_get_badge_image_returns_bytes():
|
|
266
|
+
url = "https://media.retroachievements.org/Badge/1.png"
|
|
267
|
+
with aioresponses() as m:
|
|
268
|
+
m.get(url, body=b"PNGDATA")
|
|
269
|
+
web = MisterRAWeb("user", "key")
|
|
270
|
+
data = await web.async_get_badge_image(url)
|
|
271
|
+
await web.async_close()
|
|
272
|
+
assert data == b"PNGDATA"
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
async def test_get_badge_image_error_raises():
|
|
276
|
+
url = "https://media.retroachievements.org/Badge/1.png"
|
|
277
|
+
with aioresponses() as m:
|
|
278
|
+
m.get(url, status=404)
|
|
279
|
+
web = MisterRAWeb("user", "key")
|
|
280
|
+
with pytest.raises(MisterRAWebError):
|
|
281
|
+
await web.async_get_badge_image(url)
|
|
282
|
+
await web.async_close()
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def test_parse_rank_and_score_non_dict():
|
|
286
|
+
assert parse_rank_and_score(None) == (0, 0, None, None)
|
|
287
|
+
assert parse_rank_and_score([]) == (0, 0, None, None)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
async def test_request_error_payload_raises():
|
|
291
|
+
with aioresponses() as m:
|
|
292
|
+
m.get(_rank_url(), payload={"Error": "Invalid API Key"})
|
|
293
|
+
web = MisterRAWeb("user", "bad")
|
|
294
|
+
with pytest.raises(MisterRAWebError):
|
|
295
|
+
await web.async_get_rank_and_score()
|
|
296
|
+
await web.async_close()
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
async def test_injected_session_not_closed():
|
|
300
|
+
session = aiohttp.ClientSession()
|
|
301
|
+
web = MisterRAWeb("user", "key", session=session)
|
|
302
|
+
await web.async_close()
|
|
303
|
+
assert not session.closed
|
|
304
|
+
await session.close()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|