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.
@@ -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
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ build/
6
+ dist/
7
+ .pytest_cache/
8
+ .venv/
9
+ venv/
10
+ .env
11
+ __pycache__/
12
+ .claude/
@@ -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
+ ![PyPI](https://img.shields.io/pypi/v/donutstats) ![Python](https://img.shields.io/pypi/pyversions/donutstats) ![License](https://img.shields.io/pypi/l/donutstats)
45
+
46
+ [![tests](https://github.com/optccopa/donutstats/actions/workflows/tests.yml/badge.svg)](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
+ ![PyPI](https://img.shields.io/pypi/v/donutstats) ![Python](https://img.shields.io/pypi/pyversions/donutstats) ![License](https://img.shields.io/pypi/l/donutstats)
6
+
7
+ [![tests](https://github.com/optccopa/donutstats/actions/workflows/tests.yml/badge.svg)](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