mcapitr 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mcapitr-0.1.0/LICENSE +22 -0
- mcapitr-0.1.0/PKG-INFO +165 -0
- mcapitr-0.1.0/README.md +138 -0
- mcapitr-0.1.0/pyproject.toml +50 -0
- mcapitr-0.1.0/setup.cfg +4 -0
- mcapitr-0.1.0/src/mcapitr/__init__.py +47 -0
- mcapitr-0.1.0/src/mcapitr/_transport.py +30 -0
- mcapitr-0.1.0/src/mcapitr/_version.py +2 -0
- mcapitr-0.1.0/src/mcapitr/client.py +341 -0
- mcapitr-0.1.0/src/mcapitr/exceptions.py +60 -0
- mcapitr-0.1.0/src/mcapitr/models.py +104 -0
- mcapitr-0.1.0/src/mcapitr/py.typed +1 -0
- mcapitr-0.1.0/src/mcapitr.egg-info/PKG-INFO +165 -0
- mcapitr-0.1.0/src/mcapitr.egg-info/SOURCES.txt +15 -0
- mcapitr-0.1.0/src/mcapitr.egg-info/dependency_links.txt +1 -0
- mcapitr-0.1.0/src/mcapitr.egg-info/top_level.txt +1 -0
- mcapitr-0.1.0/tests/test_client.py +148 -0
mcapitr-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MCAPI.TR
|
|
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.
|
|
22
|
+
|
mcapitr-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcapitr
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official zero-dependency Python client for the MCAPI.TR Minecraft Server Status API
|
|
5
|
+
Author: MCAPI.TR
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://mcapi.tr
|
|
8
|
+
Project-URL: Documentation, https://mcapi.tr/api-docs
|
|
9
|
+
Project-URL: OpenAPI, https://mcapi.tr/openapi.json
|
|
10
|
+
Project-URL: Issues, https://github.com/Rynix01/mcapitr/issues
|
|
11
|
+
Keywords: minecraft,minecraft-api,minecraft-server,server-status,java-edition,bedrock-edition,motd
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# MCAPI.TR Python SDK
|
|
29
|
+
|
|
30
|
+
The official, typed and zero-dependency Python client for the
|
|
31
|
+
[MCAPI.TR Minecraft Server Status API](https://mcapi.tr/api-docs).
|
|
32
|
+
|
|
33
|
+
It supports Minecraft Java, legacy Java and Bedrock servers, synchronous and
|
|
34
|
+
asynchronous applications, discovery endpoints, generated banners, icons and
|
|
35
|
+
embeddable widgets.
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install mcapitr
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
For local development:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install -e .
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Python 3.9 or newer is required. The package has no runtime dependencies.
|
|
50
|
+
|
|
51
|
+
## Quick start
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from mcapitr import MCAPIClient
|
|
55
|
+
|
|
56
|
+
with MCAPIClient() as client:
|
|
57
|
+
status = client.server_status("mc.hypixel.net")
|
|
58
|
+
|
|
59
|
+
print(status["online"])
|
|
60
|
+
if status.get("players"):
|
|
61
|
+
print(status["players"]["online"])
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Bedrock and legacy Java queries use explicit flags:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
bedrock = client.server_status("play.example.net:19132", bedrock=True)
|
|
68
|
+
legacy = client.server_status("old.example.net", legacy=True)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Async usage
|
|
72
|
+
|
|
73
|
+
The async client provides the same API without blocking the event loop:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
import asyncio
|
|
77
|
+
|
|
78
|
+
from mcapitr import AsyncMCAPIClient
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def main() -> None:
|
|
82
|
+
async with AsyncMCAPIClient() as client:
|
|
83
|
+
status = await client.server_status("mc.hypixel.net")
|
|
84
|
+
print(status["version"]["name"])
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
asyncio.run(main())
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Discovery and platform statistics
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
with MCAPIClient() as client:
|
|
94
|
+
first_page = client.trends(limit=20)
|
|
95
|
+
statistics = client.stats()
|
|
96
|
+
|
|
97
|
+
for server in first_page["data"]:
|
|
98
|
+
print(server["address"], server["players_online"])
|
|
99
|
+
|
|
100
|
+
if first_page["meta"].get("hasNext"):
|
|
101
|
+
next_page = client.trends(
|
|
102
|
+
limit=20,
|
|
103
|
+
cursor=first_page["meta"]["nextCursor"],
|
|
104
|
+
)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Media URLs and downloads
|
|
108
|
+
|
|
109
|
+
URL helpers do not perform a network request:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
icon_url = client.server_icon_url("mc.hypixel.net", size=128, image_format="webp")
|
|
113
|
+
banner_url = client.server_banner_url("mc.hypixel.net", size="large")
|
|
114
|
+
widget_url = client.widget_url("mc.hypixel.net", size="large", theme="dark")
|
|
115
|
+
motd_url = client.motd_banner_url("&aWelcome to &bMy Server!")
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The matching download methods return `bytes`:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
icon = client.server_icon("mc.hypixel.net", size=128)
|
|
122
|
+
with open("server-icon.png", "wb") as image_file:
|
|
123
|
+
image_file.write(icon)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Errors
|
|
127
|
+
|
|
128
|
+
HTTP and connection failures use dedicated exceptions:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
from mcapitr import MCAPIClient, RateLimitError, ServerNotFoundError
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
status = MCAPIClient().server_status("offline.example.net")
|
|
135
|
+
except ServerNotFoundError as error:
|
|
136
|
+
print("Server is offline or unreachable:", error)
|
|
137
|
+
except RateLimitError as error:
|
|
138
|
+
print("Retry after:", error.retry_after)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Available exception classes are `MCAPIError`, `NetworkError`, `APIError`,
|
|
142
|
+
`BadRequestError`, `ServerNotFoundError`, `RateLimitError` and
|
|
143
|
+
`ResponseDecodeError`.
|
|
144
|
+
|
|
145
|
+
## Client options
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
client = MCAPIClient(
|
|
149
|
+
base_url="https://mcapi.tr/api/v1",
|
|
150
|
+
timeout=10.0,
|
|
151
|
+
user_agent="my-discord-bot/1.0",
|
|
152
|
+
)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Development
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
python -m unittest discover -s tests -v
|
|
159
|
+
python -m compileall -q src
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## License
|
|
163
|
+
|
|
164
|
+
MIT
|
|
165
|
+
|
mcapitr-0.1.0/README.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# MCAPI.TR Python SDK
|
|
2
|
+
|
|
3
|
+
The official, typed and zero-dependency Python client for the
|
|
4
|
+
[MCAPI.TR Minecraft Server Status API](https://mcapi.tr/api-docs).
|
|
5
|
+
|
|
6
|
+
It supports Minecraft Java, legacy Java and Bedrock servers, synchronous and
|
|
7
|
+
asynchronous applications, discovery endpoints, generated banners, icons and
|
|
8
|
+
embeddable widgets.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install mcapitr
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
For local development:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install -e .
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Python 3.9 or newer is required. The package has no runtime dependencies.
|
|
23
|
+
|
|
24
|
+
## Quick start
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from mcapitr import MCAPIClient
|
|
28
|
+
|
|
29
|
+
with MCAPIClient() as client:
|
|
30
|
+
status = client.server_status("mc.hypixel.net")
|
|
31
|
+
|
|
32
|
+
print(status["online"])
|
|
33
|
+
if status.get("players"):
|
|
34
|
+
print(status["players"]["online"])
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Bedrock and legacy Java queries use explicit flags:
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
bedrock = client.server_status("play.example.net:19132", bedrock=True)
|
|
41
|
+
legacy = client.server_status("old.example.net", legacy=True)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Async usage
|
|
45
|
+
|
|
46
|
+
The async client provides the same API without blocking the event loop:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import asyncio
|
|
50
|
+
|
|
51
|
+
from mcapitr import AsyncMCAPIClient
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def main() -> None:
|
|
55
|
+
async with AsyncMCAPIClient() as client:
|
|
56
|
+
status = await client.server_status("mc.hypixel.net")
|
|
57
|
+
print(status["version"]["name"])
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
asyncio.run(main())
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Discovery and platform statistics
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
with MCAPIClient() as client:
|
|
67
|
+
first_page = client.trends(limit=20)
|
|
68
|
+
statistics = client.stats()
|
|
69
|
+
|
|
70
|
+
for server in first_page["data"]:
|
|
71
|
+
print(server["address"], server["players_online"])
|
|
72
|
+
|
|
73
|
+
if first_page["meta"].get("hasNext"):
|
|
74
|
+
next_page = client.trends(
|
|
75
|
+
limit=20,
|
|
76
|
+
cursor=first_page["meta"]["nextCursor"],
|
|
77
|
+
)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Media URLs and downloads
|
|
81
|
+
|
|
82
|
+
URL helpers do not perform a network request:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
icon_url = client.server_icon_url("mc.hypixel.net", size=128, image_format="webp")
|
|
86
|
+
banner_url = client.server_banner_url("mc.hypixel.net", size="large")
|
|
87
|
+
widget_url = client.widget_url("mc.hypixel.net", size="large", theme="dark")
|
|
88
|
+
motd_url = client.motd_banner_url("&aWelcome to &bMy Server!")
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The matching download methods return `bytes`:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
icon = client.server_icon("mc.hypixel.net", size=128)
|
|
95
|
+
with open("server-icon.png", "wb") as image_file:
|
|
96
|
+
image_file.write(icon)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Errors
|
|
100
|
+
|
|
101
|
+
HTTP and connection failures use dedicated exceptions:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from mcapitr import MCAPIClient, RateLimitError, ServerNotFoundError
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
status = MCAPIClient().server_status("offline.example.net")
|
|
108
|
+
except ServerNotFoundError as error:
|
|
109
|
+
print("Server is offline or unreachable:", error)
|
|
110
|
+
except RateLimitError as error:
|
|
111
|
+
print("Retry after:", error.retry_after)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Available exception classes are `MCAPIError`, `NetworkError`, `APIError`,
|
|
115
|
+
`BadRequestError`, `ServerNotFoundError`, `RateLimitError` and
|
|
116
|
+
`ResponseDecodeError`.
|
|
117
|
+
|
|
118
|
+
## Client options
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
client = MCAPIClient(
|
|
122
|
+
base_url="https://mcapi.tr/api/v1",
|
|
123
|
+
timeout=10.0,
|
|
124
|
+
user_agent="my-discord-bot/1.0",
|
|
125
|
+
)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Development
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
python -m unittest discover -s tests -v
|
|
132
|
+
python -m compileall -q src
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## License
|
|
136
|
+
|
|
137
|
+
MIT
|
|
138
|
+
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mcapitr"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official zero-dependency Python client for the MCAPI.TR Minecraft Server Status API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "MCAPI.TR" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"minecraft",
|
|
15
|
+
"minecraft-api",
|
|
16
|
+
"minecraft-server",
|
|
17
|
+
"server-status",
|
|
18
|
+
"java-edition",
|
|
19
|
+
"bedrock-edition",
|
|
20
|
+
"motd",
|
|
21
|
+
]
|
|
22
|
+
classifiers = [
|
|
23
|
+
"Development Status :: 4 - Beta",
|
|
24
|
+
"Intended Audience :: Developers",
|
|
25
|
+
"License :: OSI Approved :: MIT License",
|
|
26
|
+
"Programming Language :: Python :: 3",
|
|
27
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
28
|
+
"Programming Language :: Python :: 3.9",
|
|
29
|
+
"Programming Language :: Python :: 3.10",
|
|
30
|
+
"Programming Language :: Python :: 3.11",
|
|
31
|
+
"Programming Language :: Python :: 3.12",
|
|
32
|
+
"Programming Language :: Python :: 3.13",
|
|
33
|
+
"Typing :: Typed",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://mcapi.tr"
|
|
38
|
+
Documentation = "https://mcapi.tr/api-docs"
|
|
39
|
+
OpenAPI = "https://mcapi.tr/openapi.json"
|
|
40
|
+
Issues = "https://github.com/Rynix01/mcapitr/issues"
|
|
41
|
+
|
|
42
|
+
[tool.setuptools]
|
|
43
|
+
package-dir = { "" = "src" }
|
|
44
|
+
|
|
45
|
+
[tool.setuptools.packages.find]
|
|
46
|
+
where = ["src"]
|
|
47
|
+
|
|
48
|
+
[tool.setuptools.package-data]
|
|
49
|
+
mcapitr = ["py.typed"]
|
|
50
|
+
|
mcapitr-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from ._version import __version__
|
|
2
|
+
from .client import AsyncMCAPIClient, MCAPIClient
|
|
3
|
+
from .exceptions import (
|
|
4
|
+
APIError,
|
|
5
|
+
BadRequestError,
|
|
6
|
+
MCAPIError,
|
|
7
|
+
NetworkError,
|
|
8
|
+
RateLimitError,
|
|
9
|
+
ResponseDecodeError,
|
|
10
|
+
ServerNotFoundError,
|
|
11
|
+
)
|
|
12
|
+
from .models import (
|
|
13
|
+
HealthResponse,
|
|
14
|
+
Motd,
|
|
15
|
+
PlatformStatistics,
|
|
16
|
+
Players,
|
|
17
|
+
Query,
|
|
18
|
+
ServerStatus,
|
|
19
|
+
TrendMeta,
|
|
20
|
+
TrendResponse,
|
|
21
|
+
TrendServer,
|
|
22
|
+
Version,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"__version__",
|
|
27
|
+
"MCAPIClient",
|
|
28
|
+
"AsyncMCAPIClient",
|
|
29
|
+
"MCAPIError",
|
|
30
|
+
"NetworkError",
|
|
31
|
+
"APIError",
|
|
32
|
+
"BadRequestError",
|
|
33
|
+
"ServerNotFoundError",
|
|
34
|
+
"RateLimitError",
|
|
35
|
+
"ResponseDecodeError",
|
|
36
|
+
"HealthResponse",
|
|
37
|
+
"Query",
|
|
38
|
+
"Players",
|
|
39
|
+
"Version",
|
|
40
|
+
"Motd",
|
|
41
|
+
"ServerStatus",
|
|
42
|
+
"TrendServer",
|
|
43
|
+
"TrendMeta",
|
|
44
|
+
"TrendResponse",
|
|
45
|
+
"PlatformStatistics",
|
|
46
|
+
]
|
|
47
|
+
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from typing import Callable, Mapping, Tuple
|
|
2
|
+
from urllib.error import HTTPError, URLError
|
|
3
|
+
from urllib.request import Request, urlopen
|
|
4
|
+
|
|
5
|
+
from .exceptions import NetworkError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
TransportResponse = Tuple[int, Mapping[str, str], bytes]
|
|
9
|
+
Transport = Callable[[str, float, Mapping[str, str]], TransportResponse]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def default_transport(
|
|
13
|
+
url: str,
|
|
14
|
+
timeout: float,
|
|
15
|
+
headers: Mapping[str, str],
|
|
16
|
+
) -> TransportResponse:
|
|
17
|
+
request = Request(url, headers=dict(headers), method="GET")
|
|
18
|
+
try:
|
|
19
|
+
with urlopen(request, timeout=timeout) as response:
|
|
20
|
+
return (
|
|
21
|
+
int(getattr(response, "status", 200)),
|
|
22
|
+
dict(response.headers.items()),
|
|
23
|
+
response.read(),
|
|
24
|
+
)
|
|
25
|
+
except HTTPError as error:
|
|
26
|
+
return error.code, dict(error.headers.items()), error.read()
|
|
27
|
+
except (URLError, TimeoutError, OSError) as error:
|
|
28
|
+
reason = getattr(error, "reason", error)
|
|
29
|
+
raise NetworkError("Could not reach MCAPI.TR: {}".format(reason)) from error
|
|
30
|
+
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
from typing import Any, Dict, Mapping, Optional, Tuple, Type, TypeVar, cast
|
|
4
|
+
from urllib.parse import quote, urlencode, urlparse
|
|
5
|
+
|
|
6
|
+
from ._transport import Transport, default_transport
|
|
7
|
+
from ._version import __version__
|
|
8
|
+
from .exceptions import (
|
|
9
|
+
APIError,
|
|
10
|
+
BadRequestError,
|
|
11
|
+
RateLimitError,
|
|
12
|
+
ResponseDecodeError,
|
|
13
|
+
ServerNotFoundError,
|
|
14
|
+
)
|
|
15
|
+
from .models import HealthResponse, JSONDict, PlatformStatistics, ServerStatus, TrendResponse
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
DEFAULT_BASE_URL = "https://mcapi.tr/api/v1"
|
|
19
|
+
_BANNER_SIZES = ("small", "normal", "large")
|
|
20
|
+
_BANNER_STYLES = ("modern", "classic")
|
|
21
|
+
_WIDGET_THEMES = ("dark", "light")
|
|
22
|
+
_IMAGE_FORMATS = ("png", "webp")
|
|
23
|
+
|
|
24
|
+
ExceptionType = TypeVar("ExceptionType", bound=APIError)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _address(value: str) -> str:
|
|
28
|
+
if not isinstance(value, str) or not value.strip():
|
|
29
|
+
raise ValueError("address must be a non-empty string")
|
|
30
|
+
return value.strip()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _choice(name: str, value: str, allowed: Tuple[str, ...]) -> str:
|
|
34
|
+
if value not in allowed:
|
|
35
|
+
raise ValueError("{} must be one of: {}".format(name, ", ".join(allowed)))
|
|
36
|
+
return value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _edition_params(legacy: bool, bedrock: bool) -> Dict[str, str]:
|
|
40
|
+
if legacy and bedrock:
|
|
41
|
+
raise ValueError("legacy and bedrock cannot both be true")
|
|
42
|
+
params: Dict[str, str] = {}
|
|
43
|
+
if legacy:
|
|
44
|
+
params["legacy"] = "true"
|
|
45
|
+
if bedrock:
|
|
46
|
+
params["bedrock"] = "true"
|
|
47
|
+
return params
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _header(headers: Mapping[str, str], name: str) -> Optional[str]:
|
|
51
|
+
wanted = name.lower()
|
|
52
|
+
for key, value in headers.items():
|
|
53
|
+
if key.lower() == wanted:
|
|
54
|
+
return value
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class MCAPIClient:
|
|
59
|
+
"""Synchronous client for the public MCAPI.TR API."""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
*,
|
|
64
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
65
|
+
timeout: float = 10.0,
|
|
66
|
+
user_agent: Optional[str] = None,
|
|
67
|
+
transport: Optional[Transport] = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
normalized_base_url = str(base_url).strip().rstrip("/")
|
|
70
|
+
parsed = urlparse(normalized_base_url)
|
|
71
|
+
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
|
72
|
+
raise ValueError("base_url must be an absolute HTTP or HTTPS URL")
|
|
73
|
+
if timeout <= 0:
|
|
74
|
+
raise ValueError("timeout must be greater than zero")
|
|
75
|
+
|
|
76
|
+
self.base_url = normalized_base_url
|
|
77
|
+
self.timeout = float(timeout)
|
|
78
|
+
self.user_agent = user_agent or "mcapitr-python/{}".format(__version__)
|
|
79
|
+
self._transport = transport or default_transport
|
|
80
|
+
|
|
81
|
+
def __enter__(self) -> "MCAPIClient":
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
def _url(self, path: str, params: Optional[Mapping[str, Any]] = None) -> str:
|
|
88
|
+
url = "{}/{}".format(self.base_url, path.lstrip("/"))
|
|
89
|
+
if params:
|
|
90
|
+
normalized = {
|
|
91
|
+
key: str(value).lower() if isinstance(value, bool) else str(value)
|
|
92
|
+
for key, value in params.items()
|
|
93
|
+
if value is not None
|
|
94
|
+
}
|
|
95
|
+
if normalized:
|
|
96
|
+
url = "{}?{}".format(url, urlencode(normalized))
|
|
97
|
+
return url
|
|
98
|
+
|
|
99
|
+
def _error_message(self, status_code: int, body: bytes) -> str:
|
|
100
|
+
try:
|
|
101
|
+
payload = json.loads(body.decode("utf-8"))
|
|
102
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
103
|
+
payload = None
|
|
104
|
+
if isinstance(payload, dict):
|
|
105
|
+
for key in ("message", "error", "status"):
|
|
106
|
+
if payload.get(key):
|
|
107
|
+
return str(payload[key])
|
|
108
|
+
return "MCAPI.TR returned HTTP {}".format(status_code)
|
|
109
|
+
|
|
110
|
+
def _raise_api_error(
|
|
111
|
+
self,
|
|
112
|
+
status_code: int,
|
|
113
|
+
headers: Mapping[str, str],
|
|
114
|
+
body: bytes,
|
|
115
|
+
) -> None:
|
|
116
|
+
message = self._error_message(status_code, body)
|
|
117
|
+
common = {
|
|
118
|
+
"status_code": status_code,
|
|
119
|
+
"response_body": body,
|
|
120
|
+
"headers": headers,
|
|
121
|
+
}
|
|
122
|
+
if status_code == 400:
|
|
123
|
+
raise BadRequestError(message, **common)
|
|
124
|
+
if status_code == 404:
|
|
125
|
+
raise ServerNotFoundError(message, **common)
|
|
126
|
+
if status_code == 429:
|
|
127
|
+
raise RateLimitError(
|
|
128
|
+
message,
|
|
129
|
+
retry_after=_header(headers, "Retry-After"),
|
|
130
|
+
**common,
|
|
131
|
+
)
|
|
132
|
+
raise APIError(message, **common)
|
|
133
|
+
|
|
134
|
+
def _request_bytes(self, url: str) -> bytes:
|
|
135
|
+
status_code, headers, body = self._transport(
|
|
136
|
+
url,
|
|
137
|
+
self.timeout,
|
|
138
|
+
{
|
|
139
|
+
"Accept": "*/*",
|
|
140
|
+
"User-Agent": self.user_agent,
|
|
141
|
+
},
|
|
142
|
+
)
|
|
143
|
+
if status_code < 200 or status_code >= 300:
|
|
144
|
+
self._raise_api_error(status_code, headers, body)
|
|
145
|
+
return body
|
|
146
|
+
|
|
147
|
+
def _request_json(self, path: str, params: Optional[Mapping[str, Any]] = None) -> JSONDict:
|
|
148
|
+
body = self._request_bytes(self._url(path, params))
|
|
149
|
+
try:
|
|
150
|
+
payload = json.loads(body.decode("utf-8"))
|
|
151
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
152
|
+
raise ResponseDecodeError("MCAPI.TR returned invalid JSON") from error
|
|
153
|
+
if not isinstance(payload, dict):
|
|
154
|
+
raise ResponseDecodeError("MCAPI.TR returned JSON with an unexpected shape")
|
|
155
|
+
return cast(JSONDict, payload)
|
|
156
|
+
|
|
157
|
+
def health(self) -> HealthResponse:
|
|
158
|
+
return cast(HealthResponse, self._request_json("status"))
|
|
159
|
+
|
|
160
|
+
def server_status(
|
|
161
|
+
self,
|
|
162
|
+
address: str,
|
|
163
|
+
*,
|
|
164
|
+
legacy: bool = False,
|
|
165
|
+
bedrock: bool = False,
|
|
166
|
+
) -> ServerStatus:
|
|
167
|
+
server_address = quote(_address(address), safe="")
|
|
168
|
+
params = _edition_params(legacy, bedrock)
|
|
169
|
+
return cast(ServerStatus, self._request_json("status/{}".format(server_address), params))
|
|
170
|
+
|
|
171
|
+
def trends(self, *, limit: int = 10, cursor: Optional[str] = None) -> TrendResponse:
|
|
172
|
+
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 50:
|
|
173
|
+
raise ValueError("limit must be an integer between 1 and 50")
|
|
174
|
+
return cast(
|
|
175
|
+
TrendResponse,
|
|
176
|
+
self._request_json("trends", {"limit": limit, "cursor": cursor}),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def stats(self) -> PlatformStatistics:
|
|
180
|
+
return cast(PlatformStatistics, self._request_json("stats"))
|
|
181
|
+
|
|
182
|
+
def server_icon_url(
|
|
183
|
+
self,
|
|
184
|
+
address: str,
|
|
185
|
+
*,
|
|
186
|
+
size: int = 80,
|
|
187
|
+
image_format: str = "png",
|
|
188
|
+
legacy: bool = False,
|
|
189
|
+
bedrock: bool = False,
|
|
190
|
+
) -> str:
|
|
191
|
+
if isinstance(size, bool) or not isinstance(size, int) or not 16 <= size <= 256:
|
|
192
|
+
raise ValueError("size must be an integer between 16 and 256")
|
|
193
|
+
image_format = _choice("image_format", image_format, _IMAGE_FORMATS)
|
|
194
|
+
params: Dict[str, Any] = {
|
|
195
|
+
"address": _address(address),
|
|
196
|
+
"size": size,
|
|
197
|
+
"format": image_format,
|
|
198
|
+
}
|
|
199
|
+
params.update(_edition_params(legacy, bedrock))
|
|
200
|
+
return self._url("icon/dynamic", params)
|
|
201
|
+
|
|
202
|
+
def rounded_icon_url(
|
|
203
|
+
self,
|
|
204
|
+
address: str,
|
|
205
|
+
*,
|
|
206
|
+
size: int = 128,
|
|
207
|
+
legacy: bool = False,
|
|
208
|
+
bedrock: bool = False,
|
|
209
|
+
) -> str:
|
|
210
|
+
if isinstance(size, bool) or not isinstance(size, int) or not 32 <= size <= 512:
|
|
211
|
+
raise ValueError("size must be an integer between 32 and 512")
|
|
212
|
+
params: Dict[str, Any] = {"address": _address(address), "size": size}
|
|
213
|
+
params.update(_edition_params(legacy, bedrock))
|
|
214
|
+
return self._url("icon/sharp", params)
|
|
215
|
+
|
|
216
|
+
def server_banner_url(
|
|
217
|
+
self,
|
|
218
|
+
address: str,
|
|
219
|
+
*,
|
|
220
|
+
size: str = "normal",
|
|
221
|
+
style: str = "modern",
|
|
222
|
+
) -> str:
|
|
223
|
+
size = _choice("size", "normal" if size == "medium" else size, _BANNER_SIZES)
|
|
224
|
+
style = _choice("style", style, _BANNER_STYLES)
|
|
225
|
+
return self._url(
|
|
226
|
+
"banner/{}".format(quote(_address(address), safe="")),
|
|
227
|
+
{"size": size, "style": style},
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
def motd_banner_url(
|
|
231
|
+
self,
|
|
232
|
+
motd: str,
|
|
233
|
+
*,
|
|
234
|
+
text_color: Optional[str] = None,
|
|
235
|
+
accent_color: Optional[str] = None,
|
|
236
|
+
) -> str:
|
|
237
|
+
if not isinstance(motd, str) or not motd.strip():
|
|
238
|
+
raise ValueError("motd must be a non-empty string")
|
|
239
|
+
if len(motd) > 300:
|
|
240
|
+
raise ValueError("motd cannot be longer than 300 characters")
|
|
241
|
+
return self._url(
|
|
242
|
+
"banner/motd",
|
|
243
|
+
{
|
|
244
|
+
"motd": motd,
|
|
245
|
+
"textColor": text_color,
|
|
246
|
+
"accentColor": accent_color,
|
|
247
|
+
},
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
def widget_url(
|
|
251
|
+
self,
|
|
252
|
+
address: str,
|
|
253
|
+
*,
|
|
254
|
+
size: str = "large",
|
|
255
|
+
theme: str = "dark",
|
|
256
|
+
) -> str:
|
|
257
|
+
size = _choice("size", "normal" if size == "medium" else size, _BANNER_SIZES)
|
|
258
|
+
theme = _choice("theme", theme, _WIDGET_THEMES)
|
|
259
|
+
return self._url(
|
|
260
|
+
"widget/{}/{}".format(size, quote(_address(address), safe="")),
|
|
261
|
+
{"theme": theme},
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
def server_icon(self, address: str, **options: Any) -> bytes:
|
|
265
|
+
return self._request_bytes(self.server_icon_url(address, **options))
|
|
266
|
+
|
|
267
|
+
def rounded_icon(self, address: str, **options: Any) -> bytes:
|
|
268
|
+
return self._request_bytes(self.rounded_icon_url(address, **options))
|
|
269
|
+
|
|
270
|
+
def server_banner(self, address: str, **options: Any) -> bytes:
|
|
271
|
+
return self._request_bytes(self.server_banner_url(address, **options))
|
|
272
|
+
|
|
273
|
+
def motd_banner(self, motd: str, **options: Any) -> bytes:
|
|
274
|
+
return self._request_bytes(self.motd_banner_url(motd, **options))
|
|
275
|
+
|
|
276
|
+
def widget(self, address: str, **options: Any) -> str:
|
|
277
|
+
body = self._request_bytes(self.widget_url(address, **options))
|
|
278
|
+
try:
|
|
279
|
+
return body.decode("utf-8")
|
|
280
|
+
except UnicodeDecodeError as error:
|
|
281
|
+
raise ResponseDecodeError("MCAPI.TR returned invalid widget HTML") from error
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class AsyncMCAPIClient:
|
|
285
|
+
"""Async wrapper with the same behavior as :class:`MCAPIClient`."""
|
|
286
|
+
|
|
287
|
+
def __init__(self, **options: Any) -> None:
|
|
288
|
+
self._client = MCAPIClient(**options)
|
|
289
|
+
|
|
290
|
+
@property
|
|
291
|
+
def base_url(self) -> str:
|
|
292
|
+
return self._client.base_url
|
|
293
|
+
|
|
294
|
+
async def __aenter__(self) -> "AsyncMCAPIClient":
|
|
295
|
+
return self
|
|
296
|
+
|
|
297
|
+
async def __aexit__(self, exc_type: object, exc: object, traceback: object) -> None:
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
async def health(self) -> HealthResponse:
|
|
301
|
+
return await asyncio.to_thread(self._client.health)
|
|
302
|
+
|
|
303
|
+
async def server_status(self, address: str, **options: Any) -> ServerStatus:
|
|
304
|
+
return await asyncio.to_thread(self._client.server_status, address, **options)
|
|
305
|
+
|
|
306
|
+
async def trends(self, *, limit: int = 10, cursor: Optional[str] = None) -> TrendResponse:
|
|
307
|
+
return await asyncio.to_thread(self._client.trends, limit=limit, cursor=cursor)
|
|
308
|
+
|
|
309
|
+
async def stats(self) -> PlatformStatistics:
|
|
310
|
+
return await asyncio.to_thread(self._client.stats)
|
|
311
|
+
|
|
312
|
+
def server_icon_url(self, address: str, **options: Any) -> str:
|
|
313
|
+
return self._client.server_icon_url(address, **options)
|
|
314
|
+
|
|
315
|
+
def rounded_icon_url(self, address: str, **options: Any) -> str:
|
|
316
|
+
return self._client.rounded_icon_url(address, **options)
|
|
317
|
+
|
|
318
|
+
def server_banner_url(self, address: str, **options: Any) -> str:
|
|
319
|
+
return self._client.server_banner_url(address, **options)
|
|
320
|
+
|
|
321
|
+
def motd_banner_url(self, motd: str, **options: Any) -> str:
|
|
322
|
+
return self._client.motd_banner_url(motd, **options)
|
|
323
|
+
|
|
324
|
+
def widget_url(self, address: str, **options: Any) -> str:
|
|
325
|
+
return self._client.widget_url(address, **options)
|
|
326
|
+
|
|
327
|
+
async def server_icon(self, address: str, **options: Any) -> bytes:
|
|
328
|
+
return await asyncio.to_thread(self._client.server_icon, address, **options)
|
|
329
|
+
|
|
330
|
+
async def rounded_icon(self, address: str, **options: Any) -> bytes:
|
|
331
|
+
return await asyncio.to_thread(self._client.rounded_icon, address, **options)
|
|
332
|
+
|
|
333
|
+
async def server_banner(self, address: str, **options: Any) -> bytes:
|
|
334
|
+
return await asyncio.to_thread(self._client.server_banner, address, **options)
|
|
335
|
+
|
|
336
|
+
async def motd_banner(self, motd: str, **options: Any) -> bytes:
|
|
337
|
+
return await asyncio.to_thread(self._client.motd_banner, motd, **options)
|
|
338
|
+
|
|
339
|
+
async def widget(self, address: str, **options: Any) -> str:
|
|
340
|
+
return await asyncio.to_thread(self._client.widget, address, **options)
|
|
341
|
+
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from typing import Mapping, Optional
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class MCAPIError(Exception):
|
|
5
|
+
"""Base exception for all SDK errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NetworkError(MCAPIError):
|
|
9
|
+
"""The API could not be reached."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ResponseDecodeError(MCAPIError):
|
|
13
|
+
"""The API returned a response that could not be decoded."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class APIError(MCAPIError):
|
|
17
|
+
"""The API returned a non-success HTTP response."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
message: str,
|
|
22
|
+
*,
|
|
23
|
+
status_code: int,
|
|
24
|
+
response_body: bytes = b"",
|
|
25
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
super().__init__(message)
|
|
28
|
+
self.status_code = status_code
|
|
29
|
+
self.response_body = response_body
|
|
30
|
+
self.headers = dict(headers or {})
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class BadRequestError(APIError):
|
|
34
|
+
"""A request parameter was rejected by the API."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ServerNotFoundError(APIError):
|
|
38
|
+
"""The requested Minecraft server is offline or unreachable."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RateLimitError(APIError):
|
|
42
|
+
"""The API request limit has been exceeded."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
message: str,
|
|
47
|
+
*,
|
|
48
|
+
status_code: int,
|
|
49
|
+
response_body: bytes = b"",
|
|
50
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
51
|
+
retry_after: Optional[str] = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
super().__init__(
|
|
54
|
+
message,
|
|
55
|
+
status_code=status_code,
|
|
56
|
+
response_body=response_body,
|
|
57
|
+
headers=headers,
|
|
58
|
+
)
|
|
59
|
+
self.retry_after = retry_after
|
|
60
|
+
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Optional, TypedDict
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
JSONDict = Dict[str, Any]
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class HealthResponse(TypedDict, total=False):
|
|
8
|
+
status: str
|
|
9
|
+
timestamp: str
|
|
10
|
+
service: str
|
|
11
|
+
version: str
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Query(TypedDict, total=False):
|
|
15
|
+
host: str
|
|
16
|
+
port: int
|
|
17
|
+
legacy: bool
|
|
18
|
+
bedrock: bool
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PlayerSample(TypedDict, total=False):
|
|
22
|
+
name: str
|
|
23
|
+
id: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Players(TypedDict, total=False):
|
|
27
|
+
online: int
|
|
28
|
+
max: int
|
|
29
|
+
sample: List[PlayerSample]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Version(TypedDict, total=False):
|
|
33
|
+
name: str
|
|
34
|
+
protocol: int
|
|
35
|
+
name_clean: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Motd(TypedDict, total=False):
|
|
39
|
+
raw: str
|
|
40
|
+
clean: str
|
|
41
|
+
html: str
|
|
42
|
+
raw_lines: List[str]
|
|
43
|
+
clean_lines: List[str]
|
|
44
|
+
html_lines: List[str]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ServerStatus(TypedDict, total=False):
|
|
48
|
+
query: Query
|
|
49
|
+
server_id: Optional[str]
|
|
50
|
+
ip_address: Optional[str]
|
|
51
|
+
icmp: Optional[bool]
|
|
52
|
+
online: bool
|
|
53
|
+
error: Optional[str]
|
|
54
|
+
version: Optional[Version]
|
|
55
|
+
players: Optional[Players]
|
|
56
|
+
motd: Optional[Motd]
|
|
57
|
+
favicon: Optional[str]
|
|
58
|
+
roundTripLatency: Optional[float]
|
|
59
|
+
stale: bool
|
|
60
|
+
checked_at: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TrendServer(TypedDict, total=False):
|
|
64
|
+
server_id: str
|
|
65
|
+
hostname: str
|
|
66
|
+
address: str
|
|
67
|
+
port: int
|
|
68
|
+
edition: str
|
|
69
|
+
trend_score: float
|
|
70
|
+
daily_growth: float
|
|
71
|
+
is_online: bool
|
|
72
|
+
stale: bool
|
|
73
|
+
checked_at: str
|
|
74
|
+
players_online: int
|
|
75
|
+
players_max: int
|
|
76
|
+
version: Optional[str]
|
|
77
|
+
favicon: Optional[str]
|
|
78
|
+
record_players: int
|
|
79
|
+
uptime_percentage: float
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class TrendMeta(TypedDict, total=False):
|
|
83
|
+
nextCursor: Optional[str]
|
|
84
|
+
hasNext: bool
|
|
85
|
+
limit: int
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class TrendResponse(TypedDict, total=False):
|
|
89
|
+
success: bool
|
|
90
|
+
data: List[TrendServer]
|
|
91
|
+
meta: TrendMeta
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class PlatformStatistics(TypedDict, total=False):
|
|
95
|
+
totalRequests: int
|
|
96
|
+
apiRequests: int
|
|
97
|
+
serverQueries: int
|
|
98
|
+
totalChecks: int
|
|
99
|
+
onlineServers: int
|
|
100
|
+
offlineServers: int
|
|
101
|
+
activePlayers: int
|
|
102
|
+
recentChecks: List[JSONDict]
|
|
103
|
+
onlineRate: float
|
|
104
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcapitr
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official zero-dependency Python client for the MCAPI.TR Minecraft Server Status API
|
|
5
|
+
Author: MCAPI.TR
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://mcapi.tr
|
|
8
|
+
Project-URL: Documentation, https://mcapi.tr/api-docs
|
|
9
|
+
Project-URL: OpenAPI, https://mcapi.tr/openapi.json
|
|
10
|
+
Project-URL: Issues, https://github.com/Rynix01/mcapitr/issues
|
|
11
|
+
Keywords: minecraft,minecraft-api,minecraft-server,server-status,java-edition,bedrock-edition,motd
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# MCAPI.TR Python SDK
|
|
29
|
+
|
|
30
|
+
The official, typed and zero-dependency Python client for the
|
|
31
|
+
[MCAPI.TR Minecraft Server Status API](https://mcapi.tr/api-docs).
|
|
32
|
+
|
|
33
|
+
It supports Minecraft Java, legacy Java and Bedrock servers, synchronous and
|
|
34
|
+
asynchronous applications, discovery endpoints, generated banners, icons and
|
|
35
|
+
embeddable widgets.
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install mcapitr
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
For local development:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install -e .
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Python 3.9 or newer is required. The package has no runtime dependencies.
|
|
50
|
+
|
|
51
|
+
## Quick start
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from mcapitr import MCAPIClient
|
|
55
|
+
|
|
56
|
+
with MCAPIClient() as client:
|
|
57
|
+
status = client.server_status("mc.hypixel.net")
|
|
58
|
+
|
|
59
|
+
print(status["online"])
|
|
60
|
+
if status.get("players"):
|
|
61
|
+
print(status["players"]["online"])
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Bedrock and legacy Java queries use explicit flags:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
bedrock = client.server_status("play.example.net:19132", bedrock=True)
|
|
68
|
+
legacy = client.server_status("old.example.net", legacy=True)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Async usage
|
|
72
|
+
|
|
73
|
+
The async client provides the same API without blocking the event loop:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
import asyncio
|
|
77
|
+
|
|
78
|
+
from mcapitr import AsyncMCAPIClient
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def main() -> None:
|
|
82
|
+
async with AsyncMCAPIClient() as client:
|
|
83
|
+
status = await client.server_status("mc.hypixel.net")
|
|
84
|
+
print(status["version"]["name"])
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
asyncio.run(main())
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Discovery and platform statistics
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
with MCAPIClient() as client:
|
|
94
|
+
first_page = client.trends(limit=20)
|
|
95
|
+
statistics = client.stats()
|
|
96
|
+
|
|
97
|
+
for server in first_page["data"]:
|
|
98
|
+
print(server["address"], server["players_online"])
|
|
99
|
+
|
|
100
|
+
if first_page["meta"].get("hasNext"):
|
|
101
|
+
next_page = client.trends(
|
|
102
|
+
limit=20,
|
|
103
|
+
cursor=first_page["meta"]["nextCursor"],
|
|
104
|
+
)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Media URLs and downloads
|
|
108
|
+
|
|
109
|
+
URL helpers do not perform a network request:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
icon_url = client.server_icon_url("mc.hypixel.net", size=128, image_format="webp")
|
|
113
|
+
banner_url = client.server_banner_url("mc.hypixel.net", size="large")
|
|
114
|
+
widget_url = client.widget_url("mc.hypixel.net", size="large", theme="dark")
|
|
115
|
+
motd_url = client.motd_banner_url("&aWelcome to &bMy Server!")
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The matching download methods return `bytes`:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
icon = client.server_icon("mc.hypixel.net", size=128)
|
|
122
|
+
with open("server-icon.png", "wb") as image_file:
|
|
123
|
+
image_file.write(icon)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Errors
|
|
127
|
+
|
|
128
|
+
HTTP and connection failures use dedicated exceptions:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
from mcapitr import MCAPIClient, RateLimitError, ServerNotFoundError
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
status = MCAPIClient().server_status("offline.example.net")
|
|
135
|
+
except ServerNotFoundError as error:
|
|
136
|
+
print("Server is offline or unreachable:", error)
|
|
137
|
+
except RateLimitError as error:
|
|
138
|
+
print("Retry after:", error.retry_after)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Available exception classes are `MCAPIError`, `NetworkError`, `APIError`,
|
|
142
|
+
`BadRequestError`, `ServerNotFoundError`, `RateLimitError` and
|
|
143
|
+
`ResponseDecodeError`.
|
|
144
|
+
|
|
145
|
+
## Client options
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
client = MCAPIClient(
|
|
149
|
+
base_url="https://mcapi.tr/api/v1",
|
|
150
|
+
timeout=10.0,
|
|
151
|
+
user_agent="my-discord-bot/1.0",
|
|
152
|
+
)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Development
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
python -m unittest discover -s tests -v
|
|
159
|
+
python -m compileall -q src
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## License
|
|
163
|
+
|
|
164
|
+
MIT
|
|
165
|
+
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/mcapitr/__init__.py
|
|
5
|
+
src/mcapitr/_transport.py
|
|
6
|
+
src/mcapitr/_version.py
|
|
7
|
+
src/mcapitr/client.py
|
|
8
|
+
src/mcapitr/exceptions.py
|
|
9
|
+
src/mcapitr/models.py
|
|
10
|
+
src/mcapitr/py.typed
|
|
11
|
+
src/mcapitr.egg-info/PKG-INFO
|
|
12
|
+
src/mcapitr.egg-info/SOURCES.txt
|
|
13
|
+
src/mcapitr.egg-info/dependency_links.txt
|
|
14
|
+
src/mcapitr.egg-info/top_level.txt
|
|
15
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mcapitr
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
import unittest
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Mapping, Tuple
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
10
|
+
|
|
11
|
+
from mcapitr import ( # noqa: E402
|
|
12
|
+
AsyncMCAPIClient,
|
|
13
|
+
MCAPIClient,
|
|
14
|
+
RateLimitError,
|
|
15
|
+
ResponseDecodeError,
|
|
16
|
+
ServerNotFoundError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class StubTransport:
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
status: int = 200,
|
|
24
|
+
payload: object = None,
|
|
25
|
+
headers: Mapping[str, str] = None,
|
|
26
|
+
raw_body: bytes = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
self.status = status
|
|
29
|
+
self.payload = {} if payload is None else payload
|
|
30
|
+
self.headers = dict(headers or {})
|
|
31
|
+
self.raw_body = raw_body
|
|
32
|
+
self.calls = []
|
|
33
|
+
|
|
34
|
+
def __call__(
|
|
35
|
+
self,
|
|
36
|
+
url: str,
|
|
37
|
+
timeout: float,
|
|
38
|
+
headers: Mapping[str, str],
|
|
39
|
+
) -> Tuple[int, Mapping[str, str], bytes]:
|
|
40
|
+
self.calls.append((url, timeout, dict(headers)))
|
|
41
|
+
body = self.raw_body
|
|
42
|
+
if body is None:
|
|
43
|
+
body = json.dumps(self.payload).encode("utf-8")
|
|
44
|
+
return self.status, self.headers, body
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ClientTests(unittest.TestCase):
|
|
48
|
+
def test_server_status_encodes_address_and_flags(self) -> None:
|
|
49
|
+
transport = StubTransport(payload={"online": True, "query": {}})
|
|
50
|
+
client = MCAPIClient(transport=transport)
|
|
51
|
+
|
|
52
|
+
response = client.server_status(" play.example.net:19132 ", bedrock=True)
|
|
53
|
+
|
|
54
|
+
self.assertTrue(response["online"])
|
|
55
|
+
self.assertEqual(
|
|
56
|
+
transport.calls[0][0],
|
|
57
|
+
"https://mcapi.tr/api/v1/status/play.example.net%3A19132?bedrock=true",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def test_health_and_stats_use_v1_routes(self) -> None:
|
|
61
|
+
transport = StubTransport(payload={"status": "OK"})
|
|
62
|
+
client = MCAPIClient(transport=transport)
|
|
63
|
+
|
|
64
|
+
client.health()
|
|
65
|
+
client.stats()
|
|
66
|
+
|
|
67
|
+
self.assertEqual(transport.calls[0][0], "https://mcapi.tr/api/v1/status")
|
|
68
|
+
self.assertEqual(transport.calls[1][0], "https://mcapi.tr/api/v1/stats")
|
|
69
|
+
|
|
70
|
+
def test_trends_validates_and_builds_cursor_query(self) -> None:
|
|
71
|
+
transport = StubTransport(payload={"success": True, "data": [], "meta": {}})
|
|
72
|
+
client = MCAPIClient(transport=transport)
|
|
73
|
+
|
|
74
|
+
client.trends(limit=25, cursor="4ce8f89c-463f-44e9-a757-b6d540a53038")
|
|
75
|
+
|
|
76
|
+
self.assertEqual(
|
|
77
|
+
transport.calls[0][0],
|
|
78
|
+
"https://mcapi.tr/api/v1/trends?limit=25&cursor=4ce8f89c-463f-44e9-a757-b6d540a53038",
|
|
79
|
+
)
|
|
80
|
+
with self.assertRaises(ValueError):
|
|
81
|
+
client.trends(limit=0)
|
|
82
|
+
|
|
83
|
+
def test_edition_flags_are_mutually_exclusive(self) -> None:
|
|
84
|
+
client = MCAPIClient(transport=StubTransport())
|
|
85
|
+
with self.assertRaisesRegex(ValueError, "cannot both be true"):
|
|
86
|
+
client.server_status("mc.example.net", legacy=True, bedrock=True)
|
|
87
|
+
|
|
88
|
+
def test_media_urls_are_validated_and_encoded(self) -> None:
|
|
89
|
+
client = MCAPIClient(transport=StubTransport())
|
|
90
|
+
|
|
91
|
+
self.assertEqual(
|
|
92
|
+
client.server_icon_url("mc.example.net", size=128, image_format="webp"),
|
|
93
|
+
"https://mcapi.tr/api/v1/icon/dynamic?address=mc.example.net&size=128&format=webp",
|
|
94
|
+
)
|
|
95
|
+
self.assertEqual(
|
|
96
|
+
client.server_banner_url("mc.example.net", size="medium"),
|
|
97
|
+
"https://mcapi.tr/api/v1/banner/mc.example.net?size=normal&style=modern",
|
|
98
|
+
)
|
|
99
|
+
with self.assertRaises(ValueError):
|
|
100
|
+
client.widget_url("mc.example.net", theme="blue")
|
|
101
|
+
|
|
102
|
+
def test_not_found_response_has_specific_exception(self) -> None:
|
|
103
|
+
transport = StubTransport(status=404, payload={"error": "Server unreachable"})
|
|
104
|
+
client = MCAPIClient(transport=transport)
|
|
105
|
+
|
|
106
|
+
with self.assertRaises(ServerNotFoundError) as raised:
|
|
107
|
+
client.server_status("offline.example.net")
|
|
108
|
+
|
|
109
|
+
self.assertEqual(raised.exception.status_code, 404)
|
|
110
|
+
self.assertEqual(str(raised.exception), "Server unreachable")
|
|
111
|
+
|
|
112
|
+
def test_rate_limit_includes_retry_after(self) -> None:
|
|
113
|
+
transport = StubTransport(
|
|
114
|
+
status=429,
|
|
115
|
+
payload={"message": "Too many requests"},
|
|
116
|
+
headers={"retry-after": "12"},
|
|
117
|
+
)
|
|
118
|
+
client = MCAPIClient(transport=transport)
|
|
119
|
+
|
|
120
|
+
with self.assertRaises(RateLimitError) as raised:
|
|
121
|
+
client.stats()
|
|
122
|
+
|
|
123
|
+
self.assertEqual(raised.exception.retry_after, "12")
|
|
124
|
+
|
|
125
|
+
def test_invalid_json_raises_decode_error(self) -> None:
|
|
126
|
+
client = MCAPIClient(transport=StubTransport(raw_body=b"not-json"))
|
|
127
|
+
with self.assertRaises(ResponseDecodeError):
|
|
128
|
+
client.health()
|
|
129
|
+
|
|
130
|
+
def test_binary_download_returns_bytes(self) -> None:
|
|
131
|
+
transport = StubTransport(raw_body=b"\x89PNG\r\n")
|
|
132
|
+
client = MCAPIClient(transport=transport)
|
|
133
|
+
self.assertEqual(client.server_icon("mc.example.net"), b"\x89PNG\r\n")
|
|
134
|
+
|
|
135
|
+
def test_async_client_matches_sync_behavior(self) -> None:
|
|
136
|
+
transport = StubTransport(payload={"online": True, "query": {}})
|
|
137
|
+
|
|
138
|
+
async def run() -> None:
|
|
139
|
+
async with AsyncMCAPIClient(transport=transport) as client:
|
|
140
|
+
result = await client.server_status("mc.example.net")
|
|
141
|
+
self.assertTrue(result["online"])
|
|
142
|
+
|
|
143
|
+
asyncio.run(run())
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if __name__ == "__main__":
|
|
147
|
+
unittest.main()
|
|
148
|
+
|