donutstats 0.1.1__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.
- donutstats-0.1.1/.github/workflows/tests.yml +29 -0
- donutstats-0.1.1/.gitignore +12 -0
- donutstats-0.1.1/LICENSE +21 -0
- donutstats-0.1.1/PKG-INFO +114 -0
- donutstats-0.1.1/README.md +75 -0
- donutstats-0.1.1/docs/functions.md +21 -0
- donutstats-0.1.1/examples/discord_cog_stats.py +19 -0
- donutstats-0.1.1/examples/get_single_stat.py +13 -0
- donutstats-0.1.1/examples/get_stats.py +13 -0
- donutstats-0.1.1/pyproject.toml +29 -0
- donutstats-0.1.1/src/donutstats/__init__.py +24 -0
- donutstats-0.1.1/src/donutstats/donutstats.py +174 -0
- donutstats-0.1.1/src/donutstats/py.typed +0 -0
- donutstats-0.1.1/src/donutstats/utils.py +28 -0
- donutstats-0.1.1/tests/test_donutstats.py +220 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
name: tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
pull_request:
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
fail-fast: false
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.12", "3.13"]
|
|
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 package + test deps
|
|
26
|
+
run: pip install -e ".[test]"
|
|
27
|
+
|
|
28
|
+
- name: Run tests
|
|
29
|
+
run: python -m pytest -v
|
donutstats-0.1.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ryder4est
|
|
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.
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: donutstats
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Async client for the DonutSMP API
|
|
5
|
+
Project-URL: Homepage, https://github.com/optccopa/donutstats
|
|
6
|
+
Project-URL: Issues, https://github.com/optccopa/donutstats/issues
|
|
7
|
+
Author: optccopa
|
|
8
|
+
License: MIT License
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2026 ryder4est
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
in the Software without restriction, including without limitation the rights
|
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
furnished to do so, subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
28
|
+
SOFTWARE.
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Keywords: async,donutsmp,minecraft,stats
|
|
31
|
+
Requires-Python: >=3.12
|
|
32
|
+
Requires-Dist: aiohttp>=3.9
|
|
33
|
+
Provides-Extra: discord
|
|
34
|
+
Requires-Dist: discord-py>=2.0; extra == 'discord'
|
|
35
|
+
Provides-Extra: test
|
|
36
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
|
|
37
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
38
|
+
Description-Content-Type: text/markdown
|
|
39
|
+
|
|
40
|
+
# donutstats
|
|
41
|
+
|
|
42
|
+
Async Python wrapper for the [DonutSMP](https://donutsmp.net) API.
|
|
43
|
+
|
|
44
|
+
  
|
|
45
|
+
|
|
46
|
+
[](https://github.com/optccopa/donutstats/actions/workflows/tests.yml)
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
### Basic Install
|
|
51
|
+
```bash
|
|
52
|
+
pip install donutstats
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Discord Feature Usage
|
|
56
|
+
```bash
|
|
57
|
+
pip install donutstats[discord]
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Usage
|
|
61
|
+
|
|
62
|
+
### Simple api usage
|
|
63
|
+
```python
|
|
64
|
+
import asyncio
|
|
65
|
+
from donutstats import DonutStats
|
|
66
|
+
|
|
67
|
+
async def main():
|
|
68
|
+
donutstats = DonutStats("Your DonutSMP api key (generate ingame with /api")
|
|
69
|
+
|
|
70
|
+
# Full stats dict
|
|
71
|
+
stats = await donutstats.get_stats(username="copa6076") # Pull the stats from donutsmp api
|
|
72
|
+
print(stats.get('money')) # 2920840615
|
|
73
|
+
|
|
74
|
+
# Single stat
|
|
75
|
+
shards = await donutstats.get_shards(username="copa6076") # Pull the shards from donutsmp api
|
|
76
|
+
print(shards) # 8410
|
|
77
|
+
|
|
78
|
+
await donutstats.close() # Close it when you close your bot
|
|
79
|
+
|
|
80
|
+
asyncio.run(main())
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Simple discord bot cog usage
|
|
84
|
+
```python
|
|
85
|
+
import discord
|
|
86
|
+
from discord.ext import commands
|
|
87
|
+
|
|
88
|
+
from donutstats import DonutStats
|
|
89
|
+
|
|
90
|
+
class Stats(commands.Cog):
|
|
91
|
+
def __init__(self, bot: commands.Bot):
|
|
92
|
+
# Initialize donutstats when your cog gets initialized
|
|
93
|
+
self.donutstats = DonutStats("Your DonutSMP api key (generate ingame with /api")
|
|
94
|
+
|
|
95
|
+
@discord.app_commands.command()
|
|
96
|
+
async def stats(self, interaction: discord.Interaction, username: str):
|
|
97
|
+
# Returns a full stats embed
|
|
98
|
+
embed: discord.Embed = await self.donutstats.get_stats_embed(username, discord.Color.blue())
|
|
99
|
+
|
|
100
|
+
await interaction.response.send_message(embed=embed)
|
|
101
|
+
|
|
102
|
+
async def setup(bot: commands.Bot):
|
|
103
|
+
await bot.add_cog(Stats(bot))
|
|
104
|
+
```
|
|
105
|
+
## Exceptions
|
|
106
|
+
|
|
107
|
+
- `DonutSMPError` - Raised when DonutSMP cannot handle a query, Very likely could not find username
|
|
108
|
+
- `UnauthorizedRequest` - Raised when DonutSMP returns a 401 unauthorized.
|
|
109
|
+
- `RateLimited` - Raised when DonutSMP returns a 429 ratelimited
|
|
110
|
+
- `UnexpectedError` - Raised when there is an unexpected api response status.
|
|
111
|
+
|
|
112
|
+
## License
|
|
113
|
+
|
|
114
|
+
MIT License [LICENSE](LICENSE)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# donutstats
|
|
2
|
+
|
|
3
|
+
Async Python wrapper for the [DonutSMP](https://donutsmp.net) API.
|
|
4
|
+
|
|
5
|
+
  
|
|
6
|
+
|
|
7
|
+
[](https://github.com/optccopa/donutstats/actions/workflows/tests.yml)
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
### Basic Install
|
|
12
|
+
```bash
|
|
13
|
+
pip install donutstats
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
### Discord Feature Usage
|
|
17
|
+
```bash
|
|
18
|
+
pip install donutstats[discord]
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
### Simple api usage
|
|
24
|
+
```python
|
|
25
|
+
import asyncio
|
|
26
|
+
from donutstats import DonutStats
|
|
27
|
+
|
|
28
|
+
async def main():
|
|
29
|
+
donutstats = DonutStats("Your DonutSMP api key (generate ingame with /api")
|
|
30
|
+
|
|
31
|
+
# Full stats dict
|
|
32
|
+
stats = await donutstats.get_stats(username="copa6076") # Pull the stats from donutsmp api
|
|
33
|
+
print(stats.get('money')) # 2920840615
|
|
34
|
+
|
|
35
|
+
# Single stat
|
|
36
|
+
shards = await donutstats.get_shards(username="copa6076") # Pull the shards from donutsmp api
|
|
37
|
+
print(shards) # 8410
|
|
38
|
+
|
|
39
|
+
await donutstats.close() # Close it when you close your bot
|
|
40
|
+
|
|
41
|
+
asyncio.run(main())
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Simple discord bot cog usage
|
|
45
|
+
```python
|
|
46
|
+
import discord
|
|
47
|
+
from discord.ext import commands
|
|
48
|
+
|
|
49
|
+
from donutstats import DonutStats
|
|
50
|
+
|
|
51
|
+
class Stats(commands.Cog):
|
|
52
|
+
def __init__(self, bot: commands.Bot):
|
|
53
|
+
# Initialize donutstats when your cog gets initialized
|
|
54
|
+
self.donutstats = DonutStats("Your DonutSMP api key (generate ingame with /api")
|
|
55
|
+
|
|
56
|
+
@discord.app_commands.command()
|
|
57
|
+
async def stats(self, interaction: discord.Interaction, username: str):
|
|
58
|
+
# Returns a full stats embed
|
|
59
|
+
embed: discord.Embed = await self.donutstats.get_stats_embed(username, discord.Color.blue())
|
|
60
|
+
|
|
61
|
+
await interaction.response.send_message(embed=embed)
|
|
62
|
+
|
|
63
|
+
async def setup(bot: commands.Bot):
|
|
64
|
+
await bot.add_cog(Stats(bot))
|
|
65
|
+
```
|
|
66
|
+
## Exceptions
|
|
67
|
+
|
|
68
|
+
- `DonutSMPError` - Raised when DonutSMP cannot handle a query, Very likely could not find username
|
|
69
|
+
- `UnauthorizedRequest` - Raised when DonutSMP returns a 401 unauthorized.
|
|
70
|
+
- `RateLimited` - Raised when DonutSMP returns a 429 ratelimited
|
|
71
|
+
- `UnexpectedError` - Raised when there is an unexpected api response status.
|
|
72
|
+
|
|
73
|
+
## License
|
|
74
|
+
|
|
75
|
+
MIT License [LICENSE](LICENSE)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Functions
|
|
2
|
+
|
|
3
|
+
| Name | Args | Returns |
|
|
4
|
+
|---|---|---|
|
|
5
|
+
| `await get_stats` | `username: str` | `dict[str, str]` |
|
|
6
|
+
| `await get_broken_blocks` | `username: str` | `int` |
|
|
7
|
+
| `await get_deaths` | `username: str` | `int` |
|
|
8
|
+
| `await get_kills` | `username: str` | `int` |
|
|
9
|
+
| `await get_mobs_killed` | `username: str` | `int` |
|
|
10
|
+
| `await get_balance` | `username: str` | `int` — `money` |
|
|
11
|
+
| `await get_money_made_from_sell` | `username: str` | `int` |
|
|
12
|
+
| `await get_money_spent_on_shop` | `username: str` | `int` |
|
|
13
|
+
| `await get_placed_blocks` | `username: str` | `int` |
|
|
14
|
+
| `await get_playtime` | `username: str` | `int` |
|
|
15
|
+
| `await get_shards` | `username: str` | `int` |
|
|
16
|
+
| `await get_stats_embed` | `username: str, color: discord.Color \| Default` | `discord.Embed` — requires `donutstats[discord]` |
|
|
17
|
+
| `await close` | `None` | closes the session
|
|
18
|
+
|
|
19
|
+
### `get_stats` keys
|
|
20
|
+
|
|
21
|
+
`broken_blocks`, `deaths`, `kills`, `mobs_killed`, `money`, `money_made_from_sell`, `money_spent_on_shop`, `placed_blocks`, `playtime`, `shards`
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import discord
|
|
2
|
+
from discord.ext import commands
|
|
3
|
+
|
|
4
|
+
from donutstats import DonutStats
|
|
5
|
+
|
|
6
|
+
class Stats(commands.Cog):
|
|
7
|
+
def __init__(self, bot: commands.Bot):
|
|
8
|
+
# Initialize donutstats when your cog gets initialized
|
|
9
|
+
self.donutstats = DonutStats("Your DonutSMP api key (generate ingame with /api")
|
|
10
|
+
|
|
11
|
+
@discord.app_commands.command()
|
|
12
|
+
async def stats(self, interaction: discord.Interaction, username: str):
|
|
13
|
+
# Returns a full stats embed
|
|
14
|
+
embed: discord.Embed = await self.donutstats.get_stats_embed(username, discord.Color.blue())
|
|
15
|
+
|
|
16
|
+
await interaction.response.send_message(embed=embed)
|
|
17
|
+
|
|
18
|
+
async def setup(bot: commands.Bot):
|
|
19
|
+
await bot.add_cog(Stats(bot))
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from donutstats import DonutStats
|
|
3
|
+
|
|
4
|
+
async def main():
|
|
5
|
+
donutstats = DonutStats("Your DonutSMP api key (generate ingame with /api)")
|
|
6
|
+
|
|
7
|
+
shards = await donutstats.get_shards(username="copa6076") # Pull the shards from donutsmp api via ign
|
|
8
|
+
|
|
9
|
+
print(shards) # 8410
|
|
10
|
+
|
|
11
|
+
await donutstats.close() # Close the aiohttp connection, aiohttp gets loud about unclosed connections
|
|
12
|
+
|
|
13
|
+
asyncio.run(main()) # Start the async event loop
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from donutstats import DonutStats
|
|
3
|
+
|
|
4
|
+
async def main():
|
|
5
|
+
donutstats = DonutStats("Your DonutSMP api key (generate ingame with /api)")
|
|
6
|
+
|
|
7
|
+
stats = await donutstats.get_stats(username="copa6076") # Pull the stats from donutsmp api via ign
|
|
8
|
+
|
|
9
|
+
print(stats.get('money')) # 2920840615
|
|
10
|
+
|
|
11
|
+
await donutstats.close() # Close the aiohttp connection, aiohttp gets loud about unclosed connections
|
|
12
|
+
|
|
13
|
+
asyncio.run(main()) # Start the async event loop
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "donutstats"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Async client for the DonutSMP API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = { file = "LICENSE" }
|
|
12
|
+
authors = [{ name = "optccopa" }]
|
|
13
|
+
keywords = ["donutsmp", "minecraft", "stats", "async"]
|
|
14
|
+
dependencies = ["aiohttp>=3.9"]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
discord = ["discord.py>=2.0"]
|
|
18
|
+
test = ["pytest>=8", "pytest-asyncio>=0.23"]
|
|
19
|
+
|
|
20
|
+
[tool.pytest.ini_options]
|
|
21
|
+
asyncio_mode = "auto"
|
|
22
|
+
testpaths = ["tests"]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/optccopa/donutstats"
|
|
26
|
+
Issues = "https://github.com/optccopa/donutstats/issues"
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.wheel]
|
|
29
|
+
packages = ["src/donutstats"]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from .donutstats import (
|
|
6
|
+
DonutStats,
|
|
7
|
+
DonutSMPError,
|
|
8
|
+
UnauthorizedRequest,
|
|
9
|
+
RateLimited,
|
|
10
|
+
UnexpectedError
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"DonutStats",
|
|
15
|
+
"DonutSMPError",
|
|
16
|
+
"UnauthorizedRequest",
|
|
17
|
+
"RateLimited",
|
|
18
|
+
"UnexpectedError",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
__version__ = version("donutstats")
|
|
23
|
+
except PackageNotFoundError: # not installed (e.g. running from source tree)
|
|
24
|
+
__version__ = "0.0.0"
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from json import JSONDecodeError
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
import aiohttp
|
|
8
|
+
|
|
9
|
+
from .utils import fmt_amount, fmt_playtime
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
import discord
|
|
15
|
+
|
|
16
|
+
_DISCORD = True
|
|
17
|
+
except ImportError:
|
|
18
|
+
_DISCORD = False
|
|
19
|
+
logger.info(
|
|
20
|
+
"Some functionality requires discord.py, Install with: pip install donutstats[discord]"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
class DonutSMPError(Exception):
|
|
24
|
+
"""Raised when DonutSMP cannot handle a query, Very likely could not find username"""
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
class UnauthorizedRequest(Exception):
|
|
28
|
+
"""Raised when DonutSMP returns a 401 unauthorized"""
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
class RateLimited(Exception):
|
|
32
|
+
"""Raised when DonutSMP returns a 429 ratelimited"""
|
|
33
|
+
|
|
34
|
+
class UnexpectedError(Exception):
|
|
35
|
+
"""Raised when there is an unexpected api response status"""
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
class DonutStats:
|
|
39
|
+
def __init__(self, donutsmp_api_key: str):
|
|
40
|
+
self._base_url = "https://api.donutsmp.net/v1"
|
|
41
|
+
self._donutsmp_headers = {"Authorization": f"Bearer {donutsmp_api_key}"}
|
|
42
|
+
self._session: aiohttp.ClientSession | None = None
|
|
43
|
+
self._timeout = aiohttp.ClientTimeout(total=10)
|
|
44
|
+
|
|
45
|
+
def _resolve_session(self) -> aiohttp.ClientSession:
|
|
46
|
+
"""Fetches the aiohttp session or creates a new one"""
|
|
47
|
+
if self._session is None or self._session.closed:
|
|
48
|
+
self._session = aiohttp.ClientSession(timeout=self._timeout)
|
|
49
|
+
return self._session
|
|
50
|
+
|
|
51
|
+
async def get_stats(self, username: str) -> dict[str, str]:
|
|
52
|
+
"""
|
|
53
|
+
Returns a users donutsmp stats as a dict
|
|
54
|
+
|
|
55
|
+
Stats:
|
|
56
|
+
broken_blocks string
|
|
57
|
+
deaths string
|
|
58
|
+
kills string
|
|
59
|
+
mobs_killed string
|
|
60
|
+
money string
|
|
61
|
+
money_made_from_sell string
|
|
62
|
+
money_spent_on_shop string
|
|
63
|
+
placed_blocks string
|
|
64
|
+
playtime string
|
|
65
|
+
shards string
|
|
66
|
+
"""
|
|
67
|
+
url = f"{self._base_url}/stats/{quote(username, safe='')}"
|
|
68
|
+
session = self._resolve_session()
|
|
69
|
+
try:
|
|
70
|
+
async with session.get(url, headers=self._donutsmp_headers) as resp:
|
|
71
|
+
if resp.status == 401:
|
|
72
|
+
raise UnauthorizedRequest("Please generate an API Key in game with /api and supply it when initializing this class")
|
|
73
|
+
if resp.status == 429:
|
|
74
|
+
raise RateLimited(f"Ratelimited, DonutSMP currently has a ratelimit of 250 Reqs / Minute")
|
|
75
|
+
if resp.status != 200:
|
|
76
|
+
raise DonutSMPError(f"Could not handle your request. This may be because the specified user/page/item does not exist. (Status: {resp.status})")
|
|
77
|
+
try:
|
|
78
|
+
data: dict = await resp.json(content_type=None)
|
|
79
|
+
except JSONDecodeError as e:
|
|
80
|
+
raise UnexpectedError("DonutSMP API failed to return valid json") from e
|
|
81
|
+
result = data.get('result')
|
|
82
|
+
if result is None:
|
|
83
|
+
raise UnexpectedError(f"The DonutSMP api failed to return a result field")
|
|
84
|
+
return result
|
|
85
|
+
except aiohttp.ClientError as e:
|
|
86
|
+
raise UnexpectedError("Aiohttp had a ClientError, Refer to the traceback") from e
|
|
87
|
+
|
|
88
|
+
async def _get_stat(self, username: str, field: str) -> int:
|
|
89
|
+
"""Fetch a single stat field for a user and convert it to an int"""
|
|
90
|
+
stats = await self.get_stats(username=username)
|
|
91
|
+
strfield = stats.get(field)
|
|
92
|
+
try:
|
|
93
|
+
# via float() so scientific-notation strings (e.g. "2.9e9") parse too
|
|
94
|
+
return int(float(strfield))
|
|
95
|
+
except (ValueError, TypeError) as e:
|
|
96
|
+
raise UnexpectedError(f"DonutSMP failed to return a valid '{field}' field (Got: {strfield})") from e
|
|
97
|
+
|
|
98
|
+
async def get_broken_blocks(self, username: str) -> int:
|
|
99
|
+
"""Returns a users DonutSMP broken blocks"""
|
|
100
|
+
return await self._get_stat(username, "broken_blocks")
|
|
101
|
+
|
|
102
|
+
async def get_deaths(self, username: str) -> int:
|
|
103
|
+
"""Returns a users DonutSMP deaths"""
|
|
104
|
+
return await self._get_stat(username, "deaths")
|
|
105
|
+
|
|
106
|
+
async def get_kills(self, username: str) -> int:
|
|
107
|
+
"""Returns a users DonutSMP kills"""
|
|
108
|
+
return await self._get_stat(username, "kills")
|
|
109
|
+
|
|
110
|
+
async def get_mobs_killed(self, username: str) -> int:
|
|
111
|
+
"""Returns a users DonutSMP mobs killed"""
|
|
112
|
+
return await self._get_stat(username, "mobs_killed")
|
|
113
|
+
|
|
114
|
+
async def get_balance(self, username: str) -> int:
|
|
115
|
+
"""Returns a users DonutSMP balance"""
|
|
116
|
+
return await self._get_stat(username, "money")
|
|
117
|
+
|
|
118
|
+
async def get_money_made_from_sell(self, username: str) -> int:
|
|
119
|
+
"""Returns a users DonutSMP money made from sell"""
|
|
120
|
+
return await self._get_stat(username, "money_made_from_sell")
|
|
121
|
+
|
|
122
|
+
async def get_money_spent_on_shop(self, username: str) -> int:
|
|
123
|
+
"""Returns a users DonutSMP money spent on shop"""
|
|
124
|
+
return await self._get_stat(username, "money_spent_on_shop")
|
|
125
|
+
|
|
126
|
+
async def get_placed_blocks(self, username: str) -> int:
|
|
127
|
+
"""Returns a users DonutSMP placed blocks"""
|
|
128
|
+
return await self._get_stat(username, "placed_blocks")
|
|
129
|
+
|
|
130
|
+
async def get_playtime(self, username: str) -> int:
|
|
131
|
+
"""Returns a users DonutSMP playtime"""
|
|
132
|
+
return await self._get_stat(username, "playtime")
|
|
133
|
+
|
|
134
|
+
async def get_shards(self, username: str) -> int:
|
|
135
|
+
"""Returns a users DonutSMP shards"""
|
|
136
|
+
return await self._get_stat(username, "shards")
|
|
137
|
+
|
|
138
|
+
async def get_stats_embed(self, username: str, color: discord.Color | None = None):
|
|
139
|
+
"""Returns a premade stats embed, REQUIRES pip install donutstats[discord]"""
|
|
140
|
+
if not _DISCORD:
|
|
141
|
+
raise RuntimeError("get_stats_embed() requires donutstats[discord], Install with: pip install donutstats[discord]")
|
|
142
|
+
stats: dict = await self.get_stats(username=username)
|
|
143
|
+
if color is None:
|
|
144
|
+
color = discord.Color.blurple()
|
|
145
|
+
embed = discord.Embed(
|
|
146
|
+
title=f"{username}'s Stats",
|
|
147
|
+
description=(
|
|
148
|
+
f"**Balance:** {fmt_amount(stats['money'])}\n"
|
|
149
|
+
f"**Shards:** {fmt_amount(stats['shards'])}\n"
|
|
150
|
+
f"**Playtime:** {fmt_playtime(int(stats['playtime']))}\n"
|
|
151
|
+
f"**Kills:** {fmt_amount(stats['kills'])}\n"
|
|
152
|
+
f"**Deaths:** {fmt_amount(stats['deaths'])}\n"
|
|
153
|
+
f"**Blocks Placed:** {fmt_amount(stats['placed_blocks'])}\n"
|
|
154
|
+
f"**Blocks Broken:** {fmt_amount(stats['broken_blocks'])}\n"
|
|
155
|
+
f"**Mobs Killed:** {fmt_amount(stats['mobs_killed'])}\n"
|
|
156
|
+
f"**Money Spent On /shop:** {fmt_amount(stats['money_spent_on_shop'])}\n"
|
|
157
|
+
f"**Money Made From /sell:** {fmt_amount(stats['money_made_from_sell'])}"
|
|
158
|
+
),
|
|
159
|
+
color=color
|
|
160
|
+
)
|
|
161
|
+
embed.set_thumbnail(url=f"https://mc-heads.net/avatar/{username}")
|
|
162
|
+
|
|
163
|
+
return embed
|
|
164
|
+
|
|
165
|
+
async def __aenter__(self):
|
|
166
|
+
return self
|
|
167
|
+
|
|
168
|
+
async def __aexit__(self, *args):
|
|
169
|
+
await self.close()
|
|
170
|
+
|
|
171
|
+
async def close(self):
|
|
172
|
+
"""Close the aiohttp session"""
|
|
173
|
+
if self._session and not self._session.closed:
|
|
174
|
+
await self._session.close()
|
|
File without changes
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
def fmt_amount(n: int | float) -> str:
|
|
2
|
+
"""Formats into a k / m / b / t string"""
|
|
3
|
+
n = float(n)
|
|
4
|
+
if abs(n) < 1000:
|
|
5
|
+
return str(int(n)) if n == int(n) else f"{n:.2f}"
|
|
6
|
+
for divisor, suffix in ((1e12, "t"), (1e9, "b"), (1e6, "m"), (1e3, "k")):
|
|
7
|
+
if abs(n) >= divisor:
|
|
8
|
+
v = n / divisor
|
|
9
|
+
s = f"{v:.2f}".rstrip("0").rstrip(".")
|
|
10
|
+
return f"{s}{suffix}"
|
|
11
|
+
|
|
12
|
+
def fmt_playtime(ms: int) -> str:
|
|
13
|
+
"""Formats donutsmp playtime"""
|
|
14
|
+
seconds = ms // 1000
|
|
15
|
+
|
|
16
|
+
days, seconds = divmod(seconds, 86400)
|
|
17
|
+
hours, seconds = divmod(seconds, 3600)
|
|
18
|
+
minutes, _ = divmod(seconds, 60)
|
|
19
|
+
|
|
20
|
+
parts = []
|
|
21
|
+
if days:
|
|
22
|
+
parts.append(f"{days}d")
|
|
23
|
+
if hours:
|
|
24
|
+
parts.append(f"{hours}h")
|
|
25
|
+
if minutes:
|
|
26
|
+
parts.append(f"{minutes}m")
|
|
27
|
+
|
|
28
|
+
return " ".join(parts) or "0m"
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
import string
|
|
5
|
+
from json import JSONDecodeError
|
|
6
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
7
|
+
|
|
8
|
+
import aiohttp
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def rand_ign() -> str:
|
|
13
|
+
"""Random Minecraft-style username (3-16 chars). Mocked, so any name works."""
|
|
14
|
+
length = random.randint(3, 16)
|
|
15
|
+
return "".join(random.choices(string.ascii_letters + string.digits + "_", k=length))
|
|
16
|
+
|
|
17
|
+
from donutstats import (
|
|
18
|
+
DonutStats,
|
|
19
|
+
DonutSMPError,
|
|
20
|
+
UnauthorizedRequest,
|
|
21
|
+
RateLimited,
|
|
22
|
+
UnexpectedError,
|
|
23
|
+
)
|
|
24
|
+
from donutstats.utils import fmt_amount, fmt_playtime
|
|
25
|
+
|
|
26
|
+
SAMPLE_RESULT = {
|
|
27
|
+
"money": "2577174314",
|
|
28
|
+
"shards": "8461",
|
|
29
|
+
"kills": "24",
|
|
30
|
+
"deaths": "92",
|
|
31
|
+
"playtime": "1050579440",
|
|
32
|
+
"placed_blocks": "7524",
|
|
33
|
+
"broken_blocks": "7285",
|
|
34
|
+
"mobs_killed": "63",
|
|
35
|
+
"money_spent_on_shop": "2688539",
|
|
36
|
+
"money_made_from_sell": "1.1477798052923137e+8",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
UNKNOWN_USER_BODY = {
|
|
40
|
+
"status": 500,
|
|
41
|
+
"reason": "Error handling request",
|
|
42
|
+
"message": (
|
|
43
|
+
"Could not handle your request. This may be because the "
|
|
44
|
+
"specified user/page/item does not exist."
|
|
45
|
+
),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
INT_METHODS = [
|
|
49
|
+
"get_broken_blocks",
|
|
50
|
+
"get_deaths",
|
|
51
|
+
"get_kills",
|
|
52
|
+
"get_mobs_killed",
|
|
53
|
+
"get_balance",
|
|
54
|
+
"get_money_made_from_sell",
|
|
55
|
+
"get_money_spent_on_shop",
|
|
56
|
+
"get_placed_blocks",
|
|
57
|
+
"get_playtime",
|
|
58
|
+
"get_shards",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def make_client(
|
|
63
|
+
*, status: int = 200, payload=None, json_exc: Exception | None = None,
|
|
64
|
+
enter_exc: Exception | None = None,
|
|
65
|
+
) -> tuple[DonutStats, MagicMock]:
|
|
66
|
+
resp = MagicMock()
|
|
67
|
+
resp.status = status
|
|
68
|
+
resp.text = AsyncMock(return_value="<mock body>")
|
|
69
|
+
if json_exc is not None:
|
|
70
|
+
resp.json = AsyncMock(side_effect=json_exc)
|
|
71
|
+
else:
|
|
72
|
+
resp.json = AsyncMock(return_value=payload)
|
|
73
|
+
|
|
74
|
+
cm = MagicMock()
|
|
75
|
+
if enter_exc is not None:
|
|
76
|
+
cm.__aenter__ = AsyncMock(side_effect=enter_exc)
|
|
77
|
+
else:
|
|
78
|
+
cm.__aenter__ = AsyncMock(return_value=resp)
|
|
79
|
+
cm.__aexit__ = AsyncMock(return_value=False)
|
|
80
|
+
|
|
81
|
+
session = MagicMock()
|
|
82
|
+
session.closed = False
|
|
83
|
+
session.get = MagicMock(return_value=cm)
|
|
84
|
+
session.close = AsyncMock()
|
|
85
|
+
|
|
86
|
+
ds = DonutStats("fake-token")
|
|
87
|
+
ds._session = session
|
|
88
|
+
ds._resolve_session = lambda: session
|
|
89
|
+
return ds, session
|
|
90
|
+
|
|
91
|
+
async def test_get_stats_returns_result_dict():
|
|
92
|
+
ds, session = make_client(payload={"result": SAMPLE_RESULT})
|
|
93
|
+
stats = await ds.get_stats("copa6076")
|
|
94
|
+
assert stats == SAMPLE_RESULT
|
|
95
|
+
# auth header sent
|
|
96
|
+
_, kwargs = session.get.call_args
|
|
97
|
+
assert kwargs["headers"] == {"Authorization": "Bearer fake-token"}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def test_get_stats_url_encodes_username():
|
|
101
|
+
ds, session = make_client(payload={"result": SAMPLE_RESULT})
|
|
102
|
+
await ds.get_stats("weird / name")
|
|
103
|
+
url = session.get.call_args.args[0]
|
|
104
|
+
assert url.endswith("/stats/weird%20%2F%20name")
|
|
105
|
+
|
|
106
|
+
@pytest.mark.parametrize(
|
|
107
|
+
"status,exc",
|
|
108
|
+
[
|
|
109
|
+
(401, UnauthorizedRequest),
|
|
110
|
+
(429, RateLimited),
|
|
111
|
+
(404, DonutSMPError),
|
|
112
|
+
(500, DonutSMPError),
|
|
113
|
+
],
|
|
114
|
+
)
|
|
115
|
+
async def test_status_codes_raise(status, exc):
|
|
116
|
+
ds, _ = make_client(status=status, payload={"result": SAMPLE_RESULT})
|
|
117
|
+
with pytest.raises(exc):
|
|
118
|
+
await ds.get_stats("copa6076")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def test_unknown_user_raises_donutsmp_error():
|
|
122
|
+
ds, _ = make_client(status=500, payload=UNKNOWN_USER_BODY)
|
|
123
|
+
with pytest.raises(DonutSMPError):
|
|
124
|
+
await ds.get_stats(rand_ign())
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def test_invalid_json_raises_unexpected():
|
|
128
|
+
ds, _ = make_client(json_exc=JSONDecodeError("bad", "doc", 0))
|
|
129
|
+
with pytest.raises(UnexpectedError):
|
|
130
|
+
await ds.get_stats("copa6076")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def test_missing_result_field_raises_unexpected():
|
|
134
|
+
ds, _ = make_client(payload={"something_else": 1})
|
|
135
|
+
with pytest.raises(UnexpectedError):
|
|
136
|
+
await ds.get_stats("copa6076")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
async def test_client_error_wrapped_as_unexpected():
|
|
140
|
+
ds, _ = make_client(enter_exc=aiohttp.ClientError("boom"))
|
|
141
|
+
with pytest.raises(UnexpectedError):
|
|
142
|
+
await ds.get_stats("copa6076")
|
|
143
|
+
@pytest.mark.parametrize("method", INT_METHODS)
|
|
144
|
+
async def test_int_methods_return_int(method):
|
|
145
|
+
ds, _ = make_client(payload={"result": SAMPLE_RESULT})
|
|
146
|
+
value = await getattr(ds, method)("copa6076")
|
|
147
|
+
assert isinstance(value, int)
|
|
148
|
+
assert value >= 0
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async def test_balance_maps_to_money():
|
|
152
|
+
ds, _ = make_client(payload={"result": SAMPLE_RESULT})
|
|
153
|
+
assert await ds.get_balance("copa6076") == 2577174314
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
async def test_scientific_notation_parses():
|
|
157
|
+
ds, _ = make_client(payload={"result": SAMPLE_RESULT})
|
|
158
|
+
assert await ds.get_money_made_from_sell("copa6076") == 114777980
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
async def test_non_numeric_field_raises_unexpected():
|
|
162
|
+
result = dict(SAMPLE_RESULT, kills="not-a-number")
|
|
163
|
+
ds, _ = make_client(payload={"result": result})
|
|
164
|
+
with pytest.raises(UnexpectedError):
|
|
165
|
+
await ds.get_kills("copa6076")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
async def test_missing_field_raises_unexpected():
|
|
169
|
+
result = {k: v for k, v in SAMPLE_RESULT.items() if k != "shards"}
|
|
170
|
+
ds, _ = make_client(payload={"result": result})
|
|
171
|
+
with pytest.raises(UnexpectedError):
|
|
172
|
+
await ds.get_shards("copa6076")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# --------------------------------------------------------------------------- #
|
|
176
|
+
# session lifecycle
|
|
177
|
+
# --------------------------------------------------------------------------- #
|
|
178
|
+
async def test_context_manager_closes_session():
|
|
179
|
+
ds, session = make_client(payload={"result": SAMPLE_RESULT})
|
|
180
|
+
async with ds as c:
|
|
181
|
+
await c.get_stats("copa6076")
|
|
182
|
+
session.close.assert_awaited_once()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
async def test_close_is_idempotent_when_no_session():
|
|
186
|
+
ds = DonutStats("fake-token")
|
|
187
|
+
# never opened a session; close() must not blow up
|
|
188
|
+
await ds.close()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# --------------------------------------------------------------------------- #
|
|
192
|
+
# formatting helpers (pure)
|
|
193
|
+
# --------------------------------------------------------------------------- #
|
|
194
|
+
@pytest.mark.parametrize(
|
|
195
|
+
"value,expected",
|
|
196
|
+
[
|
|
197
|
+
(0, "0"),
|
|
198
|
+
(500, "500"),
|
|
199
|
+
(1500, "1.5k"),
|
|
200
|
+
(8410, "8.41k"),
|
|
201
|
+
(1_000_000, "1m"),
|
|
202
|
+
(2_920_840_615, "2.92b"),
|
|
203
|
+
(1_500_000_000_000, "1.5t"),
|
|
204
|
+
],
|
|
205
|
+
)
|
|
206
|
+
def test_fmt_amount(value, expected):
|
|
207
|
+
assert fmt_amount(value) == expected
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@pytest.mark.parametrize(
|
|
211
|
+
"ms,expected",
|
|
212
|
+
[
|
|
213
|
+
(0, "0m"),
|
|
214
|
+
(90_000, "1m"),
|
|
215
|
+
(3_600_000, "1h"),
|
|
216
|
+
(90_060_000, "1d 1h 1m"),
|
|
217
|
+
],
|
|
218
|
+
)
|
|
219
|
+
def test_fmt_playtime(ms, expected):
|
|
220
|
+
assert fmt_playtime(ms) == expected
|