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 +7 -0
- rlstatsapi/_websocket.py +176 -0
- rlstatsapi/client.py +152 -0
- rlstatsapi/events.py +29 -0
- rlstatsapi/models.py +13 -0
- rlstatsapi-0.1.0.dist-info/METADATA +74 -0
- rlstatsapi-0.1.0.dist-info/RECORD +10 -0
- rlstatsapi-0.1.0.dist-info/WHEEL +5 -0
- rlstatsapi-0.1.0.dist-info/licenses/LICENSE +21 -0
- rlstatsapi-0.1.0.dist-info/top_level.txt +1 -0
rlstatsapi/__init__.py
ADDED
rlstatsapi/_websocket.py
ADDED
|
@@ -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,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
|