rlstatsapi 0.1.5__tar.gz → 0.1.6__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.
- {rlstatsapi-0.1.5/src/rlstatsapi.egg-info → rlstatsapi-0.1.6}/PKG-INFO +2 -1
- {rlstatsapi-0.1.5 → rlstatsapi-0.1.6}/pyproject.toml +2 -2
- {rlstatsapi-0.1.5 → rlstatsapi-0.1.6}/src/rlstatsapi/__init__.py +4 -2
- {rlstatsapi-0.1.5 → rlstatsapi-0.1.6}/src/rlstatsapi/__main__.py +6 -0
- {rlstatsapi-0.1.5 → rlstatsapi-0.1.6}/src/rlstatsapi/client.py +137 -30
- {rlstatsapi-0.1.5 → rlstatsapi-0.1.6}/src/rlstatsapi/config.py +28 -6
- {rlstatsapi-0.1.5 → rlstatsapi-0.1.6}/src/rlstatsapi/models.py +31 -0
- {rlstatsapi-0.1.5 → rlstatsapi-0.1.6}/src/rlstatsapi/state.py +55 -1
- {rlstatsapi-0.1.5 → rlstatsapi-0.1.6/src/rlstatsapi.egg-info}/PKG-INFO +2 -1
- {rlstatsapi-0.1.5 → rlstatsapi-0.1.6}/src/rlstatsapi.egg-info/SOURCES.txt +2 -0
- {rlstatsapi-0.1.5 → rlstatsapi-0.1.6}/src/rlstatsapi.egg-info/requires.txt +1 -0
- rlstatsapi-0.1.6/tests/test_client.py +49 -0
- rlstatsapi-0.1.6/tests/test_client_unit.py +340 -0
- {rlstatsapi-0.1.5 → rlstatsapi-0.1.6}/tests/test_config.py +10 -10
- {rlstatsapi-0.1.5 → rlstatsapi-0.1.6}/LICENSE +0 -0
- {rlstatsapi-0.1.5 → rlstatsapi-0.1.6}/README.md +0 -0
- {rlstatsapi-0.1.5 → rlstatsapi-0.1.6}/setup.cfg +0 -0
- {rlstatsapi-0.1.5 → rlstatsapi-0.1.6}/src/rlstatsapi/events.py +0 -0
- {rlstatsapi-0.1.5 → rlstatsapi-0.1.6}/src/rlstatsapi/py.typed +0 -0
- {rlstatsapi-0.1.5 → rlstatsapi-0.1.6}/src/rlstatsapi/types.py +0 -0
- {rlstatsapi-0.1.5 → rlstatsapi-0.1.6}/src/rlstatsapi.egg-info/dependency_links.txt +0 -0
- {rlstatsapi-0.1.5 → rlstatsapi-0.1.6}/src/rlstatsapi.egg-info/entry_points.txt +0 -0
- {rlstatsapi-0.1.5 → rlstatsapi-0.1.6}/src/rlstatsapi.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rlstatsapi
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Summary: Rocket League Stats API client
|
|
5
5
|
Author: Manuel Cabral
|
|
6
6
|
License-Expression: MIT
|
|
@@ -19,6 +19,7 @@ License-File: LICENSE
|
|
|
19
19
|
Provides-Extra: dev
|
|
20
20
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
21
21
|
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
22
|
+
Requires-Dist: black; extra == "dev"
|
|
22
23
|
Dynamic: license-file
|
|
23
24
|
|
|
24
25
|
<img width="600" alt="rlstatsapi-logo" src="https://github.com/manucabral/RocketLeagueStatsAPI/blob/main/docs/assets/social-preview.png" />
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "rlstatsapi"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.6"
|
|
8
8
|
description = "Rocket League Stats API client"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -28,7 +28,7 @@ Repository = "https://github.com/manucabral/RocketLeagueStatsAPI"
|
|
|
28
28
|
rlstatsapi = "rlstatsapi.__main__:main"
|
|
29
29
|
|
|
30
30
|
[project.optional-dependencies]
|
|
31
|
-
dev = ["pytest>=8", "pytest-asyncio>=0.23"]
|
|
31
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.23", "black"]
|
|
32
32
|
|
|
33
33
|
[tool.pytest.ini_options]
|
|
34
34
|
asyncio_mode = "auto"
|
|
@@ -8,7 +8,7 @@ helpers so consumers can import from `rlstatsapi` directly.
|
|
|
8
8
|
from importlib.metadata import PackageNotFoundError
|
|
9
9
|
from importlib.metadata import version as _pkg_version
|
|
10
10
|
|
|
11
|
-
from .client import StatsClient
|
|
11
|
+
from .client import ConnectionState, StatsClient
|
|
12
12
|
from .config import (
|
|
13
13
|
DEFAULT_PACKET_SEND_RATE,
|
|
14
14
|
DEFAULT_STATS_API_FILENAME,
|
|
@@ -22,7 +22,7 @@ from .config import (
|
|
|
22
22
|
set_stats_api_port,
|
|
23
23
|
)
|
|
24
24
|
from .events import KNOWN_EVENTS
|
|
25
|
-
from .models import ClientMetrics, EventMessage, MatchStateSnapshot
|
|
25
|
+
from .models import ClientMetrics, EventMessage, MatchStateSnapshot, PlayerSnapshot
|
|
26
26
|
from .state import MatchStateTracker
|
|
27
27
|
from .types import (
|
|
28
28
|
BallHitPayload,
|
|
@@ -58,10 +58,12 @@ except PackageNotFoundError:
|
|
|
58
58
|
__all__ = [
|
|
59
59
|
"__version__",
|
|
60
60
|
"StatsClient",
|
|
61
|
+
"ConnectionState",
|
|
61
62
|
"StatsAPIConfigStatus",
|
|
62
63
|
"EventMessage",
|
|
63
64
|
"ClientMetrics",
|
|
64
65
|
"MatchStateSnapshot",
|
|
66
|
+
"PlayerSnapshot",
|
|
65
67
|
"MatchStateTracker",
|
|
66
68
|
"KNOWN_EVENTS",
|
|
67
69
|
"DEFAULT_PACKET_SEND_RATE",
|
|
@@ -26,6 +26,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
def _build_parser() -> argparse.ArgumentParser:
|
|
29
|
+
"""Build and return the top-level argument parser with all subcommands."""
|
|
29
30
|
parser = argparse.ArgumentParser(prog="rlstatsapi")
|
|
30
31
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
31
32
|
|
|
@@ -87,12 +88,14 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
87
88
|
|
|
88
89
|
|
|
89
90
|
def _run_status(args: argparse.Namespace) -> int:
|
|
91
|
+
"""Print the current TAStatsAPI.ini config as JSON."""
|
|
90
92
|
status = get_stats_api_status(args.path)
|
|
91
93
|
print(json.dumps(asdict(status), indent=2))
|
|
92
94
|
return 0
|
|
93
95
|
|
|
94
96
|
|
|
95
97
|
def _run_enable(args: argparse.Namespace) -> int:
|
|
98
|
+
"""Enable the Stats API exporter in TAStatsAPI.ini and print the result."""
|
|
96
99
|
status = configure_stats_api(
|
|
97
100
|
enabled=True,
|
|
98
101
|
port=args.port,
|
|
@@ -105,6 +108,7 @@ def _run_enable(args: argparse.Namespace) -> int:
|
|
|
105
108
|
|
|
106
109
|
|
|
107
110
|
def _run_disable(args: argparse.Namespace) -> int:
|
|
111
|
+
"""Disable the Stats API exporter in TAStatsAPI.ini and print the result."""
|
|
108
112
|
status = configure_stats_api(
|
|
109
113
|
enabled=False,
|
|
110
114
|
port=args.port,
|
|
@@ -117,10 +121,12 @@ def _run_disable(args: argparse.Namespace) -> int:
|
|
|
117
121
|
|
|
118
122
|
|
|
119
123
|
def _run_listen(args: argparse.Namespace) -> int:
|
|
124
|
+
"""Bridge the sync CLI entry point into the async ``_listen`` coroutine."""
|
|
120
125
|
return asyncio.run(_listen(args))
|
|
121
126
|
|
|
122
127
|
|
|
123
128
|
async def _listen(args: argparse.Namespace) -> int:
|
|
129
|
+
"""Connect to the local Stats API and stream events to stdout until Ctrl+C."""
|
|
124
130
|
async with StatsClient(
|
|
125
131
|
host=args.host,
|
|
126
132
|
port=args.port,
|
|
@@ -8,11 +8,13 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
import asyncio
|
|
10
10
|
import contextlib
|
|
11
|
+
import functools
|
|
11
12
|
import inspect
|
|
12
13
|
import json
|
|
13
14
|
import logging
|
|
14
15
|
import random
|
|
15
16
|
from collections import defaultdict
|
|
17
|
+
from enum import Enum
|
|
16
18
|
from typing import Any, AsyncIterator, Awaitable, Callable, Iterable, Literal, overload
|
|
17
19
|
|
|
18
20
|
from .models import ClientMetrics, EventMessage, MatchStateSnapshot
|
|
@@ -43,11 +45,22 @@ from .types import (
|
|
|
43
45
|
Handler = Callable[[EventMessage], Awaitable[None] | None]
|
|
44
46
|
_AnyCallable = Callable[..., Any]
|
|
45
47
|
_SimpleHandler = Callable[[], Awaitable[None] | None]
|
|
46
|
-
_ErrorHandler = Callable[
|
|
48
|
+
_ErrorHandler = Callable[
|
|
49
|
+
[EventMessage, Exception, _AnyCallable], Awaitable[None] | None
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ConnectionState(Enum):
|
|
54
|
+
"""Granular connection lifecycle state for a StatsClient."""
|
|
55
|
+
|
|
56
|
+
DISCONNECTED = "disconnected"
|
|
57
|
+
CONNECTING = "connecting"
|
|
58
|
+
CONNECTED = "connected"
|
|
59
|
+
FAILED = "failed"
|
|
47
60
|
|
|
48
61
|
|
|
49
62
|
class StatsClient:
|
|
50
|
-
"""
|
|
63
|
+
"""Event client for Rocket League Stats API."""
|
|
51
64
|
|
|
52
65
|
def __init__(
|
|
53
66
|
self,
|
|
@@ -62,7 +75,31 @@ class StatsClient:
|
|
|
62
75
|
overflow: Literal["block", "drop", "raise"] = "block",
|
|
63
76
|
connect_timeout: float = 5.0,
|
|
64
77
|
drain_on_disconnect: bool = False,
|
|
78
|
+
handler_timeout: float | None = None,
|
|
65
79
|
) -> None:
|
|
80
|
+
"""Configure the client.
|
|
81
|
+
|
|
82
|
+
Does not connect until ``connect()`` or async context manager entry.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
host: Stats API host address.
|
|
86
|
+
port: Stats API TCP port.
|
|
87
|
+
reconnect: Whether to reconnect automatically on connection loss.
|
|
88
|
+
reconnect_delay: Initial delay in seconds before the first reconnect attempt.
|
|
89
|
+
max_reconnect_delay: Upper bound for exponential backoff delay.
|
|
90
|
+
max_reconnect_attempts: Stop reconnecting after this many failures.
|
|
91
|
+
None means unlimited.
|
|
92
|
+
include_raw: Attach the original JSON string to each ``EventMessage.raw``.
|
|
93
|
+
queue_size: Max events buffered in the internal queue.
|
|
94
|
+
overflow: Queue-full behavior ``"block"`` waits, ``"drop"`` discards,
|
|
95
|
+
``"raise"`` kills the connection.
|
|
96
|
+
connect_timeout: Seconds to wait for TCP handshake before raising.
|
|
97
|
+
drain_on_disconnect: Clear the queue when the session ends.
|
|
98
|
+
handler_timeout: Max seconds an async handler may run before being cancelled.
|
|
99
|
+
Sync handlers are not affected. None disables the timeout.
|
|
100
|
+
"""
|
|
101
|
+
if reconnect_delay <= 0:
|
|
102
|
+
raise ValueError("reconnect_delay must be positive")
|
|
66
103
|
self.host = host
|
|
67
104
|
self.port = port
|
|
68
105
|
self.reconnect = reconnect
|
|
@@ -73,6 +110,7 @@ class StatsClient:
|
|
|
73
110
|
self.connect_timeout = connect_timeout
|
|
74
111
|
self.overflow = overflow
|
|
75
112
|
self.drain_on_disconnect = drain_on_disconnect
|
|
113
|
+
self.handler_timeout = handler_timeout
|
|
76
114
|
|
|
77
115
|
self._queue: asyncio.Queue[EventMessage] = asyncio.Queue(maxsize=queue_size)
|
|
78
116
|
self._handlers_by_event: dict[str, list[_AnyCallable]] = defaultdict(list)
|
|
@@ -86,18 +124,21 @@ class StatsClient:
|
|
|
86
124
|
self._writer: asyncio.StreamWriter | None = None
|
|
87
125
|
self._reader_task: asyncio.Task[None] | None = None
|
|
88
126
|
self._stopping = False
|
|
89
|
-
self.
|
|
127
|
+
self._connection_state = ConnectionState.DISCONNECTED
|
|
90
128
|
self._permanently_failed = False
|
|
91
129
|
self._last_error: Exception | None = None
|
|
92
130
|
self._metrics = ClientMetrics()
|
|
93
131
|
self._state_tracker = MatchStateTracker()
|
|
94
132
|
|
|
133
|
+
@property
|
|
134
|
+
def connection_state(self) -> ConnectionState:
|
|
135
|
+
"""Return the current connection lifecycle state."""
|
|
136
|
+
return self._connection_state
|
|
137
|
+
|
|
95
138
|
@property
|
|
96
139
|
def is_connected(self) -> bool:
|
|
97
|
-
"""
|
|
98
|
-
|
|
99
|
-
"""
|
|
100
|
-
return self._connected
|
|
140
|
+
"""Return True if the client is currently connected to the Stats API."""
|
|
141
|
+
return self._connection_state == ConnectionState.CONNECTED
|
|
101
142
|
|
|
102
143
|
@property
|
|
103
144
|
def metrics(self) -> ClientMetrics:
|
|
@@ -120,13 +161,16 @@ class StatsClient:
|
|
|
120
161
|
return self._permanently_failed
|
|
121
162
|
|
|
122
163
|
async def __aenter__(self) -> StatsClient:
|
|
164
|
+
"""Connect and return self for use as an async context manager."""
|
|
123
165
|
await self.connect()
|
|
124
166
|
return self
|
|
125
167
|
|
|
126
168
|
async def __aexit__(self, *_: object) -> None:
|
|
169
|
+
"""Disconnect when leaving the async context manager."""
|
|
127
170
|
await self.disconnect()
|
|
128
171
|
|
|
129
172
|
async def connect(self) -> None:
|
|
173
|
+
"""Start the background reader task and begin connecting. No-op if already running."""
|
|
130
174
|
if self._reader_task and not self._reader_task.done():
|
|
131
175
|
self._logger.debug("connect() ignored: reader already running")
|
|
132
176
|
return
|
|
@@ -137,15 +181,17 @@ class StatsClient:
|
|
|
137
181
|
self._reader_task = asyncio.create_task(self._run(), name="rlstatsapi-reader")
|
|
138
182
|
self._logger.debug("reader task started")
|
|
139
183
|
|
|
140
|
-
async def disconnect(self) -> None:
|
|
141
|
-
"""
|
|
142
|
-
|
|
184
|
+
async def disconnect(self, timeout: float = 5.0) -> None:
|
|
185
|
+
"""Disconnect from the Stats API and stop the reader task.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
timeout: Seconds to wait for the reader task to finish before giving up.
|
|
143
189
|
"""
|
|
144
190
|
self._stopping = True
|
|
145
191
|
self._logger.debug("disconnect requested")
|
|
146
192
|
|
|
147
193
|
if self._writer is not None:
|
|
148
|
-
with contextlib.suppress(
|
|
194
|
+
with contextlib.suppress(OSError, asyncio.TimeoutError):
|
|
149
195
|
self._writer.close()
|
|
150
196
|
await self._writer.wait_closed()
|
|
151
197
|
self._writer = None
|
|
@@ -153,8 +199,8 @@ class StatsClient:
|
|
|
153
199
|
|
|
154
200
|
if self._reader_task is not None:
|
|
155
201
|
self._reader_task.cancel()
|
|
156
|
-
with contextlib.suppress(asyncio.CancelledError):
|
|
157
|
-
await self._reader_task
|
|
202
|
+
with contextlib.suppress(asyncio.TimeoutError, asyncio.CancelledError):
|
|
203
|
+
await asyncio.wait_for(self._reader_task, timeout=timeout)
|
|
158
204
|
self._reader_task = None
|
|
159
205
|
if self.drain_on_disconnect:
|
|
160
206
|
self.clear_queue()
|
|
@@ -460,8 +506,12 @@ class StatsClient:
|
|
|
460
506
|
if handler is None:
|
|
461
507
|
|
|
462
508
|
def decorator(h: _AnyCallable) -> _AnyCallable:
|
|
463
|
-
|
|
464
|
-
|
|
509
|
+
@functools.wraps(h)
|
|
510
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
511
|
+
return h(*args, **kwargs)
|
|
512
|
+
|
|
513
|
+
self._handlers_by_event[event_name].append(wrapper)
|
|
514
|
+
return wrapper
|
|
465
515
|
|
|
466
516
|
return decorator
|
|
467
517
|
self._handlers_by_event[event_name].append(handler)
|
|
@@ -490,9 +540,11 @@ class StatsClient:
|
|
|
490
540
|
|
|
491
541
|
def clear_queue(self) -> None:
|
|
492
542
|
"""Drop any queued events that have not been consumed yet."""
|
|
493
|
-
while
|
|
494
|
-
|
|
543
|
+
while True:
|
|
544
|
+
try:
|
|
495
545
|
self._queue.get_nowait()
|
|
546
|
+
except asyncio.QueueEmpty:
|
|
547
|
+
break
|
|
496
548
|
|
|
497
549
|
def once(self, event_name: str, handler: _AnyCallable) -> None:
|
|
498
550
|
"""Register a handler that runs once and removes itself automatically."""
|
|
@@ -682,17 +734,51 @@ class StatsClient:
|
|
|
682
734
|
if not filters or message.event in filters:
|
|
683
735
|
yield message
|
|
684
736
|
|
|
737
|
+
async def wait_for(
|
|
738
|
+
self,
|
|
739
|
+
event_name: str,
|
|
740
|
+
*,
|
|
741
|
+
timeout: float | None = None,
|
|
742
|
+
) -> EventMessage:
|
|
743
|
+
"""Wait until the next occurrence of ``event_name`` and return the message.
|
|
744
|
+
|
|
745
|
+
Args:
|
|
746
|
+
event_name: The event to wait for (e.g. ``"GoalScored"``).
|
|
747
|
+
timeout: Seconds before raising ``asyncio.TimeoutError``. None waits forever.
|
|
748
|
+
|
|
749
|
+
Returns:
|
|
750
|
+
The first matching ``EventMessage`` received after this call.
|
|
751
|
+
|
|
752
|
+
Raises:
|
|
753
|
+
asyncio.TimeoutError: If ``timeout`` elapses before the event arrives.
|
|
754
|
+
"""
|
|
755
|
+
loop = asyncio.get_event_loop()
|
|
756
|
+
future: asyncio.Future[EventMessage] = loop.create_future()
|
|
757
|
+
|
|
758
|
+
def _handler(msg: EventMessage) -> None:
|
|
759
|
+
if not future.done():
|
|
760
|
+
self.off(event_name, _handler)
|
|
761
|
+
future.set_result(msg)
|
|
762
|
+
|
|
763
|
+
self._handlers_by_event[event_name].append(_handler)
|
|
764
|
+
try:
|
|
765
|
+
return await asyncio.wait_for(future, timeout=timeout)
|
|
766
|
+
except asyncio.TimeoutError:
|
|
767
|
+
self.off(event_name, _handler)
|
|
768
|
+
raise
|
|
769
|
+
|
|
685
770
|
async def _run(self) -> None:
|
|
686
771
|
"""Main loop to manage connection and reading from the Stats API."""
|
|
687
772
|
attempt = 0
|
|
688
773
|
while not self._stopping:
|
|
689
774
|
try:
|
|
690
775
|
self._logger.debug("connecting to tcp://%s:%d", self.host, self.port)
|
|
776
|
+
self._connection_state = ConnectionState.CONNECTING
|
|
691
777
|
self._reader, self._writer = await asyncio.wait_for(
|
|
692
778
|
asyncio.open_connection(host=self.host, port=self.port),
|
|
693
779
|
timeout=self.connect_timeout,
|
|
694
780
|
)
|
|
695
|
-
self.
|
|
781
|
+
self._connection_state = ConnectionState.CONNECTED
|
|
696
782
|
attempt = 0
|
|
697
783
|
self._logger.debug("tcp socket connected")
|
|
698
784
|
await self._fire_simple(self._on_connect_handlers)
|
|
@@ -716,6 +802,7 @@ class StatsClient:
|
|
|
716
802
|
and attempt > self.max_reconnect_attempts
|
|
717
803
|
):
|
|
718
804
|
self._permanently_failed = True
|
|
805
|
+
self._connection_state = ConnectionState.FAILED
|
|
719
806
|
self._logger.debug(
|
|
720
807
|
"max reconnect attempts (%d) reached",
|
|
721
808
|
self.max_reconnect_attempts,
|
|
@@ -728,11 +815,11 @@ class StatsClient:
|
|
|
728
815
|
self._logger.debug("reconnecting in %.2fs (attempt %d)", delay, attempt)
|
|
729
816
|
await asyncio.sleep(delay)
|
|
730
817
|
finally:
|
|
731
|
-
if self.
|
|
732
|
-
self.
|
|
818
|
+
if self._connection_state == ConnectionState.CONNECTED:
|
|
819
|
+
self._connection_state = ConnectionState.DISCONNECTED
|
|
733
820
|
await self._fire_simple(self._on_disconnect_handlers)
|
|
734
821
|
if self._writer is not None:
|
|
735
|
-
with contextlib.suppress(
|
|
822
|
+
with contextlib.suppress(OSError, asyncio.TimeoutError):
|
|
736
823
|
self._writer.close()
|
|
737
824
|
await self._writer.wait_closed()
|
|
738
825
|
self._writer = None
|
|
@@ -749,14 +836,19 @@ class StatsClient:
|
|
|
749
836
|
chunk = await reader.read(4096)
|
|
750
837
|
if not chunk:
|
|
751
838
|
raise ConnectionAbortedError("Socket closed by peer")
|
|
752
|
-
|
|
839
|
+
decoded_chunk = chunk.decode("utf-8", errors="replace")
|
|
840
|
+
if "�" in decoded_chunk:
|
|
841
|
+
self._logger.warning(
|
|
842
|
+
"received invalid UTF-8 bytes; replaced with U+FFFD"
|
|
843
|
+
)
|
|
844
|
+
buffer += decoded_chunk
|
|
753
845
|
|
|
754
846
|
while True:
|
|
755
847
|
lstripped = buffer.lstrip()
|
|
756
848
|
if not lstripped:
|
|
757
849
|
buffer = ""
|
|
758
850
|
break
|
|
759
|
-
if lstripped
|
|
851
|
+
if lstripped != buffer:
|
|
760
852
|
buffer = lstripped
|
|
761
853
|
|
|
762
854
|
try:
|
|
@@ -796,9 +888,7 @@ class StatsClient:
|
|
|
796
888
|
await self._dispatch(message)
|
|
797
889
|
|
|
798
890
|
async def _dispatch(self, message: EventMessage) -> None:
|
|
799
|
-
"""
|
|
800
|
-
Dispatches to handlers for `message.event` and global handlers.
|
|
801
|
-
"""
|
|
891
|
+
"""Dispatches to handlers for `message.event` and global handlers."""
|
|
802
892
|
handlers = list(self._handlers_any) + list(
|
|
803
893
|
self._handlers_by_event.get(message.event, [])
|
|
804
894
|
)
|
|
@@ -806,25 +896,40 @@ class StatsClient:
|
|
|
806
896
|
try:
|
|
807
897
|
result = handler(message)
|
|
808
898
|
if inspect.isawaitable(result):
|
|
809
|
-
|
|
899
|
+
if (
|
|
900
|
+
self.handler_timeout is not None
|
|
901
|
+
and inspect.iscoroutinefunction(handler)
|
|
902
|
+
):
|
|
903
|
+
await asyncio.wait_for(result, timeout=self.handler_timeout)
|
|
904
|
+
else:
|
|
905
|
+
await result
|
|
810
906
|
except Exception as exc:
|
|
811
907
|
self._metrics.handler_errors += 1
|
|
812
908
|
if self._error_handlers:
|
|
813
909
|
for error_handler in list(self._error_handlers):
|
|
814
|
-
|
|
815
|
-
r = error_handler(message
|
|
910
|
+
try:
|
|
911
|
+
r = error_handler(message, exc, handler)
|
|
816
912
|
if inspect.isawaitable(r):
|
|
817
913
|
await r
|
|
914
|
+
except Exception as err_exc:
|
|
915
|
+
self._logger.error(
|
|
916
|
+
"error handler itself raised: %s", err_exc
|
|
917
|
+
)
|
|
818
918
|
else:
|
|
819
919
|
self._logger.error("handler error for %s: %s", message.event, exc)
|
|
820
920
|
|
|
821
921
|
async def _fire_simple(self, handlers: list[_SimpleHandler]) -> None:
|
|
822
922
|
"""Run connection lifecycle callbacks and await async ones when needed."""
|
|
823
923
|
for handler in list(handlers):
|
|
824
|
-
|
|
924
|
+
try:
|
|
825
925
|
result = handler()
|
|
826
926
|
if inspect.isawaitable(result):
|
|
827
927
|
await result
|
|
928
|
+
except Exception as exc:
|
|
929
|
+
self._logger.error("lifecycle handler error: %s", exc)
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
_MAX_JSON_FIELD_BYTES = 1 << 20 # 1 MiB
|
|
828
933
|
|
|
829
934
|
|
|
830
935
|
def _parse_message_obj(
|
|
@@ -836,6 +941,8 @@ def _parse_message_obj(
|
|
|
836
941
|
event = decoded.get("Event")
|
|
837
942
|
data = decoded.get("Data")
|
|
838
943
|
if isinstance(data, str):
|
|
944
|
+
if len(data) > _MAX_JSON_FIELD_BYTES:
|
|
945
|
+
return None
|
|
839
946
|
try:
|
|
840
947
|
data = json.loads(data)
|
|
841
948
|
except json.JSONDecodeError:
|
|
@@ -7,10 +7,13 @@ prepare the game config before starting Rocket League and then connect with
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import logging
|
|
10
11
|
import os
|
|
11
12
|
from dataclasses import dataclass
|
|
12
13
|
from pathlib import Path
|
|
13
14
|
|
|
15
|
+
_logger = logging.getLogger("rlstatsapi.config")
|
|
16
|
+
|
|
14
17
|
DEFAULT_PACKET_SEND_RATE = 30
|
|
15
18
|
DEFAULT_STATS_API_PORT = 49123
|
|
16
19
|
DEFAULT_STATS_API_FILENAME = "TAStatsAPI.ini"
|
|
@@ -165,6 +168,7 @@ def configure_stats_api(
|
|
|
165
168
|
|
|
166
169
|
|
|
167
170
|
def _require_config_path(path: str | Path | None) -> Path:
|
|
171
|
+
"""Return the resolved config path or raise ``FileNotFoundError`` if not found."""
|
|
168
172
|
config_path = find_stats_api_config(path)
|
|
169
173
|
if config_path is None:
|
|
170
174
|
raise FileNotFoundError(_WARNING)
|
|
@@ -172,6 +176,7 @@ def _require_config_path(path: str | Path | None) -> Path:
|
|
|
172
176
|
|
|
173
177
|
|
|
174
178
|
def _parse_int(value: str | None) -> int | None:
|
|
179
|
+
"""Parse a string to int, returning None on failure or missing input."""
|
|
175
180
|
if value is None:
|
|
176
181
|
return None
|
|
177
182
|
try:
|
|
@@ -181,10 +186,14 @@ def _parse_int(value: str | None) -> int | None:
|
|
|
181
186
|
|
|
182
187
|
|
|
183
188
|
def _read_key_values(text: str) -> dict[str, str]:
|
|
189
|
+
"""Parse ``key=value`` pairs from INI-style text, ignoring comments and blank lines."""
|
|
184
190
|
values: dict[str, str] = {}
|
|
185
191
|
for line in text.splitlines():
|
|
186
192
|
stripped = line.strip()
|
|
187
|
-
if not stripped or stripped.startswith(("#", ";"))
|
|
193
|
+
if not stripped or stripped.startswith(("#", ";")):
|
|
194
|
+
continue
|
|
195
|
+
if "=" not in stripped:
|
|
196
|
+
_logger.warning("skipped malformed config line: %r", stripped)
|
|
188
197
|
continue
|
|
189
198
|
key, value = stripped.split("=", 1)
|
|
190
199
|
values[key.strip().casefold()] = value.strip()
|
|
@@ -192,7 +201,18 @@ def _read_key_values(text: str) -> dict[str, str]:
|
|
|
192
201
|
|
|
193
202
|
|
|
194
203
|
def _status_for_path(path: Path) -> StatsAPIConfigStatus:
|
|
195
|
-
|
|
204
|
+
"""Build a ``StatsAPIConfigStatus`` by reading and parsing an existing config file."""
|
|
205
|
+
try:
|
|
206
|
+
text = path.read_text(encoding="utf-8")
|
|
207
|
+
except FileNotFoundError:
|
|
208
|
+
return StatsAPIConfigStatus(
|
|
209
|
+
found=False,
|
|
210
|
+
enabled=False,
|
|
211
|
+
path=str(path),
|
|
212
|
+
packet_send_rate=None,
|
|
213
|
+
port=None,
|
|
214
|
+
warning=_WARNING,
|
|
215
|
+
)
|
|
196
216
|
values = _read_key_values(text)
|
|
197
217
|
packet_send_rate = _parse_int(values.get("packetsendrate"))
|
|
198
218
|
port = _parse_int(values.get("port"))
|
|
@@ -207,6 +227,7 @@ def _status_for_path(path: Path) -> StatsAPIConfigStatus:
|
|
|
207
227
|
|
|
208
228
|
|
|
209
229
|
def _set_or_append_key(lines: list[str], key: str, value: str) -> list[str]:
|
|
230
|
+
"""Replace an existing ``key=value`` line in-place, or append it if not present."""
|
|
210
231
|
output: list[str] = []
|
|
211
232
|
replaced = False
|
|
212
233
|
for line in lines:
|
|
@@ -227,17 +248,18 @@ def _set_or_append_key(lines: list[str], key: str, value: str) -> list[str]:
|
|
|
227
248
|
|
|
228
249
|
|
|
229
250
|
def _write_lines(path: Path, lines: list[str], original_text: str) -> None:
|
|
251
|
+
"""Write lines back to file, preserving the original trailing newline."""
|
|
230
252
|
newline = "\n" if original_text.endswith(("\n", "\r\n")) else ""
|
|
231
253
|
path.write_text("\n".join(lines) + newline, encoding="utf-8")
|
|
232
254
|
|
|
233
255
|
|
|
234
256
|
def _validate_packet_send_rate(packet_send_rate: int) -> None:
|
|
235
|
-
if packet_send_rate
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
raise ValueError("packet_send_rate must be greater than 0 when enabled")
|
|
257
|
+
"""Raise ``ValueError`` if packet_send_rate is outside the valid range [1, 120]."""
|
|
258
|
+
if packet_send_rate < 1 or packet_send_rate > 120:
|
|
259
|
+
raise ValueError("packet_send_rate must be between 1 and 120 when enabled")
|
|
239
260
|
|
|
240
261
|
|
|
241
262
|
def _validate_port(port: int) -> None:
|
|
263
|
+
"""Raise ``ValueError`` if port is outside the valid range [1, 65535]."""
|
|
242
264
|
if port < 1 or port > 65535:
|
|
243
265
|
raise ValueError("port must be between 1 and 65535")
|
|
@@ -7,6 +7,7 @@ through internal queues and user callbacks.
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import datetime
|
|
10
11
|
from typing import Any
|
|
11
12
|
|
|
12
13
|
from .types import EventName, TypedEventMessage, cast_event_data
|
|
@@ -40,6 +41,34 @@ class ClientMetrics:
|
|
|
40
41
|
reconnect_count: int = 0
|
|
41
42
|
handler_errors: int = 0
|
|
42
43
|
connection_failures: int = 0
|
|
44
|
+
started_at: datetime = field(default_factory=datetime.now)
|
|
45
|
+
|
|
46
|
+
def reset(self) -> None:
|
|
47
|
+
"""Reset all counters and update started_at to now."""
|
|
48
|
+
self.received_events = 0
|
|
49
|
+
self.queued_events = 0
|
|
50
|
+
self.dropped_events = 0
|
|
51
|
+
self.reconnect_count = 0
|
|
52
|
+
self.handler_errors = 0
|
|
53
|
+
self.connection_failures = 0
|
|
54
|
+
self.started_at = datetime.now()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(slots=True)
|
|
58
|
+
class PlayerSnapshot:
|
|
59
|
+
"""Per-player state extracted from the latest UpdateState event."""
|
|
60
|
+
|
|
61
|
+
name: str
|
|
62
|
+
shortcut: int
|
|
63
|
+
team_num: int
|
|
64
|
+
score: int = 0
|
|
65
|
+
goals: int = 0
|
|
66
|
+
assists: int = 0
|
|
67
|
+
saves: int = 0
|
|
68
|
+
shots: int = 0
|
|
69
|
+
boost: int = 0
|
|
70
|
+
speed: float = 0.0
|
|
71
|
+
is_demolished: bool = False
|
|
43
72
|
|
|
44
73
|
|
|
45
74
|
@dataclass(slots=True)
|
|
@@ -58,4 +87,6 @@ class MatchStateSnapshot:
|
|
|
58
87
|
last_event: str | None = None
|
|
59
88
|
last_scorer: str | None = None
|
|
60
89
|
last_goal_speed: float | None = None
|
|
90
|
+
players: list[PlayerSnapshot] = field(default_factory=list)
|
|
91
|
+
target_player: PlayerSnapshot | None = None
|
|
61
92
|
event_counts: dict[str, int] = field(default_factory=dict)
|
|
@@ -8,7 +8,7 @@ without forcing users to rebuild the same state cache in every script.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
-
from .models import EventMessage, MatchStateSnapshot
|
|
11
|
+
from .models import EventMessage, MatchStateSnapshot, PlayerSnapshot
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
class MatchStateTracker:
|
|
@@ -58,11 +58,13 @@ class MatchStateTracker:
|
|
|
58
58
|
snapshot: MatchStateSnapshot,
|
|
59
59
|
message: EventMessage,
|
|
60
60
|
) -> None:
|
|
61
|
+
"""Extract game-level fields from an UpdateState payload into the snapshot."""
|
|
61
62
|
game = message.data.get("Game", {})
|
|
62
63
|
if not isinstance(game, dict):
|
|
63
64
|
return
|
|
64
65
|
|
|
65
66
|
self._apply_teams(snapshot, game.get("Teams", []))
|
|
67
|
+
self._apply_players(snapshot, message.data.get("Players", []), game)
|
|
66
68
|
|
|
67
69
|
time_seconds = game.get("TimeSeconds")
|
|
68
70
|
if isinstance(time_seconds, int):
|
|
@@ -81,6 +83,7 @@ class MatchStateTracker:
|
|
|
81
83
|
snapshot.winner = winner
|
|
82
84
|
|
|
83
85
|
def _apply_teams(self, snapshot: MatchStateSnapshot, teams: object) -> None:
|
|
86
|
+
"""Update blue/orange score from the Teams list in an UpdateState payload."""
|
|
84
87
|
if not isinstance(teams, list):
|
|
85
88
|
return
|
|
86
89
|
|
|
@@ -94,11 +97,62 @@ class MatchStateTracker:
|
|
|
94
97
|
if team_num == 1 and isinstance(score, int):
|
|
95
98
|
snapshot.orange_score = score
|
|
96
99
|
|
|
100
|
+
def _apply_players(
|
|
101
|
+
self,
|
|
102
|
+
snapshot: MatchStateSnapshot,
|
|
103
|
+
players: object,
|
|
104
|
+
game: dict,
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Rebuild the players list and resolve target_player from UpdateState."""
|
|
107
|
+
if not isinstance(players, list):
|
|
108
|
+
snapshot.players = []
|
|
109
|
+
snapshot.target_player = None
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
result: list[PlayerSnapshot] = []
|
|
113
|
+
for p in players:
|
|
114
|
+
if not isinstance(p, dict):
|
|
115
|
+
continue
|
|
116
|
+
name = p.get("Name")
|
|
117
|
+
shortcut = p.get("Shortcut")
|
|
118
|
+
team_num = p.get("TeamNum")
|
|
119
|
+
if not isinstance(name, str) or not isinstance(shortcut, int):
|
|
120
|
+
continue
|
|
121
|
+
result.append(
|
|
122
|
+
PlayerSnapshot(
|
|
123
|
+
name=name,
|
|
124
|
+
shortcut=shortcut,
|
|
125
|
+
team_num=team_num if isinstance(team_num, int) else 0,
|
|
126
|
+
score=p.get("Score") or 0,
|
|
127
|
+
goals=p.get("Goals") or 0,
|
|
128
|
+
assists=p.get("Assists") or 0,
|
|
129
|
+
saves=p.get("Saves") or 0,
|
|
130
|
+
shots=p.get("Shots") or 0,
|
|
131
|
+
boost=p.get("Boost") or 0,
|
|
132
|
+
speed=float(p.get("Speed") or 0.0),
|
|
133
|
+
is_demolished=bool(p.get("bDemolished", False)),
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
snapshot.players = result
|
|
137
|
+
|
|
138
|
+
target_player: PlayerSnapshot | None = None
|
|
139
|
+
if game.get("bHasTarget"):
|
|
140
|
+
target = game.get("Target")
|
|
141
|
+
if isinstance(target, dict):
|
|
142
|
+
target_shortcut = target.get("Shortcut")
|
|
143
|
+
if isinstance(target_shortcut, int):
|
|
144
|
+
for player in result:
|
|
145
|
+
if player.shortcut == target_shortcut:
|
|
146
|
+
target_player = player
|
|
147
|
+
break
|
|
148
|
+
snapshot.target_player = target_player
|
|
149
|
+
|
|
97
150
|
def _apply_goal_scored(
|
|
98
151
|
self,
|
|
99
152
|
snapshot: MatchStateSnapshot,
|
|
100
153
|
message: EventMessage,
|
|
101
154
|
) -> None:
|
|
155
|
+
"""Update last scorer name and goal speed from a GoalScored payload."""
|
|
102
156
|
scorer = message.data.get("Scorer", {})
|
|
103
157
|
if isinstance(scorer, dict):
|
|
104
158
|
name = scorer.get("Name")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rlstatsapi
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Summary: Rocket League Stats API client
|
|
5
5
|
Author: Manuel Cabral
|
|
6
6
|
License-Expression: MIT
|
|
@@ -19,6 +19,7 @@ License-File: LICENSE
|
|
|
19
19
|
Provides-Extra: dev
|
|
20
20
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
21
21
|
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
22
|
+
Requires-Dist: black; extra == "dev"
|
|
22
23
|
Dynamic: license-file
|
|
23
24
|
|
|
24
25
|
<img width="600" alt="rlstatsapi-logo" src="https://github.com/manucabral/RocketLeagueStatsAPI/blob/main/docs/assets/social-preview.png" />
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Real integration test for StatsClient against a live Rocket League exporter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import socket
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from rlstatsapi import StatsClient
|
|
12
|
+
|
|
13
|
+
RL_STATSAPI_HOST = os.getenv("RL_STATSAPI_HOST", "127.0.0.1")
|
|
14
|
+
RL_STATSAPI_PORT = int(os.getenv("RL_STATSAPI_PORT", "49123"))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _tcp_open(host: str, port: int) -> bool:
|
|
18
|
+
try:
|
|
19
|
+
with socket.create_connection((host, port), timeout=1.0):
|
|
20
|
+
return True
|
|
21
|
+
except OSError:
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.mark.asyncio
|
|
26
|
+
@pytest.mark.integration
|
|
27
|
+
async def test_client_receives_real_event() -> None:
|
|
28
|
+
"""Connects to a real RL exporter and verifies one event is received."""
|
|
29
|
+
|
|
30
|
+
if not _tcp_open(RL_STATSAPI_HOST, RL_STATSAPI_PORT):
|
|
31
|
+
pytest.skip(
|
|
32
|
+
f"Rocket League exporter is not reachable at {RL_STATSAPI_HOST}:{RL_STATSAPI_PORT}"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
client = StatsClient(
|
|
36
|
+
host=RL_STATSAPI_HOST,
|
|
37
|
+
port=RL_STATSAPI_PORT,
|
|
38
|
+
reconnect=False,
|
|
39
|
+
connect_timeout=2.0,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
await client.connect()
|
|
43
|
+
try:
|
|
44
|
+
message = await asyncio.wait_for(client.events().__anext__(), timeout=3.0)
|
|
45
|
+
assert isinstance(message.event, str)
|
|
46
|
+
assert isinstance(message.data, dict)
|
|
47
|
+
assert message.event
|
|
48
|
+
finally:
|
|
49
|
+
await client.disconnect()
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""Unit coverage for client parsing, filtering, and convenience helpers."""
|
|
2
|
+
|
|
3
|
+
# pylint: disable=protected-access
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Any, Callable
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from rlstatsapi.client import ConnectionState, StatsClient, _parse_message_obj
|
|
14
|
+
from rlstatsapi.models import EventMessage, PlayerSnapshot
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_parse_message_decodes_string_data_payload() -> None:
|
|
18
|
+
message = _parse_message_obj(
|
|
19
|
+
{
|
|
20
|
+
"Event": "GoalScored",
|
|
21
|
+
"Data": '{"Scorer":{"Name":"PlayerA","Shortcut":1,"TeamNum":0}}',
|
|
22
|
+
},
|
|
23
|
+
raw="{}",
|
|
24
|
+
include_raw=True,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
assert message is not None
|
|
28
|
+
assert message.event == "GoalScored"
|
|
29
|
+
assert message.data["Scorer"]["Name"] == "PlayerA"
|
|
30
|
+
assert message.raw == "{}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_event_message_as_type_exposes_typed_data() -> None:
|
|
34
|
+
message = EventMessage(
|
|
35
|
+
event="GoalScored",
|
|
36
|
+
data={"Scorer": {"Name": "PlayerA", "Shortcut": 1, "TeamNum": 0}},
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
typed_message = message.as_type("GoalScored")
|
|
40
|
+
|
|
41
|
+
assert typed_message.event == "GoalScored"
|
|
42
|
+
assert typed_message.data["Scorer"]["Name"] == "PlayerA"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.mark.asyncio
|
|
46
|
+
async def test_events_filter_only_yields_requested_names() -> None:
|
|
47
|
+
client = StatsClient(reconnect=False)
|
|
48
|
+
await client._queue.put(EventMessage(event="UpdateState", data={}))
|
|
49
|
+
await client._queue.put(EventMessage(event="GoalScored", data={}))
|
|
50
|
+
|
|
51
|
+
iterator = client.events("GoalScored")
|
|
52
|
+
message = await asyncio.wait_for(iterator.__anext__(), timeout=1.0)
|
|
53
|
+
|
|
54
|
+
assert message.event == "GoalScored"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_on_many_registers_same_handler_for_multiple_events() -> None:
|
|
58
|
+
client = StatsClient(reconnect=False)
|
|
59
|
+
|
|
60
|
+
def handler(_message: EventMessage) -> None:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
client.on_many(["GoalScored", "MatchEnded"], handler)
|
|
64
|
+
|
|
65
|
+
assert handler in client._handlers_by_event["GoalScored"]
|
|
66
|
+
assert handler in client._handlers_by_event["MatchEnded"]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_off_removes_specific_handler() -> None:
|
|
70
|
+
client = StatsClient(reconnect=False)
|
|
71
|
+
|
|
72
|
+
def handler(_: EventMessage) -> None:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
client.on("GoalScored", handler)
|
|
76
|
+
client.off("GoalScored", handler)
|
|
77
|
+
|
|
78
|
+
assert handler not in client._handlers_by_event["GoalScored"]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_off_any_removes_global_handler() -> None:
|
|
82
|
+
client = StatsClient(reconnect=False)
|
|
83
|
+
|
|
84
|
+
def handler(_: EventMessage) -> None:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
client.on_any(handler)
|
|
88
|
+
client.off_any(handler)
|
|
89
|
+
|
|
90
|
+
assert handler not in client._handlers_any
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@pytest.mark.asyncio
|
|
94
|
+
async def test_once_handler_fires_once_and_removes_itself() -> None:
|
|
95
|
+
client = StatsClient(reconnect=False)
|
|
96
|
+
calls: list[str] = []
|
|
97
|
+
|
|
98
|
+
def handler(msg: EventMessage) -> None:
|
|
99
|
+
calls.append(msg.event)
|
|
100
|
+
|
|
101
|
+
client.once("GoalScored", handler)
|
|
102
|
+
msg = EventMessage(event="GoalScored", data={})
|
|
103
|
+
await client._dispatch(msg)
|
|
104
|
+
await client._dispatch(msg)
|
|
105
|
+
|
|
106
|
+
assert len(calls) == 1
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_clear_queue_empties_all_items() -> None:
|
|
110
|
+
client = StatsClient(reconnect=False)
|
|
111
|
+
client._queue.put_nowait(EventMessage(event="A", data={}))
|
|
112
|
+
client._queue.put_nowait(EventMessage(event="B", data={}))
|
|
113
|
+
client.clear_queue()
|
|
114
|
+
|
|
115
|
+
assert client._queue.empty()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@pytest.mark.asyncio
|
|
119
|
+
async def test_events_no_filter_yields_all() -> None:
|
|
120
|
+
client = StatsClient(reconnect=False)
|
|
121
|
+
await client._queue.put(EventMessage(event="UpdateState", data={}))
|
|
122
|
+
await client._queue.put(EventMessage(event="GoalScored", data={}))
|
|
123
|
+
|
|
124
|
+
iterator = client.events()
|
|
125
|
+
msg1 = await asyncio.wait_for(iterator.__anext__(), timeout=1.0)
|
|
126
|
+
msg2 = await asyncio.wait_for(iterator.__anext__(), timeout=1.0)
|
|
127
|
+
|
|
128
|
+
assert {msg1.event, msg2.event} == {"UpdateState", "GoalScored"}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@pytest.mark.asyncio
|
|
132
|
+
async def test_overflow_drop_increments_dropped_metric() -> None:
|
|
133
|
+
client = StatsClient(reconnect=False, queue_size=1, overflow="drop")
|
|
134
|
+
client._queue.put_nowait(EventMessage(event="A", data={}))
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
client._queue.put_nowait(EventMessage(event="B", data={}))
|
|
138
|
+
client._metrics.queued_events += 1
|
|
139
|
+
except asyncio.QueueFull:
|
|
140
|
+
client._metrics.dropped_events += 1
|
|
141
|
+
|
|
142
|
+
assert client._metrics.dropped_events == 1
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@pytest.mark.asyncio
|
|
146
|
+
async def test_on_handler_error_called_on_handler_exception() -> None:
|
|
147
|
+
client = StatsClient(reconnect=False)
|
|
148
|
+
errors: list[tuple[EventMessage, Exception, Callable[..., Any]]] = []
|
|
149
|
+
|
|
150
|
+
def error_handler(
|
|
151
|
+
msg: EventMessage, exc: Exception, handler: Callable[..., Any]
|
|
152
|
+
) -> None:
|
|
153
|
+
errors.append((msg, exc, handler))
|
|
154
|
+
|
|
155
|
+
client.on_handler_error(error_handler)
|
|
156
|
+
|
|
157
|
+
def bad_handler(_: EventMessage) -> None:
|
|
158
|
+
raise RuntimeError("boom")
|
|
159
|
+
|
|
160
|
+
client.on("GoalScored", bad_handler)
|
|
161
|
+
msg = EventMessage(event="GoalScored", data={})
|
|
162
|
+
await client._dispatch(msg)
|
|
163
|
+
|
|
164
|
+
assert len(errors) == 1
|
|
165
|
+
assert errors[0][0] is msg
|
|
166
|
+
assert isinstance(errors[0][1], RuntimeError)
|
|
167
|
+
assert errors[0][2] is bad_handler
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_reconnect_delay_zero_raises() -> None:
|
|
171
|
+
with pytest.raises(ValueError, match="reconnect_delay"):
|
|
172
|
+
StatsClient(reconnect_delay=0)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_state_tracker_updates_from_update_state() -> None:
|
|
176
|
+
client = StatsClient(reconnect=False)
|
|
177
|
+
client._state_tracker.update(
|
|
178
|
+
EventMessage(
|
|
179
|
+
event="UpdateState",
|
|
180
|
+
data={
|
|
181
|
+
"MatchGuid": "abc",
|
|
182
|
+
"Game": {
|
|
183
|
+
"TimeSeconds": 120,
|
|
184
|
+
"bOvertime": False,
|
|
185
|
+
"Arena": "Stadium_P",
|
|
186
|
+
"Teams": [
|
|
187
|
+
{"TeamNum": 0, "Score": 2},
|
|
188
|
+
{"TeamNum": 1, "Score": 1},
|
|
189
|
+
],
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
assert client.state.match_guid == "abc"
|
|
196
|
+
assert client.state.blue_score == 2
|
|
197
|
+
assert client.state.orange_score == 1
|
|
198
|
+
assert client.state.time_seconds == 120
|
|
199
|
+
assert client.state.arena == "Stadium_P"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_connection_state_starts_disconnected() -> None:
|
|
203
|
+
client = StatsClient(reconnect=False)
|
|
204
|
+
assert client.connection_state == ConnectionState.DISCONNECTED
|
|
205
|
+
assert not client.is_connected
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_metrics_reset_clears_counters() -> None:
|
|
209
|
+
client = StatsClient(reconnect=False)
|
|
210
|
+
client._metrics.received_events = 10
|
|
211
|
+
client._metrics.handler_errors = 3
|
|
212
|
+
before = client.metrics.started_at
|
|
213
|
+
client.metrics.reset()
|
|
214
|
+
assert client.metrics.received_events == 0
|
|
215
|
+
assert client.metrics.handler_errors == 0
|
|
216
|
+
assert client.metrics.started_at >= before
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def test_metrics_started_at_is_set() -> None:
|
|
220
|
+
client = StatsClient(reconnect=False)
|
|
221
|
+
assert isinstance(client.metrics.started_at, datetime)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@pytest.mark.asyncio
|
|
225
|
+
async def test_wait_for_resolves_on_matching_event() -> None:
|
|
226
|
+
client = StatsClient(reconnect=False)
|
|
227
|
+
msg = EventMessage(event="GoalScored", data={"GoalSpeed": 99.0})
|
|
228
|
+
|
|
229
|
+
async def dispatch_later() -> None:
|
|
230
|
+
await asyncio.sleep(0)
|
|
231
|
+
await client._dispatch(msg)
|
|
232
|
+
|
|
233
|
+
asyncio.create_task(dispatch_later())
|
|
234
|
+
result = await asyncio.wait_for(client.wait_for("GoalScored"), timeout=1.0)
|
|
235
|
+
assert result is msg
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@pytest.mark.asyncio
|
|
239
|
+
async def test_wait_for_raises_timeout_when_event_never_arrives() -> None:
|
|
240
|
+
client = StatsClient(reconnect=False)
|
|
241
|
+
with pytest.raises(asyncio.TimeoutError):
|
|
242
|
+
await client.wait_for("GoalScored", timeout=0.05)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@pytest.mark.asyncio
|
|
246
|
+
async def test_wait_for_cleans_up_handler_on_timeout() -> None:
|
|
247
|
+
client = StatsClient(reconnect=False)
|
|
248
|
+
with pytest.raises(asyncio.TimeoutError):
|
|
249
|
+
await client.wait_for("GoalScored", timeout=0.05)
|
|
250
|
+
assert len(client._handlers_by_event["GoalScored"]) == 0
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@pytest.mark.asyncio
|
|
254
|
+
async def test_handler_timeout_cancels_slow_async_handler() -> None:
|
|
255
|
+
client = StatsClient(reconnect=False, handler_timeout=0.05)
|
|
256
|
+
finished = False
|
|
257
|
+
|
|
258
|
+
async def slow_handler(_: EventMessage) -> None:
|
|
259
|
+
nonlocal finished
|
|
260
|
+
await asyncio.sleep(10)
|
|
261
|
+
finished = True
|
|
262
|
+
|
|
263
|
+
client.on("GoalScored", slow_handler)
|
|
264
|
+
await client._dispatch(EventMessage(event="GoalScored", data={}))
|
|
265
|
+
assert not finished
|
|
266
|
+
assert client.metrics.handler_errors == 1
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def test_state_tracker_populates_players() -> None:
|
|
270
|
+
client = StatsClient(reconnect=False)
|
|
271
|
+
client._state_tracker.update(
|
|
272
|
+
EventMessage(
|
|
273
|
+
event="UpdateState",
|
|
274
|
+
data={
|
|
275
|
+
"Players": [
|
|
276
|
+
{
|
|
277
|
+
"Name": "Alice",
|
|
278
|
+
"Shortcut": 1,
|
|
279
|
+
"TeamNum": 0,
|
|
280
|
+
"Score": 5,
|
|
281
|
+
"Boost": 80,
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
"Name": "Bob",
|
|
285
|
+
"Shortcut": 2,
|
|
286
|
+
"TeamNum": 1,
|
|
287
|
+
"Score": 3,
|
|
288
|
+
"Boost": 20,
|
|
289
|
+
},
|
|
290
|
+
],
|
|
291
|
+
"Game": {},
|
|
292
|
+
},
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
players = client.state.players
|
|
297
|
+
assert len(players) == 2
|
|
298
|
+
alice = next(p for p in players if p.name == "Alice")
|
|
299
|
+
assert isinstance(alice, PlayerSnapshot)
|
|
300
|
+
assert alice.team_num == 0
|
|
301
|
+
assert alice.score == 5
|
|
302
|
+
assert alice.boost == 80
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def test_state_tracker_resolves_target_player() -> None:
|
|
306
|
+
client = StatsClient(reconnect=False)
|
|
307
|
+
client._state_tracker.update(
|
|
308
|
+
EventMessage(
|
|
309
|
+
event="UpdateState",
|
|
310
|
+
data={
|
|
311
|
+
"Players": [
|
|
312
|
+
{"Name": "Alice", "Shortcut": 1, "TeamNum": 0},
|
|
313
|
+
{"Name": "Bob", "Shortcut": 2, "TeamNum": 1},
|
|
314
|
+
],
|
|
315
|
+
"Game": {
|
|
316
|
+
"bHasTarget": True,
|
|
317
|
+
"Target": {"Name": "Bob", "Shortcut": 2, "TeamNum": 1},
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
)
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
assert client.state.target_player is not None
|
|
324
|
+
assert client.state.target_player.name == "Bob"
|
|
325
|
+
assert client.state.target_player.shortcut == 2
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def test_state_tracker_no_target_when_flag_false() -> None:
|
|
329
|
+
client = StatsClient(reconnect=False)
|
|
330
|
+
client._state_tracker.update(
|
|
331
|
+
EventMessage(
|
|
332
|
+
event="UpdateState",
|
|
333
|
+
data={
|
|
334
|
+
"Players": [{"Name": "Alice", "Shortcut": 1, "TeamNum": 0}],
|
|
335
|
+
"Game": {"bHasTarget": False},
|
|
336
|
+
},
|
|
337
|
+
)
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
assert client.state.target_player is None
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Tests for
|
|
1
|
+
"""Tests for TAStatsAPI.ini helpers."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -21,13 +21,13 @@ def _write_config(path: Path, text: str) -> None:
|
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
def test_find_stats_api_config_explicit_path(tmp_path: Path) -> None:
|
|
24
|
-
config_path = tmp_path / "
|
|
24
|
+
config_path = tmp_path / "TAStatsAPI.ini"
|
|
25
25
|
_write_config(config_path, "PacketSendRate=30\nPort=49123\n")
|
|
26
26
|
assert find_stats_api_config(config_path) == config_path.resolve()
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
def test_get_stats_api_status_reads_values(tmp_path: Path) -> None:
|
|
30
|
-
config_path = tmp_path / "
|
|
30
|
+
config_path = tmp_path / "TAStatsAPI.ini"
|
|
31
31
|
_write_config(config_path, "PacketSendRate=30\nPort=5000\n")
|
|
32
32
|
|
|
33
33
|
status = get_stats_api_status(config_path)
|
|
@@ -39,7 +39,7 @@ def test_get_stats_api_status_reads_values(tmp_path: Path) -> None:
|
|
|
39
39
|
|
|
40
40
|
|
|
41
41
|
def test_get_stats_api_status_missing_keys(tmp_path: Path) -> None:
|
|
42
|
-
config_path = tmp_path / "
|
|
42
|
+
config_path = tmp_path / "TAStatsAPI.ini"
|
|
43
43
|
_write_config(config_path, "[TAGame.MatchStatsExporter_TA]\n")
|
|
44
44
|
|
|
45
45
|
status = get_stats_api_status(config_path)
|
|
@@ -51,7 +51,7 @@ def test_get_stats_api_status_missing_keys(tmp_path: Path) -> None:
|
|
|
51
51
|
|
|
52
52
|
|
|
53
53
|
def test_set_stats_api_enabled_true_writes_positive_rate(tmp_path: Path) -> None:
|
|
54
|
-
config_path = tmp_path / "
|
|
54
|
+
config_path = tmp_path / "TAStatsAPI.ini"
|
|
55
55
|
_write_config(config_path, "Port=49123\nPacketSendRate=0\n")
|
|
56
56
|
|
|
57
57
|
status = set_stats_api_enabled(True, packet_send_rate=45, path=config_path)
|
|
@@ -62,7 +62,7 @@ def test_set_stats_api_enabled_true_writes_positive_rate(tmp_path: Path) -> None
|
|
|
62
62
|
|
|
63
63
|
|
|
64
64
|
def test_set_stats_api_enabled_false_writes_zero(tmp_path: Path) -> None:
|
|
65
|
-
config_path = tmp_path / "
|
|
65
|
+
config_path = tmp_path / "TAStatsAPI.ini"
|
|
66
66
|
_write_config(config_path, "PacketSendRate=30\nPort=49123\n")
|
|
67
67
|
|
|
68
68
|
status = set_stats_api_enabled(False, path=config_path)
|
|
@@ -73,7 +73,7 @@ def test_set_stats_api_enabled_false_writes_zero(tmp_path: Path) -> None:
|
|
|
73
73
|
|
|
74
74
|
|
|
75
75
|
def test_set_stats_api_port_preserves_other_content(tmp_path: Path) -> None:
|
|
76
|
-
config_path = tmp_path / "
|
|
76
|
+
config_path = tmp_path / "TAStatsAPI.ini"
|
|
77
77
|
original = (
|
|
78
78
|
"; comment\n"
|
|
79
79
|
"[TAGame.MatchStatsExporter_TA]\n"
|
|
@@ -92,7 +92,7 @@ def test_set_stats_api_port_preserves_other_content(tmp_path: Path) -> None:
|
|
|
92
92
|
|
|
93
93
|
|
|
94
94
|
def test_configure_stats_api_writes_both_keys(tmp_path: Path) -> None:
|
|
95
|
-
config_path = tmp_path / "
|
|
95
|
+
config_path = tmp_path / "TAStatsAPI.ini"
|
|
96
96
|
_write_config(config_path, "PacketSendRate=0\n")
|
|
97
97
|
|
|
98
98
|
status = configure_stats_api(
|
|
@@ -108,7 +108,7 @@ def test_configure_stats_api_writes_both_keys(tmp_path: Path) -> None:
|
|
|
108
108
|
|
|
109
109
|
|
|
110
110
|
def test_invalid_packet_send_rate_raises(tmp_path: Path) -> None:
|
|
111
|
-
config_path = tmp_path / "
|
|
111
|
+
config_path = tmp_path / "TAStatsAPI.ini"
|
|
112
112
|
_write_config(config_path, "PacketSendRate=0\nPort=49123\n")
|
|
113
113
|
|
|
114
114
|
with pytest.raises(ValueError, match="packet_send_rate"):
|
|
@@ -116,7 +116,7 @@ def test_invalid_packet_send_rate_raises(tmp_path: Path) -> None:
|
|
|
116
116
|
|
|
117
117
|
|
|
118
118
|
def test_invalid_port_raises(tmp_path: Path) -> None:
|
|
119
|
-
config_path = tmp_path / "
|
|
119
|
+
config_path = tmp_path / "TAStatsAPI.ini"
|
|
120
120
|
_write_config(config_path, "PacketSendRate=30\nPort=49123\n")
|
|
121
121
|
|
|
122
122
|
with pytest.raises(ValueError, match="port"):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|