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 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
+
@@ -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
+
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ __version__ = "0.1.0"
2
+
@@ -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
+ 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
+