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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.