prcpy-erlc 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.
- prcpy/__init__.py +51 -0
- prcpy/client.py +360 -0
- prcpy/errors.py +58 -0
- prcpy/models.py +265 -0
- prcpy/py.typed +0 -0
- prcpy/rate_limits.py +31 -0
- prcpy/webhooks.py +61 -0
- prcpy_erlc-0.1.0.dist-info/METADATA +248 -0
- prcpy_erlc-0.1.0.dist-info/RECORD +11 -0
- prcpy_erlc-0.1.0.dist-info/WHEEL +4 -0
- prcpy_erlc-0.1.0.dist-info/licenses/LICENSE +21 -0
prcpy/__init__.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from .client import AsyncClient, Client
|
|
2
|
+
from .errors import (
|
|
3
|
+
APIError,
|
|
4
|
+
AuthenticationError,
|
|
5
|
+
BadRequestError,
|
|
6
|
+
CommandError,
|
|
7
|
+
ERLCError,
|
|
8
|
+
RateLimitError,
|
|
9
|
+
ServerOfflineError,
|
|
10
|
+
)
|
|
11
|
+
from .models import (
|
|
12
|
+
CommandLog,
|
|
13
|
+
CommandResult,
|
|
14
|
+
EmergencyCall,
|
|
15
|
+
JoinLog,
|
|
16
|
+
KillLog,
|
|
17
|
+
Location,
|
|
18
|
+
ModCall,
|
|
19
|
+
Player,
|
|
20
|
+
Server,
|
|
21
|
+
Staff,
|
|
22
|
+
Vehicle,
|
|
23
|
+
)
|
|
24
|
+
from .rate_limits import RateLimitInfo
|
|
25
|
+
from .webhooks import WebhookVerificationError, verify_signature
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"APIError",
|
|
29
|
+
"AsyncClient",
|
|
30
|
+
"AuthenticationError",
|
|
31
|
+
"BadRequestError",
|
|
32
|
+
"Client",
|
|
33
|
+
"CommandError",
|
|
34
|
+
"CommandLog",
|
|
35
|
+
"CommandResult",
|
|
36
|
+
"ERLCError",
|
|
37
|
+
"EmergencyCall",
|
|
38
|
+
"JoinLog",
|
|
39
|
+
"KillLog",
|
|
40
|
+
"Location",
|
|
41
|
+
"ModCall",
|
|
42
|
+
"Player",
|
|
43
|
+
"RateLimitError",
|
|
44
|
+
"RateLimitInfo",
|
|
45
|
+
"Server",
|
|
46
|
+
"ServerOfflineError",
|
|
47
|
+
"Staff",
|
|
48
|
+
"Vehicle",
|
|
49
|
+
"WebhookVerificationError",
|
|
50
|
+
"verify_signature",
|
|
51
|
+
]
|
prcpy/client.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from typing_extensions import Self
|
|
10
|
+
|
|
11
|
+
from .errors import (
|
|
12
|
+
APIError,
|
|
13
|
+
AuthenticationError,
|
|
14
|
+
BadRequestError,
|
|
15
|
+
CommandError,
|
|
16
|
+
RateLimitError,
|
|
17
|
+
ServerOfflineError,
|
|
18
|
+
)
|
|
19
|
+
from .models import (
|
|
20
|
+
CommandLog,
|
|
21
|
+
CommandResult,
|
|
22
|
+
JoinLog,
|
|
23
|
+
KillLog,
|
|
24
|
+
ModCall,
|
|
25
|
+
Player,
|
|
26
|
+
Server,
|
|
27
|
+
Staff,
|
|
28
|
+
Vehicle,
|
|
29
|
+
)
|
|
30
|
+
from .rate_limits import RateLimitInfo
|
|
31
|
+
|
|
32
|
+
BASE_URL = "https://api.erlc.gg"
|
|
33
|
+
|
|
34
|
+
_INCLUDE_MAP = {
|
|
35
|
+
"players": "Players",
|
|
36
|
+
"staff": "Staff",
|
|
37
|
+
"join_logs": "JoinLogs",
|
|
38
|
+
"queue": "Queue",
|
|
39
|
+
"kill_logs": "KillLogs",
|
|
40
|
+
"command_logs": "CommandLogs",
|
|
41
|
+
"mod_calls": "ModCalls",
|
|
42
|
+
"emergency_calls": "EmergencyCalls",
|
|
43
|
+
"vehicles": "Vehicles",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _ClientBase:
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
server_key: str,
|
|
51
|
+
*,
|
|
52
|
+
global_api_key: str | None = None,
|
|
53
|
+
base_url: str = BASE_URL,
|
|
54
|
+
timeout: float | httpx.Timeout = 30.0,
|
|
55
|
+
) -> None:
|
|
56
|
+
if not server_key.strip():
|
|
57
|
+
raise ValueError("server_key must not be empty")
|
|
58
|
+
self.server_key = server_key
|
|
59
|
+
self.global_api_key = global_api_key
|
|
60
|
+
self.base_url = base_url.rstrip("/")
|
|
61
|
+
self.timeout = timeout
|
|
62
|
+
self.rate_limit: RateLimitInfo | None = None
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def from_env(cls, **kwargs: Any):
|
|
66
|
+
server_key = os.getenv("ERLC_SERVER_KEY")
|
|
67
|
+
if not server_key:
|
|
68
|
+
raise ValueError("ERLC_SERVER_KEY is not set")
|
|
69
|
+
return cls(
|
|
70
|
+
server_key,
|
|
71
|
+
global_api_key=os.getenv("ERLC_GLOBAL_API_KEY"),
|
|
72
|
+
**kwargs,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def _headers(self) -> dict[str, str]:
|
|
76
|
+
headers = {
|
|
77
|
+
"Accept": "application/json",
|
|
78
|
+
"server-key": self.server_key,
|
|
79
|
+
"User-Agent": "prcpy/0.1.0",
|
|
80
|
+
}
|
|
81
|
+
if self.global_api_key:
|
|
82
|
+
headers["Authorization"] = self.global_api_key
|
|
83
|
+
return headers
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def _include_params(flags: dict[str, bool]) -> dict[str, str]:
|
|
87
|
+
unknown = set(flags) - set(_INCLUDE_MAP)
|
|
88
|
+
if unknown:
|
|
89
|
+
names = ", ".join(sorted(unknown))
|
|
90
|
+
raise ValueError(f"Unknown server include(s): {names}")
|
|
91
|
+
return {
|
|
92
|
+
_INCLUDE_MAP[name]: "true"
|
|
93
|
+
for name, enabled in flags.items()
|
|
94
|
+
if enabled
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
def _raise_for_error(self, response: httpx.Response) -> None:
|
|
98
|
+
if response.is_success:
|
|
99
|
+
return
|
|
100
|
+
try:
|
|
101
|
+
payload = response.json()
|
|
102
|
+
except ValueError:
|
|
103
|
+
payload = None
|
|
104
|
+
|
|
105
|
+
if isinstance(payload, dict):
|
|
106
|
+
message = payload.get("message") or payload.get("error") or response.text
|
|
107
|
+
error_code = payload.get("code")
|
|
108
|
+
command_id = payload.get("commandId")
|
|
109
|
+
else:
|
|
110
|
+
message = response.text or response.reason_phrase
|
|
111
|
+
error_code = None
|
|
112
|
+
command_id = None
|
|
113
|
+
|
|
114
|
+
common = {
|
|
115
|
+
"status_code": response.status_code,
|
|
116
|
+
"error_code": error_code,
|
|
117
|
+
"details": payload,
|
|
118
|
+
"command_id": command_id,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if response.status_code == 403:
|
|
122
|
+
raise AuthenticationError(message, **common)
|
|
123
|
+
if response.status_code == 400:
|
|
124
|
+
raise BadRequestError(message, **common)
|
|
125
|
+
if response.status_code == 429:
|
|
126
|
+
retry_after = response.headers.get("Retry-After")
|
|
127
|
+
try:
|
|
128
|
+
retry_after_value = float(retry_after) if retry_after else None
|
|
129
|
+
except ValueError:
|
|
130
|
+
retry_after_value = None
|
|
131
|
+
raise RateLimitError(
|
|
132
|
+
message,
|
|
133
|
+
retry_after=retry_after_value,
|
|
134
|
+
bucket=self.rate_limit.bucket if self.rate_limit else None,
|
|
135
|
+
**common,
|
|
136
|
+
)
|
|
137
|
+
if response.status_code == 422:
|
|
138
|
+
raise ServerOfflineError(message, **common)
|
|
139
|
+
if response.request.method == "POST" and response.request.url.path.endswith("/command"):
|
|
140
|
+
raise CommandError(message, **common)
|
|
141
|
+
raise APIError(message, **common)
|
|
142
|
+
|
|
143
|
+
@staticmethod
|
|
144
|
+
def _json(response: httpx.Response) -> Any:
|
|
145
|
+
if not response.content:
|
|
146
|
+
return None
|
|
147
|
+
try:
|
|
148
|
+
return response.json()
|
|
149
|
+
except ValueError as exc:
|
|
150
|
+
raise APIError(
|
|
151
|
+
"The API returned an invalid JSON response.",
|
|
152
|
+
status_code=response.status_code,
|
|
153
|
+
details=response.text,
|
|
154
|
+
) from exc
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class Client(_ClientBase):
|
|
158
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
159
|
+
super().__init__(*args, **kwargs)
|
|
160
|
+
self._http = httpx.Client(timeout=self.timeout)
|
|
161
|
+
|
|
162
|
+
def close(self) -> None:
|
|
163
|
+
self._http.close()
|
|
164
|
+
|
|
165
|
+
def __enter__(self) -> Self:
|
|
166
|
+
return self
|
|
167
|
+
|
|
168
|
+
def __exit__(self, *_: object) -> None:
|
|
169
|
+
self.close()
|
|
170
|
+
|
|
171
|
+
def request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
172
|
+
response = self._http.request(
|
|
173
|
+
method,
|
|
174
|
+
f"{self.base_url}{path}" if path.startswith("/") else path,
|
|
175
|
+
headers=self._headers(),
|
|
176
|
+
**kwargs,
|
|
177
|
+
)
|
|
178
|
+
self.rate_limit = RateLimitInfo.from_headers(response.headers)
|
|
179
|
+
self._raise_for_error(response)
|
|
180
|
+
return self._json(response)
|
|
181
|
+
|
|
182
|
+
def get_server(
|
|
183
|
+
self,
|
|
184
|
+
*,
|
|
185
|
+
players: bool = False,
|
|
186
|
+
staff: bool = False,
|
|
187
|
+
join_logs: bool = False,
|
|
188
|
+
queue: bool = False,
|
|
189
|
+
kill_logs: bool = False,
|
|
190
|
+
command_logs: bool = False,
|
|
191
|
+
mod_calls: bool = False,
|
|
192
|
+
emergency_calls: bool = False,
|
|
193
|
+
vehicles: bool = False,
|
|
194
|
+
) -> Server:
|
|
195
|
+
flags = {
|
|
196
|
+
"players": players,
|
|
197
|
+
"staff": staff,
|
|
198
|
+
"join_logs": join_logs,
|
|
199
|
+
"queue": queue,
|
|
200
|
+
"kill_logs": kill_logs,
|
|
201
|
+
"command_logs": command_logs,
|
|
202
|
+
"mod_calls": mod_calls,
|
|
203
|
+
"emergency_calls": emergency_calls,
|
|
204
|
+
"vehicles": vehicles,
|
|
205
|
+
}
|
|
206
|
+
return Server.from_api(
|
|
207
|
+
self.request("GET", "/v2/server", params=self._include_params(flags))
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def get_players(self) -> list[Player]:
|
|
211
|
+
return [
|
|
212
|
+
Player.from_api(item)
|
|
213
|
+
for item in self.request("GET", "/v1/server/players")
|
|
214
|
+
]
|
|
215
|
+
|
|
216
|
+
def get_staff(self) -> Staff:
|
|
217
|
+
return Staff.from_api(self.request("GET", "/v1/server/staff"))
|
|
218
|
+
|
|
219
|
+
def get_join_logs(self) -> list[JoinLog]:
|
|
220
|
+
return [
|
|
221
|
+
JoinLog.from_api(item)
|
|
222
|
+
for item in self.request("GET", "/v1/server/joinlogs")
|
|
223
|
+
]
|
|
224
|
+
|
|
225
|
+
def get_queue(self) -> list[int]:
|
|
226
|
+
return self.request("GET", "/v1/server/queue")
|
|
227
|
+
|
|
228
|
+
def get_kill_logs(self) -> list[KillLog]:
|
|
229
|
+
return [
|
|
230
|
+
KillLog.from_api(item)
|
|
231
|
+
for item in self.request("GET", "/v1/server/killlogs")
|
|
232
|
+
]
|
|
233
|
+
|
|
234
|
+
def get_command_logs(self) -> list[CommandLog]:
|
|
235
|
+
return [
|
|
236
|
+
CommandLog.from_api(item)
|
|
237
|
+
for item in self.request("GET", "/v1/server/commandlogs")
|
|
238
|
+
]
|
|
239
|
+
|
|
240
|
+
def get_mod_calls(self) -> list[ModCall]:
|
|
241
|
+
return [
|
|
242
|
+
ModCall.from_api(item)
|
|
243
|
+
for item in self.request("GET", "/v1/server/modcalls")
|
|
244
|
+
]
|
|
245
|
+
|
|
246
|
+
def get_bans(self) -> dict[str, Any]:
|
|
247
|
+
return self.request("GET", "/v1/server/bans")
|
|
248
|
+
|
|
249
|
+
def get_vehicles(self) -> list[Vehicle]:
|
|
250
|
+
return [
|
|
251
|
+
Vehicle.from_api(item)
|
|
252
|
+
for item in self.request("GET", "/v1/server/vehicles")
|
|
253
|
+
]
|
|
254
|
+
|
|
255
|
+
def send_command(self, command: str) -> CommandResult:
|
|
256
|
+
if not command.strip():
|
|
257
|
+
raise ValueError("command must not be empty")
|
|
258
|
+
return CommandResult.from_api(
|
|
259
|
+
self.request("POST", "/v2/server/command", json={"command": command})
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class AsyncClient(_ClientBase):
|
|
264
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
265
|
+
super().__init__(*args, **kwargs)
|
|
266
|
+
self._http = httpx.AsyncClient(timeout=self.timeout)
|
|
267
|
+
|
|
268
|
+
async def aclose(self) -> None:
|
|
269
|
+
await self._http.aclose()
|
|
270
|
+
|
|
271
|
+
async def __aenter__(self) -> Self:
|
|
272
|
+
return self
|
|
273
|
+
|
|
274
|
+
async def __aexit__(self, *_: object) -> None:
|
|
275
|
+
await self.aclose()
|
|
276
|
+
|
|
277
|
+
async def request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
278
|
+
response = await self._http.request(
|
|
279
|
+
method,
|
|
280
|
+
f"{self.base_url}{path}" if path.startswith("/") else path,
|
|
281
|
+
headers=self._headers(),
|
|
282
|
+
**kwargs,
|
|
283
|
+
)
|
|
284
|
+
self.rate_limit = RateLimitInfo.from_headers(response.headers)
|
|
285
|
+
self._raise_for_error(response)
|
|
286
|
+
return self._json(response)
|
|
287
|
+
|
|
288
|
+
async def get_server(
|
|
289
|
+
self,
|
|
290
|
+
*,
|
|
291
|
+
players: bool = False,
|
|
292
|
+
staff: bool = False,
|
|
293
|
+
join_logs: bool = False,
|
|
294
|
+
queue: bool = False,
|
|
295
|
+
kill_logs: bool = False,
|
|
296
|
+
command_logs: bool = False,
|
|
297
|
+
mod_calls: bool = False,
|
|
298
|
+
emergency_calls: bool = False,
|
|
299
|
+
vehicles: bool = False,
|
|
300
|
+
) -> Server:
|
|
301
|
+
flags = {
|
|
302
|
+
"players": players,
|
|
303
|
+
"staff": staff,
|
|
304
|
+
"join_logs": join_logs,
|
|
305
|
+
"queue": queue,
|
|
306
|
+
"kill_logs": kill_logs,
|
|
307
|
+
"command_logs": command_logs,
|
|
308
|
+
"mod_calls": mod_calls,
|
|
309
|
+
"emergency_calls": emergency_calls,
|
|
310
|
+
"vehicles": vehicles,
|
|
311
|
+
}
|
|
312
|
+
data = await self.request(
|
|
313
|
+
"GET",
|
|
314
|
+
"/v2/server",
|
|
315
|
+
params=self._include_params(flags),
|
|
316
|
+
)
|
|
317
|
+
return Server.from_api(data)
|
|
318
|
+
|
|
319
|
+
async def get_players(self) -> list[Player]:
|
|
320
|
+
data = await self.request("GET", "/v1/server/players")
|
|
321
|
+
return [Player.from_api(item) for item in data]
|
|
322
|
+
|
|
323
|
+
async def get_staff(self) -> Staff:
|
|
324
|
+
return Staff.from_api(await self.request("GET", "/v1/server/staff"))
|
|
325
|
+
|
|
326
|
+
async def get_join_logs(self) -> list[JoinLog]:
|
|
327
|
+
data = await self.request("GET", "/v1/server/joinlogs")
|
|
328
|
+
return [JoinLog.from_api(item) for item in data]
|
|
329
|
+
|
|
330
|
+
async def get_queue(self) -> list[int]:
|
|
331
|
+
return await self.request("GET", "/v1/server/queue")
|
|
332
|
+
|
|
333
|
+
async def get_kill_logs(self) -> list[KillLog]:
|
|
334
|
+
data = await self.request("GET", "/v1/server/killlogs")
|
|
335
|
+
return [KillLog.from_api(item) for item in data]
|
|
336
|
+
|
|
337
|
+
async def get_command_logs(self) -> list[CommandLog]:
|
|
338
|
+
data = await self.request("GET", "/v1/server/commandlogs")
|
|
339
|
+
return [CommandLog.from_api(item) for item in data]
|
|
340
|
+
|
|
341
|
+
async def get_mod_calls(self) -> list[ModCall]:
|
|
342
|
+
data = await self.request("GET", "/v1/server/modcalls")
|
|
343
|
+
return [ModCall.from_api(item) for item in data]
|
|
344
|
+
|
|
345
|
+
async def get_bans(self) -> dict[str, Any]:
|
|
346
|
+
return await self.request("GET", "/v1/server/bans")
|
|
347
|
+
|
|
348
|
+
async def get_vehicles(self) -> list[Vehicle]:
|
|
349
|
+
data = await self.request("GET", "/v1/server/vehicles")
|
|
350
|
+
return [Vehicle.from_api(item) for item in data]
|
|
351
|
+
|
|
352
|
+
async def send_command(self, command: str) -> CommandResult:
|
|
353
|
+
if not command.strip():
|
|
354
|
+
raise ValueError("command must not be empty")
|
|
355
|
+
data = await self.request(
|
|
356
|
+
"POST",
|
|
357
|
+
"/v2/server/command",
|
|
358
|
+
json={"command": command},
|
|
359
|
+
)
|
|
360
|
+
return CommandResult.from_api(data)
|
prcpy/errors.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ERLCError(Exception):
|
|
7
|
+
def __init__(
|
|
8
|
+
self,
|
|
9
|
+
message: str,
|
|
10
|
+
*,
|
|
11
|
+
status_code: int | None = None,
|
|
12
|
+
error_code: int | str | None = None,
|
|
13
|
+
details: Any = None,
|
|
14
|
+
command_id: str | None = None,
|
|
15
|
+
) -> None:
|
|
16
|
+
super().__init__(message)
|
|
17
|
+
self.message = message
|
|
18
|
+
self.status_code = status_code
|
|
19
|
+
self.error_code = error_code
|
|
20
|
+
self.details = details
|
|
21
|
+
self.command_id = command_id
|
|
22
|
+
|
|
23
|
+
def __str__(self) -> str:
|
|
24
|
+
return self.message
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class APIError(ERLCError):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AuthenticationError(APIError):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class BadRequestError(APIError):
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RateLimitError(APIError):
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
message: str,
|
|
43
|
+
*,
|
|
44
|
+
retry_after: float | None = None,
|
|
45
|
+
bucket: str | None = None,
|
|
46
|
+
**kwargs: Any,
|
|
47
|
+
) -> None:
|
|
48
|
+
super().__init__(message, **kwargs)
|
|
49
|
+
self.retry_after = retry_after
|
|
50
|
+
self.bucket = bucket
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ServerOfflineError(APIError):
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class CommandError(APIError):
|
|
58
|
+
pass
|
prcpy/models.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field, fields
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(slots=True)
|
|
8
|
+
class Model:
|
|
9
|
+
raw: dict[str, Any] = field(default_factory=dict, repr=False)
|
|
10
|
+
|
|
11
|
+
def to_dict(self) -> dict[str, Any]:
|
|
12
|
+
return {
|
|
13
|
+
item.name: getattr(self, item.name)
|
|
14
|
+
for item in fields(self)
|
|
15
|
+
if item.name != "raw"
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(slots=True)
|
|
20
|
+
class Location(Model):
|
|
21
|
+
x: float | None = None
|
|
22
|
+
z: float | None = None
|
|
23
|
+
postal_code: str | None = None
|
|
24
|
+
street_name: str | None = None
|
|
25
|
+
building_number: str | None = None
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def from_api(cls, data: dict[str, Any]) -> Location:
|
|
29
|
+
return cls(
|
|
30
|
+
x=data.get("LocationX"),
|
|
31
|
+
z=data.get("LocationZ"),
|
|
32
|
+
postal_code=data.get("PostalCode"),
|
|
33
|
+
street_name=data.get("StreetName"),
|
|
34
|
+
building_number=data.get("BuildingNumber"),
|
|
35
|
+
raw=data,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(slots=True)
|
|
40
|
+
class Player(Model):
|
|
41
|
+
username: str = ""
|
|
42
|
+
roblox_id: int | None = None
|
|
43
|
+
team: str = ""
|
|
44
|
+
permission: str = ""
|
|
45
|
+
callsign: str | None = None
|
|
46
|
+
location: Location | None = None
|
|
47
|
+
wanted_stars: int = 0
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def is_wanted(self) -> bool:
|
|
51
|
+
return self.wanted_stars > 0
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_api(cls, data: dict[str, Any]) -> Player:
|
|
55
|
+
value = data.get("Player", "")
|
|
56
|
+
username, separator, identifier = value.partition(":")
|
|
57
|
+
try:
|
|
58
|
+
roblox_id = int(identifier) if separator and identifier else None
|
|
59
|
+
except ValueError:
|
|
60
|
+
roblox_id = None
|
|
61
|
+
location = data.get("Location")
|
|
62
|
+
return cls(
|
|
63
|
+
username=username or value,
|
|
64
|
+
roblox_id=roblox_id,
|
|
65
|
+
team=data.get("Team", ""),
|
|
66
|
+
permission=data.get("Permission", ""),
|
|
67
|
+
callsign=data.get("Callsign"),
|
|
68
|
+
location=Location.from_api(location) if isinstance(location, dict) else None,
|
|
69
|
+
wanted_stars=data.get("WantedStars", 0),
|
|
70
|
+
raw=data,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(slots=True)
|
|
75
|
+
class Staff(Model):
|
|
76
|
+
co_owner_ids: list[int] = field(default_factory=list)
|
|
77
|
+
admins: dict[str, str] = field(default_factory=dict)
|
|
78
|
+
mods: dict[str, str] = field(default_factory=dict)
|
|
79
|
+
helpers: dict[str, str] = field(default_factory=dict)
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def from_api(cls, data: dict[str, Any]) -> Staff:
|
|
83
|
+
return cls(
|
|
84
|
+
co_owner_ids=data.get("CoOwners", []),
|
|
85
|
+
admins=data.get("Admins", {}),
|
|
86
|
+
mods=data.get("Mods", {}),
|
|
87
|
+
helpers=data.get("Helpers", {}),
|
|
88
|
+
raw=data,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(slots=True)
|
|
93
|
+
class JoinLog(Model):
|
|
94
|
+
joined: bool = False
|
|
95
|
+
timestamp: int = 0
|
|
96
|
+
player: str = ""
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def from_api(cls, data: dict[str, Any]) -> JoinLog:
|
|
100
|
+
return cls(
|
|
101
|
+
joined=data.get("Join", False),
|
|
102
|
+
timestamp=data.get("Timestamp", 0),
|
|
103
|
+
player=data.get("Player", ""),
|
|
104
|
+
raw=data,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(slots=True)
|
|
109
|
+
class KillLog(Model):
|
|
110
|
+
killed: str = ""
|
|
111
|
+
timestamp: int = 0
|
|
112
|
+
killer: str = ""
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def from_api(cls, data: dict[str, Any]) -> KillLog:
|
|
116
|
+
return cls(
|
|
117
|
+
killed=data.get("Killed", ""),
|
|
118
|
+
timestamp=data.get("Timestamp", 0),
|
|
119
|
+
killer=data.get("Killer", ""),
|
|
120
|
+
raw=data,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(slots=True)
|
|
125
|
+
class CommandLog(Model):
|
|
126
|
+
player: str = ""
|
|
127
|
+
timestamp: int = 0
|
|
128
|
+
command: str = ""
|
|
129
|
+
|
|
130
|
+
@classmethod
|
|
131
|
+
def from_api(cls, data: dict[str, Any]) -> CommandLog:
|
|
132
|
+
return cls(
|
|
133
|
+
player=data.get("Player", ""),
|
|
134
|
+
timestamp=data.get("Timestamp", 0),
|
|
135
|
+
command=data.get("Command", ""),
|
|
136
|
+
raw=data,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(slots=True)
|
|
141
|
+
class ModCall(Model):
|
|
142
|
+
caller: str = ""
|
|
143
|
+
moderator: str | None = None
|
|
144
|
+
timestamp: int = 0
|
|
145
|
+
|
|
146
|
+
@classmethod
|
|
147
|
+
def from_api(cls, data: dict[str, Any]) -> ModCall:
|
|
148
|
+
return cls(
|
|
149
|
+
caller=data.get("Caller", ""),
|
|
150
|
+
moderator=data.get("Moderator"),
|
|
151
|
+
timestamp=data.get("Timestamp", 0),
|
|
152
|
+
raw=data,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass(slots=True)
|
|
157
|
+
class EmergencyCall(Model):
|
|
158
|
+
team: str = ""
|
|
159
|
+
caller_id: int | None = None
|
|
160
|
+
player_ids: list[int] = field(default_factory=list)
|
|
161
|
+
position: tuple[float, float] | None = None
|
|
162
|
+
started_at: int = 0
|
|
163
|
+
call_number: int | None = None
|
|
164
|
+
description: str = ""
|
|
165
|
+
position_descriptor: str = ""
|
|
166
|
+
|
|
167
|
+
@classmethod
|
|
168
|
+
def from_api(cls, data: dict[str, Any]) -> EmergencyCall:
|
|
169
|
+
position = data.get("Position")
|
|
170
|
+
coordinates = (
|
|
171
|
+
(float(position[0]), float(position[1]))
|
|
172
|
+
if isinstance(position, list) and len(position) >= 2
|
|
173
|
+
else None
|
|
174
|
+
)
|
|
175
|
+
return cls(
|
|
176
|
+
team=data.get("Team", ""),
|
|
177
|
+
caller_id=data.get("Caller"),
|
|
178
|
+
player_ids=data.get("Players", []),
|
|
179
|
+
position=coordinates,
|
|
180
|
+
started_at=data.get("StartedAt", 0),
|
|
181
|
+
call_number=data.get("CallNumber"),
|
|
182
|
+
description=data.get("Description", ""),
|
|
183
|
+
position_descriptor=data.get("PositionDescriptor", ""),
|
|
184
|
+
raw=data,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@dataclass(slots=True)
|
|
189
|
+
class Vehicle(Model):
|
|
190
|
+
name: str = ""
|
|
191
|
+
owner: str = ""
|
|
192
|
+
plate: str | None = None
|
|
193
|
+
texture: str | None = None
|
|
194
|
+
color_hex: str | None = None
|
|
195
|
+
color_name: str | None = None
|
|
196
|
+
|
|
197
|
+
@classmethod
|
|
198
|
+
def from_api(cls, data: dict[str, Any]) -> Vehicle:
|
|
199
|
+
return cls(
|
|
200
|
+
name=data.get("Name", ""),
|
|
201
|
+
owner=data.get("Owner", ""),
|
|
202
|
+
plate=data.get("Plate"),
|
|
203
|
+
texture=data.get("Texture"),
|
|
204
|
+
color_hex=data.get("ColorHex"),
|
|
205
|
+
color_name=data.get("ColorName"),
|
|
206
|
+
raw=data,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@dataclass(slots=True)
|
|
211
|
+
class Server(Model):
|
|
212
|
+
name: str = ""
|
|
213
|
+
owner_id: int = 0
|
|
214
|
+
co_owner_ids: list[int] = field(default_factory=list)
|
|
215
|
+
current_players: int = 0
|
|
216
|
+
max_players: int = 0
|
|
217
|
+
join_key: str = ""
|
|
218
|
+
account_verification_requirement: str = ""
|
|
219
|
+
team_balance: bool = False
|
|
220
|
+
players: list[Player] | None = None
|
|
221
|
+
staff: Staff | None = None
|
|
222
|
+
join_logs: list[JoinLog] | None = None
|
|
223
|
+
queue: list[int] | None = None
|
|
224
|
+
kill_logs: list[KillLog] | None = None
|
|
225
|
+
command_logs: list[CommandLog] | None = None
|
|
226
|
+
mod_calls: list[ModCall] | None = None
|
|
227
|
+
emergency_calls: list[EmergencyCall] | None = None
|
|
228
|
+
vehicles: list[Vehicle] | None = None
|
|
229
|
+
|
|
230
|
+
@classmethod
|
|
231
|
+
def from_api(cls, data: dict[str, Any]) -> Server:
|
|
232
|
+
return cls(
|
|
233
|
+
name=data.get("Name", ""),
|
|
234
|
+
owner_id=data.get("OwnerId", 0),
|
|
235
|
+
co_owner_ids=data.get("CoOwnerIds", []),
|
|
236
|
+
current_players=data.get("CurrentPlayers", 0),
|
|
237
|
+
max_players=data.get("MaxPlayers", 0),
|
|
238
|
+
join_key=data.get("JoinKey", ""),
|
|
239
|
+
account_verification_requirement=data.get("AccVerifiedReq", ""),
|
|
240
|
+
team_balance=data.get("TeamBalance", False),
|
|
241
|
+
players=[Player.from_api(item) for item in data["Players"]] if "Players" in data else None,
|
|
242
|
+
staff=Staff.from_api(data["Staff"]) if "Staff" in data else None,
|
|
243
|
+
join_logs=[JoinLog.from_api(item) for item in data["JoinLogs"]] if "JoinLogs" in data else None,
|
|
244
|
+
queue=data.get("Queue"),
|
|
245
|
+
kill_logs=[KillLog.from_api(item) for item in data["KillLogs"]] if "KillLogs" in data else None,
|
|
246
|
+
command_logs=[CommandLog.from_api(item) for item in data["CommandLogs"]] if "CommandLogs" in data else None,
|
|
247
|
+
mod_calls=[ModCall.from_api(item) for item in data["ModCalls"]] if "ModCalls" in data else None,
|
|
248
|
+
emergency_calls=[EmergencyCall.from_api(item) for item in data["EmergencyCalls"]] if "EmergencyCalls" in data else None,
|
|
249
|
+
vehicles=[Vehicle.from_api(item) for item in data["Vehicles"]] if "Vehicles" in data else None,
|
|
250
|
+
raw=data,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@dataclass(slots=True)
|
|
255
|
+
class CommandResult(Model):
|
|
256
|
+
message: str = ""
|
|
257
|
+
command_id: str | None = None
|
|
258
|
+
|
|
259
|
+
@classmethod
|
|
260
|
+
def from_api(cls, data: dict[str, Any]) -> CommandResult:
|
|
261
|
+
return cls(
|
|
262
|
+
message=data.get("message", ""),
|
|
263
|
+
command_id=data.get("commandId"),
|
|
264
|
+
raw=data,
|
|
265
|
+
)
|
prcpy/py.typed
ADDED
|
File without changes
|
prcpy/rate_limits.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(slots=True, frozen=True)
|
|
7
|
+
class RateLimitInfo:
|
|
8
|
+
bucket: str | None = None
|
|
9
|
+
limit: int | None = None
|
|
10
|
+
remaining: int | None = None
|
|
11
|
+
reset: int | None = None
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
def from_headers(cls, headers: object) -> RateLimitInfo:
|
|
15
|
+
get = headers.get
|
|
16
|
+
|
|
17
|
+
def integer(name: str) -> int | None:
|
|
18
|
+
value = get(name)
|
|
19
|
+
if value is None:
|
|
20
|
+
return None
|
|
21
|
+
try:
|
|
22
|
+
return int(value)
|
|
23
|
+
except (TypeError, ValueError):
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
return cls(
|
|
27
|
+
bucket=get("X-RateLimit-Bucket"),
|
|
28
|
+
limit=integer("X-RateLimit-Limit"),
|
|
29
|
+
remaining=integer("X-RateLimit-Remaining"),
|
|
30
|
+
reset=integer("X-RateLimit-Reset"),
|
|
31
|
+
)
|
prcpy/webhooks.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import binascii
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class WebhookVerificationError(ValueError):
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _load_public_key(value: str):
|
|
12
|
+
from cryptography.hazmat.primitives import serialization
|
|
13
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
14
|
+
|
|
15
|
+
candidates: list[bytes] = []
|
|
16
|
+
try:
|
|
17
|
+
candidates.append(bytes.fromhex(value))
|
|
18
|
+
except ValueError:
|
|
19
|
+
pass
|
|
20
|
+
try:
|
|
21
|
+
candidates.append(base64.b64decode(value, validate=True))
|
|
22
|
+
except (ValueError, binascii.Error):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
for data in candidates:
|
|
26
|
+
if len(data) == 32:
|
|
27
|
+
return Ed25519PublicKey.from_public_bytes(data)
|
|
28
|
+
try:
|
|
29
|
+
return serialization.load_der_public_key(data)
|
|
30
|
+
except ValueError:
|
|
31
|
+
continue
|
|
32
|
+
|
|
33
|
+
raise WebhookVerificationError("Invalid Ed25519 public key.")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def verify_signature(
|
|
37
|
+
timestamp: str,
|
|
38
|
+
signature: str,
|
|
39
|
+
body: bytes,
|
|
40
|
+
*,
|
|
41
|
+
public_key: str,
|
|
42
|
+
) -> bool:
|
|
43
|
+
try:
|
|
44
|
+
from cryptography.exceptions import InvalidSignature
|
|
45
|
+
except ImportError as exc:
|
|
46
|
+
raise RuntimeError(
|
|
47
|
+
'Install "prcpy-erlc[webhooks]" to verify webhook signatures.'
|
|
48
|
+
) from exc
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
signature_bytes = bytes.fromhex(signature)
|
|
52
|
+
except (ValueError, binascii.Error) as exc:
|
|
53
|
+
raise WebhookVerificationError("Invalid webhook signature.") from exc
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
key = _load_public_key(public_key)
|
|
57
|
+
key.verify(signature_bytes, timestamp.encode("utf-8") + body)
|
|
58
|
+
except InvalidSignature:
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
return True
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: prcpy-erlc
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A typed Python client for the ER:LC Private Server API.
|
|
5
|
+
Project-URL: Documentation, https://apidocs.erlc.gg/
|
|
6
|
+
Project-URL: Repository, https://github.com/Metolix/erlcpy
|
|
7
|
+
Project-URL: Issues, https://github.com/Metolix/erlcpy/issues
|
|
8
|
+
Author: Sidhak Singh
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 Sidhak Singh
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Keywords: api,erlc,private-server,python,roblox
|
|
32
|
+
Classifier: Development Status :: 3 - Alpha
|
|
33
|
+
Classifier: Intended Audience :: Developers
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
41
|
+
Classifier: Typing :: Typed
|
|
42
|
+
Requires-Python: >=3.10
|
|
43
|
+
Requires-Dist: httpx<1,>=0.27
|
|
44
|
+
Provides-Extra: dev
|
|
45
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
46
|
+
Requires-Dist: cryptography>=42; extra == 'dev'
|
|
47
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
48
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
49
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
50
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
51
|
+
Provides-Extra: webhooks
|
|
52
|
+
Requires-Dist: cryptography>=42; extra == 'webhooks'
|
|
53
|
+
Description-Content-Type: text/markdown
|
|
54
|
+
|
|
55
|
+
# prcpy
|
|
56
|
+
|
|
57
|
+
A typed Python client for the ER:LC Private Server API.
|
|
58
|
+
|
|
59
|
+
prcpy provides synchronous and asynchronous clients, typed response models, rate-limit metadata, structured exceptions, webhook helpers, and a low-level request interface for endpoints that are added to the API later.
|
|
60
|
+
|
|
61
|
+
> prcpy is an independent open-source project and is not affiliated with or endorsed by ER:LC or PRC.
|
|
62
|
+
|
|
63
|
+
## Requirements
|
|
64
|
+
|
|
65
|
+
- Python 3.10+
|
|
66
|
+
- An ER:LC private server with API access
|
|
67
|
+
- A server key from the server's API settings
|
|
68
|
+
|
|
69
|
+
## Installation
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pip install prcpy-erlc
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
For webhook signature verification:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
pip install "prcpy-erlc[webhooks]"
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Quick start
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from prcpy import Client
|
|
85
|
+
|
|
86
|
+
with Client("your-server-key") as client:
|
|
87
|
+
server = client.get_server(players=True)
|
|
88
|
+
|
|
89
|
+
print(server.name)
|
|
90
|
+
print(f"{server.current_players}/{server.max_players}")
|
|
91
|
+
|
|
92
|
+
for player in server.players or []:
|
|
93
|
+
print(player.username, player.team)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Async
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from prcpy import AsyncClient
|
|
100
|
+
|
|
101
|
+
async with AsyncClient("your-server-key") as client:
|
|
102
|
+
server = await client.get_server(players=True)
|
|
103
|
+
print(server.name)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Available server data
|
|
107
|
+
|
|
108
|
+
The v2 server endpoint supports these optional resources:
|
|
109
|
+
|
|
110
|
+
- `players`
|
|
111
|
+
- `staff`
|
|
112
|
+
- `join_logs`
|
|
113
|
+
- `queue`
|
|
114
|
+
- `kill_logs`
|
|
115
|
+
- `command_logs`
|
|
116
|
+
- `mod_calls`
|
|
117
|
+
- `emergency_calls`
|
|
118
|
+
- `vehicles`
|
|
119
|
+
|
|
120
|
+
Only request the data an application needs.
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
server = client.get_server(
|
|
124
|
+
players=True,
|
|
125
|
+
staff=True,
|
|
126
|
+
vehicles=True,
|
|
127
|
+
emergency_calls=True,
|
|
128
|
+
)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Convenience methods
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
players = client.get_players()
|
|
135
|
+
staff = client.get_staff()
|
|
136
|
+
join_logs = client.get_join_logs()
|
|
137
|
+
queue = client.get_queue()
|
|
138
|
+
kill_logs = client.get_kill_logs()
|
|
139
|
+
command_logs = client.get_command_logs()
|
|
140
|
+
mod_calls = client.get_mod_calls()
|
|
141
|
+
vehicles = client.get_vehicles()
|
|
142
|
+
bans = client.get_bans()
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Commands
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
result = client.send_command(":h Hello from prcpy")
|
|
149
|
+
print(result.message)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The API documents a `commandId` on command failures. prcpy exposes it as `CommandError.command_id` or `ServerOfflineError.command_id` when supplied by the API.
|
|
153
|
+
|
|
154
|
+
## Authentication
|
|
155
|
+
|
|
156
|
+
All API requests use the `server-key` header.
|
|
157
|
+
|
|
158
|
+
Public applications may also provide a global API key:
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
client = Client(
|
|
162
|
+
"server-key",
|
|
163
|
+
global_api_key="global-api-key",
|
|
164
|
+
)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Environment variables are supported:
|
|
168
|
+
|
|
169
|
+
```text
|
|
170
|
+
ERLC_SERVER_KEY=...
|
|
171
|
+
ERLC_GLOBAL_API_KEY=...
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
client = Client.from_env()
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Never commit keys to source control.
|
|
179
|
+
|
|
180
|
+
## Rate limits
|
|
181
|
+
|
|
182
|
+
The client reads the API's `X-RateLimit-Bucket`, `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` response headers.
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
client.get_server()
|
|
186
|
+
print(client.rate_limit)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
A 429 response raises `RateLimitError` with `retry_after` when the server provides a `Retry-After` header.
|
|
190
|
+
|
|
191
|
+
prcpy does not silently retry commands. This avoids accidentally executing an in-game command more than once.
|
|
192
|
+
|
|
193
|
+
## Raw API access
|
|
194
|
+
|
|
195
|
+
The typed methods cover the documented API, but the API can grow independently of this package.
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
data = client.request("GET", "/v2/server", params={"Players": "true"})
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## Webhooks
|
|
202
|
+
|
|
203
|
+
prcpy includes a small helper for Ed25519 webhook verification:
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
from prcpy.webhooks import verify_signature
|
|
207
|
+
|
|
208
|
+
verify_signature(
|
|
209
|
+
timestamp=request.headers["X-Signature-Timestamp"],
|
|
210
|
+
signature=request.headers["X-Signature-Ed25519"],
|
|
211
|
+
body=raw_request_body,
|
|
212
|
+
public_key="your-webhook-public-key",
|
|
213
|
+
)
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Always verify the raw request body before parsing JSON.
|
|
217
|
+
|
|
218
|
+
## Development
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
python -m pip install -e ".[dev]"
|
|
222
|
+
ruff check .
|
|
223
|
+
pytest
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## Project structure
|
|
227
|
+
|
|
228
|
+
```text
|
|
229
|
+
prcpy/
|
|
230
|
+
├── docs/
|
|
231
|
+
├── examples/
|
|
232
|
+
├── src/prcpy/
|
|
233
|
+
│ ├── client.py
|
|
234
|
+
│ ├── errors.py
|
|
235
|
+
│ ├── models.py
|
|
236
|
+
│ ├── rate_limits.py
|
|
237
|
+
│ └── webhooks.py
|
|
238
|
+
├── tests/
|
|
239
|
+
├── .github/workflows/
|
|
240
|
+
├── CONTRIBUTING.md
|
|
241
|
+
├── LICENSE
|
|
242
|
+
├── pyproject.toml
|
|
243
|
+
└── README.md
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## License
|
|
247
|
+
|
|
248
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
prcpy/__init__.py,sha256=OEcz7uvKCQ5fgyPVS1_f4BHyrEsUkOA4bQ2phWRajN8,937
|
|
2
|
+
prcpy/client.py,sha256=y6kIxBNpfO8_tZyoOxjCIvbcl0RE47xDXPlU4Lm04a8,11224
|
|
3
|
+
prcpy/errors.py,sha256=zrOLS305RpA2FkcpDENSD2UJOAjH80ZiTvWxdo29HNE,1134
|
|
4
|
+
prcpy/models.py,sha256=bZojYy1w5wDi-NthMnT1u0XfMNvA5fnQCH_hP-b3qoA,8259
|
|
5
|
+
prcpy/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
prcpy/rate_limits.py,sha256=gpYcfMXoD4egOTPRqj4BnciQ3kuIwXc5TqK2qHVEEvA,842
|
|
7
|
+
prcpy/webhooks.py,sha256=ASGqgrMVViNcglLNejPazucJx7Nd9OphwtelPWEmZXk,1581
|
|
8
|
+
prcpy_erlc-0.1.0.dist-info/METADATA,sha256=4BPFNYkTrwNPMlEEpZ4lpqne8Hus6y-yHI9RTcO7pY0,6555
|
|
9
|
+
prcpy_erlc-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
10
|
+
prcpy_erlc-0.1.0.dist-info/licenses/LICENSE,sha256=NwC3LB3tBuPeYr4Ffl2oYKuPcqEMo5F-6US7ZXoiTjw,1069
|
|
11
|
+
prcpy_erlc-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sidhak Singh
|
|
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.
|