rlstatsapi 0.1.0__tar.gz → 0.1.1__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.
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rlstatsapi
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Rocket League Stats API client
5
5
  Author: Manuel Cabral
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://github.com/manucabral/RocketLeagueStatsAPI
8
- Keywords: rocket-league,stats,websocket,asyncio
8
+ Keywords: rocket-league,stats,socket,asyncio
9
9
  Classifier: Programming Language :: Python :: 3
10
10
  Classifier: Programming Language :: Python :: 3 :: Only
11
11
  Classifier: License :: OSI Approved :: MIT License
@@ -17,30 +17,38 @@ Description-Content-Type: text/markdown
17
17
  License-File: LICENSE
18
18
  Dynamic: license-file
19
19
 
20
- # rlstatsapi
20
+ # RL Stats API
21
21
 
22
- Simple and fast Python client to read Rocket League Stats API events.
22
+ `rlstatsapi` is a simple and fast Python client for reading live Rocket League Stats API events over a local TCP socket.
23
23
 
24
24
  ## Install
25
25
 
26
+ From PyPI:
27
+
26
28
  ```bash
27
- pip install -e .
29
+ pip install rlstatsapi
28
30
  ```
29
31
 
30
- ## Configure Rocket League
32
+ From GitHub (latest `main`):
33
+
34
+ ```bash
35
+ pip install git+https://github.com/nania/RocketLeagueStatsAPI.git
36
+ ```
31
37
 
32
- Before starting the game, edit:
38
+ ## Rocket League setup
39
+
40
+ Before launching Rocket League, edit:
33
41
 
34
42
  `<Install Dir>\TAGame\Config\DefaultStatsAPI.ini`
35
43
 
36
- Minimum required values:
44
+ Use at least:
37
45
 
38
- - `PacketSendRate=60` (any value > 0)
46
+ - `PacketSendRate=30` (any value `> 0` enables the exporter)
39
47
  - `Port=49123`
40
48
 
41
- Then restart Rocket League.
49
+ Restart the game after changing the file.
42
50
 
43
- ## Quick usage
51
+ ## Quick start
44
52
 
45
53
  ```python
46
54
  import asyncio
@@ -66,9 +74,13 @@ asyncio.run(main())
66
74
 
67
75
  ## Public API
68
76
 
69
- - `StatsClient(...)`
70
- - `connect()`
71
- - `disconnect()`
77
+ - `StatsClient(host="127.0.0.1", port=49123, reconnect=True, reconnect_delay=0.5, include_raw=False, queue_size=2048, connect_timeout=5.0, log_enabled=False)`
78
+ - `connect()` / `disconnect()`
72
79
  - `on(event_name, handler)`
73
80
  - `on_any(handler)`
74
81
  - `events()`
82
+
83
+ ## Notes
84
+
85
+ - Works for regular matches. Some fields like `MatchGuid` are only present in online/LAN contexts.
86
+ - 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,67 @@
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/nania/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
+
42
+ async def main() -> None:
43
+ client = StatsClient(log_enabled=True)
44
+ client.on_any(lambda msg: print(msg.event, msg.data))
45
+
46
+ await client.connect()
47
+ try:
48
+ await asyncio.Event().wait()
49
+ finally:
50
+ await client.disconnect()
51
+
52
+
53
+ asyncio.run(main())
54
+ ```
55
+
56
+ ## Public API
57
+
58
+ - `StatsClient(host="127.0.0.1", port=49123, reconnect=True, reconnect_delay=0.5, include_raw=False, queue_size=2048, connect_timeout=5.0, log_enabled=False)`
59
+ - `connect()` / `disconnect()`
60
+ - `on(event_name, handler)`
61
+ - `on_any(handler)`
62
+ - `events()`
63
+
64
+ ## Notes
65
+
66
+ - Works for regular matches. Some fields like `MatchGuid` are only present in online/LAN contexts.
67
+ - In current builds this endpoint may behave as plain TCP JSON stream instead of websocket upgrade. This library handles the TCP stream format.
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "rlstatsapi"
7
- version = "0.1.0"
7
+ version = "0.1.1"
8
8
  description = "Rocket League Stats API client"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
11
11
  license = { text = "MIT" }
12
12
  authors = [{ name = "Manuel Cabral" }]
13
- keywords = ["rocket-league", "stats", "websocket", "asyncio"]
13
+ keywords = ["rocket-league", "stats", "socket", "asyncio"]
14
14
  classifiers = [
15
15
  "Programming Language :: Python :: 3",
16
16
  "Programming Language :: Python :: 3 :: Only",
@@ -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,15 +164,20 @@ 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
 
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rlstatsapi
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Rocket League Stats API client
5
5
  Author: Manuel Cabral
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://github.com/manucabral/RocketLeagueStatsAPI
8
- Keywords: rocket-league,stats,websocket,asyncio
8
+ Keywords: rocket-league,stats,socket,asyncio
9
9
  Classifier: Programming Language :: Python :: 3
10
10
  Classifier: Programming Language :: Python :: 3 :: Only
11
11
  Classifier: License :: OSI Approved :: MIT License
@@ -17,30 +17,38 @@ Description-Content-Type: text/markdown
17
17
  License-File: LICENSE
18
18
  Dynamic: license-file
19
19
 
20
- # rlstatsapi
20
+ # RL Stats API
21
21
 
22
- Simple and fast Python client to read Rocket League Stats API events.
22
+ `rlstatsapi` is a simple and fast Python client for reading live Rocket League Stats API events over a local TCP socket.
23
23
 
24
24
  ## Install
25
25
 
26
+ From PyPI:
27
+
26
28
  ```bash
27
- pip install -e .
29
+ pip install rlstatsapi
28
30
  ```
29
31
 
30
- ## Configure Rocket League
32
+ From GitHub (latest `main`):
33
+
34
+ ```bash
35
+ pip install git+https://github.com/nania/RocketLeagueStatsAPI.git
36
+ ```
31
37
 
32
- Before starting the game, edit:
38
+ ## Rocket League setup
39
+
40
+ Before launching Rocket League, edit:
33
41
 
34
42
  `<Install Dir>\TAGame\Config\DefaultStatsAPI.ini`
35
43
 
36
- Minimum required values:
44
+ Use at least:
37
45
 
38
- - `PacketSendRate=60` (any value > 0)
46
+ - `PacketSendRate=30` (any value `> 0` enables the exporter)
39
47
  - `Port=49123`
40
48
 
41
- Then restart Rocket League.
49
+ Restart the game after changing the file.
42
50
 
43
- ## Quick usage
51
+ ## Quick start
44
52
 
45
53
  ```python
46
54
  import asyncio
@@ -66,9 +74,13 @@ asyncio.run(main())
66
74
 
67
75
  ## Public API
68
76
 
69
- - `StatsClient(...)`
70
- - `connect()`
71
- - `disconnect()`
77
+ - `StatsClient(host="127.0.0.1", port=49123, reconnect=True, reconnect_delay=0.5, include_raw=False, queue_size=2048, connect_timeout=5.0, log_enabled=False)`
78
+ - `connect()` / `disconnect()`
72
79
  - `on(event_name, handler)`
73
80
  - `on_any(handler)`
74
81
  - `events()`
82
+
83
+ ## Notes
84
+
85
+ - Works for regular matches. Some fields like `MatchGuid` are only present in online/LAN contexts.
86
+ - In current builds this endpoint may behave as plain TCP JSON stream instead of websocket upgrade. This library handles the TCP stream format.
@@ -2,7 +2,6 @@ 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
@@ -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,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
File without changes
File without changes