rlstatsapi 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.
rlstatsapi/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """rlstatsapi public API."""
2
+
3
+ from .client import StatsClient
4
+ from .models import EventMessage
5
+ from .events import KNOWN_EVENTS
6
+
7
+ __all__ = ["StatsClient", "EventMessage", "KNOWN_EVENTS"]
@@ -0,0 +1,176 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import base64
5
+ import hashlib
6
+ import os
7
+ import ssl
8
+ from dataclasses import dataclass
9
+
10
+ _WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
11
+
12
+
13
+ class WebSocketHandshakeError(ConnectionError):
14
+ """Raised when websocket upgrade fails."""
15
+
16
+
17
+ @dataclass(slots=True)
18
+ class SimpleWebSocket:
19
+ """Minimal stdlib websocket client optimized for receiving text frames."""
20
+
21
+ reader: asyncio.StreamReader
22
+ writer: asyncio.StreamWriter
23
+
24
+ async def recv_text(self) -> str:
25
+ opcode, payload = await self._read_frame()
26
+
27
+ if opcode == 0x8: # close
28
+ await self.close()
29
+ raise ConnectionAbortedError("Websocket closed by peer")
30
+ if opcode == 0x9: # ping
31
+ await self._send_pong(payload)
32
+ return await self.recv_text()
33
+ if opcode == 0xA: # pong
34
+ return await self.recv_text()
35
+ if opcode != 0x1:
36
+ raise ConnectionError(f"Unsupported opcode: {opcode}")
37
+
38
+ return payload.decode("utf-8")
39
+
40
+ async def close(self) -> None:
41
+ if self.writer.is_closing():
42
+ return
43
+ try:
44
+ await self._send_frame(0x8, b"")
45
+ except Exception:
46
+ pass
47
+ self.writer.close()
48
+ await self.writer.wait_closed()
49
+
50
+ async def _send_pong(self, payload: bytes) -> None:
51
+ await self._send_frame(0xA, payload)
52
+
53
+ async def _send_frame(self, opcode: int, payload: bytes) -> None:
54
+ fin_and_opcode = 0x80 | (opcode & 0x0F)
55
+ mask_and_len = 0x80
56
+ payload_len = len(payload)
57
+
58
+ header = bytearray([fin_and_opcode])
59
+ if payload_len < 126:
60
+ header.append(mask_and_len | payload_len)
61
+ elif payload_len <= 0xFFFF:
62
+ header.append(mask_and_len | 126)
63
+ header.extend(payload_len.to_bytes(2, "big"))
64
+ else:
65
+ header.append(mask_and_len | 127)
66
+ header.extend(payload_len.to_bytes(8, "big"))
67
+
68
+ mask = os.urandom(4)
69
+ header.extend(mask)
70
+
71
+ masked_payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
72
+
73
+ self.writer.write(header)
74
+ self.writer.write(masked_payload)
75
+ await self.writer.drain()
76
+
77
+ async def _read_frame(self) -> tuple[int, bytes]:
78
+ first_two = await self.reader.readexactly(2)
79
+ first, second = first_two[0], first_two[1]
80
+
81
+ fin = (first & 0x80) != 0
82
+ opcode = first & 0x0F
83
+ masked = (second & 0x80) != 0
84
+ payload_len = second & 0x7F
85
+
86
+ if payload_len == 126:
87
+ payload_len = int.from_bytes(await self.reader.readexactly(2), "big")
88
+ elif payload_len == 127:
89
+ payload_len = int.from_bytes(await self.reader.readexactly(8), "big")
90
+
91
+ mask_key = await self.reader.readexactly(4) if masked else b""
92
+ payload = await self.reader.readexactly(payload_len)
93
+
94
+ if masked:
95
+ payload = bytes(b ^ mask_key[i % 4] for i, b in enumerate(payload))
96
+
97
+ if not fin:
98
+ raise ConnectionError("Fragmented frames are not supported")
99
+
100
+ return opcode, payload
101
+
102
+
103
+ async def connect_websocket(
104
+ *,
105
+ host: str,
106
+ port: int,
107
+ path: str = "/",
108
+ timeout: float = 5.0,
109
+ secure: bool = False,
110
+ ) -> SimpleWebSocket:
111
+ """Open websocket connection via stdlib stream upgrade."""
112
+
113
+ key = base64.b64encode(os.urandom(16)).decode("ascii")
114
+
115
+ ssl_ctx = ssl.create_default_context() if secure else None
116
+ reader, writer = await asyncio.wait_for(
117
+ asyncio.open_connection(host=host, port=port, ssl=ssl_ctx),
118
+ timeout=timeout,
119
+ )
120
+
121
+ request = (
122
+ f"GET {path} HTTP/1.1\r\n"
123
+ f"Host: {host}:{port}\r\n"
124
+ "Upgrade: websocket\r\n"
125
+ "Connection: Upgrade\r\n"
126
+ f"Sec-WebSocket-Key: {key}\r\n"
127
+ "Sec-WebSocket-Version: 13\r\n"
128
+ "\r\n"
129
+ )
130
+
131
+ writer.write(request.encode("ascii"))
132
+ await writer.drain()
133
+
134
+ response = await _read_http_response(reader)
135
+ status_line = response[0]
136
+
137
+ if " 101 " not in status_line:
138
+ writer.close()
139
+ await writer.wait_closed()
140
+ raise WebSocketHandshakeError(f"Handshake failed: {status_line.strip()}")
141
+
142
+ headers = _parse_headers(response[1:])
143
+ accept = headers.get("sec-websocket-accept")
144
+
145
+ expected = base64.b64encode(
146
+ hashlib.sha1((key + _WS_GUID).encode("ascii")).digest()
147
+ ).decode("ascii")
148
+ if accept != expected:
149
+ writer.close()
150
+ await writer.wait_closed()
151
+ raise WebSocketHandshakeError("Invalid Sec-WebSocket-Accept from server")
152
+
153
+ return SimpleWebSocket(reader=reader, writer=writer)
154
+
155
+
156
+ async def _read_http_response(reader: asyncio.StreamReader) -> list[str]:
157
+ lines: list[str] = []
158
+ while True:
159
+ line = (await reader.readline()).decode("iso-8859-1")
160
+ if not line:
161
+ raise WebSocketHandshakeError("Unexpected EOF during handshake")
162
+ lines.append(line)
163
+ if line == "\r\n":
164
+ return lines
165
+
166
+
167
+ def _parse_headers(lines: list[str]) -> dict[str, str]:
168
+ headers: dict[str, str] = {}
169
+ for line in lines:
170
+ if line == "\r\n":
171
+ break
172
+ if ":" not in line:
173
+ continue
174
+ key, value = line.split(":", 1)
175
+ headers[key.strip().lower()] = value.strip()
176
+ return headers
rlstatsapi/client.py ADDED
@@ -0,0 +1,152 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import inspect
6
+ import json
7
+ import logging
8
+ from collections import defaultdict
9
+ from typing import Any, AsyncIterator, Awaitable, Callable
10
+
11
+ from .models import EventMessage
12
+ from ._websocket import SimpleWebSocket, connect_websocket
13
+
14
+ Handler = Callable[[EventMessage], Awaitable[None] | None]
15
+
16
+
17
+ class StatsClient:
18
+ """High-throughput asyncio client for Rocket League Stats API."""
19
+
20
+ def __init__(
21
+ self,
22
+ host: str = "127.0.0.1",
23
+ port: int = 49123,
24
+ reconnect: bool = True,
25
+ reconnect_delay: float = 0.5,
26
+ include_raw: bool = False,
27
+ queue_size: int = 2048,
28
+ connect_timeout: float = 5.0,
29
+ log_enabled: bool = False,
30
+ ) -> None:
31
+ self.host = host
32
+ self.port = port
33
+ self.reconnect = reconnect
34
+ self.reconnect_delay = reconnect_delay
35
+ self.include_raw = include_raw
36
+ self.connect_timeout = connect_timeout
37
+ self.log_enabled = log_enabled
38
+
39
+ self._queue: asyncio.Queue[EventMessage] = asyncio.Queue(maxsize=queue_size)
40
+ self._handlers_by_event: dict[str, list[Handler]] = defaultdict(list)
41
+ self._handlers_any: list[Handler] = []
42
+ self._logger = logging.getLogger("rlstatsapi")
43
+
44
+ self._ws: SimpleWebSocket | None = None
45
+ self._reader_task: asyncio.Task[None] | None = None
46
+ self._stopping = False
47
+
48
+ async def connect(self) -> None:
49
+ if self._reader_task and not self._reader_task.done():
50
+ self._log("connect() ignored: reader already running")
51
+ return
52
+
53
+ self._stopping = False
54
+ self._reader_task = asyncio.create_task(self._run(), name="rlstatsapi-reader")
55
+ self._log("reader task started")
56
+
57
+ async def disconnect(self) -> None:
58
+ self._stopping = True
59
+ self._log("disconnect requested")
60
+
61
+ if self._ws is not None:
62
+ with contextlib.suppress(Exception):
63
+ await self._ws.close()
64
+ self._ws = None
65
+
66
+ if self._reader_task is not None:
67
+ self._reader_task.cancel()
68
+ with contextlib.suppress(asyncio.CancelledError):
69
+ await self._reader_task
70
+ self._reader_task = None
71
+ self._log("disconnected")
72
+
73
+ def on(self, event_name: str, handler: Handler) -> None:
74
+ self._handlers_by_event[event_name].append(handler)
75
+
76
+ def on_any(self, handler: Handler) -> None:
77
+ self._handlers_any.append(handler)
78
+
79
+ async def events(self) -> AsyncIterator[EventMessage]:
80
+ while True:
81
+ yield await self._queue.get()
82
+
83
+ async def _run(self) -> None:
84
+ while not self._stopping:
85
+ try:
86
+ self._log(f"connecting to ws://{self.host}:{self.port}")
87
+ self._ws = await connect_websocket(
88
+ host=self.host,
89
+ port=self.port,
90
+ timeout=self.connect_timeout,
91
+ )
92
+ self._log("websocket connected")
93
+ await self._read_loop(self._ws)
94
+ except asyncio.CancelledError:
95
+ self._log("reader task cancelled")
96
+ raise
97
+ except Exception as exc:
98
+ self._log(f"connection/read error: {exc}")
99
+ if not self.reconnect or self._stopping:
100
+ self._log("stopping reader (reconnect disabled or stopping)")
101
+ return
102
+ self._log(f"reconnecting in {self.reconnect_delay:.2f}s")
103
+ await asyncio.sleep(self.reconnect_delay)
104
+ finally:
105
+ if self._ws is not None:
106
+ with contextlib.suppress(Exception):
107
+ await self._ws.close()
108
+ self._ws = None
109
+ self._log("websocket closed")
110
+
111
+ async def _read_loop(self, ws: SimpleWebSocket) -> None:
112
+ while not self._stopping:
113
+ raw = await ws.recv_text()
114
+ message = _parse_message(raw, include_raw=self.include_raw)
115
+ if message is None:
116
+ self._log("ignored invalid message (json/envelope)")
117
+ continue
118
+
119
+ await self._queue.put(message)
120
+ await self._dispatch(message)
121
+
122
+ async def _dispatch(self, message: EventMessage) -> None:
123
+ handlers = self._handlers_any + self._handlers_by_event.get(message.event, [])
124
+
125
+ for handler in handlers:
126
+ try:
127
+ result = handler(message)
128
+ if inspect.isawaitable(result):
129
+ await result
130
+ except Exception as exc:
131
+ # Never kill the stream because of user handlers.
132
+ self._log(f"handler error for {message.event}: {exc}")
133
+ continue
134
+
135
+ def _log(self, message: str) -> None:
136
+ if self.log_enabled:
137
+ self._logger.info("[StatsClient] %s", message)
138
+
139
+
140
+ def _parse_message(raw: str, *, include_raw: bool) -> EventMessage | None:
141
+ try:
142
+ decoded = json.loads(raw)
143
+ except json.JSONDecodeError:
144
+ return None
145
+
146
+ event = decoded.get("Event")
147
+ data = decoded.get("Data")
148
+
149
+ if not isinstance(event, str) or not isinstance(data, dict):
150
+ return None
151
+
152
+ return EventMessage(event=event, data=data, raw=raw if include_raw else None)
rlstatsapi/events.py ADDED
@@ -0,0 +1,29 @@
1
+ """
2
+ Known Rocket League Stats API event names.
3
+ Sources:
4
+ https://www.rocketleague.com/en/developer/stats-api
5
+ """
6
+
7
+ KNOWN_EVENTS: frozenset[str] = frozenset(
8
+ {
9
+ "UpdateState",
10
+ "BallHit",
11
+ "ClockUpdatedSeconds",
12
+ "CountdownBegin",
13
+ "CrossbarHit",
14
+ "GoalReplayEnd",
15
+ "GoalReplayStart",
16
+ "GoalReplayWillEnd",
17
+ "GoalScored",
18
+ "MatchCreated",
19
+ "MatchInitialized",
20
+ "MatchDestroyed",
21
+ "MatchEnded",
22
+ "MatchPaused",
23
+ "MatchUnpaused",
24
+ "PodiumStart",
25
+ "ReplayCreated",
26
+ "RoundStarted",
27
+ "StatfeedEvent",
28
+ }
29
+ )
rlstatsapi/models.py ADDED
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(slots=True)
8
+ class EventMessage:
9
+ """Normalized Rocket League Stats API message envelope."""
10
+
11
+ event: str
12
+ data: dict[str, Any]
13
+ raw: str | None = None
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.4
2
+ Name: rlstatsapi
3
+ Version: 0.1.0
4
+ Summary: Rocket League Stats API client
5
+ Author: Manuel Cabral
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/manucabral/RocketLeagueStatsAPI
8
+ Keywords: rocket-league,stats,websocket,asyncio
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3 :: Only
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Topic :: Games/Entertainment
14
+ Classifier: Topic :: Software Development :: Libraries
15
+ Requires-Python: >=3.11
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Dynamic: license-file
19
+
20
+ # rlstatsapi
21
+
22
+ Simple and fast Python client to read Rocket League Stats API events.
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ pip install -e .
28
+ ```
29
+
30
+ ## Configure Rocket League
31
+
32
+ Before starting the game, edit:
33
+
34
+ `<Install Dir>\TAGame\Config\DefaultStatsAPI.ini`
35
+
36
+ Minimum required values:
37
+
38
+ - `PacketSendRate=60` (any value > 0)
39
+ - `Port=49123`
40
+
41
+ Then restart Rocket League.
42
+
43
+ ## Quick usage
44
+
45
+ ```python
46
+ import asyncio
47
+ import logging
48
+ from rlstatsapi import StatsClient
49
+
50
+ logging.basicConfig(level=logging.INFO)
51
+
52
+
53
+ async def main() -> None:
54
+ client = StatsClient(log_enabled=True)
55
+ client.on_any(lambda msg: print(msg.event, msg.data))
56
+
57
+ await client.connect()
58
+ try:
59
+ await asyncio.Event().wait()
60
+ finally:
61
+ await client.disconnect()
62
+
63
+
64
+ asyncio.run(main())
65
+ ```
66
+
67
+ ## Public API
68
+
69
+ - `StatsClient(...)`
70
+ - `connect()`
71
+ - `disconnect()`
72
+ - `on(event_name, handler)`
73
+ - `on_any(handler)`
74
+ - `events()`
@@ -0,0 +1,10 @@
1
+ rlstatsapi/__init__.py,sha256=SoTKr3z3GE7TLVNUZFLC3vPLhRS2H5cwEX61537akxk,188
2
+ rlstatsapi/_websocket.py,sha256=clcv8JyWmKltWaO1rdRz5Ass8Dw-PyLddyFkVVOCtPs,5287
3
+ rlstatsapi/client.py,sha256=1BRMx4DhVhFSwO1RFNr9d48QHzunTc9ETh5Lle8szfc,5322
4
+ rlstatsapi/events.py,sha256=JBiiZPmccErEnKkE4CkUT6itHVV6TzwgcuGReo82TzQ,643
5
+ rlstatsapi/models.py,sha256=BxuLyRCRHMWB6l-gLTq5iJyMQtU5hnL4IbXVMXBcTLk,270
6
+ rlstatsapi-0.1.0.dist-info/licenses/LICENSE,sha256=sGDuB5mcSzEcvUMdHUnzZ33HJOb_ywbBOeccH_yFy08,1082
7
+ rlstatsapi-0.1.0.dist-info/METADATA,sha256=A5S1M8dZIzrbUDDfBddRa8ffPvxsuG5xly8Ji0S5M-Q,1617
8
+ rlstatsapi-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
9
+ rlstatsapi-0.1.0.dist-info/top_level.txt,sha256=3EqyA0VBelcI4k7GKueCofwKoTkOhPUaPY8RZm0b36U,11
10
+ rlstatsapi-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Manu
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.
@@ -0,0 +1 @@
1
+ rlstatsapi