prc-client 1.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.
prc/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """`prc-client`: A flexible, feature-rich Python API client for the ER:LC private server API."""
2
+
3
+ from . import v1, v2, utils, exceptions
4
+ from .users import FullUser, UsernameUser, IdUser
5
+ from .command import cmd
6
+
7
+ __all__ = [
8
+ "v1", "v2", "utils", "exceptions",
9
+ "FullUser", "UsernameUser", "IdUser", "cmd"
10
+ ]
11
+
12
+ import logging
13
+ logging.getLogger(__name__).addHandler(logging.NullHandler())
prc/base_client.py ADDED
@@ -0,0 +1,263 @@
1
+ from typing import Literal, Self, cast, TYPE_CHECKING
2
+ import logging
3
+ import threading
4
+ import time
5
+ import asyncio
6
+ import httpx
7
+
8
+ from .policy import CommandPolicy
9
+ from .v2.models import Endpoint as V2Endpoint
10
+ from .v1.models import Endpoint as V1Endpoint
11
+ from .exceptions import ApiError, RateLimited, DeserializationError
12
+
13
+ if TYPE_CHECKING:
14
+ from .v2.client import AsyncClient as V2AsyncClient, Client as V2Client
15
+ from .v1.client import AsyncClient as V1AsyncClient, Client as V1Client
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ type ClientType = "V2AsyncClient | V2Client | V1AsyncClient | V1Client"
20
+ type HTTPXClient = httpx.Client | httpx.AsyncClient
21
+ type EndpointType = V1Endpoint | V2Endpoint
22
+
23
+ def create_async_client(server_key: str, **kwargs) -> httpx.AsyncClient:
24
+ logger.debug("Creating new async HTTPX client.")
25
+ return httpx.AsyncClient(
26
+ base_url="https://api.erlc.gg/",
27
+ headers={"server-key": server_key},
28
+ **kwargs
29
+ )
30
+
31
+ def create_sync_client(server_key: str, **kwargs) -> httpx.Client:
32
+ logger.debug("Creating new sync HTTPX client.")
33
+ return httpx.Client(
34
+ base_url="https://api.erlc.gg/",
35
+ headers={"server-key": server_key},
36
+ **kwargs
37
+ )
38
+
39
+ class _AsyncContext:
40
+ server_key: str
41
+ connection: HTTPXClient | None
42
+
43
+ async def __aenter__(self) -> Self:
44
+ self.connection = create_async_client(self.server_key)
45
+ return self
46
+
47
+ async def __aexit__(self, exc_type, exc, tb):
48
+ if self.connection and not self.connection.is_closed:
49
+ # use cast here because the context manager ensures AsyncClient
50
+ await cast(httpx.AsyncClient, self.connection).aclose()
51
+ self.connection = None
52
+ self.closed = True
53
+
54
+ class _SyncContext:
55
+ server_key: str
56
+ connection: HTTPXClient | None
57
+
58
+ def __enter__(self) -> Self:
59
+ self.connection = create_sync_client(self.server_key)
60
+ return self
61
+
62
+ def __exit__(self, exc_type, exc, tb):
63
+ if self.connection and not self.connection.is_closed:
64
+ # use cast here because the context manager ensures Client
65
+ cast(httpx.Client, self.connection).close()
66
+ self.connection = None
67
+ self.closed = True
68
+
69
+ class _BaseApiClient(_SyncContext, _AsyncContext):
70
+ def __init__(self,
71
+ server_key: str,
72
+ *,
73
+ policy: CommandPolicy | None = None,
74
+ wait_for_rate_limit: bool = True,
75
+ connection: HTTPXClient | None = None
76
+ ) -> None:
77
+ """
78
+ Parameters
79
+ ----------
80
+ server_key: `str`
81
+ The private server API key.
82
+ policy: `CommandPolicy` | `None` (optional)
83
+ The optional command policy to use to validate commands. Raises `CommandPolicyViolation` on violations.
84
+ rate_limit_config: `RateLimitConfig` (optional)
85
+ The rate limit configuration for this client. Defaults to safe values.
86
+ connection: `HTTPXClient` | `None` (optional)
87
+ An existing HTTPX client to use. If not provided, a new one will be created.
88
+ """
89
+ # prevent circular import
90
+ from .v2.client import AsyncClient as V2AsyncClient
91
+ from .v1.client import AsyncClient as V1AsyncClient
92
+
93
+ self.server_key: str = server_key
94
+ self.policy = policy
95
+ self.wait_for_rate_limit = wait_for_rate_limit
96
+ self.connection: HTTPXClient | None = connection
97
+ self.closed: bool = False
98
+ self.is_async: bool = issubclass(type(self), (V2AsyncClient, V1AsyncClient))
99
+
100
+ # rate limit tracking attributes, updated on each request based on response headers
101
+ self.post_expiration: int = int(time.time())
102
+ self.get_remaining: int = 0
103
+ self.get_expiration: int = int(time.time())
104
+
105
+ if self.is_async:
106
+ self.lock = asyncio.Lock()
107
+ else:
108
+ self.lock = threading.Lock()
109
+
110
+ def _raise_for_status(self, resp: httpx.Response):
111
+ body = resp.json()
112
+ if resp.status_code == 429:
113
+ logger.error("Currently being rate-limited by PRC.", extra={"body": body})
114
+ try:
115
+ raise RateLimited.from_dict(body)
116
+ except DeserializationError:
117
+ raise RateLimited(
118
+ code=429,
119
+ message="API call failed (rate limited by PRC - unknown refresh_after).",
120
+ retry_after=0.0
121
+ )
122
+
123
+ if resp.status_code != 200:
124
+ try:
125
+ logger.error("API call failed with non-200 status code.", extra={"body": body})
126
+ raise ApiError.from_dict(body)
127
+ except DeserializationError:
128
+ logger.error(
129
+ "API call failed with non-200 status code %s.",
130
+ resp.status_code,
131
+ extra={"code": resp.status_code}
132
+ )
133
+ raise ApiError(code=resp.status_code, message="API call failed (status not 200).")
134
+
135
+ def _update_ratelimit(self, resp: httpx.Response):
136
+ headers = resp.headers
137
+
138
+ limit_remaining = headers.get("x-ratelimit-remaining")
139
+ limit_expiration = headers.get("x-ratelimit-reset")
140
+
141
+ if resp.request.method == "GET":
142
+ self.get_remaining = int(limit_remaining) or self.get_remaining
143
+ self.get_expiration = int(limit_expiration) or self.get_expiration
144
+ else:
145
+ self.post_expiration = int(limit_expiration) or self.post_expiration
146
+
147
+ logger.debug("Updated rate limit info.", extra={
148
+ "get_remaining": self.get_remaining,
149
+ "get_expiration": self.get_expiration,
150
+ "post_expiration": self.post_expiration
151
+ })
152
+
153
+ def _get_method(self, endpoint: EndpointType) -> Literal["GET", "POST"]:
154
+ if isinstance(endpoint, V2Endpoint):
155
+ return "GET" if endpoint == V2Endpoint.v2_server else "POST"
156
+ else:
157
+ return "POST" if endpoint == V1Endpoint.command else "GET"
158
+
159
+ async def _send_async_request(self, endpoint: EndpointType, **kwargs) -> httpx.Response:
160
+ if self.connection is None and self.closed:
161
+ logger.error("Attempted to send async request with closed connection.")
162
+ raise RuntimeError("Unable to make request as this connection is closed.")
163
+ if self.connection is None:
164
+ logger.debug("No existing connection found; creating new async client for request.")
165
+ self.connection = create_async_client(self.server_key)
166
+ if not isinstance(self.connection, httpx.AsyncClient):
167
+ logger.error("Async request attempted with non-async client.")
168
+ raise RuntimeError("Cannot send async request; connection is not an async client.")
169
+
170
+ method = self._get_method(endpoint)
171
+ if self.wait_for_rate_limit:
172
+ async with cast(asyncio.Lock, self.lock):
173
+ if method == "GET":
174
+ wait_for = self._get_wait_time()
175
+ else:
176
+ wait_for = self._post_wait_time()
177
+
178
+ if wait_for > 0:
179
+ logger.debug("Waiting for %s seconds due to rate limit before sending async request.", wait_for)
180
+ await asyncio.sleep(wait_for)
181
+ logger.debug("Rate limit waiting finished.")
182
+
183
+ logger.debug("Sending async request to endpoint %s.", endpoint.value)
184
+ resp = await self.connection.request(method, endpoint.value, **kwargs)
185
+ self._update_ratelimit(resp)
186
+ self._raise_for_status(resp)
187
+
188
+ return resp
189
+
190
+ logger.debug("Sending async request to endpoint %s.", endpoint.value)
191
+ resp = await self.connection.request(method, endpoint.value, **kwargs)
192
+ self._update_ratelimit(resp)
193
+ self._raise_for_status(resp)
194
+
195
+ return resp
196
+
197
+ def _send_sync_request(self, endpoint: EndpointType, **kwargs) -> httpx.Response:
198
+ if self.connection is None and self.closed:
199
+ logger.error("Attempted to send sync request with closed connection.")
200
+ raise RuntimeError("Unable to make request as this connection is closed.")
201
+ if self.connection is None:
202
+ logger.debug("No existing connection found; creating new sync client for request.")
203
+ self.connection = create_sync_client(self.server_key)
204
+ if not isinstance(self.connection, httpx.Client):
205
+ logger.error("Sync request attempted with non-sync client.")
206
+ raise RuntimeError("Cannot send sync request; connection is not a sync client.")
207
+
208
+ method = self._get_method(endpoint)
209
+ if self.wait_for_rate_limit:
210
+ with cast(threading.Lock, self.lock):
211
+ if method == "GET":
212
+ wait_for = self._get_wait_time()
213
+ else:
214
+ wait_for = self._post_wait_time()
215
+
216
+ if wait_for > 0:
217
+ logger.debug("Waiting for %s seconds due to rate limit before sending sync request.", wait_for)
218
+ time.sleep(wait_for)
219
+ logger.debug("Rate limit waiting finished.")
220
+
221
+ logger.debug("Sending sync request to endpoint %s.", endpoint.value)
222
+ resp = self.connection.request(method, endpoint.value, **kwargs)
223
+ self._update_ratelimit(resp)
224
+ self._raise_for_status(resp)
225
+
226
+ return resp
227
+
228
+ logger.debug("Sending sync request to endpoint %s.", endpoint.value)
229
+ resp = self.connection.request(method, endpoint.value, **kwargs)
230
+ self._update_ratelimit(resp)
231
+ self._raise_for_status(resp)
232
+
233
+ return resp
234
+
235
+ def _get_wait_time(self) -> float:
236
+ if self.get_remaining > 0:
237
+ return 0.0
238
+ return max(self.get_expiration - time.time() + 1, 0)
239
+
240
+ def _post_wait_time(self) -> float:
241
+ return max(self.post_expiration - time.time() + 1, 0)
242
+
243
+ async def aclose(self):
244
+ if not isinstance(self.connection, httpx.AsyncClient):
245
+ logger.error("Attempted to close async connection with non-async client.")
246
+ raise RuntimeError("Connection is not an async client; close with .close().")
247
+ if self.connection and not self.connection.is_closed:
248
+ await self.connection.aclose()
249
+
250
+ self.connection = None
251
+ self.closed = True
252
+ logger.info("Async connection closed.")
253
+
254
+ def close(self):
255
+ if not isinstance(self.connection, httpx.Client):
256
+ logger.error("Attempted to close sync connection with non-sync client.")
257
+ raise RuntimeError("Connection is not an sync client; close with .aclose().")
258
+ if self.connection and not self.connection.is_closed:
259
+ self.connection.close()
260
+
261
+ self.connection = None
262
+ self.closed = True
263
+ logger.info("Sync connection closed.")
prc/command.py ADDED
@@ -0,0 +1,163 @@
1
+ import logging
2
+ from collections.abc import Sequence
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING, ClassVar
5
+ from httpx import Response
6
+
7
+ from .users import FullUser, UsernameUser, IdUser
8
+ from .v2.models import Player as V2Player
9
+ from .v1.models import Player as V1Player
10
+
11
+ if TYPE_CHECKING:
12
+ from .v2.client import AsyncClient as V2AsyncClient, Client as V2Client
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ def normalize_command(command: str) -> str:
17
+ """Normalize a command by ensuring it starts with a colon."""
18
+ return command if command.startswith(":") else f":{command}"
19
+
20
+ @dataclass
21
+ class Command:
22
+ """Represents an in-game command.
23
+
24
+ You should import and use the `cmd` instance to dynamically create `Command` objects
25
+ instead of instantiating this class directly:
26
+
27
+ ```python
28
+ from prc.v2 import cmd
29
+
30
+ cmd.pm("Alice", "Hello World!") # Command(text=":pm Alice Hello World!")
31
+ ```
32
+
33
+ Attributes
34
+ ----------
35
+ text: `str`
36
+ The command's normalized text.
37
+ """
38
+ text: str
39
+
40
+ dangerous_cmds: ClassVar[set[str]] = {
41
+ ":kick", ":ban", ":wanted", ":unwanted",
42
+ ":jail", ":unjail", ":kill", ":heal",
43
+ ":refresh", ":respawn"
44
+ }
45
+
46
+ def __post_init__(self) -> None:
47
+ self.text = normalize_command(self.text)
48
+
49
+ async def asend(self, client: "V2AsyncClient"):
50
+ """Sends this command to the API using the provided asynchronous client."""
51
+ logger.info("Sending async command: '%s'", self.text)
52
+ return await client.send_command(self)
53
+
54
+ def send(self, client: "V2Client") -> Response:
55
+ """Sends this command to the API using the provided synchronous client."""
56
+ logger.info("Sending sync command: '%s'", self.text)
57
+ return client.send_command(self)
58
+
59
+ @property
60
+ def payload(self) -> dict[str, str]:
61
+ """Returns the payload to send this command to the API."""
62
+ return {"command": self.text}
63
+
64
+ @property
65
+ def command(self) -> str:
66
+ """Returns the command with leading colon (`:h`, `:kick`, etc.)"""
67
+ return self.text.split()[0]
68
+
69
+ @property
70
+ def dangerous(self) -> bool:
71
+ """Returns True if the command is dangerous (e.g. `:kick all`, `:ban all`), else False."""
72
+ return any(
73
+ self.text.startswith((f"{cmd} all", f"{cmd} others"))
74
+ for cmd in type(self).dangerous_cmds
75
+ )
76
+
77
+ # define user types
78
+ type AnyUserType = V2Player | V1Player | FullUser | UsernameUser | IdUser | str | int
79
+ type UsernameUserType = V2Player | V1Player | FullUser | UsernameUser | str
80
+ type IdUserType = V2Player | V1Player | FullUser | IdUser | int
81
+
82
+ type CommandLike = Command | str
83
+
84
+ class _CmdFactory:
85
+ if TYPE_CHECKING:
86
+ # define in-game command methods
87
+ def h(self, message: str) -> Command: ...
88
+ def hint(self, message: str) -> Command: ...
89
+ def m(self, message: str) -> Command: ...
90
+ def message(self, message: str) -> Command: ...
91
+ def pm(self, player: UsernameUserType | Sequence[UsernameUserType], message: str) -> Command: ...
92
+ def privatemessage(self, player: UsernameUserType | Sequence[UsernameUserType], message: str) -> Command: ...
93
+ def kick(self, player: AnyUserType | Sequence[AnyUserType], reason: str = "") -> Command: ...
94
+ def ban(self, player: AnyUserType | Sequence[AnyUserType], reason: str = "") -> Command: ...
95
+ def unban(self, player: AnyUserType | Sequence[AnyUserType]) -> Command: ...
96
+ def wanted(self, player: UsernameUserType | Sequence[UsernameUserType]) -> Command: ...
97
+ def unwanted(self, player: UsernameUserType | Sequence[UsernameUserType]) -> Command: ...
98
+ def jail(self, player: UsernameUserType | Sequence[UsernameUserType]) -> Command: ...
99
+ def unjail(self, player: UsernameUserType | Sequence[UsernameUserType]) -> Command: ...
100
+ def kill(self, player: UsernameUserType | Sequence[UsernameUserType]) -> Command: ...
101
+ def heal(self, player: UsernameUserType | Sequence[UsernameUserType]) -> Command: ...
102
+ def refresh(self, player: UsernameUserType | Sequence[UsernameUserType]) -> Command: ...
103
+ def respawn(self, player: UsernameUserType | Sequence[UsernameUserType]) -> Command: ...
104
+ def tp(self, player1: UsernameUserType, player2: UsernameUserType) -> Command: ...
105
+ def teleport(self, player1: UsernameUserType, player2: UsernameUserType) -> Command: ...
106
+ def admin(self, player: AnyUserType) -> Command: ...
107
+ def unadmin(self, player: AnyUserType) -> Command: ...
108
+ def mod(self, player: AnyUserType) -> Command: ...
109
+ def unmod(self, player: AnyUserType) -> Command: ...
110
+ def helper(self, player: AnyUserType) -> Command: ...
111
+ def unhelper(self, player: AnyUserType) -> Command: ...
112
+ def log(self, message: str) -> Command: ...
113
+ def weather(self, weather: str) -> Command: ...
114
+ def time(self, time: float) -> Command: ...
115
+ def shutdown(self) -> Command: ...
116
+
117
+ # handle argument parsing and custom command creation
118
+ def __getattr__(self, name: str):
119
+ def call(*args, **kwargs) -> Command:
120
+ # parse player/user objects into username/ID representations
121
+ parsed: list[str] = []
122
+ collected_args = list(args) + list(kwargs.values())
123
+
124
+ for arg in collected_args:
125
+ # treat non-string sequences as a grouped player list
126
+ if isinstance(arg, Sequence) and not isinstance(arg, (str, bytes)):
127
+ items: list[str] = []
128
+ for item in arg:
129
+ if isinstance(item, (V2Player, V1Player)):
130
+ items.append(item.user.name)
131
+ elif isinstance(item, (UsernameUser, FullUser)):
132
+ items.append(item.name)
133
+ elif isinstance(item, IdUser):
134
+ items.append(str(item.id))
135
+ else:
136
+ items.append(str(item))
137
+ parsed.append(','.join(items))
138
+ else:
139
+ item = arg
140
+ if isinstance(item, (V2Player, V1Player)):
141
+ parsed.append(item.user.name)
142
+ elif isinstance(item, (UsernameUser, FullUser)):
143
+ parsed.append(item.name)
144
+ elif isinstance(item, IdUser):
145
+ parsed.append(str(item.id))
146
+ else:
147
+ parsed.append(str(item))
148
+
149
+ # join the parsed arguments
150
+ joined_args = ' '.join(parsed)
151
+ full = f"{name} {joined_args}".strip()
152
+ logger.debug("Created command: '%s'", full)
153
+
154
+ return Command(text=full)
155
+ return call
156
+
157
+ cmd: _CmdFactory = _CmdFactory()
158
+ """Factory for creating in-game commands.
159
+
160
+ Used to build commands, such as `cmd.hint("Hello, World!")` or `cmd.kick(["player1", "player2"], "reason")`.
161
+
162
+ In-game commands have type hints/method stubs, but any command can be created the same way
163
+ and user arguments will be parsed automatically."""
prc/events/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .router import Router
2
+ from .models import Context
3
+
4
+ __all__ = ["Router", "Context"]
@@ -0,0 +1,56 @@
1
+ from typing import TYPE_CHECKING
2
+ if TYPE_CHECKING:
3
+ from .router import Router
4
+
5
+ class _On:
6
+ def __init__(self, router: "Router") -> None:
7
+ self.router = router
8
+
9
+ def command(self, command: str, *commands: str):
10
+ """Register a function to be called when a specific command is run in-game."""
11
+ def wrapper(func):
12
+ self.router._add_command(func, command)
13
+ for cmd in commands:
14
+ self.router._add_command(func, cmd)
15
+ return func
16
+ return wrapper
17
+
18
+ def any_custom_command(self):
19
+ """Register a function to be called when any custom command is run in-game."""
20
+ def wrapper(func):
21
+ self.router._add_command(func, None)
22
+ return func
23
+ return wrapper
24
+
25
+ def custom_event(self, event_name: str, *event_names: str):
26
+ """Register a function to be called when a custom event with the specified name is received."""
27
+ def wrapper(func):
28
+ self.router._add_function(func, event_name)
29
+ for name in event_names:
30
+ self.router._add_function(func, name)
31
+ return func
32
+ return wrapper
33
+
34
+ def emergency_start(self):
35
+ """Register a function to be called when an emergency call is made in-game."""
36
+ def wrapper(func):
37
+ self.router._add_function(func, "EmergencyCallStarted")
38
+ return func
39
+ return wrapper
40
+
41
+ def probe(self):
42
+ """Register a function to be called when a webhook ping is received from the PRC API.
43
+
44
+ These are occasionally sent by the API to test the validity of the webhook URL and its signature verification.
45
+ """
46
+ def wrapper(func):
47
+ self.router._add_function(func, "WebhookProbe")
48
+ return func
49
+ return wrapper
50
+
51
+ def any_event(self):
52
+ """Register a function to be called when any non-command event is received."""
53
+ def wrapper(func):
54
+ self.router._add_function(func, None)
55
+ return func
56
+ return wrapper
@@ -0,0 +1,5 @@
1
+ from .fastapi import _FastApiIntegration
2
+ from .quart import _QuartIntegration
3
+ from .starlette import _StarletteIntegration
4
+
5
+ __all__ = ["_FastApiIntegration", "_QuartIntegration", "_StarletteIntegration"]
@@ -0,0 +1,24 @@
1
+ from typing import Literal
2
+ from ..router import Router
3
+
4
+ try:
5
+ from fastapi import Request, BackgroundTasks
6
+ except ImportError:
7
+ raise RuntimeError((
8
+ "FastAPI integration for PRC events requires the `fastapi` library. "
9
+ "Install the dependency with `pip install prc-client[fastapi]`."
10
+ ))
11
+
12
+ class _FastApiIntegration:
13
+ def __init__(self, router: Router) -> None:
14
+ self.router = router
15
+
16
+ async def handle_fastapi_request(self, request: Request, background_tasks: BackgroundTasks) -> Literal[200, 400]:
17
+ raw_body = await request.body()
18
+ headers = dict(request.headers)
19
+
20
+ status, task = await self.router.prepare_request(raw_body, headers)
21
+ if status == 200 and task:
22
+ background_tasks.add_task(task)
23
+
24
+ return status
@@ -0,0 +1,25 @@
1
+ from typing import Literal
2
+ from ..router import Router
3
+
4
+ try:
5
+ from quart import Quart, request
6
+ except ImportError:
7
+ raise RuntimeError((
8
+ "Quart integration for PRC events requires the `quart` library. "
9
+ "Install the dependency with `pip install prc-client[quart]`."
10
+ ))
11
+
12
+ class _QuartIntegration:
13
+ def __init__(self, router: Router) -> None:
14
+ self.router = router
15
+
16
+ async def handle_quart_request(self, app: Quart) -> Literal[200, 400]:
17
+ raw_body = await request.get_data(as_text=False)
18
+ raw_body = raw_body.encode() if isinstance(raw_body, str) else raw_body
19
+ headers = dict(request.headers)
20
+
21
+ status, task = await self.router.prepare_request(raw_body, headers)
22
+ if status == 200 and task:
23
+ app.add_background_task(task)
24
+
25
+ return status
@@ -0,0 +1,29 @@
1
+ from typing import Literal
2
+ from ..router import Router
3
+
4
+ try:
5
+ from starlette.requests import Request
6
+ from starlette.background import BackgroundTask
7
+ except ImportError:
8
+ raise RuntimeError((
9
+ "Starlette integration for PRC events requires the `starlette` library. "
10
+ "Install the dependency with `pip install prc-client[starlette]`."
11
+ ))
12
+
13
+ class _StarletteIntegration:
14
+ def __init__(self, router: Router) -> None:
15
+ self.router = router
16
+
17
+ async def handle_starlette_request(
18
+ self, request: Request
19
+ ) -> tuple[Literal[200, 400], BackgroundTask | None]:
20
+ raw_body = await request.body()
21
+ headers = dict(request.headers)
22
+
23
+ status, task = await self.router.prepare_request(raw_body, headers)
24
+ if status == 200 and task:
25
+ background_task = BackgroundTask(task)
26
+ else:
27
+ background_task = None
28
+
29
+ return status, background_task