mcapitr 0.1.0__py3-none-any.whl
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/__init__.py +47 -0
- mcapitr/_transport.py +30 -0
- mcapitr/_version.py +2 -0
- mcapitr/client.py +341 -0
- mcapitr/exceptions.py +60 -0
- mcapitr/models.py +104 -0
- mcapitr/py.typed +1 -0
- mcapitr-0.1.0.dist-info/METADATA +165 -0
- mcapitr-0.1.0.dist-info/RECORD +12 -0
- mcapitr-0.1.0.dist-info/WHEEL +5 -0
- mcapitr-0.1.0.dist-info/licenses/LICENSE +22 -0
- mcapitr-0.1.0.dist-info/top_level.txt +1 -0
mcapitr/__init__.py
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
|
+
|
mcapitr/_transport.py
ADDED
|
@@ -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
|
+
|
mcapitr/_version.py
ADDED
mcapitr/client.py
ADDED
|
@@ -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
|
+
|
mcapitr/exceptions.py
ADDED
|
@@ -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
|
+
|
mcapitr/models.py
ADDED
|
@@ -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
|
+
|
mcapitr/py.typed
ADDED
|
@@ -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,12 @@
|
|
|
1
|
+
mcapitr/__init__.py,sha256=GpFPIw_lr57HSY_9rvrS-6Y2HczJFFH5Vt_i9mdl0hM,846
|
|
2
|
+
mcapitr/_transport.py,sha256=IS-QrpTbxeNIZokY6y8Xtn2xH6hW_FWcUoWmeH-tsd4,1012
|
|
3
|
+
mcapitr/_version.py,sha256=p4MxVO8ih2-hnh7INVBkrqMKBDGTkMso1LEbM6mx3jg,23
|
|
4
|
+
mcapitr/client.py,sha256=m9at8BeeH7tQkJGS4pBjqMt_nj9lW2jn0exRD8uBWos,12316
|
|
5
|
+
mcapitr/exceptions.py,sha256=a-5YCed_VFlTC9W4qmqxhI6afJBP-5aHdomnmifuCGk,1459
|
|
6
|
+
mcapitr/models.py,sha256=nzvCvjU4NcVEZwDxhSIsV_4wycRCiQrzWA3PO4JWQgs,1974
|
|
7
|
+
mcapitr/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
8
|
+
mcapitr-0.1.0.dist-info/licenses/LICENSE,sha256=L0BKyDj5B8V_Vqj_SdgeVwaESVT2-X75GtyA1ypNShM,1066
|
|
9
|
+
mcapitr-0.1.0.dist-info/METADATA,sha256=B-KXqFFIHEieYEqO-mWBnDMhH6NHFUyB6p4xgJpowN4,4389
|
|
10
|
+
mcapitr-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
11
|
+
mcapitr-0.1.0.dist-info/top_level.txt,sha256=_xygevqkfsY8TbBubWK0TqSfid2-FvJQsZ74LWCfjM0,8
|
|
12
|
+
mcapitr-0.1.0.dist-info/RECORD,,
|
|
@@ -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
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mcapitr
|