rlstatsapi 0.1.0__tar.gz → 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,112 @@
1
+ Metadata-Version: 2.4
2
+ Name: rlstatsapi
3
+ Version: 0.1.2
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,socket,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
+ # RL Stats API
21
+
22
+ `rlstatsapi` is a simple and fast Python client for reading live Rocket League Stats API events over a local TCP socket.
23
+
24
+ ## Install
25
+
26
+ From PyPI:
27
+
28
+ ```bash
29
+ pip install rlstatsapi
30
+ ```
31
+
32
+ From GitHub (latest `main`):
33
+
34
+ ```bash
35
+ pip install git+https://github.com/manucabral/RocketLeagueStatsAPI.git
36
+ ```
37
+
38
+ ## Rocket League setup
39
+
40
+ Before launching Rocket League, edit:
41
+
42
+ `<Install Dir>\TAGame\Config\DefaultStatsAPI.ini`
43
+
44
+ Use at least:
45
+
46
+ - `PacketSendRate=30` (any value `> 0` enables the exporter)
47
+ - `Port=49123`
48
+
49
+ Restart the game after changing the file.
50
+
51
+ ## Quick start
52
+
53
+ ```python
54
+ import asyncio
55
+ import logging
56
+ from rlstatsapi import StatsClient
57
+
58
+ logging.basicConfig(level=logging.INFO)
59
+
60
+ async def main() -> None:
61
+ client = StatsClient(log_enabled=True)
62
+ client.on_any(lambda msg: print(msg.event, msg.data))
63
+
64
+ await client.connect()
65
+ try:
66
+ await asyncio.Event().wait()
67
+ finally:
68
+ await client.disconnect()
69
+
70
+ asyncio.run(main())
71
+ ```
72
+
73
+ ## Example Event (Goals)
74
+
75
+ ```python
76
+ import asyncio
77
+ from rlstatsapi import StatsClient
78
+ from rlstatsapi.models import EventMessage
79
+ from rlstatsapi.types import GoalScoredPayload, cast_event_data
80
+
81
+ async def on_goal(msg: EventMessage) -> None:
82
+ data: GoalScoredPayload = cast_event_data("GoalScored", msg.data)
83
+ scorer = data.get("Scorer", {})
84
+ print("Goal by:", scorer.get("Name"))
85
+
86
+ async def main() -> None:
87
+ client = StatsClient()
88
+ client.on("GoalScored", on_goal)
89
+ await client.connect()
90
+ try:
91
+ await asyncio.Event().wait()
92
+ finally:
93
+ await client.disconnect()
94
+
95
+ asyncio.run(main())
96
+ ```
97
+
98
+
99
+ ## Public API
100
+
101
+ - `StatsClient(...)`
102
+ - `connect()` / `disconnect()`
103
+ - `on(event_name, handler)`
104
+ - `on_any(handler)`
105
+ - `events()`
106
+ - typed helpers: `EventName`, `TypedEventMessage[...]`, `cast_event_data(...)`
107
+
108
+
109
+ ## Notes
110
+
111
+ - Works for regular matches. Some fields like `MatchGuid` are only present in online/LAN contexts.
112
+ - In current builds this endpoint may behave as plain TCP JSON stream instead of websocket upgrade. This library handles the TCP stream format.
@@ -0,0 +1,93 @@
1
+ # RL Stats API
2
+
3
+ `rlstatsapi` is a simple and fast Python client for reading live Rocket League Stats API events over a local TCP socket.
4
+
5
+ ## Install
6
+
7
+ From PyPI:
8
+
9
+ ```bash
10
+ pip install rlstatsapi
11
+ ```
12
+
13
+ From GitHub (latest `main`):
14
+
15
+ ```bash
16
+ pip install git+https://github.com/manucabral/RocketLeagueStatsAPI.git
17
+ ```
18
+
19
+ ## Rocket League setup
20
+
21
+ Before launching Rocket League, edit:
22
+
23
+ `<Install Dir>\TAGame\Config\DefaultStatsAPI.ini`
24
+
25
+ Use at least:
26
+
27
+ - `PacketSendRate=30` (any value `> 0` enables the exporter)
28
+ - `Port=49123`
29
+
30
+ Restart the game after changing the file.
31
+
32
+ ## Quick start
33
+
34
+ ```python
35
+ import asyncio
36
+ import logging
37
+ from rlstatsapi import StatsClient
38
+
39
+ logging.basicConfig(level=logging.INFO)
40
+
41
+ async def main() -> None:
42
+ client = StatsClient(log_enabled=True)
43
+ client.on_any(lambda msg: print(msg.event, msg.data))
44
+
45
+ await client.connect()
46
+ try:
47
+ await asyncio.Event().wait()
48
+ finally:
49
+ await client.disconnect()
50
+
51
+ asyncio.run(main())
52
+ ```
53
+
54
+ ## Example Event (Goals)
55
+
56
+ ```python
57
+ import asyncio
58
+ from rlstatsapi import StatsClient
59
+ from rlstatsapi.models import EventMessage
60
+ from rlstatsapi.types import GoalScoredPayload, cast_event_data
61
+
62
+ async def on_goal(msg: EventMessage) -> None:
63
+ data: GoalScoredPayload = cast_event_data("GoalScored", msg.data)
64
+ scorer = data.get("Scorer", {})
65
+ print("Goal by:", scorer.get("Name"))
66
+
67
+ async def main() -> None:
68
+ client = StatsClient()
69
+ client.on("GoalScored", on_goal)
70
+ await client.connect()
71
+ try:
72
+ await asyncio.Event().wait()
73
+ finally:
74
+ await client.disconnect()
75
+
76
+ asyncio.run(main())
77
+ ```
78
+
79
+
80
+ ## Public API
81
+
82
+ - `StatsClient(...)`
83
+ - `connect()` / `disconnect()`
84
+ - `on(event_name, handler)`
85
+ - `on_any(handler)`
86
+ - `events()`
87
+ - typed helpers: `EventName`, `TypedEventMessage[...]`, `cast_event_data(...)`
88
+
89
+
90
+ ## Notes
91
+
92
+ - Works for regular matches. Some fields like `MatchGuid` are only present in online/LAN contexts.
93
+ - In current builds this endpoint may behave as plain TCP JSON stream instead of websocket upgrade. This library handles the TCP stream format.
@@ -1,30 +1,30 @@
1
- [build-system]
2
- requires = ["setuptools>=69", "wheel"]
3
- build-backend = "setuptools.build_meta"
4
-
5
- [project]
6
- name = "rlstatsapi"
7
- version = "0.1.0"
8
- description = "Rocket League Stats API client"
9
- readme = "README.md"
10
- requires-python = ">=3.11"
11
- license = { text = "MIT" }
12
- authors = [{ name = "Manuel Cabral" }]
13
- keywords = ["rocket-league", "stats", "websocket", "asyncio"]
14
- classifiers = [
15
- "Programming Language :: Python :: 3",
16
- "Programming Language :: Python :: 3 :: Only",
17
- "License :: OSI Approved :: MIT License",
18
- "Operating System :: OS Independent",
19
- "Topic :: Games/Entertainment",
20
- "Topic :: Software Development :: Libraries",
21
- ]
22
-
23
- [project.urls]
24
- Homepage = "https://github.com/manucabral/RocketLeagueStatsAPI"
25
-
26
- [tool.setuptools]
27
- package-dir = {"" = "src"}
28
-
29
- [tool.setuptools.packages.find]
30
- where = ["src"]
1
+ [build-system]
2
+ requires = ["setuptools>=69", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "rlstatsapi"
7
+ version = "0.1.2"
8
+ description = "Rocket League Stats API client"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Manuel Cabral" }]
13
+ keywords = ["rocket-league", "stats", "socket", "asyncio"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3 :: Only",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ "Topic :: Games/Entertainment",
20
+ "Topic :: Software Development :: Libraries",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/manucabral/RocketLeagueStatsAPI"
25
+
26
+ [tool.setuptools]
27
+ package-dir = {"" = "src"}
28
+
29
+ [tool.setuptools.packages.find]
30
+ where = ["src"]
@@ -0,0 +1,25 @@
1
+ """rlstatsapi public API."""
2
+
3
+ from .client import StatsClient
4
+ from .events import KNOWN_EVENTS
5
+ from .models import EventMessage
6
+ from .types import (
7
+ EventName,
8
+ GoalScoredPayload,
9
+ KnownEventMessage,
10
+ TypedEventMessage,
11
+ UpdateStatePayload,
12
+ cast_event_data,
13
+ )
14
+
15
+ __all__ = [
16
+ "StatsClient",
17
+ "EventMessage",
18
+ "KNOWN_EVENTS",
19
+ "EventName",
20
+ "TypedEventMessage",
21
+ "KnownEventMessage",
22
+ "UpdateStatePayload",
23
+ "GoalScoredPayload",
24
+ "cast_event_data",
25
+ ]
@@ -9,7 +9,6 @@ from collections import defaultdict
9
9
  from typing import Any, AsyncIterator, Awaitable, Callable
10
10
 
11
11
  from .models import EventMessage
12
- from ._websocket import SimpleWebSocket, connect_websocket
13
12
 
14
13
  Handler = Callable[[EventMessage], Awaitable[None] | None]
15
14
 
@@ -41,7 +40,8 @@ class StatsClient:
41
40
  self._handlers_any: list[Handler] = []
42
41
  self._logger = logging.getLogger("rlstatsapi")
43
42
 
44
- self._ws: SimpleWebSocket | None = None
43
+ self._reader: asyncio.StreamReader | None = None
44
+ self._writer: asyncio.StreamWriter | None = None
45
45
  self._reader_task: asyncio.Task[None] | None = None
46
46
  self._stopping = False
47
47
 
@@ -58,10 +58,12 @@ class StatsClient:
58
58
  self._stopping = True
59
59
  self._log("disconnect requested")
60
60
 
61
- if self._ws is not None:
61
+ if self._writer is not None:
62
62
  with contextlib.suppress(Exception):
63
- await self._ws.close()
64
- self._ws = None
63
+ self._writer.close()
64
+ await self._writer.wait_closed()
65
+ self._writer = None
66
+ self._reader = None
65
67
 
66
68
  if self._reader_task is not None:
67
69
  self._reader_task.cancel()
@@ -83,14 +85,13 @@ class StatsClient:
83
85
  async def _run(self) -> None:
84
86
  while not self._stopping:
85
87
  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,
88
+ self._log(f"connecting to tcp://{self.host}:{self.port}")
89
+ self._reader, self._writer = await asyncio.wait_for(
90
+ asyncio.open_connection(host=self.host, port=self.port),
90
91
  timeout=self.connect_timeout,
91
92
  )
92
- self._log("websocket connected")
93
- await self._read_loop(self._ws)
93
+ self._log("tcp socket connected")
94
+ await self._read_loop(self._reader)
94
95
  except asyncio.CancelledError:
95
96
  self._log("reader task cancelled")
96
97
  raise
@@ -102,22 +103,48 @@ class StatsClient:
102
103
  self._log(f"reconnecting in {self.reconnect_delay:.2f}s")
103
104
  await asyncio.sleep(self.reconnect_delay)
104
105
  finally:
105
- if self._ws is not None:
106
+ if self._writer is not None:
106
107
  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:
108
+ self._writer.close()
109
+ await self._writer.wait_closed()
110
+ self._writer = None
111
+ self._reader = None
112
+ self._log("tcp socket closed")
113
+
114
+ async def _read_loop(self, reader: asyncio.StreamReader) -> None:
115
+ decoder = json.JSONDecoder()
116
+ buffer = ""
112
117
  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
+ chunk = await reader.read(4096)
119
+ if not chunk:
120
+ raise ConnectionAbortedError("Socket closed by peer")
121
+ buffer += chunk.decode("utf-8", errors="ignore")
122
+
123
+ while True:
124
+ lstripped = buffer.lstrip()
125
+ if not lstripped:
126
+ buffer = ""
127
+ break
128
+ if lstripped is not buffer:
129
+ buffer = lstripped
130
+
131
+ try:
132
+ decoded, end_idx = decoder.raw_decode(buffer)
133
+ except json.JSONDecodeError:
134
+ break
135
+
136
+ raw = buffer[:end_idx]
137
+ buffer = buffer[end_idx:]
138
+
139
+ message = _parse_message_obj(
140
+ decoded, raw=raw, include_raw=self.include_raw
141
+ )
142
+ if message is None:
143
+ self._log("ignored invalid message (json/envelope)")
144
+ continue
118
145
 
119
- await self._queue.put(message)
120
- await self._dispatch(message)
146
+ await self._queue.put(message)
147
+ await self._dispatch(message)
121
148
 
122
149
  async def _dispatch(self, message: EventMessage) -> None:
123
150
  handlers = self._handlers_any + self._handlers_by_event.get(message.event, [])
@@ -137,16 +164,21 @@ class StatsClient:
137
164
  self._logger.info("[StatsClient] %s", message)
138
165
 
139
166
 
140
- def _parse_message(raw: str, *, include_raw: bool) -> EventMessage | None:
141
- try:
142
- decoded = json.loads(raw)
143
- except json.JSONDecodeError:
167
+ def _parse_message_obj(
168
+ decoded: Any, *, raw: str, include_raw: bool
169
+ ) -> EventMessage | None:
170
+ if not isinstance(decoded, dict):
144
171
  return None
145
-
146
172
  event = decoded.get("Event")
147
173
  data = decoded.get("Data")
148
174
 
175
+ if isinstance(data, str):
176
+ try:
177
+ data = json.loads(data)
178
+ except json.JSONDecodeError:
179
+ return None
180
+
149
181
  if not isinstance(event, str) or not isinstance(data, dict):
150
182
  return None
151
183
 
152
- return EventMessage(event=event, data=data, raw=raw if include_raw else None)
184
+ return EventMessage(event=event, data=data, raw=raw if include_raw else None)
@@ -0,0 +1,373 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Generic, Literal, Mapping, TypeAlias, TypeVar, TypedDict, cast, overload
5
+
6
+ EventName: TypeAlias = Literal[
7
+ "UpdateState",
8
+ "BallHit",
9
+ "ClockUpdatedSeconds",
10
+ "CountdownBegin",
11
+ "CrossbarHit",
12
+ "GoalReplayEnd",
13
+ "GoalReplayStart",
14
+ "GoalReplayWillEnd",
15
+ "GoalScored",
16
+ "MatchCreated",
17
+ "MatchInitialized",
18
+ "MatchDestroyed",
19
+ "MatchEnded",
20
+ "MatchPaused",
21
+ "MatchUnpaused",
22
+ "PodiumStart",
23
+ "ReplayCreated",
24
+ "RoundStarted",
25
+ "StatfeedEvent",
26
+ ]
27
+
28
+
29
+ class Vector3(TypedDict):
30
+ X: float
31
+ Y: float
32
+ Z: float
33
+
34
+
35
+ class PlayerRef(TypedDict):
36
+ Name: str
37
+ Shortcut: int
38
+ TeamNum: int
39
+
40
+
41
+ class TeamState(TypedDict):
42
+ Name: str
43
+ TeamNum: int
44
+ Score: int
45
+ ColorPrimary: str
46
+ ColorSecondary: str
47
+
48
+
49
+ class BallState(TypedDict):
50
+ Speed: float
51
+ TeamNum: int
52
+
53
+
54
+ class PlayerAttacker(TypedDict):
55
+ Name: str
56
+ Shortcut: int
57
+ TeamNum: int
58
+
59
+
60
+ class TargetRef(TypedDict):
61
+ Name: str
62
+ Shortcut: int
63
+ TeamNum: int
64
+
65
+
66
+ class UpdateStatePlayer(TypedDict, total=False):
67
+ Name: str
68
+ PrimaryId: str
69
+ Shortcut: int
70
+ TeamNum: int
71
+ Score: int
72
+ Goals: int
73
+ Shots: int
74
+ Assists: int
75
+ Saves: int
76
+ Touches: int
77
+ CarTouches: int
78
+ Demos: int
79
+ bHasCar: bool
80
+ Speed: float
81
+ Boost: int
82
+ bBoosting: bool
83
+ bOnGround: bool
84
+ bOnWall: bool
85
+ bPowersliding: bool
86
+ bDemolished: bool
87
+ Attacker: PlayerAttacker
88
+ bSupersonic: bool
89
+
90
+
91
+ class UpdateStateGame(TypedDict, total=False):
92
+ Teams: list[TeamState]
93
+ TimeSeconds: int
94
+ bOvertime: bool
95
+ Frame: int
96
+ Elapsed: float
97
+ Ball: BallState
98
+ bReplay: bool
99
+ bHasWinner: bool
100
+ Winner: str
101
+ Arena: str
102
+ bHasTarget: bool
103
+ Target: TargetRef
104
+
105
+
106
+ class UpdateStatePayload(TypedDict, total=False):
107
+ MatchGuid: str
108
+ Players: list[UpdateStatePlayer]
109
+ Game: UpdateStateGame
110
+
111
+
112
+ class BallHitBall(TypedDict):
113
+ PreHitSpeed: float
114
+ PostHitSpeed: float
115
+ Location: Vector3
116
+
117
+
118
+ class BallHitPayload(TypedDict, total=False):
119
+ MatchGuid: str
120
+ Players: list[PlayerRef]
121
+ Ball: BallHitBall
122
+
123
+
124
+ class ClockUpdatedSecondsPayload(TypedDict, total=False):
125
+ MatchGuid: str
126
+ TimeSeconds: int
127
+ bOvertime: bool
128
+
129
+
130
+ class CountdownBeginPayload(TypedDict, total=False):
131
+ MatchGuid: str
132
+
133
+
134
+ class BallLastTouch(TypedDict):
135
+ Player: PlayerRef
136
+ Speed: float
137
+
138
+
139
+ class CrossbarHitPayload(TypedDict, total=False):
140
+ MatchGuid: str
141
+ BallLocation: Vector3
142
+ BallSpeed: float
143
+ ImpactForce: float
144
+ BallLastTouch: BallLastTouch
145
+
146
+
147
+ class GoalReplayEndPayload(TypedDict, total=False):
148
+ MatchGuid: str
149
+
150
+
151
+ class GoalReplayStartPayload(TypedDict, total=False):
152
+ MatchGuid: str
153
+
154
+
155
+ class GoalReplayWillEndPayload(TypedDict, total=False):
156
+ MatchGuid: str
157
+
158
+
159
+ class GoalScoredPayload(TypedDict, total=False):
160
+ MatchGuid: str
161
+ GoalSpeed: float
162
+ GoalTime: float
163
+ ImpactLocation: Vector3
164
+ Scorer: PlayerRef
165
+ Assister: PlayerRef
166
+ BallLastTouch: BallLastTouch
167
+
168
+
169
+ class MatchCreatedPayload(TypedDict, total=False):
170
+ MatchGuid: str
171
+
172
+
173
+ class MatchInitializedPayload(TypedDict, total=False):
174
+ MatchGuid: str
175
+
176
+
177
+ class MatchDestroyedPayload(TypedDict, total=False):
178
+ MatchGuid: str
179
+
180
+
181
+ class MatchEndedPayload(TypedDict, total=False):
182
+ MatchGuid: str
183
+ WinnerTeamNum: int
184
+
185
+
186
+ class MatchPausedPayload(TypedDict, total=False):
187
+ MatchGuid: str
188
+
189
+
190
+ class MatchUnpausedPayload(TypedDict, total=False):
191
+ MatchGuid: str
192
+
193
+
194
+ class PodiumStartPayload(TypedDict, total=False):
195
+ MatchGuid: str
196
+
197
+
198
+ class ReplayCreatedPayload(TypedDict, total=False):
199
+ MatchGuid: str
200
+
201
+
202
+ class RoundStartedPayload(TypedDict, total=False):
203
+ MatchGuid: str
204
+
205
+
206
+ class StatfeedEventPayload(TypedDict, total=False):
207
+ MatchGuid: str
208
+ EventName: str
209
+ Type: str
210
+ MainTarget: PlayerRef
211
+ SecondaryTarget: PlayerRef
212
+
213
+
214
+ KnownEventPayload: TypeAlias = (
215
+ UpdateStatePayload
216
+ | BallHitPayload
217
+ | ClockUpdatedSecondsPayload
218
+ | CountdownBeginPayload
219
+ | CrossbarHitPayload
220
+ | GoalReplayEndPayload
221
+ | GoalReplayStartPayload
222
+ | GoalReplayWillEndPayload
223
+ | GoalScoredPayload
224
+ | MatchCreatedPayload
225
+ | MatchInitializedPayload
226
+ | MatchDestroyedPayload
227
+ | MatchEndedPayload
228
+ | MatchPausedPayload
229
+ | MatchUnpausedPayload
230
+ | PodiumStartPayload
231
+ | ReplayCreatedPayload
232
+ | RoundStartedPayload
233
+ | StatfeedEventPayload
234
+ )
235
+
236
+ KnownEventPayloadMap: TypeAlias = dict[EventName, KnownEventPayload]
237
+
238
+ TData = TypeVar("TData", bound=Mapping[str, Any])
239
+
240
+
241
+ @dataclass(slots=True)
242
+ class TypedEventMessage(Generic[TData]):
243
+ event: str
244
+ data: TData
245
+ raw: str | None = None
246
+
247
+
248
+ KnownEventMessage: TypeAlias = TypedEventMessage[KnownEventPayload]
249
+
250
+
251
+ @overload
252
+ def cast_event_data(
253
+ event_name: Literal["UpdateState"], data: Mapping[str, Any]
254
+ ) -> UpdateStatePayload: ...
255
+
256
+
257
+ @overload
258
+ def cast_event_data(
259
+ event_name: Literal["BallHit"], data: Mapping[str, Any]
260
+ ) -> BallHitPayload: ...
261
+
262
+
263
+ @overload
264
+ def cast_event_data(
265
+ event_name: Literal["ClockUpdatedSeconds"], data: Mapping[str, Any]
266
+ ) -> ClockUpdatedSecondsPayload: ...
267
+
268
+
269
+ @overload
270
+ def cast_event_data(
271
+ event_name: Literal["CountdownBegin"], data: Mapping[str, Any]
272
+ ) -> CountdownBeginPayload: ...
273
+
274
+
275
+ @overload
276
+ def cast_event_data(
277
+ event_name: Literal["CrossbarHit"], data: Mapping[str, Any]
278
+ ) -> CrossbarHitPayload: ...
279
+
280
+
281
+ @overload
282
+ def cast_event_data(
283
+ event_name: Literal["GoalReplayEnd"], data: Mapping[str, Any]
284
+ ) -> GoalReplayEndPayload: ...
285
+
286
+
287
+ @overload
288
+ def cast_event_data(
289
+ event_name: Literal["GoalReplayStart"], data: Mapping[str, Any]
290
+ ) -> GoalReplayStartPayload: ...
291
+
292
+
293
+ @overload
294
+ def cast_event_data(
295
+ event_name: Literal["GoalReplayWillEnd"], data: Mapping[str, Any]
296
+ ) -> GoalReplayWillEndPayload: ...
297
+
298
+
299
+ @overload
300
+ def cast_event_data(
301
+ event_name: Literal["GoalScored"], data: Mapping[str, Any]
302
+ ) -> GoalScoredPayload: ...
303
+
304
+
305
+ @overload
306
+ def cast_event_data(
307
+ event_name: Literal["MatchCreated"], data: Mapping[str, Any]
308
+ ) -> MatchCreatedPayload: ...
309
+
310
+
311
+ @overload
312
+ def cast_event_data(
313
+ event_name: Literal["MatchInitialized"], data: Mapping[str, Any]
314
+ ) -> MatchInitializedPayload: ...
315
+
316
+
317
+ @overload
318
+ def cast_event_data(
319
+ event_name: Literal["MatchDestroyed"], data: Mapping[str, Any]
320
+ ) -> MatchDestroyedPayload: ...
321
+
322
+
323
+ @overload
324
+ def cast_event_data(
325
+ event_name: Literal["MatchEnded"], data: Mapping[str, Any]
326
+ ) -> MatchEndedPayload: ...
327
+
328
+
329
+ @overload
330
+ def cast_event_data(
331
+ event_name: Literal["MatchPaused"], data: Mapping[str, Any]
332
+ ) -> MatchPausedPayload: ...
333
+
334
+
335
+ @overload
336
+ def cast_event_data(
337
+ event_name: Literal["MatchUnpaused"], data: Mapping[str, Any]
338
+ ) -> MatchUnpausedPayload: ...
339
+
340
+
341
+ @overload
342
+ def cast_event_data(
343
+ event_name: Literal["PodiumStart"], data: Mapping[str, Any]
344
+ ) -> PodiumStartPayload: ...
345
+
346
+
347
+ @overload
348
+ def cast_event_data(
349
+ event_name: Literal["ReplayCreated"], data: Mapping[str, Any]
350
+ ) -> ReplayCreatedPayload: ...
351
+
352
+
353
+ @overload
354
+ def cast_event_data(
355
+ event_name: Literal["RoundStarted"], data: Mapping[str, Any]
356
+ ) -> RoundStartedPayload: ...
357
+
358
+
359
+ @overload
360
+ def cast_event_data(
361
+ event_name: Literal["StatfeedEvent"], data: Mapping[str, Any]
362
+ ) -> StatfeedEventPayload: ...
363
+
364
+
365
+ @overload
366
+ def cast_event_data(event_name: str, data: Mapping[str, Any]) -> Mapping[str, Any]: ...
367
+
368
+
369
+ def cast_event_data(event_name: str, data: Mapping[str, Any]) -> Mapping[str, Any]:
370
+ """Type-only helper to narrow `data` based on event name."""
371
+
372
+ _ = event_name
373
+ return cast(Mapping[str, Any], data)
@@ -0,0 +1,112 @@
1
+ Metadata-Version: 2.4
2
+ Name: rlstatsapi
3
+ Version: 0.1.2
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,socket,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
+ # RL Stats API
21
+
22
+ `rlstatsapi` is a simple and fast Python client for reading live Rocket League Stats API events over a local TCP socket.
23
+
24
+ ## Install
25
+
26
+ From PyPI:
27
+
28
+ ```bash
29
+ pip install rlstatsapi
30
+ ```
31
+
32
+ From GitHub (latest `main`):
33
+
34
+ ```bash
35
+ pip install git+https://github.com/manucabral/RocketLeagueStatsAPI.git
36
+ ```
37
+
38
+ ## Rocket League setup
39
+
40
+ Before launching Rocket League, edit:
41
+
42
+ `<Install Dir>\TAGame\Config\DefaultStatsAPI.ini`
43
+
44
+ Use at least:
45
+
46
+ - `PacketSendRate=30` (any value `> 0` enables the exporter)
47
+ - `Port=49123`
48
+
49
+ Restart the game after changing the file.
50
+
51
+ ## Quick start
52
+
53
+ ```python
54
+ import asyncio
55
+ import logging
56
+ from rlstatsapi import StatsClient
57
+
58
+ logging.basicConfig(level=logging.INFO)
59
+
60
+ async def main() -> None:
61
+ client = StatsClient(log_enabled=True)
62
+ client.on_any(lambda msg: print(msg.event, msg.data))
63
+
64
+ await client.connect()
65
+ try:
66
+ await asyncio.Event().wait()
67
+ finally:
68
+ await client.disconnect()
69
+
70
+ asyncio.run(main())
71
+ ```
72
+
73
+ ## Example Event (Goals)
74
+
75
+ ```python
76
+ import asyncio
77
+ from rlstatsapi import StatsClient
78
+ from rlstatsapi.models import EventMessage
79
+ from rlstatsapi.types import GoalScoredPayload, cast_event_data
80
+
81
+ async def on_goal(msg: EventMessage) -> None:
82
+ data: GoalScoredPayload = cast_event_data("GoalScored", msg.data)
83
+ scorer = data.get("Scorer", {})
84
+ print("Goal by:", scorer.get("Name"))
85
+
86
+ async def main() -> None:
87
+ client = StatsClient()
88
+ client.on("GoalScored", on_goal)
89
+ await client.connect()
90
+ try:
91
+ await asyncio.Event().wait()
92
+ finally:
93
+ await client.disconnect()
94
+
95
+ asyncio.run(main())
96
+ ```
97
+
98
+
99
+ ## Public API
100
+
101
+ - `StatsClient(...)`
102
+ - `connect()` / `disconnect()`
103
+ - `on(event_name, handler)`
104
+ - `on_any(handler)`
105
+ - `events()`
106
+ - typed helpers: `EventName`, `TypedEventMessage[...]`, `cast_event_data(...)`
107
+
108
+
109
+ ## Notes
110
+
111
+ - Works for regular matches. Some fields like `MatchGuid` are only present in online/LAN contexts.
112
+ - In current builds this endpoint may behave as plain TCP JSON stream instead of websocket upgrade. This library handles the TCP stream format.
@@ -2,10 +2,10 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  src/rlstatsapi/__init__.py
5
- src/rlstatsapi/_websocket.py
6
5
  src/rlstatsapi/client.py
7
6
  src/rlstatsapi/events.py
8
7
  src/rlstatsapi/models.py
8
+ src/rlstatsapi/types.py
9
9
  src/rlstatsapi.egg-info/PKG-INFO
10
10
  src/rlstatsapi.egg-info/SOURCES.txt
11
11
  src/rlstatsapi.egg-info/dependency_links.txt
rlstatsapi-0.1.0/PKG-INFO DELETED
@@ -1,74 +0,0 @@
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()`
@@ -1,55 +0,0 @@
1
- # rlstatsapi
2
-
3
- Simple and fast Python client to read Rocket League Stats API events.
4
-
5
- ## Install
6
-
7
- ```bash
8
- pip install -e .
9
- ```
10
-
11
- ## Configure Rocket League
12
-
13
- Before starting the game, edit:
14
-
15
- `<Install Dir>\TAGame\Config\DefaultStatsAPI.ini`
16
-
17
- Minimum required values:
18
-
19
- - `PacketSendRate=60` (any value > 0)
20
- - `Port=49123`
21
-
22
- Then restart Rocket League.
23
-
24
- ## Quick usage
25
-
26
- ```python
27
- import asyncio
28
- import logging
29
- from rlstatsapi import StatsClient
30
-
31
- logging.basicConfig(level=logging.INFO)
32
-
33
-
34
- async def main() -> None:
35
- client = StatsClient(log_enabled=True)
36
- client.on_any(lambda msg: print(msg.event, msg.data))
37
-
38
- await client.connect()
39
- try:
40
- await asyncio.Event().wait()
41
- finally:
42
- await client.disconnect()
43
-
44
-
45
- asyncio.run(main())
46
- ```
47
-
48
- ## Public API
49
-
50
- - `StatsClient(...)`
51
- - `connect()`
52
- - `disconnect()`
53
- - `on(event_name, handler)`
54
- - `on_any(handler)`
55
- - `events()`
@@ -1,7 +0,0 @@
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"]
@@ -1,176 +0,0 @@
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
@@ -1,74 +0,0 @@
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()`
File without changes
File without changes